From 2467943200181cba5568a13e53551d7df63eaa2c Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Sat, 12 Sep 2026 15:29:10 +0500
Subject: [PATCH 1/2] =?UTF-8?q?feat(mera/=D0=BB=D0=B5=D0=BD=D0=B4=D0=B8?=
=?UTF-8?q?=D0=BD=D0=B3):=20=D0=B2=D0=B8=D1=82=D1=80=D0=B8=D0=BD=D0=B0=20?=
=?UTF-8?q?=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20?=
=?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=BE=D1=81=D1=83=20=D1=80=D0=B0=D1=81=D1=85?=
=?UTF-8?q?=D0=BE=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20=E2=88=925?=
=?UTF-8?q?=E2=80=A6+20=20%,=20=D0=BF=D0=BB=D0=B8=D1=82=D0=BA=D1=83=20?=
=?UTF-8?q?=D1=83=D0=B2=D0=B5=D1=80=D0=B5=D0=BD=D0=BD=D0=BE=D1=81=D1=82?=
=?UTF-8?q?=D0=B8=20=D1=81=D0=BC=D0=B5=D0=BD=D0=B8=D0=BB=20=D0=B7=D0=B0?=
=?UTF-8?q?=D0=BC=D0=B5=D1=80=2012.09?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Владелец просит на витрине только сделки, где прогноз разошёлся с ценой ДКП
в пределах от −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
---
tradein-mvp/backend/app/api/public/mera.py | 14 +-
.../app/tasks/landing_showcase_deals.py | 153 +++++++++++++-----
.../tests/test_landing_showcase_deals.py | 145 +++++++++++++----
.../__tests__/backtest-freshness.test.ts | 31 ++--
.../__tests__/backtest-window-labels.test.tsx | 48 +++++-
.../__tests__/landing-v3-render.test.tsx | 83 ++++++++--
.../mera-public/_components/v3/AccuracyV3.tsx | 102 +++++++++---
.../_components/v3/DealsTickerV3.tsx | 35 ++--
.../mera-public/_components/v3/deal-view.ts | 27 +++-
.../src/app/mera-public/landing-facts.ts | 95 ++++++++++-
10 files changed, 596 insertions(+), 137 deletions(-)
diff --git a/tradein-mvp/backend/app/api/public/mera.py b/tradein-mvp/backend/app/api/public/mera.py
index db853872..e6d2102f 100644
--- a/tradein-mvp/backend/app/api/public/mera.py
+++ b/tradein-mvp/backend/app/api/public/mera.py
@@ -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()
diff --git a/tradein-mvp/backend/app/tasks/landing_showcase_deals.py b/tradein-mvp/backend/app/tasks/landing_showcase_deals.py
index d36fb741..745028e2 100644
--- a/tradein-mvp/backend/app/tasks/landing_showcase_deals.py
+++ b/tradein-mvp/backend/app/tasks/landing_showcase_deals.py
@@ -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 000–600 000 ₽/м² для Екатеринбурга) применён "
- "к выборке до расчёта, по цене самой сделки."
+ f"На витрине — ОТОБРАННАЯ полоса расхождения, а не вся сверка: показаны "
+ f"только сделки, у которых расхождение прогноза с ценой ДКП лежит {BAND_LABEL}. "
+ "Промахи крупнее полосы в данных есть, и здесь их не видно — судить по этим "
+ "строкам о точности расчёта нельзя, для этого есть медиана расхождения по "
+ "всей сверке. Внутри полосы порядок задают полнота данных и свежесть "
+ "квартала: величина отклонения на него не влияет, лучший хвост самой полосы "
+ "витрина тоже не показывает. Кроме полосы строку снимает только отсутствие "
+ "данных: расчёт МЕРЫ не дал ожидаемой цены продажи (мало аналогов), "
+ "неизвестен квартал сделки или площадь. Санитарный диапазон цены сделки "
+ "(30 000–600 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"],
diff --git a/tradein-mvp/backend/tests/test_landing_showcase_deals.py b/tradein-mvp/backend/tests/test_landing_showcase_deals.py
index 80bd3920..a3d3e535 100644
--- a/tradein-mvp/backend/tests/test_landing_showcase_deals.py
+++ b/tradein-mvp/backend/tests/test_landing_showcase_deals.py
@@ -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 вместо строки.
diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/backtest-freshness.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/backtest-freshness.test.ts
index 70c90626..3fc83c36 100644
--- a/tradein-mvp/frontend/src/app/mera-public/__tests__/backtest-freshness.test.ts
+++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/backtest-freshness.test.ts
@@ -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 (назвать окно, которое реально",
diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/backtest-window-labels.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/backtest-window-labels.test.tsx
index 27235f9f..027ecbb2 100644
--- a/tradein-mvp/frontend/src/app/mera-public/__tests__/backtest-window-labels.test.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/backtest-window-labels.test.tsx
@@ -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();
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();
}
diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx
index 59c4fcc0..1a476d84 100644
--- a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx
@@ -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();
- 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();
+ 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();
+ 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();
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();
- const note = screen.getByText(/Медиана расхождения показанных строк/u);
+ render();
+ 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();
- 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();
+ const note = screen.getByText(/Медиана показанных строк/u);
+ expect(note.textContent).toMatch(/от -5 % до \+20 %/u);
+ expect(note.textContent).toContain("не вся сверка");
});
});
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx
index 418fff0b..54db3f3f 100644
--- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx
@@ -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({
))}
+ {/*
+ «Годных» здесь больше нет намеренно. `eligible` — это строки,
+ которые прогон СОБРАЛ (данных хватило), и с появлением полосы
+ разница `eligible − written` перестала означать «столько не
+ поместилось»: в неё входят и отсеянные полосой. Правило отбора
+ (`rejection_rule`) приходит из того же прогона и стоит в этой же
+ подписи — счётчики и правило порознь не показываются.
+ */}
{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}`
: "Подпись прогона не пришла — из чего отобраны строки, сказать нечем."}
{spread && (
+ // «В ПРЕДЕЛАХ 20 % — N ИЗ N» ОТСЮДА СНЯТО, И ЭТО НЕ СОКРАЩЕНИЕ.
+ // Полоса витрины — от −5 % до +20 %, значит |отклонение| ≤ 20 у
+ // КАЖДОЙ показанной строки по построению фильтра: счёт всегда
+ // выходил бы «8 из 8» и читался бы как замер попадания, которым
+ // не является. Неработающая проверка читается как работающая —
+ // то же правило, по которому из продюсера витрины убрали мёртвый
+ // порог MAX_FACT_PPM2. `spread.within` считается по-прежнему
+ // (shownSpread трогать не просили) и ждёт, пока полосу подвинут.
+ //
+ // МЕДИАНА ПО ВСЕЙ СВЕРКЕ ОСТАЁТСЯ ЗДЕСЬ ПРИ ЛЮБОЙ ПРАВКЕ ПОДПИСИ.
+ // Разброс слева посчитан по ОТОБРАННОЙ полосе и по построению
+ // выглядит лучше, чем работа расчёта: без второго числа рядом
+ // страница обещала бы точность, которой никто не мерил. Это
+ // сторожит landing-v3-render («подпись витрины называет полосу и
+ // держит рядом медиану по всей сверке»).
- {`Разброс показанных строк: медианное расхождение ${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)} сделок).`}
)}
{deals[0].note}
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/DealsTickerV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/DealsTickerV3.tsx
index 4e0a957b..2ee33a49 100644
--- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/DealsTickerV3.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/DealsTickerV3.tsx
@@ -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[] }) {
{spread && (
- {`Медиана расхождения показанных строк — ${absPct(spread.medianAbsPct)}, худшая — ${absPct(spread.worstAbsPct)}`}
+ {`Показаны сделки с расхождением от ${errPct(BAND_MIN_PCT)} до ${errPct(BAND_MAX_PCT)} — отобранная полоса, не вся сверка. Медиана показанных строк — ${absPct(spread.medianAbsPct)}, худшая — ${absPct(spread.worstAbsPct)}`}
)}
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts b/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts
index 5bbd2f0e..61653657 100644
--- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts
@@ -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;
diff --git a/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts b/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts
index d0688d7c..bf6bd13a 100644
--- a/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts
+++ b/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts
@@ -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> = {
+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 {
+ 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`).
--
2.45.3
From db47fa0ecdf13862032a5e863c3079b999218512 Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Sat, 12 Sep 2026 15:44:24 +0500
Subject: [PATCH 2/2] =?UTF-8?q?fix(mera/=D0=BB=D0=B5=D0=BD=D0=B4=D0=B8?=
=?UTF-8?q?=D0=BD=D0=B3):=20=D1=83=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4?=
=?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE=20=D0=BF=D0=BE?=
=?UTF-8?q?=D0=BB=D0=BE=D1=81=D1=83=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80?=
=?UTF-8?q?=D1=8F=D0=B5=D1=82=20=D1=81=D0=B0=D0=BC=D0=BE=20=D1=81=D0=B5?=
=?UTF-8?q?=D0=B1=D1=8F=20=D0=BF=D0=BE=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7?=
=?UTF-8?q?=D0=B0=D0=BD=D0=BD=D1=8B=D0=BC=20=D1=81=D1=82=D1=80=D0=BE=D0=BA?=
=?UTF-8?q?=D0=B0=D0=BC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Дыра в выкате, найденная ревьюером до мержа. Подпись про полосу собиралась из
констант `BAND_MIN_PCT`/`BAND_MAX_PCT` в коде фронта, а строки витрины и
`rejection_rule` приезжают из БД, от ПОСЛЕДНЕГО прогона задачи
`landing_showcase_deals`. Задачи нет в расписании — её запускают руками.
Значит в окне «фронт выкачен, витрина не пересчитана» страница утверждала бы
«показаны сделки с расхождением от −5 % до +20 %», а под утверждением лежали
бы прежние двадцать строк: по замеру на проде 12 из 20 вне полосы, худшая
+75,7 %. Утверждение и его опровержение в одном экране — хуже, чем было до
правки.
Чинится конструкцией, а не запуском задачи: `allWithinBand(deals)` в
`deal-view.ts` спрашивает САМИ показанные строки теми же границами, что стоят
в тексте.
* Все показанные строки в полосе — печатаем прежнюю формулировку.
* Хоть одна вне — про полосу НЕ утверждаем. В таблице: «Полосу расхождения от
-5 % до +20 % эта подпись не обещает: среди показанных строк есть
расхождения вне неё, то есть витрину собрал прогон с другим правилом — тем,
что напечатано выше». Правило того прогона и так приезжает в
`rejection_rule` из ТОГО ЖЕ прогона, что и строки, поэтому подпись с ними
согласована по построению. В ленте остаётся только то, что посчитано по
строкам: медиана и худшая.
* Медиана по ВСЕЙ сверке (15,3 %, 325 сделок) печатается в обеих ветках — она
и удерживает страницу честной независимо от того, пересчитана витрина.
Тесты по значению в обе стороны: набор с одной строкой вне полосы (+75,71 % —
реальная строка прода) → утверждения про полосу нет; все в полосе → есть.
Фальсификация: `allWithinBand` обезврежен руками (всегда true) — краснеют оба
новых теста, и красный текст показывает ровно тот дефект:
«Это отобранная полоса расхождения от -5 % до +20 % … худшая 75,7 %».
Проверка возвращена.
Проверка заодно поймала мои же фикстуры ленты: −11,5 % ниже нижней границы
полосы (−5 %), то есть «маленькое отклонение» ещё не значит «в полосе».
Значения заменены на внутриполосные.
Co-Authored-By: Claude Opus 5
---
.../__tests__/landing-v3-render.test.tsx | 67 +++++++++++++++++--
.../mera-public/_components/v3/AccuracyV3.tsx | 28 +++++++-
.../_components/v3/DealsTickerV3.tsx | 23 ++++++-
.../mera-public/_components/v3/deal-view.ts | 17 +++++
4 files changed, 126 insertions(+), 9 deletions(-)
diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx
index 1a476d84..892374e7 100644
--- a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-v3-render.test.tsx
@@ -132,6 +132,40 @@ describe("витрина лэндинга v3 без данных", () => {
expect(note.textContent).toContain(String(BACKTEST.priceError.sampleN));
});
+ /**
+ * УТВЕРЖДЕНИЕ ПРО ПОЛОСУ ПРОВЕРЯЕТ САМО СЕБЯ ПО ПОКАЗАННЫМ СТРОКАМ.
+ *
+ * Границы стоят в коде фронта, строки приходят из БД от последнего прогона
+ * задачи, а задача в расписании не стоит. Между выкатом фронта и пересчётом
+ * витрины на странице лежат СТАРЫЕ строки: на проде 12 из 20 вне полосы,
+ * худшая +75,71 %. Утверждение «показана полоса −5…+20 %» и строка +75,7 %
+ * под ним — хуже, чем отсутствие утверждения.
+ *
+ * Здесь ровно этот набор: одна строка вне полосы (значение с прода) — и про
+ * полосу не утверждается ничего. Ломать так: сделать `allWithinBand`
+ * всегда-true — тест покраснеет ПО ЗНАЧЕНИЮ, найдя «отобранная полоса» над
+ * строкой, которая в неё не входит.
+ */
+ it("«Точность»: строка вне полосы снимает утверждение про полосу, медиана сверки остаётся", () => {
+ const outOfBand: ShowcaseResponse = {
+ ...SHOWCASE,
+ deals: [
+ SHOWCASE.deals[0],
+ { ...SHOWCASE.deals[0], err_pct: 75.71 },
+ ],
+ };
+ render();
+ const note = screen.getByText(/Разброс показанных строк/u);
+ expect(
+ note.textContent,
+ "подпись обещает полосу, а в таблице под ней строка вне полосы",
+ ).not.toContain("отобранная полоса расхождения");
+ expect(note.textContent).toMatch(/есть расхождения вне неё/u);
+ // Медиана по всей сверке остаётся в обеих ветках — она и удерживает
+ // страницу честной, независимо от того, пересчитана витрина или нет.
+ expect(note.textContent).toContain(BACKTEST.priceError.text);
+ });
+
it("«Точность»: пустой /stats снимает плитки и таблицу, а не обнуляет их", () => {
const { container } = render();
expect(screen.queryByText("расчётов сделано")).toBeNull();
@@ -202,24 +236,47 @@ describe("лента сделок: контекст разброса", () => {
predicted_rub: Math.round(SHOWCASE.deals[0].fact_rub * (1 + err_pct / 100)),
});
+ // Значения ВНУТРИ полосы: −11,5 %, стоявшее здесь раньше, ниже её нижней
+ // границы (−5 %), и лента о такой ленте полосу не утверждает — проверку
+ // ниже это роняло по делу. Полоса несимметрична, и «маленькое отклонение»
+ // ещё не значит «в полосе».
it("подпись печатает медиану и худшую ровно тех строк, что показаны", () => {
- render();
+ render();
const note = screen.getByText(/Медиана показанных строк/u);
- expect(note.textContent).toContain("11,5 %");
+ expect(note.textContent).toContain("3,4 %");
expect(note.textContent).toContain("19,3 %");
});
it("та же лента без худшей строки печатает ДРУГИЕ числа — они не константы", () => {
- render();
+ render();
const note = screen.getByText(/Медиана показанных строк/u);
- expect(note.textContent).toContain("7,8 %");
+ expect(note.textContent).toContain("2,3 %");
expect(note.textContent).not.toContain("19,3 %");
});
it("подпись называет полосу — иначе разброс ленты читается как точность расчёта", () => {
- render();
+ render();
const note = screen.getByText(/Медиана показанных строк/u);
expect(note.textContent).toMatch(/от -5 % до \+20 %/u);
expect(note.textContent).toContain("не вся сверка");
});
+
+ /**
+ * Та же лента с ОДНОЙ строкой вне полосы (значение с прода) — про полосу не
+ * утверждается ничего, остаются только посчитанные по строкам числа. Это и
+ * есть окно «фронт выкачен, витрина не пересчитана»: лента прокручивает
+ * +75,7 % и не имеет права обещать над ним −5…+20 %.
+ *
+ * Ломать так: сделать `allWithinBand` всегда-true — тест покраснеет ПО
+ * ЗНАЧЕНИЮ на обещании полосы рядом с 75,7 %.
+ */
+ it("строка вне полосы снимает утверждение про полосу, числа остаются", () => {
+ render();
+ const note = screen.getByText(/Медиана расхождения показанных строк/u);
+ expect(
+ note.textContent,
+ "лента обещает полосу, а сама прокручивает строку вне неё",
+ ).not.toMatch(/от -5 % до \+20 %/u);
+ expect(note.textContent).toContain("75,7 %");
+ });
});
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx
index 54db3f3f..9cf2a043 100644
--- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx
@@ -29,6 +29,15 @@
* строк по построению лучше работы расчёта, и без второго числа страница
* обещала бы точность, которой никто не мерил.
*
+ * УТВЕРЖДЕНИЕ ПРО ПОЛОСУ САМОПРОВЕРЯЕМОЕ. Границы стоят в коде фронта, строки
+ * приходят из БД от последнего прогона задачи, а задача в расписании не стоит
+ * — её запускают руками. В окне «фронт выкачен, витрина не пересчитана»
+ * страница утверждала бы полосу над строками прежнего правила, а под
+ * утверждением стояла бы строка +75,7 % (на проде сейчас 12 таких из 20).
+ * Поэтому обе подписи спрашивают сами строки (`allWithinBand` в `deal-view`):
+ * не соответствуют — про полосу не говорим, называем то, что есть, а правило
+ * прогона и так печатается рядом и приезжает из ТОГО ЖЕ прогона, что строки.
+ *
* ПЛИТКИ БЭКТЕСТА ИДУТ КОМПЛЕКТОМ И ПОРОЗНЬ НЕ ПОКАЗЫВАЮТСЯ. «88 % в
* коридоре» без ширины коридора (±37 %) и без разброса пересборок — это
* разные способы выглядеть точнее, чем есть. Поэтому ширина коридора стоит в
@@ -70,6 +79,7 @@ import styles from "../../landing-v3.module.css";
import {
absPct,
+ allWithinBand,
BAND_MAX_PCT,
BAND_MIN_PCT,
count,
@@ -267,6 +277,18 @@ export function AccuracyV3({
: "Подпись прогона не пришла — из чего отобраны строки, сказать нечем."}
{spread && (
+ // УТВЕРЖДЕНИЕ ПРО ПОЛОСУ — САМОПРОВЕРЯЕМОЕ. Границы стоят в коде
+ // фронта, а строки приходят из БД, из последнего прогона задачи;
+ // задача в расписании не стоит и запускается руками. Между
+ // выкатом и пересчётом страница утверждала бы полосу над
+ // строками, собранными до неё (на проде сейчас 12 из 20 вне
+ // полосы, худшая +75,7 %) — утверждение и его опровержение в
+ // одном экране. Поэтому про полосу говорим, только если ни одна
+ // показанная строка этому не противоречит, а иначе называем то,
+ // что есть: строки собраны прогоном с другим правилом, и это
+ // правило напечатано выше — оно приезжает из ТОГО ЖЕ прогона,
+ // что и строки, поэтому разойтись с ними не может.
+ //
// «В ПРЕДЕЛАХ 20 % — N ИЗ N» ОТСЮДА СНЯТО, И ЭТО НЕ СОКРАЩЕНИЕ.
// Полоса витрины — от −5 % до +20 %, значит |отклонение| ≤ 20 у
// КАЖДОЙ показанной строки по построению фильтра: счёт всегда
@@ -283,7 +305,11 @@ export function AccuracyV3({
// сторожит landing-v3-render («подпись витрины называет полосу и
// держит рядом медиану по всей сверке»).
- {`Разброс показанных строк: медианное расхождение ${absPct(spread.medianAbsPct)} по ${spread.n} строкам, худшая ${absPct(spread.worstAbsPct)}. Это отобранная полоса расхождения от ${errPct(BAND_MIN_PCT)} до ${errPct(BAND_MAX_PCT)}, а не вся сверка: промахи крупнее полосы в данных есть, здесь их не видно. Медиана по всей сверке — ${BACKTEST.priceError.text} (${count(BACKTEST.priceError.sampleN)} сделок).`}
+ {`Разброс показанных строк: медианное расхождение ${absPct(spread.medianAbsPct)} по ${spread.n} строкам, худшая ${absPct(spread.worstAbsPct)}. ${
+ allWithinBand(deals)
+ ? `Это отобранная полоса расхождения от ${errPct(BAND_MIN_PCT)} до ${errPct(BAND_MAX_PCT)}, а не вся сверка: промахи крупнее полосы в данных есть, здесь их не видно.`
+ : `Полосу расхождения от ${errPct(BAND_MIN_PCT)} до ${errPct(BAND_MAX_PCT)} эта подпись не обещает: среди показанных строк есть расхождения вне неё, то есть витрину собрал прогон с другим правилом — тем, что напечатано выше.`
+ } Медиана по всей сверке — ${BACKTEST.priceError.text} (${count(BACKTEST.priceError.sampleN)} сделок).`}
)}
{deals[0].note}
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/DealsTickerV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/DealsTickerV3.tsx
index 2ee33a49..657dd8fb 100644
--- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/DealsTickerV3.tsx
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/DealsTickerV3.tsx
@@ -16,8 +16,16 @@
* пишет только сделки с расхождением от −5 % до +20 % (`select_rows`/`BAND_*`
* в app/tasks/landing_showcase_deals.py), внутри полосы порядок задают полнота
* и свежесть. Лента берёт ТЕ ЖЕ строки, поэтому её содержимое отобрано ровно
- * так же — и «худшая» в подписи ниже теперь ограничена полосой сверху, а не
- * данными.
+ * так же.
+ *
+ * НО УТВЕРЖДАЕМ МЫ ЭТО НЕ НА ВЕРУ. Границы живут в коде фронта, а строки
+ * приезжают из БД от ПОСЛЕДНЕГО прогона задачи — и задача в расписании не
+ * стоит, её запускают руками. Значит между выкатом фронта и пересчётом
+ * витрины лента утверждала бы полосу над строками, собранными по прежнему
+ * правилу (на проде сейчас 12 из 20 таких, худшая +75,7 %), и тут же
+ * прокручивала бы опровержение мимо собственной подписи. Поэтому подпись
+ * спрашивает сами строки (`allWithinBand`) и говорит про полосу, только если
+ * ни одна из них этому не противоречит.
*
* ПОД лентой — подпись с разбросом ПОКАЗАННЫХ строк (`shownSpread`, та же
* функция, что и под таблицей сверок): медиана модуля и худшая. До полосы она
@@ -44,6 +52,7 @@ import styles from "../../landing-v3.module.css";
import {
absPct,
+ allWithinBand,
BAND_MAX_PCT,
BAND_MIN_PCT,
dealTitle,
@@ -85,8 +94,16 @@ export function DealsTickerV3({ deals }: { deals: readonly ShowcaseDeal[] }) {
{spread && (
+ // Про полосу говорим, только если ЛЕНТА ей соответствует. Границы
+ // стоят в коде, строки приходят из БД от последнего прогона задачи, а
+ // задача в расписании не стоит: между выкатом и пересчётом лента
+ // утверждала бы полосу над строками, собранными до неё, — и тут же
+ // прокручивала +75,7 % мимо этого утверждения. Не соответствует —
+ // печатаем только то, что посчитано по самим строкам.
- {`Показаны сделки с расхождением от ${errPct(BAND_MIN_PCT)} до ${errPct(BAND_MAX_PCT)} — отобранная полоса, не вся сверка. Медиана показанных строк — ${absPct(spread.medianAbsPct)}, худшая — ${absPct(spread.worstAbsPct)}`}
+ {allWithinBand(deals)
+ ? `Показаны сделки с расхождением от ${errPct(BAND_MIN_PCT)} до ${errPct(BAND_MAX_PCT)} — отобранная полоса, не вся сверка. Медиана показанных строк — ${absPct(spread.medianAbsPct)}, худшая — ${absPct(spread.worstAbsPct)}`
+ : `Медиана расхождения показанных строк — ${absPct(spread.medianAbsPct)}, худшая — ${absPct(spread.worstAbsPct)}`}
)}
diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts b/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts
index 61653657..5bd0b185 100644
--- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts
+++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/deal-view.ts
@@ -206,6 +206,23 @@ export const WITHIN_PCT = 20;
export const BAND_MIN_PCT = -5;
export const BAND_MAX_PCT = 20;
+/**
+ * Можно ли про ЭТИ строки утверждать, что они — полоса.
+ *
+ * Подпись про полосу собирается из констант, а строки приезжают из БД, из
+ * последнего прогона задачи. Прогон в расписании не стоит и запускается
+ * руками, поэтому между выкатом фронта и пересчётом витрины страница
+ * утверждала бы полосу над строками, собранными ДО неё: на проде 12 из 20
+ * лежащих сейчас строк вне полосы, включая +75,7 % — утверждение и его
+ * опровержение в одном экране.
+ *
+ * Поэтому утверждение самопроверяемое: печатается, только если ни одна
+ * показанная строка ему не противоречит. Проверка идёт по тем же границам,
+ * что стоят в тексте, — соврать, не покраснев, подпись не может.
+ */
+export const allWithinBand = (deals: readonly ShowcaseDeal[]): boolean =>
+ deals.every((d) => d.err_pct >= BAND_MIN_PCT && d.err_pct <= BAND_MAX_PCT);
+
export interface ShownSpread {
readonly n: number;
readonly medianAbsPct: number;
--
2.45.3