From 1d9adb4a246934495d9b5c67638c9bc072d67f71 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 9 Sep 2026 02:25:47 +0300 Subject: [PATCH] =?UTF-8?q?feat(tradein/estimator):=20deal=5Fcity=5Fprice?= =?UTF-8?q?=5Fbands=20=D0=BF=D0=BE=20=D0=BA=D0=BB=D1=8E=D1=87=D1=83=20(reg?= =?UTF-8?q?ion=5Fcode,=20city)=20=E2=80=94=20=D0=BC=D0=B8=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20298,=20refresh=20per-region,=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=82=D1=80=D0=B5=D0=B1=D0=B8=D1=82=D0=B5=D0=BB?= =?UTF-8?q?=D0=B8=20(#3051=20sub-PR=20B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit После импорта 212 937 московских ДКП (region_code=77) region_stats refresh'а (p1 для tier region_fallback) считался бы по пулу 66+77 и поднял бы floor ~250 малым городам области. Ключ bands становится (region_code, city): миграция 298 (колонка DEFAULT 66, смена PK через DO-guard, re-seed per-region, ЕКБ-исключение только для 66, doc_type=ДКП), тот же SQL в refresh-джобе, estimator/backtest читают bands по паре. Для 66 набор строк байт-идентичен прежнему (ревью двумя линзами). --- tradein-mvp/backend/app/services/estimator.py | 81 +++++---- .../tasks/deal_city_price_bands_refresh.py | 86 ++++++--- .../sql/298_deal_city_price_bands_region.sql | 170 ++++++++++++++++++ .../backend/scripts/backtest_estimator.py | 41 +++-- 4 files changed, 305 insertions(+), 73 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/298_deal_city_price_bands_region.sql diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index f299925b..12f0aa31 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -345,22 +345,24 @@ DEAL_MAX_FLOOR = 60 # выше реального максимума ЕКБ → # глобальный пол 50k ₽/м² для дешёвых городов области НЕ anti-outlier guard, а # cut-off легитимного рынка (Североуральск median ≈21.7k, Новоуральск ≈36k, # Асбест ≈41k — все ниже 50k → 46.6% не-ЕКБ сделок молча дропались). -# deal_city_price_bands (миграция 178) — per-city [ppm2_min, ppm2_max] band из -# перцентилей p1/p99 реальных rosreestr-сделок города (hard floor/ceiling -# 8000/800000 всё равно режут доли/опечатки). Екатеринбург НАМЕРЕННО исключён -# из таблицы — .get(city, ...) fallback на глобальные DEAL_MIN_PPM2/MAX_PPM2 -# ниже гарантирует byte-identical ЕКБ-поведение. +# deal_city_price_bands (миграция 178, ключ (region_code, city) — миграция 298 +# #3051) — per-(region, city) [ppm2_min, ppm2_max] band из перцентилей p1/p99 +# реальных rosreestr-сделок города (hard floor/ceiling 8000/800000 всё равно +# режут доли/опечатки). Екатеринбург (region_code=66) НАМЕРЕННО исключён из +# таблицы — .get((region_code, city), ...) fallback на глобальные +# DEAL_MIN_PPM2/MAX_PPM2 ниже гарантирует byte-identical ЕКБ-поведение. _CITY_PRICE_BANDS_CACHE_TTL_S = 300.0 -_city_price_bands_cache: tuple[dict[str, tuple[int, int]], float] | None = None +_city_price_bands_cache: tuple[dict[tuple[int, str], tuple[int, int]], float] | None = None -def _load_city_price_bands(db: Session) -> dict[str, tuple[int, int]]: - """#2478: {city: (ppm2_min, ppm2_max)} из deal_city_price_bands (миграция 178). +def _load_city_price_bands(db: Session) -> dict[tuple[int, str], tuple[int, int]]: + """#2478 + #3051 (298): {(region_code, city): (ppm2_min, ppm2_max)} из deal_city_price_bands. Кэш в процессе (TTL _CITY_PRICE_BANDS_CACHE_TTL_S) — таблица рефрешится - периодическим ре-запуском миграции, не на каждый estimate. Таблицы нет / - любая ошибка → {} (graceful) — вызывающий код fallback'ит на глобальные - DEAL_MIN_PPM2/DEAL_MAX_PPM2, т.е. поведение как до #2478. + периодическим ре-запуском derivation (298 / deal_city_price_bands_refresh), + не на каждый estimate. Таблицы нет / любая ошибка → {} (graceful) — + вызывающий код fallback'ит на глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2, т.е. + поведение как до #2478. """ global _city_price_bands_cache if _city_price_bands_cache is not None: @@ -369,14 +371,18 @@ def _load_city_price_bands(db: Session) -> dict[str, tuple[int, int]]: return bands try: rows = ( - db.execute(text("SELECT city, ppm2_min, ppm2_max FROM deal_city_price_bands")) + db.execute( + text("SELECT region_code, city, ppm2_min, ppm2_max FROM deal_city_price_bands") + ) .mappings() .all() ) except Exception as exc: logger.warning("deal_city_price_bands lookup failed (graceful): %s", exc) return {} - bands = {r["city"]: (int(r["ppm2_min"]), int(r["ppm2_max"])) for r in rows} + bands = { + (int(r["region_code"]), r["city"]): (int(r["ppm2_min"]), int(r["ppm2_max"])) for r in rows + } _city_price_bands_cache = (bands, time.monotonic()) return bands @@ -1885,7 +1891,8 @@ def _fetch_dkp_corridor( f""" SELECT d.price_per_m2, d.deal_date FROM deals d - LEFT JOIN deal_city_price_bands b ON b.city = d.city + LEFT JOIN deal_city_price_bands b + ON b.region_code = d.region_code AND b.city = d.city WHERE d.source = 'rosreestr' AND d.region_code = CAST(:region_code AS int) AND d.address ILIKE :street_pattern @@ -1896,11 +1903,12 @@ def _fetch_dkp_corridor( - (CAST(:period_months AS integer) || ' months')::interval AND d.price_per_m2 > 0 {city_filter_sql} - -- #699 + #2478: режем нерыночные ppm²-выбросы из коридора - -- expected_sold. Per-city band (deal_city_price_bands, миграция - -- 178) когда для города сделки есть строка (не-ЕКБ область); - -- иначе (в т.ч. Екатеринбург, НАМЕРЕННО не в таблице) fallback - -- на глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2 — byte-identical. + -- #699 + #2478 + #3051: режем нерыночные ppm²-выбросы из коридора + -- expected_sold. Per-(region, city) band (deal_city_price_bands, + -- миграция 178, ключ (region_code, city) — миграция 298) когда для + -- (региона, города) сделки есть строка; иначе (в т.ч. Екатеринбург + -- region_code=66, НАМЕРЕННО не в таблице) fallback на глобальные + -- DEAL_MIN_PPM2/DEAL_MAX_PPM2 — byte-identical. AND d.price_per_m2 BETWEEN COALESCE(b.ppm2_min, CAST(:ppm_min AS int)) AND COALESCE(b.ppm2_max, CAST(:ppm_max AS int)) """ @@ -1968,7 +1976,8 @@ def _fetch_dkp_corridor( """ SELECT d.price_per_m2, d.deal_date FROM deals d - LEFT JOIN deal_city_price_bands b ON b.city = d.city + LEFT JOIN deal_city_price_bands b + ON b.region_code = d.region_code AND b.city = d.city WHERE d.source = 'rosreestr' AND d.region_code = CAST(:region_code AS int) AND d.city IS NOT NULL @@ -6552,25 +6561,29 @@ def _is_plausible_deal( area_m2: float | None = None, price_rub: float | None = None, city: str | None = None, - bands: dict[str, tuple[int, int]] | None = None, + bands: dict[tuple[int, str], tuple[int, int]] | None = None, + region_code: int = regions_mod.DEFAULT_REGION_CODE, ) -> bool: - """#699 + Mera-audit fix-2 + #2478: True если ДКП-сделка правдоподобна (не выброс). + """#699 + Mera-audit fix-2 + #2478 + #3051 (298): True если ДКП-сделка правдоподобна. Абсолютные guard-bands (см. DEAL_* константы). None-поля не судим (keep — нечем сравнивать). Проверки: - price_per_m2 вне [ppm_min, ppm_max] → drop, где (ppm_min, ppm_max) = - bands.get(city, (DEAL_MIN_PPM2, DEAL_MAX_PPM2)) — #2478: per-city band - (deal_city_price_bands, миграция 178) для не-ЕКБ городов области, где - глобальный пол 50k ₽/м² режет легитимно дешёвый рынок. city не в bands - (в т.ч. Екатеринбург — НАМЕРЕННО не в таблице) или bands=None → - fallback на глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2 (byte-identical - сегодняшнему поведению). + bands.get((region_code, city), (DEAL_MIN_PPM2, DEAL_MAX_PPM2)) — #2478: + per-(region, city) band (deal_city_price_bands, миграция 178, ключ + (region_code, city) — миграция 298) для не-ЕКБ городов области, где + глобальный пол 50k ₽/м² режет легитимно дешёвый рынок. region_code — + kw-only с дефолтом regions_mod.DEFAULT_REGION_CODE (66), позиционные + вызовы не ломаются. (region_code, city) не в bands (в т.ч. Екатеринбург + region_code=66 — НАМЕРЕННО не в таблице) или bands=None → fallback на + глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2 (byte-identical сегодняшнему + поведению). - floor < 1 или floor > DEAL_MAX_FLOOR → drop (битый парсер: floor=-5/999) - floor > total_floors физически невозможен → drop - area_m2 задана и <= 0 → drop (битый парсер) - price_rub задана и <= 0 → drop (нерыночная/технческая сделка) """ - ppm_min, ppm_max = (bands or {}).get(city, (DEAL_MIN_PPM2, DEAL_MAX_PPM2)) + ppm_min, ppm_max = (bands or {}).get((region_code, city), (DEAL_MIN_PPM2, DEAL_MAX_PPM2)) if price_per_m2 is not None and not (ppm_min <= price_per_m2 <= ppm_max): return False if floor is not None: @@ -6595,7 +6608,7 @@ def _fetch_deals( SELECT source, address, lat, lon, rooms, area_m2, floor, total_floors, - price_rub, price_per_m2, city, + price_rub, price_per_m2, city, region_code, deal_date, days_on_market, cadastral_number, ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography) AS distance_m @@ -6625,8 +6638,11 @@ def _fetch_deals( # #699 + Mera-audit fix-2: отсекаем ДКП-выбросы (битый этаж / нерыночный ppm² # / нулевая площадь / нулевая цена) до выдачи в actual_deals и expected_sold. - # #2478: per-city ppm² band (deal_city_price_bands) вместо глобального - # DEAL_MIN_PPM2/MAX_PPM2 — грузим один раз на вызов, graceful {} при ошибке. + # #2478 + #3051 (298): per-(region, city) ppm² band (deal_city_price_bands) + # вместо глобального DEAL_MIN_PPM2/MAX_PPM2 — грузим один раз на вызов, + # graceful {} при ошибке. region_code=NULL (не должно случаться для + # rosreestr-строк, см. 177/288 backfill) → DEFAULT_REGION_CODE, чтобы band + # lookup не падал на None-ключе. bands = _load_city_price_bands(db) deals = [dict(r) for r in rows] clean = [ @@ -6640,6 +6656,7 @@ def _fetch_deals( d.get("price_rub"), city=d.get("city"), bands=bands, + region_code=d.get("region_code") or regions_mod.DEFAULT_REGION_CODE, ) ] if len(clean) < len(deals): diff --git a/tradein-mvp/backend/app/tasks/deal_city_price_bands_refresh.py b/tradein-mvp/backend/app/tasks/deal_city_price_bands_refresh.py index 5e2322f9..882bbe0f 100644 --- a/tradein-mvp/backend/app/tasks/deal_city_price_bands_refresh.py +++ b/tradein-mvp/backend/app/tasks/deal_city_price_bands_refresh.py @@ -15,17 +15,29 @@ asking_to_sold_ratio_refresh (06:00-07:00 UTC), чтобы бэнды счита свежему срезу deals, что и ratio-таблица того же дня. SQL derivation ниже — БАЙТ-В-БАЙТ та же логика, что seed в -data/sql/194_deal_city_price_bands_tiers.sql (region_stats / city_stats / tiered: +data/sql/298_deal_city_price_bands_region.sql (region_stats / city_stats / tiered: трёхуровневая схема full N>=30 / rough N 10-29 / region_fallback N 1-9, см. -комментарий в 194 для полного обоснования тиров и hard floor/ceiling клампов). +комментарий в 194/298 для полного обоснования тиров и hard floor/ceiling клампов). + +#3051 «Москва» (298): ключ (region_code, city) вместо (city) — region_stats и +city_stats теперь группируются ПО РЕГИОНУ (region_code), а не по всей таблице +deals целиком. Без этого пул для tier='region_fallback' одного региона +подмешивал бы сделки другого (Москва в deals region_code=77 иначе тянула бы +p1-floor малых городов Свердловской обл. region_code=66 вверх). Для +region_code=66 derivation байт-в-байт прежняя (194): фильтр +NOT (region_code = 66 AND city = 'Екатеринбург') — тот же инвариант, что +раньше `city <> 'Екатеринбург'`; region_stats/city_stats для региона 66 видят +ТУ ЖЕ популяцию строк, что видели до появления региона 77 в deals. Нет DELETE перед re-derive (в отличие от asking_to_sold_ratio.py true-mirror -паттерна) — множество городов монотонно растёт (rosreestr_dkp_import только -INSERT/ON CONFLICT DO UPDATE, никогда не удаляет сделки), поэтому merge-по-city -(ON CONFLICT DO UPDATE) достаточен: город, перешедший в другой tier, просто -перезаписывается на следующем refresh. Екатеринбург НЕ включён (WHERE city <> -'Екатеринбург') — estimator.py fallback на глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2 -для ЕКБ остаётся byte-identical (invariant из 178/194 сохранён). +паттерна) — множество (region_code, city) монотонно растёт +(rosreestr_dkp_import только INSERT/ON CONFLICT DO UPDATE, никогда не удаляет +сделки), поэтому merge-по-ключу (ON CONFLICT DO UPDATE) достаточен: город, +перешедший в другой tier, просто перезаписывается на следующем refresh. +Екатеринбург НЕ включён для региона 66 (WHERE NOT (region_code = 66 AND +city = 'Екатеринбург')) — estimator.py fallback на глобальные +DEAL_MIN_PPM2/DEAL_MAX_PPM2 для ЕКБ остаётся byte-identical (invariant из +178/194/298 сохранён). """ from __future__ import annotations @@ -39,22 +51,28 @@ from app.services import scrape_runs as runs_mod logger = logging.getLogger(__name__) -# ── Derivation + re-seed (БАЙТ-В-БАЙТ из 194) ───────────────────────────────── +# ── Derivation + re-seed (БАЙТ-В-БАЙТ из 298, region-aware) ────────────────── _REDERIVE_SQL = text( """ WITH region_stats AS ( - SELECT GREATEST( - round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int, - 8000 - ) AS region_ppm2_min + SELECT + region_code, + GREATEST( + round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int, + 8000 + ) AS region_ppm2_min FROM deals WHERE source = 'rosreestr' + AND doc_type = 'ДКП' AND price_per_m2 IS NOT NULL AND city IS NOT NULL - AND city <> 'Екатеринбург' + AND region_code IS NOT NULL + AND NOT (region_code = 66 AND city = 'Екатеринбург') + GROUP BY region_code ), city_stats AS ( SELECT + region_code, city, GREATEST(round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int, 8000) AS ppm2_p1, @@ -63,13 +81,15 @@ _REDERIVE_SQL = text( count(*) AS n_deals FROM deals WHERE source = 'rosreestr' + AND doc_type = 'ДКП' AND price_per_m2 IS NOT NULL AND city IS NOT NULL - AND city <> 'Екатеринбург' - GROUP BY city + AND region_code IS NOT NULL + AND NOT (region_code = 66 AND city = 'Екатеринбург') + GROUP BY region_code, city ), tiered AS ( - SELECT city, ppm2_p1 AS ppm2_min, ppm2_p99 AS ppm2_max, n_deals, + SELECT region_code, city, ppm2_p1 AS ppm2_min, ppm2_p99 AS ppm2_max, n_deals, 'full'::text AS tier FROM city_stats WHERE n_deals >= 30 @@ -77,23 +97,24 @@ _REDERIVE_SQL = text( UNION ALL - SELECT city, LEAST(ppm2_p1, 700000) AS ppm2_min, 800000 AS ppm2_max, n_deals, - 'rough'::text AS tier + SELECT region_code, city, LEAST(ppm2_p1, 700000) AS ppm2_min, 800000 AS ppm2_max, + n_deals, 'rough'::text AS tier FROM city_stats WHERE n_deals BETWEEN 10 AND 29 UNION ALL - SELECT c.city, r.region_ppm2_min AS ppm2_min, 800000 AS ppm2_max, c.n_deals, - 'region_fallback'::text AS tier + SELECT c.region_code, c.city, r.region_ppm2_min AS ppm2_min, 800000 AS ppm2_max, + c.n_deals, 'region_fallback'::text AS tier FROM city_stats c - CROSS JOIN region_stats r + JOIN region_stats r ON r.region_code = c.region_code WHERE c.n_deals < 10 ) - INSERT INTO deal_city_price_bands (city, ppm2_min, ppm2_max, n_deals, tier, refreshed_at) - SELECT city, ppm2_min, ppm2_max, n_deals, tier, now() + INSERT INTO deal_city_price_bands + (region_code, city, ppm2_min, ppm2_max, n_deals, tier, refreshed_at) + SELECT region_code, city, ppm2_min, ppm2_max, n_deals, tier, now() FROM tiered - ON CONFLICT (city) DO UPDATE + ON CONFLICT (region_code, city) DO UPDATE SET ppm2_min = EXCLUDED.ppm2_min, ppm2_max = EXCLUDED.ppm2_max, n_deals = EXCLUDED.n_deals, @@ -103,13 +124,18 @@ _REDERIVE_SQL = text( ) # ── Post-insert counters ────────────────────────────────────────────────────── +# #3051 (298): regions — число различных region_code в таблице после re-derive +# (Свердловская обл. + Москва после включения региона 77). Добавлено в конец +# SELECT-списка, прежние 4 счётчика на тех же местах — контракт _COUNTERS_SQL +# (rows_written/full_rows/rough_rows/region_fallback_rows) не ломается. _COUNTERS_SQL = text( """ SELECT COUNT(*) AS rows_written, COUNT(*) FILTER (WHERE tier = 'full') AS full_rows, COUNT(*) FILTER (WHERE tier = 'rough') AS rough_rows, - COUNT(*) FILTER (WHERE tier = 'region_fallback') AS region_fallback_rows + COUNT(*) FILTER (WHERE tier = 'region_fallback') AS region_fallback_rows, + COUNT(DISTINCT region_code) AS regions FROM deal_city_price_bands """ ) @@ -124,13 +150,15 @@ def refresh_deal_city_price_bands(db: Session, run_id: int) -> dict[str, int]: Финализирует scrape_runs (mark_done / mark_failed) и пишет counters. - Returns {"rows_written": N, "full_rows": .., "rough_rows": .., "region_fallback_rows": ..}. + Returns {"rows_written": N, "full_rows": .., "rough_rows": .., "region_fallback_rows": .., + "regions": ..}. """ counters: dict[str, int] = { "rows_written": 0, "full_rows": 0, "rough_rows": 0, "region_fallback_rows": 0, + "regions": 0, } try: db.execute(_REDERIVE_SQL) @@ -141,17 +169,19 @@ def refresh_deal_city_price_bands(db: Session, run_id: int) -> dict[str, int]: counters["full_rows"] = int(row["full_rows"] or 0) counters["rough_rows"] = int(row["rough_rows"] or 0) counters["region_fallback_rows"] = int(row["region_fallback_rows"] or 0) + counters["regions"] = int(row["regions"] or 0) db.commit() runs_mod.mark_done(db, run_id, counters) logger.info( "refresh_deal_city_price_bands run_id=%d done: " - "rows_written=%d full=%d rough=%d region_fallback=%d", + "rows_written=%d full=%d rough=%d region_fallback=%d regions=%d", run_id, counters["rows_written"], counters["full_rows"], counters["rough_rows"], counters["region_fallback_rows"], + counters["regions"], ) return counters except Exception as exc: diff --git a/tradein-mvp/backend/data/sql/298_deal_city_price_bands_region.sql b/tradein-mvp/backend/data/sql/298_deal_city_price_bands_region.sql new file mode 100644 index 00000000..633398c9 --- /dev/null +++ b/tradein-mvp/backend/data/sql/298_deal_city_price_bands_region.sql @@ -0,0 +1,170 @@ +-- 298_deal_city_price_bands_region.sql +-- deal_city_price_bands: ключ (region_code, city) вместо (city) — #3051, sub-PR B. +-- +-- Dependencies: 178_deal_city_price_bands.sql (таблица, PK(city)), 194_deal_city_price_bands_tiers.sql +-- (tier-схема, derivation), 288_deals_doc_type.sql (deals.doc_type). +-- Apply after: 289_rosreestr_fdw_msk_columns_seed77.sql +-- +-- WHY: +-- В deals уже 212 937 строк region_code=77 city='Москва' рядом со 108 623 +-- строками region_code=66 (Свердловская обл.) — Москва в той же таблице +-- `deals`, что и область. Старая derivation (178/194) ключует бэнды ТОЛЬКО +-- по city и считает region_stats (пул для tier='region_fallback') ПО ВСЕЙ +-- таблице deals без фильтра региона. С двумя регионами в одной таблице это +-- ломает две вещи одновременно: +-- 1. city='Москва' коллизирует c одноимёнными city в других регионах +-- (маловероятно для этой пары, но ключ (city) в принципе не region-safe). +-- 2. region_stats-пул для 'region_fallback' смешивает 212k строк Москвы в +-- p1-перцентиль floor для тонких городов Свердловской обл. — Москва на +-- порядок дороже, пул сдвигается вверх, floor малых городов области +-- задирается выше их реального рынка. +-- +-- WHAT: +-- - deal_city_price_bands.region_code int NOT NULL DEFAULT 66 — бэкфилл +-- существующих 383 строк на 66 (таблица до этой миграции была исключительно +-- Свердловская обл., см. 178/194 WHERE-фильтры без region_code). +-- - PK (city) → PK (region_code, city) — через DO-guard по pg_get_constraintdef, +-- идемпотентно (см. ниже). +-- - Re-seed той же derivation, что 194/refresh-таск, НО region_stats и +-- city_stats группируются по (region_code[, city]) — Москва больше не +-- утекает в пул малых городов области, и наоборот. +-- +-- БАЙТ-ИДЕНТИЧНОСТЬ ДЛЯ region_code=66: +-- - Фильтр `NOT (region_code = 66 AND city = 'Екатеринбург')` — тот же +-- инвариант, что `city <> 'Екатеринбург'` в 178/194, ограниченный явно на +-- регион 66 (ЕКБ существует только там). +-- - `doc_type = 'ДКП'` — НЕ сужает выборку region_code=66: backfill 288 +-- проставил doc_type='ДКП' на 100% строк source='rosreestr' (на момент +-- миграции 288 других doc_type для source='rosreestr' не было и не могло +-- быть — импорт всегда фильтровал ДКП на входе). +-- - `region_stats` теперь GROUP BY region_code — для region_code=66 пул p1 +-- считается ИСКЛЮЧИТЕЛЬНО по строкам региона 66 (та же популяция, что была +-- у 194 ДО появления в deals региона 77), т.е. Москва (region_code=77) +-- физически не участвует в агрегате region_stats-строки region_code=66. +-- - `city_stats` теперь GROUP BY region_code, city — для региона 66 разбивка +-- по городам не меняется (раньше GROUP BY city неявно уже был per-region, +-- т.к. в deals была только область). +-- - Клампы (8000/800000/700000), tier-пороги (30/10) и p1/p99-перцентили — +-- формулы дословно как в 194/refresh, только с добавленным region_code в +-- SELECT/GROUP BY/JOIN. +-- - `tiered` JOIN region_stats теперь по region_code (вместо CROSS JOIN на +-- единственную строку) — для региона 66 эквивалентно прежнему CROSS JOIN, +-- т.к. region_stats содержит ровно одну строку на регион. +-- +-- ИДЕМПОТЕНТНОСТЬ: +-- ADD COLUMN IF NOT EXISTS; PK-guard проверяет ТЕКУЩЕЕ определение PK через +-- pg_get_constraintdef и меняет его только если оно ещё (city) — повторный +-- прогон видит PK(region_code, city) и не трогает его. INSERT ... ON CONFLICT +-- (region_code, city) DO UPDATE — повторный прогон рефрешит бэнды под свежие +-- сделки (та же семантика 178/194). + +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE deal_city_price_bands + ADD COLUMN IF NOT EXISTS region_code int NOT NULL DEFAULT 66; + +COMMENT ON COLUMN deal_city_price_bands.region_code IS + 'Регион ключа бэнда (deals.region_code). 66 = Свердловская обл. (Екатеринбург ' + 'намеренно исключён из таблицы для ЭТОГО региона — estimator fallback на ' + 'глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2). Часть составного PK (region_code, city).'; + +-- PK (city) → PK (region_code, city), идемпотентно: смотрим ТЕКУЩЕЕ определение +-- PK и меняем его только если это ещё старый PK(city). +DO $$ +DECLARE + v_pk_def text; +BEGIN + SELECT pg_get_constraintdef(oid) INTO v_pk_def + FROM pg_constraint + WHERE conrelid = 'deal_city_price_bands'::regclass + AND contype = 'p'; + + IF v_pk_def = 'PRIMARY KEY (city)' THEN + ALTER TABLE deal_city_price_bands DROP CONSTRAINT deal_city_price_bands_pkey; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'deal_city_price_bands'::regclass + AND contype = 'p' + ) THEN + ALTER TABLE deal_city_price_bands + ADD CONSTRAINT deal_city_price_bands_pkey PRIMARY KEY (region_code, city); + END IF; +END $$; + +-- Страховка: если region_code=66 город='Москва' когда-либо просочился (не +-- должен — таблица была исключительно Свердловской обл.), убираем перед re-seed, +-- чтобы ON CONFLICT ниже не унаследовал мусорную строку под чужим regionʼом. +DELETE FROM deal_city_price_bands WHERE region_code = 66 AND city = 'Москва'; + +-- Re-seed — та же derivation, что 194/refresh-таск, региональная (см. WHY/WHAT). +WITH region_stats AS ( + SELECT + region_code, + GREATEST( + round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int, + 8000 + ) AS region_ppm2_min + FROM deals + WHERE source = 'rosreestr' + AND doc_type = 'ДКП' + AND price_per_m2 IS NOT NULL + AND city IS NOT NULL + AND region_code IS NOT NULL + AND NOT (region_code = 66 AND city = 'Екатеринбург') + GROUP BY region_code +), +city_stats AS ( + SELECT + region_code, + city, + GREATEST(round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int, 8000) + AS ppm2_p1, + LEAST(round(percentile_cont(0.99) WITHIN GROUP (ORDER BY price_per_m2))::int, 800000) + AS ppm2_p99, + count(*) AS n_deals + FROM deals + WHERE source = 'rosreestr' + AND doc_type = 'ДКП' + AND price_per_m2 IS NOT NULL + AND city IS NOT NULL + AND region_code IS NOT NULL + AND NOT (region_code = 66 AND city = 'Екатеринбург') + GROUP BY region_code, city +), +tiered AS ( + SELECT region_code, city, ppm2_p1 AS ppm2_min, ppm2_p99 AS ppm2_max, n_deals, + 'full'::text AS tier + FROM city_stats + WHERE n_deals >= 30 + AND ppm2_p99 >= 8000 + + UNION ALL + + SELECT region_code, city, LEAST(ppm2_p1, 700000) AS ppm2_min, 800000 AS ppm2_max, n_deals, + 'rough'::text AS tier + FROM city_stats + WHERE n_deals BETWEEN 10 AND 29 + + UNION ALL + + SELECT c.region_code, c.city, r.region_ppm2_min AS ppm2_min, 800000 AS ppm2_max, c.n_deals, + 'region_fallback'::text AS tier + FROM city_stats c + JOIN region_stats r ON r.region_code = c.region_code + WHERE c.n_deals < 10 +) +INSERT INTO deal_city_price_bands (region_code, city, ppm2_min, ppm2_max, n_deals, tier, refreshed_at) +SELECT region_code, city, ppm2_min, ppm2_max, n_deals, tier, now() +FROM tiered +ON CONFLICT (region_code, city) DO UPDATE + SET ppm2_min = EXCLUDED.ppm2_min, + ppm2_max = EXCLUDED.ppm2_max, + n_deals = EXCLUDED.n_deals, + tier = EXCLUDED.tier, + refreshed_at = EXCLUDED.refreshed_at; + +COMMIT; diff --git a/tradein-mvp/backend/scripts/backtest_estimator.py b/tradein-mvp/backend/scripts/backtest_estimator.py index 41a5b555..1fada32b 100644 --- a/tradein-mvp/backend/scripts/backtest_estimator.py +++ b/tradein-mvp/backend/scripts/backtest_estimator.py @@ -1233,14 +1233,19 @@ _CANDIDATES_SQL = text( ) # (oblast D) Per-city PPM2 sanity band — same table the estimator's -# _fetch_dkp_corridor COALESCEs against (migration 178). Looked up only when -# --city is set (see _resolve_city_ppm2_band); the default (city=None) path -# never issues this query. +# _fetch_dkp_corridor COALESCEs against (migration 178, key (region_code, city) +# since migration 298 — #3051 "Москва"). Looked up only when --city is set (see +# _resolve_city_ppm2_band); the default (city=None) path never issues this +# query. region_code defaults to 66 (Свердловская обл.) — this script has no +# region CLI flag yet, so every call is scoped to the oblast, matching every +# existing --city invocation (byte-identical to the pre-298 unscoped lookup, +# which only ever saw region-66 rows since the table was oblast-only before). _CITY_PPM2_BAND_SQL = text( """ SELECT ppm2_min, ppm2_max FROM deal_city_price_bands - WHERE city = CAST(:city AS text) + WHERE region_code = CAST(:region_code AS int) + AND city = CAST(:city AS text) """ ) @@ -1294,22 +1299,32 @@ def _sample_sql(city: str | None, scattered: bool = False) -> Any: ) -def _resolve_city_ppm2_band(db: Session, city: str | None) -> tuple[float, float]: - """Per-city PPM2 sanity band (oblast D) — falls back to the module globals. +def _resolve_city_ppm2_band( + db: Session, city: str | None, region_code: int = 66 +) -> tuple[float, float]: + """Per-(region, city) PPM2 sanity band (oblast D, #3051) — falls back to module globals. ``city is None`` → ``(PPM2_MIN, PPM2_MAX)``, unchanged default behaviour, NO extra query. Otherwise looks up ``deal_city_price_bands`` for that - city's own ``[ppm2_min, ppm2_max]`` — a region-66 town can have a - materially different sane ₽/m² range than the EKB-tuned 30k..600k globals - (e.g. Нижний Тагил: 16 955..108 175). No row for the city (e.g. - Екатеринбург — intentionally excluded from the table, mirrors - estimator._fetch_dkp_corridor's own COALESCE fallback) or any DB error → - the globals; read-only best-effort, never raises. + (region_code, city)'s own ``[ppm2_min, ppm2_max]`` — a region-66 town can + have a materially different sane ₽/m² range than the EKB-tuned 30k..600k + globals (e.g. Нижний Тагил: 16 955..108 175). No row for the (region, city) + (e.g. Екатеринбург region_code=66 — intentionally excluded from the table, + mirrors estimator._fetch_dkp_corridor's own COALESCE fallback) or any DB + error → the globals; read-only best-effort, never raises. + + ``region_code`` defaults to 66 (Свердловская обл.) — this script has no + region CLI flag yet (out of scope, #3051 sub-PR B); every existing + --city caller keeps its byte-identical lookup. """ if city is None: return float(PPM2_MIN), float(PPM2_MAX) try: - row = db.execute(_CITY_PPM2_BAND_SQL, {"city": city}).mappings().first() + row = ( + db.execute(_CITY_PPM2_BAND_SQL, {"city": city, "region_code": region_code}) + .mappings() + .first() + ) except Exception as exc: # pragma: no cover — defensive, read-only best-effort logger.warning("city PPM2 band lookup failed for %r (fallback to global): %s", city, exc) return float(PPM2_MIN), float(PPM2_MAX) -- 2.45.3