fix(tradein/estimator): per-city ценовой band ДКП-сделок для всей Свердловской обл. (66) #2485
2 changed files with 163 additions and 15 deletions
|
|
@ -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_PPM2 = 800_000 # выше премиума → опечатка/коммерция (verified p99.9=500k, 2026-06-21)
|
||||||
DEAL_MAX_FLOOR = 60 # выше реального максимума ЕКБ → битый этаж (напр. floor:100)
|
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).
|
# Используется как hard-filter в Tier 0 _fetch_analogs (PR 9, 2026-05-24).
|
||||||
# Если target_year не задан — cohort = None → фильтр отключён, Tier 0 пропускается.
|
# Если target_year не задан — cohort = None → фильтр отключён, Tier 0 пропускается.
|
||||||
|
|
@ -1249,18 +1290,24 @@ def _fetch_dkp_corridor(
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
SELECT price_per_m2, deal_date
|
SELECT d.price_per_m2, d.deal_date
|
||||||
FROM deals
|
FROM deals d
|
||||||
WHERE source = 'rosreestr'
|
LEFT JOIN deal_city_price_bands b ON b.city = d.city
|
||||||
AND address ILIKE :street_pattern
|
WHERE d.source = 'rosreestr'
|
||||||
AND address ~* :street_regex
|
AND d.address ILIKE :street_pattern
|
||||||
AND rooms = CAST(:rooms AS integer)
|
AND d.address ~* :street_regex
|
||||||
AND area_m2 BETWEEN :area_min AND :area_max
|
AND d.rooms = CAST(:rooms AS integer)
|
||||||
AND deal_date > NOW()
|
AND d.area_m2 BETWEEN :area_min AND :area_max
|
||||||
|
AND d.deal_date > NOW()
|
||||||
- (CAST(:period_months AS integer) || ' months')::interval
|
- (CAST(:period_months AS integer) || ' months')::interval
|
||||||
AND price_per_m2 > 0
|
AND d.price_per_m2 > 0
|
||||||
-- #699: режем нерыночные ppm²-выбросы из коридора expected_sold
|
-- #699 + #2478: режем нерыночные ppm²-выбросы из коридора
|
||||||
AND price_per_m2 BETWEEN :ppm_min AND :ppm_max
|
-- 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,
|
total_floors: int | None,
|
||||||
area_m2: float | None = None,
|
area_m2: float | None = None,
|
||||||
price_rub: float | None = None,
|
price_rub: float | None = None,
|
||||||
|
city: str | None = None,
|
||||||
|
bands: dict[str, tuple[int, int]] | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""#699 + Mera-audit fix-2: True если ДКП-сделка правдоподобна (не выброс).
|
"""#699 + Mera-audit fix-2 + #2478: True если ДКП-сделка правдоподобна (не выброс).
|
||||||
|
|
||||||
Абсолютные guard-bands (см. DEAL_* константы). None-поля не судим (keep —
|
Абсолютные 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 < 1 или floor > DEAL_MAX_FLOOR → drop (битый парсер: floor=-5/999)
|
||||||
- floor > total_floors физически невозможен → drop
|
- floor > total_floors физически невозможен → drop
|
||||||
- area_m2 задана и <= 0 → drop (битый парсер)
|
- area_m2 задана и <= 0 → drop (битый парсер)
|
||||||
- price_rub задана и <= 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
|
return False
|
||||||
if floor is not None:
|
if floor is not None:
|
||||||
if floor < 1 or floor > DEAL_MAX_FLOOR:
|
if floor < 1 or floor > DEAL_MAX_FLOOR:
|
||||||
|
|
@ -4697,7 +4753,7 @@ def _fetch_deals(
|
||||||
SELECT
|
SELECT
|
||||||
source, address, lat, lon,
|
source, address, lat, lon,
|
||||||
rooms, area_m2, floor, total_floors,
|
rooms, area_m2, floor, total_floors,
|
||||||
price_rub, price_per_m2,
|
price_rub, price_per_m2, city,
|
||||||
deal_date, days_on_market,
|
deal_date, days_on_market,
|
||||||
cadastral_number,
|
cadastral_number,
|
||||||
ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography) AS distance_m
|
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²
|
# #699 + Mera-audit fix-2: отсекаем ДКП-выбросы (битый этаж / нерыночный ppm²
|
||||||
# / нулевая площадь / нулевая цена) до выдачи в actual_deals и expected_sold.
|
# / нулевая площадь / нулевая цена) до выдачи в 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]
|
deals = [dict(r) for r in rows]
|
||||||
clean = [
|
clean = [
|
||||||
d
|
d
|
||||||
|
|
@ -4737,6 +4796,8 @@ def _fetch_deals(
|
||||||
d.get("total_floors"),
|
d.get("total_floors"),
|
||||||
d.get("area_m2"),
|
d.get("area_m2"),
|
||||||
d.get("price_rub"),
|
d.get("price_rub"),
|
||||||
|
city=d.get("city"),
|
||||||
|
bands=bands,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
if len(clean) < len(deals):
|
if len(clean) < len(deals):
|
||||||
|
|
|
||||||
87
tradein-mvp/backend/data/sql/178_deal_city_price_bands.sql
Normal file
87
tradein-mvp/backend/data/sql/178_deal_city_price_bands.sql
Normal file
|
|
@ -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;
|
||||||
Loading…
Add table
Reference in a new issue