From 030aa38b67f140f9adc4585b83b17f841935674c Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 29 Aug 2026 18:44:06 +0500 Subject: [PATCH] =?UTF-8?q?feat(mera):=20=D0=B2=D0=B8=D1=82=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BB=D1=8D=D0=BD=D0=B4=D0=B8=D0=BD=D0=B3=D0=B0?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B?= =?UTF-8?q?=D1=85=20=D0=94=D0=9A=D0=9F-=D1=81=D0=B4=D0=B5=D0=BB=D0=BA?= =?UTF-8?q?=D0=B0=D1=85=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=B4=D1=83=D0=BC=D0=B0=D0=BD=D0=BD=D1=8B=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Лента «МЕРА сказала X — продали за Y» жила на константах в marketing-v3.ts. Здесь появляется её настоящий источник: сделки Росреестра по ЕКБ, прогнанные через тот же спайн оценщика, что и боевой расчёт (backtest_estimator). Отбор строк идёт по полноте данных и свежести квартала и НЕ смотрит на величину ошибки: отбор по малой ошибке дал бы формально работающий код и врущую витрину — показанные строки перестали бы быть выборкой из работы оценщика. Свойство закреплено двусторонним тестом. Витрина не показывает адреса (номер дома есть у 2.7% сделок) и не показывает дня сделки (deal_date — первое число квартала). Каждая строка несёт note о том, что замер не point-in-time. Заниженные ради налога ДКП отбрасываются по |отклонению| > 40% и ₽/м² вне [30k; 1.2M], счётчик отброшенного — в лог. --- tradein-mvp/backend/app/api/public/mera.py | 89 ++++- tradein-mvp/backend/app/core/rbac.py | 4 + .../app/tasks/landing_showcase_deals.py | 354 ++++++++++++++++++ .../data/sql/276_landing_showcase_deals.sql | 44 +++ .../tests/test_landing_showcase_deals.py | 137 +++++++ .../backend/tests/test_public_mera_api.py | 52 ++- 6 files changed, 676 insertions(+), 4 deletions(-) create mode 100644 tradein-mvp/backend/app/tasks/landing_showcase_deals.py create mode 100644 tradein-mvp/backend/data/sql/276_landing_showcase_deals.sql create mode 100644 tradein-mvp/backend/tests/test_landing_showcase_deals.py diff --git a/tradein-mvp/backend/app/api/public/mera.py b/tradein-mvp/backend/app/api/public/mera.py index 3c2bc5f2..8487dc1f 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 ...`, и добавление префиксной ветки ради двух путей расширило бы механизм, которым пользуется весь бэкенд, ради одной фичи. @@ -63,6 +63,7 @@ from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, Field +from sqlalchemy import text from sqlalchemy.orm import Session from app.api.v1.geocode import SuggestResponse, suggest_addresses @@ -91,10 +92,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) # ── Общий суточный потолок публичных подсказок ────────────────────────────── # @@ -286,3 +292,82 @@ def public_coverage( """ _enforce(_coverage_limiter, request, "coverage") return coverage_probe(payload=payload, db=db) + + +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 ShowcaseResponse(BaseModel): + """Витрина целиком. `computed_at` — когда считали, `deals` — что показываем.""" + + computed_at: str | None + deals: list[ShowcaseDeal] + + +_SHOWCASE_SQL = text( + """ + SELECT district, rooms, area_m2, floor, total_floors, deal_quarter, + predicted_rub, fact_rub, err_pct, n_analogs, note, computed_at + FROM landing_showcase_deals + WHERE computed_at = (SELECT max(computed_at) FROM landing_showcase_deals) + 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. + + Пустой список — штатный ответ, а не ошибка: до первого пересчёта показывать + нечего, и это ровно то, что фронт должен увидеть вместо выдуманных строк. + """ + _enforce(_showcase_limiter, request, "showcase") + rows = db.execute(_SHOWCASE_SQL).mappings().all() + computed_at = rows[0]["computed_at"].isoformat() if rows else None + return ShowcaseResponse( + computed_at=computed_at, + 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 7966280d..9dfd0ef0 100644 --- a/tradein-mvp/backend/app/core/rbac.py +++ b/tradein-mvp/backend/app/core/rbac.py @@ -114,6 +114,10 @@ _PUBLIC_PATHS = frozenset( # держится на структуре пакета app/api/public/, а не на матчере. "/api/public/mera/suggest", "/api/public/mera/coverage", + # Витрина реальных ДКП-сделок против прогноза (миграция 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..42a7e3ba --- /dev/null +++ b/tradein-mvp/backend/app/tasks/landing_showcase_deals.py @@ -0,0 +1,354 @@ +"""Пересчёт витрины лэндинга на РЕАЛЬНЫХ сделках (миграция 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`. + +Полнота — сколько из полей, которые видит посетитель (район, этаж, этажность), +у строки заполнено. Свежесть — порядок квартала сделки. + +ЧЕСТНОСТЬ ВИТРИНЫ (нарушение любого пункта = витрина врёт) +---------------------------------------------------------- + * АДРЕСА НЕТ. Номер дома есть у 2.7% сделок, поэтому строка — это «район + + 2-к, 54 м², 5 эт.», и никогда не улица с домом. + * ДНЯ НЕТ. `deals.deal_date` — первое число квартала (10 различных значений + на всю таблицу), поэтому в витрине только «II квартал 2026». + * ЗАМЕР НЕ POINT-IN-TIME. Спайн считает прогноз по СЕГОДНЯШНИМ активным + объявлениям, а сделка — прошлая. Между ними дрейф рынка, который в ошибку + входит целиком. Это записано в `note` КАЖДОЙ строки, а не только здесь: + поле note едет на фронт вместе с числами, а докстринг — нет. + * ЦЕНА ДКП БЫВАЕТ ЗАНИЖЕНА (налоговая оптимизация, сделки между своими). + Отсюда отбраковка выбросов ниже — и счётчики отбракованного в логе, чтобы + «мы показываем 20 отличных строк» нельзя было прочитать как «столько и + было». + +ЗАПУСК (прод, 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__) + +# ── Пороги отбраковки выбросов ─────────────────────────────────────────────── +# +# ДКП-цена — это то, что стороны заявили в Росреестр. Занижение до 1 млн ради +# налога встречается, и такая строка на витрине выглядит как чудовищный промах +# оценщика, хотя оценщик прав, а врёт документ. Отбрасываем обе стороны хвоста +# по отклонению и заведомо невозможный ₽/м². +MAX_ABS_ERR_PCT = 40.0 +MIN_FACT_PPM2 = 30_000.0 +MAX_FACT_PPM2 = 1_200_000.0 + +# Замер по ЕКБ (n=398): MAPE 14.24%, покрытие коридором 88.22%. Порог 40% +# отсекает хвост, а не «неудобные» строки: при MAPE 14% строка с отклонением +# больше 40% — это почти всегда дефект цены в ДКП, а не дефект прогноза. + +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: + return None + quarter = quarter_label(deal_date) + if quarter is None: + return None + if not (MIN_FACT_PPM2 <= fact_ppm2 <= MAX_FACT_PPM2): + return None + + fact_rub = fact_ppm2 * area_m2 + # Знак ошибки — как в бэктесте: (прогноз − факт) / факт. Плюс = МЕРА + # назвала дороже, чем ушло по ДКП. + err_pct = 100.0 * (predicted_rub - fact_rub) / fact_rub + if abs(err_pct) > MAX_ABS_ERR_PCT: + return None + + 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") +_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]: + """Прогнать бэктест по ЕКБ и перезаписать витрину. Возвращает счётчики. + + Счётчики (`considered` / `priced` / `rejected_outlier` / `written`) — + не отладочный шум: без них «на витрине 20 отличных строк» неотличимо от + «столько и было». Отбракованное печатается в лог всегда. + """ + # Импорт внутри функции: `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_rejected = 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_rejected += 1 + continue + candidates.append(row) + + chosen = select_rows(candidates, limit) + + db.execute(_DELETE_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, + }, + ) + db.commit() + + counters = { + "considered": len(deals), + "priced": n_priced, + "rejected_outlier": n_rejected, + "with_district": sum(1 for r in chosen if r.district is not None), + "written": len(chosen), + } + logger.info( + "витрина обновлена: рассмотрено=%d оценено=%d отбраковано_выбросов=%d " + "записано=%d с_районом=%d", + counters["considered"], + counters["priced"], + counters["rejected_outlier"], + 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..7a363ca1 --- /dev/null +++ b/tradein-mvp/backend/data/sql/276_landing_showcase_deals.sql @@ -0,0 +1,44 @@ +-- 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, а не +-- правдоподобное значение. Отбор в задаче ранжирует такие строки ниже, но не +-- запрещает их: пустая витрина хуже витрины без района. + +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); 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..6411e02a --- /dev/null +++ b/tradein-mvp/backend/tests/test_landing_showcase_deals.py @@ -0,0 +1,137 @@ +"""Витрина лэндинга на реальных сделках — отбор и отбраковка (миграция 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 ( + MAX_ABS_ERR_PCT, + 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_underdeclared_dkp_is_rejected() -> None: + """Занижение ради налога выглядит как чудовищный промах оценщика.""" + # Факт 2 000 000 ₽ против прогноза 5 000 000 — отклонение +150%. + assert _build(fact_ppm2=40_000.0) is None + + +def test_error_just_under_threshold_survives() -> None: + """Порог отсекает хвост, а не «неудобные» строки — граница проверена с двух сторон.""" + inside = _build(predicted_rub=5_000_000.0 * (1 + (MAX_ABS_ERR_PCT - 1) / 100)) + outside = _build(predicted_rub=5_000_000.0 * (1 + (MAX_ABS_ERR_PCT + 1) / 100)) + assert inside is not None + assert outside is None + + +def test_impossible_ppm2_is_rejected_on_both_ends() -> None: + assert _build(fact_ppm2=20_000.0, predicted_rub=1_000_000.0) is None + assert _build(fact_ppm2=2_000_000.0, predicted_rub=100_000_000.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 5b834927..91b49d83 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-импорта @@ -75,9 +76,11 @@ def _reset_limiters(): """ public_mera._suggest_limiter._hits.clear() public_mera._coverage_limiter._hits.clear() + public_mera._showcase_limiter._hits.clear() yield public_mera._suggest_limiter._hits.clear() public_mera._coverage_limiter._hits.clear() + public_mera._showcase_limiter._hits.clear() @pytest.fixture() @@ -105,9 +108,9 @@ def client() -> TestClient: # ── 1-2. Периметр и его связка с rbac ──────────────────────────────────────── -def test_public_router_exposes_exactly_two_routes() -> None: +def test_public_router_exposes_exactly_three_routes() -> None: paths = {r.path for r in public_mera.router.routes} - assert paths == {"/suggest", "/coverage"}, ( + assert paths == {"/suggest", "/coverage", "/showcase"}, ( "изменился набор публичных (анонимных) ручек МЕРЫ. Это не рефакторинг: " "всё под /api/public/ проксируется на meraocenka.ru целиком и доступно " "без идентичности. Обнови тест ОСОЗНАННО вместе с rbac._PUBLIC_PATHS." @@ -155,6 +158,51 @@ def test_anonymous_gets_suggest(client: TestClient) -> None: assert resp.json() == {"items": []} +def test_anonymous_gets_showcase(client: TestClient) -> None: + """Витрина открыта анониму и отдаёт то, что лежит в таблице. + + Пустое поле района проходит НАСКВОЗЬ как null: витрина не имеет права + подставить правдоподобный район там, где его не удалось определить. + """ + 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", + "computed_at": datetime(2026, 8, 29, 10, 0, tzinfo=UTC), + } + db = MagicMock() + db.execute.return_value.mappings.return_value.all.return_value = [row] + client.app.dependency_overrides[get_db] = lambda: 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_rate_limited_per_ip(client: TestClient) -> None: + db = MagicMock() + db.execute.return_value.mappings.return_value.all.return_value = [] + 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.