From 661f19853b335b92ca6324da61a61bf46620bc93 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 2 Aug 2026 11:53:26 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/pricing):=20=D1=81=D0=BA=D0=BE?= =?UTF-8?q?=D1=83=D0=BF=D0=B8=D1=82=D1=8C=20=D1=81=D1=82=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=BD=D1=83=20=D0=BE=D0=B1=D1=8A=D1=8F=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B9=20=D0=BF=D0=BE=20=D0=B3=D0=BE=D1=80=D0=BE=D0=B4?= =?UTF-8?q?=D1=83,=20=D0=BA=D0=B0=D0=BA=20=D1=81=D1=82=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=BD=D1=83=20=D1=81=D0=B4=D0=B5=D0=BB=D0=BE=D0=BA=20(#2583=20?= =?UTF-8?q?H2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/app/tasks/asking_to_sold_ratio.py | 35 ++++++++-- .../tests/test_asking_to_sold_ratio.py | 68 ++++++++++++++++--- 2 files changed, 88 insertions(+), 15 deletions(-) diff --git a/tradein-mvp/backend/app/tasks/asking_to_sold_ratio.py b/tradein-mvp/backend/app/tasks/asking_to_sold_ratio.py index 49958e18..66dc908c 100644 --- a/tradein-mvp/backend/app/tasks/asking_to_sold_ratio.py +++ b/tradein-mvp/backend/app/tasks/asking_to_sold_ratio.py @@ -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 ( diff --git a/tradein-mvp/backend/tests/test_asking_to_sold_ratio.py b/tradein-mvp/backend/tests/test_asking_to_sold_ratio.py index 9f4e8d21..c4cd9092 100644 --- a/tradein-mvp/backend/tests/test_asking_to_sold_ratio.py +++ b/tradein-mvp/backend/tests/test_asking_to_sold_ratio.py @@ -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)))) -- 2.45.3