Витрина показывала fact_rub = price_per_m2 * area_m2, хотя deals.price_rub лежит в той же строке и не использовалась. price_per_m2 в базе integer, поэтому под подписью «Цена ДКП» ехала реконструкция: 4 799 995 вместо 4 800 000, 3 649 995 вместо 3 650 000 (прод, сделки 5777343 и др.). Теперь price_rub едет из выборки (DealSample.price_rub) и показывается как есть; err_pct считается от той же величины. Строка без price_rub НЕ показывается — подставлять реконструкцию в одну строку из двадцати значило бы спрятать тот же дефект (на проде price_rub заполнен у 33 555 из 33 555 сделок выборки витрины). Вторая находка аудита (listing_date якобы «когда увидели МЫ», экспозиция занижена втрое) НЕ ПОДТВЕРДИЛАСЬ. listing_date пишут cian (added_ts), yandex (creationDate) и avito (дата карточки выдачи) — это дата публикации у источника. Там, где заполнены и listing_date, и publish_date, они совпадают: yandex 10 761 из 10 903, avito 474 из 569, медиана разницы 0 дней. 75 дней у аудитора — эффект другой ВЫБОРКИ: publish_date есть у 15 058 активных строк (yandex + Домклик, оба старые), listing_date — у 25 982 (плюс cian с медианой 17 дней и 87% avito с медианой 19). Настоящий дефект рядом: по одному listing_date Домклик выпадал целиком (0 из 3061 активной строки), метрика считалась по 83.6% активных объявлений, и подпись об этом молчала. COALESCE(listing_date, publish_date) → охват 95.2% (29 568 из 31 068), медиана та же — 26 дней; охват теперь назван в note. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
232 lines
12 KiB
Python
232 lines
12 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_rub": 5_000_000.0, # цена ДКП из договора, ошибка 0%
|
||
"n_analogs": 30,
|
||
"lat": 56.8386, # ЕКБ: широта ~56.8, долгота ~60.6 — величины НЕ похожи,
|
||
"lon": 60.6055, # поэтому перестановка ловится по значению.
|
||
}
|
||
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_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:
|
||
"""Промах оценщика ЛЮБОГО размера остаётся на витрине.
|
||
|
||
Это второй половина запрета «не отбирать по ошибке»: фильтр по величине
|
||
ошибки — тот же отбор, просто ступенькой раньше, и он тем злее, что не
|
||
оставляет строку даже в кандидатах.
|
||
|
||
Ломать так: вернуть в `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_rub=2_000_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_rub=1_000_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
|
||
|
||
|
||
def test_missing_fact_price_is_rejected() -> 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
|
||
|
||
|
||
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"
|
||
|
||
|
||
# ── Координата: центроид улицы, но обязана доехать и обязана не перепутаться ──
|
||
|
||
|
||
def test_coords_are_carried_through_and_not_swapped() -> None:
|
||
"""Широта остаётся широтой. Обе величины float, тип перестановку не поймает.
|
||
|
||
Ломать так: поменять в `build_row` местами `lat=lat` и `lon=lon` — тест
|
||
покраснеет по ЗНАЧЕНИЮ (56.8386 против 60.6055), а не по форме.
|
||
"""
|
||
row = _build()
|
||
assert row is not None
|
||
assert row.lat == 56.8386, "широта уехала не в ту колонку"
|
||
assert row.lon == 60.6055, "долгота уехала не в ту колонку"
|
||
|
||
|
||
def test_row_without_coords_stays_on_showcase() -> None:
|
||
"""Нет точки — строка всё равно на витрине, с lat=lon=None.
|
||
|
||
Выбрасывать сделку из-за отсутствия координаты — отбор по признаку, не
|
||
связанному с качеством оценки: та же порча витрины, что и отбор по
|
||
величине ошибки, просто по другому полю. Карта переживёт строку без точки.
|
||
|
||
Ломать так: добавить в `build_row` `if lat is None or lon is None: return
|
||
None` — тест покраснеет на None вместо строки.
|
||
"""
|
||
row = _build(lat=None, lon=None)
|
||
assert row is not None, "сделка выброшена из витрины за отсутствие координаты"
|
||
assert row.lat is None and row.lon is None, "координата придумана там, где её нет"
|
||
assert row.fact_rub == 5_000_000, "остальные величины строки не пострадали"
|