feat(tradein/estimator): deal_city_price_bands по ключу (region_code, city) — миграция 298, refresh per-region (#3051 sub-PR B) #3431
4 changed files with 305 additions and 73 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue