Compare commits

..

No commits in common. "2eebc680a6d61505058a13cd308293494f07632e" and "df9dd52996eac0df6f9134c6326b164f494088df" have entirely different histories.

14 changed files with 39 additions and 498 deletions

View file

@ -118,8 +118,7 @@ REJECTION_RULE = (
NOTE = ( NOTE = (
"Прогноз посчитан по активным объявлениям на дату пересчёта, сделка — прошлая: " "Прогноз посчитан по активным объявлениям на дату пересчёта, сделка — прошлая: "
"это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. " "это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. "
"Факт — цена ДКП из договора (поле price_rub Росреестра, не пересчёт из ₽/м²): " "Факт — цена ДКП, заявленная в Росреестр: она бывает занижена сторонами, и тогда "
"она бывает занижена сторонами, и тогда "
"строка выглядит как промах оценщика, хотя врёт документ. " "строка выглядит как промах оценщика, хотя врёт документ. "
"Схема на карточке — улица сделки, а не её дом: в адресе Росреестра номер дома " "Схема на карточке — улица сделки, а не её дом: в адресе Росреестра номер дома "
"есть у 2.7% строк, поэтому дом не показан и показан быть не может." "есть у 2.7% строк, поэтому дом не показан и показан быть не может."
@ -193,7 +192,7 @@ def build_row(
total_floors: int | None, total_floors: int | None,
deal_date: date | None, deal_date: date | None,
predicted_rub: float | None, predicted_rub: float | None,
fact_rub: float | None, fact_ppm2: float,
n_analogs: int, n_analogs: int,
lat: float | None = None, lat: float | None = None,
lon: float | None = None, lon: float | None = None,
@ -205,33 +204,18 @@ def build_row(
(делить не на что). Величина отклонения причиной НЕ является ни при каких (делить не на что). Величина отклонения причиной НЕ является ни при каких
значениях см. «ФИЛЬТРА ПО ОШИБКЕ ТОЖЕ НЕТ» в докстринге модуля. значениях см. «ФИЛЬТРА ПО ОШИБКЕ ТОЖЕ НЕТ» в докстринге модуля.
ФАКТ ЭТО `deals.price_rub`, ЦЕНА ИЗ ДОГОВОРА, А НЕ ПРОИЗВЕДЕНИЕ. Колонка на
витрине называется «Цена ДКП», и подпись обязана называть ту величину, которая
показана. До 2026-08-30 здесь считалось `price_per_m2 * area_m2`, а
`deals.price_rub` лежала рядом и не использовалась: `price_per_m2` в базе
integer, поэтому произведение промахивалось на единицы рублей (на проде
4 799 995 вместо 4 800 000, 3 649 995 вместо 3 650 000). Расхождение
копеечное, но показывалась реконструкция под именем документа.
Строка без `price_rub` НЕ ПОКАЗЫВАЕТСЯ это «данных нет», и подставить туда
реконструкцию значило бы вернуть дефект в одной строке из двадцати, где его
уже никто не найдёт. Замер на проде 2026-08-30: в выборке витрины (ЕКБ,
rosreestr, с 2025-01-01, санитарный диапазон) price_rub заполнен у 33 555 из
33 555 сделок, так что отказ по этой причине защита, а не рабочий путь.
ОТСУТСТВИЕ КООРДИНАТЫ ПРИЧИНОЙ ТОЖЕ НЕ ЯВЛЯЕТСЯ. Строка без точки едет на ОТСУТСТВИЕ КООРДИНАТЫ ПРИЧИНОЙ ТОЖЕ НЕ ЯВЛЯЕТСЯ. Строка без точки едет на
витрину с lat=lon=None: карта переживёт сделку без точки, а выбрасывание витрину с lat=lon=None: карта переживёт сделку без точки, а выбрасывание
сделки из-за отсутствия координаты отбор по признаку, не связанному с сделки из-за отсутствия координаты отбор по признаку, не связанному с
качеством оценки, то есть та же порча витрины, что и отбор по ошибке. качеством оценки, то есть та же порча витрины, что и отбор по ошибке.
""" """
if predicted_rub is None or predicted_rub <= 0 or area_m2 <= 0: if predicted_rub is None or predicted_rub <= 0 or area_m2 <= 0 or fact_ppm2 <= 0:
return None
if fact_rub is None or fact_rub <= 0:
return None return None
quarter = quarter_label(deal_date) quarter = quarter_label(deal_date)
if quarter is None: if quarter is None:
return None return None
fact_rub = fact_ppm2 * area_m2
# Знак ошибки — как в бэктесте: (прогноз факт) / факт. Плюс = МЕРА # Знак ошибки — как в бэктесте: (прогноз факт) / факт. Плюс = МЕРА
# назвала дороже, чем ушло по ДКП. # назвала дороже, чем ушло по ДКП.
err_pct = 100.0 * (predicted_rub - fact_rub) / fact_rub err_pct = 100.0 * (predicted_rub - fact_rub) / fact_rub
@ -401,7 +385,7 @@ def refresh_landing_showcase_deals(
total_floors=deal.total_floors, total_floors=deal.total_floors,
deal_date=deal.deal_date, deal_date=deal.deal_date,
predicted_rub=pr.expected_sold_price, predicted_rub=pr.expected_sold_price,
fact_rub=deal.price_rub, fact_ppm2=deal.sold_ppm2,
n_analogs=len(capture[0]["kwargs"]["listings"]) if capture else 0, n_analogs=len(capture[0]["kwargs"]["listings"]) if capture else 0,
# Порядок ровно такой: lat — широта (~56.8 для ЕКБ), lon — долгота # Порядок ровно такой: lat — широта (~56.8 для ЕКБ), lon — долгота
# (~60.6). Перепутать местами — это точка в другой стране, и никакой # (~60.6). Перепутать местами — это точка в другой стране, и никакой

View file

@ -24,10 +24,7 @@
Точность считает бэктест (своя задача, свои допущения), а срок продажи требует Точность считает бэктест (своя задача, свои допущения), а срок продажи требует
пары «объявление снято сделка», которой у нас нет: снятие объявления не пары «объявление снято сделка», которой у нас нет: снятие объявления не
означает продажу. `listing_age_median_days` НЕ является сроком продажи и назван означает продажу. `listing_age_median_days` НЕ является сроком продажи и назван
экспозицией активного объявления см. note метрики. Считается от даты экспозицией активного объявления см. note метрики.
публикации у источника (`listing_date`, иначе `publish_date` это одна и та же
величина в двух колонках, замер в комментарии к `_LISTING_AGE_SQL`), и note
называет охват: у 1500 из 31 068 активных объявлений ЕКБ даты публикации нет.
ПОЧЕМУ ТОЛЬКО DOMKLIK В ЦЕНОВЫХ МЕТРИКАХ ПОЧЕМУ ТОЛЬКО DOMKLIK В ЦЕНОВЫХ МЕТРИКАХ
---------------------------------------- ----------------------------------------
@ -93,33 +90,16 @@ _ANALOGS_SQL = text("""
# Возраст АКТИВНОГО объявления = экспозиция на сегодня, а не срок продажи: # Возраст АКТИВНОГО объявления = экспозиция на сегодня, а не срок продажи:
# знаменатель — те, кто ещё висит, поэтому величина по построению занижена # знаменатель — те, кто ещё висит, поэтому величина по построению занижена
# относительно «сколько в итоге продавалось». Это ограничение уезжает в note. # относительно «сколько в итоге продавалось». Это ограничение уезжает в note.
#
# ДАТА ПУБЛИКАЦИИ ЛЕЖИТ В ДВУХ КОЛОНКАХ, И ОБЕ ЗНАЧАТ ОДНО. `listing_date`
# наполняют cian (`added_ts`), yandex (`creationDate`) и avito (дата карточки
# выдачи); `publish_date` — yandex (тем же значением) и Домклик
# (`publishedDate`). Замер на проде 2026-08-30 по активным ЕКБ: там, где
# заполнены ОБЕ, они совпадают (yandex 10 761 из 10 903, avito 474 из 569,
# медиана разницы 0 дней) — то есть это не «когда увидели мы» против «когда
# выставили», а одна величина в двух полях.
#
# Поэтому COALESCE: по одному `listing_date` Домклик выпадал ЦЕЛИКОМ (0 из
# 3061 активных строк с датой), и метрика считалась по 25 982 из 31 068
# активных объявлений — 83.6%, о чём подпись молчала. С COALESCE охват
# 29 568 из 31 068 (95.2%), а медиана осталась той же: 26 дней. Охват едет в
# note, потому что 1500 объявлений без даты публикации — это не ноль.
_LISTING_AGE_SQL = text(""" _LISTING_AGE_SQL = text("""
WITH active AS ( SELECT count(*) AS n,
SELECT (CURRENT_DATE - COALESCE(listing_date, publish_date)) AS age_days percentile_cont(0.5) WITHIN GROUP (
ORDER BY (CURRENT_DATE - listing_date)
) AS median
FROM listings FROM listings
WHERE is_active WHERE is_active
AND city = CAST(:city AS text) AND city = CAST(:city AS text)
) AND listing_date IS NOT NULL
SELECT count(*) FILTER (WHERE age_days >= 0) AS n, AND listing_date <= CURRENT_DATE
count(*) AS n_active,
percentile_cont(0.5) WITHIN GROUP (
ORDER BY age_days
) FILTER (WHERE age_days >= 0) AS median
FROM active
""") """)
# ── Динамика цены объявлений ──────────────────────────────────────────────── # ── Динамика цены объявлений ────────────────────────────────────────────────
@ -290,11 +270,9 @@ def collect_landing_metrics(db: Session) -> list[dict[str, Any]]:
"sample_n": int(row.n), "sample_n": int(row.n),
"note": ( "note": (
"Медианная ЭКСПОЗИЦИЯ активного объявления в Екатеринбурге " "Медианная ЭКСПОЗИЦИЯ активного объявления в Екатеринбурге "
"(сколько дней висит на сегодня, от даты публикации у источника). " "(сколько дней висит на сегодня). Это НЕ срок продажи: "
"Это НЕ срок продажи: считается по тем, кто ещё продаётся, и " "считается по тем, кто ещё продаётся, и снятие объявления "
"снятие объявления не означает сделку. Дата публикации известна " "не означает сделку"
f"у {int(row.n)} из {int(row.n_active)} активных объявлений города — "
"остальные в расчёт не входят"
), ),
} }
) )

View file

@ -264,12 +264,6 @@ class DealSample:
sold_ppm2: float sold_ppm2: float
deal_date: Any # datetime.date | None — carried through for reporting only deal_date: Any # datetime.date | None — carried through for reporting only
area_m2: float = 0.0 area_m2: float = 0.0
# Цена ДКП как она записана в договоре. НЕ sold_ppm2 * area_m2: price_per_m2
# в базе integer, и произведение промахивается на единицы рублей
# (4 799 995 против 4 800 000). Витрине нужна цена документа, поэтому поле
# едет из выборки, а не восстанавливается. Бэктест считает в ₽/м² и его не
# использует. None только если в строке нет цены — в выборке ЕКБ таких нет.
price_rub: float | None = None
address: str | None = None address: str | None = None
floor: int | None = None floor: int | None = None
total_floors: int | None = None total_floors: int | None = None
@ -1019,7 +1013,6 @@ _SAMPLE_SQL = text(
ST_Y(geom::geometry) AS lat, ST_Y(geom::geometry) AS lat,
rooms, rooms,
price_per_m2 AS sold_ppm2, price_per_m2 AS sold_ppm2,
price_rub,
deal_date, deal_date,
area_m2, area_m2,
address, address,
@ -1098,7 +1091,6 @@ def _sample_sql(city: str | None) -> Any:
ST_Y(geom::geometry) AS lat, ST_Y(geom::geometry) AS lat,
rooms, rooms,
price_per_m2 AS sold_ppm2, price_per_m2 AS sold_ppm2,
price_rub,
deal_date, deal_date,
area_m2, area_m2,
address, address,
@ -1184,7 +1176,6 @@ def _load_sample(
lat=float(r["lat"]), lat=float(r["lat"]),
rooms=int(r["rooms"]), rooms=int(r["rooms"]),
sold_ppm2=float(r["sold_ppm2"]), sold_ppm2=float(r["sold_ppm2"]),
price_rub=(float(r["price_rub"]) if r["price_rub"] is not None else None),
deal_date=r["deal_date"], deal_date=r["deal_date"],
area_m2=float(r["area_m2"]), area_m2=float(r["area_m2"]),
address=r["address"], address=r["address"],

View file

@ -91,7 +91,7 @@ def _build(**over: object) -> ShowcaseRow | None:
"total_floors": 9, "total_floors": 9,
"deal_date": date(2026, 4, 1), "deal_date": date(2026, 4, 1),
"predicted_rub": 5_000_000.0, "predicted_rub": 5_000_000.0,
"fact_rub": 5_000_000.0, # цена ДКП из договора, ошибка 0% "fact_ppm2": 100_000.0, # → факт 5 000 000 ₽, ошибка 0%
"n_analogs": 30, "n_analogs": 30,
"lat": 56.8386, # ЕКБ: широта ~56.8, долгота ~60.6 — величины НЕ похожи, "lat": 56.8386, # ЕКБ: широта ~56.8, долгота ~60.6 — величины НЕ похожи,
"lon": 60.6055, # поэтому перестановка ловится по значению. "lon": 60.6055, # поэтому перестановка ловится по значению.
@ -108,27 +108,6 @@ def test_plain_row_survives_and_carries_signed_error() -> None:
assert row.deal_quarter == "II квартал 2026" assert row.deal_quarter == "II квартал 2026"
def test_fact_is_the_contract_price_not_the_reconstruction() -> None:
"""«Цена ДКП» — это `deals.price_rub`, а не `price_per_m2 × area_m2`.
Числа взяты с прода (сделка 5777343): в договоре 4 800 000 , а
произведение даёт 4 799 995 `price_per_m2` в базе integer. Витрина
показывала произведение под подписью «Цена ДКП».
Ломать так: вернуть в `build_row` реконструкцию (`fact_ppm2 * area_m2`,
то есть 185 328 × 25.9) тест покраснеет ПО ЗНАЧЕНИЮ: 4 799 995 вместо
4 800 000, и вместе с ним поедет err_pct.
"""
contract_rub = 4_800_000.0
reconstruction = 185_328 * 25.9 # 4 799 995.2 — то, что показывалось раньше
assert round(reconstruction) != contract_rub
row = _build(area_m2=25.9, fact_rub=contract_rub, predicted_rub=contract_rub)
assert row is not None
assert row.fact_rub == 4_800_000, "на витрину уехала реконструкция, а не цена договора"
assert row.err_pct == 0.0, "отклонение считается от той же величины, что показана"
def test_no_error_magnitude_is_ever_rejected() -> None: def test_no_error_magnitude_is_ever_rejected() -> None:
"""Промах оценщика ЛЮБОГО размера остаётся на витрине. """Промах оценщика ЛЮБОГО размера остаётся на витрине.
@ -160,7 +139,7 @@ def test_underdeclared_dkp_is_shown_not_hidden() -> None:
строки в `note`, а не за счёт отсева. строки в `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_ppm2=40_000.0)
assert row is not None assert row is not None
assert row.err_pct == 150.0 assert row.err_pct == 150.0
@ -173,20 +152,13 @@ def test_ppm2_band_is_not_duplicated_here() -> None:
отбраковок срабатывала ровно одна по ошибке. Ломать так: вернуть любую отбраковок срабатывала ровно одна по ошибке. Ломать так: вернуть любую
из границ покраснеет соответствующая половина. из границ покраснеет соответствующая половина.
""" """
assert _build(fact_rub=1_000_000.0, predicted_rub=1_000_000.0) is not None assert _build(fact_ppm2=20_000.0, predicted_rub=1_000_000.0) is not None
assert _build(fact_rub=100_000_000.0, predicted_rub=100_000_000.0) is not None assert _build(fact_ppm2=2_000_000.0, predicted_rub=100_000_000.0) is not None
def test_missing_fact_price_is_rejected() -> None: def test_missing_fact_price_is_rejected() -> None:
"""Нет цены ДКП — строки нет. Реконструкция вместо неё запрещена. """Нулевая цена сделки — это «данных нет», а не «число некрасивое»: делить не на что."""
assert _build(fact_ppm2=0.0) is None
Подставить `price_per_m2 * area_m2` в строку без `price_rub` значило бы
вернуть тот самый дефект в одну строку из двадцати, где его уже не найти.
На проде price_rub заполнен у 33 555 из 33 555 сделок выборки, так что это
защита, а не рабочий путь.
"""
assert _build(fact_rub=None) is None
assert _build(fact_rub=0.0) is None
assert _build(area_m2=0.0) is None assert _build(area_m2=0.0) is None

View file

@ -108,7 +108,7 @@ def _rows(**overrides: Any) -> list[Any]:
base: dict[str, Any] = { base: dict[str, Any] = {
"estimates": SimpleNamespace(total=1123, period_days=94.0), "estimates": SimpleNamespace(total=1123, period_days=94.0),
"analogs": SimpleNamespace(n=975, median=Decimal("12")), "analogs": SimpleNamespace(n=975, median=Decimal("12")),
"listing_age": SimpleNamespace(n=29568, n_active=31068, median=Decimal("26")), "listing_age": SimpleNamespace(n=25943, median=Decimal("26")),
"price": SimpleNamespace(n=6276, n_cut=3018, median_pct_per_month=Decimal("-2.174")), "price": SimpleNamespace(n=6276, n_cut=3018, median_pct_per_month=Decimal("-2.174")),
"deals": SimpleNamespace(n=18657), "deals": SimpleNamespace(n=18657),
} }
@ -171,38 +171,6 @@ def test_listing_age_note_says_exposure_not_time_to_sell() -> None:
assert "НЕ срок продажи" in note assert "НЕ срок продажи" in note
def test_listing_age_note_names_its_coverage() -> None:
"""Знаменатель обязан быть в подписи: метрика видит не все активные.
Дата публикации есть у 29 568 из 31 068 активных объявлений ЕКБ (замер
2026-08-30). Полторы тысячи без даты это не ноль, и «медиана по активным
объявлениям» без охвата читается как «по всем».
Ломать так: убрать из note подстановку n/n_active тест покраснеет.
"""
got = _by_metric(ls.collect_landing_metrics(_FakeSession(_rows())))
note = got["listing_age_median_days"]["note"]
assert "29568" in note and "31068" in note, f"охват не назван: {note}"
def test_listing_age_counts_both_publication_date_columns() -> None:
"""Дата публикации лежит в двух колонках, и обе значат одно.
`listing_date` пишут cian/yandex/avito, `publish_date` yandex (тем же
значением) и Домклик. Там, где заполнены обе, они совпадают (прод
2026-08-30: yandex 10 761 из 10 903, avito 474 из 569). По одному
`listing_date` Домклик выпадал целиком 3061 активное объявление, 0 с
датой, охват 83.6% вместо 95.2%.
Ломать так: вернуть `ORDER BY (CURRENT_DATE - listing_date)` без COALESCE
тест покраснеет.
"""
sql = str(ls._LISTING_AGE_SQL)
assert "COALESCE(listing_date, publish_date)" in sql, (
"метрика снова считает по одной колонке — Домклик выпадает целиком"
)
def test_forecast_accuracy_and_time_to_sell_are_never_produced() -> None: def test_forecast_accuracy_and_time_to_sell_are_never_produced() -> None:
"""Этих величин в данных нет; их считает бэктест со своими допущениями.""" """Этих величин в данных нет; их считает бэктест со своими допущениями."""
names = {m["metric"] for m in ls.collect_landing_metrics(_FakeSession(_rows()))} names = {m["metric"] for m in ls.collect_landing_metrics(_FakeSession(_rows()))}
@ -217,7 +185,7 @@ def test_forecast_accuracy_and_time_to_sell_are_never_produced() -> None:
[ [
({"estimates": SimpleNamespace(total=0, period_days=None)}, "estimates_total"), ({"estimates": SimpleNamespace(total=0, period_days=None)}, "estimates_total"),
({"analogs": SimpleNamespace(n=0, median=None)}, "analogs_median"), ({"analogs": SimpleNamespace(n=0, median=None)}, "analogs_median"),
({"listing_age": SimpleNamespace(n=0, n_active=0, median=None)}, "listing_age_median_days"), ({"listing_age": SimpleNamespace(n=0, median=None)}, "listing_age_median_days"),
( (
{"price": SimpleNamespace(n=0, n_cut=0, median_pct_per_month=None)}, {"price": SimpleNamespace(n=0, n_cut=0, median_pct_per_month=None)},
"price_cut_share_pct", "price_cut_share_pct",
@ -292,7 +260,7 @@ def test_totally_empty_run_keeps_the_showcase_instead_of_wiping_it() -> None:
empty = _rows( empty = _rows(
estimates=SimpleNamespace(total=0, period_days=None), estimates=SimpleNamespace(total=0, period_days=None),
analogs=SimpleNamespace(n=0, median=None), analogs=SimpleNamespace(n=0, median=None),
listing_age=SimpleNamespace(n=0, n_active=0, median=None), listing_age=SimpleNamespace(n=0, median=None),
price=SimpleNamespace(n=0, n_cut=0, median_pct_per_month=None), price=SimpleNamespace(n=0, n_cut=0, median_pct_per_month=None),
deals=SimpleNamespace(n=0), deals=SimpleNamespace(n=0),
) )

View file

@ -1,80 +0,0 @@
/**
* Срок годности ручного замера.
*
* ЗАЧЕМ. Числа бэктеста (медианное расхождение, попадание в коридор, доля
* низкой уверенности) считает человек руками: ночной задачи, как у `/stats`,
* у них нет это записано в шапке `landing-facts.ts` и теперь видно на самой
* витрине (дата замера в подписи блока «Точность»). Задокументированное
* протухание всё равно протухание: пока о нём знает только комментарий,
* пересчёт остаётся ничьей задачей. Красный тест делает его чьей-то.
*
* ЧТО ИМЕННО ОН ЛОВИТ. Не «числа неверны» этого тест знать не может, а
* «замеру больше BACKTEST_MAX_AGE_DAYS дней, и никто его не подтверждал».
* Лечится двумя способами, и оба честные: перегнать бэктест и обновить числа
* вместе с датой, либо снять блок с витрины. Двигать одну лишь дату, не
* перегоняя замер, враньё, и ровно оно тут и сторожится.
*
* ПОЧЕМУ СТОЛЬКО ДНЕЙ. Данные Росреестра приходят квартальными пачками
* (`deals.deal_date` за окно принимает четыре значения по одному на
* квартал), значит чаще чем раз в квартал замеру обновляться не от чего.
* Срок = квартал плюс запас на загрузку следующей пачки.
*/
import { describe, expect, it } from "vitest";
import {
BACKTEST,
BACKTEST_MAX_AGE_DAYS,
BACKTEST_MEASURED_LABEL,
BACKTEST_MEASURED_ON,
BACKTEST_POPULATION,
BACKTEST_SHARE_LABEL,
} from "../landing-facts";
const DAY_MS = 24 * 60 * 60 * 1000;
function ageDays(iso: string, now: number): number {
return Math.floor((now - Date.parse(`${iso}T00:00:00Z`)) / DAY_MS);
}
describe("свежесть ручного замера бэктеста", () => {
it("дата замера разбирается и не из будущего — иначе сторож считает возраст мусора", () => {
const age = ageDays(BACKTEST_MEASURED_ON, Date.now());
expect(
Number.isFinite(age),
`BACKTEST_MEASURED_ON=${BACKTEST_MEASURED_ON} — не ISO-дата`,
).toBe(true);
expect(age, "дата замера в будущем").toBeGreaterThanOrEqual(0);
});
it("замеру не больше срока годности", () => {
const age = ageDays(BACKTEST_MEASURED_ON, Date.now());
expect(
age,
[
`замеру ${age} дн. (${BACKTEST_MEASURED_ON}), допустимо ${BACKTEST_MAX_AGE_DAYS}.`,
"Числа блока «Точность» посчитаны руками и с тех пор никем не подтверждены.",
"Перегнать: python -m scripts.backtest_estimator --city Екатеринбург",
"— обновить BACKTEST, BACKTEST_PERIOD_LABEL (назвать окно, которое реально",
"покрыла выборка), BACKTEST_POPULATION и дату замера; либо снять блок с",
"витрины. Двигать дату без пересчёта — враньё.",
].join(" "),
).toBeLessThanOrEqual(BACKTEST_MAX_AGE_DAYS);
});
it("сторож краснеет на протухшем замере — иначе он зелёный по построению", () => {
// Контроль на инструмент: тот же расчёт возраста, но на заведомо старой
// дате. Без него тест выше остаётся зелёным и при сломанной арифметике.
const staleNow =
Date.parse(`${BACKTEST_MEASURED_ON}T00:00:00Z`) + (BACKTEST_MAX_AGE_DAYS + 1) * DAY_MS;
expect(ageDays(BACKTEST_MEASURED_ON, staleNow)).toBeGreaterThan(BACKTEST_MAX_AGE_DAYS);
});
it("доля выборки выведена из размера выборки, а не вписана рядом", () => {
expect(BACKTEST_POPULATION).toBeGreaterThan(BACKTEST.priceError.sampleN);
const expected = `${((BACKTEST.priceError.sampleN / BACKTEST_POPULATION) * 100)
.toFixed(1)
.replace(".", ",")} %`;
expect(BACKTEST_SHARE_LABEL).toBe(expected);
expect(BACKTEST_MEASURED_LABEL).toContain(BACKTEST_MEASURED_ON.slice(0, 4));
});
});

View file

@ -1,9 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { OBLAST_CITIES } from "@/lib/city-registry"; import { describeCoverage } from "../coverage-copy";
import { describeCityExpectation, describeCoverage } from "../coverage-copy";
import { CITY_COVERAGE } from "../landing-facts";
import type { CoverageProbe } from "../public-api"; import type { CoverageProbe } from "../public-api";
/** /**
@ -114,40 +111,3 @@ describe("describeCoverage", () => {
} }
}); });
}); });
/**
* Ожидаемое качество по городу. Проверяется не вёрстка, а два обещания: город
* из дропдауна не может остаться без замера, и величина названа тем, чем она
* измерена (доля проверок, а не «объём базы» и не «точность»).
*/
describe("describeCityExpectation", () => {
it("у каждого предлагаемого города есть замер — и лишних замеров нет", () => {
const offered = OBLAST_CITIES.map((c) => c.label).sort();
const measured = CITY_COVERAGE.map((m) => m.city).sort();
expect(measured).toEqual(offered);
});
it("у величины есть размер выборки и источник — без них она на витрину не выходит", () => {
for (const measure of CITY_COVERAGE) {
expect(measure.sampleN, `${measure.city}: выборка не указана`).toBeGreaterThan(0);
const said = describeCityExpectation(measure.city);
expect(said, `${measure.city}: нет текста`).not.toBeNull();
expect(said?.source).toContain(String(measure.sampleN));
expect(said?.source.length).toBeGreaterThan(40);
expect(said?.text).toContain(String(measure.confidentPct));
}
});
it("называет ту величину, которая измерена, — долю проверок, а не точность", () => {
const said = describeCityExpectation("Ревда");
expect(said?.text).toContain("проверках");
expect(said?.text).not.toMatch(/точност/i);
// Доли складываются в целое: 16 уверенных + 84 остальных — иначе текст
// утверждает больше, чем измерено.
expect(said?.text).toContain("84");
});
it("неизвестный город не выдумывает величину", () => {
expect(describeCityExpectation("Москва")).toBeNull();
});
});

View file

@ -26,7 +26,7 @@ import { useCallback, useEffect, useId, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent } from "react"; import type { FormEvent, KeyboardEvent } from "react";
import { COVERED_CITIES, PRIMARY_CITY } from "../../content"; import { COVERED_CITIES, PRIMARY_CITY } from "../../content";
import { describeCityExpectation, describeCoverage } from "../../coverage-copy"; import { describeCoverage } from "../../coverage-copy";
import type { CoverageVerdict } from "../../coverage-copy"; import type { CoverageVerdict } from "../../coverage-copy";
import { normalizeDraftRooms, takeDraft } from "../../estimate-draft"; import { normalizeDraftRooms, takeDraft } from "../../estimate-draft";
import { import {
@ -110,7 +110,6 @@ export function EstimateFlow() {
const addressRef = useRef<HTMLInputElement>(null); const addressRef = useRef<HTMLInputElement>(null);
const areaRef = useRef<HTMLInputElement>(null); const areaRef = useRef<HTMLInputElement>(null);
const answerRef = useRef<HTMLDivElement>(null);
const coverageAbort = useRef<AbortController | null>(null); const coverageAbort = useRef<AbortController | null>(null);
// Незавершённый запрос покрытия при уходе со страницы отменяем — иначе // Незавершённый запрос покрытия при уходе со страницы отменяем — иначе
@ -141,32 +140,6 @@ export function EstimateFlow() {
if (normalized) setRooms(normalized); if (normalized) setRooms(normalized);
}, []); }, []);
// Ответ дорисовывается НИЖЕ формы, а не вместо неё (так работает «Изменить
// параметры» в результате — форма остаётся заполненной под ним). На узком
// экране это значит, что после нажатия человек видит ровно ту же форму:
// заголовок ответа оказывается за нижней кромкой (замер на 375 px,
// 30.08.2026), и нажатие читается как «ничего не произошло».
//
// Уводим к ответу и переводим на него фокус. Фокус здесь не украшение: без
// него человек с клавиатуры остаётся на кнопке «Проверить мой дом» и
// следующим Tab уходит В ОБХОД ответа, а не в него. Живая область
// (`role="status"`) объявляет текст сама, но объявление не перемещает точку
// ввода.
//
// `scroll-behavior` фиксированной строкой в JS обходит настройку системы,
// поэтому анимацию спрашиваем у той же медиа-функции, что глушит остальную
// анимацию витрины (`prefers-reduced-motion` в landing-v3.module.css).
// `matchMedia` может отсутствовать (jsdom без стабов) — тогда просто без
// анимации.
useEffect(() => {
if (phase.kind !== "result" && phase.kind !== "failed") return;
const node = answerRef.current;
if (!node) return;
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? true;
node.focus({ preventScroll: true });
node.scrollIntoView({ behavior: reduced ? "auto" : "smooth", block: "start" });
}, [phase.kind]);
// Подсказки: debounce + отмена предыдущего запроса. // Подсказки: debounce + отмена предыдущего запроса.
// //
// Контроллер создаётся СРАЗУ, а не внутри setTimeout, и отменяется в // Контроллер создаётся СРАЗУ, а не внутри setTimeout, и отменяется в
@ -290,10 +263,6 @@ export function EstimateFlow() {
} }
const showList = suggestions.length > 0 && !picked; const showList = suggestions.length > 0 && !picked;
// Чего ждать от проверки в этом городе — ДО нажатия кнопки, а не после.
// Города в списке не равны по данным, и молчание об этом человек читает как
// «везде одинаково».
const cityExpectation = describeCityExpectation(city);
return ( return (
<div className={styles.estCard}> <div className={styles.estCard}>
@ -317,13 +286,6 @@ export function EstimateFlow() {
</select> </select>
</label> </label>
{cityExpectation && (
<div className={styles.heroFormFeedback}>
<p className={styles.heroFormFeedbackText}>{cityExpectation.text}</p>
<p className={styles.heroFormFeedbackText}>{cityExpectation.source}</p>
</div>
)}
{/* Список закрывается по уходу фокуса: иначе он остаётся раскрытым и {/* Список закрывается по уходу фокуса: иначе он остаётся раскрытым и
физически перекрывает поля «Комнат» и «Площадь», в которые человек физически перекрывает поля «Комнат» и «Площадь», в которые человек
как раз собрался попасть. onBlur на контейнере, а не на инпуте, как раз собрался попасть. onBlur на контейнере, а не на инпуте,
@ -450,15 +412,7 @@ export function EstimateFlow() {
{/* Живая область постоянно в DOM: регион, добавленный в момент ответа, {/* Живая область постоянно в DOM: регион, добавленный в момент ответа,
часть скринридеров не озвучивает. */} часть скринридеров не озвучивает. */}
<div <div id={statusId} role="status" aria-live="polite">
id={statusId}
ref={answerRef}
// Точка, в которую уводится фокус после ответа (см. эффект выше).
// -1: программно достижима, из таб-порядка не торчит.
tabIndex={-1}
role="status"
aria-live="polite"
>
{fieldError === "address" && ( {fieldError === "address" && (
<div className={`${styles.heroFormFeedback} ${styles.heroFormFeedbackError}`}> <div className={`${styles.heroFormFeedback} ${styles.heroFormFeedbackError}`}>
<p className={styles.heroFormFeedbackText}> <p className={styles.heroFormFeedbackText}>

View file

@ -2,17 +2,6 @@
* AccuracyV3 «Точность»: KPI-плитки + таблица «прогноз против цены сделки» * AccuracyV3 «Точность»: KPI-плитки + таблица «прогноз против цены сделки»
* (макет v3, ~строки 231-283, id="accuracy"). Серверный компонент. * (макет v3, ~строки 231-283, id="accuracy"). Серверный компонент.
* *
* ОКНО НАЗЫВАЕТСЯ ТО, КОТОРОЕ ИЗМЕРЕНО. В подписи стояло «сделки с июня 2025
* года», а выборка бэктеста берётся `ORDER BY id DESC LIMIT :sample` все её
* 327 сделок пришлись на один квартал (проверка на проде 30.08.2026, разбор в
* `landing-facts.ts` при BACKTEST_PERIOD_LABEL). Рядом с размером выборки
* стоит её ДОЛЯ: «327 сделок» без знаменателя читается как «столько их и
* было», хотя в том же квартале их 5 954.
*
* ДАТА ЗАМЕРА НА ВИТРИНЕ, А НЕ В КОММЕНТАРИИ. Числа бэктеста считаны руками
* и не пересчитываются ночной задачей; без даты они стареют молча. Дату видит
* читатель, а срок годности сторожит `__tests__/backtest-freshness.test.ts`.
*
* ОТКУДА ЧИСЛА. Три первых плитки разовая сверка прогноза с ценой ДКП * ОТКУДА ЧИСЛА. Три первых плитки разовая сверка прогноза с ценой ДКП
* (`landing-facts.ts`, там же источник и оговорки). Экспозиция и число * (`landing-facts.ts`, там же источник и оговорки). Экспозиция и число
* расчётов `/stats`, каждая плитка рендерится только если величина пришла: * расчётов `/stats`, каждая плитка рендерится только если величина пришла:
@ -45,9 +34,7 @@
import { import {
BACKTEST, BACKTEST,
BACKTEST_CORRIDOR_LABEL, BACKTEST_CORRIDOR_LABEL,
BACKTEST_MEASURED_LABEL,
BACKTEST_PERIOD_LABEL, BACKTEST_PERIOD_LABEL,
BACKTEST_SHARE_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";
@ -128,7 +115,7 @@ export function AccuracyV3({
Мы сверили прогноз с ценой сделки вот что вышло Мы сверили прогноз с ценой сделки вот что вышло
</h2> </h2>
<p className={styles.accLead}> <p className={styles.accLead}>
{`Сверка прогноза с ценой ДКП Росреестра: ${BACKTEST_PERIOD_LABEL}, ${BACKTEST.priceError.sampleN} сделок${BACKTEST_SHARE_LABEL} сделок квартала. Разовый ${BACKTEST_MEASURED_LABEL}, регулярного пересчёта у этих чисел нет.`} {`Сверка прогноза с ценой ДКП Росреестра: ${BACKTEST_PERIOD_LABEL}, ${BACKTEST.priceError.sampleN} сделок.`}
{estimates {estimates
? ` Расчёты в системе живут в другом окне: ${estimates.text}${ ? ` Расчёты в системе живут в другом окне: ${estimates.text}${
period ? ` за ${period.text}` : "" period ? ` за ${period.text}` : ""

View file

@ -16,14 +16,6 @@
* единицу, которой её не мерили: подпись приведена к измеренному, а источник * единицу, которой её не мерили: подпись приведена к измеренному, а источник
* назван прямо в ней, а не только в `note` под плиткой. * назван прямо в ней, а не только в `note` под плиткой.
* *
* ЗАГОЛОВОК БЕЗ СРОКА (аудит 30.08). Стояло «против двух месяцев вашей
* жизни»: величина вписана в компонент руками, тогда как обе цифры секции
* приходят из `/stats` с выборками, а «двух месяцев» нет ни в одном замере.
* Подставить измеренную экспозицию вместо неё нельзя это другая величина:
* `median_listing_age_days` считается по объявлениям, которые ЕЩЁ ВИСЯТ
* (цензурированная выборка, см. public-api.ts), и сроком продажи не является.
* Поэтому срок убран, а не заменён числом.
*
* ЧЕГО ЗДЕСЬ БОЛЬШЕ НЕТ. Плитки «×2,4 дольше продаётся квартира с завышенной * ЧЕГО ЗДЕСЬ БОЛЬШЕ НЕТ. Плитки «×2,4 дольше продаётся квартира с завышенной
* ценой»: замер даёт ×1,04 (79 дней против 76), и это уже с цензурой в пользу * ценой»: замер даёт ×1,04 (79 дней против 76), и это уже с цензурой в пользу
* заявления. Её место заняла доля снижающих цену измеренная величина про то * заявления. Её место заняла доля снижающих цену измеренная величина про то
@ -85,7 +77,7 @@ export function CostOfErrorV3({ stats }: { stats: LandingStats }) {
ПОЧЕМУ ЭТО ВАЖНО ПОЧЕМУ ЭТО ВАЖНО
</div> </div>
<h2 id="cost-v3-title" className={styles.costTitle}> <h2 id="cost-v3-title" className={styles.costTitle}>
{`${SERVICE_PRICE_RUB} ₽ против ошибки в цене вашей квартиры`} {`${SERVICE_PRICE_RUB} ₽ против двух месяцев вашей жизни`}
</h2> </h2>
</div> </div>

View file

@ -28,11 +28,6 @@
* похожие объявления УЖЕ висят (`deals.days_on_market` пуст, сверять прогноз * похожие объявления УЖЕ висят (`deals.days_on_market` пуст, сверять прогноз
* срока не с чем). Обещание приведено к этой величине. * срока не с чем). Обещание приведено к этой величине.
* *
* ПОДЗАГОЛОВОК БЕЗ СРОКА (аудит 30.08). Стояло «не зависли НА ПОЛГОДА»
* длительность, которой нет ни в одном замере; ближайшая измеренная величина
* (медианная экспозиция активного объявления) считается по тем, кто ещё
* висит, и сроком продажи не является. Срок убран, а не заменён числом.
*
* ЧЕГО В ТЕКСТЕ БОЛЬШЕ НЕТ: обещания «каждый такой прогноз мы потом сверяем с * ЧЕГО В ТЕКСТЕ БОЛЬШЕ НЕТ: обещания «каждый такой прогноз мы потом сверяем с
* фактом сделки». Контура сверки прогноза клиента с его сделкой в продукте не * фактом сделки». Контура сверки прогноза клиента с его сделкой в продукте не
* существует (`trade_in_leads` 4 строки без полей исхода), и обещать его * существует (`trade_in_leads` 4 строки без полей исхода), и обещать его
@ -110,7 +105,7 @@ export function HeroV3({ stats }: { stats: LandingStats }) {
<p className={styles.heroLead}> <p className={styles.heroLead}>
Мы называем реальную цену вашей квартиры чтобы вы не продали её Мы называем реальную цену вашей квартиры чтобы вы не продали её
дешевле рынка и не зависли в продаже. дешевле и не зависли на полгода.
</p> </p>
<div className={styles.heroChecklist}> <div className={styles.heroChecklist}>

View file

@ -8,21 +8,9 @@
* *
* Ответы НЕ из макета (там реквизит рендера): собраны из уже выверенной * Ответы НЕ из макета (там реквизит рендера): собраны из уже выверенной
* честной копии продукта content.ts (FAQ), oferta/refund. Вопрос про факт * честной копии продукта content.ts (FAQ), oferta/refund. Вопрос про факт
* сделки отвечаем тем, что в продукте ЕСТЬ (сделки Росреестра, сверка * сделки отвечаем тем, что в продукте ЕСТЬ (сделки Росреестра + снятие
* прогноза с ценой ДКП), а не макетной формулировкой про «отметки продавцов», * объявлений с публикации), а не макетной формулировкой про «отметки
* механики которых не существует. * продавцов», механики которых не существует.
*
* СНЯТИЕ ОБЪЯВЛЕНИЙ ОТСЮДА УБРАНО (аудит 30.08). Ответ обещал, что мы
* «отслеживаем снятие объявлений с публикации» и считаем по этому расхождение
* прогноза с реальностью. Ни одного из двух не происходит: расхождение
* считается в `app/tasks/landing_showcase_deals.py` и
* `backend/scripts/backtest_estimator.py`, и оба берут ТОЛЬКО цену ДКП;
* признака снятия в данных нет вовсе `listing_source_snapshot.py` не пишет
* delisted/relisted намеренно (не выводимы при покрытии обхода 10-35%), на
* проде 0 таких строк в `listing_source_events`, а `deals.days_on_market`
* заполнена 0 из 108 623. Страница при этом сама себе и противоречила:
* `AccuracyV3` двумя блоками выше зовёт ту же величину «медианной экспозицией
* АКТИВНОГО объявления» именно потому, что снятие сделкой не является.
* *
* ВЕСЬ `FAQ` ИЗ content.ts ИМПОРТИРУЕТСЯ, А НЕ ПЕРЕПИСЫВАЕТСЯ. Так было не * ВЕСЬ `FAQ` ИЗ content.ts ИМПОРТИРУЕТСЯ, А НЕ ПЕРЕПИСЫВАЕТСЯ. Так было не
* сразу: при переносе брались только `how-do-you-know` и `why-region`, а * сразу: при переносе брались только `how-do-you-know` и `why-region`, а
@ -86,8 +74,7 @@ const ITEMS: readonly Objection[] = [
{ {
q: "Откуда вы знаете факт сделки?", q: "Откуда вы знаете факт сделки?",
a: [ a: [
"Зарегистрированные сделки Росреестра — это цены договоров купли-продажи, а не пожелания продавцов. По ним же мы проверяем себя: прошлые сделки прогоняются через тот же расчёт, и прогноз сравнивается с ценой ДКП — медианное расхождение и число сделок, на которых оно посчитано, стоят в шапке этой страницы.", "К объявлениям мы добавляем зарегистрированные сделки Росреестра и отслеживаем снятие объявлений с публикации. По этим данным считается расхождение прогноза с реальностью — и честно показывается, на скольких объектах построен каждый расчёт.",
"Снятие объявления с публикации сделкой не считаем: по нашим данным нельзя отличить продажу от того, что объявление просто убрали или до него не дошёл обход. И в отчёте, и здесь честно показывается, на скольких объектах построен расчёт.",
], ],
}, },
{ {

View file

@ -22,7 +22,6 @@
* ноль вместо неизвестного значения был бы худшей из ошибок. * ноль вместо неизвестного значения был бы худшей из ошибок.
*/ */
import { CITY_COVERAGE, CITY_COVERAGE_SOURCE } from "./landing-facts";
import type { CoverageProbe } from "./public-api"; import type { CoverageProbe } from "./public-api";
export interface CoverageTile { export interface CoverageTile {
@ -83,37 +82,6 @@ function tilesFor(probe: CoverageProbe): CoverageTile[] {
return tiles; return tiles;
} }
export interface CityExpectation {
/** Что человек увидит рядом с выбранным городом ДО нажатия кнопки. */
text: string;
/** Откуда величина: запрос, база, дата, размер выборки. */
source: string;
}
/**
* Половина городов дропдауна не «отказ», но и не Екатеринбург. Замер лежит
* в `landing-facts.ts::CITY_COVERAGE`, здесь только формулировка.
*
* ГОРОДА НЕ ДЕЛЯТСЯ НА «РАБОЧИЕ» И «НЕТ». Ни в одном из девяти проба не
* упирается в отказ систематически (худший Ревда: 11 пустых из 100), так
* что выбрасывать города из списка не за что. Разница между ними
* количественная её и показываем числом, а не отсутствием опции.
*/
export function describeCityExpectation(city: string): CityExpectation | null {
const measure = CITY_COVERAGE.find((m) => m.city === city);
if (!measure) return null;
const rest = 100 - measure.confidentPct;
return {
text:
`В городе ${city} выборки хватает на уверенный расчёт в ${measure.confidentPct} проверках ` +
`из 100; в остальных ${rest} данных меньше — расчёт мы всё равно сделаем, но разброс ` +
`будет шире, и в ответе это будет написано. В ${measure.emptyPct} случаях из 100 рядом ` +
"не находится ни одной похожей квартиры.",
source: `${CITY_COVERAGE_SOURCE}. Прогнано адресов: ${measure.sampleN}.`,
};
}
export function describeCoverage(probe: CoverageProbe): CoverageVerdict { export function describeCoverage(probe: CoverageProbe): CoverageVerdict {
const where = probe.city ? `в городе ${probe.city}` : "по этому адресу"; const where = probe.city ? `в городе ${probe.city}` : "по этому адресу";

View file

@ -45,7 +45,7 @@ export interface MeasuredValue {
/** Общий источник трёх величин ниже — один и тот же прогон сверки. */ /** Общий источник трёх величин ниже — один и тот же прогон сверки. */
const BACKTEST_SOURCE = const BACKTEST_SOURCE =
"Ручная сверка на проде (poincare, 29.08.2026): прогноз МЕРЫ против цены ДКП " + "Ручная сверка на проде (poincare, 29.08.2026): прогноз МЕРЫ против цены ДКП " +
"Росреестра по Екатеринбургу, сделки II квартала 2026 года"; "Росреестра по Екатеринбургу, сделки с 06.2025";
/** /**
* Сверка «прогноз цена ДКП». Три величины идут КОМПЛЕКТОМ и показываются * Сверка «прогноз цена ДКП». Три величины идут КОМПЛЕКТОМ и показываются
@ -83,66 +83,8 @@ export const BACKTEST: Readonly<Record<"priceError" | "coverage" | "confidenceLo
/** Ширина коридора — та же сверка; стоит в подписи к `coverage`, не отдельной плиткой. */ /** Ширина коридора — та же сверка; стоит в подписи к `coverage`, не отдельной плиткой. */
export const BACKTEST_CORRIDOR_LABEL = "±37 %"; export const BACKTEST_CORRIDOR_LABEL = "±37 %";
/** /** Окно сделок бэктеста. Окно РАСЧЁТОВ — другое, оно приходит из `/stats`. */
* Окно сделок бэктеста. Окно РАСЧЁТОВ другое, оно приходит из `/stats`. export const BACKTEST_PERIOD_LABEL = "сделки с июня 2025 года";
*
* ЗДЕСЬ СТОЯЛО «сделки с июня 2025 года» и это называло не то окно, которое
* измерено. `--since 2025-06-01` задаёт только нижнюю границу, а выборку
* скрипт берёт `ORDER BY id DESC LIMIT :sample`
* (`backend/scripts/backtest_estimator.py`, _SAMPLE_SQL): это последние по
* порядку загрузки строки, а не срез по всему окну. Проверено на проде
* 30.08.2026: у всех 327 сделок выборки `deal_date = 2026-04-01`, то есть
* ровно один квартал (проверены оба варианта запуска и без `--city`, и с
* `--city Екатеринбург`; результат один и тот же). Случайной выборки в
* скрипте нет, поэтому чинится ПОДПИСЬ, а не замер: числа остаются те же,
* названо окно, которое они покрыли.
*
* `deal_date` у Росреестра не дата сделки, а метка квартала: во всей таблице
* за окно ровно четыре значения (01.07.2025, 01.10.2025, 01.01.2026,
* 01.04.2026). Поэтому «квартал» предельная точность, которую данные вообще
* позволяют назвать; «сделки за апрель» было бы вторым враньём.
*/
export const BACKTEST_PERIOD_LABEL = "сделки II квартала 2026 года";
/**
* Сколько сделок было В ТОМ ЖЕ окне чтобы «мы сверили 327 сделок» не
* читалось как «столько их и было». Замер 30.08.2026 на проде: годных под те
* же фильтры выборки (`source='rosreestr'`, geom, ppm² 30 000..600 000, rooms,
* area) сделок Екатеринбурга с `deal_date = 2026-04-01` 5 954.
*
* ЗНАМЕНАТЕЛЬ БЕРЁТСЯ ИЗ ТОГО ЖЕ ОКНА, что и выборка. Соблазн подставить сюда
* 24 333 (все сделки ЕКБ с июня 2025) даёт долю красивее 1,3 % вместо
* 5,5 %, но это ровно та же ошибка, что чинится выше: доля от окна, которое
* замер не покрывал.
*/
export const BACKTEST_POPULATION = 5_954;
/**
* Доля выборки. ВЫВОДИТСЯ, а не вписывается: разъехаться с `sampleN` не может.
*/
export const BACKTEST_SHARE_LABEL = `${((BACKTEST.priceError.sampleN / BACKTEST_POPULATION) * 100)
.toFixed(1)
.replace(".", ",")} %`;
/**
* Дата замера на витрину, а не только в комментарий.
*
* Числа бэктеста считает человек руками, ночной задачи для них нет (см. шапку
* файла), поэтому они стареют молча. Дата рядом с числом первый признак
* протухания, который видит читатель; второй, обязательный для нас,
* `__tests__/backtest-freshness.test.ts`: он краснеет, когда замеру больше
* BACKTEST_MAX_AGE_DAYS. Пока теста не было, «пересчитать» было ничьей
* задачей.
*/
export const BACKTEST_MEASURED_ON = "2026-08-29";
/** Через сколько дней замер считается протухшим (см. тест свежести). */
export const BACKTEST_MAX_AGE_DAYS = 100;
const [measuredY, measuredM, measuredD] = BACKTEST_MEASURED_ON.split("-");
/** Человекочитаемая дата замера. Выводится из ISO — двух правок не требует. */
export const BACKTEST_MEASURED_LABEL = `замер ${measuredD}.${measuredM}.${measuredY}`;
/** /**
* Бейдж первого шага И подпись под кнопкой формы (`FreeCheckCard.tsx`). * Бейдж первого шага И подпись под кнопкой формы (`FreeCheckCard.tsx`).
@ -163,60 +105,3 @@ export const BACKTEST_MEASURED_LABEL = `замер ${measuredD}.${measuredM}.${m
* независимо, и правка одного не касалась остальных. * независимо, и правка одного не касалась остальных.
*/ */
export const STEP1_FIELDS_LABEL = "6 полей"; export const STEP1_FIELDS_LABEL = "6 полей";
/**
* Чего ждать от бесплатной пробы в каждом городе дропдауна.
*
* ЗАЧЕМ. В форме предлагается девять городов, и они не равны по данным. До
* этого замера человек узнавал об этом только ПОСЛЕ нажатия кнопки из
* честного, но запоздалого «данные есть, но их мало». Предлагать выбор,
* ничего не говоря о его цене, плохой продукт, даже когда отказ честен.
*
* ЧТО ИМЕННО ИЗМЕРЕНО (и чем это НЕ является). Не «объём базы» и не «число
* объявлений в городе». Измерена доля проб, которые вернули бы выборку не
* меньше городского порога `_COVERAGE_CITY_THRESHOLDS`
* (`backend/app/api/v1/trade_in.py`; порог 8 у ближнего круга, 12 у дальних
* городов) то есть доля проверок, на которые сервис отвечает «данных
* хватает», а не «данных мало».
*
* ПОЧЕМУ НЕ СЧЁТ ПО `listings.city`. Эта колонка хранит город СВИПА скрейпера,
* а не геокод объявления (миграция 196, разбор над `_CITY_CENTROIDS_DEG` в
* `trade_in.py`): вокруг Берёзовского 90/90 строк лежат с city='Екатеринбург'.
* Счёт по ней даёт ноль по трём городам-спутникам и читается как «города нет
* в базе» вывод неверный, ошибка в мерке. Города здесь резолвятся по
* координатам, тем же правилом ближайшего центроида, что и сама проба.
*
* ЧЕГО ЗАМЕР НЕ ЗНАЕТ. Точки взяты из адресов активных объявлений, а не из
* жилого фонда: там, где никто ничего не продаёт, мы не мерили. Свой адрес
* пробы исключён из когорты иначе `emptyPct` был бы нулём по построению
* (объявление всегда попадает в собственный радиус), и «ни один город не
* пуст» оказалось бы свойством запроса, а не данных.
*/
export interface CityCoverageMeasure {
/** Лейбл ровно как в `OBLAST_CITIES` — по нему город и находится. */
readonly city: string;
/** Доля проб с выборкой ≥ городского порога, % (округление до целого). */
readonly confidentPct: number;
/** Доля проб, у которых рядом не нашлось ни одной похожей квартиры, %. */
readonly emptyPct: number;
/** Сколько адресов прогнали. Где объявлений меньше — там и выборка меньше. */
readonly sampleN: number;
}
export const CITY_COVERAGE_SOURCE =
"Симуляция пробы покрытия на боевой базе (poincare, 30.08.2026): случайные адреса " +
"активных объявлений, для каждого — когорта самой ручки /coverage (радиус 1 км, то же " +
"число комнат, площадь ±15 %, свежесть 14 дней, дедуп по источнику и адресу), " +
"собственный адрес из когорты исключён";
export const CITY_COVERAGE: readonly CityCoverageMeasure[] = [
{ city: "Екатеринбург", confidentPct: 83, emptyPct: 3, sampleN: 120 },
{ city: "Верхняя Пышма", confidentPct: 70, emptyPct: 5, sampleN: 120 },
{ city: "Серов", confidentPct: 58, emptyPct: 7, sampleN: 120 },
{ city: "Нижний Тагил", confidentPct: 52, emptyPct: 7, sampleN: 120 },
{ city: "Первоуральск", confidentPct: 50, emptyPct: 3, sampleN: 120 },
{ city: "Каменск-Уральский", confidentPct: 45, emptyPct: 6, sampleN: 120 },
{ city: "Среднеуральск", confidentPct: 39, emptyPct: 21, sampleN: 56 },
{ city: "Берёзовский", confidentPct: 38, emptyPct: 18, sampleN: 120 },
{ city: "Ревда", confidentPct: 16, emptyPct: 11, sampleN: 120 },
];