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: