From f631af4de326276b08bd2424dcd3e42a60204bf1 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 12 Jul 2026 20:18:36 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein):=20city-scope=20=D0=94=D0=9A=D0=9F?= =?UTF-8?q?-=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D1=8B=20=E2=80=94=20?= =?UTF-8?q?=D1=83=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20oblast-=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D0=BC=D0=B8=D0=BD=D0=B0=D1=86=D0=B8=D1=8E=20?= =?UTF-8?q?(C1+C2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Миграция 177 залила +47183 ДКП-сделки по всей Свердловской обл. (66, 368 городов, deals.city заполнен). Потребители deals матчат по имени улицы БЕЗ city-фильтра → одноимённые улицы дешёвых городов области контаминируют ЕКБ-числа. C1 — street-deals + ДКП-коридор (клиент видит СЕЙЧАС): - estimator._fetch_dkp_corridor + api/v1/trade_in.get_street_deals: добавлен city-скоуп `city IS NOT NULL AND :address ILIKE '%'||city||'%'` (канонический deals.city как подстрока целевого адреса — self-normalizing, адрес всегда содержит город). - Live-repro (Ленина, 2к, 12мес): median 52 908 → 118 052 ₽/м² (было занижение ~−55%). C2 — дневной пересчёт asking→sold ratio (imminent, «выкупная» −29%): - tasks/asking_to_sold_ratio: deal_side + deal_global скоупятся на ЕКБ (asking-сторона = listings покрыты скрейпом только по ЕКБ; в listings нет колонки city). Константа _ASKING_CITY_PATTERN. ask-стороны не трогаем. - Live-repro (rooms=2): sold_median all-city 82 593 (ratio 0.622) → EKB 116 858 (ratio 0.880). Предотвращает обвал «выкупной» на след. пересчёте. - Когда появятся oblast-листинги (B1/B2) → заменить на per-city ratio через `district`. Тесты: subset-тест 080↔refresh (mirror _drop_segment_guard прецедента для city-guard) + позитивный тест скоупа; 125 passed вкл. regression-гейт (байт-зелёный — коридор заморожен в фикстуре), expected_sold, idor, 781. Regression-гейт не затронут (dkp_raw — frozen kwarg). --- tradein-mvp/backend/app/api/v1/trade_in.py | 6 +++++ tradein-mvp/backend/app/services/estimator.py | 9 +++++++ .../backend/app/tasks/asking_to_sold_ratio.py | 16 ++++++++++- .../tests/test_asking_to_sold_ratio.py | 27 ++++++++++++++++--- 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index f4fed5c4..89949dc2 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -1673,6 +1673,11 @@ def get_street_deals( WHERE source = 'rosreestr' AND address ILIKE :street_pattern 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 area_m2 BETWEEN :area_min AND :area_max AND deal_date > NOW() - (CAST(:period_months AS integer) || ' months')::interval @@ -1683,6 +1688,7 @@ def get_street_deals( { "street_pattern": "%" + street_name + "%", "street_regex": r"\m" + street_name + r"\M", + "address": address, "rooms": rooms, "area_min": area_min, "area_max": area_max, diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 3a29a137..14a92f90 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -1300,6 +1300,14 @@ def _fetch_dkp_corridor( WHERE d.source = 'rosreestr' AND d.address ILIKE :street_pattern 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.area_m2 BETWEEN :area_min AND :area_max AND d.deal_date > NOW() @@ -1317,6 +1325,7 @@ def _fetch_dkp_corridor( { "street_pattern": "%" + street_name + "%", "street_regex": r"\m" + street_name + r"\M", + "address": address, "rooms": rooms, "area_min": area_min, "area_max": area_max, 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 7b588372..49958e18 100644 --- a/tradein-mvp/backend/app/tasks/asking_to_sold_ratio.py +++ b/tradein-mvp/backend/app/tasks/asking_to_sold_ratio.py @@ -40,6 +40,14 @@ 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). +_ASKING_CITY_PATTERN: str = "%Екатеринбург%" # Верхняя граница берётся из settings.asking_ratio_ppm2_max (default 1_200_000). # QA-note: точное значение сверить с `SELECT max(price_per_m2) FROM deals # WHERE source='rosreestr'` на проде — ceiling должен быть > max(ppm²) premium-сделок. @@ -80,6 +88,7 @@ _REDERIVE_SQL = text( FROM deals WHERE source = 'rosreestr' 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 deal_date >= CURRENT_DATE - INTERVAL '12 months' GROUP BY LEAST(GREATEST(rooms, 0), 4) @@ -128,6 +137,7 @@ _REDERIVE_SQL = text( FROM deals WHERE source = 'rosreestr' 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 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( _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() 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 43ec5a5b..9f4e8d21 100644 --- a/tradein-mvp/backend/tests/test_asking_to_sold_ratio.py +++ b/tradein-mvp/backend/tests/test_asking_to_sold_ratio.py @@ -113,6 +113,22 @@ def test_rederivation_cte_blocks_match_080() -> None: 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: """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, ) - assert _strip_sql(_normalise_ppm2(_drop_segment_guard(seed_derivation))) in _strip_sql( - _normalise_ppm2(_drop_segment_guard(_REDERIVE_SQL)) - ) + 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) + + 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 ────────────────────────────────────────────────────────────