feat(mera/лендинг): витрина показывает полосу расхождения −5…+20 %; плитку «уверенность низкая» сменил замер 12.09 #3468
10 changed files with 715 additions and 139 deletions
|
|
@ -479,9 +479,15 @@ class ShowcaseStats(BaseModel):
|
||||||
|
|
||||||
Без этих чисел «20 отличных строк» неотличимо от «столько и было»:
|
Без этих чисел «20 отличных строк» неотличимо от «столько и было»:
|
||||||
посетитель не может отличить выборку из работы оценщика от её лучшего
|
посетитель не может отличить выборку из работы оценщика от её лучшего
|
||||||
хвоста. `eligible` минус `written` — сколько годных строк не поместилось
|
хвоста. `eligible` — сколько строк прогон СОБРАЛ (данных хватило),
|
||||||
в витрину; `rejection_rule` — по какому правилу отсеяно остальное,
|
`written` — сколько из них показано; `rejection_rule` — по какому правилу
|
||||||
записанное ТЕМ прогоном, который эти строки посчитал.
|
отобраны показанные, записанное ТЕМ прогоном, который их посчитал.
|
||||||
|
|
||||||
|
`eligible` минус `written` — НЕ «столько не поместилось»: с 2026-09-12
|
||||||
|
витрина показывает полосу расхождения −5 %..+20 %, и в разницу входят
|
||||||
|
строки, отсеянные полосой. Что это именно отбор, а не вся сверка, говорит
|
||||||
|
`rejection_rule` — поэтому счётчики и правило показываются вместе, одной
|
||||||
|
подписью, а не порознь.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
considered: int
|
considered: int
|
||||||
|
|
@ -550,7 +556,7 @@ def public_showcase(
|
||||||
нечего, и это ровно то, что фронт должен увидеть вместо выдуманных строк.
|
нечего, и это ровно то, что фронт должен увидеть вместо выдуманных строк.
|
||||||
|
|
||||||
Вместе со строками едет `stats` — сколько сделок рассмотрено, сколько
|
Вместе со строками едет `stats` — сколько сделок рассмотрено, сколько
|
||||||
годных строк не поместилось и по какому правилу отсеяно остальное. Числа
|
строк прогон собрал и по какому правилу из них отобраны показанные. Числа
|
||||||
считает пересчёт; без них витрина не имеет права подписаться честно.
|
считает пересчёт; без них витрина не имеет права подписаться честно.
|
||||||
"""
|
"""
|
||||||
run = db.execute(_SHOWCASE_RUN_SQL).mappings().first()
|
run = db.execute(_SHOWCASE_RUN_SQL).mappings().first()
|
||||||
|
|
|
||||||
|
|
@ -9,26 +9,36 @@
|
||||||
|
|
||||||
ПРАВИЛО ОТБОРА — ЯВНО И БЕЗ ПОДГОНКИ
|
ПРАВИЛО ОТБОРА — ЯВНО И БЕЗ ПОДГОНКИ
|
||||||
------------------------------------
|
------------------------------------
|
||||||
Отбираем N строк ключом::
|
Витрина показывает ПОЛОСУ РАСХОЖДЕНИЯ, а не всю сверку. С 2026-09-12 решением
|
||||||
|
владельца продукта на витрину попадают только сделки, у которых расхождение
|
||||||
|
прогноза с ценой ДКП лежит в пределах `BAND_MIN_ERR_PCT`..`BAND_MAX_ERR_PCT`
|
||||||
|
(−5 %..+20 % включительно). Оставшиеся `limit` строк ранжируются ключом::
|
||||||
|
|
||||||
(полнота данных ↓, свежесть квартала ↓, id сделки ↓)
|
(полнота данных ↓, свежесть квартала ↓, id сделки ↓)
|
||||||
|
|
||||||
Величина ошибки в ключе НЕ УЧАСТВУЕТ и участвовать не должна. Отбор по малой
|
ЭТО ОТБОР ПОКАЗАТЕЛЬНЫХ СТРОК, И НАЗЫВАТЬ ЕГО НАДО ТАК. До 2026-09-12 здесь
|
||||||
ошибке превращает витрину в рекламу: показанные 20 строк перестают быть
|
не было ни фильтра, ни слагаемого ошибки в ключе, и подпись витрины это прямо
|
||||||
выборкой из работы оценщика и становятся её лучшим хвостом, а посетитель
|
утверждала. Теперь утверждать это нельзя: строки с промахом крупнее полосы в
|
||||||
читает их как «вот так МЕРА обычно и попадает». Это тот самый случай, когда
|
данных есть (на проде 30.08.2026 из показанных двадцати вне полосы было
|
||||||
код формально работает, а продукт врёт. Проверяется тестом
|
двенадцать — от −27,9 % до +75,7 %), и они не показываются. Поэтому полоса
|
||||||
`test_landing_showcase_deals.py::test_selection_ignores_error_magnitude`.
|
названа в `REJECTION_RULE`, которое едет на фронт вместе со счётчиками
|
||||||
|
прогона, и в подписи под таблицей рядом с медианой расхождения ПО ВСЕЙ
|
||||||
|
СВЕРКЕ: два числа рядом не дают прочитать двадцать отобранных строк как
|
||||||
|
«вот так МЕРА обычно и попадает».
|
||||||
|
|
||||||
ФИЛЬТРА ПО ОШИБКЕ ТОЖЕ НЕТ — И ЭТО ТО ЖЕ САМОЕ ПРАВИЛО. До 2026-08-29 здесь
|
ЧТО ЭТО НЕ ОТМЕНЯЕТ. Внутри полосы отбор по величине ошибки по-прежнему
|
||||||
жил порог `MAX_ABS_ERR_PCT = 40`, выбрасывавший кандидата ПО ВЕЛИЧИНЕ ОШИБКИ
|
запрещён — иначе витрина показывала бы лучший хвост уже самой полосы
|
||||||
до ранжирования. Запрет выше он обходил ступенькой раньше: отбор по ошибке в
|
(`test_selection_ignores_error_magnitude`). Счётчики прогона считаются ДО
|
||||||
ключе и отбор по ошибке в фильтре — одно и то же действие, и второе даже
|
полосы: `eligible` — сколько строк прогон вообще собрал, `written` — сколько
|
||||||
злее, потому что не оставляет строку в кандидатах. Обоснование «отклонение
|
из них прошло полосу и поместилось в `limit`. Разница между ними видна
|
||||||
больше 40% — это почти всегда занижение ДКП ради налога» не держится: см.
|
посетителю, и она честная ровно потому, что рядом сказано, чем именно
|
||||||
следующий раздел, грубые занижения вырезаны выше по потоку и по свойству
|
отобраны показанные. Если в полосу попало меньше `limit` строк — показываем
|
||||||
самой сделки. Отбраковываем только то, чего в данных НЕТ (нет прогноза, нет
|
сколько есть; добирать соседями по ошибке нельзя, это вернуло бы отбор по
|
||||||
квартала, нет площади) — «число некрасивое» причиной не является.
|
величине ошибки в обход полосы.
|
||||||
|
|
||||||
|
Отбраковка по «данных нет» (нет прогноза, нет квартала, нет площади) осталась
|
||||||
|
прежней и живёт в `build_row`: строка вне полосы ОСТАЁТСЯ кандидатом и
|
||||||
|
попадает в счётчик `eligible`, её снимает отбор, а не отбраковка.
|
||||||
|
|
||||||
Полнота — сколько из полей, которые видит посетитель (район, этаж, этажность,
|
Полнота — сколько из полей, которые видит посетитель (район, этаж, этажность,
|
||||||
схема улицы), у строки заполнено. Свежесть — порядок квартала сделки.
|
схема улицы), у строки заполнено. Свежесть — порядок квартала сделки.
|
||||||
|
|
@ -57,8 +67,10 @@
|
||||||
`PPM2_MIN = 30 000` / `PPM2_MAX = 600 000` (город намеренно не заведён в
|
`PPM2_MIN = 30 000` / `PPM2_MAX = 600 000` (город намеренно не заведён в
|
||||||
`deal_city_price_bands`, там же и комментарий об этом). Значит грубые
|
`deal_city_price_bands`, там же и комментарий об этом). Значит грубые
|
||||||
занижения из выборки уже вырезаны ДО того, как сюда приходит кандидат, а
|
занижения из выборки уже вырезаны ДО того, как сюда приходит кандидат, а
|
||||||
всё, что после этого дало большую ошибку, — работа оценщика, и витрина
|
всё, что после этого дало большую ошибку, — работа оценщика. С 2026-09-12
|
||||||
обязана её показать. Своей копии диапазона здесь нет намеренно: прежние
|
такая строка на витрину не выходит (полоса), но остаётся в `eligible` и
|
||||||
|
в подписи названа отобранной, а не несуществующей. Своей копии диапазона
|
||||||
|
здесь нет намеренно: прежние
|
||||||
`MIN_FACT_PPM2 = 30k` дублировал уже применённый фильтр, а
|
`MIN_FACT_PPM2 = 30k` дублировал уже применённый фильтр, а
|
||||||
`MAX_FACT_PPM2 = 1.2M` был недостижим при потолке выборки 600k — из трёх
|
`MAX_FACT_PPM2 = 1.2M` был недостижим при потолке выборки 600k — из трёх
|
||||||
отбраковок в проде срабатывала РОВНО ОДНА, та самая, что льстила витрине.
|
отбраковок в проде срабатывала РОВНО ОДНА, та самая, что льстила витрине.
|
||||||
|
|
@ -75,7 +87,10 @@
|
||||||
ошибки, — правило отбора выше не нарушено.
|
ошибки, — правило отбора выше не нарушено.
|
||||||
* СЧЁТЧИКИ ЕДУТ НА ФРОНТ, А НЕ ТОЛЬКО В ЛОГ. «Мы показываем 20 отличных
|
* СЧЁТЧИКИ ЕДУТ НА ФРОНТ, А НЕ ТОЛЬКО В ЛОГ. «Мы показываем 20 отличных
|
||||||
строк» неотличимо от «столько и было», пока рядом не написано, сколько
|
строк» неотличимо от «столько и было», пока рядом не написано, сколько
|
||||||
сделок рассмотрено и сколько годных строк не поместилось. Поэтому итог
|
сделок рассмотрено, сколько строк прогон собрал и по какому правилу из
|
||||||
|
них отобраны показанные. С появлением полосы это перестало быть
|
||||||
|
страховкой и стало обязательным: без счётчиков и правила отобранная
|
||||||
|
двадцатка читается как вся сверка. Поэтому итог
|
||||||
прогона пишется в `landing_showcase_runs` (миграция 277) и отдаётся
|
прогона пишется в `landing_showcase_runs` (миграция 277) и отдаётся
|
||||||
ручкой `/api/public/mera/showcase` вместе со строками.
|
ручкой `/api/public/mera/showcase` вместе со строками.
|
||||||
|
|
||||||
|
|
@ -104,18 +119,47 @@ from app.services.street_scheme import StreetIndex, build_street_scheme, load_st
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── Правило отбраковки: одна формулировка, она же едет на фронт ──────────────
|
# ── Полоса расхождения: что показываем и что об этом сказано ─────────────────
|
||||||
#
|
#
|
||||||
# Порогов на величину ошибки здесь НЕТ (разбор — в докстринге модуля). Санитарный
|
# Границы ВКЛЮЧИТЕЛЬНЫЕ. Полоса несимметрична намеренно: решение владельца от
|
||||||
# диапазон ₽/м² применён выше по потоку, в `_load_sample`; дублировать его тут
|
# 2026-09-12 — показывать сделки, где МЕРА не занизила больше чем на 5 % и не
|
||||||
# значило бы завести проверку, которая в проде не срабатывает никогда.
|
# завысила больше чем на 20 %.
|
||||||
|
BAND_MIN_ERR_PCT = -5.0
|
||||||
|
BAND_MAX_ERR_PCT = 20.0
|
||||||
|
|
||||||
|
# Подпись полосы ВЫВОДИТСЯ из границ, а не вписывается рядом: «−5 %…+20 %» в
|
||||||
|
# тексте и `>= -5.0` в коде — две независимые величины, и разъедутся они
|
||||||
|
# ровно тогда, когда порог однажды подвинут.
|
||||||
|
BAND_LABEL = f"от {BAND_MIN_ERR_PCT:+.0f} % до {BAND_MAX_ERR_PCT:+.0f} % включительно"
|
||||||
|
|
||||||
|
|
||||||
|
def in_band(err_pct: float) -> bool:
|
||||||
|
"""Попадает ли расхождение в показываемую полосу (границы включительно)."""
|
||||||
|
return BAND_MIN_ERR_PCT <= err_pct <= BAND_MAX_ERR_PCT
|
||||||
|
|
||||||
|
|
||||||
|
# ── Правило отбора и отбраковки: одна формулировка, она же едет на фронт ──────
|
||||||
|
#
|
||||||
|
# Санитарный диапазон ₽/м² применён выше по потоку, в `_load_sample`;
|
||||||
|
# дублировать его тут значило бы завести проверку, которая в проде не
|
||||||
|
# срабатывает никогда.
|
||||||
|
#
|
||||||
|
# ТЕКСТ ОБЯЗАН НАЗЫВАТЬ ПОЛОСУ. Пока фильтра не было, здесь стояло «величина
|
||||||
|
# отклонения на отбор и отбраковку не влияет — иначе витрина показывала бы
|
||||||
|
# лучший хвост, а не работу расчёта». С фильтром эта фраза стала ложью ровно
|
||||||
|
# про то, чего опасалась, поэтому она снята, а не смягчена.
|
||||||
REJECTION_RULE = (
|
REJECTION_RULE = (
|
||||||
"Строка не попадает на витрину, только если данных нет: расчёт МЕРЫ не дал "
|
f"На витрине — ОТОБРАННАЯ полоса расхождения, а не вся сверка: показаны "
|
||||||
"ожидаемой цены продажи (мало аналогов), неизвестен квартал сделки или "
|
f"только сделки, у которых расхождение прогноза с ценой ДКП лежит {BAND_LABEL}. "
|
||||||
"площадь. Величина отклонения на отбор и отбраковку не влияет — иначе "
|
"Промахи крупнее полосы в данных есть, и здесь их не видно — судить по этим "
|
||||||
"витрина показывала бы лучший хвост, а не работу расчёта. Санитарный "
|
"строкам о точности расчёта нельзя, для этого есть медиана расхождения по "
|
||||||
"диапазон цены сделки (30 000–600 000 ₽/м² для Екатеринбурга) применён "
|
"всей сверке. Внутри полосы порядок задают полнота данных и свежесть "
|
||||||
"к выборке до расчёта, по цене самой сделки."
|
"квартала: величина отклонения на него не влияет, лучший хвост самой полосы "
|
||||||
|
"витрина тоже не показывает. Кроме полосы строку снимает только отсутствие "
|
||||||
|
"данных: расчёт МЕРЫ не дал ожидаемой цены продажи (мало аналогов), "
|
||||||
|
"неизвестен квартал сделки или площадь. Санитарный диапазон цены сделки "
|
||||||
|
"(30 000–600 000 ₽/м² для Екатеринбурга) применён к выборке до расчёта, по "
|
||||||
|
"цене самой сделки."
|
||||||
)
|
)
|
||||||
|
|
||||||
NOTE = (
|
NOTE = (
|
||||||
|
|
@ -184,7 +228,7 @@ def completeness(row: ShowcaseRow) -> int:
|
||||||
|
|
||||||
|
|
||||||
def _sort_key(row: ShowcaseRow) -> tuple[int, date, int]:
|
def _sort_key(row: ShowcaseRow) -> tuple[int, date, int]:
|
||||||
"""Ключ отбора. Ошибки здесь нет — см. «ПРАВИЛО ОТБОРА» в докстринге модуля."""
|
"""Ключ ранжирования. Ошибки здесь нет — см. «ПРАВИЛО ОТБОРА» в докстринге."""
|
||||||
return (
|
return (
|
||||||
-completeness(row),
|
-completeness(row),
|
||||||
-(row.deal_date or date.min).toordinal(),
|
-(row.deal_date or date.min).toordinal(),
|
||||||
|
|
@ -193,8 +237,25 @@ def _sort_key(row: ShowcaseRow) -> tuple[int, date, int]:
|
||||||
|
|
||||||
|
|
||||||
def select_rows(rows: list[ShowcaseRow], limit: int) -> list[ShowcaseRow]:
|
def select_rows(rows: list[ShowcaseRow], limit: int) -> list[ShowcaseRow]:
|
||||||
"""Отобрать `limit` строк по полноте и свежести (НЕ по величине ошибки)."""
|
"""Строки полосы −5 %..+20 %, до `limit` штук, по полноте и свежести.
|
||||||
return sorted(rows, key=_sort_key)[:limit]
|
|
||||||
|
ДВА ДЕЙСТВИЯ, И ОНИ РАЗНЫЕ. Сначала ФИЛЬТР по величине расхождения
|
||||||
|
(`in_band`) — это и есть «витрина показывает отобранную полосу, а не всю
|
||||||
|
сверку», названное так же в `REJECTION_RULE` и в подписи под таблицей.
|
||||||
|
Потом РАНЖИРОВАНИЕ уцелевших по полноте данных и свежести квартала —
|
||||||
|
внутри полосы величина ошибки на порядок не влияет, иначе показывался бы
|
||||||
|
лучший хвост уже самой полосы.
|
||||||
|
|
||||||
|
Фильтр стоит ЗДЕСЬ, а не в `build_row`, намеренно: строка вне полосы
|
||||||
|
обязана остаться кандидатом и попасть в счётчик `eligible`. Отбраковав её
|
||||||
|
раньше, мы получили бы «показано 20 из 20 годных» — счётчик, из которого
|
||||||
|
отбор не виден вообще.
|
||||||
|
|
||||||
|
В полосе меньше `limit` строк — возвращаем сколько есть. Добирать
|
||||||
|
ближайшими по ошибке нельзя: это тот же отбор по величине ошибки, просто
|
||||||
|
с другой стороны.
|
||||||
|
"""
|
||||||
|
return sorted((r for r in rows if in_band(r.err_pct)), key=_sort_key)[:limit]
|
||||||
|
|
||||||
|
|
||||||
def build_row(
|
def build_row(
|
||||||
|
|
@ -217,8 +278,11 @@ def build_row(
|
||||||
|
|
||||||
Причины отказа ИСЧЕРПЫВАЮЩИЕ и все — «данных нет»: спайн не дал ожидаемой
|
Причины отказа ИСЧЕРПЫВАЮЩИЕ и все — «данных нет»: спайн не дал ожидаемой
|
||||||
цены продажи; квартал сделки неизвестен; нет площади или цены сделки
|
цены продажи; квартал сделки неизвестен; нет площади или цены сделки
|
||||||
(делить не на что). Величина отклонения причиной НЕ является ни при каких
|
(делить не на что). Величина отклонения причиной отказа НЕ является ни при
|
||||||
значениях — см. «ФИЛЬТРА ПО ОШИБКЕ ТОЖЕ НЕТ» в докстринге модуля.
|
каких значениях: строка с любым промахом становится кандидатом и попадает
|
||||||
|
в счётчик `eligible`. Полоса, по которой из кандидатов отбираются
|
||||||
|
показанные, применяется позже и в другом месте — `select_rows`; здесь её
|
||||||
|
нет намеренно, иначе отбор перестал бы быть виден в счётчиках.
|
||||||
|
|
||||||
ФАКТ — ЭТО `deals.price_rub`, ЦЕНА ИЗ ДОГОВОРА, А НЕ ПРОИЗВЕДЕНИЕ. Колонка на
|
ФАКТ — ЭТО `deals.price_rub`, ЦЕНА ИЗ ДОГОВОРА, А НЕ ПРОИЗВЕДЕНИЕ. Колонка на
|
||||||
витрине называется «Цена ДКП», и подпись обязана называть ту величину, которая
|
витрине называется «Цена ДКП», и подпись обязана называть ту величину, которая
|
||||||
|
|
@ -384,9 +448,14 @@ def refresh_landing_showcase_deals(
|
||||||
priced из них оценщик дал ожидаемую цену продажи
|
priced из них оценщик дал ожидаемую цену продажи
|
||||||
no_prediction не дал (мало аналогов / спайн упал)
|
no_prediction не дал (мало аналогов / спайн упал)
|
||||||
incomplete цена есть, но нет квартала/площади — строку не собрать
|
incomplete цена есть, но нет квартала/площади — строку не собрать
|
||||||
eligible годных строк ВСЕГО (никакого отсева по ошибке нет)
|
eligible строк СОБРАНО всего, ДО полосы (данных хватило)
|
||||||
written из них показано (обрезано по `limit`)
|
written из них показано: прошли полосу и поместились в `limit`
|
||||||
with_district у скольких показанных удалось определить район
|
with_district у скольких показанных удалось определить район
|
||||||
|
|
||||||
|
`eligible` минус `written` — это НЕ «столько не поместилось»: с 2026-09-12
|
||||||
|
в разницу входят и строки вне полосы −5 %..+20 %. Поэтому подпись под
|
||||||
|
таблицей называет `eligible` собранными строками, а чем отобраны
|
||||||
|
показанные — говорит `REJECTION_RULE`, который едет тем же ответом.
|
||||||
"""
|
"""
|
||||||
# Импорт внутри функции: `scripts.backtest_estimator` тянет оценщик со всеми
|
# Импорт внутри функции: `scripts.backtest_estimator` тянет оценщик со всеми
|
||||||
# его зависимостями, а web-процессу это на импорте приложения не нужно.
|
# его зависимостями, а web-процессу это на импорте приложения не нужно.
|
||||||
|
|
@ -444,6 +513,16 @@ def refresh_landing_showcase_deals(
|
||||||
candidates.append(row)
|
candidates.append(row)
|
||||||
|
|
||||||
chosen = select_rows(candidates, limit)
|
chosen = select_rows(candidates, limit)
|
||||||
|
# Сколько собранных строк вообще попало в полосу — в лог, а не в счётчики:
|
||||||
|
# колонки под него в `landing_showcase_runs` нет, а без него по `written`
|
||||||
|
# не отличить «полоса оставила мало» от «упёрлись в limit».
|
||||||
|
logger.info(
|
||||||
|
"в полосе %s: %d из %d собранных, показано %d",
|
||||||
|
BAND_LABEL,
|
||||||
|
sum(1 for r in candidates if in_band(r.err_pct)),
|
||||||
|
len(candidates),
|
||||||
|
len(chosen),
|
||||||
|
)
|
||||||
schemes = _schemes_for(db, street_index, chosen, {d.id: d.address for d in deals})
|
schemes = _schemes_for(db, street_index, chosen, {d.id: d.address for d in deals})
|
||||||
|
|
||||||
db.execute(_DELETE_SQL)
|
db.execute(_DELETE_SQL)
|
||||||
|
|
@ -488,7 +567,7 @@ def refresh_landing_showcase_deals(
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"витрина обновлена: рассмотрено=%d оценено=%d без_прогноза=%d неполных=%d "
|
"витрина обновлена: рассмотрено=%d оценено=%d без_прогноза=%d неполных=%d "
|
||||||
"годных=%d записано=%d с_районом=%d",
|
"собрано=%d записано=%d с_районом=%d",
|
||||||
counters["considered"],
|
counters["considered"],
|
||||||
counters["priced"],
|
counters["priced"],
|
||||||
counters["no_prediction"],
|
counters["no_prediction"],
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,21 @@
|
||||||
"""Витрина лэндинга на реальных сделках — отбор и отбраковка (миграция 276).
|
"""Витрина лэндинга на реальных сделках — отбор и отбраковка (миграция 276).
|
||||||
|
|
||||||
Главное, что здесь защищается, — НЕ формат строки, а свойство отбора: витрина
|
Здесь защищаются ДВА разных свойства, и путать их нельзя.
|
||||||
показывает выборку из работы оценщика, а не её лучший хвост. Отбор по малой
|
|
||||||
ошибке дал бы формально работающий код и врущий продукт, и заметить это на
|
1. ПОЛОСА. С 2026-09-12 витрина показывает только расхождения −5 %..+20 %
|
||||||
глаз в проде нельзя — числа будут красивые. Поэтому проверка двусторонняя:
|
включительно — решение владельца продукта. Это отбор показательных строк,
|
||||||
самая точная строка, у которой не хватает данных, обязана проиграть менее
|
и проверяется он ПО ЗНАЧЕНИЮ, на реальных строках прода: +75,7 % и −27,9 %
|
||||||
точной, но полной.
|
на витрину не попадают, +9,9 % попадает.
|
||||||
|
2. ВНУТРИ ПОЛОСЫ отбора по величине ошибки по-прежнему нет. Иначе витрина
|
||||||
|
показывала бы лучший хвост уже самой полосы, а числа при этом остались бы
|
||||||
|
красивыми — на глаз в проде такое не ловится. Поэтому проверка
|
||||||
|
двусторонняя: самая точная строка, у которой не хватает данных, обязана
|
||||||
|
проиграть менее точной, но полной.
|
||||||
|
|
||||||
|
Отбраковка («данных нет») — третье свойство, и она живёт в `build_row`: строка
|
||||||
|
вне полосы остаётся кандидатом и попадает в счётчик `eligible`, её снимает
|
||||||
|
отбор, а не отбраковка. Ровно поэтому подпись под витриной может честно
|
||||||
|
сказать, сколько строк прогон собрал.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -16,6 +26,9 @@ from datetime import date
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
from app.tasks.landing_showcase_deals import (
|
from app.tasks.landing_showcase_deals import (
|
||||||
|
BAND_MAX_ERR_PCT,
|
||||||
|
BAND_MIN_ERR_PCT,
|
||||||
|
REJECTION_RULE,
|
||||||
ShowcaseRow,
|
ShowcaseRow,
|
||||||
build_row,
|
build_row,
|
||||||
quarter_label,
|
quarter_label,
|
||||||
|
|
@ -50,26 +63,102 @@ def _row(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Полоса −5 %..+20 %: проверка ПО ЗНАЧЕНИЮ, на реальных строках прода ───────
|
||||||
|
|
||||||
|
|
||||||
|
def test_band_drops_rows_outside_it_and_keeps_rows_inside() -> None:
|
||||||
|
"""Три строки, которые сегодня лежат на витрине прода (прогон 30.08.2026).
|
||||||
|
|
||||||
|
id 41 — расхождение +75,71 %, id 43 — −27,87 %, id 44 — +9,85 %. Первые две
|
||||||
|
на витрину попадать больше не должны, третья должна.
|
||||||
|
|
||||||
|
Ломать так: снять фильтр в `select_rows` (вернуть
|
||||||
|
`sorted(rows, key=_sort_key)`) — тест покраснеет ПО ЗНАЧЕНИЮ, показав
|
||||||
|
[44, 41, 43] вместо [44], то есть ровно те два промаха, которых владелец
|
||||||
|
на витрине видеть не хочет.
|
||||||
|
"""
|
||||||
|
far_over = _row(41, err_pct=75.71)
|
||||||
|
far_under = _row(43, err_pct=-27.87)
|
||||||
|
inside = _row(44, err_pct=9.85)
|
||||||
|
|
||||||
|
chosen = select_rows([far_over, far_under, inside], limit=20)
|
||||||
|
|
||||||
|
assert [r.deal_id for r in chosen] == [44], (
|
||||||
|
"на витрину прошла строка вне полосы −5 %..+20 %: подпись обещает "
|
||||||
|
"полосу, а показывает не её"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_band_edges_are_inclusive_and_near_misses_are_not() -> None:
|
||||||
|
"""Границы полосы включительные, а на волос за ними — уже нет.
|
||||||
|
|
||||||
|
Проверяется ПО ЗНАЧЕНИЮ у самой границы: `<` вместо `<=` в `in_band`
|
||||||
|
выбросит ровно строки 1 и 2 и покраснит тест.
|
||||||
|
"""
|
||||||
|
rows = [
|
||||||
|
_row(1, err_pct=BAND_MIN_ERR_PCT),
|
||||||
|
_row(2, err_pct=BAND_MAX_ERR_PCT),
|
||||||
|
_row(3, err_pct=BAND_MIN_ERR_PCT - 0.01),
|
||||||
|
_row(4, err_pct=BAND_MAX_ERR_PCT + 0.01),
|
||||||
|
]
|
||||||
|
|
||||||
|
assert sorted(r.deal_id for r in select_rows(rows, limit=20)) == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_band_shows_what_there_is_and_does_not_top_up() -> None:
|
||||||
|
"""В полосу попало меньше лимита — показываем сколько есть.
|
||||||
|
|
||||||
|
Добор ближайшими по ошибке был бы тем же отбором по величине ошибки, просто
|
||||||
|
с другой стороны. Ломать так: добавить в `select_rows` «добить до limit
|
||||||
|
остальными» — тест покраснеет тремя строками вместо одной.
|
||||||
|
"""
|
||||||
|
rows = [_row(1, err_pct=3.0), _row(2, err_pct=44.0), _row(3, err_pct=-60.0)]
|
||||||
|
|
||||||
|
assert [r.deal_id for r in select_rows(rows, limit=20)] == [1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejection_rule_names_the_band_that_is_actually_applied() -> None:
|
||||||
|
"""Подпись витрины называет ТУ полосу, которую применяет фильтр.
|
||||||
|
|
||||||
|
Текст едет на фронт и там читается как обещание. Вписанный руками «−5 %» в
|
||||||
|
тексте и `>= -5.0` в коде — две независимые величины; здесь проверяется,
|
||||||
|
что в тексте стоят именно границы фильтра.
|
||||||
|
|
||||||
|
Ломать так: подвинуть `BAND_MAX_ERR_PCT` на 30, не трогая текст, — тест
|
||||||
|
покраснеет на «+30 %», которого в подписи нет.
|
||||||
|
"""
|
||||||
|
assert f"{BAND_MIN_ERR_PCT:+.0f} %" in REJECTION_RULE
|
||||||
|
assert f"{BAND_MAX_ERR_PCT:+.0f} %" in REJECTION_RULE
|
||||||
|
assert "не вся сверка" in REJECTION_RULE, "подпись не говорит, что это отбор"
|
||||||
|
# Снятая формулировка не должна вернуться: с фильтром она ложь.
|
||||||
|
assert "на отбор и отбраковку не влияет" not in REJECTION_RULE
|
||||||
|
|
||||||
|
|
||||||
|
# ── Внутри полосы: порядок задают полнота и свежесть, не ошибка ───────────────
|
||||||
|
|
||||||
|
|
||||||
def test_selection_ignores_error_magnitude() -> None:
|
def test_selection_ignores_error_magnitude() -> None:
|
||||||
"""Точнейшая строка с дырами в данных НЕ должна оказаться впереди полной.
|
"""Точнейшая строка с дырами в данных НЕ должна оказаться впереди полной.
|
||||||
|
|
||||||
|
Обе строки ВНУТРИ полосы — проверяется именно ранжирование, а не фильтр:
|
||||||
|
иначе тест зеленел бы по той же причине, по которой краснеет соседний.
|
||||||
|
|
||||||
Ломать так: добавить в `_sort_key` слагаемое `abs(row.err_pct)` — тест
|
Ломать так: добавить в `_sort_key` слагаемое `abs(row.err_pct)` — тест
|
||||||
покраснеет с id 1 на первом месте вместо id 2.
|
покраснеет с id 1 на первом месте вместо id 2.
|
||||||
"""
|
"""
|
||||||
almost_perfect_but_thin = _row(1, district=None, floor=None, err_pct=0.1)
|
almost_perfect_but_thin = _row(1, district=None, floor=None, err_pct=0.1)
|
||||||
complete_but_worse = _row(2, err_pct=27.0)
|
complete_but_worse = _row(2, err_pct=19.0)
|
||||||
|
|
||||||
chosen = select_rows([almost_perfect_but_thin, complete_but_worse], limit=1)
|
chosen = select_rows([almost_perfect_but_thin, complete_but_worse], limit=1)
|
||||||
|
|
||||||
assert [r.deal_id for r in chosen] == [2], (
|
assert [r.deal_id for r in chosen] == [2], (
|
||||||
"отбор поехал за величиной ошибки — витрина перестала быть выборкой "
|
"отбор поехал за величиной ошибки — витрина показывает лучший хвост уже самой полосы"
|
||||||
"и стала рекламой лучшего хвоста"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_selection_prefers_fresher_quarter_at_equal_completeness() -> None:
|
def test_selection_prefers_fresher_quarter_at_equal_completeness() -> None:
|
||||||
older = _row(1, deal_date=date(2025, 1, 1), err_pct=1.0)
|
older = _row(1, deal_date=date(2025, 1, 1), err_pct=1.0)
|
||||||
fresher = _row(2, deal_date=date(2026, 4, 1), err_pct=35.0)
|
fresher = _row(2, deal_date=date(2026, 4, 1), err_pct=18.0)
|
||||||
|
|
||||||
assert [r.deal_id for r in select_rows([older, fresher], limit=1)] == [2]
|
assert [r.deal_id for r in select_rows([older, fresher], limit=1)] == [2]
|
||||||
|
|
||||||
|
|
@ -86,9 +175,10 @@ def test_selection_prefers_row_with_street_scheme() -> None:
|
||||||
`completeness` — тест покраснеет ПО ЗНАЧЕНИЮ, порядком [9, 8].
|
`completeness` — тест покраснеет ПО ЗНАЧЕНИЮ, порядком [9, 8].
|
||||||
|
|
||||||
Вторая сторона проверки — строка без улицы ОСТАЁТСЯ в витрине: она не
|
Вторая сторона проверки — строка без улицы ОСТАЁТСЯ в витрине: она не
|
||||||
первая, но и не выброшена. Прятать промахи по-прежнему нельзя.
|
первая, но и не выброшена. Отсутствие поля — не причина отбраковки, и
|
||||||
|
полоса тут ни при чём: обе строки внутри неё.
|
||||||
"""
|
"""
|
||||||
no_street = _row(9, has_street=False, err_pct=75.7)
|
no_street = _row(9, has_street=False, err_pct=19.0)
|
||||||
with_street = _row(8, err_pct=3.0)
|
with_street = _row(8, err_pct=3.0)
|
||||||
|
|
||||||
chosen = select_rows([no_street, with_street], limit=2)
|
chosen = select_rows([no_street, with_street], limit=2)
|
||||||
|
|
@ -157,11 +247,12 @@ def test_fact_is_the_contract_price_not_the_reconstruction() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_no_error_magnitude_is_ever_rejected() -> None:
|
def test_no_error_magnitude_is_ever_rejected() -> None:
|
||||||
"""Промах оценщика ЛЮБОГО размера остаётся на витрине.
|
"""Промах ЛЮБОГО размера остаётся кандидатом и попадает в счётчик.
|
||||||
|
|
||||||
Это второй половина запрета «не отбирать по ошибке»: фильтр по величине
|
Отбраковка и отбор — разные шаги, и величина ошибки причиной ОТБРАКОВКИ не
|
||||||
ошибки — тот же отбор, просто ступенькой раньше, и он тем злее, что не
|
является: вне полосы строка не показывается, но входит в `eligible`, и
|
||||||
оставляет строку даже в кандидатах.
|
подпись «показано N из M собранных» остаётся правдой. Отбракуй её здесь —
|
||||||
|
и отбор перестал бы быть виден в счётчиках вообще.
|
||||||
|
|
||||||
Ломать так: вернуть в `build_row` любой порог вида
|
Ломать так: вернуть в `build_row` любой порог вида
|
||||||
`if abs(err_pct) > X: return None` — тест покраснеет на первом же
|
`if abs(err_pct) > X: return None` — тест покраснеет на первом же
|
||||||
|
|
@ -171,20 +262,20 @@ def test_no_error_magnitude_is_ever_rejected() -> None:
|
||||||
for err_pct in (-95.0, -60.0, -41.0, -5.0, 0.0, 5.0, 41.0, 150.0, 900.0):
|
for err_pct in (-95.0, -60.0, -41.0, -5.0, 0.0, 5.0, 41.0, 150.0, 900.0):
|
||||||
row = _build(predicted_rub=fact_rub * (1 + err_pct / 100))
|
row = _build(predicted_rub=fact_rub * (1 + err_pct / 100))
|
||||||
assert row is not None, (
|
assert row is not None, (
|
||||||
f"строка с отклонением {err_pct:+.0f}% выброшена: витрина снова "
|
f"строка с отклонением {err_pct:+.0f}% выброшена из кандидатов: "
|
||||||
"показывает лучший хвост, а не работу оценщика"
|
"счётчик собранных строк перестал считать работу оценщика"
|
||||||
)
|
)
|
||||||
assert row.err_pct == round(err_pct, 2)
|
assert row.err_pct == round(err_pct, 2)
|
||||||
|
|
||||||
|
|
||||||
def test_underdeclared_dkp_is_shown_not_hidden() -> None:
|
def test_underdeclared_dkp_is_counted_not_dropped() -> None:
|
||||||
"""Занижение ДКП ради налога выглядит как промах — и всё равно показывается.
|
"""Занижение ДКП ради налога выглядит как промах — и остаётся кандидатом.
|
||||||
|
|
||||||
Прятать такие строки нельзя: «отклонение больше 40% — это почти всегда
|
Отбраковывать такие строки нельзя: «отклонение больше 40% — это почти
|
||||||
дефект ДКП» было догадкой, а санитарный диапазон ₽/м² уже применён к
|
всегда дефект ДКП» было догадкой, а санитарный диапазон ₽/м² уже применён
|
||||||
выборке выше по потоку (`_load_sample`, для ЕКБ 30k..600k). Всё, что
|
к выборке выше по потоку (`_load_sample`, для ЕКБ 30k..600k). На витрину
|
||||||
прошло его и дало большую ошибку, — работа оценщика. Честность за счёт
|
такая строка не выйдет — её снимет полоса, — но в `eligible` она войдёт,
|
||||||
строки в `note`, а не за счёт отсева.
|
и счётчик под таблицей останется честным.
|
||||||
"""
|
"""
|
||||||
# Факт 2 000 000 ₽ против прогноза 5 000 000 — отклонение +150%.
|
# Факт 2 000 000 ₽ против прогноза 5 000 000 — отклонение +150%.
|
||||||
row = _build(fact_rub=2_000_000.0)
|
row = _build(fact_rub=2_000_000.0)
|
||||||
|
|
@ -247,8 +338,8 @@ def test_row_without_coords_stays_on_showcase() -> None:
|
||||||
"""Нет точки — строка всё равно на витрине, с lat=lon=None.
|
"""Нет точки — строка всё равно на витрине, с lat=lon=None.
|
||||||
|
|
||||||
Выбрасывать сделку из-за отсутствия координаты — отбор по признаку, не
|
Выбрасывать сделку из-за отсутствия координаты — отбор по признаку, не
|
||||||
связанному с качеством оценки: та же порча витрины, что и отбор по
|
связанному с качеством оценки, и в отличие от полосы он нигде не назван:
|
||||||
величине ошибки, просто по другому полю. Карта переживёт строку без точки.
|
посетитель бы о нём не узнал. Карта переживёт строку без точки.
|
||||||
|
|
||||||
Ломать так: добавить в `build_row` `if lat is None or lon is None: return
|
Ломать так: добавить в `build_row` `if lat is None or lon is None: return
|
||||||
None` — тест покраснеет на None вместо строки.
|
None` — тест покраснеет на None вместо строки.
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BACKTEST,
|
BACKTEST,
|
||||||
|
BACKTEST_BAND_MEASURED_ON,
|
||||||
BACKTEST_MAX_AGE_DAYS,
|
BACKTEST_MAX_AGE_DAYS,
|
||||||
BACKTEST_MEASURED_LABEL,
|
BACKTEST_MEASURED_LABEL,
|
||||||
BACKTEST_MEASURED_ON,
|
BACKTEST_MEASURED_ON,
|
||||||
|
|
@ -39,22 +40,34 @@ function ageDays(iso: string, now: number): number {
|
||||||
return Math.floor((now - Date.parse(`${iso}T00:00:00Z`)) / DAY_MS);
|
return Math.floor((now - Date.parse(`${iso}T00:00:00Z`)) / DAY_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ДАТ НА СТРАНИЦЕ ДВЕ, И СТОРОЖИТЬ НАДО ОБЕ.
|
||||||
|
*
|
||||||
|
* 12.09.2026 в блок «Точность» приехала величина из другого прогона, со своей
|
||||||
|
* датой (`within20`). Сторож, знающий только `BACKTEST_MEASURED_ON`, оставил
|
||||||
|
* бы второе число протухать молча — то есть ровно та дыра, ради которой он и
|
||||||
|
* заводился, просто под новой датой. Список перечисляется явно: он короткий, а
|
||||||
|
* молчаливый вывод «все даты файла» однажды подхватил бы дату, которая на
|
||||||
|
* витрину не выходит.
|
||||||
|
*/
|
||||||
|
const MEASURED_DATES: readonly { iso: string; what: string }[] = [
|
||||||
|
{ iso: BACKTEST_MEASURED_ON, what: "расхождение и покрытие (BACKTEST_MEASURED_ON)" },
|
||||||
|
{ iso: BACKTEST_BAND_MEASURED_ON, what: "доля в пределах ±20 % (BACKTEST_BAND_MEASURED_ON)" },
|
||||||
|
];
|
||||||
|
|
||||||
describe("свежесть ручного замера бэктеста", () => {
|
describe("свежесть ручного замера бэктеста", () => {
|
||||||
it("дата замера разбирается и не из будущего — иначе сторож считает возраст мусора", () => {
|
it.each(MEASURED_DATES)("дата замера «$what» разбирается и не из будущего", ({ iso }) => {
|
||||||
const age = ageDays(BACKTEST_MEASURED_ON, Date.now());
|
const age = ageDays(iso, Date.now());
|
||||||
expect(
|
expect(Number.isFinite(age), `${iso} — не ISO-дата`).toBe(true);
|
||||||
Number.isFinite(age),
|
|
||||||
`BACKTEST_MEASURED_ON=${BACKTEST_MEASURED_ON} — не ISO-дата`,
|
|
||||||
).toBe(true);
|
|
||||||
expect(age, "дата замера в будущем").toBeGreaterThanOrEqual(0);
|
expect(age, "дата замера в будущем").toBeGreaterThanOrEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("замеру не больше срока годности", () => {
|
it.each(MEASURED_DATES)("замеру «$what» не больше срока годности", ({ iso }) => {
|
||||||
const age = ageDays(BACKTEST_MEASURED_ON, Date.now());
|
const age = ageDays(iso, Date.now());
|
||||||
expect(
|
expect(
|
||||||
age,
|
age,
|
||||||
[
|
[
|
||||||
`замеру ${age} дн. (${BACKTEST_MEASURED_ON}), допустимо ${BACKTEST_MAX_AGE_DAYS}.`,
|
`замеру ${age} дн. (${iso}), допустимо ${BACKTEST_MAX_AGE_DAYS}.`,
|
||||||
"Числа блока «Точность» посчитаны руками и с тех пор никем не подтверждены.",
|
"Числа блока «Точность» посчитаны руками и с тех пор никем не подтверждены.",
|
||||||
"Перегнать: python -m scripts.backtest_estimator --city Екатеринбург",
|
"Перегнать: python -m scripts.backtest_estimator --city Екатеринбург",
|
||||||
"— обновить BACKTEST, BACKTEST_PERIOD_LABEL (назвать окно, которое реально",
|
"— обновить BACKTEST, BACKTEST_PERIOD_LABEL (назвать окно, которое реально",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ import { describe, expect, it } from "vitest";
|
||||||
import { AccuracyV3 } from "../_components/v3/AccuracyV3";
|
import { AccuracyV3 } from "../_components/v3/AccuracyV3";
|
||||||
import {
|
import {
|
||||||
BACKTEST,
|
BACKTEST,
|
||||||
|
BACKTEST_BAND_MEASURED_LABEL,
|
||||||
|
BACKTEST_MEASURED_LABEL,
|
||||||
BACKTEST_PERIOD_LABEL,
|
BACKTEST_PERIOD_LABEL,
|
||||||
BACKTEST_POPULATION,
|
BACKTEST_POPULATION,
|
||||||
BACKTEST_SHARE_LABEL,
|
BACKTEST_SHARE_LABEL,
|
||||||
|
|
@ -44,22 +46,60 @@ describe("подписи окна бэктеста", () => {
|
||||||
|
|
||||||
it("уверенность посчитана на том же прогоне, что и остальные числа", () => {
|
it("уверенность посчитана на том же прогоне, что и остальные числа", () => {
|
||||||
// «325 из 327» было из прогона 29.08 и под общей подписью «замер 31.08»
|
// «325 из 327» было из прогона 29.08 и под общей подписью «замер 31.08»
|
||||||
// приписывало старому числу новую дату.
|
// приписывало старому числу новую дату. Величина снята с витрины 12.09
|
||||||
|
// (её плитку занял `within20`), но осталась в файле до решения владельца —
|
||||||
|
// и свойство за ней сторожится: вернётся она только исправной.
|
||||||
expect(BACKTEST.confidenceLow.text).not.toMatch(/327/u);
|
expect(BACKTEST.confidenceLow.text).not.toMatch(/327/u);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* У свежей величины СВОЯ дата, и подпись блока про неё не годится: лид
|
||||||
|
* называет замер 31.08, а `within20` приехал с прогона 12.09. Число под
|
||||||
|
* чужой датой — ровно тот дефект, который здесь уже чинили для «325 из 327».
|
||||||
|
*/
|
||||||
|
it("свежая величина подписана своей датой, а не общей датой блока", () => {
|
||||||
|
expect(BACKTEST.within20.source).toContain("12.09.2026");
|
||||||
|
expect(BACKTEST_BAND_MEASURED_LABEL).toContain("12.09.2026");
|
||||||
|
expect(BACKTEST_BAND_MEASURED_LABEL).not.toEqual(BACKTEST_MEASURED_LABEL);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* На странице оказались два числа разных дат, и свежее — ХУЖЕ прежнего
|
||||||
|
* (медианное расхождение 19,1 % против 15,3 %). Молчать об этом нельзя:
|
||||||
|
* читатель сложит из двух дат улучшение, которого замер не показывал.
|
||||||
|
*/
|
||||||
|
it("оговорки свежей величины признают, что новый прогон вышел хуже прежнего", () => {
|
||||||
|
const text = BACKTEST.within20.caveats.join(" ");
|
||||||
|
expect(text, "не сказано, какое расхождение дал тот же свежий прогон").toContain("19,1 %");
|
||||||
|
expect(text, "не сказано, с каким прежним числом оно расходится").toContain(
|
||||||
|
BACKTEST.priceError.text,
|
||||||
|
);
|
||||||
|
expect(text, "разброс пересборок не назван — точечное число обещает точность").toContain(
|
||||||
|
"46,2-56,6 %",
|
||||||
|
);
|
||||||
|
expect(text.toLowerCase(), "не сказано, что замер не point-in-time").toContain(
|
||||||
|
"point-in-time",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("оговорки в интерфейсе", () => {
|
describe("оговорки в интерфейсе", () => {
|
||||||
it("на экране есть оговорки ВСЕХ ТРЁХ величин блока", () => {
|
// Матчер нормализует неразрывный пробел так же, как это делает DOM: в
|
||||||
|
// оговорках перед «%» стоит U+00A0, а `queryByText` сравнивается уже с
|
||||||
|
// нормализованным текстом. Без этого тест краснеет на типографике, а не на
|
||||||
|
// пропавшей со страницы оговорке — то есть перестаёт ловить своё.
|
||||||
|
const shown = (line: string): string => line.replace(/\u00a0/gu, " ");
|
||||||
|
|
||||||
|
it("на экране есть оговорки ВСЕХ ТРЁХ показанных величин блока", () => {
|
||||||
render(<AccuracyV3 stats={{}} showcase={null} />);
|
render(<AccuracyV3 stats={{}} showcase={null} />);
|
||||||
for (const набор of [
|
for (const набор of [
|
||||||
BACKTEST.priceError.caveats,
|
BACKTEST.priceError.caveats,
|
||||||
BACKTEST.coverage.caveats,
|
BACKTEST.coverage.caveats,
|
||||||
BACKTEST.confidenceLow.caveats,
|
BACKTEST.within20.caveats,
|
||||||
]) {
|
]) {
|
||||||
for (const line of набор) {
|
for (const line of набор) {
|
||||||
expect(
|
expect(
|
||||||
screen.queryByText(line),
|
screen.queryByText(shown(line)),
|
||||||
`оговорка не выводится: ${line.slice(0, 48)}…`,
|
`оговорка не выводится: ${line.slice(0, 48)}…`,
|
||||||
).not.toBeNull();
|
).not.toBeNull();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,17 @@ import { HeroV3 } from "../_components/v3/HeroV3";
|
||||||
import { BACKTEST } from "../landing-facts";
|
import { BACKTEST } from "../landing-facts";
|
||||||
import type { LandingStat, ShowcaseResponse } from "../public-api";
|
import type { LandingStat, ShowcaseResponse } from "../public-api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Значение витрины так, как его видит `getByText`.
|
||||||
|
*
|
||||||
|
* В `landing-facts` перед знаком процента стоит НЕРАЗРЫВНЫЙ пробел — иначе на
|
||||||
|
* узкой плитке «%» уезжает на отдельную строку. Строковый матчер
|
||||||
|
* testing-library сравнивается с НОРМАЛИЗОВАННЫМ текстом DOM, где U+00A0 уже
|
||||||
|
* стал обычным пробелом, поэтому матчер обязан нормализоваться так же. Иначе
|
||||||
|
* тест краснеет на типографской правке, а не на смысле числа.
|
||||||
|
*/
|
||||||
|
const shown = (value: string): string => value.replace(/\u00a0/gu, " ");
|
||||||
|
|
||||||
const stat = (value: number, sample_n: number | null, note: string): LandingStat => ({
|
const stat = (value: number, sample_n: number | null, note: string): LandingStat => ({
|
||||||
value,
|
value,
|
||||||
sample_n,
|
sample_n,
|
||||||
|
|
@ -78,14 +89,83 @@ const SHOWCASE: ShowcaseResponse = {
|
||||||
describe("витрина лэндинга v3 без данных", () => {
|
describe("витрина лэндинга v3 без данных", () => {
|
||||||
it("«Точность»: с данными показывает величины вместе с выборкой и коридором", () => {
|
it("«Точность»: с данными показывает величины вместе с выборкой и коридором", () => {
|
||||||
render(<AccuracyV3 stats={STATS} showcase={SHOWCASE} />);
|
render(<AccuracyV3 stats={STATS} showcase={SHOWCASE} />);
|
||||||
expect(screen.getByText(BACKTEST.priceError.text)).toBeTruthy();
|
expect(screen.getByText(shown(BACKTEST.priceError.text))).toBeTruthy();
|
||||||
expect(screen.getByText(/коридор шириной ±37 %/)).toBeTruthy();
|
expect(screen.getByText(/коридор шириной ±37 %/)).toBeTruthy();
|
||||||
expect(screen.getByText(BACKTEST.confidenceLow.text)).toBeTruthy();
|
expect(screen.getByText(shown(BACKTEST.within20.text))).toBeTruthy();
|
||||||
expect(screen.getByText(/по 25 943 объявлениям/)).toBeTruthy();
|
expect(screen.getByText(/по 25 943 объявлениям/)).toBeTruthy();
|
||||||
// Подпись витрины: сколько рассмотрено и по какому правилу отсеяно.
|
// Подпись витрины: сколько рассмотрено и по какому правилу отобрано.
|
||||||
expect(screen.getByText(/рассмотрено сделок: 4 000/)).toBeTruthy();
|
expect(screen.getByText(/рассмотрено сделок: 4 000/)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Снятая величина не должна остаться на экране «по инерции»: её плитку занял
|
||||||
|
* свежий замер, и оговорка про уверенность без своей плитки объясняла бы
|
||||||
|
* пустоту. Проверяется ПО ЗНАЧЕНИЮ — вернут плитку обратно, не убрав
|
||||||
|
* `within20`, и тест покраснеет.
|
||||||
|
*/
|
||||||
|
it("«Точность»: снятая с витрины confidenceLow не рендерится ни значением, ни оговоркой", () => {
|
||||||
|
render(<AccuracyV3 stats={STATS} showcase={SHOWCASE} />);
|
||||||
|
expect(screen.queryByText(shown(BACKTEST.confidenceLow.text))).toBeNull();
|
||||||
|
for (const line of BACKTEST.confidenceLow.caveats) {
|
||||||
|
expect(screen.queryByText(line)).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ПОДПИСЬ ВИТРИНЫ НАЗЫВАЕТ ПОЛОСУ И ДЕРЖИТ РЯДОМ МЕДИАНУ ПО ВСЕЙ СВЕРКЕ.
|
||||||
|
*
|
||||||
|
* Строки отобраны фильтром −5 %..+20 % (`select_rows` в
|
||||||
|
* app/tasks/landing_showcase_deals.py), поэтому их разброс по построению
|
||||||
|
* выглядит лучше работы расчёта. Две вещи удерживают страницу от вранья:
|
||||||
|
* подпись говорит, что это отбор, и печатает медиану ВСЕЙ сверки. Убрать
|
||||||
|
* любую из них — и остаётся благополучная двадцатка без контекста.
|
||||||
|
*/
|
||||||
|
it("«Точность»: подпись под таблицей называет полосу и печатает медиану всей сверки", () => {
|
||||||
|
render(<AccuracyV3 stats={STATS} showcase={SHOWCASE} />);
|
||||||
|
const note = screen.getByText(/Разброс показанных строк/u);
|
||||||
|
expect(note.textContent).toMatch(/отобранная полоса расхождения от -5 % до \+20 %/u);
|
||||||
|
expect(note.textContent).toContain("не вся сверка");
|
||||||
|
expect(
|
||||||
|
note.textContent,
|
||||||
|
"из подписи пропала медиана по всей сверке — остался разброс отобранной полосы без контекста",
|
||||||
|
).toContain(BACKTEST.priceError.text);
|
||||||
|
expect(note.textContent).toContain(String(BACKTEST.priceError.sampleN));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* УТВЕРЖДЕНИЕ ПРО ПОЛОСУ ПРОВЕРЯЕТ САМО СЕБЯ ПО ПОКАЗАННЫМ СТРОКАМ.
|
||||||
|
*
|
||||||
|
* Границы стоят в коде фронта, строки приходят из БД от последнего прогона
|
||||||
|
* задачи, а задача в расписании не стоит. Между выкатом фронта и пересчётом
|
||||||
|
* витрины на странице лежат СТАРЫЕ строки: на проде 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 снимает плитки и таблицу, а не обнуляет их", () => {
|
it("«Точность»: пустой /stats снимает плитки и таблицу, а не обнуляет их", () => {
|
||||||
const { container } = render(<AccuracyV3 stats={{}} showcase={null} />);
|
const { container } = render(<AccuracyV3 stats={{}} showcase={null} />);
|
||||||
expect(screen.queryByText("расчётов сделано")).toBeNull();
|
expect(screen.queryByText("расчётов сделано")).toBeNull();
|
||||||
|
|
@ -93,7 +173,7 @@ describe("витрина лэндинга v3 без данных", () => {
|
||||||
expect(container.querySelector('[role="table"]')).toBeNull();
|
expect(container.querySelector('[role="table"]')).toBeNull();
|
||||||
expect(container.textContent).not.toMatch(/[—-]\s*дн\./);
|
expect(container.textContent).not.toMatch(/[—-]\s*дн\./);
|
||||||
// Бэктест не зависит от ручки — он остаётся вместе со своими оговорками.
|
// Бэктест не зависит от ручки — он остаётся вместе со своими оговорками.
|
||||||
expect(screen.getByText(BACKTEST.priceError.text)).toBeTruthy();
|
expect(screen.getByText(shown(BACKTEST.priceError.text))).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("«Цена ошибки»: у двух величин РАЗНЫЕ выборки, и обе подписаны", () => {
|
it("«Цена ошибки»: у двух величин РАЗНЫЕ выборки, и обе подписаны", () => {
|
||||||
|
|
@ -140,10 +220,14 @@ describe("витрина лэндинга v3 без данных", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Лента висит НАД первым экраном, и её строки отобраны по полноте и свежести,
|
* Лента висит НАД первым экраном и берёт те же строки витрины, то есть с
|
||||||
* а не по величине ошибки, — крупный промах в ней штатен. Проверяется, что
|
* 12.09.2026 — ОТОБРАННУЮ полосу −5 %..+20 %. Значения в фикстурах поэтому
|
||||||
* рядом с промахом стоит его контекст и что оба числа сосчитаны ПО ПОКАЗАННЫМ
|
* внутри полосы: строка с +75,7 % в ленту больше физически не попадает, и
|
||||||
* строкам: подпись с вписанными руками величинами тут же разошлась бы с лентой.
|
* тест на ней проверял бы состояние, которого продюсер не создаёт.
|
||||||
|
*
|
||||||
|
* Проверяется две вещи: подпись называет полосу (иначе благополучный разброс
|
||||||
|
* читается как точность расчёта) и оба числа сосчитаны ПО ПОКАЗАННЫМ строкам —
|
||||||
|
* подпись с вписанными руками величинами тут же разошлась бы с лентой.
|
||||||
*/
|
*/
|
||||||
describe("лента сделок: контекст разброса", () => {
|
describe("лента сделок: контекст разброса", () => {
|
||||||
const tickerDeal = (err_pct: number) => ({
|
const tickerDeal = (err_pct: number) => ({
|
||||||
|
|
@ -152,17 +236,47 @@ describe("лента сделок: контекст разброса", () => {
|
||||||
predicted_rub: Math.round(SHOWCASE.deals[0].fact_rub * (1 + err_pct / 100)),
|
predicted_rub: Math.round(SHOWCASE.deals[0].fact_rub * (1 + err_pct / 100)),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Значения ВНУТРИ полосы: −11,5 %, стоявшее здесь раньше, ниже её нижней
|
||||||
|
// границы (−5 %), и лента о такой ленте полосу не утверждает — проверку
|
||||||
|
// ниже это роняло по делу. Полоса несимметрична, и «маленькое отклонение»
|
||||||
|
// ещё не значит «в полосе».
|
||||||
it("подпись печатает медиану и худшую ровно тех строк, что показаны", () => {
|
it("подпись печатает медиану и худшую ровно тех строк, что показаны", () => {
|
||||||
render(<DealsTickerV3 deals={[tickerDeal(4), tickerDeal(-11.5), tickerDeal(75.7)]} />);
|
render(<DealsTickerV3 deals={[tickerDeal(1.2), tickerDeal(-3.4), tickerDeal(19.3)]} />);
|
||||||
const note = screen.getByText(/Медиана расхождения показанных строк/u);
|
const note = screen.getByText(/Медиана показанных строк/u);
|
||||||
expect(note.textContent).toContain("11,5 %");
|
expect(note.textContent).toContain("3,4 %");
|
||||||
expect(note.textContent).toContain("75,7 %");
|
expect(note.textContent).toContain("19,3 %");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("та же лента без худшей строки печатает ДРУГИЕ числа — они не константы", () => {
|
it("та же лента без худшей строки печатает ДРУГИЕ числа — они не константы", () => {
|
||||||
render(<DealsTickerV3 deals={[tickerDeal(4), tickerDeal(-11.5)]} />);
|
render(<DealsTickerV3 deals={[tickerDeal(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);
|
const note = screen.getByText(/Медиана расхождения показанных строк/u);
|
||||||
expect(note.textContent).toContain("7,8 %");
|
expect(
|
||||||
expect(note.textContent).not.toContain("75,7 %");
|
note.textContent,
|
||||||
|
"лента обещает полосу, а сама прокручивает строку вне неё",
|
||||||
|
).not.toMatch(/от -5 % до \+20 %/u);
|
||||||
|
expect(note.textContent).toContain("75,7 %");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,19 +13,42 @@
|
||||||
* и не пересчитываются ночной задачей; без даты они стареют молча. Дату видит
|
* и не пересчитываются ночной задачей; без даты они стареют молча. Дату видит
|
||||||
* читатель, а срок годности сторожит `__tests__/backtest-freshness.test.ts`.
|
* читатель, а срок годности сторожит `__tests__/backtest-freshness.test.ts`.
|
||||||
*
|
*
|
||||||
* ОТКУДА ЧИСЛА. Три первых плитки — разовая сверка прогноза с ценой ДКП
|
* ОТКУДА ЧИСЛА. Три первых плитки — ручная сверка прогноза с ценой ДКП
|
||||||
* (`landing-facts.ts`, там же источник и оговорки). Экспозиция и число
|
* (`landing-facts.ts`, там же источник и оговорки). Экспозиция и число
|
||||||
* расчётов — `/stats`, каждая плитка рендерится только если величина пришла:
|
* расчётов — `/stats`, каждая плитка рендерится только если величина пришла:
|
||||||
* недоступная ручка снимает плитку, а не показывает ноль. Таблица — `/showcase`
|
* недоступная ручка снимает плитку, а не показывает ноль. Таблица — `/showcase`
|
||||||
* (реальные сделки Росреестра против прогноза), и вместе со строками едет
|
* (реальные сделки Росреестра против прогноза), и вместе со строками едет
|
||||||
* подпись: сколько сделок рассмотрено, сколько годных не поместилось и по
|
* подпись: сколько сделок рассмотрено, сколько строк прогон собрал и по
|
||||||
* какому правилу отсеяно остальное.
|
* какому правилу из них отобраны показанные.
|
||||||
*
|
*
|
||||||
* ТРИ ПЛИТКИ БЭКТЕСТА ИДУТ КОМПЛЕКТОМ И ПОРОЗНЬ НЕ ПОКАЗЫВАЮТСЯ. «88 % в
|
* ВИТРИНА ПОКАЗЫВАЕТ ОТОБРАННУЮ ПОЛОСУ, И ПОДПИСЬ ОБЯЗАНА ЭТО НАЗВАТЬ. С
|
||||||
* коридоре» без ширины коридора (±37 %) и без того, что уверенность расчёта
|
* 12.09.2026 в таблицу и в ленту попадают только сделки с расхождением от
|
||||||
* низкая у 325 из 327, — это три разных способа выглядеть точнее, чем есть.
|
* −5 % до +20 % (фильтр — `select_rows` в `app/tasks/landing_showcase_deals.py`,
|
||||||
* Поэтому ширина коридора стоит в ПОДПИСИ к самой плитке, а не отдельной
|
* оттуда же текст правила). Поэтому подпись под таблицей говорит про полосу
|
||||||
* строкой мелким шрифтом, и оговорки рендерятся тут же, под сеткой.
|
* прямо и держит рядом медиану по ВСЕЙ сверке: разброс двадцати отобранных
|
||||||
|
* строк по построению лучше работы расчёта, и без второго числа страница
|
||||||
|
* обещала бы точность, которой никто не мерил.
|
||||||
|
*
|
||||||
|
* УТВЕРЖДЕНИЕ ПРО ПОЛОСУ САМОПРОВЕРЯЕМОЕ. Границы стоят в коде фронта, строки
|
||||||
|
* приходят из БД от последнего прогона задачи, а задача в расписании не стоит
|
||||||
|
* — её запускают руками. В окне «фронт выкачен, витрина не пересчитана»
|
||||||
|
* страница утверждала бы полосу над строками прежнего правила, а под
|
||||||
|
* утверждением стояла бы строка +75,7 % (на проде сейчас 12 таких из 20).
|
||||||
|
* Поэтому обе подписи спрашивают сами строки (`allWithinBand` в `deal-view`):
|
||||||
|
* не соответствуют — про полосу не говорим, называем то, что есть, а правило
|
||||||
|
* прогона и так печатается рядом и приезжает из ТОГО ЖЕ прогона, что строки.
|
||||||
|
*
|
||||||
|
* ПЛИТКИ БЭКТЕСТА ИДУТ КОМПЛЕКТОМ И ПОРОЗНЬ НЕ ПОКАЗЫВАЮТСЯ. «88 % в
|
||||||
|
* коридоре» без ширины коридора (±37 %) и без разброса пересборок — это
|
||||||
|
* разные способы выглядеть точнее, чем есть. Поэтому ширина коридора стоит в
|
||||||
|
* ПОДПИСИ к самой плитке, а не отдельной строкой мелким шрифтом, и оговорки
|
||||||
|
* рендерятся тут же, под сеткой.
|
||||||
|
*
|
||||||
|
* У ТРЕТЬЕЙ ПЛИТКИ СВОЯ ДАТА. Она приехала из прогона 12.09.2026, а лид блока
|
||||||
|
* называет замер 31.08 — поэтому дата стоит в подписи самой плитки, и одна из
|
||||||
|
* её оговорок прямо говорит, что медианное расхождение свежего прогона (19,1 %)
|
||||||
|
* ХУЖЕ прежнего (15,3 %). Два числа разных дат без этой строки читаются как
|
||||||
|
* улучшение, которого не было.
|
||||||
*
|
*
|
||||||
* ЧЕГО ЗДЕСЬ НЕТ. Плитки «точность по сроку продажи»: `deals.days_on_market`
|
* ЧЕГО ЗДЕСЬ НЕТ. Плитки «точность по сроку продажи»: `deals.days_on_market`
|
||||||
* заполнен 0 раз из 108 623 — сверять прогноз срока не с чем. На её месте
|
* заполнен 0 раз из 108 623 — сверять прогноз срока не с чем. На её месте
|
||||||
|
|
@ -44,23 +67,27 @@
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BACKTEST,
|
BACKTEST,
|
||||||
|
BACKTEST_BAND_MEASURED_LABEL,
|
||||||
BACKTEST_CORRIDOR_LABEL,
|
BACKTEST_CORRIDOR_LABEL,
|
||||||
BACKTEST_MEASURED_LABEL,
|
BACKTEST_MEASURED_LABEL,
|
||||||
BACKTEST_PERIOD_LABEL,
|
BACKTEST_PERIOD_LABEL,
|
||||||
BACKTEST_SHARE_LABEL,
|
BACKTEST_SHARE_LABEL,
|
||||||
|
BACKTEST_WITHIN_LABEL,
|
||||||
} from "../../landing-facts";
|
} from "../../landing-facts";
|
||||||
import { formatStat, type LandingStats, type ShowcaseResponse } from "../../public-api";
|
import { formatStat, type LandingStats, type ShowcaseResponse } from "../../public-api";
|
||||||
import styles from "../../landing-v3.module.css";
|
import styles from "../../landing-v3.module.css";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
absPct,
|
absPct,
|
||||||
|
allWithinBand,
|
||||||
|
BAND_MAX_PCT,
|
||||||
|
BAND_MIN_PCT,
|
||||||
count,
|
count,
|
||||||
dealMeta,
|
dealMeta,
|
||||||
dealTitle,
|
dealTitle,
|
||||||
errPct,
|
errPct,
|
||||||
rub,
|
rub,
|
||||||
shownSpread,
|
shownSpread,
|
||||||
WITHIN_PCT,
|
|
||||||
} from "./deal-view";
|
} from "./deal-view";
|
||||||
|
|
||||||
interface Tile {
|
interface Tile {
|
||||||
|
|
@ -99,29 +126,49 @@ export function AccuracyV3({
|
||||||
note: "попадание обеспечено шириной коридора, а не точностью точки",
|
note: "попадание обеспечено шириной коридора, а не точностью точки",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "confidence",
|
// Плитку «400 из 400 — уверенность низкая» сменил свежий замер
|
||||||
value: BACKTEST.confidenceLow.text,
|
// (12.09.2026). Её запись в landing-facts не удалена, а помечена снятой:
|
||||||
label: "расчётов с пометкой «уверенность низкая»",
|
// прогон 29.08 шёл по кластеризованной выборке, и держать на витрине
|
||||||
note: "оценки «уверенность высокая» в выборке не встретилось ни разу",
|
// приговор, посчитанный на данных, которым мы сами не верим, нельзя.
|
||||||
|
// Дата у этой плитки СВОЯ и стоит в подписи: у блока общая дата 31.08,
|
||||||
|
// и без даты рядом свежее число приписалось бы старому прогону.
|
||||||
|
key: "within20",
|
||||||
|
value: BACKTEST.within20.text,
|
||||||
|
label: `сделок — расхождение в пределах ${BACKTEST_WITHIN_LABEL}`,
|
||||||
|
note: `по ${BACKTEST.within20.sampleN} сделкам · ${BACKTEST_BAND_MEASURED_LABEL}`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
if (age) {
|
if (age) {
|
||||||
|
// На плитке — только размер выборки. Полное пояснение («это НЕ срок продажи»,
|
||||||
|
// откуда взята дата публикации) уходит в общий список оговорок ниже: в плитке
|
||||||
|
// оно занимало семь строк и растягивало ВЕСЬ ряд, потому что flex-строка
|
||||||
|
// тянется по самой высокой карточке. Текст с экрана не исчезает — меняется
|
||||||
|
// только место, где он стоит.
|
||||||
tiles.push({
|
tiles.push({
|
||||||
key: "listingAge",
|
key: "listingAge",
|
||||||
value: age.text,
|
value: age.text,
|
||||||
label: "медианная экспозиция активного объявления",
|
label: "медианная экспозиция активного объявления",
|
||||||
note: [age.note, age.sample].filter(Boolean).join(" · "),
|
note: age.sample ?? "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Оговорки ВСЕХ ТРЁХ величин блока, а не двух. `confidenceLow` показывается
|
// Оговорки ВСЕХ ПОКАЗАННЫХ величин блока. Правило то же, что и раньше:
|
||||||
// отдельной плиткой, но её оговорка сюда не попадала — то есть на экране
|
// плитка без своей оговорки — ровно то, чего этот блок делать не должен.
|
||||||
// стояло «400 из 400 — уверенность низкая» без единого слова о том, почему.
|
// Снятая `confidenceLow` отсюда ушла вместе со своей плиткой: оговорка про
|
||||||
// Плитка без своей оговорки — ровно то, что этот блок и не должен делать.
|
// число, которого на экране нет, объясняет пустоту.
|
||||||
|
//
|
||||||
|
// У `within20` оговорок три, и третья обязательна: на странице теперь стоят
|
||||||
|
// два числа разных дат, и свежий прогон дал медианное расхождение ХУЖЕ
|
||||||
|
// прежнего. Умолчать об этом — значит дать читателю собрать из двух дат
|
||||||
|
// картину, которой замер не подтверждает.
|
||||||
const caveats = [
|
const caveats = [
|
||||||
...BACKTEST.priceError.caveats,
|
...BACKTEST.priceError.caveats,
|
||||||
...BACKTEST.coverage.caveats,
|
...BACKTEST.coverage.caveats,
|
||||||
...BACKTEST.confidenceLow.caveats,
|
...BACKTEST.within20.caveats,
|
||||||
|
// Пояснение к экспозиции переехало сюда с плитки (см. выше). Оно длинное и
|
||||||
|
// по смыслу — такая же оговорка, как соседние: без него «34 дн.» читается
|
||||||
|
// как срок продажи, которым эта величина не является.
|
||||||
|
...(age?.note ? [age.note] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const deals = showcase?.deals ?? [];
|
const deals = showcase?.deals ?? [];
|
||||||
|
|
@ -216,14 +263,53 @@ export function AccuracyV3({
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{/*
|
||||||
|
«Годных» здесь больше нет намеренно. `eligible` — это строки,
|
||||||
|
которые прогон СОБРАЛ (данных хватило), и с появлением полосы
|
||||||
|
разница `eligible − written` перестала означать «столько не
|
||||||
|
поместилось»: в неё входят и отсеянные полосой. Правило отбора
|
||||||
|
(`rejection_rule`) приходит из того же прогона и стоит в этой же
|
||||||
|
подписи — счётчики и правило порознь не показываются.
|
||||||
|
*/}
|
||||||
<p className={styles.accFootnote}>
|
<p className={styles.accFootnote}>
|
||||||
{showcaseStats
|
{showcaseStats
|
||||||
? `Показано ${count(showcaseStats.written)} строк из ${count(showcaseStats.eligible)} годных, рассмотрено сделок: ${count(showcaseStats.considered)}. Район известен у ${count(showcaseStats.with_district)} из показанных. ${showcaseStats.rejection_rule}`
|
? `Показано ${count(showcaseStats.written)} строк из ${count(showcaseStats.eligible)} собранных прогоном, рассмотрено сделок: ${count(showcaseStats.considered)}. Район известен у ${count(showcaseStats.with_district)} из показанных. ${showcaseStats.rejection_rule}`
|
||||||
: "Подпись прогона не пришла — из чего отобраны строки, сказать нечем."}
|
: "Подпись прогона не пришла — из чего отобраны строки, сказать нечем."}
|
||||||
</p>
|
</p>
|
||||||
{spread && (
|
{spread && (
|
||||||
|
// УТВЕРЖДЕНИЕ ПРО ПОЛОСУ — САМОПРОВЕРЯЕМОЕ. Границы стоят в коде
|
||||||
|
// фронта, а строки приходят из БД, из последнего прогона задачи;
|
||||||
|
// задача в расписании не стоит и запускается руками. Между
|
||||||
|
// выкатом и пересчётом страница утверждала бы полосу над
|
||||||
|
// строками, собранными до неё (на проде сейчас 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}>
|
<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>
|
||||||
)}
|
)}
|
||||||
<p className={styles.accFootnote}>{deals[0].note}</p>
|
<p className={styles.accFootnote}>{deals[0].note}</p>
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,31 @@
|
||||||
* не из чего. Адреса тоже нет: улица и дом известны у 2.7% сделок, поэтому
|
* не из чего. Адреса тоже нет: улица и дом известны у 2.7% сделок, поэтому
|
||||||
* объект описан тем, что есть — комнаты, площадь, район, этаж.
|
* объект описан тем, что есть — комнаты, площадь, район, этаж.
|
||||||
*
|
*
|
||||||
|
* СТРОКИ — ОТОБРАННАЯ ПОЛОСА, А НЕ ВСЯ СВЕРКА. С 12.09.2026 продюсер витрины
|
||||||
|
* пишет только сделки с расхождением от −5 % до +20 % (`select_rows`/`BAND_*`
|
||||||
|
* в app/tasks/landing_showcase_deals.py), внутри полосы порядок задают полнота
|
||||||
|
* и свежесть. Лента берёт ТЕ ЖЕ строки, поэтому её содержимое отобрано ровно
|
||||||
|
* так же.
|
||||||
|
*
|
||||||
|
* НО УТВЕРЖДАЕМ МЫ ЭТО НЕ НА ВЕРУ. Границы живут в коде фронта, а строки
|
||||||
|
* приезжают из БД от ПОСЛЕДНЕГО прогона задачи — и задача в расписании не
|
||||||
|
* стоит, её запускают руками. Значит между выкатом фронта и пересчётом
|
||||||
|
* витрины лента утверждала бы полосу над строками, собранными по прежнему
|
||||||
|
* правилу (на проде сейчас 12 из 20 таких, худшая +75,7 %), и тут же
|
||||||
|
* прокручивала бы опровержение мимо собственной подписи. Поэтому подпись
|
||||||
|
* спрашивает сами строки (`allWithinBand`) и говорит про полосу, только если
|
||||||
|
* ни одна из них этому не противоречит.
|
||||||
|
*
|
||||||
* ПОД лентой — подпись с разбросом ПОКАЗАННЫХ строк (`shownSpread`, та же
|
* ПОД лентой — подпись с разбросом ПОКАЗАННЫХ строк (`shownSpread`, та же
|
||||||
* функция, что и под таблицей сверок): медиана модуля и худшая. Без неё первое,
|
* функция, что и под таблицей сверок): медиана модуля и худшая. До полосы она
|
||||||
* что видит посетитель страницы про точность, — промах в семьдесят процентов
|
* спасала от другого: первым, что посетитель видел про точность, был промах в
|
||||||
* без единой цифры контекста, опровергающий её же заголовок. Прятать промахи
|
* семьдесят процентов без единой цифры контекста. Теперь её работа обратная —
|
||||||
* нельзя: строки отобраны по ПОЛНОТЕ и СВЕЖЕСТИ (`_sort_key` в
|
* не дать прочитать благополучный разброс полосы как точность расчёта. Поэтому
|
||||||
* app/tasks/landing_showcase_deals.py), поэтому лечится контекстом, а не
|
* полоса названа В ТОЙ ЖЕ подписи, перед числами, а не только под таблицей
|
||||||
* отбором. Вывода («зато обычно точно») в подписи нет намеренно: он протух бы
|
* этажом ниже: лента висит НАД первым экраном, и её числа читают раньше любых
|
||||||
* на первом же пересчёте витрины, а два числа рядом не протухают.
|
* оговорок блока «Точность». Вывода («зато обычно точно») в подписи нет
|
||||||
|
* намеренно: он протух бы на первом же пересчёте витрины, а числа рядом не
|
||||||
|
* протухают.
|
||||||
*
|
*
|
||||||
* Список дублируется дважды подряд — стандартный приём бесшовного CSS-marquee
|
* Список дублируется дважды подряд — стандартный приём бесшовного CSS-marquee
|
||||||
* (анимация уводит ровно на −50%).
|
* (анимация уводит ровно на −50%).
|
||||||
|
|
@ -33,7 +50,16 @@
|
||||||
import type { ShowcaseDeal } from "../../public-api";
|
import type { ShowcaseDeal } from "../../public-api";
|
||||||
import styles from "../../landing-v3.module.css";
|
import styles from "../../landing-v3.module.css";
|
||||||
|
|
||||||
import { absPct, dealTitle, errPct, rub, shownSpread } from "./deal-view";
|
import {
|
||||||
|
absPct,
|
||||||
|
allWithinBand,
|
||||||
|
BAND_MAX_PCT,
|
||||||
|
BAND_MIN_PCT,
|
||||||
|
dealTitle,
|
||||||
|
errPct,
|
||||||
|
rub,
|
||||||
|
shownSpread,
|
||||||
|
} from "./deal-view";
|
||||||
|
|
||||||
export function DealsTickerV3({ deals }: { deals: readonly ShowcaseDeal[] }) {
|
export function DealsTickerV3({ deals }: { deals: readonly ShowcaseDeal[] }) {
|
||||||
const items = [...deals, ...deals];
|
const items = [...deals, ...deals];
|
||||||
|
|
@ -68,8 +94,16 @@ export function DealsTickerV3({ deals }: { deals: readonly ShowcaseDeal[] }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{spread && (
|
{spread && (
|
||||||
|
// Про полосу говорим, только если ЛЕНТА ей соответствует. Границы
|
||||||
|
// стоят в коде, строки приходят из БД от последнего прогона задачи, а
|
||||||
|
// задача в расписании не стоит: между выкатом и пересчётом лента
|
||||||
|
// утверждала бы полосу над строками, собранными до неё, — и тут же
|
||||||
|
// прокручивала +75,7 % мимо этого утверждения. Не соответствует —
|
||||||
|
// печатаем только то, что посчитано по самим строкам.
|
||||||
<p className={styles.tickerNote}>
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -174,11 +174,13 @@ export function pickVariedDeals(deals: readonly ShowcaseDeal[], n: number): Show
|
||||||
* поля в API для этого нет намеренно: любое второе место, где эти числа
|
* поля в API для этого нет намеренно: любое второе место, где эти числа
|
||||||
* считаются, рано или поздно отстанет от строк на экране.
|
* считаются, рано или поздно отстанет от строк на экране.
|
||||||
*
|
*
|
||||||
* Строки витрины отобраны по ПОЛНОТЕ и СВЕЖЕСТИ, а не по величине ошибки
|
* Строки витрины ОТОБРАНЫ ПО ПОЛОСЕ расхождения −5 %..+20 %
|
||||||
* (`_sort_key` в `app/tasks/landing_showcase_deals.py`), поэтому их разброс не
|
* (`select_rows`/`BAND_*` в `app/tasks/landing_showcase_deals.py`), а внутри
|
||||||
* обязан совпадать с разбросом всей сверки. Обе величины печатаются рядом, и
|
* полосы — по полноте и свежести. Значит этот разброс УЖЕ, чем у всей сверки,
|
||||||
* вывод о том, повезло ли показанной двадцатке, читатель делает сам — своей
|
* и совпадать с ней не может по построению. Именно поэтому подпись печатает
|
||||||
* формулировки вроде «чуть точнее» здесь нет: она бы протухла на первом же
|
* рядом медиану по всей сверке: одна величина без другой читается как
|
||||||
|
* точность расчёта, которой у отобранной двадцатки никто не мерил. Своей
|
||||||
|
* формулировки вроде «чуть точнее» здесь нет — она бы протухла на первом же
|
||||||
* пересчёте витрины, а числа рядом не протухают никогда.
|
* пересчёте витрины, а числа рядом не протухают никогда.
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
|
|
@ -189,6 +191,38 @@ export function pickVariedDeals(deals: readonly ShowcaseDeal[], n: number): Show
|
||||||
*/
|
*/
|
||||||
export const WITHIN_PCT = 20;
|
export const WITHIN_PCT = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Полоса расхождения, по которой витрина отобрана. ПРАВДА — в бэкенде
|
||||||
|
* (`BAND_MIN_ERR_PCT`/`BAND_MAX_ERR_PCT` в `app/tasks/landing_showcase_deals.py`),
|
||||||
|
* здесь копия ради подписи, и подставляется она тем же `errPct`, что рисует
|
||||||
|
* расхождение в самой таблице: «−5 %» в тексте и `-5.0` в фильтре обязаны
|
||||||
|
* называться одинаково.
|
||||||
|
*
|
||||||
|
* Полосу проговаривает и `rejection_rule`, который приходит ИЗ ТОГО ЖЕ
|
||||||
|
* прогона, что и строки, — то есть на странице границы названы дважды и из
|
||||||
|
* двух независимых источников. Разойдутся — будет видно глазами на первом же
|
||||||
|
* скриншоте, а не через квартал.
|
||||||
|
*/
|
||||||
|
export const BAND_MIN_PCT = -5;
|
||||||
|
export const BAND_MAX_PCT = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Можно ли про ЭТИ строки утверждать, что они — полоса.
|
||||||
|
*
|
||||||
|
* Подпись про полосу собирается из констант, а строки приезжают из БД, из
|
||||||
|
* последнего прогона задачи. Прогон в расписании не стоит и запускается
|
||||||
|
* руками, поэтому между выкатом фронта и пересчётом витрины страница
|
||||||
|
* утверждала бы полосу над строками, собранными ДО неё: на проде 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 {
|
export interface ShownSpread {
|
||||||
readonly n: number;
|
readonly n: number;
|
||||||
readonly medianAbsPct: number;
|
readonly medianAbsPct: number;
|
||||||
|
|
|
||||||
|
|
@ -69,13 +69,26 @@ const BACKTEST_CONFIDENCE_SOURCE =
|
||||||
"Росреестра по Екатеринбургу, сделки II квартала 2026 года";
|
"Росреестра по Екатеринбургу, сделки II квартала 2026 года";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сверка «прогноз → цена ДКП». Три величины идут КОМПЛЕКТОМ и показываются
|
* Источник `within20` — СВЕЖИЙ прогон 12.09.2026, отдельной строкой по той же
|
||||||
* вместе: попадание в коридор без ширины коридора и без уровня уверенности
|
* причине, что и предыдущий: у него своя дата и свой размер выборки (290
|
||||||
|
* сделок против 325 у головных чисел), и приписывать ему подпись «замер 31.08»
|
||||||
|
* значило бы повторить дефект, из-за которого эту строку и завели.
|
||||||
|
*/
|
||||||
|
const BACKTEST_BAND_SOURCE =
|
||||||
|
"Бэктест на боевой базе (12.09.2026, engine=full): прогноз МЕРЫ против цены " +
|
||||||
|
"ДКП Росреестра по Екатеринбургу, представительная выборка (разнесение по " +
|
||||||
|
"адресам), медиана трёх пересборок с солями 11/22/33";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сверка «прогноз → цена ДКП». Величины идут КОМПЛЕКТОМ и показываются
|
||||||
|
* вместе: попадание в коридор без ширины коридора и без разброса пересборок
|
||||||
* читается как точность, которой нет.
|
* читается как точность, которой нет.
|
||||||
*/
|
*/
|
||||||
export const BACKTEST: Readonly<Record<"priceError" | "coverage" | "confidenceLow", MeasuredValue>> = {
|
export const BACKTEST: Readonly<
|
||||||
|
Record<"priceError" | "coverage" | "confidenceLow" | "within20", MeasuredValue>
|
||||||
|
> = {
|
||||||
priceError: {
|
priceError: {
|
||||||
text: "15,3 %",
|
text: "15,3 %",
|
||||||
sampleN: 325,
|
sampleN: 325,
|
||||||
source: BACKTEST_SOURCE,
|
source: BACKTEST_SOURCE,
|
||||||
caveats: [
|
caveats: [
|
||||||
|
|
@ -88,7 +101,7 @@ export const BACKTEST: Readonly<Record<"priceError" | "coverage" | "confidenceLo
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
coverage: {
|
coverage: {
|
||||||
text: "84,3 %",
|
text: "84,3 %",
|
||||||
sampleN: 325,
|
sampleN: 325,
|
||||||
source: BACKTEST_SOURCE,
|
source: BACKTEST_SOURCE,
|
||||||
caveats: [
|
caveats: [
|
||||||
|
|
@ -96,6 +109,23 @@ export const BACKTEST: Readonly<Record<"priceError" | "coverage" | "confidenceLo
|
||||||
"коридор такой ширины накрывает почти любую сделку.",
|
"коридор такой ширины накрывает почти любую сделку.",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* СНЯТА С ВИТРИНЫ 12.09.2026, но не удалена — решением владельца её место в
|
||||||
|
* блоке «Точность» занял `within20`.
|
||||||
|
*
|
||||||
|
* Почему снята: прогон 29.08 шёл по кластеризованной выборке (`ORDER BY id
|
||||||
|
* DESC`, 20 строк на четыре улицы) — непредставительность этого прогона
|
||||||
|
* признана в шапке файла и уже стоила нам завышенного покрытия 88 %.
|
||||||
|
* Держать под ней плитку «400 из 400» значило показывать приговор,
|
||||||
|
* посчитанный на выборке, которой мы сами не верим.
|
||||||
|
*
|
||||||
|
* Почему не удалена: пересчитать её на представительной выборке никто не
|
||||||
|
* просил, а величина сама по себе не опровергнута — решение вернуть или
|
||||||
|
* выбросить за владельцем. Пока запись не рендерится ни одним компонентом
|
||||||
|
* (проверяется `landing-v3-render`), гейт `landing-numbers-gate` всё равно
|
||||||
|
* требует от неё источник и оговорку — и это правильно: вернётся на витрину
|
||||||
|
* она вместе с ними.
|
||||||
|
*/
|
||||||
confidenceLow: {
|
confidenceLow: {
|
||||||
// Тот же прогон, что и остальные числа блока: пересборка «mera» от
|
// Тот же прогон, что и остальные числа блока: пересборка «mera» от
|
||||||
// 31.08.2026. Раньше здесь стояло «325 из 327» из прогона 29.08 — под
|
// 31.08.2026. Раньше здесь стояло «325 из 327» из прогона 29.08 — под
|
||||||
|
|
@ -113,10 +143,43 @@ export const BACKTEST: Readonly<Record<"priceError" | "coverage" | "confidenceLo
|
||||||
"31.08.2026 по 60 случайным адресам его медиана — 29 %.",
|
"31.08.2026 по 60 случайным адресам его медиана — 29 %.",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Доля сделок, где расхождение прогноза с ценой ДКП уложилось в ±20 %.
|
||||||
|
*
|
||||||
|
* ЗАМЕР ОТ 12.09.2026, И ЭТО ДРУГАЯ ДАТА, ЧЕМ У ГОЛОВНЫХ ЧИСЕЛ. Оговорки
|
||||||
|
* обязаны об этом сказать: в том же прогоне медианное расхождение вышло
|
||||||
|
* 19,1 % — ХУЖЕ, чем 15,3 % из замера 31.08. Две величины меряют разное и
|
||||||
|
* взяты в разные дни, но молчать о том, что свежий прогон дал результат
|
||||||
|
* хуже прежнего, нельзя: на странице стоят оба числа, и без оговорки
|
||||||
|
* читатель соберёт из них картину, которой замер не подтверждает.
|
||||||
|
*/
|
||||||
|
within20: {
|
||||||
|
text: "52,7 %",
|
||||||
|
sampleN: 290,
|
||||||
|
source: BACKTEST_BAND_SOURCE,
|
||||||
|
caveats: [
|
||||||
|
"Замер НЕ point-in-time: прогноз считается по СЕГОДНЯШНИМ активным " +
|
||||||
|
"объявлениям против ПРОШЛЫХ сделок, и дрейф рынка за период входит в " +
|
||||||
|
"расхождение целиком.",
|
||||||
|
"Разброс по трём пересборкам — 46,2-56,6 %. Точного значения без этого " +
|
||||||
|
"диапазона не существует: медиана трёх прогонов — это оценка, а не " +
|
||||||
|
"измеренная константа.",
|
||||||
|
"Медианное расхождение в этом же прогоне — 19,1 %, то есть ВЫШЕ, чем " +
|
||||||
|
"15,3 % из замера 31.08.2026. Величины меряют разное (доля попаданий " +
|
||||||
|
"против середины распределения) и взяты в разные дни — сравнивать их " +
|
||||||
|
"напрямую нельзя, но и умалчивать о том, что свежий прогон вышел " +
|
||||||
|
"хуже прежнего, тоже.",
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Ширина окна попадания у `within20` — стоит в подписи плитки, не отдельно. */
|
||||||
|
export const BACKTEST_WITHIN_LABEL = "±20 %";
|
||||||
|
|
||||||
/** Ширина коридора — та же сверка; стоит в подписи к `coverage`, не отдельной плиткой. */
|
/** Ширина коридора — та же сверка; стоит в подписи к `coverage`, не отдельной плиткой. */
|
||||||
export const BACKTEST_CORRIDOR_LABEL = "±37 %";
|
// Неразрывный пробел перед знаком процента: на узкой плитке обычный уводил
|
||||||
|
// «%» на отдельную строку («коридор шириной ±37 / %», скрин 12.09).
|
||||||
|
export const BACKTEST_CORRIDOR_LABEL = "±37 %";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Окно сделок бэктеста. Окно РАСЧЁТОВ — другое, оно приходит из `/stats`.
|
* Окно сделок бэктеста. Окно РАСЧЁТОВ — другое, оно приходит из `/stats`.
|
||||||
|
|
@ -201,10 +264,26 @@ export const BACKTEST_MEASURED_ON = "2026-08-31";
|
||||||
/** Через сколько дней замер считается протухшим (см. тест свежести). */
|
/** Через сколько дней замер считается протухшим (см. тест свежести). */
|
||||||
export const BACKTEST_MAX_AGE_DAYS = 100;
|
export const BACKTEST_MAX_AGE_DAYS = 100;
|
||||||
|
|
||||||
const [measuredY, measuredM, measuredD] = BACKTEST_MEASURED_ON.split("-");
|
/**
|
||||||
|
* Дата ВТОРОГО замера блока — прогона 12.09.2026, из которого взят `within20`.
|
||||||
|
*
|
||||||
|
* Отдельная константа, потому что дата у него своя, а сроку годности он
|
||||||
|
* подчиняется тому же: `backtest-freshness` проверяет ОБЕ даты. Второе число
|
||||||
|
* на странице, которое не сторожит никто, — это та же дыра, ради закрытия
|
||||||
|
* которой сторож и заводился, просто под новой датой.
|
||||||
|
*/
|
||||||
|
export const BACKTEST_BAND_MEASURED_ON = "2026-09-12";
|
||||||
|
|
||||||
|
const label = (iso: string): string => {
|
||||||
|
const [y, m, d] = iso.split("-");
|
||||||
|
return `замер ${d}.${m}.${y}`;
|
||||||
|
};
|
||||||
|
|
||||||
/** Человекочитаемая дата замера. Выводится из ISO — двух правок не требует. */
|
/** Человекочитаемая дата замера. Выводится из ISO — двух правок не требует. */
|
||||||
export const BACKTEST_MEASURED_LABEL = `замер ${measuredD}.${measuredM}.${measuredY}`;
|
export const BACKTEST_MEASURED_LABEL = label(BACKTEST_MEASURED_ON);
|
||||||
|
|
||||||
|
/** То же для свежего прогона: плитка `within20` подписана СВОЕЙ датой. */
|
||||||
|
export const BACKTEST_BAND_MEASURED_LABEL = label(BACKTEST_BAND_MEASURED_ON);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Бейдж первого шага И подпись под кнопкой формы (`FreeCheckCard.tsx`).
|
* Бейдж первого шага И подпись под кнопкой формы (`FreeCheckCard.tsx`).
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue