feat(mera/b2c): лента сделок и раунды игры — из реальных сделок с реальным прогнозом #3229

Merged
bot-backend merged 3 commits from feat/b2c-showcase-real-deals into main 2026-08-29 14:27:26 +00:00
7 changed files with 920 additions and 4 deletions

View file

@ -1,4 +1,4 @@
"""Публичный API МЕРЫ (B2C, meraocenka.ru) — анонимный, ровно две ручки. """Публичный API МЕРЫ (B2C, meraocenka.ru) — анонимный, ровно три ручки.
ЗАЧЕМ ОТДЕЛЬНЫЙ ПРЕФИКС, А НЕ ОТКРЫТИЕ КУСКА /api/v1/* ЗАЧЕМ ОТДЕЛЬНЫЙ ПРЕФИКС, А НЕ ОТКРЫТИЕ КУСКА /api/v1/*
------------------------------------------------------- -------------------------------------------------------
@ -26,7 +26,7 @@ API, нужно было выбрать одно из двух:
АНОНИМНОСТЬ АНОНИМНОСТЬ
----------- -----------
`rbac_guard` (app/core/rbac.py) требует `X-Authenticated-User` для любого `rbac_guard` (app/core/rbac.py) требует `X-Authenticated-User` для любого
non-public пути. Обе ручки перечислены в `_PUBLIC_PATHS` ТОЧНЫМИ строками non-public пути. Все ручки перечислены в `_PUBLIC_PATHS` ТОЧНЫМИ строками
не префиксом: множество там frozenset с проверкой `path in ...`, и не префиксом: множество там frozenset с проверкой `path in ...`, и
добавление префиксной ветки ради двух путей расширило бы механизм, которым добавление префиксной ветки ради двух путей расширило бы механизм, которым
пользуется весь бэкенд, ради одной фичи. пользуется весь бэкенд, ради одной фичи.
@ -93,10 +93,15 @@ router = APIRouter()
# нажал ещё раз». # нажал ещё раз».
_SUGGEST_LIMIT = 20 _SUGGEST_LIMIT = 20
_COVERAGE_LIMIT = 15 _COVERAGE_LIMIT = 15
# Витрина — один SELECT по своей же маленькой таблице, внешних вызовов нет,
# поэтому бюджет шире соседних: он здесь против перебора-в-цикле, а не против
# денежных трат. Лэндинг дёргает ручку один раз на загрузку страницы.
_SHOWCASE_LIMIT = 60
_WINDOW_S = 60.0 _WINDOW_S = 60.0
_suggest_limiter = SlidingWindowLimiter(limit=_SUGGEST_LIMIT, window_s=_WINDOW_S) _suggest_limiter = SlidingWindowLimiter(limit=_SUGGEST_LIMIT, window_s=_WINDOW_S)
_coverage_limiter = SlidingWindowLimiter(limit=_COVERAGE_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 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
],
)

View file

@ -118,6 +118,10 @@ _PUBLIC_PATHS = frozenset(
# проду без единой персональной строки — их и показывают анонимному # проду без единой персональной строки — их и показывают анонимному
# посетителю, ради чего метрики и считаются. # посетителю, ради чего метрики и считаются.
"/api/public/mera/stats", "/api/public/mera/stats",
# Витрина реальных ДКП-сделок против прогноза (миграция 276): читает
# СВОЮ таблицу-витрину, где по построению нет ни адреса, ни владельца —
# район + характеристики квартиры + пара «прогноз/факт».
"/api/public/mera/showcase",
} }
) )
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед # #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед

View file

@ -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 000600 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())

View file

@ -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;

View file

@ -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;

View file

@ -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"

View file

@ -26,6 +26,7 @@ from __future__ import annotations
import os import os
import sys import sys
from datetime import UTC, datetime
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
# Settings требует DATABASE_URL на конструирование — stub до любого app-импорта # Settings требует DATABASE_URL на конструирование — stub до любого app-импорта
@ -76,10 +77,12 @@ def _reset_limiters():
public_mera._suggest_limiter._hits.clear() public_mera._suggest_limiter._hits.clear()
public_mera._coverage_limiter._hits.clear() public_mera._coverage_limiter._hits.clear()
public_mera._stats_limiter._hits.clear() public_mera._stats_limiter._hits.clear()
public_mera._showcase_limiter._hits.clear()
yield yield
public_mera._suggest_limiter._hits.clear() public_mera._suggest_limiter._hits.clear()
public_mera._coverage_limiter._hits.clear() public_mera._coverage_limiter._hits.clear()
public_mera._stats_limiter._hits.clear() public_mera._stats_limiter._hits.clear()
public_mera._showcase_limiter._hits.clear()
@pytest.fixture() @pytest.fixture()
@ -107,9 +110,9 @@ def client() -> TestClient:
# ── 1-2. Периметр и его связка с rbac ──────────────────────────────────────── # ── 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} 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 целиком и доступно " "всё под /api/public/ проксируется на meraocenka.ru целиком и доступно "
"без идентичности. Обнови тест ОСОЗНАННО вместе с rbac._PUBLIC_PATHS." "без идентичности. Обнови тест ОСОЗНАННО вместе с rbac._PUBLIC_PATHS."
@ -157,6 +160,107 @@ def test_anonymous_gets_suggest(client: TestClient) -> None:
assert resp.json() == {"items": []} 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: def test_suggest_is_post_so_address_never_lands_in_access_log() -> None:
"""Адрес едет ТЕЛОМ, а не в query. """Адрес едет ТЕЛОМ, а не в query.