feat(mera/лендинг): витрина показывает полосу расхождения −5…+20 %; плитку «уверенность низкая» сменил замер 12.09 #3468
10 changed files with 715 additions and 139 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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 вместо строки.
|
||||
|
|
|
|||
|
|
@ -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 (назвать окно, которое реально",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import { describe, expect, it } from "vitest";
|
|||
import { AccuracyV3 } from "../_components/v3/AccuracyV3";
|
||||
import {
|
||||
BACKTEST,
|
||||
BACKTEST_BAND_MEASURED_LABEL,
|
||||
BACKTEST_MEASURED_LABEL,
|
||||
BACKTEST_PERIOD_LABEL,
|
||||
BACKTEST_POPULATION,
|
||||
BACKTEST_SHARE_LABEL,
|
||||
|
|
@ -44,22 +46,60 @@ describe("подписи окна бэктеста", () => {
|
|||
|
||||
it("уверенность посчитана на том же прогоне, что и остальные числа", () => {
|
||||
// «325 из 327» было из прогона 29.08 и под общей подписью «замер 31.08»
|
||||
// приписывало старому числу новую дату.
|
||||
// приписывало старому числу новую дату. Величина снята с витрины 12.09
|
||||
// (её плитку занял `within20`), но осталась в файле до решения владельца —
|
||||
// и свойство за ней сторожится: вернётся она только исправной.
|
||||
expect(BACKTEST.confidenceLow.text).not.toMatch(/327/u);
|
||||
});
|
||||
|
||||
/**
|
||||
* У свежей величины СВОЯ дата, и подпись блока про неё не годится: лид
|
||||
* называет замер 31.08, а `within20` приехал с прогона 12.09. Число под
|
||||
* чужой датой — ровно тот дефект, который здесь уже чинили для «325 из 327».
|
||||
*/
|
||||
it("свежая величина подписана своей датой, а не общей датой блока", () => {
|
||||
expect(BACKTEST.within20.source).toContain("12.09.2026");
|
||||
expect(BACKTEST_BAND_MEASURED_LABEL).toContain("12.09.2026");
|
||||
expect(BACKTEST_BAND_MEASURED_LABEL).not.toEqual(BACKTEST_MEASURED_LABEL);
|
||||
});
|
||||
|
||||
/**
|
||||
* На странице оказались два числа разных дат, и свежее — ХУЖЕ прежнего
|
||||
* (медианное расхождение 19,1 % против 15,3 %). Молчать об этом нельзя:
|
||||
* читатель сложит из двух дат улучшение, которого замер не показывал.
|
||||
*/
|
||||
it("оговорки свежей величины признают, что новый прогон вышел хуже прежнего", () => {
|
||||
const text = BACKTEST.within20.caveats.join(" ");
|
||||
expect(text, "не сказано, какое расхождение дал тот же свежий прогон").toContain("19,1 %");
|
||||
expect(text, "не сказано, с каким прежним числом оно расходится").toContain(
|
||||
BACKTEST.priceError.text,
|
||||
);
|
||||
expect(text, "разброс пересборок не назван — точечное число обещает точность").toContain(
|
||||
"46,2-56,6 %",
|
||||
);
|
||||
expect(text.toLowerCase(), "не сказано, что замер не point-in-time").toContain(
|
||||
"point-in-time",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("оговорки в интерфейсе", () => {
|
||||
it("на экране есть оговорки ВСЕХ ТРЁХ величин блока", () => {
|
||||
// Матчер нормализует неразрывный пробел так же, как это делает DOM: в
|
||||
// оговорках перед «%» стоит U+00A0, а `queryByText` сравнивается уже с
|
||||
// нормализованным текстом. Без этого тест краснеет на типографике, а не на
|
||||
// пропавшей со страницы оговорке — то есть перестаёт ловить своё.
|
||||
const shown = (line: string): string => line.replace(/\u00a0/gu, " ");
|
||||
|
||||
it("на экране есть оговорки ВСЕХ ТРЁХ показанных величин блока", () => {
|
||||
render(<AccuracyV3 stats={{}} showcase={null} />);
|
||||
for (const набор of [
|
||||
BACKTEST.priceError.caveats,
|
||||
BACKTEST.coverage.caveats,
|
||||
BACKTEST.confidenceLow.caveats,
|
||||
BACKTEST.within20.caveats,
|
||||
]) {
|
||||
for (const line of набор) {
|
||||
expect(
|
||||
screen.queryByText(line),
|
||||
screen.queryByText(shown(line)),
|
||||
`оговорка не выводится: ${line.slice(0, 48)}…`,
|
||||
).not.toBeNull();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,83 @@ const SHOWCASE: ShowcaseResponse = {
|
|||
describe("витрина лэндинга v3 без данных", () => {
|
||||
it("«Точность»: с данными показывает величины вместе с выборкой и коридором", () => {
|
||||
render(<AccuracyV3 stats={STATS} showcase={SHOWCASE} />);
|
||||
expect(screen.getByText(BACKTEST.priceError.text)).toBeTruthy();
|
||||
expect(screen.getByText(shown(BACKTEST.priceError.text))).toBeTruthy();
|
||||
expect(screen.getByText(/коридор шириной ±37 %/)).toBeTruthy();
|
||||
expect(screen.getByText(BACKTEST.confidenceLow.text)).toBeTruthy();
|
||||
expect(screen.getByText(shown(BACKTEST.within20.text))).toBeTruthy();
|
||||
expect(screen.getByText(/по 25 943 объявлениям/)).toBeTruthy();
|
||||
// Подпись витрины: сколько рассмотрено и по какому правилу отсеяно.
|
||||
// Подпись витрины: сколько рассмотрено и по какому правилу отобрано.
|
||||
expect(screen.getByText(/рассмотрено сделок: 4 000/)).toBeTruthy();
|
||||
});
|
||||
|
||||
/**
|
||||
* Снятая величина не должна остаться на экране «по инерции»: её плитку занял
|
||||
* свежий замер, и оговорка про уверенность без своей плитки объясняла бы
|
||||
* пустоту. Проверяется ПО ЗНАЧЕНИЮ — вернут плитку обратно, не убрав
|
||||
* `within20`, и тест покраснеет.
|
||||
*/
|
||||
it("«Точность»: снятая с витрины confidenceLow не рендерится ни значением, ни оговоркой", () => {
|
||||
render(<AccuracyV3 stats={STATS} showcase={SHOWCASE} />);
|
||||
expect(screen.queryByText(shown(BACKTEST.confidenceLow.text))).toBeNull();
|
||||
for (const line of BACKTEST.confidenceLow.caveats) {
|
||||
expect(screen.queryByText(line)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* ПОДПИСЬ ВИТРИНЫ НАЗЫВАЕТ ПОЛОСУ И ДЕРЖИТ РЯДОМ МЕДИАНУ ПО ВСЕЙ СВЕРКЕ.
|
||||
*
|
||||
* Строки отобраны фильтром −5 %..+20 % (`select_rows` в
|
||||
* app/tasks/landing_showcase_deals.py), поэтому их разброс по построению
|
||||
* выглядит лучше работы расчёта. Две вещи удерживают страницу от вранья:
|
||||
* подпись говорит, что это отбор, и печатает медиану ВСЕЙ сверки. Убрать
|
||||
* любую из них — и остаётся благополучная двадцатка без контекста.
|
||||
*/
|
||||
it("«Точность»: подпись под таблицей называет полосу и печатает медиану всей сверки", () => {
|
||||
render(<AccuracyV3 stats={STATS} showcase={SHOWCASE} />);
|
||||
const note = screen.getByText(/Разброс показанных строк/u);
|
||||
expect(note.textContent).toMatch(/отобранная полоса расхождения от -5 % до \+20 %/u);
|
||||
expect(note.textContent).toContain("не вся сверка");
|
||||
expect(
|
||||
note.textContent,
|
||||
"из подписи пропала медиана по всей сверке — остался разброс отобранной полосы без контекста",
|
||||
).toContain(BACKTEST.priceError.text);
|
||||
expect(note.textContent).toContain(String(BACKTEST.priceError.sampleN));
|
||||
});
|
||||
|
||||
/**
|
||||
* УТВЕРЖДЕНИЕ ПРО ПОЛОСУ ПРОВЕРЯЕТ САМО СЕБЯ ПО ПОКАЗАННЫМ СТРОКАМ.
|
||||
*
|
||||
* Границы стоят в коде фронта, строки приходят из БД от последнего прогона
|
||||
* задачи, а задача в расписании не стоит. Между выкатом фронта и пересчётом
|
||||
* витрины на странице лежат СТАРЫЕ строки: на проде 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(<AccuracyV3 stats={STATS} showcase={outOfBand} />);
|
||||
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(<AccuracyV3 stats={{}} showcase={null} />);
|
||||
expect(screen.queryByText("расчётов сделано")).toBeNull();
|
||||
|
|
@ -93,7 +173,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 +220,14 @@ describe("витрина лэндинга v3 без данных", () => {
|
|||
});
|
||||
|
||||
/**
|
||||
* Лента висит НАД первым экраном, и её строки отобраны по полноте и свежести,
|
||||
* а не по величине ошибки, — крупный промах в ней штатен. Проверяется, что
|
||||
* рядом с промахом стоит его контекст и что оба числа сосчитаны ПО ПОКАЗАННЫМ
|
||||
* строкам: подпись с вписанными руками величинами тут же разошлась бы с лентой.
|
||||
* Лента висит НАД первым экраном и берёт те же строки витрины, то есть с
|
||||
* 12.09.2026 — ОТОБРАННУЮ полосу −5 %..+20 %. Значения в фикстурах поэтому
|
||||
* внутри полосы: строка с +75,7 % в ленту больше физически не попадает, и
|
||||
* тест на ней проверял бы состояние, которого продюсер не создаёт.
|
||||
*
|
||||
* Проверяется две вещи: подпись называет полосу (иначе благополучный разброс
|
||||
* читается как точность расчёта) и оба числа сосчитаны ПО ПОКАЗАННЫМ строкам —
|
||||
* подпись с вписанными руками величинами тут же разошлась бы с лентой.
|
||||
*/
|
||||
describe("лента сделок: контекст разброса", () => {
|
||||
const tickerDeal = (err_pct: number) => ({
|
||||
|
|
@ -152,17 +236,47 @@ describe("лента сделок: контекст разброса", () => {
|
|||
predicted_rub: Math.round(SHOWCASE.deals[0].fact_rub * (1 + err_pct / 100)),
|
||||
});
|
||||
|
||||
// Значения ВНУТРИ полосы: −11,5 %, стоявшее здесь раньше, ниже её нижней
|
||||
// границы (−5 %), и лента о такой ленте полосу не утверждает — проверку
|
||||
// ниже это роняло по делу. Полоса несимметрична, и «маленькое отклонение»
|
||||
// ещё не значит «в полосе».
|
||||
it("подпись печатает медиану и худшую ровно тех строк, что показаны", () => {
|
||||
render(<DealsTickerV3 deals={[tickerDeal(4), tickerDeal(-11.5), tickerDeal(75.7)]} />);
|
||||
const note = screen.getByText(/Медиана расхождения показанных строк/u);
|
||||
expect(note.textContent).toContain("11,5 %");
|
||||
expect(note.textContent).toContain("75,7 %");
|
||||
render(<DealsTickerV3 deals={[tickerDeal(1.2), tickerDeal(-3.4), tickerDeal(19.3)]} />);
|
||||
const note = screen.getByText(/Медиана показанных строк/u);
|
||||
expect(note.textContent).toContain("3,4 %");
|
||||
expect(note.textContent).toContain("19,3 %");
|
||||
});
|
||||
|
||||
it("та же лента без худшей строки печатает ДРУГИЕ числа — они не константы", () => {
|
||||
render(<DealsTickerV3 deals={[tickerDeal(4), tickerDeal(-11.5)]} />);
|
||||
render(<DealsTickerV3 deals={[tickerDeal(1.2), tickerDeal(-3.4)]} />);
|
||||
const note = screen.getByText(/Медиана показанных строк/u);
|
||||
expect(note.textContent).toContain("2,3 %");
|
||||
expect(note.textContent).not.toContain("19,3 %");
|
||||
});
|
||||
|
||||
it("подпись называет полосу — иначе разброс ленты читается как точность расчёта", () => {
|
||||
render(<DealsTickerV3 deals={[tickerDeal(1.2), tickerDeal(-3.4)]} />);
|
||||
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(<DealsTickerV3 deals={[tickerDeal(4), tickerDeal(75.71)]} />);
|
||||
const note = screen.getByText(/Медиана расхождения показанных строк/u);
|
||||
expect(note.textContent).toContain("7,8 %");
|
||||
expect(note.textContent).not.toContain("75,7 %");
|
||||
expect(
|
||||
note.textContent,
|
||||
"лента обещает полосу, а сама прокручивает строку вне неё",
|
||||
).not.toMatch(/от -5 % до \+20 %/u);
|
||||
expect(note.textContent).toContain("75,7 %");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,19 +13,42 @@
|
|||
* и не пересчитываются ночной задачей; без даты они стареют молча. Дату видит
|
||||
* читатель, а срок годности сторожит `__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`,
|
||||
* оттуда же текст правила). Поэтому подпись под таблицей говорит про полосу
|
||||
* прямо и держит рядом медиану по ВСЕЙ сверке: разброс двадцати отобранных
|
||||
* строк по построению лучше работы расчёта, и без второго числа страница
|
||||
* обещала бы точность, которой никто не мерил.
|
||||
*
|
||||
* УТВЕРЖДЕНИЕ ПРО ПОЛОСУ САМОПРОВЕРЯЕМОЕ. Границы стоят в коде фронта, строки
|
||||
* приходят из БД от последнего прогона задачи, а задача в расписании не стоит
|
||||
* — её запускают руками. В окне «фронт выкачен, витрина не пересчитана»
|
||||
* страница утверждала бы полосу над строками прежнего правила, а под
|
||||
* утверждением стояла бы строка +75,7 % (на проде сейчас 12 таких из 20).
|
||||
* Поэтому обе подписи спрашивают сами строки (`allWithinBand` в `deal-view`):
|
||||
* не соответствуют — про полосу не говорим, называем то, что есть, а правило
|
||||
* прогона и так печатается рядом и приезжает из ТОГО ЖЕ прогона, что строки.
|
||||
*
|
||||
* ПЛИТКИ БЭКТЕСТА ИДУТ КОМПЛЕКТОМ И ПОРОЗНЬ НЕ ПОКАЗЫВАЮТСЯ. «88 % в
|
||||
* коридоре» без ширины коридора (±37 %) и без разброса пересборок — это
|
||||
* разные способы выглядеть точнее, чем есть. Поэтому ширина коридора стоит в
|
||||
* ПОДПИСИ к самой плитке, а не отдельной строкой мелким шрифтом, и оговорки
|
||||
* рендерятся тут же, под сеткой.
|
||||
*
|
||||
* У ТРЕТЬЕЙ ПЛИТКИ СВОЯ ДАТА. Она приехала из прогона 12.09.2026, а лид блока
|
||||
* называет замер 31.08 — поэтому дата стоит в подписи самой плитки, и одна из
|
||||
* её оговорок прямо говорит, что медианное расхождение свежего прогона (19,1 %)
|
||||
* ХУЖЕ прежнего (15,3 %). Два числа разных дат без этой строки читаются как
|
||||
* улучшение, которого не было.
|
||||
*
|
||||
* ЧЕГО ЗДЕСЬ НЕТ. Плитки «точность по сроку продажи»: `deals.days_on_market`
|
||||
* заполнен 0 раз из 108 623 — сверять прогноз срока не с чем. На её месте
|
||||
|
|
@ -44,23 +67,27 @@
|
|||
|
||||
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,
|
||||
allWithinBand,
|
||||
BAND_MAX_PCT,
|
||||
BAND_MIN_PCT,
|
||||
count,
|
||||
dealMeta,
|
||||
dealTitle,
|
||||
errPct,
|
||||
rub,
|
||||
shownSpread,
|
||||
WITHIN_PCT,
|
||||
} from "./deal-view";
|
||||
|
||||
interface Tile {
|
||||
|
|
@ -99,29 +126,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 +263,53 @@ export function AccuracyV3({
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/*
|
||||
«Годных» здесь больше нет намеренно. `eligible` — это строки,
|
||||
которые прогон СОБРАЛ (данных хватило), и с появлением полосы
|
||||
разница `eligible − written` перестала означать «столько не
|
||||
поместилось»: в неё входят и отсеянные полосой. Правило отбора
|
||||
(`rejection_rule`) приходит из того же прогона и стоит в этой же
|
||||
подписи — счётчики и правило порознь не показываются.
|
||||
*/}
|
||||
<p className={styles.accFootnote}>
|
||||
{showcaseStats
|
||||
? `Показано ${count(showcaseStats.written)} строк из ${count(showcaseStats.eligible)} годных, рассмотрено сделок: ${count(showcaseStats.considered)}. Район известен у ${count(showcaseStats.with_district)} из показанных. ${showcaseStats.rejection_rule}`
|
||||
? `Показано ${count(showcaseStats.written)} строк из ${count(showcaseStats.eligible)} собранных прогоном, рассмотрено сделок: ${count(showcaseStats.considered)}. Район известен у ${count(showcaseStats.with_district)} из показанных. ${showcaseStats.rejection_rule}`
|
||||
: "Подпись прогона не пришла — из чего отобраны строки, сказать нечем."}
|
||||
</p>
|
||||
{spread && (
|
||||
// УТВЕРЖДЕНИЕ ПРО ПОЛОСУ — САМОПРОВЕРЯЕМОЕ. Границы стоят в коде
|
||||
// фронта, а строки приходят из БД, из последнего прогона задачи;
|
||||
// задача в расписании не стоит и запускается руками. Между
|
||||
// выкатом и пересчётом страница утверждала бы полосу над
|
||||
// строками, собранными до неё (на проде сейчас 12 из 20 вне
|
||||
// полосы, худшая +75,7 %) — утверждение и его опровержение в
|
||||
// одном экране. Поэтому про полосу говорим, только если ни одна
|
||||
// показанная строка этому не противоречит, а иначе называем то,
|
||||
// что есть: строки собраны прогоном с другим правилом, и это
|
||||
// правило напечатано выше — оно приезжает из ТОГО ЖЕ прогона,
|
||||
// что и строки, поэтому разойтись с ними не может.
|
||||
//
|
||||
// «В ПРЕДЕЛАХ 20 % — N ИЗ N» ОТСЮДА СНЯТО, И ЭТО НЕ СОКРАЩЕНИЕ.
|
||||
// Полоса витрины — от −5 % до +20 %, значит |отклонение| ≤ 20 у
|
||||
// КАЖДОЙ показанной строки по построению фильтра: счёт всегда
|
||||
// выходил бы «8 из 8» и читался бы как замер попадания, которым
|
||||
// не является. Неработающая проверка читается как работающая —
|
||||
// то же правило, по которому из продюсера витрины убрали мёртвый
|
||||
// порог MAX_FACT_PPM2. `spread.within` считается по-прежнему
|
||||
// (shownSpread трогать не просили) и ждёт, пока полосу подвинут.
|
||||
//
|
||||
// МЕДИАНА ПО ВСЕЙ СВЕРКЕ ОСТАЁТСЯ ЗДЕСЬ ПРИ ЛЮБОЙ ПРАВКЕ ПОДПИСИ.
|
||||
// Разброс слева посчитан по ОТОБРАННОЙ полосе и по построению
|
||||
// выглядит лучше, чем работа расчёта: без второго числа рядом
|
||||
// страница обещала бы точность, которой никто не мерил. Это
|
||||
// сторожит landing-v3-render («подпись витрины называет полосу и
|
||||
// держит рядом медиану по всей сверке»).
|
||||
<p className={styles.accFootnote}>
|
||||
{`Разброс показанных строк: медианное расхождение ${absPct(spread.medianAbsPct)}, в пределах ${WITHIN_PCT} % — ${spread.within} из ${spread.n}, худшая ${absPct(spread.worstAbsPct)}. Медиана по всей сверке — ${BACKTEST.priceError.text} (${count(BACKTEST.priceError.sampleN)} сделок). Строки отобраны по полноте и свежести, не по величине ошибки.`}
|
||||
{`Разброс показанных строк: медианное расхождение ${absPct(spread.medianAbsPct)} по ${spread.n} строкам, худшая ${absPct(spread.worstAbsPct)}. ${
|
||||
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)} сделок).`}
|
||||
</p>
|
||||
)}
|
||||
<p className={styles.accFootnote}>{deals[0].note}</p>
|
||||
|
|
|
|||
|
|
@ -12,14 +12,31 @@
|
|||
* не из чего. Адреса тоже нет: улица и дом известны у 2.7% сделок, поэтому
|
||||
* объект описан тем, что есть — комнаты, площадь, район, этаж.
|
||||
*
|
||||
* СТРОКИ — ОТОБРАННАЯ ПОЛОСА, А НЕ ВСЯ СВЕРКА. С 12.09.2026 продюсер витрины
|
||||
* пишет только сделки с расхождением от −5 % до +20 % (`select_rows`/`BAND_*`
|
||||
* в app/tasks/landing_showcase_deals.py), внутри полосы порядок задают полнота
|
||||
* и свежесть. Лента берёт ТЕ ЖЕ строки, поэтому её содержимое отобрано ровно
|
||||
* так же.
|
||||
*
|
||||
* НО УТВЕРЖДАЕМ МЫ ЭТО НЕ НА ВЕРУ. Границы живут в коде фронта, а строки
|
||||
* приезжают из БД от ПОСЛЕДНЕГО прогона задачи — и задача в расписании не
|
||||
* стоит, её запускают руками. Значит между выкатом фронта и пересчётом
|
||||
* витрины лента утверждала бы полосу над строками, собранными по прежнему
|
||||
* правилу (на проде сейчас 12 из 20 таких, худшая +75,7 %), и тут же
|
||||
* прокручивала бы опровержение мимо собственной подписи. Поэтому подпись
|
||||
* спрашивает сами строки (`allWithinBand`) и говорит про полосу, только если
|
||||
* ни одна из них этому не противоречит.
|
||||
*
|
||||
* ПОД лентой — подпись с разбросом ПОКАЗАННЫХ строк (`shownSpread`, та же
|
||||
* функция, что и под таблицей сверок): медиана модуля и худшая. Без неё первое,
|
||||
* что видит посетитель страницы про точность, — промах в семьдесят процентов
|
||||
* без единой цифры контекста, опровергающий её же заголовок. Прятать промахи
|
||||
* нельзя: строки отобраны по ПОЛНОТЕ и СВЕЖЕСТИ (`_sort_key` в
|
||||
* app/tasks/landing_showcase_deals.py), поэтому лечится контекстом, а не
|
||||
* отбором. Вывода («зато обычно точно») в подписи нет намеренно: он протух бы
|
||||
* на первом же пересчёте витрины, а два числа рядом не протухают.
|
||||
* функция, что и под таблицей сверок): медиана модуля и худшая. До полосы она
|
||||
* спасала от другого: первым, что посетитель видел про точность, был промах в
|
||||
* семьдесят процентов без единой цифры контекста. Теперь её работа обратная —
|
||||
* не дать прочитать благополучный разброс полосы как точность расчёта. Поэтому
|
||||
* полоса названа В ТОЙ ЖЕ подписи, перед числами, а не только под таблицей
|
||||
* этажом ниже: лента висит НАД первым экраном, и её числа читают раньше любых
|
||||
* оговорок блока «Точность». Вывода («зато обычно точно») в подписи нет
|
||||
* намеренно: он протух бы на первом же пересчёте витрины, а числа рядом не
|
||||
* протухают.
|
||||
*
|
||||
* Список дублируется дважды подряд — стандартный приём бесшовного CSS-marquee
|
||||
* (анимация уводит ровно на −50%).
|
||||
|
|
@ -33,7 +50,16 @@
|
|||
import type { ShowcaseDeal } from "../../public-api";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
import { absPct, dealTitle, errPct, rub, shownSpread } from "./deal-view";
|
||||
import {
|
||||
absPct,
|
||||
allWithinBand,
|
||||
BAND_MAX_PCT,
|
||||
BAND_MIN_PCT,
|
||||
dealTitle,
|
||||
errPct,
|
||||
rub,
|
||||
shownSpread,
|
||||
} from "./deal-view";
|
||||
|
||||
export function DealsTickerV3({ deals }: { deals: readonly ShowcaseDeal[] }) {
|
||||
const items = [...deals, ...deals];
|
||||
|
|
@ -68,8 +94,16 @@ export function DealsTickerV3({ deals }: { deals: readonly ShowcaseDeal[] }) {
|
|||
</div>
|
||||
</div>
|
||||
{spread && (
|
||||
// Про полосу говорим, только если ЛЕНТА ей соответствует. Границы
|
||||
// стоят в коде, строки приходят из БД от последнего прогона задачи, а
|
||||
// задача в расписании не стоит: между выкатом и пересчётом лента
|
||||
// утверждала бы полосу над строками, собранными до неё, — и тут же
|
||||
// прокручивала +75,7 % мимо этого утверждения. Не соответствует —
|
||||
// печатаем только то, что посчитано по самим строкам.
|
||||
<p className={styles.tickerNote}>
|
||||
{`Медиана расхождения показанных строк — ${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)}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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,38 @@ 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;
|
||||
|
||||
/**
|
||||
* Можно ли про ЭТИ строки утверждать, что они — полоса.
|
||||
*
|
||||
* Подпись про полосу собирается из констант, а строки приезжают из БД, из
|
||||
* последнего прогона задачи. Прогон в расписании не стоит и запускается
|
||||
* руками, поэтому между выкатом фронта и пересчётом витрины страница
|
||||
* утверждала бы полосу над строками, собранными ДО неё: на проде 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;
|
||||
|
|
|
|||
|
|
@ -69,13 +69,26 @@ const BACKTEST_CONFIDENCE_SOURCE =
|
|||
"Росреестра по Екатеринбургу, сделки II квартала 2026 года";
|
||||
|
||||
/**
|
||||
* Сверка «прогноз → цена ДКП». Три величины идут КОМПЛЕКТОМ и показываются
|
||||
* вместе: попадание в коридор без ширины коридора и без уровня уверенности
|
||||
* Источник `within20` — СВЕЖИЙ прогон 12.09.2026, отдельной строкой по той же
|
||||
* причине, что и предыдущий: у него своя дата и свой размер выборки (290
|
||||
* сделок против 325 у головных чисел), и приписывать ему подпись «замер 31.08»
|
||||
* значило бы повторить дефект, из-за которого эту строку и завели.
|
||||
*/
|
||||
const BACKTEST_BAND_SOURCE =
|
||||
"Бэктест на боевой базе (12.09.2026, engine=full): прогноз МЕРЫ против цены " +
|
||||
"ДКП Росреестра по Екатеринбургу, представительная выборка (разнесение по " +
|
||||
"адресам), медиана трёх пересборок с солями 11/22/33";
|
||||
|
||||
/**
|
||||
* Сверка «прогноз → цена ДКП». Величины идут КОМПЛЕКТОМ и показываются
|
||||
* вместе: попадание в коридор без ширины коридора и без разброса пересборок
|
||||
* читается как точность, которой нет.
|
||||
*/
|
||||
export const BACKTEST: Readonly<Record<"priceError" | "coverage" | "confidenceLow", MeasuredValue>> = {
|
||||
export const BACKTEST: Readonly<
|
||||
Record<"priceError" | "coverage" | "confidenceLow" | "within20", MeasuredValue>
|
||||
> = {
|
||||
priceError: {
|
||||
text: "15,3 %",
|
||||
text: "15,3 %",
|
||||
sampleN: 325,
|
||||
source: BACKTEST_SOURCE,
|
||||
caveats: [
|
||||
|
|
@ -88,7 +101,7 @@ export const BACKTEST: Readonly<Record<"priceError" | "coverage" | "confidenceLo
|
|||
],
|
||||
},
|
||||
coverage: {
|
||||
text: "84,3 %",
|
||||
text: "84,3 %",
|
||||
sampleN: 325,
|
||||
source: BACKTEST_SOURCE,
|
||||
caveats: [
|
||||
|
|
@ -96,6 +109,23 @@ export const BACKTEST: Readonly<Record<"priceError" | "coverage" | "confidenceLo
|
|||
"коридор такой ширины накрывает почти любую сделку.",
|
||||
],
|
||||
},
|
||||
/**
|
||||
* СНЯТА С ВИТРИНЫ 12.09.2026, но не удалена — решением владельца её место в
|
||||
* блоке «Точность» занял `within20`.
|
||||
*
|
||||
* Почему снята: прогон 29.08 шёл по кластеризованной выборке (`ORDER BY id
|
||||
* DESC`, 20 строк на четыре улицы) — непредставительность этого прогона
|
||||
* признана в шапке файла и уже стоила нам завышенного покрытия 88 %.
|
||||
* Держать под ней плитку «400 из 400» значило показывать приговор,
|
||||
* посчитанный на выборке, которой мы сами не верим.
|
||||
*
|
||||
* Почему не удалена: пересчитать её на представительной выборке никто не
|
||||
* просил, а величина сама по себе не опровергнута — решение вернуть или
|
||||
* выбросить за владельцем. Пока запись не рендерится ни одним компонентом
|
||||
* (проверяется `landing-v3-render`), гейт `landing-numbers-gate` всё равно
|
||||
* требует от неё источник и оговорку — и это правильно: вернётся на витрину
|
||||
* она вместе с ними.
|
||||
*/
|
||||
confidenceLow: {
|
||||
// Тот же прогон, что и остальные числа блока: пересборка «mera» от
|
||||
// 31.08.2026. Раньше здесь стояло «325 из 327» из прогона 29.08 — под
|
||||
|
|
@ -113,10 +143,43 @@ export const BACKTEST: Readonly<Record<"priceError" | "coverage" | "confidenceLo
|
|||
"31.08.2026 по 60 случайным адресам его медиана — 29 %.",
|
||||
],
|
||||
},
|
||||
/**
|
||||
* Доля сделок, где расхождение прогноза с ценой ДКП уложилось в ±20 %.
|
||||
*
|
||||
* ЗАМЕР ОТ 12.09.2026, И ЭТО ДРУГАЯ ДАТА, ЧЕМ У ГОЛОВНЫХ ЧИСЕЛ. Оговорки
|
||||
* обязаны об этом сказать: в том же прогоне медианное расхождение вышло
|
||||
* 19,1 % — ХУЖЕ, чем 15,3 % из замера 31.08. Две величины меряют разное и
|
||||
* взяты в разные дни, но молчать о том, что свежий прогон дал результат
|
||||
* хуже прежнего, нельзя: на странице стоят оба числа, и без оговорки
|
||||
* читатель соберёт из них картину, которой замер не подтверждает.
|
||||
*/
|
||||
within20: {
|
||||
text: "52,7 %",
|
||||
sampleN: 290,
|
||||
source: BACKTEST_BAND_SOURCE,
|
||||
caveats: [
|
||||
"Замер НЕ point-in-time: прогноз считается по СЕГОДНЯШНИМ активным " +
|
||||
"объявлениям против ПРОШЛЫХ сделок, и дрейф рынка за период входит в " +
|
||||
"расхождение целиком.",
|
||||
"Разброс по трём пересборкам — 46,2-56,6 %. Точного значения без этого " +
|
||||
"диапазона не существует: медиана трёх прогонов — это оценка, а не " +
|
||||
"измеренная константа.",
|
||||
"Медианное расхождение в этом же прогоне — 19,1 %, то есть ВЫШЕ, чем " +
|
||||
"15,3 % из замера 31.08.2026. Величины меряют разное (доля попаданий " +
|
||||
"против середины распределения) и взяты в разные дни — сравнивать их " +
|
||||
"напрямую нельзя, но и умалчивать о том, что свежий прогон вышел " +
|
||||
"хуже прежнего, тоже.",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** Ширина окна попадания у `within20` — стоит в подписи плитки, не отдельно. */
|
||||
export const BACKTEST_WITHIN_LABEL = "±20 %";
|
||||
|
||||
/** Ширина коридора — та же сверка; стоит в подписи к `coverage`, не отдельной плиткой. */
|
||||
export const BACKTEST_CORRIDOR_LABEL = "±37 %";
|
||||
// Неразрывный пробел перед знаком процента: на узкой плитке обычный уводил
|
||||
// «%» на отдельную строку («коридор шириной ±37 / %», скрин 12.09).
|
||||
export const BACKTEST_CORRIDOR_LABEL = "±37 %";
|
||||
|
||||
/**
|
||||
* Окно сделок бэктеста. Окно РАСЧЁТОВ — другое, оно приходит из `/stats`.
|
||||
|
|
@ -201,10 +264,26 @@ export const BACKTEST_MEASURED_ON = "2026-08-31";
|
|||
/** Через сколько дней замер считается протухшим (см. тест свежести). */
|
||||
export const BACKTEST_MAX_AGE_DAYS = 100;
|
||||
|
||||
const [measuredY, measuredM, measuredD] = BACKTEST_MEASURED_ON.split("-");
|
||||
/**
|
||||
* Дата ВТОРОГО замера блока — прогона 12.09.2026, из которого взят `within20`.
|
||||
*
|
||||
* Отдельная константа, потому что дата у него своя, а сроку годности он
|
||||
* подчиняется тому же: `backtest-freshness` проверяет ОБЕ даты. Второе число
|
||||
* на странице, которое не сторожит никто, — это та же дыра, ради закрытия
|
||||
* которой сторож и заводился, просто под новой датой.
|
||||
*/
|
||||
export const BACKTEST_BAND_MEASURED_ON = "2026-09-12";
|
||||
|
||||
const label = (iso: string): string => {
|
||||
const [y, m, d] = iso.split("-");
|
||||
return `замер ${d}.${m}.${y}`;
|
||||
};
|
||||
|
||||
/** Человекочитаемая дата замера. Выводится из ISO — двух правок не требует. */
|
||||
export const BACKTEST_MEASURED_LABEL = `замер ${measuredD}.${measuredM}.${measuredY}`;
|
||||
export const BACKTEST_MEASURED_LABEL = label(BACKTEST_MEASURED_ON);
|
||||
|
||||
/** То же для свежего прогона: плитка `within20` подписана СВОЕЙ датой. */
|
||||
export const BACKTEST_BAND_MEASURED_LABEL = label(BACKTEST_BAND_MEASURED_ON);
|
||||
|
||||
/**
|
||||
* Бейдж первого шага И подпись под кнопкой формы (`FreeCheckCard.tsx`).
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue