Ревью 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 при живом прогоне → красный.
407 lines
21 KiB
Python
407 lines
21 KiB
Python
"""Пересчёт витрины лэндинга на РЕАЛЬНЫХ сделках (миграция 276).
|
||
|
||
ЧТО ЭТО. Публичный лэндинг МЕРЫ показывал ленту «МЕРА сказала X — продали за Y»
|
||
на выдуманных константах (frontend `marketing-v3.ts`). Здесь считается её
|
||
настоящий источник: берём зарегистрированные ДКП-сделки Росреестра по ЕКБ,
|
||
прогоняем каждую через ТОТ ЖЕ спайн оценщика, что и боевой расчёт
|
||
(`scripts/backtest_estimator._predict_full_spine` → `estimator._price_from_inputs`),
|
||
и кладём получившиеся пары «прогноз / факт» в `landing_showcase_deals`.
|
||
|
||
ПРАВИЛО ОТБОРА — ЯВНО И БЕЗ ПОДГОНКИ
|
||
------------------------------------
|
||
Отбираем N строк ключом::
|
||
|
||
(полнота данных ↓, свежесть квартала ↓, id сделки ↓)
|
||
|
||
Величина ошибки в ключе НЕ УЧАСТВУЕТ и участвовать не должна. Отбор по малой
|
||
ошибке превращает витрину в рекламу: показанные 20 строк перестают быть
|
||
выборкой из работы оценщика и становятся её лучшим хвостом, а посетитель
|
||
читает их как «вот так МЕРА обычно и попадает». Это тот самый случай, когда
|
||
код формально работает, а продукт врёт. Проверяется тестом
|
||
`test_landing_showcase_deals.py::test_selection_ignores_error_magnitude`.
|
||
|
||
ФИЛЬТРА ПО ОШИБКЕ ТОЖЕ НЕТ — И ЭТО ТО ЖЕ САМОЕ ПРАВИЛО. До 2026-08-29 здесь
|
||
жил порог `MAX_ABS_ERR_PCT = 40`, выбрасывавший кандидата ПО ВЕЛИЧИНЕ ОШИБКИ
|
||
до ранжирования. Запрет выше он обходил ступенькой раньше: отбор по ошибке в
|
||
ключе и отбор по ошибке в фильтре — одно и то же действие, и второе даже
|
||
злее, потому что не оставляет строку в кандидатах. Обоснование «отклонение
|
||
больше 40% — это почти всегда занижение ДКП ради налога» не держится: см.
|
||
следующий раздел, грубые занижения вырезаны выше по потоку и по свойству
|
||
самой сделки. Отбраковываем только то, чего в данных НЕТ (нет прогноза, нет
|
||
квартала, нет площади) — «число некрасивое» причиной не является.
|
||
|
||
Полнота — сколько из полей, которые видит посетитель (район, этаж, этажность),
|
||
у строки заполнено. Свежесть — порядок квартала сделки.
|
||
|
||
ЧЕСТНОСТЬ ВИТРИНЫ (нарушение любого пункта = витрина врёт)
|
||
----------------------------------------------------------
|
||
* АДРЕСА НЕТ. Номер дома есть у 2.7% сделок, поэтому строка — это «район +
|
||
2-к, 54 м², 5 эт.», и никогда не улица с домом.
|
||
* ДНЯ НЕТ. `deals.deal_date` — первое число квартала (10 различных значений
|
||
на всю таблицу), поэтому в витрине только «II квартал 2026».
|
||
* ЗАМЕР НЕ POINT-IN-TIME. Спайн считает прогноз по СЕГОДНЯШНИМ активным
|
||
объявлениям, а сделка — прошлая. Между ними дрейф рынка, который в ошибку
|
||
входит целиком. Это записано в `note` КАЖДОЙ строки, а не только здесь:
|
||
поле note едет на фронт вместе с числами, а докстринг — нет.
|
||
* ЦЕНА ДКП БЫВАЕТ ЗАНИЖЕНА (налоговая оптимизация, сделки между своими), и
|
||
такая строка выглядит как чудовищный промах оценщика. Санитарный диапазон
|
||
₽/м² применяется ОДИН раз и ВЫШЕ ПО ПОТОКУ — в `_load_sample`, по свойству
|
||
самой сделки, а не по ошибке прогноза: для ЕКБ это глобальные
|
||
`PPM2_MIN = 30 000` / `PPM2_MAX = 600 000` (город намеренно не заведён в
|
||
`deal_city_price_bands`, там же и комментарий об этом). Значит грубые
|
||
занижения из выборки уже вырезаны ДО того, как сюда приходит кандидат, а
|
||
всё, что после этого дало большую ошибку, — работа оценщика, и витрина
|
||
обязана её показать. Своей копии диапазона здесь нет намеренно: прежние
|
||
`MIN_FACT_PPM2 = 30k` дублировал уже применённый фильтр, а
|
||
`MAX_FACT_PPM2 = 1.2M` был недостижим при потолке выборки 600k — из трёх
|
||
отбраковок в проде срабатывала РОВНО ОДНА, та самая, что льстила витрине.
|
||
Неработающая проверка читается как работающая, поэтому её нет.
|
||
* СЧЁТЧИКИ ЕДУТ НА ФРОНТ, А НЕ ТОЛЬКО В ЛОГ. «Мы показываем 20 отличных
|
||
строк» неотличимо от «столько и было», пока рядом не написано, сколько
|
||
сделок рассмотрено и сколько годных строк не поместилось. Поэтому итог
|
||
прогона пишется в `landing_showcase_runs` (миграция 277) и отдаётся
|
||
ручкой `/api/public/mera/showcase` вместе со строками.
|
||
|
||
ЗАПУСК (прод, read-mostly: один DELETE+INSERT в свою таблицу)::
|
||
|
||
docker exec tradein-backend python -m app.tasks.landing_showcase_deals
|
||
|
||
Планировщиком пока не дёргается — витрина обновляется редко (сделки приезжают
|
||
кварталами), а вешать ежедневный джоб ради данных, которые меняются раз в три
|
||
месяца, значит платить сотнями пространственных запросов за ничего.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import logging
|
||
from dataclasses import dataclass
|
||
from datetime import date
|
||
from typing import Any
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ── Правило отбраковки: одна формулировка, она же едет на фронт ──────────────
|
||
#
|
||
# Порогов на величину ошибки здесь НЕТ (разбор — в докстринге модуля). Санитарный
|
||
# диапазон ₽/м² применён выше по потоку, в `_load_sample`; дублировать его тут
|
||
# значило бы завести проверку, которая в проде не срабатывает никогда.
|
||
REJECTION_RULE = (
|
||
"Строка не попадает на витрину, только если данных нет: оценщик не дал "
|
||
"ожидаемой цены продажи (мало аналогов), неизвестен квартал сделки или "
|
||
"площадь. Величина отклонения на отбор и отбраковку не влияет — иначе "
|
||
"витрина показывала бы лучший хвост, а не работу оценщика. Санитарный "
|
||
"диапазон цены сделки (30 000–600 000 ₽/м² для Екатеринбурга) применён "
|
||
"к выборке до расчёта, по цене самой сделки."
|
||
)
|
||
|
||
NOTE = (
|
||
"Прогноз посчитан по активным объявлениям на дату пересчёта, сделка — прошлая: "
|
||
"это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. "
|
||
"Факт — цена ДКП, заявленная в Росреестр: она бывает занижена сторонами, и тогда "
|
||
"строка выглядит как промах оценщика, хотя врёт документ."
|
||
)
|
||
|
||
_ROMAN = {1: "I", 2: "II", 3: "III", 4: "IV"}
|
||
|
||
|
||
def quarter_label(d: date | None) -> str | None:
|
||
"""`date(2026, 4, 1)` → ``'II квартал 2026'``. Нет даты — нет ярлыка."""
|
||
if d is None:
|
||
return None
|
||
return f"{_ROMAN[(d.month - 1) // 3 + 1]} квартал {d.year}"
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ShowcaseRow:
|
||
"""Одна строка витрины — ровно то, что уедет в таблицу и на фронт."""
|
||
|
||
deal_id: int
|
||
district: str | None
|
||
rooms: int
|
||
area_m2: float
|
||
floor: int | None
|
||
total_floors: int | None
|
||
deal_date: date | None
|
||
deal_quarter: str
|
||
predicted_rub: int
|
||
fact_rub: int
|
||
err_pct: float
|
||
n_analogs: int
|
||
|
||
|
||
def completeness(row: ShowcaseRow) -> int:
|
||
"""Сколько ВИДИМЫХ посетителю полей заполнено (0..3).
|
||
|
||
Считаем район/этаж/этажность: комнаты и площадь есть у всех кандидатов по
|
||
построению выборки, поэтому в оценке полноты они бесполезны.
|
||
"""
|
||
return sum(x is not None for x in (row.district, row.floor, row.total_floors))
|
||
|
||
|
||
def _sort_key(row: ShowcaseRow) -> tuple[int, date, int]:
|
||
"""Ключ отбора. Ошибки здесь нет — см. «ПРАВИЛО ОТБОРА» в докстринге модуля."""
|
||
return (
|
||
-completeness(row),
|
||
-(row.deal_date or date.min).toordinal(),
|
||
-row.deal_id,
|
||
)
|
||
|
||
|
||
def select_rows(rows: list[ShowcaseRow], limit: int) -> list[ShowcaseRow]:
|
||
"""Отобрать `limit` строк по полноте и свежести (НЕ по величине ошибки)."""
|
||
return sorted(rows, key=_sort_key)[:limit]
|
||
|
||
|
||
def build_row(
|
||
*,
|
||
deal_id: int,
|
||
district: str | None,
|
||
rooms: int,
|
||
area_m2: float,
|
||
floor: int | None,
|
||
total_floors: int | None,
|
||
deal_date: date | None,
|
||
predicted_rub: float | None,
|
||
fact_ppm2: float,
|
||
n_analogs: int,
|
||
) -> ShowcaseRow | None:
|
||
"""Кандидат → строка витрины, либо None если считать не из чего.
|
||
|
||
Причины отказа ИСЧЕРПЫВАЮЩИЕ и все — «данных нет»: спайн не дал ожидаемой
|
||
цены продажи; квартал сделки неизвестен; нет площади или цены сделки
|
||
(делить не на что). Величина отклонения причиной НЕ является ни при каких
|
||
значениях — см. «ФИЛЬТРА ПО ОШИБКЕ ТОЖЕ НЕТ» в докстринге модуля.
|
||
"""
|
||
if predicted_rub is None or predicted_rub <= 0 or area_m2 <= 0 or fact_ppm2 <= 0:
|
||
return None
|
||
quarter = quarter_label(deal_date)
|
||
if quarter is None:
|
||
return None
|
||
|
||
fact_rub = fact_ppm2 * area_m2
|
||
# Знак ошибки — как в бэктесте: (прогноз − факт) / факт. Плюс = МЕРА
|
||
# назвала дороже, чем ушло по ДКП.
|
||
err_pct = 100.0 * (predicted_rub - fact_rub) / fact_rub
|
||
|
||
return ShowcaseRow(
|
||
deal_id=deal_id,
|
||
district=district,
|
||
rooms=rooms,
|
||
area_m2=round(area_m2, 2),
|
||
floor=floor,
|
||
total_floors=total_floors,
|
||
deal_date=deal_date,
|
||
deal_quarter=quarter,
|
||
predicted_rub=round(predicted_rub),
|
||
fact_rub=round(fact_rub),
|
||
err_pct=round(err_pct, 2),
|
||
n_analogs=n_analogs,
|
||
)
|
||
|
||
|
||
# ── Район: FDW-вьюха чужой базы, поэтому best-effort ─────────────────────────
|
||
_DISTRICT_SQL = text(
|
||
"""
|
||
SELECT d.id AS deal_id, g.district_name
|
||
FROM deals d
|
||
JOIN gendesign_ekb_districts_geom g
|
||
ON ST_Contains(g.geom, d.geom::geometry)
|
||
WHERE d.id = ANY(CAST(:ids AS bigint[]))
|
||
"""
|
||
)
|
||
|
||
|
||
def _fetch_districts(db: Session, deal_ids: list[int]) -> dict[int, str]:
|
||
"""id сделки → район. Недоступна вьюха — пустой словарь, а не выдуманный район.
|
||
|
||
`gendesign_ekb_districts_geom` — foreign table в базу gendesign, и её гранты
|
||
на той стороне уже терялись (DROP MV CASCADE снимает GRANT). Оборачиваем в
|
||
SAVEPOINT ИМЕННО ЗДЕСЬ, на месте глушения: провалившийся SELECT переводит
|
||
транзакцию в aborted, и следующий запрос упал бы уже не по своей вине.
|
||
"""
|
||
if not deal_ids:
|
||
return {}
|
||
try:
|
||
with db.begin_nested():
|
||
rows = db.execute(_DISTRICT_SQL, {"ids": deal_ids}).mappings().all()
|
||
except Exception as exc:
|
||
logger.warning("район не резолвится (витрина будет без района): %s", exc)
|
||
return {}
|
||
return {int(r["deal_id"]): r["district_name"] for r in rows if r["district_name"]}
|
||
|
||
|
||
_DELETE_SQL = text("DELETE FROM landing_showcase_deals")
|
||
_DELETE_RUNS_SQL = text("DELETE FROM landing_showcase_runs")
|
||
# Тот же `now()`, что у DEFAULT в строках витрины: в Postgres now() — время
|
||
# НАЧАЛА транзакции, а батч и его итог пишутся одной транзакцией. Ручка по
|
||
# этому computed_at и связывает счётчики со строками.
|
||
_INSERT_RUN_SQL = text(
|
||
"""
|
||
INSERT INTO landing_showcase_runs
|
||
(considered, priced, no_prediction, incomplete, eligible, written,
|
||
with_district, rejection_rule)
|
||
VALUES
|
||
(CAST(:considered AS integer), CAST(:priced AS integer),
|
||
CAST(:no_prediction AS integer), CAST(:incomplete AS integer),
|
||
CAST(:eligible AS integer), CAST(:written AS integer),
|
||
CAST(:with_district AS integer), CAST(:rejection_rule AS text))
|
||
"""
|
||
)
|
||
_INSERT_SQL = text(
|
||
"""
|
||
INSERT INTO landing_showcase_deals
|
||
(district, rooms, area_m2, floor, total_floors, deal_quarter,
|
||
predicted_rub, fact_rub, err_pct, n_analogs, note)
|
||
VALUES
|
||
(CAST(:district AS text), CAST(:rooms AS integer), CAST(:area_m2 AS numeric),
|
||
CAST(:floor AS integer), CAST(:total_floors AS integer),
|
||
CAST(:deal_quarter AS text), CAST(:predicted_rub AS bigint),
|
||
CAST(:fact_rub AS bigint), CAST(:err_pct AS numeric),
|
||
CAST(:n_analogs AS integer), CAST(:note AS text))
|
||
"""
|
||
)
|
||
|
||
|
||
def refresh_landing_showcase_deals(
|
||
db: Session,
|
||
*,
|
||
sample: int = 200,
|
||
since: str = "2025-01-01",
|
||
limit: int = 20,
|
||
city: str = "Екатеринбург",
|
||
) -> dict[str, int]:
|
||
"""Прогнать бэктест по ЕКБ и перезаписать витрину. Возвращает счётчики.
|
||
|
||
Счётчики — не отладочный шум: без них «на витрине 20 отличных строк»
|
||
неотличимо от «столько и было». Поэтому они не только пишутся в лог, но и
|
||
сохраняются в `landing_showcase_runs` и уезжают на фронт вместе со
|
||
строками. Значения:
|
||
|
||
considered сколько ДКП-сделок взято в работу
|
||
priced из них оценщик дал ожидаемую цену продажи
|
||
no_prediction не дал (мало аналогов / спайн упал)
|
||
incomplete цена есть, но нет квартала/площади — строку не собрать
|
||
eligible годных строк ВСЕГО (никакого отсева по ошибке нет)
|
||
written из них показано (обрезано по `limit`)
|
||
with_district у скольких показанных удалось определить район
|
||
"""
|
||
# Импорт внутри функции: `scripts.backtest_estimator` тянет оценщик со всеми
|
||
# его зависимостями, а web-процессу это на импорте приложения не нужно.
|
||
from scripts.backtest_estimator import (
|
||
_import_estimator_full,
|
||
_load_sample,
|
||
_predict_full_spine,
|
||
)
|
||
|
||
est = _import_estimator_full()
|
||
deals = _load_sample(db, sample=sample, since=since, city=city)
|
||
logger.info("витрина: загружено %d ДКП-сделок (city=%s, since=%s)", len(deals), city, since)
|
||
|
||
districts = _fetch_districts(db, [d.id for d in deals])
|
||
|
||
candidates: list[ShowcaseRow] = []
|
||
n_priced = 0
|
||
n_incomplete = 0
|
||
for deal in deals:
|
||
capture: list[dict[str, Any]] = []
|
||
try:
|
||
pr = _predict_full_spine(db, deal, est, capture=capture)
|
||
except Exception as exc:
|
||
logger.warning("сделка %s: спайн упал, пропускаем: %s", deal.id, exc)
|
||
db.rollback()
|
||
continue
|
||
if pr is None:
|
||
continue
|
||
n_priced += 1
|
||
row = build_row(
|
||
deal_id=deal.id,
|
||
district=districts.get(deal.id),
|
||
rooms=deal.rooms,
|
||
area_m2=deal.area_m2,
|
||
floor=deal.floor,
|
||
total_floors=deal.total_floors,
|
||
deal_date=deal.deal_date,
|
||
predicted_rub=pr.expected_sold_price,
|
||
fact_ppm2=deal.sold_ppm2,
|
||
n_analogs=len(capture[0]["kwargs"]["listings"]) if capture else 0,
|
||
)
|
||
if row is None:
|
||
n_incomplete += 1
|
||
continue
|
||
candidates.append(row)
|
||
|
||
chosen = select_rows(candidates, limit)
|
||
|
||
db.execute(_DELETE_SQL)
|
||
db.execute(_DELETE_RUNS_SQL)
|
||
for row in chosen:
|
||
db.execute(
|
||
_INSERT_SQL,
|
||
{
|
||
"district": row.district,
|
||
"rooms": row.rooms,
|
||
"area_m2": row.area_m2,
|
||
"floor": row.floor,
|
||
"total_floors": row.total_floors,
|
||
"deal_quarter": row.deal_quarter,
|
||
"predicted_rub": row.predicted_rub,
|
||
"fact_rub": row.fact_rub,
|
||
"err_pct": row.err_pct,
|
||
"n_analogs": row.n_analogs,
|
||
"note": NOTE,
|
||
},
|
||
)
|
||
counters = {
|
||
"considered": len(deals),
|
||
"priced": n_priced,
|
||
"no_prediction": len(deals) - n_priced,
|
||
"incomplete": n_incomplete,
|
||
"eligible": len(candidates),
|
||
"written": len(chosen),
|
||
"with_district": sum(1 for r in chosen if r.district is not None),
|
||
}
|
||
db.execute(_INSERT_RUN_SQL, {**counters, "rejection_rule": REJECTION_RULE})
|
||
db.commit()
|
||
|
||
logger.info(
|
||
"витрина обновлена: рассмотрено=%d оценено=%d без_прогноза=%d неполных=%d "
|
||
"годных=%d записано=%d с_районом=%d",
|
||
counters["considered"],
|
||
counters["priced"],
|
||
counters["no_prediction"],
|
||
counters["incomplete"],
|
||
counters["eligible"],
|
||
counters["written"],
|
||
counters["with_district"],
|
||
)
|
||
return counters
|
||
|
||
|
||
def main(argv: list[str] | None = None) -> int:
|
||
parser = argparse.ArgumentParser(description=__doc__)
|
||
parser.add_argument("--sample", type=int, default=200)
|
||
parser.add_argument("--since", default="2025-01-01")
|
||
parser.add_argument("--limit", type=int, default=20)
|
||
parser.add_argument("--city", default="Екатеринбург")
|
||
args = parser.parse_args(argv)
|
||
|
||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||
|
||
from app.core.db import SessionLocal
|
||
|
||
db = SessionLocal()
|
||
try:
|
||
refresh_landing_showcase_deals(
|
||
db, sample=args.sample, since=args.since, limit=args.limit, city=args.city
|
||
)
|
||
finally:
|
||
db.close()
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|