diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 766959fb..f20711ea 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -96,6 +96,16 @@ class Settings(BaseSettings): estimate_sb_floor_sigma: float = 0.25 estimate_sb_guardrail_tol: float = 0.05 # hard floor: est ≥ min(comp ppm²)×(1−tol) estimate_sb_mad_k: float = 3.5 # MAD-clip: drop comps с |ppm2−median| > k×MAD + # ── #1966: honest calibrated prediction-interval для expected_sold range ───── + # Старый expected_sold_range производился из IQR аналогов (asking-IQR × ratio): + # ~55% реальных продаж попадали в заявленный «диапазон оценки» (де-факто 50%-й + # интервал, выданный за полный). Эмпирически отношение actual_sold/expected_sold + # по 2366 прод-сделкам имеет p10=0.649, p90=1.392 → band + # expected_sold × [low_mult, high_mult] = настоящий ~80% prediction interval + # (проверено: 80.0% coverage на тех же 2366). OFF ⇒ точно старое IQR-поведение. + estimate_calibrated_pi_enabled: bool = True + estimate_pi_low_mult: float = 0.649 # empirical p10 of sold/expected_sold (#1966, n=2366) + estimate_pi_high_mult: float = 1.392 # empirical p90 of sold/expected_sold (#1966, n=2366) # ── #1795: premium headline anti-inflation (4 фикса, каждый за флагом) ────── # Диагноз: бизнес/премиум headline завышается ~2× vs медиана реальных ДКП # (Малышева 30 = 296k при median сделок 138k). Эконом/комфорт сходятся ±5%. diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 0fb4ee94..1e1afc87 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -2507,8 +2507,19 @@ def _price_from_inputs( effective_ratio = 1.0 expected_sold_per_m2 = round(median_ppm2 * effective_ratio) expected_sold_price = round(median_price * effective_ratio) - expected_sold_range_low = round(range_low * effective_ratio) - expected_sold_range_high = round(range_high * effective_ratio) + if settings.estimate_calibrated_pi_enabled and expected_sold_price: + # #1966: калиброванный ~80% prediction interval вокруг ТОЧКИ expected_sold. + # Эмпирически отношение actual_sold_ppm2 / expected_sold_per_m2 по 2366 + # прод-сделкам: p10=0.649, p90=1.392 → band [×low_mult, ×high_mult] даёт + # ~80% honest coverage. Старый IQR-производный band (asking-IQR × ratio) + # покрывал лишь ~55% реальных продаж при заявленном «диапазоне оценки». + # Множители low<1 None: assert est.median_price_rub == expected_headline assert est.median_price_per_m2 == 150_000 - # Parallel expected_sold = asking × ratio (round()). + # Parallel expected_sold point = asking × ratio (round()). assert est.expected_sold_price_rub == round(est.median_price_rub * ratio) assert est.expected_sold_per_m2 == round(est.median_price_per_m2 * ratio) - assert est.expected_sold_range_low_rub == round(est.range_low_rub * ratio) - assert est.expected_sold_range_high_rub == round(est.range_high_rub * ratio) + # #1966: expected_sold range is now a calibrated ~80% PI around the POINT + # (point × [p10, p90] of sold/expected_sold), not the old asking-IQR × ratio band. + from app.core.config import settings + + assert est.expected_sold_range_low_rub == round( + est.expected_sold_price_rub * settings.estimate_pi_low_mult + ) + assert est.expected_sold_range_high_rub == round( + est.expected_sold_price_rub * settings.estimate_pi_high_mult + ) + assert ( + est.expected_sold_range_low_rub + <= est.expected_sold_price_rub + <= est.expected_sold_range_high_rub + ) assert est.asking_to_sold_ratio == ratio assert est.ratio_basis == "per_rooms" @@ -342,8 +355,15 @@ def test_expected_sold_fires_on_anchor_only_no_radius_comps() -> None: assert est.expected_sold_price_rub > 0 assert est.expected_sold_price_rub == round(est.median_price_rub * ratio) assert est.expected_sold_per_m2 == round(est.median_price_per_m2 * ratio) - assert est.expected_sold_range_low_rub == round(est.range_low_rub * ratio) - assert est.expected_sold_range_high_rub == round(est.range_high_rub * ratio) + # #1966: calibrated ~80% PI around the expected_sold point (not asking-IQR × ratio). + from app.core.config import settings + + assert est.expected_sold_range_low_rub == round( + est.expected_sold_price_rub * settings.estimate_pi_low_mult + ) + assert est.expected_sold_range_high_rub == round( + est.expected_sold_price_rub * settings.estimate_pi_high_mult + ) def test_expected_sold_none_when_median_price_zero() -> None: diff --git a/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py b/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py index 8545d460..c71215a5 100644 --- a/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py +++ b/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py @@ -133,14 +133,25 @@ def test_expected_sold_clamped_to_headline_when_ratio_above_1( assert ( est.expected_sold_per_m2 <= est.median_price_per_m2 ), "expected_sold_per_m2 превышает median_price_per_m2" + # #1966: expected_sold range is now a calibrated ~80% PI around the point + # (point × [p10, p90] of sold/expected_sold), so it is NO LONGER bounded by the + # asking-IQR band — the high arm (point × 1.392) legitimately exceeds range_high. + # The invariant we keep is the calibrated band itself + ordering low ≤ point ≤ high. + from app.core.config import settings + assert est.expected_sold_range_high_rub is not None - assert ( - est.expected_sold_range_high_rub <= est.range_high_rub - ), "expected_sold_range_high > range_high" + assert est.expected_sold_range_high_rub == round( + est.expected_sold_price_rub * settings.estimate_pi_high_mult + ) assert est.expected_sold_range_low_rub is not None + assert est.expected_sold_range_low_rub == round( + est.expected_sold_price_rub * settings.estimate_pi_low_mult + ) assert ( - est.expected_sold_range_low_rub <= est.range_low_rub - ), "expected_sold_range_low > range_low" + est.expected_sold_range_low_rub + <= est.expected_sold_price_rub + <= est.expected_sold_range_high_rub + ) assert est.expected_sold_price_rub == est.median_price_rub assert est.expected_sold_per_m2 == est.median_price_per_m2 clamp_logs = [r for r in caplog.records if "clamped" in r.getMessage()] @@ -154,8 +165,15 @@ def test_expected_sold_not_clamped_when_ratio_below_1() -> None: assert est.median_price_rub > 0 assert est.expected_sold_price_rub == round(est.median_price_rub * ratio) assert est.expected_sold_per_m2 == round(est.median_price_per_m2 * ratio) - assert est.expected_sold_range_low_rub == round(est.range_low_rub * ratio) - assert est.expected_sold_range_high_rub == round(est.range_high_rub * ratio) + # #1966: calibrated ~80% PI around the expected_sold point (not asking-IQR × ratio). + from app.core.config import settings + + assert est.expected_sold_range_low_rub == round( + est.expected_sold_price_rub * settings.estimate_pi_low_mult + ) + assert est.expected_sold_range_high_rub == round( + est.expected_sold_price_rub * settings.estimate_pi_high_mult + ) assert est.expected_sold_price_rub < est.median_price_rub diff --git a/tradein-mvp/backend/tests/test_estimator_imv_blend.py b/tradein-mvp/backend/tests/test_estimator_imv_blend.py index 296223ae..653d27e8 100644 --- a/tradein-mvp/backend/tests/test_estimator_imv_blend.py +++ b/tradein-mvp/backend/tests/test_estimator_imv_blend.py @@ -413,8 +413,15 @@ def test_expected_sold_consistent_with_blended_median() -> None: # expected_sold выведен из POST-blend значений — no stale. assert est.expected_sold_price_rub == round(est.median_price_rub * ratio) assert est.expected_sold_per_m2 == round(est.median_price_per_m2 * ratio) - assert est.expected_sold_range_high_rub == round(est.range_high_rub * ratio) - assert est.expected_sold_range_low_rub == round(est.range_low_rub * ratio) + # #1966: expected_sold range — калиброванный ~80% PI вокруг точки, не asking-IQR × ratio. + from app.core.config import settings + + assert est.expected_sold_range_high_rub == round( + est.expected_sold_price_rub * settings.estimate_pi_high_mult + ) + assert est.expected_sold_range_low_rub == round( + est.expected_sold_price_rub * settings.estimate_pi_low_mult + ) # Sanity: sold < asking (ratio<1), но НЕ абсурдная «скидка» от stale 6М-базы. assert est.expected_sold_price_rub == round(12_000_000 * ratio) diff --git a/tradein-mvp/backend/tests/test_same_building_anchor.py b/tradein-mvp/backend/tests/test_same_building_anchor.py index 898ac7a9..506dd9dd 100644 --- a/tradein-mvp/backend/tests/test_same_building_anchor.py +++ b/tradein-mvp/backend/tests/test_same_building_anchor.py @@ -565,8 +565,16 @@ def test_estimate_expected_sold_distinct_after_anchor() -> None: # поэтому сравниваем с допуском ±1 на округление float→int. assert est.expected_sold_price_rub == round(est.median_price_rub * ratio) assert abs(est.expected_sold_per_m2 - round(est.median_price_per_m2 * ratio)) <= 1 - assert est.expected_sold_range_high_rub == round(est.range_high_rub * ratio) - assert est.expected_sold_range_low_rub == round(est.range_low_rub * ratio) + # #1966: expected_sold range — калиброванный ~80% PI вокруг точки (point × [p10,p90] + # sold/expected_sold), не asking-IQR × ratio. + from app.core.config import settings + + assert est.expected_sold_range_high_rub == round( + est.expected_sold_price_rub * settings.estimate_pi_high_mult + ) + assert est.expected_sold_range_low_rub == round( + est.expected_sold_price_rub * settings.estimate_pi_low_mult + ) # Distinct: sold строго ниже asking (ratio < 1) — фикс двойных идентичных чисел. assert est.expected_sold_price_rub < est.median_price_rub assert est.expected_sold_per_m2 < est.median_price_per_m2