gendesign/tradein-mvp/backend/tests/test_landing_showcase_deals.py
bot-backend 2467943200
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
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 / frontend-checks (pull_request) Successful in 1m13s
CI Trade-In / backend-tests (pull_request) Successful in 5m29s
feat(mera/лендинг): витрина показывает полосу расхождения −5…+20 %, плитку уверенности сменил замер 12.09
Владелец просит на витрине только сделки, где прогноз разошёлся с ценой ДКП
в пределах от −5 % до +20 %. Фильтр живёт в продюсере (`select_rows`), поэтому
таблица сверок и бегущая строка берут ОДИН набор, а не два.

Чтобы страница от этого не начала врать:

* `REJECTION_RULE` переписан. Прежняя формулировка («величина отклонения на
  отбор и отбраковку не влияет — иначе витрина показывала бы лучший хвост»)
  после фильтра стала ложью ровно про то, чего опасалась, поэтому снята, а не
  смягчена. Новая называет полосу и говорит, что это отбор показательных
  строк, а не вся сверка. Границы в текст ПОДСТАВЛЯЮТСЯ из констант
  `BAND_MIN_ERR_PCT`/`BAND_MAX_ERR_PCT` — подпись не может разъехаться с
  фильтром, и это проверяется тестом.
* Фильтр стоит в `select_rows`, а не в `build_row`: строка вне полосы остаётся
  кандидатом и попадает в `eligible`. Отбраковав её раньше, мы получили бы
  «показано 20 из 20 годных» — счётчик, из которого отбор не виден вообще.
* Счётчики разъехались с подписью, и подпись поправлена: `eligible − written`
  больше не значит «столько не поместилось», в разницу входят отсеянные
  полосой. Под таблицей теперь «показано N строк из M собранных прогоном».
* «В пределах 20 % — N из N» из подписи снято: при потолке полосы +20 счёт
  всегда выходил бы N из N и читался бы как замер попадания. Неработающая
  проверка читается как работающая.
* Медиана по ВСЕЙ сверке (15,3 %, 325 сделок) в подписи осталась и теперь
  сторожится тестом: без неё разброс отобранной двадцатки читается как
  точность расчёта.
* Полоса названа и в подписи ленты — она висит над первым экраном, её числа
  читают раньше любых оговорок блока «Точность».
* Меньше лимита в полосе — показываем сколько есть, добора нет.

Плитка «400 из 400 расчётов с пометкой „уверенность низкая“» заменена на
свежий замер 12.09.2026 (engine=full, 290 сделок, медиана трёх пересборок с
солями 11/22/33): «52,7 % сделок — расхождение в пределах ±20 %». Запись
`confidenceLow` не удалена, а помечена снятой (прогон 29.08 на
кластеризованной выборке) — до решения владельца.

Оговорки новой величины называют три вещи, без которых она льстит: замер не
point-in-time, разброс пересборок 46,2–56,6 %, и что медианное расхождение
того же прогона (19,1 %) ВЫШЕ прежних 15,3 % от 31.08 — на странице два числа
разных дат, и молчать о том, что свежий прогон вышел хуже, нельзя.

`priceError` и `coverage` не тронуты. Сторож свежести теперь следит за ОБЕИМИ
датами замеров, а не только за 31.08.

Проверено: на проде из 20 сегодняшних строк витрины в полосу попадают 8
(40 %), что сходится с 35,5 % «доли в полосе» из бэктеста 12.09.
Фальсификация: снятие фильтра руками красит 3 теста, ключевой — по значению
([44, 43, 41] вместо [44] на реальных строках прода +75,7 / −27,9 / +9,9 %).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 15:29:10 +05:00

350 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Витрина лэндинга на реальных сделках — отбор и отбраковка (миграция 276).
Здесь защищаются ДВА разных свойства, и путать их нельзя.
1. ПОЛОСА. С 2026-09-12 витрина показывает только расхождения 5 %..+20 %
включительно — решение владельца продукта. Это отбор показательных строк,
и проверяется он ПО ЗНАЧЕНИЮ, на реальных строках прода: +75,7 % и 27,9 %
на витрину не попадают, +9,9 % попадает.
2. ВНУТРИ ПОЛОСЫ отбора по величине ошибки по-прежнему нет. Иначе витрина
показывала бы лучший хвост уже самой полосы, а числа при этом остались бы
красивыми — на глаз в проде такое не ловится. Поэтому проверка
двусторонняя: самая точная строка, у которой не хватает данных, обязана
проиграть менее точной, но полной.
Отбраковка («данных нет») — третье свойство, и она живёт в `build_row`: строка
вне полосы остаётся кандидатом и попадает в счётчик `eligible`, её снимает
отбор, а не отбраковка. Ровно поэтому подпись под витриной может честно
сказать, сколько строк прогон собрал.
"""
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 (
BAND_MAX_ERR_PCT,
BAND_MIN_ERR_PCT,
REJECTION_RULE,
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,
)
# ── Полоса 5 %..+20 %: проверка ПО ЗНАЧЕНИЮ, на реальных строках прода ───────
def test_band_drops_rows_outside_it_and_keeps_rows_inside() -> None:
"""Три строки, которые сегодня лежат на витрине прода (прогон 30.08.2026).
id 41 — расхождение +75,71 %, id 43 — 27,87 %, id 44 — +9,85 %. Первые две
на витрину попадать больше не должны, третья должна.
Ломать так: снять фильтр в `select_rows` (вернуть
`sorted(rows, key=_sort_key)`) — тест покраснеет ПО ЗНАЧЕНИЮ, показав
[44, 41, 43] вместо [44], то есть ровно те два промаха, которых владелец
на витрине видеть не хочет.
"""
far_over = _row(41, err_pct=75.71)
far_under = _row(43, err_pct=-27.87)
inside = _row(44, err_pct=9.85)
chosen = select_rows([far_over, far_under, inside], limit=20)
assert [r.deal_id for r in chosen] == [44], (
"на витрину прошла строка вне полосы 5 %..+20 %: подпись обещает "
"полосу, а показывает не её"
)
def test_band_edges_are_inclusive_and_near_misses_are_not() -> None:
"""Границы полосы включительные, а на волос за ними — уже нет.
Проверяется ПО ЗНАЧЕНИЮ у самой границы: `<` вместо `<=` в `in_band`
выбросит ровно строки 1 и 2 и покраснит тест.
"""
rows = [
_row(1, err_pct=BAND_MIN_ERR_PCT),
_row(2, err_pct=BAND_MAX_ERR_PCT),
_row(3, err_pct=BAND_MIN_ERR_PCT - 0.01),
_row(4, err_pct=BAND_MAX_ERR_PCT + 0.01),
]
assert sorted(r.deal_id for r in select_rows(rows, limit=20)) == [1, 2]
def test_short_band_shows_what_there_is_and_does_not_top_up() -> None:
"""В полосу попало меньше лимита — показываем сколько есть.
Добор ближайшими по ошибке был бы тем же отбором по величине ошибки, просто
с другой стороны. Ломать так: добавить в `select_rows` «добить до limit
остальными» — тест покраснеет тремя строками вместо одной.
"""
rows = [_row(1, err_pct=3.0), _row(2, err_pct=44.0), _row(3, err_pct=-60.0)]
assert [r.deal_id for r in select_rows(rows, limit=20)] == [1]
def test_rejection_rule_names_the_band_that_is_actually_applied() -> None:
"""Подпись витрины называет ТУ полосу, которую применяет фильтр.
Текст едет на фронт и там читается как обещание. Вписанный руками «5 %» в
тексте и `>= -5.0` в коде — две независимые величины; здесь проверяется,
что в тексте стоят именно границы фильтра.
Ломать так: подвинуть `BAND_MAX_ERR_PCT` на 30, не трогая текст, — тест
покраснеет на «+30 %», которого в подписи нет.
"""
assert f"{BAND_MIN_ERR_PCT:+.0f} %" in REJECTION_RULE
assert f"{BAND_MAX_ERR_PCT:+.0f} %" in REJECTION_RULE
assert "не вся сверка" in REJECTION_RULE, "подпись не говорит, что это отбор"
# Снятая формулировка не должна вернуться: с фильтром она ложь.
assert "на отбор и отбраковку не влияет" not in REJECTION_RULE
# ── Внутри полосы: порядок задают полнота и свежесть, не ошибка ───────────────
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=19.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=18.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=19.0)
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:
"""Промах ЛЮБОГО размера остаётся кандидатом и попадает в счётчик.
Отбраковка и отбор — разные шаги, и величина ошибки причиной ОТБРАКОВКИ не
является: вне полосы строка не показывается, но входит в `eligible`, и
подпись «показано N из M собранных» остаётся правдой. Отбракуй её здесь —
и отбор перестал бы быть виден в счётчиках вообще.
Ломать так: вернуть в `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_counted_not_dropped() -> None:
"""Занижение ДКП ради налога выглядит как промах — и остаётся кандидатом.
Отбраковывать такие строки нельзя: «отклонение больше 40% — это почти
всегда дефект ДКП» было догадкой, а санитарный диапазон ₽/м² уже применён
к выборке выше по потоку (`_load_sample`, для ЕКБ 30k..600k). На витрину
такая строка не выйдет — её снимет полоса, — но в `eligible` она войдёт,
и счётчик под таблицей останется честным.
"""
# Факт 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, "остальные величины строки не пострадали"