From c1a01fdbf97e71fd3677584e089eac7a7c57ca2f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 12 Jul 2026 22:03:59 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/estimator):=20=D0=A1=D0=B1=D0=B5?= =?UTF-8?q?=D1=80=D0=98=D0=BD=D0=B4=D0=B5=D0=BA=D1=81=20=E2=80=94=20=D1=82?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B2=D1=82=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D1=87=D0=BA=D0=B0=20+=20confidence=20dispersion-ceiling=20(R2?= =?UTF-8?q?=20H1+H2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H1 (деньги, каждая оценка): time-adjust ДКП-коридора брал СберИндекс `residential_real_estate_prices`, а для обл.66 это 100% «Первичный рынок» (новостройки) → коррекция направленно противоположна вторичке (первичка Jan→May +0.89% vs вторичка −0.39%). Fix: SBER_COEFF_DASHBOARDS → (real_estate_deals, dinamika-tsen-obyavlenii) — обе вторичка; + segment-guard в SQL (`segment ILIKE '%вторичн%'`) как defense-in-depth. Live-verified: остаются только Вторичный-серии. H2 (честность): `_compute_confidence` ветка «≥4 адреса» не имела потолка разброса → пул с ±45% IQR + «расширили радиус из-за нехватки данных» уходил как «medium» вопреки объяснению. Fix: medium требует IQR/median < 0.35; + force-low при fallback-расширении с разбросом > 0.30. Regression-гейт: перегенерён baseline (dedup OFF, как в гейте) — ровно 1 из 277 фикстур-кейсов medium→low (высокодисперсный, справедливо): calibration.medium.n 2→1 (mape 14.64→6.99 — ненадёжный ушёл), low.coverage 81.82→81.88. Δ минимальна и обоснована. 127 тестов зелёные. Stale-СберИндекс (72д) — НЕ трогаю в коде: time-adjust валидно ре-базит Jan→May; сброс потерял бы валидную коррекцию. Реальный gap операционный (pull не гонялся с 19.06) — в scheduler/ops. --- tradein-mvp/backend/app/services/estimator.py | 22 +++++++++-- .../tests/fixtures/backtest_baseline.json | 24 ++++++------ .../tests/test_estimator_pure_units.py | 39 +++++++++++++++++-- 3 files changed, 65 insertions(+), 20 deletions(-) diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 838de0c8..5fc8ff0c 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -105,8 +105,11 @@ DEALS_HEADLINE_FALLBACK_MIN_N = 3 # Rosreestr deals freeze ~2026-01; the sber monthly index re-bases a stale deal's ppm² # to the latest available month. Region fixed to Свердловская обл. (tradein MVP = ЕКБ). SBER_TIME_ADJUST_REGION = "Свердловская область" -# Coefficient series preference: hedonic (quality-adjusted, cleanest) → deals (fallback). -SBER_COEFF_DASHBOARDS = ("residential_real_estate_prices", "real_estate_deals") +# Coefficient series preference — ТОЛЬКО вторичный рынок (эстиматор оценивает вторичку): +# real_estate_deals (Вторичный, зарег. сделки) → dinamika-tsen-obyavlenii (Вторичный, asking). +# #R2-H1: residential_real_estate_prices УБРАН — для обл.66 это 100% «Первичный рынок» +# (новостройки), даёт направленно ПРОТИВОПОЛОЖНУЮ time-adjust коррекцию для вторички. +SBER_COEFF_DASHBOARDS = ("real_estate_deals", "dinamika-tsen-obyavlenii") SBER_TIME_FACTOR_MIN = 0.7 # clamp guards against bad/sparse index months SBER_TIME_FACTOR_MAX = 1.6 @@ -1221,6 +1224,9 @@ def _load_sber_index_series(db: Session, *, region: str) -> dict[date, float]: FROM sber_price_index WHERE city = CAST(:region AS text) AND dashboard = CAST(:dash AS text) + -- #R2-H1: только вторичный рынок (эстиматор — вторичка); + -- первичка (новостройки) = направленно неверная коррекция. + AND (segment IS NULL OR segment ILIKE '%вторичн%') ORDER BY period_month """), {"region": region, "dash": dash}, @@ -5729,9 +5735,11 @@ def _compute_confidence( (например, MIN_ANALOGS_PER_SOURCE=5 + same-building bias). high — unique_addr ≥ 7 AND IQR/median < 0.15 - medium — unique_addr ≥ 4 OR (unique_addr ≥ 2 AND IQR/median < 0.25) + medium — (unique_addr ≥ 4 AND IQR/median < 0.35) OR (unique_addr ≥ 2 AND IQR/median < 0.25) low — иначе + #R2-H2: dispersion-ceiling на medium (0.35) + force-low при fallback-расширении + с разбросом > 0.30 — badge не должен противоречить собственному объяснению. Downgrade на один уровень если avg_lots_per_addr > 2.5 (concentration bias). """ if median_ppm2 == 0: @@ -5760,13 +5768,19 @@ def _compute_confidence( # Базовый уровень по уникальным адресам if unique_addr_count >= 7 and iqr_pct < 0.15: base = "high" - elif unique_addr_count >= 4: + elif unique_addr_count >= 4 and iqr_pct < 0.35: base = "medium" elif unique_addr_count >= 2 and iqr_pct < 0.25: base = "medium" else: base = "low" + # #R2-H2: расширение радиуса/площади из-за нехватки данных + высокий разброс + # (IQR/median > 0.30) не может честно оставаться medium/high — иначе badge + # противоречит объяснению («расширили радиус … ±45%»). Форсим low. + if base != "low" and (fallback_radius_used or area_widened) and iqr_pct > 0.30: + base = "low" + # Downgrade на один шаг если слишком много лотов сконцентрировано на малом числе адресов if avg_lots_per_addr > 2.5 and base != "low": downgrade_map = {"high": "medium", "medium": "low"} diff --git a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json index 9abac124..55fe9bb3 100644 --- a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json +++ b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json @@ -7,16 +7,16 @@ "n_covered": 0 }, "low": { - "coverage_pct": 81.82, - "mape_pct": 13.23, - "n": 275, - "n_covered": 225 + "coverage_pct": 81.88, + "mape_pct": 13.25, + "n": 276, + "n_covered": 226 }, "medium": { "coverage_pct": 100.0, - "mape_pct": 14.64, - "n": 2, - "n_covered": 2 + "mape_pct": 6.99, + "n": 1, + "n_covered": 1 } }, "confidence_order": [ @@ -136,14 +136,14 @@ "n_covered": 0 }, "low": { - "coverage_pct": 81.82, - "n": 275, - "n_covered": 225 + "coverage_pct": 81.88, + "n": 276, + "n_covered": 226 }, "medium": { "coverage_pct": 100.0, - "n": 2, - "n_covered": 2 + "n": 1, + "n_covered": 1 } } }, diff --git a/tradein-mvp/backend/tests/test_estimator_pure_units.py b/tradein-mvp/backend/tests/test_estimator_pure_units.py index ca791a6f..1170e4c2 100644 --- a/tradein-mvp/backend/tests/test_estimator_pure_units.py +++ b/tradein-mvp/backend/tests/test_estimator_pure_units.py @@ -181,18 +181,49 @@ def test_confidence_high_with_7_unique_addresses_and_tight_iqr() -> None: def test_confidence_medium_via_4_unique_addresses() -> None: - # 4 unique addresses → medium branch (independent of IQR). - # Wide IQR ensures it is NOT "high". avg = 4/4 = 1.0 (no downgrade). + # #R2-H2: 4 unique addresses AND IQR/median < 0.35 → medium. avg = 4/4 = 1.0 (no downgrade). + listings = _addr_lots(["a", "b", "c", "d"]) + level, _ = estimator._compute_confidence( + n_analogs=4, + median_ppm2=100, + q1=90, + q3=115, # IQR/median = 0.25 < 0.35 → medium + fallback_radius_used=False, + listings=listings, + ) + assert level == "medium" + + +def test_confidence_4_addresses_wide_iqr_now_low() -> None: + # #R2-H2: 4 unique addresses but IQR/median = 0.60 (huge dispersion) → low, NOT medium. + # Dispersion ceiling: the badge must not contradict a ±30% spread in its own explanation. listings = _addr_lots(["a", "b", "c", "d"]) level, _ = estimator._compute_confidence( n_analogs=4, median_ppm2=100, q1=70, - q3=130, # IQR/median = 0.60 → not high + q3=130, # IQR/median = 0.60 fallback_radius_used=False, listings=listings, ) - assert level == "medium" + assert level == "low" + + +def test_confidence_force_low_on_radius_widen_with_dispersion() -> None: + # #R2-H2: a pool that would be medium (4 addr, IQR 0.32 < 0.35) is FORCED low when it + # was radius-widened due to sparse data AND spread > 0.30 — badge can't say "medium" + # while the explanation admits "расширили радиус … из-за нехватки данных". + listings = _addr_lots(["a", "b", "c", "d"]) + level, expl = estimator._compute_confidence( + n_analogs=4, + median_ppm2=100, + q1=84, + q3=116, # IQR/median = 0.32 → medium base, then forced low + fallback_radius_used=True, + listings=listings, + ) + assert level == "low" + assert "расширили радиус" in expl def test_confidence_medium_via_2_unique_addresses_and_tight_iqr() -> None: