fix(tradein/pricing): скоупить сторону объявлений по городу, как сторону сделок (#2583 H2) (#2617)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m36s
Deploy Trade-In / build-backend (push) Successful in 1m0s
Deploy Trade-In / deploy (push) Successful in 1m23s
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m36s
Deploy Trade-In / build-backend (push) Successful in 1m0s
Deploy Trade-In / deploy (push) Successful in 1m23s
ask_side/ask_global получают предикат (city IS NULL OR city ILIKE :asking_city), симметрично deal-стороне. Областные объявления (развёртки с 12 июля) занижали ask-медиану и завышали коэффициент выкупа на 2.5-4.9%. NULL-толерантность намеренная: listings.city пока заполнена не у всех источников. Refs #2583
This commit is contained in:
parent
42adfe0900
commit
e484b5cce9
2 changed files with 88 additions and 15 deletions
|
|
@ -41,12 +41,20 @@ from app.services import scrape_runs as runs_mod
|
|||
# Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется.
|
||||
_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).
|
||||
# #C2 — исторически asking-сторона (listings) была покрыта скрейпом ТОЛЬКО по ЕКБ, а
|
||||
# миграция 177 залила ДКП-сделки по всей обл.66 (368 городов) → sold-медиана смешивала
|
||||
# дешёвую область с ЕКБ-asking и обваливала ratio (0.877→0.62, «выкупная» −29% системно).
|
||||
# Скоупили SOLD-сторону (deal_side/deal_global) на ЕКБ, чтобы sold и asking считались по
|
||||
# ОДНОМУ рынку.
|
||||
#
|
||||
# #2583 H2 (аудит, 2026-08): oblast-развёртки заработали 12 июля — областные объявления
|
||||
# попали в знаменатель (ask_side/ask_global) без городского скоупа, а sold-сторона
|
||||
# осталась скоуплена на ЕКБ → асимметрия вернулась с другой стороны (дешёвая область
|
||||
# занижает ask-медиану → ratio завышен на 2.5-5.3% по всем бакетам, выкупные цены
|
||||
# системно переплачены). Теперь ask_side/ask_global ТОЖЕ скоупятся этим паттерном
|
||||
# (предикат `city IS NULL OR city ILIKE :asking_city` — см. комментарий на месте в CTE
|
||||
# ниже) — симметрично deal-стороне. Когда появится per-city ratio через зарезервированный
|
||||
# столбец `district` (#647), эта константа станет per-city параметром для обеих сторон.
|
||||
_ASKING_CITY_PATTERN: str = "%Екатеринбург%"
|
||||
# Верхняя граница берётся из settings.asking_ratio_ppm2_max (default 1_200_000).
|
||||
# QA-note: точное значение сверить с `SELECT max(price_per_m2) FROM deals
|
||||
|
|
@ -71,7 +79,8 @@ _DELETE_SQL = text(
|
|||
# ppm² ∈ [_PPM2_MIN, settings.asking_ratio_ppm2_max], deal_date >= CURRENT_DATE − 12 months),
|
||||
# бакет LEAST(GREATEST(rooms,0),4).
|
||||
# ask_median = percentile_cont(0.5) по listings.price_per_m2
|
||||
# (is_active, та же ppm²-полоса [_PPM2_MIN, asking_ratio_ppm2_max]).
|
||||
# (is_active, та же ppm²-полоса [_PPM2_MIN, asking_ratio_ppm2_max], тот же город что
|
||||
# SOLD-сторона — city IS NULL OR city ILIKE :asking_city, #2583 H2).
|
||||
# per_rooms строки — только при n_deals>=30 AND n_listings>=30 AND ask>0 AND sold>0.
|
||||
# global -1 строка (basis='global_fallback') — всегда (если ask>0 AND sold>0). window_months=12.
|
||||
# Порог/окно — литералы; ppm²-полоса передаётся bind-параметрами :ppm2_min/:ppm2_max
|
||||
|
|
@ -105,6 +114,15 @@ _REDERIVE_SQL = text(
|
|||
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
||||
-- novostroyki guard (#1186): NULL = legacy вторичка до м.011
|
||||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||||
-- #2583 H2: скоупим ASKING-сторону на тот же город, что и SOLD-сторона
|
||||
-- (симметрично deal_side выше) — иначе дешёвые oblast-объявления (развёртки
|
||||
-- с 12 июля) занижают ask-медиану и завышают ratio. city IS NULL считается
|
||||
-- "своим" (не отбрасывается) НАМЕРЕННО: listings.city заполнена пока только у
|
||||
-- Авито (Циан/Домклик/Яндекс — NULL, #2598/#2606), симметричный
|
||||
-- `city ILIKE :asking_city` без IS NULL выбросил бы ~70% выборки. По мере
|
||||
-- роста покрытия колонки этот предикат сам ужесточается без правок кода; когда
|
||||
-- покрытие станет полным — заменить на строго симметричный `city ILIKE :asking_city`.
|
||||
AND (city IS NULL OR city ILIKE :asking_city)
|
||||
GROUP BY LEAST(GREATEST(rooms, 0), 4)
|
||||
),
|
||||
-- Per-rooms строки: только бакеты с обеими сторонами, прошедшие порог 30/30 и ask>0.
|
||||
|
|
@ -152,6 +170,9 @@ _REDERIVE_SQL = text(
|
|||
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
||||
-- novostroyki guard (#1186): NULL = legacy вторичка до м.011
|
||||
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
|
||||
-- #2583 H2: тот же городской скоуп, что и ask_side выше (см. комментарий там
|
||||
-- про причину city IS NULL == "свой" и #2598/#2606).
|
||||
AND (city IS NULL OR city ILIKE :asking_city)
|
||||
),
|
||||
-- Global fallback строка rooms_bucket=-1 (пишется всегда, если ask>0).
|
||||
global_row AS (
|
||||
|
|
|
|||
|
|
@ -116,17 +116,59 @@ def test_rederivation_cte_blocks_match_080() -> None:
|
|||
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-стороны — НЕ трогаем.
|
||||
Миграция 177 залила ДКП по всей обл.66, а asking (listings) исторически — только ЕКБ.
|
||||
Без скоупа sold-медиана смешивала дешёвую область → ratio 0.877→0.62, «выкупная» −29%.
|
||||
Оба deal-CTE (per-rooms + global) несут предикат unconditionally (deals.city не имеет
|
||||
массовых NULL как listings.city — #2598 их не касается).
|
||||
"""
|
||||
assert ratio_mod._ASKING_CITY_PATTERN == "%Екатеринбург%"
|
||||
# Оба deal-CTE (deal_side + deal_global) скоупятся — ровно 2 вхождения.
|
||||
# Оба deal-CTE (deal_side + deal_global) скоупятся unconditional-предикатом —
|
||||
# ровно 2 вхождения формы БЕЗ city IS NULL (ask-сторона использует другую форму,
|
||||
# см. test_ask_side_and_ask_global_scoped_to_asking_city).
|
||||
assert _REDERIVE_SQL.count("AND city ILIKE :asking_city") == 2
|
||||
# ask-стороны (listings) НЕ фильтруются по городу (в listings нет колонки city).
|
||||
|
||||
|
||||
def test_ask_side_and_ask_global_scoped_to_asking_city() -> None:
|
||||
"""#2583 H2: ask-сторона (ask_side + ask_global) ТЕПЕРЬ ТОЖЕ скоупится на asking_city.
|
||||
|
||||
Oblast-развёртки заработали 12 июля — областные объявления (дешевле ЕКБ) попали в
|
||||
знаменатель ask_median БЕЗ городского скоупа, а sold-сторона осталась скоуплена на
|
||||
ЕКБ (см. предыдущий тест) → асимметрия занижала ask_median и завышала ratio на
|
||||
2.5-5.3% по бакетам комнат 1-4 (замер на проде, аудит #2583 H2). Falsifiable: этот
|
||||
assert FALSE на непропатченном коде (ask_side/ask_global без city-предиката вообще)
|
||||
и TRUE после того как предикат `(city IS NULL OR city ILIKE :asking_city)` добавлен —
|
||||
проверено `git stash` на строках реализации.
|
||||
"""
|
||||
_a = _REDERIVE_SQL.index("ask_side AS")
|
||||
_b = _REDERIVE_SQL.index("per_bucket AS")
|
||||
assert "asking_city" not in _REDERIVE_SQL[_a:_b]
|
||||
ask_side_block = _REDERIVE_SQL[_a:_b]
|
||||
assert "AND (city IS NULL OR city ILIKE :asking_city)" in ask_side_block
|
||||
|
||||
_c = _REDERIVE_SQL.index("ask_global AS")
|
||||
_d = _REDERIVE_SQL.index("global_row AS")
|
||||
ask_global_block = _REDERIVE_SQL[_c:_d]
|
||||
assert "AND (city IS NULL OR city ILIKE :asking_city)" in ask_global_block
|
||||
|
||||
|
||||
def test_ask_side_keeps_city_is_null_rows_not_naive_filter() -> None:
|
||||
"""Guard against the naive (wrong) fix — a plain symmetric `city ILIKE :asking_city`.
|
||||
|
||||
listings.city заполнена пока только у Авито (#2598/#2606) — Циан/Домклик/Яндекс
|
||||
строки несут city IS NULL. На проде (2026-08, аудит #2583 H2) это ~8200 из ~11500
|
||||
строк, проходящих остальные WHERE-предикаты (~71%). Наивный симметричный
|
||||
`city ILIKE :asking_city` (как у deal_side) молча выбросил бы все city IS NULL
|
||||
строки, схлопнув ask_median c ~11500 до ~2100 ЕКБ-only объявлений — именно та
|
||||
over-correction, от которой предостерегает #2583 H2.
|
||||
"""
|
||||
cte_pairs = (("ask_side AS", "per_bucket AS"), ("ask_global AS", "global_row AS"))
|
||||
for cte_name, next_cte in cte_pairs:
|
||||
start = _REDERIVE_SQL.index(cte_name)
|
||||
end = _REDERIVE_SQL.index(next_cte)
|
||||
block = _REDERIVE_SQL[start:end]
|
||||
assert "city IS NULL" in block, f"{cte_name}: missing IS NULL tolerance"
|
||||
# The naive fix (deal_side-style, no NULL tolerance) must NOT appear standalone.
|
||||
naive = re.search(r"AND\s+city\s+ILIKE\s+:asking_city(?!\))", block)
|
||||
assert naive is None, f"{cte_name}: found naive filter without IS NULL tolerance"
|
||||
|
||||
|
||||
def _strip_sql(s: str) -> str:
|
||||
|
|
@ -152,6 +194,9 @@ def test_migration_080_derivation_is_subset_of_refresh_sql() -> None:
|
|||
|
||||
#1186: the refresh now adds the novostroyki guard predicate to each ask_* CTE;
|
||||
it is normalised away here so the 080 seed (no guard) still matches.
|
||||
|
||||
#2583 H2: the refresh now also adds the NULL-tolerant city-scope predicate to each
|
||||
ask_* CTE (symmetric to the #C2 SOLD-side guard) — normalised away the same way.
|
||||
"""
|
||||
seed_sql = _MIGRATION_080.read_text("utf-8")
|
||||
# Extract the WITH … (up to the ON CONFLICT) from the seed.
|
||||
|
|
@ -176,8 +221,15 @@ def test_migration_080_derivation_is_subset_of_refresh_sql() -> None:
|
|||
)
|
||||
|
||||
def _drop_city_guard(s: str) -> str:
|
||||
"""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)
|
||||
"""Remove the #C2 SOLD-side + #2583 H2 ASK-side city-scope predicates.
|
||||
|
||||
Both are absent in the 080 seed: #C2 added the unconditional SOLD-side guard
|
||||
(deal_side/deal_global), #2583 H2 later added the NULL-tolerant ASK-side guard
|
||||
(ask_side/ask_global).
|
||||
"""
|
||||
s = re.sub(r"AND\s+city\s+ILIKE\s+:asking_city", "", s)
|
||||
s = re.sub(r"AND\s*\(\s*city\s+IS\s+NULL\s+OR\s+city\s+ILIKE\s+:asking_city\s*\)", "", s)
|
||||
return s
|
||||
|
||||
def _norm(s: str) -> str:
|
||||
return _strip_sql(_normalise_ppm2(_drop_city_guard(_drop_segment_guard(s))))
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue