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(
{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