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

View file

@ -1,11 +1,21 @@
"""Витрина лэндинга на реальных сделках — отбор и отбраковка (миграция 276). """Витрина лэндинга на реальных сделках — отбор и отбраковка (миграция 276).
Главное, что здесь защищается, НЕ формат строки, а свойство отбора: витрина Здесь защищаются ДВА разных свойства, и путать их нельзя.
показывает выборку из работы оценщика, а не её лучший хвост. Отбор по малой
ошибке дал бы формально работающий код и врущий продукт, и заметить это на 1. ПОЛОСА. С 2026-09-12 витрина показывает только расхождения 5 %..+20 %
глаз в проде нельзя числа будут красивые. Поэтому проверка двусторонняя: включительно решение владельца продукта. Это отбор показательных строк,
самая точная строка, у которой не хватает данных, обязана проиграть менее и проверяется он ПО ЗНАЧЕНИЮ, на реальных строках прода: +75,7 % и 27,9 %
точной, но полной. на витрину не попадают, +9,9 % попадает.
2. ВНУТРИ ПОЛОСЫ отбора по величине ошибки по-прежнему нет. Иначе витрина
показывала бы лучший хвост уже самой полосы, а числа при этом остались бы
красивыми на глаз в проде такое не ловится. Поэтому проверка
двусторонняя: самая точная строка, у которой не хватает данных, обязана
проиграть менее точной, но полной.
Отбраковка («данных нет») третье свойство, и она живёт в `build_row`: строка
вне полосы остаётся кандидатом и попадает в счётчик `eligible`, её снимает
отбор, а не отбраковка. Ровно поэтому подпись под витриной может честно
сказать, сколько строк прогон собрал.
""" """
from __future__ import annotations from __future__ import annotations
@ -16,6 +26,9 @@ from datetime import date
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.tasks.landing_showcase_deals import ( from app.tasks.landing_showcase_deals import (
BAND_MAX_ERR_PCT,
BAND_MIN_ERR_PCT,
REJECTION_RULE,
ShowcaseRow, ShowcaseRow,
build_row, build_row,
quarter_label, 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: def test_selection_ignores_error_magnitude() -> None:
"""Точнейшая строка с дырами в данных НЕ должна оказаться впереди полной. """Точнейшая строка с дырами в данных НЕ должна оказаться впереди полной.
Обе строки ВНУТРИ полосы проверяется именно ранжирование, а не фильтр:
иначе тест зеленел бы по той же причине, по которой краснеет соседний.
Ломать так: добавить в `_sort_key` слагаемое `abs(row.err_pct)` тест Ломать так: добавить в `_sort_key` слагаемое `abs(row.err_pct)` тест
покраснеет с id 1 на первом месте вместо id 2. покраснеет с id 1 на первом месте вместо id 2.
""" """
almost_perfect_but_thin = _row(1, district=None, floor=None, err_pct=0.1) 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) chosen = select_rows([almost_perfect_but_thin, complete_but_worse], limit=1)
assert [r.deal_id for r in chosen] == [2], ( assert [r.deal_id for r in chosen] == [2], (
"отбор поехал за величиной ошибки — витрина перестала быть выборкой " "отбор поехал за величиной ошибки — витрина показывает лучший хвост уже самой полосы"
"и стала рекламой лучшего хвоста"
) )
def test_selection_prefers_fresher_quarter_at_equal_completeness() -> None: def test_selection_prefers_fresher_quarter_at_equal_completeness() -> None:
older = _row(1, deal_date=date(2025, 1, 1), err_pct=1.0) 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] 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]. `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) with_street = _row(8, err_pct=3.0)
chosen = select_rows([no_street, with_street], limit=2) 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: def test_no_error_magnitude_is_ever_rejected() -> None:
"""Промах оценщика ЛЮБОГО размера остаётся на витрине. """Промах ЛЮБОГО размера остаётся кандидатом и попадает в счётчик.
Это второй половина запрета «не отбирать по ошибке»: фильтр по величине Отбраковка и отбор разные шаги, и величина ошибки причиной ОТБРАКОВКИ не
ошибки тот же отбор, просто ступенькой раньше, и он тем злее, что не является: вне полосы строка не показывается, но входит в `eligible`, и
оставляет строку даже в кандидатах. подпись «показано N из M собранных» остаётся правдой. Отбракуй её здесь
и отбор перестал бы быть виден в счётчиках вообще.
Ломать так: вернуть в `build_row` любой порог вида Ломать так: вернуть в `build_row` любой порог вида
`if abs(err_pct) > X: return None` тест покраснеет на первом же `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): 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)) row = _build(predicted_rub=fact_rub * (1 + err_pct / 100))
assert row is not None, ( assert row is not None, (
f"строка с отклонением {err_pct:+.0f}% выброшена: витрина снова " f"строка с отклонением {err_pct:+.0f}% выброшена из кандидатов: "
"показывает лучший хвост, а не работу оценщика" "счётчик собранных строк перестал считать работу оценщика"
) )
assert row.err_pct == round(err_pct, 2) 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% это почти всегда Отбраковывать такие строки нельзя: «отклонение больше 40% это почти
дефект ДКП» было догадкой, а санитарный диапазон /м² уже применён к всегда дефект ДКП» было догадкой, а санитарный диапазон /м² уже применён
выборке выше по потоку (`_load_sample`, для ЕКБ 30k..600k). Всё, что к выборке выше по потоку (`_load_sample`, для ЕКБ 30k..600k). На витрину
прошло его и дало большую ошибку, работа оценщика. Честность за счёт такая строка не выйдет её снимет полоса, но в `eligible` она войдёт,
строки в `note`, а не за счёт отсева. и счётчик под таблицей останется честным.
""" """
# Факт 2 000 000 ₽ против прогноза 5 000 000 — отклонение +150%. # Факт 2 000 000 ₽ против прогноза 5 000 000 — отклонение +150%.
row = _build(fact_rub=2_000_000.0) row = _build(fact_rub=2_000_000.0)
@ -247,8 +338,8 @@ def test_row_without_coords_stays_on_showcase() -> None:
"""Нет точки — строка всё равно на витрине, с lat=lon=None. """Нет точки — строка всё равно на витрине, с lat=lon=None.
Выбрасывать сделку из-за отсутствия координаты отбор по признаку, не Выбрасывать сделку из-за отсутствия координаты отбор по признаку, не
связанному с качеством оценки: та же порча витрины, что и отбор по связанному с качеством оценки, и в отличие от полосы он нигде не назван:
величине ошибки, просто по другому полю. Карта переживёт строку без точки. посетитель бы о нём не узнал. Карта переживёт строку без точки.
Ломать так: добавить в `build_row` `if lat is None or lon is None: return Ломать так: добавить в `build_row` `if lat is None or lon is None: return
None` тест покраснеет на None вместо строки. None` тест покраснеет на None вместо строки.

View file

@ -26,6 +26,7 @@ import { describe, expect, it } from "vitest";
import { import {
BACKTEST, BACKTEST,
BACKTEST_BAND_MEASURED_ON,
BACKTEST_MAX_AGE_DAYS, BACKTEST_MAX_AGE_DAYS,
BACKTEST_MEASURED_LABEL, BACKTEST_MEASURED_LABEL,
BACKTEST_MEASURED_ON, 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); 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("свежесть ручного замера бэктеста", () => { describe("свежесть ручного замера бэктеста", () => {
it("дата замера разбирается и не из будущего — иначе сторож считает возраст мусора", () => { it.each(MEASURED_DATES)("дата замера «$what» разбирается и не из будущего", ({ iso }) => {
const age = ageDays(BACKTEST_MEASURED_ON, Date.now()); const age = ageDays(iso, Date.now());
expect( expect(Number.isFinite(age), `${iso} — не ISO-дата`).toBe(true);
Number.isFinite(age),
`BACKTEST_MEASURED_ON=${BACKTEST_MEASURED_ON} — не ISO-дата`,
).toBe(true);
expect(age, "дата замера в будущем").toBeGreaterThanOrEqual(0); expect(age, "дата замера в будущем").toBeGreaterThanOrEqual(0);
}); });
it("замеру не больше срока годности", () => { it.each(MEASURED_DATES)("замеру «$what» не больше срока годности", ({ iso }) => {
const age = ageDays(BACKTEST_MEASURED_ON, Date.now()); const age = ageDays(iso, Date.now());
expect( expect(
age, age,
[ [
`замеру ${age} дн. (${BACKTEST_MEASURED_ON}), допустимо ${BACKTEST_MAX_AGE_DAYS}.`, `замеру ${age} дн. (${iso}), допустимо ${BACKTEST_MAX_AGE_DAYS}.`,
"Числа блока «Точность» посчитаны руками и с тех пор никем не подтверждены.", "Числа блока «Точность» посчитаны руками и с тех пор никем не подтверждены.",
"Перегнать: python -m scripts.backtest_estimator --city Екатеринбург", "Перегнать: python -m scripts.backtest_estimator --city Екатеринбург",
"— обновить BACKTEST, BACKTEST_PERIOD_LABEL (назвать окно, которое реально", "— обновить BACKTEST, BACKTEST_PERIOD_LABEL (назвать окно, которое реально",

View file

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

View file

@ -16,6 +16,17 @@ import { HeroV3 } from "../_components/v3/HeroV3";
import { BACKTEST } from "../landing-facts"; import { BACKTEST } from "../landing-facts";
import type { LandingStat, ShowcaseResponse } from "../public-api"; 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 => ({ const stat = (value: number, sample_n: number | null, note: string): LandingStat => ({
value, value,
sample_n, sample_n,
@ -78,14 +89,49 @@ const SHOWCASE: ShowcaseResponse = {
describe("витрина лэндинга v3 без данных", () => { describe("витрина лэндинга v3 без данных", () => {
it("«Точность»: с данными показывает величины вместе с выборкой и коридором", () => { it("«Точность»: с данными показывает величины вместе с выборкой и коридором", () => {
render(<AccuracyV3 stats={STATS} showcase={SHOWCASE} />); 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(/коридор шириной ±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(/по 25 943 объявлениям/)).toBeTruthy();
// Подпись витрины: сколько рассмотрено и по какому правилу отсеяно. // Подпись витрины: сколько рассмотрено и по какому правилу отобрано.
expect(screen.getByText(/рассмотрено сделок: 4 000/)).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 снимает плитки и таблицу, а не обнуляет их", () => { it("«Точность»: пустой /stats снимает плитки и таблицу, а не обнуляет их", () => {
const { container } = render(<AccuracyV3 stats={{}} showcase={null} />); const { container } = render(<AccuracyV3 stats={{}} showcase={null} />);
expect(screen.queryByText("расчётов сделано")).toBeNull(); expect(screen.queryByText("расчётов сделано")).toBeNull();
@ -93,7 +139,7 @@ describe("витрина лэндинга v3 без данных", () => {
expect(container.querySelector('[role="table"]')).toBeNull(); expect(container.querySelector('[role="table"]')).toBeNull();
expect(container.textContent).not.toMatch(/[—-]\s*дн\./); expect(container.textContent).not.toMatch(/[—-]\s*дн\./);
// Бэктест не зависит от ручки — он остаётся вместе со своими оговорками. // Бэктест не зависит от ручки — он остаётся вместе со своими оговорками.
expect(screen.getByText(BACKTEST.priceError.text)).toBeTruthy(); expect(screen.getByText(shown(BACKTEST.priceError.text))).toBeTruthy();
}); });
it("«Цена ошибки»: у двух величин РАЗНЫЕ выборки, и обе подписаны", () => { it("«Цена ошибки»: у двух величин РАЗНЫЕ выборки, и обе подписаны", () => {
@ -140,10 +186,14 @@ describe("витрина лэндинга v3 без данных", () => {
}); });
/** /**
* Лента висит НАД первым экраном, и её строки отобраны по полноте и свежести, * Лента висит НАД первым экраном и берёт те же строки витрины, то есть с
* а не по величине ошибки, крупный промах в ней штатен. Проверяется, что * 12.09.2026 ОТОБРАННУЮ полосу 5 %..+20 %. Значения в фикстурах поэтому
* рядом с промахом стоит его контекст и что оба числа сосчитаны ПО ПОКАЗАННЫМ * внутри полосы: строка с +75,7 % в ленту больше физически не попадает, и
* строкам: подпись с вписанными руками величинами тут же разошлась бы с лентой. * тест на ней проверял бы состояние, которого продюсер не создаёт.
*
* Проверяется две вещи: подпись называет полосу (иначе благополучный разброс
* читается как точность расчёта) и оба числа сосчитаны ПО ПОКАЗАННЫМ строкам
* подпись с вписанными руками величинами тут же разошлась бы с лентой.
*/ */
describe("лента сделок: контекст разброса", () => { describe("лента сделок: контекст разброса", () => {
const tickerDeal = (err_pct: number) => ({ const tickerDeal = (err_pct: number) => ({
@ -153,16 +203,23 @@ describe("лента сделок: контекст разброса", () => {
}); });
it("подпись печатает медиану и худшую ровно тех строк, что показаны", () => { it("подпись печатает медиану и худшую ровно тех строк, что показаны", () => {
render(<DealsTickerV3 deals={[tickerDeal(4), tickerDeal(-11.5), tickerDeal(75.7)]} />); render(<DealsTickerV3 deals={[tickerDeal(4), tickerDeal(-11.5), tickerDeal(19.3)]} />);
const note = screen.getByText(/Медиана расхождения показанных строк/u); const note = screen.getByText(/Медиана показанных строк/u);
expect(note.textContent).toContain("11,5 %"); expect(note.textContent).toContain("11,5 %");
expect(note.textContent).toContain("75,7 %"); expect(note.textContent).toContain("19,3 %");
}); });
it("та же лента без худшей строки печатает ДРУГИЕ числа — они не константы", () => { it("та же лента без худшей строки печатает ДРУГИЕ числа — они не константы", () => {
render(<DealsTickerV3 deals={[tickerDeal(4), tickerDeal(-11.5)]} />); 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).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`. * читатель, а срок годности сторожит `__tests__/backtest-freshness.test.ts`.
* *
* ОТКУДА ЧИСЛА. Три первых плитки разовая сверка прогноза с ценой ДКП * ОТКУДА ЧИСЛА. Три первых плитки ручная сверка прогноза с ценой ДКП
* (`landing-facts.ts`, там же источник и оговорки). Экспозиция и число * (`landing-facts.ts`, там же источник и оговорки). Экспозиция и число
* расчётов `/stats`, каждая плитка рендерится только если величина пришла: * расчётов `/stats`, каждая плитка рендерится только если величина пришла:
* недоступная ручка снимает плитку, а не показывает ноль. Таблица `/showcase` * недоступная ручка снимает плитку, а не показывает ноль. Таблица `/showcase`
* (реальные сделки Росреестра против прогноза), и вместе со строками едет * (реальные сделки Росреестра против прогноза), и вместе со строками едет
* подпись: сколько сделок рассмотрено, сколько годных не поместилось и по * подпись: сколько сделок рассмотрено, сколько строк прогон собрал и по
* какому правилу отсеяно остальное. * какому правилу из них отобраны показанные.
* *
* ТРИ ПЛИТКИ БЭКТЕСТА ИДУТ КОМПЛЕКТОМ И ПОРОЗНЬ НЕ ПОКАЗЫВАЮТСЯ. «88 % в * ВИТРИНА ПОКАЗЫВАЕТ ОТОБРАННУЮ ПОЛОСУ, И ПОДПИСЬ ОБЯЗАНА ЭТО НАЗВАТЬ. С
* коридоре» без ширины коридора (±37 %) и без того, что уверенность расчёта * 12.09.2026 в таблицу и в ленту попадают только сделки с расхождением от
* низкая у 325 из 327, это три разных способа выглядеть точнее, чем есть. * 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` * ЧЕГО ЗДЕСЬ НЕТ. Плитки «точность по сроку продажи»: `deals.days_on_market`
* заполнен 0 раз из 108 623 сверять прогноз срока не с чем. На её месте * заполнен 0 раз из 108 623 сверять прогноз срока не с чем. На её месте
@ -44,23 +58,26 @@
import { import {
BACKTEST, BACKTEST,
BACKTEST_BAND_MEASURED_LABEL,
BACKTEST_CORRIDOR_LABEL, BACKTEST_CORRIDOR_LABEL,
BACKTEST_MEASURED_LABEL, BACKTEST_MEASURED_LABEL,
BACKTEST_PERIOD_LABEL, BACKTEST_PERIOD_LABEL,
BACKTEST_SHARE_LABEL, BACKTEST_SHARE_LABEL,
BACKTEST_WITHIN_LABEL,
} from "../../landing-facts"; } from "../../landing-facts";
import { formatStat, type LandingStats, type ShowcaseResponse } from "../../public-api"; import { formatStat, type LandingStats, type ShowcaseResponse } from "../../public-api";
import styles from "../../landing-v3.module.css"; import styles from "../../landing-v3.module.css";
import { import {
absPct, absPct,
BAND_MAX_PCT,
BAND_MIN_PCT,
count, count,
dealMeta, dealMeta,
dealTitle, dealTitle,
errPct, errPct,
rub, rub,
shownSpread, shownSpread,
WITHIN_PCT,
} from "./deal-view"; } from "./deal-view";
interface Tile { interface Tile {
@ -99,29 +116,49 @@ export function AccuracyV3({
note: "попадание обеспечено шириной коридора, а не точностью точки", note: "попадание обеспечено шириной коридора, а не точностью точки",
}, },
{ {
key: "confidence", // Плитку «400 из 400 — уверенность низкая» сменил свежий замер
value: BACKTEST.confidenceLow.text, // (12.09.2026). Её запись в landing-facts не удалена, а помечена снятой:
label: "расчётов с пометкой «уверенность низкая»", // прогон 29.08 шёл по кластеризованной выборке, и держать на витрине
note: "оценки «уверенность высокая» в выборке не встретилось ни разу", // приговор, посчитанный на данных, которым мы сами не верим, нельзя.
// Дата у этой плитки СВОЯ и стоит в подписи: у блока общая дата 31.08,
// и без даты рядом свежее число приписалось бы старому прогону.
key: "within20",
value: BACKTEST.within20.text,
label: `сделок — расхождение в пределах ${BACKTEST_WITHIN_LABEL}`,
note: `по ${BACKTEST.within20.sampleN} сделкам · ${BACKTEST_BAND_MEASURED_LABEL}`,
}, },
]; ];
if (age) { if (age) {
// На плитке — только размер выборки. Полное пояснение («это НЕ срок продажи»,
// откуда взята дата публикации) уходит в общий список оговорок ниже: в плитке
// оно занимало семь строк и растягивало ВЕСЬ ряд, потому что flex-строка
// тянется по самой высокой карточке. Текст с экрана не исчезает — меняется
// только место, где он стоит.
tiles.push({ tiles.push({
key: "listingAge", key: "listingAge",
value: age.text, value: age.text,
label: "медианная экспозиция активного объявления", label: "медианная экспозиция активного объявления",
note: [age.note, age.sample].filter(Boolean).join(" · "), note: age.sample ?? "",
}); });
} }
// Оговорки ВСЕХ ТРЁХ величин блока, а не двух. `confidenceLow` показывается // Оговорки ВСЕХ ПОКАЗАННЫХ величин блока. Правило то же, что и раньше:
// отдельной плиткой, но её оговорка сюда не попадала — то есть на экране // плитка без своей оговорки — ровно то, чего этот блок делать не должен.
// стояло «400 из 400 — уверенность низкая» без единого слова о том, почему. // Снятая `confidenceLow` отсюда ушла вместе со своей плиткой: оговорка про
// Плитка без своей оговорки — ровно то, что этот блок и не должен делать. // число, которого на экране нет, объясняет пустоту.
//
// У `within20` оговорок три, и третья обязательна: на странице теперь стоят
// два числа разных дат, и свежий прогон дал медианное расхождение ХУЖЕ
// прежнего. Умолчать об этом — значит дать читателю собрать из двух дат
// картину, которой замер не подтверждает.
const caveats = [ const caveats = [
...BACKTEST.priceError.caveats, ...BACKTEST.priceError.caveats,
...BACKTEST.coverage.caveats, ...BACKTEST.coverage.caveats,
...BACKTEST.confidenceLow.caveats, ...BACKTEST.within20.caveats,
// Пояснение к экспозиции переехало сюда с плитки (см. выше). Оно длинное и
// по смыслу — такая же оговорка, как соседние: без него «34 дн.» читается
// как срок продажи, которым эта величина не является.
...(age?.note ? [age.note] : []),
]; ];
const deals = showcase?.deals ?? []; const deals = showcase?.deals ?? [];
@ -216,14 +253,37 @@ export function AccuracyV3({
</div> </div>
))} ))}
</div> </div>
{/*
«Годных» здесь больше нет намеренно. `eligible` это строки,
которые прогон СОБРАЛ (данных хватило), и с появлением полосы
разница `eligible written` перестала означать «столько не
поместилось»: в неё входят и отсеянные полосой. Правило отбора
(`rejection_rule`) приходит из того же прогона и стоит в этой же
подписи счётчики и правило порознь не показываются.
*/}
<p className={styles.accFootnote}> <p className={styles.accFootnote}>
{showcaseStats {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> </p>
{spread && ( {spread && (
// «В ПРЕДЕЛАХ 20 % — N ИЗ N» ОТСЮДА СНЯТО, И ЭТО НЕ СОКРАЩЕНИЕ.
// Полоса витрины — от 5 % до +20 %, значит |отклонение| ≤ 20 у
// КАЖДОЙ показанной строки по построению фильтра: счёт всегда
// выходил бы «8 из 8» и читался бы как замер попадания, которым
// не является. Неработающая проверка читается как работающая —
// то же правило, по которому из продюсера витрины убрали мёртвый
// порог MAX_FACT_PPM2. `spread.within` считается по-прежнему
// (shownSpread трогать не просили) и ждёт, пока полосу подвинут.
//
// МЕДИАНА ПО ВСЕЙ СВЕРКЕ ОСТАЁТСЯ ЗДЕСЬ ПРИ ЛЮБОЙ ПРАВКЕ ПОДПИСИ.
// Разброс слева посчитан по ОТОБРАННОЙ полосе и по построению
// выглядит лучше, чем работа расчёта: без второго числа рядом
// страница обещала бы точность, которой никто не мерил. Это
// сторожит landing-v3-render («подпись витрины называет полосу и
// держит рядом медиану по всей сверке»).
<p className={styles.accFootnote}> <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>
)} )}
<p className={styles.accFootnote}>{deals[0].note}</p> <p className={styles.accFootnote}>{deals[0].note}</p>

View file

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

View file

@ -174,11 +174,13 @@ export function pickVariedDeals(deals: readonly ShowcaseDeal[], n: number): Show
* поля в API для этого нет намеренно: любое второе место, где эти числа * поля в API для этого нет намеренно: любое второе место, где эти числа
* считаются, рано или поздно отстанет от строк на экране. * считаются, рано или поздно отстанет от строк на экране.
* *
* Строки витрины отобраны по ПОЛНОТЕ и СВЕЖЕСТИ, а не по величине ошибки * Строки витрины ОТОБРАНЫ ПО ПОЛОСЕ расхождения 5 %..+20 %
* (`_sort_key` в `app/tasks/landing_showcase_deals.py`), поэтому их разброс не * (`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; 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 { export interface ShownSpread {
readonly n: number; readonly n: number;
readonly medianAbsPct: number; readonly medianAbsPct: number;

View file

@ -69,13 +69,26 @@ const BACKTEST_CONFIDENCE_SOURCE =
"Росреестра по Екатеринбургу, сделки II квартала 2026 года"; "Росреестра по Екатеринбургу, сделки 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: { priceError: {
text: "15,3 %", text: "15,3 %",
sampleN: 325, sampleN: 325,
source: BACKTEST_SOURCE, source: BACKTEST_SOURCE,
caveats: [ caveats: [
@ -88,7 +101,7 @@ export const BACKTEST: Readonly<Record<"priceError" | "coverage" | "confidenceLo
], ],
}, },
coverage: { coverage: {
text: "84,3 %", text: "84,3 %",
sampleN: 325, sampleN: 325,
source: BACKTEST_SOURCE, source: BACKTEST_SOURCE,
caveats: [ 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: { confidenceLow: {
// Тот же прогон, что и остальные числа блока: пересборка «mera» от // Тот же прогон, что и остальные числа блока: пересборка «mera» от
// 31.08.2026. Раньше здесь стояло «325 из 327» из прогона 29.08 — под // 31.08.2026. Раньше здесь стояло «325 из 327» из прогона 29.08 — под
@ -113,10 +143,43 @@ export const BACKTEST: Readonly<Record<"priceError" | "coverage" | "confidenceLo
"31.08.2026 по 60 случайным адресам его медиана — 29 %.", "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`, не отдельной плиткой. */ /** Ширина коридора — та же сверка; стоит в подписи к `coverage`, не отдельной плиткой. */
export const BACKTEST_CORRIDOR_LABEL = "±37 %"; // Неразрывный пробел перед знаком процента: на узкой плитке обычный уводил
// «%» на отдельную строку («коридор шириной ±37 / %», скрин 12.09).
export const BACKTEST_CORRIDOR_LABEL = "±37 %";
/** /**
* Окно сделок бэктеста. Окно РАСЧЁТОВ другое, оно приходит из `/stats`. * Окно сделок бэктеста. Окно РАСЧЁТОВ другое, оно приходит из `/stats`.
@ -201,10 +264,26 @@ export const BACKTEST_MEASURED_ON = "2026-08-31";
/** Через сколько дней замер считается протухшим (см. тест свежести). */ /** Через сколько дней замер считается протухшим (см. тест свежести). */
export const BACKTEST_MAX_AGE_DAYS = 100; 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 — двух правок не требует. */ /** Человекочитаемая дата замера. Выводится из 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`). * Бейдж первого шага И подпись под кнопкой формы (`FreeCheckCard.tsx`).