gendesign/tradein-mvp/backend/app/tasks/landing_showcase_deals.py
bot-backend abe559cf8f fix(mera): витрина больше не отсеивает промахи оценщика, счётчики едут на фронт
Ревью MAJOR по честности, два пункта.

1. Убран MAX_ABS_ERR_PCT = 40 из build_row. Докстринг модуля сам запрещает
отбор по величине ошибки, но запрет был реализован только в _sort_key, а
фильтр — тот же отбор ступенькой раньше, и злее: строка не попадала даже в
кандидаты. Обоснование «отклонение >40% — почти всегда занижение ДКП ради
налога» не держится: _load_sample уже режет выборку санитарным диапазоном
₽/м² (для ЕКБ это глобальные PPM2_MIN=30k / PPM2_MAX=600k — город намеренно
не заведён в deal_city_price_bands), то есть грубые занижения вырезаны выше
по потоку и ПО СВОЙСТВУ САМОЙ СДЕЛКИ. Всё, что после этого дало большую
ошибку, — работа оценщика, и посетитель обязан её видеть. Честность про
заниженные ДКП перенесена в note каждой строки.

Заодно убраны MIN_FACT_PPM2=30k (дублировал уже применённый фильтр) и
MAX_FACT_PPM2=1.2M (недостижим при потолке выборки 600k): из трёх отбраковок
в проде срабатывала ровно одна — та, что льстила витрине, а два мёртвых
порога читались как работающие. Осталась только структурная отбраковка «нет
прогноза / квартала / площади».

2. Счётчики прогона выведены в ответ ручки. Итог пересчёта пишется в
landing_showcase_runs (миграция 277) и уезжает в ShowcaseResponse.stats
вместе с правилом отбраковки: показано 20 из N годных, рассмотрено M сделок.
Отдельная таблица, а не колонки в строках, — иначе в самом важном случае
(показывать нечего) счётчики исчезли бы вместе со строками. Ручка теперь
берёт и строки, и числа ИЗ ОДНОГО прогона: иначе пустой прогон показал бы
вчерашние строки под сегодняшними счётчиками.

Тесты двусторонние и проверены на сломанном коде: возврат любого порога по
ошибке → красный с величиной отклонения в сообщении; возврат любой границы
₽/м² → красная своя половина; stats=None при живом прогоне → красный.
2026-08-29 19:20:04 +05:00

407 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Пересчёт витрины лэндинга на РЕАЛЬНЫХ сделках (миграция 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())