fix(mera): витрина больше не отсеивает промахи оценщика, счётчики едут на фронт
Ревью 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:
parent
b72dbc5da3
commit
abe559cf8f
5 changed files with 312 additions and 77 deletions
|
|
@ -381,19 +381,57 @@ class ShowcaseDeal(BaseModel):
|
|||
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):
|
||||
"""Витрина целиком. `computed_at` — когда считали, `deals` — что показываем."""
|
||||
"""Витрина целиком: когда считали, что показываем и из чего это отобрано.
|
||||
|
||||
`stats` = None только до первого пересчёта — тогда и `deals` пуст.
|
||||
"""
|
||||
|
||||
computed_at: str | None
|
||||
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(
|
||||
"""
|
||||
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
|
||||
WHERE computed_at = (SELECT max(computed_at) FROM landing_showcase_deals)
|
||||
WHERE computed_at = CAST(:computed_at AS timestamptz)
|
||||
ORDER BY id
|
||||
"""
|
||||
)
|
||||
|
|
@ -413,12 +451,28 @@ def public_showcase(
|
|||
|
||||
Пустой список — штатный ответ, а не ошибка: до первого пересчёта показывать
|
||||
нечего, и это ровно то, что фронт должен увидеть вместо выдуманных строк.
|
||||
|
||||
Вместе со строками едет `stats` — сколько сделок рассмотрено, сколько
|
||||
годных строк не поместилось и по какому правилу отсеяно остальное. Числа
|
||||
считает пересчёт; без них витрина не имеет права подписаться честно.
|
||||
"""
|
||||
_enforce(_showcase_limiter, request, "showcase")
|
||||
rows = db.execute(_SHOWCASE_SQL).mappings().all()
|
||||
computed_at = rows[0]["computed_at"].isoformat() if rows else None
|
||||
run = db.execute(_SHOWCASE_RUN_SQL).mappings().first()
|
||||
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(
|
||||
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=[
|
||||
ShowcaseDeal(
|
||||
district=r["district"],
|
||||
|
|
|
|||
|
|
@ -20,6 +20,16 @@
|
|||
код формально работает, а продукт врёт. Проверяется тестом
|
||||
`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 едет на фронт вместе с числами, а докстринг — нет.
|
||||
* ЦЕНА ДКП БЫВАЕТ ЗАНИЖЕНА (налоговая оптимизация, сделки между своими).
|
||||
Отсюда отбраковка выбросов ниже — и счётчики отбракованного в логе, чтобы
|
||||
«мы показываем 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 в свою таблицу)::
|
||||
|
||||
|
|
@ -60,24 +84,25 @@ from sqlalchemy.orm import Session
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Пороги отбраковки выбросов ───────────────────────────────────────────────
|
||||
# ── Правило отбраковки: одна формулировка, она же едет на фронт ──────────────
|
||||
#
|
||||
# ДКП-цена — это то, что стороны заявили в Росреестр. Занижение до 1 млн ради
|
||||
# налога встречается, и такая строка на витрине выглядит как чудовищный промах
|
||||
# оценщика, хотя оценщик прав, а врёт документ. Отбрасываем обе стороны хвоста
|
||||
# по отклонению и заведомо невозможный ₽/м².
|
||||
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%
|
||||
# отсекает хвост, а не «неудобные» строки: при MAPE 14% строка с отклонением
|
||||
# больше 40% — это почти всегда дефект цены в ДКП, а не дефект прогноза.
|
||||
# Порогов на величину ошибки здесь НЕТ (разбор — в докстринге модуля). Санитарный
|
||||
# диапазон ₽/м² применён выше по потоку, в `_load_sample`; дублировать его тут
|
||||
# значило бы завести проверку, которая в проде не срабатывает никогда.
|
||||
REJECTION_RULE = (
|
||||
"Строка не попадает на витрину, только если данных нет: оценщик не дал "
|
||||
"ожидаемой цены продажи (мало аналогов), неизвестен квартал сделки или "
|
||||
"площадь. Величина отклонения на отбор и отбраковку не влияет — иначе "
|
||||
"витрина показывала бы лучший хвост, а не работу оценщика. Санитарный "
|
||||
"диапазон цены сделки (30 000–600 000 ₽/м² для Екатеринбурга) применён "
|
||||
"к выборке до расчёта, по цене самой сделки."
|
||||
)
|
||||
|
||||
NOTE = (
|
||||
"Прогноз посчитан по активным объявлениям на дату пересчёта, сделка — прошлая: "
|
||||
"это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. "
|
||||
"Факт — цена ДКП, заявленная в Росреестр."
|
||||
"Факт — цена ДКП, заявленная в Росреестр: она бывает занижена сторонами, и тогда "
|
||||
"строка выглядит как промах оценщика, хотя врёт документ."
|
||||
)
|
||||
|
||||
_ROMAN = {1: "I", 2: "II", 3: "III", 4: "IV"}
|
||||
|
|
@ -144,26 +169,23 @@ def build_row(
|
|||
fact_ppm2: float,
|
||||
n_analogs: int,
|
||||
) -> 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
|
||||
quarter = quarter_label(deal_date)
|
||||
if quarter is None:
|
||||
return None
|
||||
if not (MIN_FACT_PPM2 <= fact_ppm2 <= MAX_FACT_PPM2):
|
||||
return None
|
||||
|
||||
fact_rub = fact_ppm2 * area_m2
|
||||
# Знак ошибки — как в бэктесте: (прогноз − факт) / факт. Плюс = МЕРА
|
||||
# назвала дороже, чем ушло по ДКП.
|
||||
err_pct = 100.0 * (predicted_rub - fact_rub) / fact_rub
|
||||
if abs(err_pct) > MAX_ABS_ERR_PCT:
|
||||
return None
|
||||
|
||||
return ShowcaseRow(
|
||||
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_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 INTO landing_showcase_deals
|
||||
|
|
@ -238,9 +276,18 @@ def refresh_landing_showcase_deals(
|
|||
) -> 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` тянет оценщик со всеми
|
||||
# его зависимостями, а web-процессу это на импорте приложения не нужно.
|
||||
|
|
@ -258,7 +305,7 @@ def refresh_landing_showcase_deals(
|
|||
|
||||
candidates: list[ShowcaseRow] = []
|
||||
n_priced = 0
|
||||
n_rejected = 0
|
||||
n_incomplete = 0
|
||||
for deal in deals:
|
||||
capture: list[dict[str, Any]] = []
|
||||
try:
|
||||
|
|
@ -283,13 +330,14 @@ def refresh_landing_showcase_deals(
|
|||
n_analogs=len(capture[0]["kwargs"]["listings"]) if capture else 0,
|
||||
)
|
||||
if row is None:
|
||||
n_rejected += 1
|
||||
n_incomplete += 1
|
||||
continue
|
||||
candidates.append(row)
|
||||
|
||||
chosen = select_rows(candidates, limit)
|
||||
|
||||
db.execute(_DELETE_SQL)
|
||||
db.execute(_DELETE_RUNS_SQL)
|
||||
for row in chosen:
|
||||
db.execute(
|
||||
_INSERT_SQL,
|
||||
|
|
@ -307,21 +355,26 @@ def refresh_landing_showcase_deals(
|
|||
"note": NOTE,
|
||||
},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
counters = {
|
||||
"considered": len(deals),
|
||||
"priced": n_priced,
|
||||
"rejected_outlier": n_rejected,
|
||||
"with_district": sum(1 for r in chosen if r.district is not None),
|
||||
"no_prediction": len(deals) - n_priced,
|
||||
"incomplete": n_incomplete,
|
||||
"eligible": len(candidates),
|
||||
"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(
|
||||
"витрина обновлена: рассмотрено=%d оценено=%d отбраковано_выбросов=%d "
|
||||
"записано=%d с_районом=%d",
|
||||
"витрина обновлена: рассмотрено=%d оценено=%d без_прогноза=%d неполных=%d "
|
||||
"годных=%d записано=%d с_районом=%d",
|
||||
counters["considered"],
|
||||
counters["priced"],
|
||||
counters["rejected_outlier"],
|
||||
counters["no_prediction"],
|
||||
counters["incomplete"],
|
||||
counters["eligible"],
|
||||
counters["written"],
|
||||
counters["with_district"],
|
||||
)
|
||||
|
|
|
|||
38
tradein-mvp/backend/data/sql/277_landing_showcase_runs.sql
Normal file
38
tradein-mvp/backend/data/sql/277_landing_showcase_runs.sql
Normal 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);
|
||||
|
|
@ -16,7 +16,6 @@ from datetime import date
|
|||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.tasks.landing_showcase_deals import (
|
||||
MAX_ABS_ERR_PCT,
|
||||
ShowcaseRow,
|
||||
build_row,
|
||||
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]
|
||||
|
||||
|
||||
# ── Отбраковка выбросов ──────────────────────────────────────────────────────
|
||||
# ── Отбраковка: только «данных нет», никогда «число некрасивое» ──────────────
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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%.
|
||||
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:
|
||||
"""Порог отсекает хвост, а не «неудобные» строки — граница проверена с двух сторон."""
|
||||
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))
|
||||
assert inside is not None
|
||||
assert outside is None
|
||||
def test_ppm2_band_is_not_duplicated_here() -> None:
|
||||
"""Своей копии ₽/м²-диапазона в `build_row` нет — она была мёртвой.
|
||||
|
||||
`MIN_FACT_PPM2 = 30k` дублировал уже применённый фильтр выборки, а
|
||||
`MAX_FACT_PPM2 = 1.2M` был недостижим при её потолке 600k: из трёх
|
||||
отбраковок срабатывала ровно одна — по ошибке. Ломать так: вернуть любую
|
||||
из границ — покраснеет соответствующая половина.
|
||||
"""
|
||||
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:
|
||||
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
|
||||
def test_missing_fact_price_is_rejected() -> 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:
|
||||
|
|
|
|||
|
|
@ -160,29 +160,47 @@ def test_anonymous_gets_suggest(client: TestClient) -> None:
|
|||
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:
|
||||
"""Витрина открыта анониму и отдаёт то, что лежит в таблице.
|
||||
|
||||
Пустое поле района проходит НАСКВОЗЬ как null: витрина не имеет права
|
||||
подставить правдоподобный район там, где его не удалось определить.
|
||||
"""
|
||||
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",
|
||||
"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
|
||||
client.app.dependency_overrides[get_db] = lambda: _showcase_db()
|
||||
|
||||
resp = client.get(f"{PREFIX}/showcase")
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
|
@ -194,9 +212,47 @@ def test_anonymous_gets_showcase(client: TestClient) -> None:
|
|||
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:
|
||||
db = MagicMock()
|
||||
db.execute.return_value.mappings.return_value.all.return_value = []
|
||||
db = _showcase_db(rows=[])
|
||||
client.app.dependency_overrides[get_db] = lambda: db
|
||||
codes = [
|
||||
client.get(f"{PREFIX}/showcase").status_code for _ in range(public_mera._SHOWCASE_LIMIT + 1)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue