gendesign/tradein-mvp/backend/data/sql/178_deal_city_price_bands.sql
bot-backend e2502a0134
All checks were successful
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 50s
fix(tradein/estimator): per-city ценовой band ДКП-сделок для всей обл. 66
Глобальный 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).
2026-07-12 19:07:05 +03:00

87 lines
5.1 KiB
PL/PgSQL
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.

-- 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;