Ревью 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 при живом прогоне → красный.
171 lines
7.8 KiB
Python
171 lines
7.8 KiB
Python
"""Витрина лэндинга на реальных сделках — отбор и отбраковка (миграция 276).
|
||
|
||
Главное, что здесь защищается, — НЕ формат строки, а свойство отбора: витрина
|
||
показывает выборку из работы оценщика, а не её лучший хвост. Отбор по малой
|
||
ошибке дал бы формально работающий код и врущий продукт, и заметить это на
|
||
глаз в проде нельзя — числа будут красивые. Поэтому проверка двусторонняя:
|
||
самая точная строка, у которой не хватает данных, обязана проиграть менее
|
||
точной, но полной.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from datetime import date
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from app.tasks.landing_showcase_deals import (
|
||
ShowcaseRow,
|
||
build_row,
|
||
quarter_label,
|
||
select_rows,
|
||
)
|
||
|
||
|
||
def _row(
|
||
deal_id: int,
|
||
*,
|
||
district: str | None = "Кировский",
|
||
floor: int | None = 5,
|
||
total_floors: int | None = 9,
|
||
deal_date: date = date(2026, 1, 1),
|
||
err_pct: float = 10.0,
|
||
) -> ShowcaseRow:
|
||
return ShowcaseRow(
|
||
deal_id=deal_id,
|
||
district=district,
|
||
rooms=2,
|
||
area_m2=54.0,
|
||
floor=floor,
|
||
total_floors=total_floors,
|
||
deal_date=deal_date,
|
||
deal_quarter="I квартал 2026",
|
||
predicted_rub=6_000_000,
|
||
fact_rub=5_500_000,
|
||
err_pct=err_pct,
|
||
n_analogs=40,
|
||
)
|
||
|
||
|
||
def test_selection_ignores_error_magnitude() -> None:
|
||
"""Точнейшая строка с дырами в данных НЕ должна оказаться впереди полной.
|
||
|
||
Ломать так: добавить в `_sort_key` слагаемое `abs(row.err_pct)` — тест
|
||
покраснеет с id 1 на первом месте вместо id 2.
|
||
"""
|
||
almost_perfect_but_thin = _row(1, district=None, floor=None, err_pct=0.1)
|
||
complete_but_worse = _row(2, err_pct=27.0)
|
||
|
||
chosen = select_rows([almost_perfect_but_thin, complete_but_worse], limit=1)
|
||
|
||
assert [r.deal_id for r in chosen] == [2], (
|
||
"отбор поехал за величиной ошибки — витрина перестала быть выборкой "
|
||
"и стала рекламой лучшего хвоста"
|
||
)
|
||
|
||
|
||
def test_selection_prefers_fresher_quarter_at_equal_completeness() -> None:
|
||
older = _row(1, deal_date=date(2025, 1, 1), err_pct=1.0)
|
||
fresher = _row(2, deal_date=date(2026, 4, 1), err_pct=35.0)
|
||
|
||
assert [r.deal_id for r in select_rows([older, fresher], limit=1)] == [2]
|
||
|
||
|
||
def test_selection_is_deterministic_on_full_ties() -> None:
|
||
"""Полные совпадения ключа разводятся id — иначе витрина «мерцает»."""
|
||
rows = [_row(7), _row(9), _row(8)]
|
||
assert [r.deal_id for r in select_rows(rows, limit=3)] == [9, 8, 7]
|
||
|
||
|
||
# ── Отбраковка: только «данных нет», никогда «число некрасивое» ──────────────
|
||
|
||
|
||
def _build(**over: object) -> ShowcaseRow | None:
|
||
kwargs: dict[str, object] = {
|
||
"deal_id": 1,
|
||
"district": "Кировский",
|
||
"rooms": 2,
|
||
"area_m2": 50.0,
|
||
"floor": 5,
|
||
"total_floors": 9,
|
||
"deal_date": date(2026, 4, 1),
|
||
"predicted_rub": 5_000_000.0,
|
||
"fact_ppm2": 100_000.0, # → факт 5 000 000 ₽, ошибка 0%
|
||
"n_analogs": 30,
|
||
}
|
||
kwargs.update(over)
|
||
return build_row(**kwargs) # type: ignore[arg-type]
|
||
|
||
|
||
def test_plain_row_survives_and_carries_signed_error() -> None:
|
||
row = _build(predicted_rub=5_500_000.0)
|
||
assert row is not None
|
||
assert row.fact_rub == 5_000_000
|
||
assert row.err_pct == 10.0, "знак и база ошибки: (прогноз − факт) / факт"
|
||
assert row.deal_quarter == "II квартал 2026"
|
||
|
||
|
||
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%.
|
||
row = _build(fact_ppm2=40_000.0)
|
||
assert row is not None
|
||
assert row.err_pct == 150.0
|
||
|
||
|
||
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_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:
|
||
"""Спайн не дал ожидаемой цены продажи — строки нет. Подставлять нечего."""
|
||
assert _build(predicted_rub=None) is None
|
||
|
||
|
||
def test_unknown_quarter_is_not_invented() -> None:
|
||
assert _build(deal_date=None) is None
|
||
assert quarter_label(None) is None
|
||
assert quarter_label(date(2026, 7, 1)) == "III квартал 2026"
|