diff --git a/tradein-mvp/backend/app/api/public/mera.py b/tradein-mvp/backend/app/api/public/mera.py index ace32b7a..fbd28d9f 100644 --- a/tradein-mvp/backend/app/api/public/mera.py +++ b/tradein-mvp/backend/app/api/public/mera.py @@ -1,4 +1,4 @@ -"""Публичный API МЕРЫ (B2C, meraocenka.ru) — анонимный, ровно две ручки. +"""Публичный API МЕРЫ (B2C, meraocenka.ru) — анонимный, ровно три ручки. ЗАЧЕМ ОТДЕЛЬНЫЙ ПРЕФИКС, А НЕ ОТКРЫТИЕ КУСКА /api/v1/* ------------------------------------------------------- @@ -26,7 +26,7 @@ API, нужно было выбрать одно из двух: АНОНИМНОСТЬ ----------- `rbac_guard` (app/core/rbac.py) требует `X-Authenticated-User` для любого -non-public пути. Обе ручки перечислены в `_PUBLIC_PATHS` ТОЧНЫМИ строками — +non-public пути. Все ручки перечислены в `_PUBLIC_PATHS` ТОЧНЫМИ строками — не префиксом: множество там — frozenset с проверкой `path in ...`, и добавление префиксной ветки ради двух путей расширило бы механизм, которым пользуется весь бэкенд, ради одной фичи. @@ -93,10 +93,15 @@ router = APIRouter() # нажал ещё раз». _SUGGEST_LIMIT = 20 _COVERAGE_LIMIT = 15 +# Витрина — один SELECT по своей же маленькой таблице, внешних вызовов нет, +# поэтому бюджет шире соседних: он здесь против перебора-в-цикле, а не против +# денежных трат. Лэндинг дёргает ручку один раз на загрузку страницы. +_SHOWCASE_LIMIT = 60 _WINDOW_S = 60.0 _suggest_limiter = SlidingWindowLimiter(limit=_SUGGEST_LIMIT, window_s=_WINDOW_S) _coverage_limiter = SlidingWindowLimiter(limit=_COVERAGE_LIMIT, window_s=_WINDOW_S) +_showcase_limiter = SlidingWindowLimiter(limit=_SHOWCASE_LIMIT, window_s=_WINDOW_S) # ── Общий суточный потолок публичных подсказок ────────────────────────────── # @@ -354,3 +359,134 @@ def public_stats( ) for row in rows } +class ShowcaseDeal(BaseModel): + """Одна строка витрины «МЕРА сказала X — продали за Y». + + `district` / `floor` / `total_floors` НУЛЛАБЕЛЬНЫ намеренно: этих величин в + ДКП-данных может не быть, и фронт обязан пережить null, а не получить + правдоподобную подстановку. Улицы и дома в модели нет вовсе — номер дома + есть у 2.7% сделок (разбор в миграции 276). + """ + + district: str | None + rooms: int + area_m2: float + floor: int | None + total_floors: int | None + deal_quarter: str + predicted_rub: int + fact_rub: int + err_pct: float + n_analogs: int + note: str + + +class ShowcaseStats(BaseModel): + """Итог прогона, который дал показанные строки. Подпись под витриной. + + Без этих чисел «20 отличных строк» неотличимо от «столько и было»: + посетитель не может отличить выборку из работы оценщика от её лучшего + хвоста. `eligible` минус `written` — сколько годных строк не поместилось + в витрину; `rejection_rule` — по какому правилу отсеяно остальное, + записанное ТЕМ прогоном, который эти строки посчитал. + """ + + considered: int + priced: int + no_prediction: int + incomplete: int + eligible: int + written: int + with_district: int + rejection_rule: str + + +class ShowcaseResponse(BaseModel): + """Витрина целиком: когда считали, что показываем и из чего это отобрано. + + `stats` = None только до первого пересчёта — тогда и `deals` пуст. + """ + + computed_at: str | None + deals: list[ShowcaseDeal] + stats: ShowcaseStats | None = None + + +# Последний прогон — единственная точка отсчёта: и `computed_at`, и счётчики, и +# набор строк берутся ИЗ НЕГО. Брать строки по своему max(computed_at) значило +# бы, что прогон, не давший ни одной строки, показывает вчерашние строки под +# сегодняшними счётчиками. +_SHOWCASE_RUN_SQL = text( + """ + SELECT computed_at, considered, priced, no_prediction, incomplete, + eligible, written, with_district, rejection_rule + FROM landing_showcase_runs + ORDER BY computed_at DESC, id DESC + LIMIT 1 + """ +) + +_SHOWCASE_SQL = text( + """ + SELECT district, rooms, area_m2, floor, total_floors, deal_quarter, + predicted_rub, fact_rub, err_pct, n_analogs, note + FROM landing_showcase_deals + WHERE computed_at = CAST(:computed_at AS timestamptz) + ORDER BY id + """ +) + + +@router.get("/showcase", response_model=ShowcaseResponse) +def public_showcase( + request: Request, + db: Annotated[Session, Depends(get_db)], +) -> ShowcaseResponse: + """Витрина лэндинга: реальные ДКП-сделки против прогноза МЕРЫ. + + Читает готовый батч из `landing_showcase_deals` (пересчёт — + `app/tasks/landing_showcase_deals.py`), а не считает прогноз на лету: + один прогноз — это несколько пространственных SELECT'ов, двадцать штук на + анонимный GET были бы рычагом для DoS. + + Пустой список — штатный ответ, а не ошибка: до первого пересчёта показывать + нечего, и это ровно то, что фронт должен увидеть вместо выдуманных строк. + + Вместе со строками едет `stats` — сколько сделок рассмотрено, сколько + годных строк не поместилось и по какому правилу отсеяно остальное. Числа + считает пересчёт; без них витрина не имеет права подписаться честно. + """ + _enforce(_showcase_limiter, request, "showcase") + run = db.execute(_SHOWCASE_RUN_SQL).mappings().first() + if run is None: + return ShowcaseResponse(computed_at=None, deals=[], stats=None) + rows = db.execute(_SHOWCASE_SQL, {"computed_at": run["computed_at"]}).mappings().all() + return ShowcaseResponse( + computed_at=run["computed_at"].isoformat(), + stats=ShowcaseStats( + considered=int(run["considered"]), + priced=int(run["priced"]), + no_prediction=int(run["no_prediction"]), + incomplete=int(run["incomplete"]), + eligible=int(run["eligible"]), + written=int(run["written"]), + with_district=int(run["with_district"]), + rejection_rule=run["rejection_rule"], + ), + deals=[ + ShowcaseDeal( + district=r["district"], + rooms=int(r["rooms"]), + area_m2=float(r["area_m2"]), + floor=(int(r["floor"]) if r["floor"] is not None else None), + total_floors=(int(r["total_floors"]) if r["total_floors"] is not None else None), + deal_quarter=r["deal_quarter"], + predicted_rub=int(r["predicted_rub"]), + fact_rub=int(r["fact_rub"]), + err_pct=float(r["err_pct"]), + n_analogs=int(r["n_analogs"]), + note=r["note"], + ) + for r in rows + ], + ) diff --git a/tradein-mvp/backend/app/core/rbac.py b/tradein-mvp/backend/app/core/rbac.py index b4f9471b..524b53da 100644 --- a/tradein-mvp/backend/app/core/rbac.py +++ b/tradein-mvp/backend/app/core/rbac.py @@ -118,6 +118,10 @@ _PUBLIC_PATHS = frozenset( # проду без единой персональной строки — их и показывают анонимному # посетителю, ради чего метрики и считаются. "/api/public/mera/stats", + # Витрина реальных ДКП-сделок против прогноза (миграция 276): читает + # СВОЮ таблицу-витрину, где по построению нет ни адреса, ни владельца — + # район + характеристики квартиры + пара «прогноз/факт». + "/api/public/mera/showcase", } ) # #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед diff --git a/tradein-mvp/backend/app/tasks/landing_showcase_deals.py b/tradein-mvp/backend/app/tasks/landing_showcase_deals.py new file mode 100644 index 00000000..5f8a064a --- /dev/null +++ b/tradein-mvp/backend/app/tasks/landing_showcase_deals.py @@ -0,0 +1,407 @@ +"""Пересчёт витрины лэндинга на РЕАЛЬНЫХ сделках (миграция 276). + +ЧТО ЭТО. Публичный лэндинг МЕРЫ показывал ленту «МЕРА сказала X — продали за Y» +на выдуманных константах (frontend `marketing-v3.ts`). Здесь считается её +настоящий источник: берём зарегистрированные ДКП-сделки Росреестра по ЕКБ, +прогоняем каждую через ТОТ ЖЕ спайн оценщика, что и боевой расчёт +(`scripts/backtest_estimator._predict_full_spine` → `estimator._price_from_inputs`), +и кладём получившиеся пары «прогноз / факт» в `landing_showcase_deals`. + +ПРАВИЛО ОТБОРА — ЯВНО И БЕЗ ПОДГОНКИ +------------------------------------ +Отбираем N строк ключом:: + + (полнота данных ↓, свежесть квартала ↓, id сделки ↓) + +Величина ошибки в ключе НЕ УЧАСТВУЕТ и участвовать не должна. Отбор по малой +ошибке превращает витрину в рекламу: показанные 20 строк перестают быть +выборкой из работы оценщика и становятся её лучшим хвостом, а посетитель +читает их как «вот так МЕРА обычно и попадает». Это тот самый случай, когда +код формально работает, а продукт врёт. Проверяется тестом +`test_landing_showcase_deals.py::test_selection_ignores_error_magnitude`. + +ФИЛЬТРА ПО ОШИБКЕ ТОЖЕ НЕТ — И ЭТО ТО ЖЕ САМОЕ ПРАВИЛО. До 2026-08-29 здесь +жил порог `MAX_ABS_ERR_PCT = 40`, выбрасывавший кандидата ПО ВЕЛИЧИНЕ ОШИБКИ +до ранжирования. Запрет выше он обходил ступенькой раньше: отбор по ошибке в +ключе и отбор по ошибке в фильтре — одно и то же действие, и второе даже +злее, потому что не оставляет строку в кандидатах. Обоснование «отклонение +больше 40% — это почти всегда занижение ДКП ради налога» не держится: см. +следующий раздел, грубые занижения вырезаны выше по потоку и по свойству +самой сделки. Отбраковываем только то, чего в данных НЕТ (нет прогноза, нет +квартала, нет площади) — «число некрасивое» причиной не является. + +Полнота — сколько из полей, которые видит посетитель (район, этаж, этажность), +у строки заполнено. Свежесть — порядок квартала сделки. + +ЧЕСТНОСТЬ ВИТРИНЫ (нарушение любого пункта = витрина врёт) +---------------------------------------------------------- + * АДРЕСА НЕТ. Номер дома есть у 2.7% сделок, поэтому строка — это «район + + 2-к, 54 м², 5 эт.», и никогда не улица с домом. + * ДНЯ НЕТ. `deals.deal_date` — первое число квартала (10 различных значений + на всю таблицу), поэтому в витрине только «II квартал 2026». + * ЗАМЕР НЕ POINT-IN-TIME. Спайн считает прогноз по СЕГОДНЯШНИМ активным + объявлениям, а сделка — прошлая. Между ними дрейф рынка, который в ошибку + входит целиком. Это записано в `note` КАЖДОЙ строки, а не только здесь: + поле note едет на фронт вместе с числами, а докстринг — нет. + * ЦЕНА ДКП БЫВАЕТ ЗАНИЖЕНА (налоговая оптимизация, сделки между своими), и + такая строка выглядит как чудовищный промах оценщика. Санитарный диапазон + ₽/м² применяется ОДИН раз и ВЫШЕ ПО ПОТОКУ — в `_load_sample`, по свойству + самой сделки, а не по ошибке прогноза: для ЕКБ это глобальные + `PPM2_MIN = 30 000` / `PPM2_MAX = 600 000` (город намеренно не заведён в + `deal_city_price_bands`, там же и комментарий об этом). Значит грубые + занижения из выборки уже вырезаны ДО того, как сюда приходит кандидат, а + всё, что после этого дало большую ошибку, — работа оценщика, и витрина + обязана её показать. Своей копии диапазона здесь нет намеренно: прежние + `MIN_FACT_PPM2 = 30k` дублировал уже применённый фильтр, а + `MAX_FACT_PPM2 = 1.2M` был недостижим при потолке выборки 600k — из трёх + отбраковок в проде срабатывала РОВНО ОДНА, та самая, что льстила витрине. + Неработающая проверка читается как работающая, поэтому её нет. + * СЧЁТЧИКИ ЕДУТ НА ФРОНТ, А НЕ ТОЛЬКО В ЛОГ. «Мы показываем 20 отличных + строк» неотличимо от «столько и было», пока рядом не написано, сколько + сделок рассмотрено и сколько годных строк не поместилось. Поэтому итог + прогона пишется в `landing_showcase_runs` (миграция 277) и отдаётся + ручкой `/api/public/mera/showcase` вместе со строками. + +ЗАПУСК (прод, read-mostly: один DELETE+INSERT в свою таблицу):: + + docker exec tradein-backend python -m app.tasks.landing_showcase_deals + +Планировщиком пока не дёргается — витрина обновляется редко (сделки приезжают +кварталами), а вешать ежедневный джоб ради данных, которые меняются раз в три +месяца, значит платить сотнями пространственных запросов за ничего. +""" + +from __future__ import annotations + +import argparse +import logging +from dataclasses import dataclass +from datetime import date +from typing import Any + +from sqlalchemy import text +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + +# ── Правило отбраковки: одна формулировка, она же едет на фронт ────────────── +# +# Порогов на величину ошибки здесь НЕТ (разбор — в докстринге модуля). Санитарный +# диапазон ₽/м² применён выше по потоку, в `_load_sample`; дублировать его тут +# значило бы завести проверку, которая в проде не срабатывает никогда. +REJECTION_RULE = ( + "Строка не попадает на витрину, только если данных нет: оценщик не дал " + "ожидаемой цены продажи (мало аналогов), неизвестен квартал сделки или " + "площадь. Величина отклонения на отбор и отбраковку не влияет — иначе " + "витрина показывала бы лучший хвост, а не работу оценщика. Санитарный " + "диапазон цены сделки (30 000–600 000 ₽/м² для Екатеринбурга) применён " + "к выборке до расчёта, по цене самой сделки." +) + +NOTE = ( + "Прогноз посчитан по активным объявлениям на дату пересчёта, сделка — прошлая: " + "это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. " + "Факт — цена ДКП, заявленная в Росреестр: она бывает занижена сторонами, и тогда " + "строка выглядит как промах оценщика, хотя врёт документ." +) + +_ROMAN = {1: "I", 2: "II", 3: "III", 4: "IV"} + + +def quarter_label(d: date | None) -> str | None: + """`date(2026, 4, 1)` → ``'II квартал 2026'``. Нет даты — нет ярлыка.""" + if d is None: + return None + return f"{_ROMAN[(d.month - 1) // 3 + 1]} квартал {d.year}" + + +@dataclass(frozen=True) +class ShowcaseRow: + """Одна строка витрины — ровно то, что уедет в таблицу и на фронт.""" + + deal_id: int + district: str | None + rooms: int + area_m2: float + floor: int | None + total_floors: int | None + deal_date: date | None + deal_quarter: str + predicted_rub: int + fact_rub: int + err_pct: float + n_analogs: int + + +def completeness(row: ShowcaseRow) -> int: + """Сколько ВИДИМЫХ посетителю полей заполнено (0..3). + + Считаем район/этаж/этажность: комнаты и площадь есть у всех кандидатов по + построению выборки, поэтому в оценке полноты они бесполезны. + """ + return sum(x is not None for x in (row.district, row.floor, row.total_floors)) + + +def _sort_key(row: ShowcaseRow) -> tuple[int, date, int]: + """Ключ отбора. Ошибки здесь нет — см. «ПРАВИЛО ОТБОРА» в докстринге модуля.""" + return ( + -completeness(row), + -(row.deal_date or date.min).toordinal(), + -row.deal_id, + ) + + +def select_rows(rows: list[ShowcaseRow], limit: int) -> list[ShowcaseRow]: + """Отобрать `limit` строк по полноте и свежести (НЕ по величине ошибки).""" + return sorted(rows, key=_sort_key)[:limit] + + +def build_row( + *, + deal_id: int, + district: str | None, + rooms: int, + area_m2: float, + floor: int | None, + total_floors: int | None, + deal_date: date | None, + predicted_rub: float | None, + fact_ppm2: float, + n_analogs: int, +) -> ShowcaseRow | None: + """Кандидат → строка витрины, либо None если считать не из чего. + + Причины отказа ИСЧЕРПЫВАЮЩИЕ и все — «данных нет»: спайн не дал ожидаемой + цены продажи; квартал сделки неизвестен; нет площади или цены сделки + (делить не на что). Величина отклонения причиной НЕ является ни при каких + значениях — см. «ФИЛЬТРА ПО ОШИБКЕ ТОЖЕ НЕТ» в докстринге модуля. + """ + if predicted_rub is None or predicted_rub <= 0 or area_m2 <= 0 or fact_ppm2 <= 0: + return None + quarter = quarter_label(deal_date) + if quarter is None: + return None + + fact_rub = fact_ppm2 * area_m2 + # Знак ошибки — как в бэктесте: (прогноз − факт) / факт. Плюс = МЕРА + # назвала дороже, чем ушло по ДКП. + err_pct = 100.0 * (predicted_rub - fact_rub) / fact_rub + + return ShowcaseRow( + deal_id=deal_id, + district=district, + rooms=rooms, + area_m2=round(area_m2, 2), + floor=floor, + total_floors=total_floors, + deal_date=deal_date, + deal_quarter=quarter, + predicted_rub=round(predicted_rub), + fact_rub=round(fact_rub), + err_pct=round(err_pct, 2), + n_analogs=n_analogs, + ) + + +# ── Район: FDW-вьюха чужой базы, поэтому best-effort ───────────────────────── +_DISTRICT_SQL = text( + """ + SELECT d.id AS deal_id, g.district_name + FROM deals d + JOIN gendesign_ekb_districts_geom g + ON ST_Contains(g.geom, d.geom::geometry) + WHERE d.id = ANY(CAST(:ids AS bigint[])) + """ +) + + +def _fetch_districts(db: Session, deal_ids: list[int]) -> dict[int, str]: + """id сделки → район. Недоступна вьюха — пустой словарь, а не выдуманный район. + + `gendesign_ekb_districts_geom` — foreign table в базу gendesign, и её гранты + на той стороне уже терялись (DROP MV CASCADE снимает GRANT). Оборачиваем в + SAVEPOINT ИМЕННО ЗДЕСЬ, на месте глушения: провалившийся SELECT переводит + транзакцию в aborted, и следующий запрос упал бы уже не по своей вине. + """ + if not deal_ids: + return {} + try: + with db.begin_nested(): + rows = db.execute(_DISTRICT_SQL, {"ids": deal_ids}).mappings().all() + except Exception as exc: + logger.warning("район не резолвится (витрина будет без района): %s", exc) + return {} + return {int(r["deal_id"]): r["district_name"] for r in rows if r["district_name"]} + + +_DELETE_SQL = text("DELETE FROM landing_showcase_deals") +_DELETE_RUNS_SQL = text("DELETE FROM landing_showcase_runs") +# Тот же `now()`, что у DEFAULT в строках витрины: в Postgres now() — время +# НАЧАЛА транзакции, а батч и его итог пишутся одной транзакцией. Ручка по +# этому computed_at и связывает счётчики со строками. +_INSERT_RUN_SQL = text( + """ + INSERT INTO landing_showcase_runs + (considered, priced, no_prediction, incomplete, eligible, written, + with_district, rejection_rule) + VALUES + (CAST(:considered AS integer), CAST(:priced AS integer), + CAST(:no_prediction AS integer), CAST(:incomplete AS integer), + CAST(:eligible AS integer), CAST(:written AS integer), + CAST(:with_district AS integer), CAST(:rejection_rule AS text)) + """ +) +_INSERT_SQL = text( + """ + INSERT INTO landing_showcase_deals + (district, rooms, area_m2, floor, total_floors, deal_quarter, + predicted_rub, fact_rub, err_pct, n_analogs, note) + VALUES + (CAST(:district AS text), CAST(:rooms AS integer), CAST(:area_m2 AS numeric), + CAST(:floor AS integer), CAST(:total_floors AS integer), + CAST(:deal_quarter AS text), CAST(:predicted_rub AS bigint), + CAST(:fact_rub AS bigint), CAST(:err_pct AS numeric), + CAST(:n_analogs AS integer), CAST(:note AS text)) + """ +) + + +def refresh_landing_showcase_deals( + db: Session, + *, + sample: int = 200, + since: str = "2025-01-01", + limit: int = 20, + city: str = "Екатеринбург", +) -> dict[str, int]: + """Прогнать бэктест по ЕКБ и перезаписать витрину. Возвращает счётчики. + + Счётчики — не отладочный шум: без них «на витрине 20 отличных строк» + неотличимо от «столько и было». Поэтому они не только пишутся в лог, но и + сохраняются в `landing_showcase_runs` и уезжают на фронт вместе со + строками. Значения: + + considered сколько ДКП-сделок взято в работу + priced из них оценщик дал ожидаемую цену продажи + no_prediction не дал (мало аналогов / спайн упал) + incomplete цена есть, но нет квартала/площади — строку не собрать + eligible годных строк ВСЕГО (никакого отсева по ошибке нет) + written из них показано (обрезано по `limit`) + with_district у скольких показанных удалось определить район + """ + # Импорт внутри функции: `scripts.backtest_estimator` тянет оценщик со всеми + # его зависимостями, а web-процессу это на импорте приложения не нужно. + from scripts.backtest_estimator import ( + _import_estimator_full, + _load_sample, + _predict_full_spine, + ) + + est = _import_estimator_full() + deals = _load_sample(db, sample=sample, since=since, city=city) + logger.info("витрина: загружено %d ДКП-сделок (city=%s, since=%s)", len(deals), city, since) + + districts = _fetch_districts(db, [d.id for d in deals]) + + candidates: list[ShowcaseRow] = [] + n_priced = 0 + n_incomplete = 0 + for deal in deals: + capture: list[dict[str, Any]] = [] + try: + pr = _predict_full_spine(db, deal, est, capture=capture) + except Exception as exc: + logger.warning("сделка %s: спайн упал, пропускаем: %s", deal.id, exc) + db.rollback() + continue + if pr is None: + continue + n_priced += 1 + row = build_row( + deal_id=deal.id, + district=districts.get(deal.id), + rooms=deal.rooms, + area_m2=deal.area_m2, + floor=deal.floor, + total_floors=deal.total_floors, + deal_date=deal.deal_date, + predicted_rub=pr.expected_sold_price, + fact_ppm2=deal.sold_ppm2, + n_analogs=len(capture[0]["kwargs"]["listings"]) if capture else 0, + ) + if row is None: + n_incomplete += 1 + continue + candidates.append(row) + + chosen = select_rows(candidates, limit) + + db.execute(_DELETE_SQL) + db.execute(_DELETE_RUNS_SQL) + for row in chosen: + db.execute( + _INSERT_SQL, + { + "district": row.district, + "rooms": row.rooms, + "area_m2": row.area_m2, + "floor": row.floor, + "total_floors": row.total_floors, + "deal_quarter": row.deal_quarter, + "predicted_rub": row.predicted_rub, + "fact_rub": row.fact_rub, + "err_pct": row.err_pct, + "n_analogs": row.n_analogs, + "note": NOTE, + }, + ) + counters = { + "considered": len(deals), + "priced": n_priced, + "no_prediction": len(deals) - n_priced, + "incomplete": n_incomplete, + "eligible": len(candidates), + "written": len(chosen), + "with_district": sum(1 for r in chosen if r.district is not None), + } + db.execute(_INSERT_RUN_SQL, {**counters, "rejection_rule": REJECTION_RULE}) + db.commit() + + logger.info( + "витрина обновлена: рассмотрено=%d оценено=%d без_прогноза=%d неполных=%d " + "годных=%d записано=%d с_районом=%d", + counters["considered"], + counters["priced"], + counters["no_prediction"], + counters["incomplete"], + counters["eligible"], + counters["written"], + counters["with_district"], + ) + return counters + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--sample", type=int, default=200) + parser.add_argument("--since", default="2025-01-01") + parser.add_argument("--limit", type=int, default=20) + parser.add_argument("--city", default="Екатеринбург") + args = parser.parse_args(argv) + + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + + from app.core.db import SessionLocal + + db = SessionLocal() + try: + refresh_landing_showcase_deals( + db, sample=args.sample, since=args.since, limit=args.limit, city=args.city + ) + finally: + db.close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tradein-mvp/backend/data/sql/276_landing_showcase_deals.sql b/tradein-mvp/backend/data/sql/276_landing_showcase_deals.sql new file mode 100644 index 00000000..2674773b --- /dev/null +++ b/tradein-mvp/backend/data/sql/276_landing_showcase_deals.sql @@ -0,0 +1,50 @@ +-- 276: витрина лэндинга на РЕАЛЬНЫХ сделках (issue B2C-showcase). +-- +-- ЗАЧЕМ ТАБЛИЦА, А НЕ ВЫЧИСЛЕНИЕ В РУЧКЕ. Прогноз считается полным спайном +-- оценщика: несколько пространственных SELECT'ов на КАЖДУЮ сделку. Двадцать +-- сделок — это сотни запросов; на публичной ручке без авторизации это готовый +-- рычаг для DoS. Поэтому пересчёт — офлайн-задача (app/tasks/landing_showcase_deals.py), +-- ручка читает готовые строки. +-- +-- ЧЕГО ЗДЕСЬ НАМЕРЕННО НЕТ — АДРЕСА. В `deals` номер дома есть у 2.7% строк +-- (620 различных адресов на 24 644 сделки), то есть «улица + дом» на витрине +-- была бы додумана. Показываем район + характеристики квартиры; улицы нет +-- даже колонкой, чтобы её нельзя было «на минутку» вывести. +-- +-- deal_quarter — ТЕКСТ КВАРТАЛА, не дата: `deals.deal_date` принимает всего 10 +-- различных значений на всю таблицу (первое число квартала), то есть дня +-- сделки в данных нет. Хранить date здесь значило бы отдать фронту точность, +-- которой не существует. +-- +-- district и floor — NULLABLE. Район резолвится через FDW-вьюху чужой базы +-- (gendesign_ekb_districts_geom), и её гранты уже терялись (см. C3); floor в +-- части ДКП-строк пуст. Правило проекта: нет величины — пишем NULL, а не +-- правдоподобное значение. Отбор в задаче ранжирует такие строки ниже, но не +-- запрещает их: пустая витрина хуже витрины без района. +BEGIN; +-- Конвенция проекта (#2752): блокирующий DDL идёт под lock_timeout, иначе он +-- встанет в очередь за чужой сессией и утащит за собой запросы приложения. +SET LOCAL lock_timeout = '5s'; + +CREATE TABLE IF NOT EXISTS landing_showcase_deals ( + id bigserial PRIMARY KEY, + computed_at timestamptz NOT NULL DEFAULT now(), + district text, + rooms integer NOT NULL, + area_m2 numeric(8, 2) NOT NULL, + floor integer, + total_floors integer, + deal_quarter text NOT NULL, + predicted_rub bigint NOT NULL, + fact_rub bigint NOT NULL, + err_pct numeric(6, 2) NOT NULL, + n_analogs integer NOT NULL, + note text NOT NULL +); + +-- Ручка всегда читает ПОСЛЕДНИЙ пересчёт (max computed_at) — старые батчи +-- остаются для сверки «что показывали неделю назад». +CREATE INDEX IF NOT EXISTS idx_landing_showcase_deals_computed_at + ON landing_showcase_deals (computed_at DESC); + +COMMIT; diff --git a/tradein-mvp/backend/data/sql/277_landing_showcase_runs.sql b/tradein-mvp/backend/data/sql/277_landing_showcase_runs.sql new file mode 100644 index 00000000..f150a8e9 --- /dev/null +++ b/tradein-mvp/backend/data/sql/277_landing_showcase_runs.sql @@ -0,0 +1,44 @@ +-- 277: итог пересчёта витрины лэндинга — счётчики рядом со строками. +-- +-- ЗАЧЕМ ОТДЕЛЬНАЯ ТАБЛИЦА, А НЕ КОЛОНКИ В landing_showcase_deals. Счётчики — +-- факт ПРОГОНА, а не строки: на 20 строк они были бы продублированы 20 раз, а +-- в самом важном случае — когда показывать оказалось нечего — исчезли бы +-- вместе со строками. «Рассмотрено 200, показывать нечего» обязано доезжать до +-- фронта ровно так же, как «рассмотрено 200, показано 20». +-- +-- ЗАЧЕМ ВООБЩЕ. Витрина показывает 20 сделок «МЕРА сказала X — продали за Y». +-- Без чисел рядом эти 20 строк читаются как «столько и было»: посетитель не +-- может отличить выборку из работы оценщика от её лучшего хвоста. Поэтому +-- ручка /api/public/mera/showcase отдаёт вместе со строками, сколько сделок +-- рассмотрено, сколько годных строк не поместилось и по какому правилу +-- отбраковано остальное. +-- +-- rejection_rule — ТЕКСТ, А НЕ КОД ПРАВИЛА. Правило живёт в +-- app/tasks/landing_showcase_deals.REJECTION_RULE и пишется сюда тем прогоном, +-- который эти числа и посчитал: подпись под витриной обязана описывать ТОТ +-- отбор, что дал эти строки, а не тот, что задеплоен сегодня. +-- +-- computed_at совпадает со строками батча: задача пишет строки и этот итог +-- ОДНОЙ транзакцией, а now() в Postgres — время начала транзакции. +BEGIN; +-- Конвенция проекта (#2752): блокирующий DDL идёт под lock_timeout, иначе он +-- встанет в очередь за чужой сессией и утащит за собой запросы приложения. +SET LOCAL lock_timeout = '5s'; + +CREATE TABLE IF NOT EXISTS landing_showcase_runs ( + id bigserial PRIMARY KEY, + computed_at timestamptz NOT NULL DEFAULT now(), + considered integer NOT NULL, + priced integer NOT NULL, + no_prediction integer NOT NULL, + incomplete integer NOT NULL, + eligible integer NOT NULL, + written integer NOT NULL, + with_district integer NOT NULL, + rejection_rule text NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_landing_showcase_runs_computed_at + ON landing_showcase_runs (computed_at DESC); + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_landing_showcase_deals.py b/tradein-mvp/backend/tests/test_landing_showcase_deals.py new file mode 100644 index 00000000..7b5a51ba --- /dev/null +++ b/tradein-mvp/backend/tests/test_landing_showcase_deals.py @@ -0,0 +1,171 @@ +"""Витрина лэндинга на реальных сделках — отбор и отбраковка (миграция 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" diff --git a/tradein-mvp/backend/tests/test_public_mera_api.py b/tradein-mvp/backend/tests/test_public_mera_api.py index a2eef695..8124329c 100644 --- a/tradein-mvp/backend/tests/test_public_mera_api.py +++ b/tradein-mvp/backend/tests/test_public_mera_api.py @@ -26,6 +26,7 @@ from __future__ import annotations import os import sys +from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock, patch # Settings требует DATABASE_URL на конструирование — stub до любого app-импорта @@ -76,10 +77,12 @@ def _reset_limiters(): public_mera._suggest_limiter._hits.clear() public_mera._coverage_limiter._hits.clear() public_mera._stats_limiter._hits.clear() + public_mera._showcase_limiter._hits.clear() yield public_mera._suggest_limiter._hits.clear() public_mera._coverage_limiter._hits.clear() public_mera._stats_limiter._hits.clear() + public_mera._showcase_limiter._hits.clear() @pytest.fixture() @@ -107,9 +110,9 @@ def client() -> TestClient: # ── 1-2. Периметр и его связка с rbac ──────────────────────────────────────── -def test_public_router_exposes_exactly_three_routes() -> None: +def test_public_router_exposes_exactly_four_routes() -> None: paths = {r.path for r in public_mera.router.routes} - assert paths == {"/suggest", "/coverage", "/stats"}, ( + assert paths == {"/suggest", "/coverage", "/stats", "/showcase"}, ( "изменился набор публичных (анонимных) ручек МЕРЫ. Это не рефакторинг: " "всё под /api/public/ проксируется на meraocenka.ru целиком и доступно " "без идентичности. Обнови тест ОСОЗНАННО вместе с rbac._PUBLIC_PATHS." @@ -157,6 +160,107 @@ def test_anonymous_gets_suggest(client: TestClient) -> None: assert resp.json() == {"items": []} +_SHOWCASE_ROW = { + "district": None, + "rooms": 2, + "area_m2": 54.0, + "floor": 5, + "total_floors": None, + "deal_quarter": "II квартал 2026", + "predicted_rub": 6_100_000, + "fact_rub": 5_900_000, + "err_pct": 3.39, + "n_analogs": 41, + "note": "не point-in-time", +} +_SHOWCASE_RUN = { + "computed_at": datetime(2026, 8, 29, 10, 0, tzinfo=UTC), + "considered": 200, + "priced": 173, + "no_prediction": 27, + "incomplete": 4, + "eligible": 169, + "written": 20, + "with_district": 18, + "rejection_rule": "данных нет: нет прогноза / квартала / площади", +} + + +def _showcase_db(run: dict | None = _SHOWCASE_RUN, rows: list | None = None) -> MagicMock: + db = MagicMock() + chain = db.execute.return_value.mappings.return_value + chain.first.return_value = run + chain.all.return_value = [_SHOWCASE_ROW] if rows is None else rows + return db + + +def test_anonymous_gets_showcase(client: TestClient) -> None: + """Витрина открыта анониму и отдаёт то, что лежит в таблице. + + Пустое поле района проходит НАСКВОЗЬ как null: витрина не имеет права + подставить правдоподобный район там, где его не удалось определить. + """ + client.app.dependency_overrides[get_db] = lambda: _showcase_db() + + resp = client.get(f"{PREFIX}/showcase") + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["computed_at"].startswith("2026-08-29T10:00") + assert body["deals"][0]["district"] is None + assert body["deals"][0]["fact_rub"] == 5_900_000 + # Адреса в контракте ручки нет вовсе — в `deals` дом известен у 2.7% строк. + assert "address" not in body["deals"][0] + + +def test_showcase_carries_counters_so_20_rows_cannot_read_as_all_there_was( + client: TestClient, +) -> None: + """Счётчики прогона доезжают до фронта, а не остаются в логе бэкенда. + + Без них «20 отличных строк» неотличимо от «столько и было»: посетитель не + может отличить выборку из работы оценщика от её лучшего хвоста. Здесь + показано 20 из 169 годных — и оба числа обязаны быть в ответе, вместе с + правилом, по которому отсеяно остальное. + + Ломать так: убрать `stats` из `ShowcaseResponse` (или перестать его + заполнять) — тест покраснеет на отсутствующем ключе, а не на форме. + """ + client.app.dependency_overrides[get_db] = lambda: _showcase_db() + + body = client.get(f"{PREFIX}/showcase").json() + stats = body["stats"] + assert stats is not None, "витрина отдаёт строки без счётчиков — подписаться нечем" + assert stats["considered"] == 200 + assert stats["eligible"] == 169 + assert stats["written"] == 20 + assert stats["eligible"] - stats["written"] == 149, "не видно, сколько годных не влезло" + assert stats["no_prediction"] == 27 + assert stats["rejection_rule"], "правило отбраковки не подписано" + + +def test_showcase_without_a_run_shows_nothing_and_says_so(client: TestClient) -> None: + """Пересчёта не было — ни строк, ни счётчиков, и это штатный ответ. + + Обратная сторона предыдущего теста: `stats` не выдумывается там, где + прогона не было. Заодно это гейт на связку «строки берутся ИЗ прогона»: + строки в таблице есть, но прогона нет — показывать их не из чего. + """ + client.app.dependency_overrides[get_db] = lambda: _showcase_db(run=None) + + body = client.get(f"{PREFIX}/showcase").json() + assert body == {"computed_at": None, "deals": [], "stats": None} + + +def test_showcase_rate_limited_per_ip(client: TestClient) -> None: + db = _showcase_db(rows=[]) + client.app.dependency_overrides[get_db] = lambda: db + codes = [ + client.get(f"{PREFIX}/showcase").status_code for _ in range(public_mera._SHOWCASE_LIMIT + 1) + ] + assert codes[: public_mera._SHOWCASE_LIMIT] == [200] * public_mera._SHOWCASE_LIMIT + assert codes[-1] == 429, f"бюджет витрины не сработал: {codes}" + + def test_suggest_is_post_so_address_never_lands_in_access_log() -> None: """Адрес едет ТЕЛОМ, а не в query.