feat(mera): витрина лэндинга на реальных ДКП-сделках вместо выдуманных
Some checks failed
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Failing after 19s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m14s
Some checks failed
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Failing after 19s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m14s
Лента «МЕРА сказала X — продали за Y» жила на константах в marketing-v3.ts. Здесь появляется её настоящий источник: сделки Росреестра по ЕКБ, прогнанные через тот же спайн оценщика, что и боевой расчёт (backtest_estimator). Отбор строк идёт по полноте данных и свежести квартала и НЕ смотрит на величину ошибки: отбор по малой ошибке дал бы формально работающий код и врущую витрину — показанные строки перестали бы быть выборкой из работы оценщика. Свойство закреплено двусторонним тестом. Витрина не показывает адреса (номер дома есть у 2.7% сделок) и не показывает дня сделки (deal_date — первое число квартала). Каждая строка несёт note о том, что замер не point-in-time. Заниженные ради налога ДКП отбрасываются по |отклонению| > 40% и ₽/м² вне [30k; 1.2M], счётчик отброшенного — в лог.
This commit is contained in:
parent
deddef80ad
commit
030aa38b67
6 changed files with 676 additions and 4 deletions
|
|
@ -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
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) перед
|
||||
|
|
|
|||
354
tradein-mvp/backend/app/tasks/landing_showcase_deals.py
Normal file
354
tradein-mvp/backend/app/tasks/landing_showcase_deals.py
Normal file
|
|
@ -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())
|
||||
44
tradein-mvp/backend/data/sql/276_landing_showcase_deals.sql
Normal file
44
tradein-mvp/backend/data/sql/276_landing_showcase_deals.sql
Normal file
|
|
@ -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);
|
||||
137
tradein-mvp/backend/tests/test_landing_showcase_deals.py
Normal file
137
tradein-mvp/backend/tests/test_landing_showcase_deals.py
Normal file
|
|
@ -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"
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue