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`). 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;