All checks were successful
CI Trade-In / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
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 5m14s
Первой строкой витрины и первым раундом игры «Проверьте себя» стояла единственная из двадцати сделка БЕЗ улицы: у неё вместо схемы улиц рисовался полигон района. Причина — `completeness()` считала район, этаж и этажность, но не улицу, хотя именно она решает, будет ли у строки карта. Схема улицы — такое же ВИДИМОЕ поле, как район: строка, которой нечем нарисовать карту, полнее строки с картой быть не может. Признак берётся из уже загружаемого индекса улиц (`StreetIndex.lookup`, поиск в памяти); индекс поднят выше отбора, дорогие пространственные запросы остались в `_schemes_for` и по-прежнему считаются только для показанных строк. Отбор по ВЕЛИЧИНЕ ОШИБКИ не введён и введён быть не может: строка с отклонением +75,7% остаётся в витрине, просто больше не открывает её. Прежнее правило «наличие схемы на отбор не влияет» в докстринге `_schemes_for` заменено с разбором, почему оно давало этот дефект. Тест двусторонний: при прочих равных строка со схемой выше строки без неё, а строка без улицы остаётся в витрине. Фальсифицирован — снятие `row.has_street` из `completeness` даёт красное ПО ЗНАЧЕНИЮ ([9, 8] вместо [8, 9]), не по исключению. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
259 lines
13 KiB
Python
259 lines
13 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,
|
||
has_street: bool = True,
|
||
) -> 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,
|
||
has_street=has_street,
|
||
)
|
||
|
||
|
||
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_prefers_row_with_street_scheme() -> None:
|
||
"""Строка без улицы не может быть самой полной: ей нечем нарисовать карту.
|
||
|
||
Так дефект и выглядел в проде: первой строкой витрины и первым раундом игры
|
||
«Проверьте себя» стояла единственная из двадцати сделка БЕЗ улицы, и вместо
|
||
схемы улиц у неё рисовался полигон РАЙОНА.
|
||
|
||
Всё остальное здесь одинаково, а id подобраны так, что без учёта улицы
|
||
выиграл бы разрыв ключа по id. Ломать так: убрать `row.has_street` из
|
||
`completeness` — тест покраснеет ПО ЗНАЧЕНИЮ, порядком [9, 8].
|
||
|
||
Вторая сторона проверки — строка без улицы ОСТАЁТСЯ в витрине: она не
|
||
первая, но и не выброшена. Прятать промахи по-прежнему нельзя.
|
||
"""
|
||
no_street = _row(9, has_street=False, err_pct=75.7)
|
||
with_street = _row(8, err_pct=3.0)
|
||
|
||
chosen = select_rows([no_street, with_street], limit=2)
|
||
|
||
assert [r.deal_id for r in chosen] == [8, 9], (
|
||
"строка, которой нечем нарисовать карту, оказалась полнее строки со "
|
||
"схемой улиц — витрина открывается полигоном района"
|
||
)
|
||
|
||
|
||
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, "остальные величины строки не пострадали"
|