diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index cba82f8b..c176d71d 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -89,6 +89,13 @@ SBER_COEFF_DASHBOARDS = ("residential_real_estate_prices", "real_estate_deals") SBER_TIME_FACTOR_MIN = 0.7 # clamp guards against bad/sparse index months SBER_TIME_FACTOR_MAX = 1.6 +# #2141: порог, ниже которого сохранённый asking_to_sold_ratio НЕ пересчитываем из +# expected_sold/median — оставляем табличный ratio байт-в-байт. Отсекает лишь шум +# округления round() (≤ 0.5/median_price ≈ 5e-7 при median ≥ 1M); любой значимый +# сдвиг expected_sold (hedonic/le_asking/corridor-clamp) заведомо больше и триггерит +# честный пересчёт дескриптора. 1e-4 << шаг бейджа round((1−ratio)×100) (0.005). +_RATIO_DESCRIPTOR_EPS = 1e-4 + # #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются # нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади — # которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные @@ -2496,6 +2503,21 @@ def _price_from_inputs( expected_sold_range_low = round(range_low * effective_ratio) expected_sold_range_high = round(range_high * effective_ratio) + # #2141: честный asking_to_sold_ratio (дескриптор бейджа «−N% к рынку»). + # ratio_resolver отдаёт СЫРОЙ per-rooms/tier дисконт, но ФАКТИЧЕСКИЙ + # expected_sold сдвинут относительно median×ratio: hedonic-коррекция (#2002, + # год+площадь, factor ∈ [0.75, 1.30]), le_asking-clamp, corridor-clamp. Тогда + # сохранённый сырой ratio ≠ expected_sold/headline, и web/PDF-бейдж + # round((1−ratio)×100) систематически врёт (напр. stored 0.771 при факте 0.945 + # → «−23%» вместо честных «−5%»). Пересчитываем ratio как реальное + # expected_sold_price/median_price — дескриптор становится честным, а сам + # expected_sold (выкуп) НЕ трогаем: hedonic-uplift остаётся прибит к sale-модели. + # Нет сдвига (expected == median×ratio, hedonic OFF) → табличный ratio байт-в-байт. + if expected_sold_price and expected_sold_price > 0: + honest_ratio = expected_sold_price / median_price + if abs(honest_ratio - asking_to_sold_ratio) > _RATIO_DESCRIPTOR_EPS: + asking_to_sold_ratio = honest_ratio + # ── #652: ДКП-коридор реальных сделок (advisory) ───────────────────────── dkp_corridor: DkpCorridor | None = None if dkp_raw is not None: diff --git a/tradein-mvp/backend/tests/test_estimator_expected_sold.py b/tradein-mvp/backend/tests/test_estimator_expected_sold.py index 1c3aa49a..c49ffd23 100644 --- a/tradein-mvp/backend/tests/test_estimator_expected_sold.py +++ b/tradein-mvp/backend/tests/test_estimator_expected_sold.py @@ -281,8 +281,15 @@ def test_graceful_when_ratio_none() -> None: assert est.median_price_rub == int(150_000.0 * 40.0) -def test_global_fallback_basis_carried_through() -> None: +def test_global_fallback_basis_carried_through(monkeypatch: pytest.MonkeyPatch) -> None: """basis='global_fallback' propagates to the returned estimate.""" + # #2141: сохранённый asking_to_sold_ratio теперь честный дескриптор + # (== expected_sold/median). Держим hedonic-коррекцию OFF, иначе uplift + # сдвинул бы ratio с сырого 0.79 (сам пересчёт проверяют + # test_honest_ratio_* в test_estimator_price_spine.py). + from app.services import estimator as _est + + monkeypatch.setattr(_est.settings, "estimate_hedonic_correction_enabled", False) est = _run_estimate((0.79, "global_fallback")) assert est.ratio_basis == "global_fallback" assert est.asking_to_sold_ratio == 0.79 diff --git a/tradein-mvp/backend/tests/test_estimator_price_spine.py b/tradein-mvp/backend/tests/test_estimator_price_spine.py index be2713bd..b61f79b7 100644 --- a/tradein-mvp/backend/tests/test_estimator_price_spine.py +++ b/tradein-mvp/backend/tests/test_estimator_price_spine.py @@ -407,3 +407,62 @@ def test_coarse_geo_downgrades_confidence_to_low() -> None: assert pr.confidence == "low" assert "населённого пункта" in pr.explanation + + +# ── #2141: честный asking_to_sold_ratio (дескриптор бейджа «−N% к рынку») ────── + + +def test_honest_ratio_invariant_with_hedonic_uplift(monkeypatch: pytest.MonkeyPatch) -> None: + """#2141: hedonic-uplift (#2002) поднимает expected_sold выше median×raw_ratio → + сохранённый asking_to_sold_ratio пересчитывается в ЧЕСТНЫЙ expected_sold/median, + но сам expected_sold (выкуп) НЕ падает до наивного median×raw. Это ровно тот + live-дефект, где бейдж показывал «−23%» при фактических «−5%».""" + monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True) + raw_ratio = 0.74 + # Малая площадь + новостройка → hedonic factor у верхней границы (≈1.28). + pr = _call( + listings=_lots(200_000, n=7), + area_m2=25.0, + rooms=1, + target_year=2024, + ratio=raw_ratio, + ) + assert pr.expected_sold_price is not None + assert pr.asking_to_sold_ratio is not None + assert pr.median_price > 0 + # Выкуп НЕ упал до наивного median×raw_ratio — hedonic-uplift сохранён. + assert pr.expected_sold_price > round(pr.median_price * raw_ratio) + # Инвариант: сохранённый ratio == фактическому expected_sold/median (бейдж честный). + assert abs(pr.asking_to_sold_ratio - pr.expected_sold_price / pr.median_price) < 1e-3 + # Пересчёт действительно сработал (ratio уже не сырой табличный). + assert pr.asking_to_sold_ratio > raw_ratio + 1e-3 + + +def test_honest_ratio_invariant_under_corridor_clamp(monkeypatch: pytest.MonkeyPatch) -> None: + """#2141: даже когда corridor-clamp прижимает headline ВНИЗ к ДКП-коридору, + сохранённый asking_to_sold_ratio == expected_sold/median на ФИНАЛЬНЫХ значениях.""" + monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True) + pr = _call( + listings=_lots(250_000, n=7), + area_m2=50.0, + rooms=2, + target_year=2024, + dkp_raw=_dkp_raw(low=80_000, median=120_000, high=150_000, count=15), + ratio=0.80, + ) + assert pr.expected_sold_price is not None + assert pr.asking_to_sold_ratio is not None + # corridor-clamp прижал headline (250k > cap=150k×1.40=210k). + assert pr.median_ppm2 < 250_000 + # Инвариант держится на финальных значениях (после clamp + hedonic). + assert abs(pr.asking_to_sold_ratio - pr.expected_sold_price / pr.median_price) < 1e-3 + + +def test_honest_ratio_byte_identical_without_shift(monkeypatch: pytest.MonkeyPatch) -> None: + """#2141 regression: hedonic OFF + без клампа → expected == median×ratio, и + сохранённый ratio остаётся СЫРЫМ табличным байт-в-байт (дескриптор не пересчитан).""" + monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", False) + pr = _call(listings=_lots(100_000, n=7), area_m2=50.0, rooms=2, ratio=0.90) + assert pr.asking_to_sold_ratio == 0.90 # байт-в-байт сырой табличный ratio + assert pr.expected_sold_price == round(pr.median_price * 0.90) + assert abs(pr.asking_to_sold_ratio - pr.expected_sold_price / pr.median_price) < 1e-3