gendesign/tradein-mvp/backend/app/tasks/asking_to_sold_ratio.py
lekss361 571980c56e
All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 31s
Deploy Trade-In / build-backend (push) Successful in 44s
Deploy Trade-In / deploy (push) Successful in 36s
feat(tradein): ppm²-tier segmentation of asking→sold ratio (#928, flag OFF) (#934)
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
2026-05-31 21:22:39 +00:00

481 lines
23 KiB
Python
Raw 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.

"""Daily recompute of the asking→sold correction ratios (#648 Stage 4).
ПРОБЛЕМА: asking_to_sold_ratios (migration 080) засеяна один раз derivation-CTE
(медиана SOLD ДКП за 12 мес vs медиана ASKING активных listings, per-rooms + global -1
fallback). Оценщик (estimator.py, Stage 3) читает её per-estimate (cache 300s) и домножает
asking-median прогноз на sold/asking. По мере ночного импорта новых ДКП-сделок
(rosreestr_dkp_import) ratio устаревает — нужен периодический пересчёт по тому же derivation.
TRUE-MIRROR REFRESH (флаг database-expert): 080-seed использовал ON CONFLICT DO UPDATE,
который при повторном прогоне ОБНОВЛЯЕТ существующие строки, но НЕ удаляет per-rooms строки
бакетов, упавших ниже порога 30/30 на более позднем прогоне (StaLE rows). Поэтому refresh
сначала DELETE FROM asking_to_sold_ratios WHERE district = '' (все строки #648), затем
заново гоняет ту же 080-derivation INSERT...SELECT. DELETE+INSERT в ОДНОЙ транзакции
(атомарно — таблица никогда не пуста mid-refresh). После commit таблица == свежий re-seed.
Задача синхронная (DB-only, никаких внешних HTTP-вызовов) — запускается in-app
scheduler'ом через trigger_asking_to_sold_ratio_run() (scheduler.py), по образцу
snapshot_listing_sources / import_rosreestr_dkp (sync task в run_in_executor).
Окно расписания 06:00-07:00 UTC — ПОСЛЕ rosreestr_dkp_import (04:00-06:00 UTC), чтобы
refresh потреблял свежие ДКП-сделки того же дня.
SQL derivation ниже — БАЙТ-В-БАЙТ та же логика, что seed в data/sql/080_asking_to_sold_ratios.sql
(deal_side / ask_side / per_bucket + deal_global / ask_global / global_row: трейлинг-12мес
окно, ppm²-полоса [_PPM2_MIN, settings.asking_ratio_ppm2_max] (default [30000,1200000]),
бакет LEAST(GREATEST(rooms,0),4), порог n_deals>=30 AND n_listings>=30 для per_rooms,
global -1 строка всегда). ON CONFLICT убран — DELETE идёт первым,
конфликтов нет (повторный прогон в одной tx невозможен, refresh = re-seed по семантике).
"""
from __future__ import annotations
import logging
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
from app.services import scrape_runs as runs_mod
# Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется.
_PPM2_MIN: int = 30_000
# Верхняя граница берётся из settings.asking_ratio_ppm2_max (default 1_200_000).
# QA-note: точное значение сверить с `SELECT max(price_per_m2) FROM deals
# WHERE source='rosreestr'` на проде — ceiling должен быть > max(ppm²) premium-сделок.
logger = logging.getLogger(__name__)
# ── True-mirror cleanup: drop all #648 rows before re-derivation ──────────────
# district = '' — все строки #648 (district зарезервирован под #647, пока всегда '').
# Удаляем ПЕРЕД re-derive, чтобы бакеты, упавшие ниже порога 30/30, не оставались
# stale (ON CONFLICT DO UPDATE такие строки бы не тронул). В одной транзакции с INSERT.
_DELETE_SQL = text(
"""
DELETE FROM asking_to_sold_ratios
WHERE district = ''
"""
)
# ── Derivation + re-seed (БАЙТ-В-БАЙТ из 080, ON CONFLICT убран — DELETE идёт первым) ──
# deal_side / ask_side / per_bucket + deal_global / ask_global / global_row:
# sold_median = percentile_cont(0.5) по deals.price_per_m2 (source='rosreestr',
# ppm² ∈ [_PPM2_MIN, settings.asking_ratio_ppm2_max], deal_date >= CURRENT_DATE 12 months),
# бакет LEAST(GREATEST(rooms,0),4).
# ask_median = percentile_cont(0.5) по listings.price_per_m2
# (is_active, та же ppm²-полоса [_PPM2_MIN, asking_ratio_ppm2_max]).
# per_rooms строки — только при n_deals>=30 AND n_listings>=30 AND ask>0 AND sold>0.
# global -1 строка (basis='global_fallback') — всегда (если ask>0 AND sold>0). window_months=12.
# Порог/окно — литералы; ppm²-полоса передаётся bind-параметрами :ppm2_min/:ppm2_max
# (безопасно от SQL-инъекций; CAST не нужен — psycopg v3 передаёт int напрямую).
_REDERIVE_SQL = text(
"""
WITH
-- SOLD медианы по бакетам комнат за трейлинг-12мес (ДКП Росреестра).
deal_side AS (
SELECT
LEAST(GREATEST(rooms, 0), 4) AS rooms_bucket,
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS sold_median,
COUNT(*) AS n_deals
FROM deals
WHERE source = 'rosreestr'
AND rooms IS NOT NULL
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
GROUP BY LEAST(GREATEST(rooms, 0), 4)
),
-- ASKING медианы по бакетам комнат среди ТЕКУЩИХ активных объявлений.
ask_side AS (
SELECT
LEAST(GREATEST(rooms, 0), 4) AS rooms_bucket,
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median,
COUNT(*) AS n_listings
FROM listings
WHERE is_active
AND rooms IS NOT NULL
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
GROUP BY LEAST(GREATEST(rooms, 0), 4)
),
-- Per-rooms строки: только бакеты с обеими сторонами, прошедшие порог 30/30 и ask>0.
-- Тонкие бакеты (n<30) сюда НЕ попадают → estimator делает fallback на -1.
per_bucket AS (
SELECT
d.rooms_bucket,
''::text AS district,
(d.sold_median / a.ask_median)::numeric AS ratio,
round(d.sold_median)::bigint AS sold_median,
round(a.ask_median)::bigint AS ask_median,
d.n_deals::int AS n_deals,
a.n_listings::int AS n_listings,
12 AS window_months,
'per_rooms'::text AS basis
FROM deal_side d
JOIN ask_side a USING (rooms_bucket)
WHERE d.n_deals >= 30
AND a.n_listings >= 30
AND a.ask_median IS NOT NULL
AND a.ask_median > 0
AND d.sold_median IS NOT NULL -- divide-safety (порог n_deals>=30 гарантирует)
AND d.sold_median > 0
),
-- SOLD медиана по ВСЕМ комнатам (без бакет-фильтра) за трейлинг-12мес — для global row.
deal_global AS (
SELECT
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS sold_median,
COUNT(*) AS n_deals
FROM deals
WHERE source = 'rosreestr'
AND rooms IS NOT NULL
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
),
-- ASKING медиана по ВСЕМ активным listings (без бакет-фильтра) — для global row.
ask_global AS (
SELECT
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median,
COUNT(*) AS n_listings
FROM listings
WHERE is_active
AND rooms IS NOT NULL
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
),
-- Global fallback строка rooms_bucket=-1 (пишется всегда, если ask>0).
global_row AS (
SELECT
-1 AS rooms_bucket,
''::text AS district,
(d.sold_median / a.ask_median)::numeric AS ratio,
round(d.sold_median)::bigint AS sold_median,
round(a.ask_median)::bigint AS ask_median,
d.n_deals::int AS n_deals,
a.n_listings::int AS n_listings,
12 AS window_months,
'global_fallback'::text AS basis
FROM deal_global d
CROSS JOIN ask_global a
WHERE a.ask_median IS NOT NULL
AND a.ask_median > 0
AND d.sold_median IS NOT NULL -- защита от пустого окна сделок (иначе ratio=NULL)
AND d.sold_median > 0
)
INSERT INTO asking_to_sold_ratios (
rooms_bucket, district, ratio, sold_median, ask_median,
n_deals, n_listings, window_months, basis
)
SELECT rooms_bucket, district, ratio, sold_median, ask_median,
n_deals, n_listings, window_months, basis FROM global_row
UNION ALL
SELECT rooms_bucket, district, ratio, sold_median, ask_median,
n_deals, n_listings, window_months, basis FROM per_bucket
"""
)
# ── Post-insert counters ──────────────────────────────────────────────────────
# Считываем итог из таблицы (всё ещё в той же транзакции — до commit): сколько строк
# записано всего, сколько per_rooms, был ли использован global -1 fallback.
_COUNTERS_SQL = text(
"""
SELECT
COUNT(*) AS rows_written,
COUNT(*) FILTER (WHERE basis = 'per_rooms') AS per_rooms_rows,
COUNT(*) FILTER (WHERE rooms_bucket = -1) AS used_global_fallback
FROM asking_to_sold_ratios
WHERE district = ''
"""
)
# ── Tiered refresh (#928): DELETE + re-derive for tier tables ────────────────
# атомарно DELETE WHERE district='' ПЕРЕД re-derive INSERT (те же семантика, что
# для asking_to_sold_ratios выше: stale строки не оставляем).
_DELETE_TIERED_ROWS_SQL = text(
"""
DELETE FROM asking_to_sold_ratios_tiered WHERE district = ''
"""
)
_DELETE_TIER_BOUNDS_SQL = text(
"""
DELETE FROM asking_to_sold_tier_bounds WHERE district = ''
"""
)
# ── Tiered derivation (BYTE-CONSISTENT с migration 098) ──────────────────────
# Те же CTE что в 098: bounds -> deal_tiered/ask_tiered -> tier_rows + all_rows + global_row.
# Bind params: :ppm2_min / :ppm2_max (как в _REDERIVE_SQL выше).
# CAST не нужен -- psycopg v3 передаёт int напрямую.
_REDERIVE_BOUNDS_SQL = text(
"""
WITH bounds AS (
SELECT
LEAST(GREATEST(rooms, 0), 4) AS rooms_bucket,
percentile_cont(0.3333) WITHIN GROUP (ORDER BY price_per_m2)::bigint AS t33,
percentile_cont(0.6667) WITHIN GROUP (ORDER BY price_per_m2)::bigint AS t66,
COUNT(*)::int AS n_listings
FROM listings
WHERE is_active
AND rooms IS NOT NULL
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
GROUP BY LEAST(GREATEST(rooms, 0), 4)
HAVING COUNT(*) > 0
)
INSERT INTO asking_to_sold_tier_bounds (rooms_bucket, district, t33, t66, n_listings)
SELECT rooms_bucket, ''::text, t33, t66, n_listings FROM bounds
"""
)
_REDERIVE_TIERED_SQL = text(
"""
WITH
bnd AS (
SELECT rooms_bucket, t33, t66 FROM asking_to_sold_tier_bounds WHERE district = ''
),
deal_tiered AS (
SELECT
LEAST(GREATEST(d.rooms, 0), 4) AS rooms_bucket,
CASE
WHEN d.price_per_m2 < b.t33 THEN 'low'
WHEN d.price_per_m2 < b.t66 THEN 'mid'
ELSE 'high'
END AS ppm2_tier,
d.price_per_m2
FROM deals d
JOIN bnd b ON b.rooms_bucket = LEAST(GREATEST(d.rooms, 0), 4)
WHERE d.source = 'rosreestr'
AND d.rooms IS NOT NULL
AND d.price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
AND d.deal_date >= CURRENT_DATE - INTERVAL '12 months'
),
ask_tiered AS (
SELECT
LEAST(GREATEST(l.rooms, 0), 4) AS rooms_bucket,
CASE
WHEN l.price_per_m2 < b.t33 THEN 'low'
WHEN l.price_per_m2 < b.t66 THEN 'mid'
ELSE 'high'
END AS ppm2_tier,
l.price_per_m2
FROM listings l
JOIN bnd b ON b.rooms_bucket = LEAST(GREATEST(l.rooms, 0), 4)
WHERE l.is_active
AND l.rooms IS NOT NULL
AND l.price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
),
deal_side_tier AS (
SELECT
rooms_bucket,
ppm2_tier,
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS sold_median,
COUNT(*)::int AS n_deals
FROM deal_tiered
GROUP BY rooms_bucket, ppm2_tier
),
ask_side_tier AS (
SELECT
rooms_bucket,
ppm2_tier,
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median,
COUNT(*)::int AS n_listings
FROM ask_tiered
GROUP BY rooms_bucket, ppm2_tier
),
tier_rows AS (
SELECT
d.rooms_bucket,
''::text AS district,
d.ppm2_tier,
(d.sold_median / a.ask_median)::numeric AS ratio,
round(d.sold_median)::bigint AS sold_median,
round(a.ask_median)::bigint AS ask_median,
d.n_deals,
a.n_listings,
12 AS window_months,
'per_rooms_tier'::text AS basis
FROM deal_side_tier d
JOIN ask_side_tier a USING (rooms_bucket, ppm2_tier)
WHERE d.n_deals >= 30
AND a.n_listings >= 30
AND a.ask_median IS NOT NULL
AND a.ask_median > 0
AND d.sold_median IS NOT NULL
AND d.sold_median > 0
),
deal_side_all AS (
SELECT
LEAST(GREATEST(rooms, 0), 4) AS rooms_bucket,
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS sold_median,
COUNT(*)::int AS n_deals
FROM deals
WHERE source = 'rosreestr'
AND rooms IS NOT NULL
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
GROUP BY LEAST(GREATEST(rooms, 0), 4)
),
ask_side_all AS (
SELECT
LEAST(GREATEST(rooms, 0), 4) AS rooms_bucket,
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median,
COUNT(*)::int AS n_listings
FROM listings
WHERE is_active
AND rooms IS NOT NULL
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
GROUP BY LEAST(GREATEST(rooms, 0), 4)
),
all_rows AS (
SELECT
d.rooms_bucket,
''::text AS district,
'all'::text AS ppm2_tier,
(d.sold_median / a.ask_median)::numeric AS ratio,
round(d.sold_median)::bigint AS sold_median,
round(a.ask_median)::bigint AS ask_median,
d.n_deals,
a.n_listings,
12 AS window_months,
'per_rooms_all'::text AS basis
FROM deal_side_all d
JOIN ask_side_all a USING (rooms_bucket)
WHERE d.n_deals >= 30
AND a.n_listings >= 30
AND a.ask_median IS NOT NULL
AND a.ask_median > 0
AND d.sold_median IS NOT NULL
AND d.sold_median > 0
),
deal_global AS (
SELECT
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS sold_median,
COUNT(*)::int AS n_deals
FROM deals
WHERE source = 'rosreestr'
AND rooms IS NOT NULL
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
),
ask_global AS (
SELECT
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_m2) AS ask_median,
COUNT(*)::int AS n_listings
FROM listings
WHERE is_active
AND rooms IS NOT NULL
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
),
global_row AS (
SELECT
-1 AS rooms_bucket,
''::text AS district,
'all'::text AS ppm2_tier,
(d.sold_median / a.ask_median)::numeric AS ratio,
round(d.sold_median)::bigint AS sold_median,
round(a.ask_median)::bigint AS ask_median,
d.n_deals,
a.n_listings,
12 AS window_months,
'global_all'::text AS basis
FROM deal_global d
CROSS JOIN ask_global a
WHERE a.ask_median IS NOT NULL
AND a.ask_median > 0
AND d.sold_median IS NOT NULL
AND d.sold_median > 0
)
INSERT INTO asking_to_sold_ratios_tiered (
rooms_bucket, district, ppm2_tier, ratio, sold_median, ask_median,
n_deals, n_listings, window_months, basis
)
SELECT rooms_bucket, district, ppm2_tier, ratio, sold_median, ask_median,
n_deals, n_listings, window_months, basis FROM global_row
UNION ALL
SELECT rooms_bucket, district, ppm2_tier, ratio, sold_median, ask_median,
n_deals, n_listings, window_months, basis FROM all_rows
UNION ALL
SELECT rooms_bucket, district, ppm2_tier, ratio, sold_median, ask_median,
n_deals, n_listings, window_months, basis FROM tier_rows
"""
)
# ── Post-insert counters (tiered) ─────────────────────────────────────────────
_TIERED_COUNTERS_SQL = text(
"""
SELECT
(SELECT COUNT(*) FROM asking_to_sold_ratios_tiered WHERE district = '')
AS tiered_rows_written,
(SELECT COUNT(*) FROM asking_to_sold_tier_bounds WHERE district = '')
AS tier_bounds_written
"""
)
def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]:
"""Пересчитать asking_to_sold_ratios (TRUE-MIRROR refresh, #648 Stage 4).
Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources).
В ОДНОЙ транзакции (атомарно — таблица никогда не пуста mid-refresh):
1. DELETE FROM asking_to_sold_ratios WHERE district = '' — снести stale-строки.
2. Заново прогнать 080-derivation INSERT...SELECT (per_rooms при 30/30 + global -1).
Затем counters из таблицы, commit, mark_done. Семантика == re-seed миграции 080.
Финализирует scrape_runs (mark_done / mark_failed) и пишет counters.
Returns {"rows_written": N, "per_rooms_rows": M, "used_global_fallback": 0|1}.
"""
counters: dict[str, int] = {
"rows_written": 0,
"per_rooms_rows": 0,
"used_global_fallback": 0,
"tiered_rows_written": 0,
"tier_bounds_written": 0,
}
try:
# DELETE + re-derive INSERT в одной транзакции (НЕ коммитим между ними —
# таблица не должна остаться пустой, если INSERT упадёт).
db.execute(_DELETE_SQL)
db.execute(
_REDERIVE_SQL,
{"ppm2_min": _PPM2_MIN, "ppm2_max": settings.asking_ratio_ppm2_max},
)
row = db.execute(_COUNTERS_SQL).mappings().first()
if row is not None:
counters["rows_written"] = int(row["rows_written"] or 0)
counters["per_rooms_rows"] = int(row["per_rooms_rows"] or 0)
counters["used_global_fallback"] = int(row["used_global_fallback"] or 0)
# Tiered refresh (#928): DELETE + re-derive bounds + tier rows в той же транзакции.
# Атомарно — таблицы никогда не пустые mid-refresh (всё в одном commit ниже).
db.execute(_DELETE_TIERED_ROWS_SQL)
db.execute(_DELETE_TIER_BOUNDS_SQL)
db.execute(
_REDERIVE_BOUNDS_SQL,
{"ppm2_min": _PPM2_MIN, "ppm2_max": settings.asking_ratio_ppm2_max},
)
db.execute(
_REDERIVE_TIERED_SQL,
{"ppm2_min": _PPM2_MIN, "ppm2_max": settings.asking_ratio_ppm2_max},
)
tier_row = db.execute(_TIERED_COUNTERS_SQL).mappings().first()
if tier_row is not None:
counters["tiered_rows_written"] = int(tier_row["tiered_rows_written"] or 0)
counters["tier_bounds_written"] = int(tier_row["tier_bounds_written"] or 0)
db.commit()
runs_mod.mark_done(db, run_id, counters)
logger.info(
"recompute_asking_to_sold_ratios run_id=%d done: "
"rows_written=%d per_rooms_rows=%d used_global_fallback=%d "
"tiered_rows_written=%d tier_bounds_written=%d",
run_id,
counters["rows_written"],
counters["per_rooms_rows"],
counters["used_global_fallback"],
counters["tiered_rows_written"],
counters["tier_bounds_written"],
)
return counters
except Exception as exc:
logger.exception("recompute_asking_to_sold_ratios run_id=%d failed", run_id)
db.rollback()
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
raise