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
Владелец просит на витрине только сделки, где прогноз разошёлся с ценой ДКП в пределах от −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>
350 lines
19 KiB
Python
350 lines
19 KiB
Python
"""Витрина лэндинга на реальных сделках — отбор и отбраковка (миграция 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, "остальные величины строки не пострадали"
|