feat(mera/лендинг): витрина показывает полосу расхождения −5…+20 %, плитку уверенности сменил замер 12.09
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m13s
CI Trade-In / backend-tests (pull_request) Successful in 5m29s

Владелец просит на витрине только сделки, где прогноз разошёлся с ценой ДКП
в пределах от −5 % до +20 %. Фильтр живёт в продюсере (`select_rows`), поэтому
таблица сверок и бегущая строка берут ОДИН набор, а не два.

Чтобы страница от этого не начала врать:

* `REJECTION_RULE` переписан. Прежняя формулировка («величина отклонения на
  отбор и отбраковку не влияет — иначе витрина показывала бы лучший хвост»)
  после фильтра стала ложью ровно про то, чего опасалась, поэтому снята, а не
  смягчена. Новая называет полосу и говорит, что это отбор показательных
  строк, а не вся сверка. Границы в текст ПОДСТАВЛЯЮТСЯ из констант
  `BAND_MIN_ERR_PCT`/`BAND_MAX_ERR_PCT` — подпись не может разъехаться с
  фильтром, и это проверяется тестом.
* Фильтр стоит в `select_rows`, а не в `build_row`: строка вне полосы остаётся
  кандидатом и попадает в `eligible`. Отбраковав её раньше, мы получили бы
  «показано 20 из 20 годных» — счётчик, из которого отбор не виден вообще.
* Счётчики разъехались с подписью, и подпись поправлена: `eligible − written`
  больше не значит «столько не поместилось», в разницу входят отсеянные
  полосой. Под таблицей теперь «показано N строк из M собранных прогоном».
* «В пределах 20 % — N из N» из подписи снято: при потолке полосы +20 счёт
  всегда выходил бы N из N и читался бы как замер попадания. Неработающая
  проверка читается как работающая.
* Медиана по ВСЕЙ сверке (15,3 %, 325 сделок) в подписи осталась и теперь
  сторожится тестом: без неё разброс отобранной двадцатки читается как
  точность расчёта.
* Полоса названа и в подписи ленты — она висит над первым экраном, её числа
  читают раньше любых оговорок блока «Точность».
* Меньше лимита в полосе — показываем сколько есть, добора нет.

Плитка «400 из 400 расчётов с пометкой „уверенность низкая“» заменена на
свежий замер 12.09.2026 (engine=full, 290 сделок, медиана трёх пересборок с
солями 11/22/33): «52,7 % сделок — расхождение в пределах ±20 %». Запись
`confidenceLow` не удалена, а помечена снятой (прогон 29.08 на
кластеризованной выборке) — до решения владельца.

Оговорки новой величины называют три вещи, без которых она льстит: замер не
point-in-time, разброс пересборок 46,2–56,6 %, и что медианное расхождение
того же прогона (19,1 %) ВЫШЕ прежних 15,3 % от 31.08 — на странице два числа
разных дат, и молчать о том, что свежий прогон вышел хуже, нельзя.

`priceError` и `coverage` не тронуты. Сторож свежести теперь следит за ОБЕИМИ
датами замеров, а не только за 31.08.

Проверено: на проде из 20 сегодняшних строк витрины в полосу попадают 8
(40 %), что сходится с 35,5 % «доли в полосе» из бэктеста 12.09.
Фальсификация: снятие фильтра руками красит 3 теста, ключевой — по значению
([44, 43, 41] вместо [44] на реальных строках прода +75,7 / −27,9 / +9,9 %).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
bot-backend 2026-09-12 15:29:10 +05:00
parent 5f2810b8c6
commit 2467943200
10 changed files with 596 additions and 137 deletions

View file

@ -479,9 +479,15 @@ class ShowcaseStats(BaseModel):
Без этих чисел «20 отличных строк» неотличимо от «столько и было»:
посетитель не может отличить выборку из работы оценщика от её лучшего
хвоста. `eligible` минус `written` сколько годных строк не поместилось
в витрину; `rejection_rule` по какому правилу отсеяно остальное,
записанное ТЕМ прогоном, который эти строки посчитал.
хвоста. `eligible` сколько строк прогон СОБРАЛ (данных хватило),
`written` сколько из них показано; `rejection_rule` по какому правилу
отобраны показанные, записанное ТЕМ прогоном, который их посчитал.
`eligible` минус `written` НЕ «столько не поместилось»: с 2026-09-12
витрина показывает полосу расхождения 5 %..+20 %, и в разницу входят
строки, отсеянные полосой. Что это именно отбор, а не вся сверка, говорит
`rejection_rule` поэтому счётчики и правило показываются вместе, одной
подписью, а не порознь.
"""
considered: int
@ -550,7 +556,7 @@ def public_showcase(
нечего, и это ровно то, что фронт должен увидеть вместо выдуманных строк.
Вместе со строками едет `stats` сколько сделок рассмотрено, сколько
годных строк не поместилось и по какому правилу отсеяно остальное. Числа
строк прогон собрал и по какому правилу из них отобраны показанные. Числа
считает пересчёт; без них витрина не имеет права подписаться честно.
"""
run = db.execute(_SHOWCASE_RUN_SQL).mappings().first()

View file

@ -9,26 +9,36 @@
ПРАВИЛО ОТБОРА ЯВНО И БЕЗ ПОДГОНКИ
------------------------------------
Отбираем N строк ключом::
Витрина показывает ПОЛОСУ РАСХОЖДЕНИЯ, а не всю сверку. С 2026-09-12 решением
владельца продукта на витрину попадают только сделки, у которых расхождение
прогноза с ценой ДКП лежит в пределах `BAND_MIN_ERR_PCT`..`BAND_MAX_ERR_PCT`
(5 %..+20 % включительно). Оставшиеся `limit` строк ранжируются ключом::
(полнота данных , свежесть квартала , id сделки )
Величина ошибки в ключе НЕ УЧАСТВУЕТ и участвовать не должна. Отбор по малой
ошибке превращает витрину в рекламу: показанные 20 строк перестают быть
выборкой из работы оценщика и становятся её лучшим хвостом, а посетитель
читает их как «вот так МЕРА обычно и попадает». Это тот самый случай, когда
код формально работает, а продукт врёт. Проверяется тестом
`test_landing_showcase_deals.py::test_selection_ignores_error_magnitude`.
ЭТО ОТБОР ПОКАЗАТЕЛЬНЫХ СТРОК, И НАЗЫВАТЬ ЕГО НАДО ТАК. До 2026-09-12 здесь
не было ни фильтра, ни слагаемого ошибки в ключе, и подпись витрины это прямо
утверждала. Теперь утверждать это нельзя: строки с промахом крупнее полосы в
данных есть (на проде 30.08.2026 из показанных двадцати вне полосы было
двенадцать от 27,9 % до +75,7 %), и они не показываются. Поэтому полоса
названа в `REJECTION_RULE`, которое едет на фронт вместе со счётчиками
прогона, и в подписи под таблицей рядом с медианой расхождения ПО ВСЕЙ
СВЕРКЕ: два числа рядом не дают прочитать двадцать отобранных строк как
«вот так МЕРА обычно и попадает».
ФИЛЬТРА ПО ОШИБКЕ ТОЖЕ НЕТ И ЭТО ТО ЖЕ САМОЕ ПРАВИЛО. До 2026-08-29 здесь
жил порог `MAX_ABS_ERR_PCT = 40`, выбрасывавший кандидата ПО ВЕЛИЧИНЕ ОШИБКИ
до ранжирования. Запрет выше он обходил ступенькой раньше: отбор по ошибке в
ключе и отбор по ошибке в фильтре одно и то же действие, и второе даже
злее, потому что не оставляет строку в кандидатах. Обоснование «отклонение
больше 40% это почти всегда занижение ДКП ради налога» не держится: см.
следующий раздел, грубые занижения вырезаны выше по потоку и по свойству
самой сделки. Отбраковываем только то, чего в данных НЕТ (нет прогноза, нет
квартала, нет площади) «число некрасивое» причиной не является.
ЧТО ЭТО НЕ ОТМЕНЯЕТ. Внутри полосы отбор по величине ошибки по-прежнему
запрещён иначе витрина показывала бы лучший хвост уже самой полосы
(`test_selection_ignores_error_magnitude`). Счётчики прогона считаются ДО
полосы: `eligible` сколько строк прогон вообще собрал, `written` сколько
из них прошло полосу и поместилось в `limit`. Разница между ними видна
посетителю, и она честная ровно потому, что рядом сказано, чем именно
отобраны показанные. Если в полосу попало меньше `limit` строк показываем
сколько есть; добирать соседями по ошибке нельзя, это вернуло бы отбор по
величине ошибки в обход полосы.
Отбраковка по «данных нет» (нет прогноза, нет квартала, нет площади) осталась
прежней и живёт в `build_row`: строка вне полосы ОСТАЁТСЯ кандидатом и
попадает в счётчик `eligible`, её снимает отбор, а не отбраковка.
Полнота сколько из полей, которые видит посетитель (район, этаж, этажность,
схема улицы), у строки заполнено. Свежесть порядок квартала сделки.
@ -57,8 +67,10 @@
`PPM2_MIN = 30 000` / `PPM2_MAX = 600 000` (город намеренно не заведён в
`deal_city_price_bands`, там же и комментарий об этом). Значит грубые
занижения из выборки уже вырезаны ДО того, как сюда приходит кандидат, а
всё, что после этого дало большую ошибку, работа оценщика, и витрина
обязана её показать. Своей копии диапазона здесь нет намеренно: прежние
всё, что после этого дало большую ошибку, работа оценщика. С 2026-09-12
такая строка на витрину не выходит (полоса), но остаётся в `eligible` и
в подписи названа отобранной, а не несуществующей. Своей копии диапазона
здесь нет намеренно: прежние
`MIN_FACT_PPM2 = 30k` дублировал уже применённый фильтр, а
`MAX_FACT_PPM2 = 1.2M` был недостижим при потолке выборки 600k из трёх
отбраковок в проде срабатывала РОВНО ОДНА, та самая, что льстила витрине.
@ -75,7 +87,10 @@
ошибки, правило отбора выше не нарушено.
* СЧЁТЧИКИ ЕДУТ НА ФРОНТ, А НЕ ТОЛЬКО В ЛОГ. «Мы показываем 20 отличных
строк» неотличимо от «столько и было», пока рядом не написано, сколько
сделок рассмотрено и сколько годных строк не поместилось. Поэтому итог
сделок рассмотрено, сколько строк прогон собрал и по какому правилу из
них отобраны показанные. С появлением полосы это перестало быть
страховкой и стало обязательным: без счётчиков и правила отобранная
двадцатка читается как вся сверка. Поэтому итог
прогона пишется в `landing_showcase_runs` (миграция 277) и отдаётся
ручкой `/api/public/mera/showcase` вместе со строками.
@ -104,18 +119,47 @@ from app.services.street_scheme import StreetIndex, build_street_scheme, load_st
logger = logging.getLogger(__name__)
# ── Правило отбраковки: одна формулировка, она же едет на фронт ──────────────
# ── Полоса расхождения: что показываем и что об этом сказано ─────────────────
#
# Порогов на величину ошибки здесь НЕТ (разбор — в докстринге модуля). Санитарный
# диапазон ₽/м² применён выше по потоку, в `_load_sample`; дублировать его тут
# значило бы завести проверку, которая в проде не срабатывает никогда.
# Границы ВКЛЮЧИТЕЛЬНЫЕ. Полоса несимметрична намеренно: решение владельца от
# 2026-09-12 — показывать сделки, где МЕРА не занизила больше чем на 5 % и не
# завысила больше чем на 20 %.
BAND_MIN_ERR_PCT = -5.0
BAND_MAX_ERR_PCT = 20.0
# Подпись полосы ВЫВОДИТСЯ из границ, а не вписывается рядом: «5 %…+20 %» в
# тексте и `>= -5.0` в коде — две независимые величины, и разъедутся они
# ровно тогда, когда порог однажды подвинут.
BAND_LABEL = f"от {BAND_MIN_ERR_PCT:+.0f} % до {BAND_MAX_ERR_PCT:+.0f} % включительно"
def in_band(err_pct: float) -> bool:
"""Попадает ли расхождение в показываемую полосу (границы включительно)."""
return BAND_MIN_ERR_PCT <= err_pct <= BAND_MAX_ERR_PCT
# ── Правило отбора и отбраковки: одна формулировка, она же едет на фронт ──────
#
# Санитарный диапазон ₽/м² применён выше по потоку, в `_load_sample`;
# дублировать его тут значило бы завести проверку, которая в проде не
# срабатывает никогда.
#
# ТЕКСТ ОБЯЗАН НАЗЫВАТЬ ПОЛОСУ. Пока фильтра не было, здесь стояло «величина
# отклонения на отбор и отбраковку не влияет — иначе витрина показывала бы
# лучший хвост, а не работу расчёта». С фильтром эта фраза стала ложью ровно
# про то, чего опасалась, поэтому она снята, а не смягчена.
REJECTION_RULE = (
"Строка не попадает на витрину, только если данных нет: расчёт МЕРЫ не дал "
"ожидаемой цены продажи (мало аналогов), неизвестен квартал сделки или "
"площадь. Величина отклонения на отбор и отбраковку не влияет — иначе "
"витрина показывала бы лучший хвост, а не работу расчёта. Санитарный "
"диапазон цены сделки (30 000600 000 ₽/м² для Екатеринбурга) применён "
"к выборке до расчёта, по цене самой сделки."
f"На витрине — ОТОБРАННАЯ полоса расхождения, а не вся сверка: показаны "
f"только сделки, у которых расхождение прогноза с ценой ДКП лежит {BAND_LABEL}. "
"Промахи крупнее полосы в данных есть, и здесь их не видно — судить по этим "
"строкам о точности расчёта нельзя, для этого есть медиана расхождения по "
"всей сверке. Внутри полосы порядок задают полнота данных и свежесть "
"квартала: величина отклонения на него не влияет, лучший хвост самой полосы "
"витрина тоже не показывает. Кроме полосы строку снимает только отсутствие "
"данных: расчёт МЕРЫ не дал ожидаемой цены продажи (мало аналогов), "
"неизвестен квартал сделки или площадь. Санитарный диапазон цены сделки "
"(30 000600 000 ₽/м² для Екатеринбурга) применён к выборке до расчёта, по "
"цене самой сделки."
)
NOTE = (
@ -184,7 +228,7 @@ def completeness(row: ShowcaseRow) -> int:
def _sort_key(row: ShowcaseRow) -> tuple[int, date, int]:
"""Ключ отбора. Ошибки здесь нет — см. «ПРАВИЛО ОТБОРА» в докстринге модуля."""
"""Ключ ранжирования. Ошибки здесь нет — см. «ПРАВИЛО ОТБОРА» в докстринге."""
return (
-completeness(row),
-(row.deal_date or date.min).toordinal(),
@ -193,8 +237,25 @@ def _sort_key(row: ShowcaseRow) -> tuple[int, date, int]:
def select_rows(rows: list[ShowcaseRow], limit: int) -> list[ShowcaseRow]:
"""Отобрать `limit` строк по полноте и свежести (НЕ по величине ошибки)."""
return sorted(rows, key=_sort_key)[:limit]
"""Строки полосы 5 %..+20 %, до `limit` штук, по полноте и свежести.
ДВА ДЕЙСТВИЯ, И ОНИ РАЗНЫЕ. Сначала ФИЛЬТР по величине расхождения
(`in_band`) это и есть «витрина показывает отобранную полосу, а не всю
сверку», названное так же в `REJECTION_RULE` и в подписи под таблицей.
Потом РАНЖИРОВАНИЕ уцелевших по полноте данных и свежести квартала
внутри полосы величина ошибки на порядок не влияет, иначе показывался бы
лучший хвост уже самой полосы.
Фильтр стоит ЗДЕСЬ, а не в `build_row`, намеренно: строка вне полосы
обязана остаться кандидатом и попасть в счётчик `eligible`. Отбраковав её
раньше, мы получили бы «показано 20 из 20 годных» счётчик, из которого
отбор не виден вообще.
В полосе меньше `limit` строк возвращаем сколько есть. Добирать
ближайшими по ошибке нельзя: это тот же отбор по величине ошибки, просто
с другой стороны.
"""
return sorted((r for r in rows if in_band(r.err_pct)), key=_sort_key)[:limit]
def build_row(
@ -217,8 +278,11 @@ def build_row(
Причины отказа ИСЧЕРПЫВАЮЩИЕ и все «данных нет»: спайн не дал ожидаемой
цены продажи; квартал сделки неизвестен; нет площади или цены сделки
(делить не на что). Величина отклонения причиной НЕ является ни при каких
значениях см. «ФИЛЬТРА ПО ОШИБКЕ ТОЖЕ НЕТ» в докстринге модуля.
(делить не на что). Величина отклонения причиной отказа НЕ является ни при
каких значениях: строка с любым промахом становится кандидатом и попадает
в счётчик `eligible`. Полоса, по которой из кандидатов отбираются
показанные, применяется позже и в другом месте `select_rows`; здесь её
нет намеренно, иначе отбор перестал бы быть виден в счётчиках.
ФАКТ ЭТО `deals.price_rub`, ЦЕНА ИЗ ДОГОВОРА, А НЕ ПРОИЗВЕДЕНИЕ. Колонка на
витрине называется «Цена ДКП», и подпись обязана называть ту величину, которая
@ -384,9 +448,14 @@ def refresh_landing_showcase_deals(
priced из них оценщик дал ожидаемую цену продажи
no_prediction не дал (мало аналогов / спайн упал)
incomplete цена есть, но нет квартала/площади строку не собрать
eligible годных строк ВСЕГО (никакого отсева по ошибке нет)
written из них показано (обрезано по `limit`)
eligible строк СОБРАНО всего, ДО полосы (данных хватило)
written из них показано: прошли полосу и поместились в `limit`
with_district у скольких показанных удалось определить район
`eligible` минус `written` это НЕ «столько не поместилось»: с 2026-09-12
в разницу входят и строки вне полосы 5 %..+20 %. Поэтому подпись под
таблицей называет `eligible` собранными строками, а чем отобраны
показанные говорит `REJECTION_RULE`, который едет тем же ответом.
"""
# Импорт внутри функции: `scripts.backtest_estimator` тянет оценщик со всеми
# его зависимостями, а web-процессу это на импорте приложения не нужно.
@ -444,6 +513,16 @@ def refresh_landing_showcase_deals(
candidates.append(row)
chosen = select_rows(candidates, limit)
# Сколько собранных строк вообще попало в полосу — в лог, а не в счётчики:
# колонки под него в `landing_showcase_runs` нет, а без него по `written`
# не отличить «полоса оставила мало» от «упёрлись в limit».
logger.info(
"в полосе %s: %d из %d собранных, показано %d",
BAND_LABEL,
sum(1 for r in candidates if in_band(r.err_pct)),
len(candidates),
len(chosen),
)
schemes = _schemes_for(db, street_index, chosen, {d.id: d.address for d in deals})
db.execute(_DELETE_SQL)
@ -488,7 +567,7 @@ def refresh_landing_showcase_deals(
logger.info(
"витрина обновлена: рассмотрено=%d оценено=%d без_прогноза=%d неполных=%d "
"годных=%d записано=%d с_районом=%d",
"собрано=%d записано=%d с_районом=%d",
counters["considered"],
counters["priced"],
counters["no_prediction"],

View file

@ -1,11 +1,21 @@
"""Витрина лэндинга на реальных сделках — отбор и отбраковка (миграция 276).
Главное, что здесь защищается, НЕ формат строки, а свойство отбора: витрина
показывает выборку из работы оценщика, а не её лучший хвост. Отбор по малой
ошибке дал бы формально работающий код и врущий продукт, и заметить это на
глаз в проде нельзя числа будут красивые. Поэтому проверка двусторонняя:
самая точная строка, у которой не хватает данных, обязана проиграть менее
точной, но полной.
Здесь защищаются ДВА разных свойства, и путать их нельзя.
1. ПОЛОСА. С 2026-09-12 витрина показывает только расхождения 5 %..+20 %
включительно решение владельца продукта. Это отбор показательных строк,
и проверяется он ПО ЗНАЧЕНИЮ, на реальных строках прода: +75,7 % и 27,9 %
на витрину не попадают, +9,9 % попадает.
2. ВНУТРИ ПОЛОСЫ отбора по величине ошибки по-прежнему нет. Иначе витрина
показывала бы лучший хвост уже самой полосы, а числа при этом остались бы
красивыми на глаз в проде такое не ловится. Поэтому проверка
двусторонняя: самая точная строка, у которой не хватает данных, обязана
проиграть менее точной, но полной.
Отбраковка («данных нет») третье свойство, и она живёт в `build_row`: строка
вне полосы остаётся кандидатом и попадает в счётчик `eligible`, её снимает
отбор, а не отбраковка. Ровно поэтому подпись под витриной может честно
сказать, сколько строк прогон собрал.
"""
from __future__ import annotations
@ -16,6 +26,9 @@ from datetime import date
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.tasks.landing_showcase_deals import (
BAND_MAX_ERR_PCT,
BAND_MIN_ERR_PCT,
REJECTION_RULE,
ShowcaseRow,
build_row,
quarter_label,
@ -50,26 +63,102 @@ def _row(
)
# ── Полоса 5 %..+20 %: проверка ПО ЗНАЧЕНИЮ, на реальных строках прода ───────
def test_band_drops_rows_outside_it_and_keeps_rows_inside() -> None:
"""Три строки, которые сегодня лежат на витрине прода (прогон 30.08.2026).
id 41 расхождение +75,71 %, id 43 27,87 %, id 44 +9,85 %. Первые две
на витрину попадать больше не должны, третья должна.
Ломать так: снять фильтр в `select_rows` (вернуть
`sorted(rows, key=_sort_key)`) тест покраснеет ПО ЗНАЧЕНИЮ, показав
[44, 41, 43] вместо [44], то есть ровно те два промаха, которых владелец
на витрине видеть не хочет.
"""
far_over = _row(41, err_pct=75.71)
far_under = _row(43, err_pct=-27.87)
inside = _row(44, err_pct=9.85)
chosen = select_rows([far_over, far_under, inside], limit=20)
assert [r.deal_id for r in chosen] == [44], (
"на витрину прошла строка вне полосы 5 %..+20 %: подпись обещает "
"полосу, а показывает не её"
)
def test_band_edges_are_inclusive_and_near_misses_are_not() -> None:
"""Границы полосы включительные, а на волос за ними — уже нет.
Проверяется ПО ЗНАЧЕНИЮ у самой границы: `<` вместо `<=` в `in_band`
выбросит ровно строки 1 и 2 и покраснит тест.
"""
rows = [
_row(1, err_pct=BAND_MIN_ERR_PCT),
_row(2, err_pct=BAND_MAX_ERR_PCT),
_row(3, err_pct=BAND_MIN_ERR_PCT - 0.01),
_row(4, err_pct=BAND_MAX_ERR_PCT + 0.01),
]
assert sorted(r.deal_id for r in select_rows(rows, limit=20)) == [1, 2]
def test_short_band_shows_what_there_is_and_does_not_top_up() -> None:
"""В полосу попало меньше лимита — показываем сколько есть.
Добор ближайшими по ошибке был бы тем же отбором по величине ошибки, просто
с другой стороны. Ломать так: добавить в `select_rows` «добить до limit
остальными» тест покраснеет тремя строками вместо одной.
"""
rows = [_row(1, err_pct=3.0), _row(2, err_pct=44.0), _row(3, err_pct=-60.0)]
assert [r.deal_id for r in select_rows(rows, limit=20)] == [1]
def test_rejection_rule_names_the_band_that_is_actually_applied() -> None:
"""Подпись витрины называет ТУ полосу, которую применяет фильтр.
Текст едет на фронт и там читается как обещание. Вписанный руками «5 %» в
тексте и `>= -5.0` в коде две независимые величины; здесь проверяется,
что в тексте стоят именно границы фильтра.
Ломать так: подвинуть `BAND_MAX_ERR_PCT` на 30, не трогая текст, тест
покраснеет на «+30 %», которого в подписи нет.
"""
assert f"{BAND_MIN_ERR_PCT:+.0f} %" in REJECTION_RULE
assert f"{BAND_MAX_ERR_PCT:+.0f} %" in REJECTION_RULE
assert "не вся сверка" in REJECTION_RULE, "подпись не говорит, что это отбор"
# Снятая формулировка не должна вернуться: с фильтром она ложь.
assert "на отбор и отбраковку не влияет" not in REJECTION_RULE
# ── Внутри полосы: порядок задают полнота и свежесть, не ошибка ───────────────
def test_selection_ignores_error_magnitude() -> None:
"""Точнейшая строка с дырами в данных НЕ должна оказаться впереди полной.
Обе строки ВНУТРИ полосы проверяется именно ранжирование, а не фильтр:
иначе тест зеленел бы по той же причине, по которой краснеет соседний.
Ломать так: добавить в `_sort_key` слагаемое `abs(row.err_pct)` тест
покраснеет с id 1 на первом месте вместо id 2.
"""
almost_perfect_but_thin = _row(1, district=None, floor=None, err_pct=0.1)
complete_but_worse = _row(2, err_pct=27.0)
complete_but_worse = _row(2, err_pct=19.0)
chosen = select_rows([almost_perfect_but_thin, complete_but_worse], limit=1)
assert [r.deal_id for r in chosen] == [2], (
"отбор поехал за величиной ошибки — витрина перестала быть выборкой "
"и стала рекламой лучшего хвоста"
"отбор поехал за величиной ошибки — витрина показывает лучший хвост уже самой полосы"
)
def test_selection_prefers_fresher_quarter_at_equal_completeness() -> None:
older = _row(1, deal_date=date(2025, 1, 1), err_pct=1.0)
fresher = _row(2, deal_date=date(2026, 4, 1), err_pct=35.0)
fresher = _row(2, deal_date=date(2026, 4, 1), err_pct=18.0)
assert [r.deal_id for r in select_rows([older, fresher], limit=1)] == [2]
@ -86,9 +175,10 @@ def test_selection_prefers_row_with_street_scheme() -> None:
`completeness` тест покраснеет ПО ЗНАЧЕНИЮ, порядком [9, 8].
Вторая сторона проверки строка без улицы ОСТАЁТСЯ в витрине: она не
первая, но и не выброшена. Прятать промахи по-прежнему нельзя.
первая, но и не выброшена. Отсутствие поля не причина отбраковки, и
полоса тут ни при чём: обе строки внутри неё.
"""
no_street = _row(9, has_street=False, err_pct=75.7)
no_street = _row(9, has_street=False, err_pct=19.0)
with_street = _row(8, err_pct=3.0)
chosen = select_rows([no_street, with_street], limit=2)
@ -157,11 +247,12 @@ def test_fact_is_the_contract_price_not_the_reconstruction() -> None:
def test_no_error_magnitude_is_ever_rejected() -> None:
"""Промах оценщика ЛЮБОГО размера остаётся на витрине.
"""Промах ЛЮБОГО размера остаётся кандидатом и попадает в счётчик.
Это второй половина запрета «не отбирать по ошибке»: фильтр по величине
ошибки тот же отбор, просто ступенькой раньше, и он тем злее, что не
оставляет строку даже в кандидатах.
Отбраковка и отбор разные шаги, и величина ошибки причиной ОТБРАКОВКИ не
является: вне полосы строка не показывается, но входит в `eligible`, и
подпись «показано N из M собранных» остаётся правдой. Отбракуй её здесь
и отбор перестал бы быть виден в счётчиках вообще.
Ломать так: вернуть в `build_row` любой порог вида
`if abs(err_pct) > X: return None` тест покраснеет на первом же
@ -171,20 +262,20 @@ def test_no_error_magnitude_is_ever_rejected() -> None:
for err_pct in (-95.0, -60.0, -41.0, -5.0, 0.0, 5.0, 41.0, 150.0, 900.0):
row = _build(predicted_rub=fact_rub * (1 + err_pct / 100))
assert row is not None, (
f"строка с отклонением {err_pct:+.0f}% выброшена: витрина снова "
"показывает лучший хвост, а не работу оценщика"
f"строка с отклонением {err_pct:+.0f}% выброшена из кандидатов: "
"счётчик собранных строк перестал считать работу оценщика"
)
assert row.err_pct == round(err_pct, 2)
def test_underdeclared_dkp_is_shown_not_hidden() -> None:
"""Занижение ДКП ради налога выглядит как промах — и всё равно показывается.
def test_underdeclared_dkp_is_counted_not_dropped() -> None:
"""Занижение ДКП ради налога выглядит как промах — и остаётся кандидатом.
Прятать такие строки нельзя: «отклонение больше 40% это почти всегда
дефект ДКП» было догадкой, а санитарный диапазон /м² уже применён к
выборке выше по потоку (`_load_sample`, для ЕКБ 30k..600k). Всё, что
прошло его и дало большую ошибку, работа оценщика. Честность за счёт
строки в `note`, а не за счёт отсева.
Отбраковывать такие строки нельзя: «отклонение больше 40% это почти
всегда дефект ДКП» было догадкой, а санитарный диапазон /м² уже применён
к выборке выше по потоку (`_load_sample`, для ЕКБ 30k..600k). На витрину
такая строка не выйдет её снимет полоса, но в `eligible` она войдёт,
и счётчик под таблицей останется честным.
"""
# Факт 2 000 000 ₽ против прогноза 5 000 000 — отклонение +150%.
row = _build(fact_rub=2_000_000.0)
@ -247,8 +338,8 @@ def test_row_without_coords_stays_on_showcase() -> None:
"""Нет точки — строка всё равно на витрине, с lat=lon=None.
Выбрасывать сделку из-за отсутствия координаты отбор по признаку, не
связанному с качеством оценки: та же порча витрины, что и отбор по
величине ошибки, просто по другому полю. Карта переживёт строку без точки.
связанному с качеством оценки, и в отличие от полосы он нигде не назван:
посетитель бы о нём не узнал. Карта переживёт строку без точки.
Ломать так: добавить в `build_row` `if lat is None or lon is None: return
None` тест покраснеет на None вместо строки.

View file

@ -26,6 +26,7 @@ import { describe, expect, it } from "vitest";
import {
BACKTEST,
BACKTEST_BAND_MEASURED_ON,
BACKTEST_MAX_AGE_DAYS,
BACKTEST_MEASURED_LABEL,
BACKTEST_MEASURED_ON,
@ -39,22 +40,34 @@ function ageDays(iso: string, now: number): number {
return Math.floor((now - Date.parse(`${iso}T00:00:00Z`)) / DAY_MS);
}
/**
* ДАТ НА СТРАНИЦЕ ДВЕ, И СТОРОЖИТЬ НАДО ОБЕ.
*
* 12.09.2026 в блок «Точность» приехала величина из другого прогона, со своей
* датой (`within20`). Сторож, знающий только `BACKTEST_MEASURED_ON`, оставил
* бы второе число протухать молча то есть ровно та дыра, ради которой он и
* заводился, просто под новой датой. Список перечисляется явно: он короткий, а
* молчаливый вывод «все даты файла» однажды подхватил бы дату, которая на
* витрину не выходит.
*/
const MEASURED_DATES: readonly { iso: string; what: string }[] = [
{ iso: BACKTEST_MEASURED_ON, what: "расхождение и покрытие (BACKTEST_MEASURED_ON)" },
{ iso: BACKTEST_BAND_MEASURED_ON, what: "доля в пределах ±20 % (BACKTEST_BAND_MEASURED_ON)" },
];
describe("свежесть ручного замера бэктеста", () => {
it("дата замера разбирается и не из будущего — иначе сторож считает возраст мусора", () => {
const age = ageDays(BACKTEST_MEASURED_ON, Date.now());
expect(
Number.isFinite(age),
`BACKTEST_MEASURED_ON=${BACKTEST_MEASURED_ON} — не ISO-дата`,
).toBe(true);
it.each(MEASURED_DATES)("дата замера «$what» разбирается и не из будущего", ({ iso }) => {
const age = ageDays(iso, Date.now());
expect(Number.isFinite(age), `${iso} — не ISO-дата`).toBe(true);
expect(age, "дата замера в будущем").toBeGreaterThanOrEqual(0);
});
it("замеру не больше срока годности", () => {
const age = ageDays(BACKTEST_MEASURED_ON, Date.now());
it.each(MEASURED_DATES)("замеру «$what» не больше срока годности", ({ iso }) => {
const age = ageDays(iso, Date.now());
expect(
age,
[
`замеру ${age} дн. (${BACKTEST_MEASURED_ON}), допустимо ${BACKTEST_MAX_AGE_DAYS}.`,
`замеру ${age} дн. (${iso}), допустимо ${BACKTEST_MAX_AGE_DAYS}.`,
"Числа блока «Точность» посчитаны руками и с тех пор никем не подтверждены.",
"Перегнать: python -m scripts.backtest_estimator --city Екатеринбург",
"— обновить BACKTEST, BACKTEST_PERIOD_LABEL (назвать окно, которое реально",

View file

@ -17,6 +17,8 @@ import { describe, expect, it } from "vitest";
import { AccuracyV3 } from "../_components/v3/AccuracyV3";
import {
BACKTEST,
BACKTEST_BAND_MEASURED_LABEL,
BACKTEST_MEASURED_LABEL,
BACKTEST_PERIOD_LABEL,
BACKTEST_POPULATION,
BACKTEST_SHARE_LABEL,
@ -44,22 +46,60 @@ describe("подписи окна бэктеста", () => {
it("уверенность посчитана на том же прогоне, что и остальные числа", () => {
// «325 из 327» было из прогона 29.08 и под общей подписью «замер 31.08»
// приписывало старому числу новую дату.
// приписывало старому числу новую дату. Величина снята с витрины 12.09
// (её плитку занял `within20`), но осталась в файле до решения владельца —
// и свойство за ней сторожится: вернётся она только исправной.
expect(BACKTEST.confidenceLow.text).not.toMatch(/327/u);
});
/**
* У свежей величины СВОЯ дата, и подпись блока про неё не годится: лид
* называет замер 31.08, а `within20` приехал с прогона 12.09. Число под
* чужой датой ровно тот дефект, который здесь уже чинили для «325 из 327».
*/
it("свежая величина подписана своей датой, а не общей датой блока", () => {
expect(BACKTEST.within20.source).toContain("12.09.2026");
expect(BACKTEST_BAND_MEASURED_LABEL).toContain("12.09.2026");
expect(BACKTEST_BAND_MEASURED_LABEL).not.toEqual(BACKTEST_MEASURED_LABEL);
});
/**
* На странице оказались два числа разных дат, и свежее ХУЖЕ прежнего
* (медианное расхождение 19,1 % против 15,3 %). Молчать об этом нельзя:
* читатель сложит из двух дат улучшение, которого замер не показывал.
*/
it("оговорки свежей величины признают, что новый прогон вышел хуже прежнего", () => {
const text = BACKTEST.within20.caveats.join(" ");
expect(text, "не сказано, какое расхождение дал тот же свежий прогон").toContain("19,1 %");
expect(text, "не сказано, с каким прежним числом оно расходится").toContain(
BACKTEST.priceError.text,
);
expect(text, "разброс пересборок не назван — точечное число обещает точность").toContain(
"46,2-56,6 %",
);
expect(text.toLowerCase(), "не сказано, что замер не point-in-time").toContain(
"point-in-time",
);
});
});
describe("оговорки в интерфейсе", () => {
it("на экране есть оговорки ВСЕХ ТРЁХ величин блока", () => {
// Матчер нормализует неразрывный пробел так же, как это делает DOM: в
// оговорках перед «%» стоит U+00A0, а `queryByText` сравнивается уже с
// нормализованным текстом. Без этого тест краснеет на типографике, а не на
// пропавшей со страницы оговорке — то есть перестаёт ловить своё.
const shown = (line: string): string => line.replace(/\u00a0/gu, " ");
it("на экране есть оговорки ВСЕХ ТРЁХ показанных величин блока", () => {
render(<AccuracyV3 stats={{}} showcase={null} />);
for (const набор of [
BACKTEST.priceError.caveats,
BACKTEST.coverage.caveats,
BACKTEST.confidenceLow.caveats,
BACKTEST.within20.caveats,
]) {
for (const line of набор) {
expect(
screen.queryByText(line),
screen.queryByText(shown(line)),
`оговорка не выводится: ${line.slice(0, 48)}`,
).not.toBeNull();
}

View file

@ -16,6 +16,17 @@ import { HeroV3 } from "../_components/v3/HeroV3";
import { BACKTEST } from "../landing-facts";
import type { LandingStat, ShowcaseResponse } from "../public-api";
/**
* Значение витрины так, как его видит `getByText`.
*
* В `landing-facts` перед знаком процента стоит НЕРАЗРЫВНЫЙ пробел иначе на
* узкой плитке «%» уезжает на отдельную строку. Строковый матчер
* testing-library сравнивается с НОРМАЛИЗОВАННЫМ текстом DOM, где U+00A0 уже
* стал обычным пробелом, поэтому матчер обязан нормализоваться так же. Иначе
* тест краснеет на типографской правке, а не на смысле числа.
*/
const shown = (value: string): string => value.replace(/\u00a0/gu, " ");
const stat = (value: number, sample_n: number | null, note: string): LandingStat => ({
value,
sample_n,
@ -78,14 +89,49 @@ const SHOWCASE: ShowcaseResponse = {
describe("витрина лэндинга v3 без данных", () => {
it("«Точность»: с данными показывает величины вместе с выборкой и коридором", () => {
render(<AccuracyV3 stats={STATS} showcase={SHOWCASE} />);
expect(screen.getByText(BACKTEST.priceError.text)).toBeTruthy();
expect(screen.getByText(shown(BACKTEST.priceError.text))).toBeTruthy();
expect(screen.getByText(/коридор шириной ±37 %/)).toBeTruthy();
expect(screen.getByText(BACKTEST.confidenceLow.text)).toBeTruthy();
expect(screen.getByText(shown(BACKTEST.within20.text))).toBeTruthy();
expect(screen.getByText(/по 25 943 объявлениям/)).toBeTruthy();
// Подпись витрины: сколько рассмотрено и по какому правилу отсеяно.
// Подпись витрины: сколько рассмотрено и по какому правилу отобрано.
expect(screen.getByText(/рассмотрено сделок: 4 000/)).toBeTruthy();
});
/**
* Снятая величина не должна остаться на экране «по инерции»: её плитку занял
* свежий замер, и оговорка про уверенность без своей плитки объясняла бы
* пустоту. Проверяется ПО ЗНАЧЕНИЮ вернут плитку обратно, не убрав
* `within20`, и тест покраснеет.
*/
it("«Точность»: снятая с витрины confidenceLow не рендерится ни значением, ни оговоркой", () => {
render(<AccuracyV3 stats={STATS} showcase={SHOWCASE} />);
expect(screen.queryByText(shown(BACKTEST.confidenceLow.text))).toBeNull();
for (const line of BACKTEST.confidenceLow.caveats) {
expect(screen.queryByText(line)).toBeNull();
}
});
/**
* ПОДПИСЬ ВИТРИНЫ НАЗЫВАЕТ ПОЛОСУ И ДЕРЖИТ РЯДОМ МЕДИАНУ ПО ВСЕЙ СВЕРКЕ.
*
* Строки отобраны фильтром 5 %..+20 % (`select_rows` в
* app/tasks/landing_showcase_deals.py), поэтому их разброс по построению
* выглядит лучше работы расчёта. Две вещи удерживают страницу от вранья:
* подпись говорит, что это отбор, и печатает медиану ВСЕЙ сверки. Убрать
* любую из них и остаётся благополучная двадцатка без контекста.
*/
it("«Точность»: подпись под таблицей называет полосу и печатает медиану всей сверки", () => {
render(<AccuracyV3 stats={STATS} showcase={SHOWCASE} />);
const note = screen.getByText(/Разброс показанных строк/u);
expect(note.textContent).toMatch(/отобранная полоса расхождения от -5 % до \+20 %/u);
expect(note.textContent).toContain("не вся сверка");
expect(
note.textContent,
"из подписи пропала медиана по всей сверке — остался разброс отобранной полосы без контекста",
).toContain(BACKTEST.priceError.text);
expect(note.textContent).toContain(String(BACKTEST.priceError.sampleN));
});
it("«Точность»: пустой /stats снимает плитки и таблицу, а не обнуляет их", () => {
const { container } = render(<AccuracyV3 stats={{}} showcase={null} />);
expect(screen.queryByText("расчётов сделано")).toBeNull();
@ -93,7 +139,7 @@ describe("витрина лэндинга v3 без данных", () => {
expect(container.querySelector('[role="table"]')).toBeNull();
expect(container.textContent).not.toMatch(/[—-]\s*дн\./);
// Бэктест не зависит от ручки — он остаётся вместе со своими оговорками.
expect(screen.getByText(BACKTEST.priceError.text)).toBeTruthy();
expect(screen.getByText(shown(BACKTEST.priceError.text))).toBeTruthy();
});
it("«Цена ошибки»: у двух величин РАЗНЫЕ выборки, и обе подписаны", () => {
@ -140,10 +186,14 @@ describe("витрина лэндинга v3 без данных", () => {
});
/**
* Лента висит НАД первым экраном, и её строки отобраны по полноте и свежести,
* а не по величине ошибки, крупный промах в ней штатен. Проверяется, что
* рядом с промахом стоит его контекст и что оба числа сосчитаны ПО ПОКАЗАННЫМ
* строкам: подпись с вписанными руками величинами тут же разошлась бы с лентой.
* Лента висит НАД первым экраном и берёт те же строки витрины, то есть с
* 12.09.2026 ОТОБРАННУЮ полосу 5 %..+20 %. Значения в фикстурах поэтому
* внутри полосы: строка с +75,7 % в ленту больше физически не попадает, и
* тест на ней проверял бы состояние, которого продюсер не создаёт.
*
* Проверяется две вещи: подпись называет полосу (иначе благополучный разброс
* читается как точность расчёта) и оба числа сосчитаны ПО ПОКАЗАННЫМ строкам
* подпись с вписанными руками величинами тут же разошлась бы с лентой.
*/
describe("лента сделок: контекст разброса", () => {
const tickerDeal = (err_pct: number) => ({
@ -153,16 +203,23 @@ describe("лента сделок: контекст разброса", () => {
});
it("подпись печатает медиану и худшую ровно тех строк, что показаны", () => {
render(<DealsTickerV3 deals={[tickerDeal(4), tickerDeal(-11.5), tickerDeal(75.7)]} />);
const note = screen.getByText(/Медиана расхождения показанных строк/u);
render(<DealsTickerV3 deals={[tickerDeal(4), tickerDeal(-11.5), tickerDeal(19.3)]} />);
const note = screen.getByText(/Медиана показанных строк/u);
expect(note.textContent).toContain("11,5 %");
expect(note.textContent).toContain("75,7 %");
expect(note.textContent).toContain("19,3 %");
});
it("та же лента без худшей строки печатает ДРУГИЕ числа — они не константы", () => {
render(<DealsTickerV3 deals={[tickerDeal(4), tickerDeal(-11.5)]} />);
const note = screen.getByText(/Медиана расхождения показанных строк/u);
const note = screen.getByText(/Медиана показанных строк/u);
expect(note.textContent).toContain("7,8 %");
expect(note.textContent).not.toContain("75,7 %");
expect(note.textContent).not.toContain("19,3 %");
});
it("подпись называет полосу — иначе разброс ленты читается как точность расчёта", () => {
render(<DealsTickerV3 deals={[tickerDeal(4), tickerDeal(-11.5)]} />);
const note = screen.getByText(/Медиана показанных строк/u);
expect(note.textContent).toMatch(/от -5 % до \+20 %/u);
expect(note.textContent).toContain("не вся сверка");
});
});

View file

@ -13,19 +13,33 @@
* и не пересчитываются ночной задачей; без даты они стареют молча. Дату видит
* читатель, а срок годности сторожит `__tests__/backtest-freshness.test.ts`.
*
* ОТКУДА ЧИСЛА. Три первых плитки разовая сверка прогноза с ценой ДКП
* ОТКУДА ЧИСЛА. Три первых плитки ручная сверка прогноза с ценой ДКП
* (`landing-facts.ts`, там же источник и оговорки). Экспозиция и число
* расчётов `/stats`, каждая плитка рендерится только если величина пришла:
* недоступная ручка снимает плитку, а не показывает ноль. Таблица `/showcase`
* (реальные сделки Росреестра против прогноза), и вместе со строками едет
* подпись: сколько сделок рассмотрено, сколько годных не поместилось и по
* какому правилу отсеяно остальное.
* подпись: сколько сделок рассмотрено, сколько строк прогон собрал и по
* какому правилу из них отобраны показанные.
*
* ТРИ ПЛИТКИ БЭКТЕСТА ИДУТ КОМПЛЕКТОМ И ПОРОЗНЬ НЕ ПОКАЗЫВАЮТСЯ. «88 % в
* коридоре» без ширины коридора (±37 %) и без того, что уверенность расчёта
* низкая у 325 из 327, это три разных способа выглядеть точнее, чем есть.
* Поэтому ширина коридора стоит в ПОДПИСИ к самой плитке, а не отдельной
* строкой мелким шрифтом, и оговорки рендерятся тут же, под сеткой.
* ВИТРИНА ПОКАЗЫВАЕТ ОТОБРАННУЮ ПОЛОСУ, И ПОДПИСЬ ОБЯЗАНА ЭТО НАЗВАТЬ. С
* 12.09.2026 в таблицу и в ленту попадают только сделки с расхождением от
* 5 % до +20 % (фильтр `select_rows` в `app/tasks/landing_showcase_deals.py`,
* оттуда же текст правила). Поэтому подпись под таблицей говорит про полосу
* прямо и держит рядом медиану по ВСЕЙ сверке: разброс двадцати отобранных
* строк по построению лучше работы расчёта, и без второго числа страница
* обещала бы точность, которой никто не мерил.
*
* ПЛИТКИ БЭКТЕСТА ИДУТ КОМПЛЕКТОМ И ПОРОЗНЬ НЕ ПОКАЗЫВАЮТСЯ. «88 % в
* коридоре» без ширины коридора (±37 %) и без разброса пересборок это
* разные способы выглядеть точнее, чем есть. Поэтому ширина коридора стоит в
* ПОДПИСИ к самой плитке, а не отдельной строкой мелким шрифтом, и оговорки
* рендерятся тут же, под сеткой.
*
* У ТРЕТЬЕЙ ПЛИТКИ СВОЯ ДАТА. Она приехала из прогона 12.09.2026, а лид блока
* называет замер 31.08 поэтому дата стоит в подписи самой плитки, и одна из
* её оговорок прямо говорит, что медианное расхождение свежего прогона (19,1 %)
* ХУЖЕ прежнего (15,3 %). Два числа разных дат без этой строки читаются как
* улучшение, которого не было.
*
* ЧЕГО ЗДЕСЬ НЕТ. Плитки «точность по сроку продажи»: `deals.days_on_market`
* заполнен 0 раз из 108 623 сверять прогноз срока не с чем. На её месте
@ -44,23 +58,26 @@
import {
BACKTEST,
BACKTEST_BAND_MEASURED_LABEL,
BACKTEST_CORRIDOR_LABEL,
BACKTEST_MEASURED_LABEL,
BACKTEST_PERIOD_LABEL,
BACKTEST_SHARE_LABEL,
BACKTEST_WITHIN_LABEL,
} from "../../landing-facts";
import { formatStat, type LandingStats, type ShowcaseResponse } from "../../public-api";
import styles from "../../landing-v3.module.css";
import {
absPct,
BAND_MAX_PCT,
BAND_MIN_PCT,
count,
dealMeta,
dealTitle,
errPct,
rub,
shownSpread,
WITHIN_PCT,
} from "./deal-view";
interface Tile {
@ -99,29 +116,49 @@ export function AccuracyV3({
note: "попадание обеспечено шириной коридора, а не точностью точки",
},
{
key: "confidence",
value: BACKTEST.confidenceLow.text,
label: "расчётов с пометкой «уверенность низкая»",
note: "оценки «уверенность высокая» в выборке не встретилось ни разу",
// Плитку «400 из 400 — уверенность низкая» сменил свежий замер
// (12.09.2026). Её запись в landing-facts не удалена, а помечена снятой:
// прогон 29.08 шёл по кластеризованной выборке, и держать на витрине
// приговор, посчитанный на данных, которым мы сами не верим, нельзя.
// Дата у этой плитки СВОЯ и стоит в подписи: у блока общая дата 31.08,
// и без даты рядом свежее число приписалось бы старому прогону.
key: "within20",
value: BACKTEST.within20.text,
label: `сделок — расхождение в пределах ${BACKTEST_WITHIN_LABEL}`,
note: `по ${BACKTEST.within20.sampleN} сделкам · ${BACKTEST_BAND_MEASURED_LABEL}`,
},
];
if (age) {
// На плитке — только размер выборки. Полное пояснение («это НЕ срок продажи»,
// откуда взята дата публикации) уходит в общий список оговорок ниже: в плитке
// оно занимало семь строк и растягивало ВЕСЬ ряд, потому что flex-строка
// тянется по самой высокой карточке. Текст с экрана не исчезает — меняется
// только место, где он стоит.
tiles.push({
key: "listingAge",
value: age.text,
label: "медианная экспозиция активного объявления",
note: [age.note, age.sample].filter(Boolean).join(" · "),
note: age.sample ?? "",
});
}
// Оговорки ВСЕХ ТРЁХ величин блока, а не двух. `confidenceLow` показывается
// отдельной плиткой, но её оговорка сюда не попадала — то есть на экране
// стояло «400 из 400 — уверенность низкая» без единого слова о том, почему.
// Плитка без своей оговорки — ровно то, что этот блок и не должен делать.
// Оговорки ВСЕХ ПОКАЗАННЫХ величин блока. Правило то же, что и раньше:
// плитка без своей оговорки — ровно то, чего этот блок делать не должен.
// Снятая `confidenceLow` отсюда ушла вместе со своей плиткой: оговорка про
// число, которого на экране нет, объясняет пустоту.
//
// У `within20` оговорок три, и третья обязательна: на странице теперь стоят
// два числа разных дат, и свежий прогон дал медианное расхождение ХУЖЕ
// прежнего. Умолчать об этом — значит дать читателю собрать из двух дат
// картину, которой замер не подтверждает.
const caveats = [
...BACKTEST.priceError.caveats,
...BACKTEST.coverage.caveats,
...BACKTEST.confidenceLow.caveats,
...BACKTEST.within20.caveats,
// Пояснение к экспозиции переехало сюда с плитки (см. выше). Оно длинное и
// по смыслу — такая же оговорка, как соседние: без него «34 дн.» читается
// как срок продажи, которым эта величина не является.
...(age?.note ? [age.note] : []),
];
const deals = showcase?.deals ?? [];
@ -216,14 +253,37 @@ export function AccuracyV3({
</div>
))}
</div>
{/*
«Годных» здесь больше нет намеренно. `eligible` это строки,
которые прогон СОБРАЛ (данных хватило), и с появлением полосы
разница `eligible written` перестала означать «столько не
поместилось»: в неё входят и отсеянные полосой. Правило отбора
(`rejection_rule`) приходит из того же прогона и стоит в этой же
подписи счётчики и правило порознь не показываются.
*/}
<p className={styles.accFootnote}>
{showcaseStats
? `Показано ${count(showcaseStats.written)} строк из ${count(showcaseStats.eligible)} годных, рассмотрено сделок: ${count(showcaseStats.considered)}. Район известен у ${count(showcaseStats.with_district)} из показанных. ${showcaseStats.rejection_rule}`
? `Показано ${count(showcaseStats.written)} строк из ${count(showcaseStats.eligible)} собранных прогоном, рассмотрено сделок: ${count(showcaseStats.considered)}. Район известен у ${count(showcaseStats.with_district)} из показанных. ${showcaseStats.rejection_rule}`
: "Подпись прогона не пришла — из чего отобраны строки, сказать нечем."}
</p>
{spread && (
// «В ПРЕДЕЛАХ 20 % — N ИЗ N» ОТСЮДА СНЯТО, И ЭТО НЕ СОКРАЩЕНИЕ.
// Полоса витрины — от 5 % до +20 %, значит |отклонение| ≤ 20 у
// КАЖДОЙ показанной строки по построению фильтра: счёт всегда
// выходил бы «8 из 8» и читался бы как замер попадания, которым
// не является. Неработающая проверка читается как работающая —
// то же правило, по которому из продюсера витрины убрали мёртвый
// порог MAX_FACT_PPM2. `spread.within` считается по-прежнему
// (shownSpread трогать не просили) и ждёт, пока полосу подвинут.
//
// МЕДИАНА ПО ВСЕЙ СВЕРКЕ ОСТАЁТСЯ ЗДЕСЬ ПРИ ЛЮБОЙ ПРАВКЕ ПОДПИСИ.
// Разброс слева посчитан по ОТОБРАННОЙ полосе и по построению
// выглядит лучше, чем работа расчёта: без второго числа рядом
// страница обещала бы точность, которой никто не мерил. Это
// сторожит landing-v3-render («подпись витрины называет полосу и
// держит рядом медиану по всей сверке»).
<p className={styles.accFootnote}>
{`Разброс показанных строк: медианное расхождение ${absPct(spread.medianAbsPct)}, в пределах ${WITHIN_PCT} % — ${spread.within} из ${spread.n}, худшая ${absPct(spread.worstAbsPct)}. Медиана по всей сверке — ${BACKTEST.priceError.text} (${count(BACKTEST.priceError.sampleN)} сделок). Строки отобраны по полноте и свежести, не по величине ошибки.`}
{`Разброс показанных строк: медианное расхождение ${absPct(spread.medianAbsPct)} по ${spread.n} строкам, худшая ${absPct(spread.worstAbsPct)}. Это отобранная полоса расхождения от ${errPct(BAND_MIN_PCT)} до ${errPct(BAND_MAX_PCT)}, а не вся сверка: промахи крупнее полосы в данных есть, здесь их не видно. Медиана по всей сверке — ${BACKTEST.priceError.text} (${count(BACKTEST.priceError.sampleN)} сделок).`}
</p>
)}
<p className={styles.accFootnote}>{deals[0].note}</p>

View file

@ -12,14 +12,23 @@
* не из чего. Адреса тоже нет: улица и дом известны у 2.7% сделок, поэтому
* объект описан тем, что есть комнаты, площадь, район, этаж.
*
* СТРОКИ ОТОБРАННАЯ ПОЛОСА, А НЕ ВСЯ СВЕРКА. С 12.09.2026 продюсер витрины
* пишет только сделки с расхождением от 5 % до +20 % (`select_rows`/`BAND_*`
* в app/tasks/landing_showcase_deals.py), внутри полосы порядок задают полнота
* и свежесть. Лента берёт ТЕ ЖЕ строки, поэтому её содержимое отобрано ровно
* так же и «худшая» в подписи ниже теперь ограничена полосой сверху, а не
* данными.
*
* ПОД лентой подпись с разбросом ПОКАЗАННЫХ строк (`shownSpread`, та же
* функция, что и под таблицей сверок): медиана модуля и худшая. Без неё первое,
* что видит посетитель страницы про точность, промах в семьдесят процентов
* без единой цифры контекста, опровергающий её же заголовок. Прятать промахи
* нельзя: строки отобраны по ПОЛНОТЕ и СВЕЖЕСТИ (`_sort_key` в
* app/tasks/landing_showcase_deals.py), поэтому лечится контекстом, а не
* отбором. Вывода («зато обычно точно») в подписи нет намеренно: он протух бы
* на первом же пересчёте витрины, а два числа рядом не протухают.
* функция, что и под таблицей сверок): медиана модуля и худшая. До полосы она
* спасала от другого: первым, что посетитель видел про точность, был промах в
* семьдесят процентов без единой цифры контекста. Теперь её работа обратная
* не дать прочитать благополучный разброс полосы как точность расчёта. Поэтому
* полоса названа В ТОЙ ЖЕ подписи, перед числами, а не только под таблицей
* этажом ниже: лента висит НАД первым экраном, и её числа читают раньше любых
* оговорок блока «Точность». Вывода («зато обычно точно») в подписи нет
* намеренно: он протух бы на первом же пересчёте витрины, а числа рядом не
* протухают.
*
* Список дублируется дважды подряд стандартный приём бесшовного CSS-marquee
* (анимация уводит ровно на 50%).
@ -33,7 +42,15 @@
import type { ShowcaseDeal } from "../../public-api";
import styles from "../../landing-v3.module.css";
import { absPct, dealTitle, errPct, rub, shownSpread } from "./deal-view";
import {
absPct,
BAND_MAX_PCT,
BAND_MIN_PCT,
dealTitle,
errPct,
rub,
shownSpread,
} from "./deal-view";
export function DealsTickerV3({ deals }: { deals: readonly ShowcaseDeal[] }) {
const items = [...deals, ...deals];
@ -69,7 +86,7 @@ export function DealsTickerV3({ deals }: { deals: readonly ShowcaseDeal[] }) {
</div>
{spread && (
<p className={styles.tickerNote}>
{`Медиана расхождения показанных строк — ${absPct(spread.medianAbsPct)}, худшая — ${absPct(spread.worstAbsPct)}`}
{`Показаны сделки с расхождением от ${errPct(BAND_MIN_PCT)} до ${errPct(BAND_MAX_PCT)} — отобранная полоса, не вся сверка. Медиана показанных строк — ${absPct(spread.medianAbsPct)}, худшая — ${absPct(spread.worstAbsPct)}`}
</p>
)}
</div>

View file

@ -174,11 +174,13 @@ export function pickVariedDeals(deals: readonly ShowcaseDeal[], n: number): Show
* поля в API для этого нет намеренно: любое второе место, где эти числа
* считаются, рано или поздно отстанет от строк на экране.
*
* Строки витрины отобраны по ПОЛНОТЕ и СВЕЖЕСТИ, а не по величине ошибки
* (`_sort_key` в `app/tasks/landing_showcase_deals.py`), поэтому их разброс не
* обязан совпадать с разбросом всей сверки. Обе величины печатаются рядом, и
* вывод о том, повезло ли показанной двадцатке, читатель делает сам своей
* формулировки вроде «чуть точнее» здесь нет: она бы протухла на первом же
* Строки витрины ОТОБРАНЫ ПО ПОЛОСЕ расхождения 5 %..+20 %
* (`select_rows`/`BAND_*` в `app/tasks/landing_showcase_deals.py`), а внутри
* полосы по полноте и свежести. Значит этот разброс УЖЕ, чем у всей сверки,
* и совпадать с ней не может по построению. Именно поэтому подпись печатает
* рядом медиану по всей сверке: одна величина без другой читается как
* точность расчёта, которой у отобранной двадцатки никто не мерил. Своей
* формулировки вроде «чуть точнее» здесь нет она бы протухла на первом же
* пересчёте витрины, а числа рядом не протухают никогда.
*/
/**
@ -189,6 +191,21 @@ export function pickVariedDeals(deals: readonly ShowcaseDeal[], n: number): Show
*/
export const WITHIN_PCT = 20;
/**
* Полоса расхождения, по которой витрина отобрана. ПРАВДА в бэкенде
* (`BAND_MIN_ERR_PCT`/`BAND_MAX_ERR_PCT` в `app/tasks/landing_showcase_deals.py`),
* здесь копия ради подписи, и подставляется она тем же `errPct`, что рисует
* расхождение в самой таблице: «5 %» в тексте и `-5.0` в фильтре обязаны
* называться одинаково.
*
* Полосу проговаривает и `rejection_rule`, который приходит ИЗ ТОГО ЖЕ
* прогона, что и строки, то есть на странице границы названы дважды и из
* двух независимых источников. Разойдутся будет видно глазами на первом же
* скриншоте, а не через квартал.
*/
export const BAND_MIN_PCT = -5;
export const BAND_MAX_PCT = 20;
export interface ShownSpread {
readonly n: number;
readonly medianAbsPct: number;

View file

@ -69,13 +69,26 @@ const BACKTEST_CONFIDENCE_SOURCE =
"Росреестра по Екатеринбургу, сделки II квартала 2026 года";
/**
* Сверка «прогноз цена ДКП». Три величины идут КОМПЛЕКТОМ и показываются
* вместе: попадание в коридор без ширины коридора и без уровня уверенности
* Источник `within20` СВЕЖИЙ прогон 12.09.2026, отдельной строкой по той же
* причине, что и предыдущий: у него своя дата и свой размер выборки (290
* сделок против 325 у головных чисел), и приписывать ему подпись «замер 31.08»
* значило бы повторить дефект, из-за которого эту строку и завели.
*/
const BACKTEST_BAND_SOURCE =
"Бэктест на боевой базе (12.09.2026, engine=full): прогноз МЕРЫ против цены " +
"ДКП Росреестра по Екатеринбургу, представительная выборка (разнесение по " +
"адресам), медиана трёх пересборок с солями 11/22/33";
/**
* Сверка «прогноз цена ДКП». Величины идут КОМПЛЕКТОМ и показываются
* вместе: попадание в коридор без ширины коридора и без разброса пересборок
* читается как точность, которой нет.
*/
export const BACKTEST: Readonly<Record<"priceError" | "coverage" | "confidenceLow", MeasuredValue>> = {
export const BACKTEST: Readonly<
Record<"priceError" | "coverage" | "confidenceLow" | "within20", MeasuredValue>
> = {
priceError: {
text: "15,3 %",
text: "15,3 %",
sampleN: 325,
source: BACKTEST_SOURCE,
caveats: [
@ -88,7 +101,7 @@ export const BACKTEST: Readonly<Record<"priceError" | "coverage" | "confidenceLo
],
},
coverage: {
text: "84,3 %",
text: "84,3 %",
sampleN: 325,
source: BACKTEST_SOURCE,
caveats: [
@ -96,6 +109,23 @@ export const BACKTEST: Readonly<Record<"priceError" | "coverage" | "confidenceLo
"коридор такой ширины накрывает почти любую сделку.",
],
},
/**
* СНЯТА С ВИТРИНЫ 12.09.2026, но не удалена решением владельца её место в
* блоке «Точность» занял `within20`.
*
* Почему снята: прогон 29.08 шёл по кластеризованной выборке (`ORDER BY id
* DESC`, 20 строк на четыре улицы) — непредставительность этого прогона
* признана в шапке файла и уже стоила нам завышенного покрытия 88 %.
* Держать под ней плитку «400 из 400» значило показывать приговор,
* посчитанный на выборке, которой мы сами не верим.
*
* Почему не удалена: пересчитать её на представительной выборке никто не
* просил, а величина сама по себе не опровергнута решение вернуть или
* выбросить за владельцем. Пока запись не рендерится ни одним компонентом
* (проверяется `landing-v3-render`), гейт `landing-numbers-gate` всё равно
* требует от неё источник и оговорку и это правильно: вернётся на витрину
* она вместе с ними.
*/
confidenceLow: {
// Тот же прогон, что и остальные числа блока: пересборка «mera» от
// 31.08.2026. Раньше здесь стояло «325 из 327» из прогона 29.08 — под
@ -113,10 +143,43 @@ export const BACKTEST: Readonly<Record<"priceError" | "coverage" | "confidenceLo
"31.08.2026 по 60 случайным адресам его медиана — 29 %.",
],
},
/**
* Доля сделок, где расхождение прогноза с ценой ДКП уложилось в ±20 %.
*
* ЗАМЕР ОТ 12.09.2026, И ЭТО ДРУГАЯ ДАТА, ЧЕМ У ГОЛОВНЫХ ЧИСЕЛ. Оговорки
* обязаны об этом сказать: в том же прогоне медианное расхождение вышло
* 19,1 % ХУЖЕ, чем 15,3 % из замера 31.08. Две величины меряют разное и
* взяты в разные дни, но молчать о том, что свежий прогон дал результат
* хуже прежнего, нельзя: на странице стоят оба числа, и без оговорки
* читатель соберёт из них картину, которой замер не подтверждает.
*/
within20: {
text: "52,7 %",
sampleN: 290,
source: BACKTEST_BAND_SOURCE,
caveats: [
"Замер НЕ point-in-time: прогноз считается по СЕГОДНЯШНИМ активным " +
"объявлениям против ПРОШЛЫХ сделок, и дрейф рынка за период входит в " +
"расхождение целиком.",
"Разброс по трём пересборкам — 46,2-56,6 %. Точного значения без этого " +
"диапазона не существует: медиана трёх прогонов — это оценка, а не " +
"измеренная константа.",
"Медианное расхождение в этом же прогоне — 19,1 %, то есть ВЫШЕ, чем " +
"15,3 % из замера 31.08.2026. Величины меряют разное (доля попаданий " +
"против середины распределения) и взяты в разные дни — сравнивать их " +
"напрямую нельзя, но и умалчивать о том, что свежий прогон вышел " +
"хуже прежнего, тоже.",
],
},
};
/** Ширина окна попадания у `within20` — стоит в подписи плитки, не отдельно. */
export const BACKTEST_WITHIN_LABEL = "±20 %";
/** Ширина коридора — та же сверка; стоит в подписи к `coverage`, не отдельной плиткой. */
export const BACKTEST_CORRIDOR_LABEL = "±37 %";
// Неразрывный пробел перед знаком процента: на узкой плитке обычный уводил
// «%» на отдельную строку («коридор шириной ±37 / %», скрин 12.09).
export const BACKTEST_CORRIDOR_LABEL = "±37 %";
/**
* Окно сделок бэктеста. Окно РАСЧЁТОВ другое, оно приходит из `/stats`.
@ -201,10 +264,26 @@ export const BACKTEST_MEASURED_ON = "2026-08-31";
/** Через сколько дней замер считается протухшим (см. тест свежести). */
export const BACKTEST_MAX_AGE_DAYS = 100;
const [measuredY, measuredM, measuredD] = BACKTEST_MEASURED_ON.split("-");
/**
* Дата ВТОРОГО замера блока прогона 12.09.2026, из которого взят `within20`.
*
* Отдельная константа, потому что дата у него своя, а сроку годности он
* подчиняется тому же: `backtest-freshness` проверяет ОБЕ даты. Второе число
* на странице, которое не сторожит никто, это та же дыра, ради закрытия
* которой сторож и заводился, просто под новой датой.
*/
export const BACKTEST_BAND_MEASURED_ON = "2026-09-12";
const label = (iso: string): string => {
const [y, m, d] = iso.split("-");
return `замер ${d}.${m}.${y}`;
};
/** Человекочитаемая дата замера. Выводится из ISO — двух правок не требует. */
export const BACKTEST_MEASURED_LABEL = `замер ${measuredD}.${measuredM}.${measuredY}`;
export const BACKTEST_MEASURED_LABEL = label(BACKTEST_MEASURED_ON);
/** То же для свежего прогона: плитка `within20` подписана СВОЕЙ датой. */
export const BACKTEST_BAND_MEASURED_LABEL = label(BACKTEST_BAND_MEASURED_ON);
/**
* Бейдж первого шага И подпись под кнопкой формы (`FreeCheckCard.tsx`).