fix(mera): витрина больше не отсеивает промахи оценщика, счётчики едут на фронт
Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Failing after 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m40s

Ревью MAJOR по честности, два пункта.

1. Убран MAX_ABS_ERR_PCT = 40 из build_row. Докстринг модуля сам запрещает
отбор по величине ошибки, но запрет был реализован только в _sort_key, а
фильтр — тот же отбор ступенькой раньше, и злее: строка не попадала даже в
кандидаты. Обоснование «отклонение >40% — почти всегда занижение ДКП ради
налога» не держится: _load_sample уже режет выборку санитарным диапазоном
₽/м² (для ЕКБ это глобальные PPM2_MIN=30k / PPM2_MAX=600k — город намеренно
не заведён в deal_city_price_bands), то есть грубые занижения вырезаны выше
по потоку и ПО СВОЙСТВУ САМОЙ СДЕЛКИ. Всё, что после этого дало большую
ошибку, — работа оценщика, и посетитель обязан её видеть. Честность про
заниженные ДКП перенесена в note каждой строки.

Заодно убраны MIN_FACT_PPM2=30k (дублировал уже применённый фильтр) и
MAX_FACT_PPM2=1.2M (недостижим при потолке выборки 600k): из трёх отбраковок
в проде срабатывала ровно одна — та, что льстила витрине, а два мёртвых
порога читались как работающие. Осталась только структурная отбраковка «нет
прогноза / квартала / площади».

2. Счётчики прогона выведены в ответ ручки. Итог пересчёта пишется в
landing_showcase_runs (миграция 277) и уезжает в ShowcaseResponse.stats
вместе с правилом отбраковки: показано 20 из N годных, рассмотрено M сделок.
Отдельная таблица, а не колонки в строках, — иначе в самом важном случае
(показывать нечего) счётчики исчезли бы вместе со строками. Ручка теперь
берёт и строки, и числа ИЗ ОДНОГО прогона: иначе пустой прогон показал бы
вчерашние строки под сегодняшними счётчиками.

Тесты двусторонние и проверены на сломанном коде: возврат любого порога по
ошибке → красный с величиной отклонения в сообщении; возврат любой границы
₽/м² → красная своя половина; stats=None при живом прогоне → красный.
This commit is contained in:
bot-backend 2026-08-29 19:09:54 +05:00
parent 030aa38b67
commit 4507afea89
5 changed files with 312 additions and 77 deletions

View file

@ -316,19 +316,57 @@ class ShowcaseDeal(BaseModel):
note: str note: str
class ShowcaseStats(BaseModel):
"""Итог прогона, который дал показанные строки. Подпись под витриной.
Без этих чисел «20 отличных строк» неотличимо от «столько и было»:
посетитель не может отличить выборку из работы оценщика от её лучшего
хвоста. `eligible` минус `written` сколько годных строк не поместилось
в витрину; `rejection_rule` по какому правилу отсеяно остальное,
записанное ТЕМ прогоном, который эти строки посчитал.
"""
considered: int
priced: int
no_prediction: int
incomplete: int
eligible: int
written: int
with_district: int
rejection_rule: str
class ShowcaseResponse(BaseModel): class ShowcaseResponse(BaseModel):
"""Витрина целиком. `computed_at` — когда считали, `deals` — что показываем.""" """Витрина целиком: когда считали, что показываем и из чего это отобрано.
`stats` = None только до первого пересчёта тогда и `deals` пуст.
"""
computed_at: str | None computed_at: str | None
deals: list[ShowcaseDeal] deals: list[ShowcaseDeal]
stats: ShowcaseStats | None = None
# Последний прогон — единственная точка отсчёта: и `computed_at`, и счётчики, и
# набор строк берутся ИЗ НЕГО. Брать строки по своему max(computed_at) значило
# бы, что прогон, не давший ни одной строки, показывает вчерашние строки под
# сегодняшними счётчиками.
_SHOWCASE_RUN_SQL = text(
"""
SELECT computed_at, considered, priced, no_prediction, incomplete,
eligible, written, with_district, rejection_rule
FROM landing_showcase_runs
ORDER BY computed_at DESC, id DESC
LIMIT 1
"""
)
_SHOWCASE_SQL = text( _SHOWCASE_SQL = text(
""" """
SELECT district, rooms, area_m2, floor, total_floors, deal_quarter, SELECT district, rooms, area_m2, floor, total_floors, deal_quarter,
predicted_rub, fact_rub, err_pct, n_analogs, note, computed_at predicted_rub, fact_rub, err_pct, n_analogs, note
FROM landing_showcase_deals FROM landing_showcase_deals
WHERE computed_at = (SELECT max(computed_at) FROM landing_showcase_deals) WHERE computed_at = CAST(:computed_at AS timestamptz)
ORDER BY id ORDER BY id
""" """
) )
@ -348,12 +386,28 @@ def public_showcase(
Пустой список штатный ответ, а не ошибка: до первого пересчёта показывать Пустой список штатный ответ, а не ошибка: до первого пересчёта показывать
нечего, и это ровно то, что фронт должен увидеть вместо выдуманных строк. нечего, и это ровно то, что фронт должен увидеть вместо выдуманных строк.
Вместе со строками едет `stats` сколько сделок рассмотрено, сколько
годных строк не поместилось и по какому правилу отсеяно остальное. Числа
считает пересчёт; без них витрина не имеет права подписаться честно.
""" """
_enforce(_showcase_limiter, request, "showcase") _enforce(_showcase_limiter, request, "showcase")
rows = db.execute(_SHOWCASE_SQL).mappings().all() run = db.execute(_SHOWCASE_RUN_SQL).mappings().first()
computed_at = rows[0]["computed_at"].isoformat() if rows else None if run is None:
return ShowcaseResponse(computed_at=None, deals=[], stats=None)
rows = db.execute(_SHOWCASE_SQL, {"computed_at": run["computed_at"]}).mappings().all()
return ShowcaseResponse( return ShowcaseResponse(
computed_at=computed_at, computed_at=run["computed_at"].isoformat(),
stats=ShowcaseStats(
considered=int(run["considered"]),
priced=int(run["priced"]),
no_prediction=int(run["no_prediction"]),
incomplete=int(run["incomplete"]),
eligible=int(run["eligible"]),
written=int(run["written"]),
with_district=int(run["with_district"]),
rejection_rule=run["rejection_rule"],
),
deals=[ deals=[
ShowcaseDeal( ShowcaseDeal(
district=r["district"], district=r["district"],

View file

@ -20,6 +20,16 @@
код формально работает, а продукт врёт. Проверяется тестом код формально работает, а продукт врёт. Проверяется тестом
`test_landing_showcase_deals.py::test_selection_ignores_error_magnitude`. `test_landing_showcase_deals.py::test_selection_ignores_error_magnitude`.
ФИЛЬТРА ПО ОШИБКЕ ТОЖЕ НЕТ И ЭТО ТО ЖЕ САМОЕ ПРАВИЛО. До 2026-08-29 здесь
жил порог `MAX_ABS_ERR_PCT = 40`, выбрасывавший кандидата ПО ВЕЛИЧИНЕ ОШИБКИ
до ранжирования. Запрет выше он обходил ступенькой раньше: отбор по ошибке в
ключе и отбор по ошибке в фильтре одно и то же действие, и второе даже
злее, потому что не оставляет строку в кандидатах. Обоснование «отклонение
больше 40% это почти всегда занижение ДКП ради налога» не держится: см.
следующий раздел, грубые занижения вырезаны выше по потоку и по свойству
самой сделки. Отбраковываем только то, чего в данных НЕТ (нет прогноза, нет
квартала, нет площади) «число некрасивое» причиной не является.
Полнота сколько из полей, которые видит посетитель (район, этаж, этажность), Полнота сколько из полей, которые видит посетитель (район, этаж, этажность),
у строки заполнено. Свежесть порядок квартала сделки. у строки заполнено. Свежесть порядок квартала сделки.
@ -33,10 +43,24 @@
объявлениям, а сделка прошлая. Между ними дрейф рынка, который в ошибку объявлениям, а сделка прошлая. Между ними дрейф рынка, который в ошибку
входит целиком. Это записано в `note` КАЖДОЙ строки, а не только здесь: входит целиком. Это записано в `note` КАЖДОЙ строки, а не только здесь:
поле note едет на фронт вместе с числами, а докстринг нет. поле note едет на фронт вместе с числами, а докстринг нет.
* ЦЕНА ДКП БЫВАЕТ ЗАНИЖЕНА (налоговая оптимизация, сделки между своими). * ЦЕНА ДКП БЫВАЕТ ЗАНИЖЕНА (налоговая оптимизация, сделки между своими), и
Отсюда отбраковка выбросов ниже и счётчики отбракованного в логе, чтобы такая строка выглядит как чудовищный промах оценщика. Санитарный диапазон
«мы показываем 20 отличных строк» нельзя было прочитать как «столько и /м² применяется ОДИН раз и ВЫШЕ ПО ПОТОКУ в `_load_sample`, по свойству
было». самой сделки, а не по ошибке прогноза: для ЕКБ это глобальные
`PPM2_MIN = 30 000` / `PPM2_MAX = 600 000` (город намеренно не заведён в
`deal_city_price_bands`, там же и комментарий об этом). Значит грубые
занижения из выборки уже вырезаны ДО того, как сюда приходит кандидат, а
всё, что после этого дало большую ошибку, работа оценщика, и витрина
обязана её показать. Своей копии диапазона здесь нет намеренно: прежние
`MIN_FACT_PPM2 = 30k` дублировал уже применённый фильтр, а
`MAX_FACT_PPM2 = 1.2M` был недостижим при потолке выборки 600k из трёх
отбраковок в проде срабатывала РОВНО ОДНА, та самая, что льстила витрине.
Неработающая проверка читается как работающая, поэтому её нет.
* СЧЁТЧИКИ ЕДУТ НА ФРОНТ, А НЕ ТОЛЬКО В ЛОГ. «Мы показываем 20 отличных
строк» неотличимо от «столько и было», пока рядом не написано, сколько
сделок рассмотрено и сколько годных строк не поместилось. Поэтому итог
прогона пишется в `landing_showcase_runs` (миграция 277) и отдаётся
ручкой `/api/public/mera/showcase` вместе со строками.
ЗАПУСК (прод, read-mostly: один DELETE+INSERT в свою таблицу):: ЗАПУСК (прод, read-mostly: один DELETE+INSERT в свою таблицу)::
@ -60,24 +84,25 @@ from sqlalchemy.orm import Session
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ── Пороги отбраковки выбросов ─────────────────────────────────────────────── # ── Правило отбраковки: одна формулировка, она же едет на фронт ──────────────
# #
# ДКП-цена — это то, что стороны заявили в Росреестр. Занижение до 1 млн ради # Порогов на величину ошибки здесь НЕТ (разбор — в докстринге модуля). Санитарный
# налога встречается, и такая строка на витрине выглядит как чудовищный промах # диапазон ₽/м² применён выше по потоку, в `_load_sample`; дублировать его тут
# оценщика, хотя оценщик прав, а врёт документ. Отбрасываем обе стороны хвоста # значило бы завести проверку, которая в проде не срабатывает никогда.
# по отклонению и заведомо невозможный ₽/м². REJECTION_RULE = (
MAX_ABS_ERR_PCT = 40.0 "Строка не попадает на витрину, только если данных нет: оценщик не дал "
MIN_FACT_PPM2 = 30_000.0 "ожидаемой цены продажи (мало аналогов), неизвестен квартал сделки или "
MAX_FACT_PPM2 = 1_200_000.0 "площадь. Величина отклонения на отбор и отбраковку не влияет — иначе "
"витрина показывала бы лучший хвост, а не работу оценщика. Санитарный "
# Замер по ЕКБ (n=398): MAPE 14.24%, покрытие коридором 88.22%. Порог 40% "диапазон цены сделки (30 000600 000 ₽/м² для Екатеринбурга) применён "
# отсекает хвост, а не «неудобные» строки: при MAPE 14% строка с отклонением "к выборке до расчёта, по цене самой сделки."
# больше 40% — это почти всегда дефект цены в ДКП, а не дефект прогноза. )
NOTE = ( NOTE = (
"Прогноз посчитан по активным объявлениям на дату пересчёта, сделка — прошлая: " "Прогноз посчитан по активным объявлениям на дату пересчёта, сделка — прошлая: "
"это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. " "это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. "
"Факт — цена ДКП, заявленная в Росреестр." "Факт — цена ДКП, заявленная в Росреестр: она бывает занижена сторонами, и тогда "
"строка выглядит как промах оценщика, хотя врёт документ."
) )
_ROMAN = {1: "I", 2: "II", 3: "III", 4: "IV"} _ROMAN = {1: "I", 2: "II", 3: "III", 4: "IV"}
@ -144,26 +169,23 @@ def build_row(
fact_ppm2: float, fact_ppm2: float,
n_analogs: int, n_analogs: int,
) -> ShowcaseRow | None: ) -> ShowcaseRow | None:
"""Кандидат → строка витрины, либо None если строка отбракована. """Кандидат → строка витрины, либо 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 return None
quarter = quarter_label(deal_date) quarter = quarter_label(deal_date)
if quarter is None: if quarter is None:
return None return None
if not (MIN_FACT_PPM2 <= fact_ppm2 <= MAX_FACT_PPM2):
return None
fact_rub = fact_ppm2 * area_m2 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
if abs(err_pct) > MAX_ABS_ERR_PCT:
return None
return ShowcaseRow( return ShowcaseRow(
deal_id=deal_id, deal_id=deal_id,
@ -213,6 +235,22 @@ def _fetch_districts(db: Session, deal_ids: list[int]) -> dict[int, str]:
_DELETE_SQL = text("DELETE FROM landing_showcase_deals") _DELETE_SQL = text("DELETE FROM landing_showcase_deals")
_DELETE_RUNS_SQL = text("DELETE FROM landing_showcase_runs")
# Тот же `now()`, что у DEFAULT в строках витрины: в Postgres now() — время
# НАЧАЛА транзакции, а батч и его итог пишутся одной транзакцией. Ручка по
# этому computed_at и связывает счётчики со строками.
_INSERT_RUN_SQL = text(
"""
INSERT INTO landing_showcase_runs
(considered, priced, no_prediction, incomplete, eligible, written,
with_district, rejection_rule)
VALUES
(CAST(:considered AS integer), CAST(:priced AS integer),
CAST(:no_prediction AS integer), CAST(:incomplete AS integer),
CAST(:eligible AS integer), CAST(:written AS integer),
CAST(:with_district AS integer), CAST(:rejection_rule AS text))
"""
)
_INSERT_SQL = text( _INSERT_SQL = text(
""" """
INSERT INTO landing_showcase_deals INSERT INTO landing_showcase_deals
@ -238,9 +276,18 @@ def refresh_landing_showcase_deals(
) -> dict[str, int]: ) -> dict[str, int]:
"""Прогнать бэктест по ЕКБ и перезаписать витрину. Возвращает счётчики. """Прогнать бэктест по ЕКБ и перезаписать витрину. Возвращает счётчики.
Счётчики (`considered` / `priced` / `rejected_outlier` / `written`) Счётчики не отладочный шум: без них «на витрине 20 отличных строк»
не отладочный шум: без них «на витрине 20 отличных строк» неотличимо от неотличимо от «столько и было». Поэтому они не только пишутся в лог, но и
«столько и было». Отбракованное печатается в лог всегда. сохраняются в `landing_showcase_runs` и уезжают на фронт вместе со
строками. Значения:
considered сколько ДКП-сделок взято в работу
priced из них оценщик дал ожидаемую цену продажи
no_prediction не дал (мало аналогов / спайн упал)
incomplete цена есть, но нет квартала/площади строку не собрать
eligible годных строк ВСЕГО (никакого отсева по ошибке нет)
written из них показано (обрезано по `limit`)
with_district у скольких показанных удалось определить район
""" """
# Импорт внутри функции: `scripts.backtest_estimator` тянет оценщик со всеми # Импорт внутри функции: `scripts.backtest_estimator` тянет оценщик со всеми
# его зависимостями, а web-процессу это на импорте приложения не нужно. # его зависимостями, а web-процессу это на импорте приложения не нужно.
@ -258,7 +305,7 @@ def refresh_landing_showcase_deals(
candidates: list[ShowcaseRow] = [] candidates: list[ShowcaseRow] = []
n_priced = 0 n_priced = 0
n_rejected = 0 n_incomplete = 0
for deal in deals: for deal in deals:
capture: list[dict[str, Any]] = [] capture: list[dict[str, Any]] = []
try: try:
@ -283,13 +330,14 @@ def refresh_landing_showcase_deals(
n_analogs=len(capture[0]["kwargs"]["listings"]) if capture else 0, n_analogs=len(capture[0]["kwargs"]["listings"]) if capture else 0,
) )
if row is None: if row is None:
n_rejected += 1 n_incomplete += 1
continue continue
candidates.append(row) candidates.append(row)
chosen = select_rows(candidates, limit) chosen = select_rows(candidates, limit)
db.execute(_DELETE_SQL) db.execute(_DELETE_SQL)
db.execute(_DELETE_RUNS_SQL)
for row in chosen: for row in chosen:
db.execute( db.execute(
_INSERT_SQL, _INSERT_SQL,
@ -307,21 +355,26 @@ def refresh_landing_showcase_deals(
"note": NOTE, "note": NOTE,
}, },
) )
db.commit()
counters = { counters = {
"considered": len(deals), "considered": len(deals),
"priced": n_priced, "priced": n_priced,
"rejected_outlier": n_rejected, "no_prediction": len(deals) - n_priced,
"with_district": sum(1 for r in chosen if r.district is not None), "incomplete": n_incomplete,
"eligible": len(candidates),
"written": len(chosen), "written": len(chosen),
"with_district": sum(1 for r in chosen if r.district is not None),
} }
db.execute(_INSERT_RUN_SQL, {**counters, "rejection_rule": REJECTION_RULE})
db.commit()
logger.info( logger.info(
"витрина обновлена: рассмотрено=%d оценено=%d отбраковано_выбросов=%d " "витрина обновлена: рассмотрено=%d оценено=%d без_прогноза=%d неполных=%d "
"записано=%d с_районом=%d", "годных=%d записано=%d с_районом=%d",
counters["considered"], counters["considered"],
counters["priced"], counters["priced"],
counters["rejected_outlier"], counters["no_prediction"],
counters["incomplete"],
counters["eligible"],
counters["written"], counters["written"],
counters["with_district"], counters["with_district"],
) )

View file

@ -0,0 +1,38 @@
-- 277: итог пересчёта витрины лэндинга — счётчики рядом со строками.
--
-- ЗАЧЕМ ОТДЕЛЬНАЯ ТАБЛИЦА, А НЕ КОЛОНКИ В landing_showcase_deals. Счётчики —
-- факт ПРОГОНА, а не строки: на 20 строк они были бы продублированы 20 раз, а
-- в самом важном случае — когда показывать оказалось нечего — исчезли бы
-- вместе со строками. «Рассмотрено 200, показывать нечего» обязано доезжать до
-- фронта ровно так же, как «рассмотрено 200, показано 20».
--
-- ЗАЧЕМ ВООБЩЕ. Витрина показывает 20 сделок «МЕРА сказала X — продали за Y».
-- Без чисел рядом эти 20 строк читаются как «столько и было»: посетитель не
-- может отличить выборку из работы оценщика от её лучшего хвоста. Поэтому
-- ручка /api/public/mera/showcase отдаёт вместе со строками, сколько сделок
-- рассмотрено, сколько годных строк не поместилось и по какому правилу
-- отбраковано остальное.
--
-- rejection_rule — ТЕКСТ, А НЕ КОД ПРАВИЛА. Правило живёт в
-- app/tasks/landing_showcase_deals.REJECTION_RULE и пишется сюда тем прогоном,
-- который эти числа и посчитал: подпись под витриной обязана описывать ТОТ
-- отбор, что дал эти строки, а не тот, что задеплоен сегодня.
--
-- computed_at совпадает со строками батча: задача пишет строки и этот итог
-- ОДНОЙ транзакцией, а now() в Postgres — время начала транзакции.
CREATE TABLE IF NOT EXISTS landing_showcase_runs (
id bigserial PRIMARY KEY,
computed_at timestamptz NOT NULL DEFAULT now(),
considered integer NOT NULL,
priced integer NOT NULL,
no_prediction integer NOT NULL,
incomplete integer NOT NULL,
eligible integer NOT NULL,
written integer NOT NULL,
with_district integer NOT NULL,
rejection_rule text NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_landing_showcase_runs_computed_at
ON landing_showcase_runs (computed_at DESC);

View file

@ -16,7 +16,6 @@ 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 (
MAX_ABS_ERR_PCT,
ShowcaseRow, ShowcaseRow,
build_row, build_row,
quarter_label, quarter_label,
@ -79,7 +78,7 @@ def test_selection_is_deterministic_on_full_ties() -> None:
assert [r.deal_id for r in select_rows(rows, limit=3)] == [9, 8, 7] assert [r.deal_id for r in select_rows(rows, limit=3)] == [9, 8, 7]
# ── Отбраковка выбросов ────────────────────────────────────────────────────── # ── Отбраковка: только «данных нет», никогда «число некрасивое» ──────────────
def _build(**over: object) -> ShowcaseRow | None: def _build(**over: object) -> ShowcaseRow | None:
@ -107,23 +106,58 @@ def test_plain_row_survives_and_carries_signed_error() -> None:
assert row.deal_quarter == "II квартал 2026" assert row.deal_quarter == "II квартал 2026"
def test_underdeclared_dkp_is_rejected() -> None: def test_no_error_magnitude_is_ever_rejected() -> None:
"""Занижение ради налога выглядит как чудовищный промах оценщика.""" """Промах оценщика ЛЮБОГО размера остаётся на витрине.
Это второй половина запрета «не отбирать по ошибке»: фильтр по величине
ошибки тот же отбор, просто ступенькой раньше, и он тем злее, что не
оставляет строку даже в кандидатах.
Ломать так: вернуть в `build_row` любой порог вида
`if abs(err_pct) > X: return None` тест покраснеет на первом же
отклонении больше X с этим отклонением в сообщении.
"""
fact_rub = 5_000_000.0 # 100 000 ₽/м² × 50 м²
for err_pct in (-95.0, -60.0, -41.0, -5.0, 0.0, 5.0, 41.0, 150.0, 900.0):
row = _build(predicted_rub=fact_rub * (1 + err_pct / 100))
assert row is not None, (
f"строка с отклонением {err_pct:+.0f}% выброшена: витрина снова "
"показывает лучший хвост, а не работу оценщика"
)
assert row.err_pct == round(err_pct, 2)
def test_underdeclared_dkp_is_shown_not_hidden() -> None:
"""Занижение ДКП ради налога выглядит как промах — и всё равно показывается.
Прятать такие строки нельзя: «отклонение больше 40% это почти всегда
дефект ДКП» было догадкой, а санитарный диапазон /м² уже применён к
выборке выше по потоку (`_load_sample`, для ЕКБ 30k..600k). Всё, что
прошло его и дало большую ошибку, работа оценщика. Честность за счёт
строки в `note`, а не за счёт отсева.
"""
# Факт 2 000 000 ₽ против прогноза 5 000 000 — отклонение +150%. # Факт 2 000 000 ₽ против прогноза 5 000 000 — отклонение +150%.
assert _build(fact_ppm2=40_000.0) is None row = _build(fact_ppm2=40_000.0)
assert row is not None
assert row.err_pct == 150.0
def test_error_just_under_threshold_survives() -> None: def test_ppm2_band_is_not_duplicated_here() -> None:
"""Порог отсекает хвост, а не «неудобные» строки — граница проверена с двух сторон.""" """Своей копии ₽/м²-диапазона в `build_row` нет — она была мёртвой.
inside = _build(predicted_rub=5_000_000.0 * (1 + (MAX_ABS_ERR_PCT - 1) / 100))
outside = _build(predicted_rub=5_000_000.0 * (1 + (MAX_ABS_ERR_PCT + 1) / 100)) `MIN_FACT_PPM2 = 30k` дублировал уже применённый фильтр выборки, а
assert inside is not None `MAX_FACT_PPM2 = 1.2M` был недостижим при её потолке 600k: из трёх
assert outside is None отбраковок срабатывала ровно одна по ошибке. Ломать так: вернуть любую
из границ покраснеет соответствующая половина.
"""
assert _build(fact_ppm2=20_000.0, predicted_rub=1_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_impossible_ppm2_is_rejected_on_both_ends() -> None: def test_missing_fact_price_is_rejected() -> None:
assert _build(fact_ppm2=20_000.0, predicted_rub=1_000_000.0) is None """Нулевая цена сделки — это «данных нет», а не «число некрасивое»: делить не на что."""
assert _build(fact_ppm2=2_000_000.0, predicted_rub=100_000_000.0) is None assert _build(fact_ppm2=0.0) is None
assert _build(area_m2=0.0) is None
def test_no_expected_sold_price_is_not_invented() -> None: def test_no_expected_sold_price_is_not_invented() -> None:

View file

@ -158,29 +158,47 @@ def test_anonymous_gets_suggest(client: TestClient) -> None:
assert resp.json() == {"items": []} assert resp.json() == {"items": []}
_SHOWCASE_ROW = {
"district": None,
"rooms": 2,
"area_m2": 54.0,
"floor": 5,
"total_floors": None,
"deal_quarter": "II квартал 2026",
"predicted_rub": 6_100_000,
"fact_rub": 5_900_000,
"err_pct": 3.39,
"n_analogs": 41,
"note": "не point-in-time",
}
_SHOWCASE_RUN = {
"computed_at": datetime(2026, 8, 29, 10, 0, tzinfo=UTC),
"considered": 200,
"priced": 173,
"no_prediction": 27,
"incomplete": 4,
"eligible": 169,
"written": 20,
"with_district": 18,
"rejection_rule": "данных нет: нет прогноза / квартала / площади",
}
def _showcase_db(run: dict | None = _SHOWCASE_RUN, rows: list | None = None) -> MagicMock:
db = MagicMock()
chain = db.execute.return_value.mappings.return_value
chain.first.return_value = run
chain.all.return_value = [_SHOWCASE_ROW] if rows is None else rows
return db
def test_anonymous_gets_showcase(client: TestClient) -> None: def test_anonymous_gets_showcase(client: TestClient) -> None:
"""Витрина открыта анониму и отдаёт то, что лежит в таблице. """Витрина открыта анониму и отдаёт то, что лежит в таблице.
Пустое поле района проходит НАСКВОЗЬ как null: витрина не имеет права Пустое поле района проходит НАСКВОЗЬ как null: витрина не имеет права
подставить правдоподобный район там, где его не удалось определить. подставить правдоподобный район там, где его не удалось определить.
""" """
row = { client.app.dependency_overrides[get_db] = lambda: _showcase_db()
"district": None,
"rooms": 2,
"area_m2": 54.0,
"floor": 5,
"total_floors": None,
"deal_quarter": "II квартал 2026",
"predicted_rub": 6_100_000,
"fact_rub": 5_900_000,
"err_pct": 3.39,
"n_analogs": 41,
"note": "не point-in-time",
"computed_at": datetime(2026, 8, 29, 10, 0, tzinfo=UTC),
}
db = MagicMock()
db.execute.return_value.mappings.return_value.all.return_value = [row]
client.app.dependency_overrides[get_db] = lambda: db
resp = client.get(f"{PREFIX}/showcase") resp = client.get(f"{PREFIX}/showcase")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
@ -192,9 +210,47 @@ def test_anonymous_gets_showcase(client: TestClient) -> None:
assert "address" not in body["deals"][0] assert "address" not in body["deals"][0]
def test_showcase_carries_counters_so_20_rows_cannot_read_as_all_there_was(
client: TestClient,
) -> None:
"""Счётчики прогона доезжают до фронта, а не остаются в логе бэкенда.
Без них «20 отличных строк» неотличимо от «столько и было»: посетитель не
может отличить выборку из работы оценщика от её лучшего хвоста. Здесь
показано 20 из 169 годных и оба числа обязаны быть в ответе, вместе с
правилом, по которому отсеяно остальное.
Ломать так: убрать `stats` из `ShowcaseResponse` (или перестать его
заполнять) тест покраснеет на отсутствующем ключе, а не на форме.
"""
client.app.dependency_overrides[get_db] = lambda: _showcase_db()
body = client.get(f"{PREFIX}/showcase").json()
stats = body["stats"]
assert stats is not None, "витрина отдаёт строки без счётчиков — подписаться нечем"
assert stats["considered"] == 200
assert stats["eligible"] == 169
assert stats["written"] == 20
assert stats["eligible"] - stats["written"] == 149, "не видно, сколько годных не влезло"
assert stats["no_prediction"] == 27
assert stats["rejection_rule"], "правило отбраковки не подписано"
def test_showcase_without_a_run_shows_nothing_and_says_so(client: TestClient) -> None:
"""Пересчёта не было — ни строк, ни счётчиков, и это штатный ответ.
Обратная сторона предыдущего теста: `stats` не выдумывается там, где
прогона не было. Заодно это гейт на связку «строки берутся ИЗ прогона»:
строки в таблице есть, но прогона нет показывать их не из чего.
"""
client.app.dependency_overrides[get_db] = lambda: _showcase_db(run=None)
body = client.get(f"{PREFIX}/showcase").json()
assert body == {"computed_at": None, "deals": [], "stats": None}
def test_showcase_rate_limited_per_ip(client: TestClient) -> None: def test_showcase_rate_limited_per_ip(client: TestClient) -> None:
db = MagicMock() db = _showcase_db(rows=[])
db.execute.return_value.mappings.return_value.all.return_value = []
client.app.dependency_overrides[get_db] = lambda: db client.app.dependency_overrides[get_db] = lambda: db
codes = [ codes = [
client.get(f"{PREFIX}/showcase").status_code for _ in range(public_mera._SHOWCASE_LIMIT + 1) client.get(f"{PREFIX}/showcase").status_code for _ in range(public_mera._SHOWCASE_LIMIT + 1)