From e2502a0134dbe3782270525bfb7ecf0ec44d1963 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 12 Jul 2026 19:07:05 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/estimator):=20per-city=20=D1=86?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=D0=B2=D0=BE=D0=B9=20band=20=D0=94=D0=9A?= =?UTF-8?q?=D0=9F-=D1=81=D0=B4=D0=B5=D0=BB=D0=BE=D0=BA=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=B2=D1=81=D0=B5=D0=B9=20=D0=BE=D0=B1=D0=BB.=2066?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Глобальный DEAL_MIN_PPM2=50k резал 46.6% не-ЕКБ сделок (легит дешёвые города: Североуральск p50=21.7k, Новоуральск 36k, Асбест 41k). Band стал per-city. - migration 178: deal_city_price_bands (city PK, ppm2_min/max, n_deals), populate из deals (source=rosreestr, city<>Екатеринбург, count>=30), min=GREATEST(p1,8000) max=LEAST(p99,800000), idempotent upsert; HAVING-guard p99>=8000 (airtight против inverted band). - estimator.py оба enforcement-пойнта city-aware с fallback на глобаль: - _fetch_dkp_corridor SQL: LEFT JOIN deal_city_price_bands + COALESCE(band, :global). - _is_plausible_deal(city, bands) + _fetch_deals: _load_city_price_bands (TTL-cache), SELECT city. - ЕКБ исключён из таблицы → fallback на 50k/800k → EKB-сделки byte-identical (deals спеллит EKB только 'Екатеринбург', 49791). EKB deal-level инвариант airtight; interior-EKB оценки идентичны. Регресс-гейт байт-зелёный. Follow-ups: periodic refresh band-таблицы; city-scoping _fetch_dkp_corridor (→ C2). --- tradein-mvp/backend/app/services/estimator.py | 91 ++++++++++++++++--- .../data/sql/178_deal_city_price_bands.sql | 87 ++++++++++++++++++ 2 files changed, 163 insertions(+), 15 deletions(-) create mode 100644 tradein-mvp/backend/data/sql/178_deal_city_price_bands.sql diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 4f41ee64..a81b18fa 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -138,6 +138,47 @@ DEAL_MIN_PPM2 = 50_000 # ниже = не arms-length (доля/обремене DEAL_MAX_PPM2 = 800_000 # выше премиума → опечатка/коммерция (verified p99.9=500k, 2026-06-21) DEAL_MAX_FLOOR = 60 # выше реального максимума ЕКБ → битый этаж (напр. floor:100) +# #2478: DEAL_MIN_PPM2/MAX_PPM2 выше калиброваны ИСКЛЮЧИТЕЛЬНО под ЕКБ-вторичку. +# Миграция 177 открыла +47183 не-ЕКБ сделок (region 66, deals.city заполнен) — +# глобальный пол 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 ЕКБ-поведение. +_CITY_PRICE_BANDS_CACHE_TTL_S = 300.0 +_city_price_bands_cache: tuple[dict[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). + + Кэш в процессе (TTL _CITY_PRICE_BANDS_CACHE_TTL_S) — таблица рефрешится + периодическим ре-запуском миграции, не на каждый estimate. Таблицы нет / + любая ошибка → {} (graceful) — вызывающий код fallback'ит на глобальные + DEAL_MIN_PPM2/DEAL_MAX_PPM2, т.е. поведение как до #2478. + """ + global _city_price_bands_cache + if _city_price_bands_cache is not None: + bands, fetched = _city_price_bands_cache + if (time.monotonic() - fetched) < _CITY_PRICE_BANDS_CACHE_TTL_S: + return bands + try: + rows = ( + db.execute(text("SELECT 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} + _city_price_bands_cache = (bands, time.monotonic()) + return bands + + # Когорта по году постройки — типизация массовой застройки РФ. # Используется как hard-filter в Tier 0 _fetch_analogs (PR 9, 2026-05-24). # Если target_year не задан — cohort = None → фильтр отключён, Tier 0 пропускается. @@ -1249,18 +1290,24 @@ def _fetch_dkp_corridor( db.execute( text( """ - SELECT price_per_m2, deal_date - FROM deals - WHERE source = 'rosreestr' - AND address ILIKE :street_pattern - AND address ~* :street_regex - AND rooms = CAST(:rooms AS integer) - AND area_m2 BETWEEN :area_min AND :area_max - AND deal_date > NOW() + SELECT d.price_per_m2, d.deal_date + FROM deals d + LEFT JOIN deal_city_price_bands b ON b.city = d.city + WHERE d.source = 'rosreestr' + AND d.address ILIKE :street_pattern + AND d.address ~* :street_regex + AND d.rooms = CAST(:rooms AS integer) + AND d.area_m2 BETWEEN :area_min AND :area_max + AND d.deal_date > NOW() - (CAST(:period_months AS integer) || ' months')::interval - AND price_per_m2 > 0 - -- #699: режем нерыночные ppm²-выбросы из коридора expected_sold - AND price_per_m2 BETWEEN :ppm_min AND :ppm_max + AND d.price_per_m2 > 0 + -- #699 + #2478: режем нерыночные ppm²-выбросы из коридора + -- expected_sold. Per-city band (deal_city_price_bands, миграция + -- 178) когда для города сделки есть строка (не-ЕКБ область); + -- иначе (в т.ч. Екатеринбург, НАМЕРЕННО не в таблице) 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)) """ ), { @@ -4662,18 +4709,27 @@ def _is_plausible_deal( total_floors: int | None, area_m2: float | None = None, price_rub: float | None = None, + city: str | None = None, + bands: dict[str, tuple[int, int]] | None = None, ) -> bool: - """#699 + Mera-audit fix-2: True если ДКП-сделка правдоподобна (не выброс). + """#699 + Mera-audit fix-2 + #2478: True если ДКП-сделка правдоподобна (не выброс). Абсолютные guard-bands (см. DEAL_* константы). None-поля не судим (keep — нечем сравнивать). Проверки: - - price_per_m2 вне [DEAL_MIN_PPM2, DEAL_MAX_PPM2] → drop + - 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 + сегодняшнему поведению). - floor < 1 или floor > DEAL_MAX_FLOOR → drop (битый парсер: floor=-5/999) - floor > total_floors физически невозможен → drop - area_m2 задана и <= 0 → drop (битый парсер) - price_rub задана и <= 0 → drop (нерыночная/технческая сделка) """ - if price_per_m2 is not None and not (DEAL_MIN_PPM2 <= price_per_m2 <= DEAL_MAX_PPM2): + ppm_min, ppm_max = (bands or {}).get(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: if floor < 1 or floor > DEAL_MAX_FLOOR: @@ -4697,7 +4753,7 @@ def _fetch_deals( SELECT source, address, lat, lon, rooms, area_m2, floor, total_floors, - price_rub, price_per_m2, + price_rub, price_per_m2, city, deal_date, days_on_market, cadastral_number, ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography) AS distance_m @@ -4727,6 +4783,9 @@ 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 {} при ошибке. + bands = _load_city_price_bands(db) deals = [dict(r) for r in rows] clean = [ d @@ -4737,6 +4796,8 @@ def _fetch_deals( d.get("total_floors"), d.get("area_m2"), d.get("price_rub"), + city=d.get("city"), + bands=bands, ) ] if len(clean) < len(deals): diff --git a/tradein-mvp/backend/data/sql/178_deal_city_price_bands.sql b/tradein-mvp/backend/data/sql/178_deal_city_price_bands.sql new file mode 100644 index 00000000..bcdc5130 --- /dev/null +++ b/tradein-mvp/backend/data/sql/178_deal_city_price_bands.sql @@ -0,0 +1,87 @@ +-- 178_deal_city_price_bands.sql +-- Per-city ppm² plausible-deal guard-bands — снятие Екатеринбург-центричного +-- глобального DEAL_MIN_PPM2=50_000 (estimator.py) для остальной Свердловской обл. +-- +-- WHY: +-- Миграция 177 открыла +47183 не-ЕКБ сделок (deals.city заполнен для всей +-- области). Но estimator.py всё ещё режет ДКП-сделки абсолютным guard-band +-- [DEAL_MIN_PPM2=50_000, DEAL_MAX_PPM2=800_000] ₽/м² — калиброванным +-- ИСКЛЮЧИТЕЛЬНО под ЕКБ-вторичку (см. комментарий у DEAL_MIN_PPM2 в +-- estimator.py). Для дешёвых городов области этот пол — не anti-outlier +-- guard, а cut-off легитимного рынка: +-- Североуральск median ≈ 21.7k ₽/м², Новоуральск ≈ 36k, Асбест ≈ 41k — +-- ВСЕ ниже 50k → 46.6% не-ЕКБ сделок молча дропаются как «доля/ошибка», +-- хотя это нормальная цена для города. +-- +-- WHAT: +-- Таблица deal_city_price_bands — per-city [ppm2_min, ppm2_max] band, +-- вычисленный из РЕАЛЬНЫХ rosreestr-сделок города (p1/p99 персентиль, +-- зажатый абсолютными hard-floor/ceiling 8000/800000 — доли и опечатки +-- режутся даже в самых дешёвых городах). Екатеринбург НАМЕРЕННО исключён +-- из популяции — estimator.py при отсутствии строки в этой таблице +-- fallback'ит на глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2 константы, т.е. +-- ЕКБ-поведение byte-identical сегодняшнему (см. invariant в PR). +-- +-- Города с < 30 сделок исключены (HAVING count(*) >= 30) — слишком тонкая +-- выборка для устойчивого перцентиля, estimator fallback'ит на global band. +-- +-- USAGE (estimator.py, тот же PR): +-- - SQL enforcement (_fetch_dkp_corridor, ~line 1263): LEFT JOIN на +-- deals.city = deal_city_price_bands.city, COALESCE(b.ppm2_min/max, global). +-- - Python enforcement (_is_plausible_deal via _fetch_deals): bands dict, +-- загружен один раз через _load_city_price_bands(db), .get(city, global). +-- +-- IDEMPOTENCY / SAFETY: +-- CREATE TABLE IF NOT EXISTS — безопасный re-run. Populate — INSERT ... +-- ON CONFLICT (city) DO UPDATE, т.е. повторный прогон РЕФРЕШИТ бэнды под +-- свежие сделки (не no-op по дизайну — таблица предполагает периодический +-- refresh по мере роста deals; scheduler для авто-рефреша НЕ входит в этот +-- PR, см. follow-up в PR description). +-- +-- Dependencies: 177_deals_city_region.sql (deals.city, backfill). + +BEGIN; + +CREATE TABLE IF NOT EXISTS deal_city_price_bands ( + city text PRIMARY KEY, + ppm2_min int NOT NULL, + ppm2_max int NOT NULL, + n_deals int NOT NULL, + refreshed_at timestamptz NOT NULL DEFAULT now() +); + +COMMENT ON TABLE deal_city_price_bands IS + 'Per-city ppm² plausible-deal guard-bands (estimator.py _is_plausible_deal / ' + '_fetch_dkp_corridor). Екатеринбург НЕ включён — estimator fallback на глобальные ' + 'DEAL_MIN_PPM2=50000/DEAL_MAX_PPM2=800000 сохраняет ЕКБ-поведение byte-identical. ' + 'Refresh: повторный прогон этого файла (ON CONFLICT DO UPDATE); авто-scheduler ' + 'пока не реализован (см. issue follow-up).'; + +INSERT INTO deal_city_price_bands (city, ppm2_min, ppm2_max, n_deals, refreshed_at) +SELECT + city, + GREATEST(round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int, 8000) + AS ppm2_min, + LEAST(round(percentile_cont(0.99) WITHIN GROUP (ORDER BY price_per_m2))::int, 800000) + AS ppm2_max, + count(*) AS n_deals, + now() AS refreshed_at +FROM deals +WHERE source = 'rosreestr' + AND price_per_m2 IS NOT NULL + AND city IS NOT NULL + AND city <> 'Екатеринбург' +GROUP BY city +HAVING count(*) >= 30 + -- Инвариант ppm2_max >= ppm2_min: если весь распределение города < 8000 ₽/м² + -- (p99 < 8000), LEAST(p99,800000) < GREATEST(p1,8000) → band [8000, <8000] не + -- матчит ничего → все сделки города молча дропались бы. Гейтим такие города + -- (для жилой ДКП недостижимо, но airtight против мусорного города). + AND round(percentile_cont(0.99) WITHIN GROUP (ORDER BY price_per_m2))::int >= 8000 +ON CONFLICT (city) DO UPDATE + SET ppm2_min = EXCLUDED.ppm2_min, + ppm2_max = EXCLUDED.ppm2_max, + n_deals = EXCLUDED.n_deals, + refreshed_at = EXCLUDED.refreshed_at; + +COMMIT; -- 2.45.3