fix(tradein): city-scope ДКП-запросы — убрать oblast-контаминацию (C1+C2) #2489
4 changed files with 54 additions and 4 deletions
|
|
@ -1673,6 +1673,11 @@ def get_street_deals(
|
||||||
WHERE source = 'rosreestr'
|
WHERE source = 'rosreestr'
|
||||||
AND address ILIKE :street_pattern
|
AND address ILIKE :street_pattern
|
||||||
AND address ~* :street_regex
|
AND address ~* :street_regex
|
||||||
|
-- #C1 city-scope (см. estimator._fetch_dkp_corridor): сделка в городе
|
||||||
|
-- целевого адреса — одноимённые улицы др. городов обл.66 (миграция 177)
|
||||||
|
-- не контаминируют витрину. Матчим канонический city как подстроку адреса.
|
||||||
|
AND city IS NOT NULL
|
||||||
|
AND :address ILIKE '%' || city || '%'
|
||||||
AND rooms = CAST(:rooms AS integer)
|
AND rooms = CAST(:rooms AS integer)
|
||||||
AND area_m2 BETWEEN :area_min AND :area_max
|
AND area_m2 BETWEEN :area_min AND :area_max
|
||||||
AND deal_date > NOW() - (CAST(:period_months AS integer) || ' months')::interval
|
AND deal_date > NOW() - (CAST(:period_months AS integer) || ' months')::interval
|
||||||
|
|
@ -1683,6 +1688,7 @@ def get_street_deals(
|
||||||
{
|
{
|
||||||
"street_pattern": "%" + street_name + "%",
|
"street_pattern": "%" + street_name + "%",
|
||||||
"street_regex": r"\m" + street_name + r"\M",
|
"street_regex": r"\m" + street_name + r"\M",
|
||||||
|
"address": address,
|
||||||
"rooms": rooms,
|
"rooms": rooms,
|
||||||
"area_min": area_min,
|
"area_min": area_min,
|
||||||
"area_max": area_max,
|
"area_max": area_max,
|
||||||
|
|
|
||||||
|
|
@ -1300,6 +1300,14 @@ def _fetch_dkp_corridor(
|
||||||
WHERE d.source = 'rosreestr'
|
WHERE d.source = 'rosreestr'
|
||||||
AND d.address ILIKE :street_pattern
|
AND d.address ILIKE :street_pattern
|
||||||
AND d.address ~* :street_regex
|
AND d.address ~* :street_regex
|
||||||
|
-- #C1 city-scope: сделка должна быть в ГОРОДЕ целевого адреса.
|
||||||
|
-- Миграция 177 залила ДКП по всей обл.66 (deals.city, 368 городов) —
|
||||||
|
-- без этого одноимённые улицы др. городов (Ленина/Мира/…)
|
||||||
|
-- контаминируют коридор (median занижался ~−46% для ЕКБ). Матчим
|
||||||
|
-- канонический deals.city как подстроку целевого адреса (адрес
|
||||||
|
-- всегда содержит город: «…г. Екатеринбург…»). self-normalizing.
|
||||||
|
AND d.city IS NOT NULL
|
||||||
|
AND :address ILIKE '%' || d.city || '%'
|
||||||
AND d.rooms = CAST(:rooms AS integer)
|
AND d.rooms = CAST(:rooms AS integer)
|
||||||
AND d.area_m2 BETWEEN :area_min AND :area_max
|
AND d.area_m2 BETWEEN :area_min AND :area_max
|
||||||
AND d.deal_date > NOW()
|
AND d.deal_date > NOW()
|
||||||
|
|
@ -1317,6 +1325,7 @@ def _fetch_dkp_corridor(
|
||||||
{
|
{
|
||||||
"street_pattern": "%" + street_name + "%",
|
"street_pattern": "%" + street_name + "%",
|
||||||
"street_regex": r"\m" + street_name + r"\M",
|
"street_regex": r"\m" + street_name + r"\M",
|
||||||
|
"address": address,
|
||||||
"rooms": rooms,
|
"rooms": rooms,
|
||||||
"area_min": area_min,
|
"area_min": area_min,
|
||||||
"area_max": area_max,
|
"area_max": area_max,
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,14 @@ from app.services import scrape_runs as runs_mod
|
||||||
|
|
||||||
# Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется.
|
# Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется.
|
||||||
_PPM2_MIN: int = 30_000
|
_PPM2_MIN: int = 30_000
|
||||||
|
|
||||||
|
# #C2 — asking-сторона (listings) покрыта скрейпом ТОЛЬКО по ЕКБ (per-city scrape B1/B2
|
||||||
|
# ещё нет; в listings даже нет колонки city). Миграция 177 залила ДКП-сделки по всей
|
||||||
|
# обл.66 (368 городов) → sold-медиана смешивала дешёвую область с ЕКБ-asking и обваливала
|
||||||
|
# ratio (0.877→0.62, «выкупная» −29% системно). Скоупим SOLD-сторону (deal_side/deal_global)
|
||||||
|
# на ЕКБ, чтобы sold и asking считались по ОДНОМУ рынку. Когда появятся oblast-листинги —
|
||||||
|
# заменить на per-city ratio через зарезервированный столбец `district` (#647).
|
||||||
|
_ASKING_CITY_PATTERN: str = "%Екатеринбург%"
|
||||||
# Верхняя граница берётся из settings.asking_ratio_ppm2_max (default 1_200_000).
|
# Верхняя граница берётся из settings.asking_ratio_ppm2_max (default 1_200_000).
|
||||||
# QA-note: точное значение сверить с `SELECT max(price_per_m2) FROM deals
|
# QA-note: точное значение сверить с `SELECT max(price_per_m2) FROM deals
|
||||||
# WHERE source='rosreestr'` на проде — ceiling должен быть > max(ppm²) premium-сделок.
|
# WHERE source='rosreestr'` на проде — ceiling должен быть > max(ppm²) premium-сделок.
|
||||||
|
|
@ -80,6 +88,7 @@ _REDERIVE_SQL = text(
|
||||||
FROM deals
|
FROM deals
|
||||||
WHERE source = 'rosreestr'
|
WHERE source = 'rosreestr'
|
||||||
AND rooms IS NOT NULL
|
AND rooms IS NOT NULL
|
||||||
|
AND city ILIKE :asking_city -- #C2 SOLD-сторона на ЕКБ (match asking-рынок)
|
||||||
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
||||||
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
|
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
|
||||||
GROUP BY LEAST(GREATEST(rooms, 0), 4)
|
GROUP BY LEAST(GREATEST(rooms, 0), 4)
|
||||||
|
|
@ -128,6 +137,7 @@ _REDERIVE_SQL = text(
|
||||||
FROM deals
|
FROM deals
|
||||||
WHERE source = 'rosreestr'
|
WHERE source = 'rosreestr'
|
||||||
AND rooms IS NOT NULL
|
AND rooms IS NOT NULL
|
||||||
|
AND city ILIKE :asking_city -- #C2 SOLD-сторона на ЕКБ (match asking-рынок)
|
||||||
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
||||||
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
|
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
|
||||||
),
|
),
|
||||||
|
|
@ -213,7 +223,11 @@ def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]:
|
||||||
db.execute(_DELETE_SQL)
|
db.execute(_DELETE_SQL)
|
||||||
db.execute(
|
db.execute(
|
||||||
_REDERIVE_SQL,
|
_REDERIVE_SQL,
|
||||||
{"ppm2_min": _PPM2_MIN, "ppm2_max": settings.asking_ratio_ppm2_max},
|
{
|
||||||
|
"ppm2_min": _PPM2_MIN,
|
||||||
|
"ppm2_max": settings.asking_ratio_ppm2_max,
|
||||||
|
"asking_city": _ASKING_CITY_PATTERN,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
row = db.execute(_COUNTERS_SQL).mappings().first()
|
row = db.execute(_COUNTERS_SQL).mappings().first()
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,22 @@ def test_rederivation_cte_blocks_match_080() -> None:
|
||||||
assert f"{cte} AS" in _REDERIVE_SQL, f"missing CTE {cte!r}"
|
assert f"{cte} AS" in _REDERIVE_SQL, f"missing CTE {cte!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rederivation_scopes_sold_side_to_asking_city() -> None:
|
||||||
|
"""#C2: SOLD-сторона (deal_side + deal_global) скоупится на город asking-стороны (ЕКБ).
|
||||||
|
|
||||||
|
Миграция 177 залила ДКП по всей обл.66, а asking (listings) — только ЕКБ. Без скоупа
|
||||||
|
sold-медиана смешивала дешёвую область → ratio 0.877→0.62, «выкупная» −29%. Оба
|
||||||
|
deal-CTE (per-rooms + global) должны нести предикат; ask-стороны — НЕ трогаем.
|
||||||
|
"""
|
||||||
|
assert ratio_mod._ASKING_CITY_PATTERN == "%Екатеринбург%"
|
||||||
|
# Оба deal-CTE (deal_side + deal_global) скоупятся — ровно 2 вхождения.
|
||||||
|
assert _REDERIVE_SQL.count("AND city ILIKE :asking_city") == 2
|
||||||
|
# ask-стороны (listings) НЕ фильтруются по городу (в listings нет колонки city).
|
||||||
|
_a = _REDERIVE_SQL.index("ask_side AS")
|
||||||
|
_b = _REDERIVE_SQL.index("per_bucket AS")
|
||||||
|
assert "asking_city" not in _REDERIVE_SQL[_a:_b]
|
||||||
|
|
||||||
|
|
||||||
def _strip_sql(s: str) -> str:
|
def _strip_sql(s: str) -> str:
|
||||||
"""Drop -- line comments and collapse whitespace — leaves only the executable SQL.
|
"""Drop -- line comments and collapse whitespace — leaves only the executable SQL.
|
||||||
|
|
||||||
|
|
@ -159,9 +175,14 @@ def test_migration_080_derivation_is_subset_of_refresh_sql() -> None:
|
||||||
s,
|
s,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert _strip_sql(_normalise_ppm2(_drop_segment_guard(seed_derivation))) in _strip_sql(
|
def _drop_city_guard(s: str) -> str:
|
||||||
_normalise_ppm2(_drop_segment_guard(_REDERIVE_SQL))
|
"""Remove the #C2 EKB city-scope predicate on the SOLD side (absent in the 080 seed)."""
|
||||||
)
|
return re.sub(r"AND\s+city\s+ILIKE\s+:asking_city", "", s)
|
||||||
|
|
||||||
|
def _norm(s: str) -> str:
|
||||||
|
return _strip_sql(_normalise_ppm2(_drop_city_guard(_drop_segment_guard(s))))
|
||||||
|
|
||||||
|
assert _norm(seed_derivation) in _norm(_REDERIVE_SQL)
|
||||||
|
|
||||||
|
|
||||||
# ── Counters query ────────────────────────────────────────────────────────────
|
# ── Counters query ────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue