From ecc3ab5aab19a0c50b5b8204d5e18e585732c431 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 2 Jul 2026 18:26:19 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/estimator):=20=D1=87=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BD=D1=8B=D0=B9=20asking=5Fto=5Fsold=5Fratio=20?= =?UTF-8?q?=E2=80=94=20=D0=B1=D0=B5=D0=B9=D0=B4=D0=B6=20=C2=AB=E2=88=92N%?= =?UTF-8?q?=20=D0=BA=20=D1=80=D1=8B=D0=BD=D0=BA=D1=83=C2=BB=20=D0=B1=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=20=D0=B2=D1=80=D1=91?= =?UTF-8?q?=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Оценщик клиента жаловался на «большой интервал между рекомендованной ценой и оценкой». Разбор: бейдж «−23% к рынку» (web HeroSummary + PDF, формула round((1−ratio)×100)) систематически завышал скидку. Root cause: сохранённый asking_to_sold_ratio — это СЫРОЙ per-rooms/tier дисконт из ratio_resolver, но фактический expected_sold сдвинут относительно median×ratio последующими корректировками: hedonic year+area (#2002, factor ∈ [0.75, 1.30], ON by default), le_asking-clamp и corridor-clamp. Пример с прода (451de30b): median 7.75M × raw 0.771 = 5.97M, hedonic ×1.226 → expected_sold 7.32M — но stored ratio остался 0.771, тогда как фактическое expected_sold/median = 0.945. Бейдж показывал «−23%» вместо честных «−5%». Fix: после финализации expected_sold пересчитываем сохранённый asking_to_sold_ratio как реальное expected_sold_price/median_price (честный дескриптор). Сам expected_sold (выкуп) НЕ трогаем — hedonic-uplift остаётся прибит к sale-модели, buyout не падает до наивного median×raw. Порог _RATIO_DESCRIPTOR_EPS=1e-4 отсекает шум округления: без сдвига (hedonic OFF, нет клампа) табличный ratio сохраняется байт-в-байт → регрессия на не-зажатых оценках отсутствует. Стор asking_to_sold_ratio — чисто ДЕСКРИПТОР (web/PDF/history badge), НЕ калибровочный вход: калибровочный ratio живёт в таблице asking_to_sold_ratios (refresh-task, читает resolver) — не тронута. Backtest #1966 скорит expected_sold_per_m2 (не stored ratio) — не затронут (expected_sold без изменений). Tests: 3 новых в test_estimator_price_spine.py (инвариант при hedonic-uplift + corridor-clamp; byte-identical регрессия без сдвига); поправлен test_global_fallback_basis_carried_through (hedonic OFF для сырого ratio). Full suite: 2749 passed (кроме pre-existing test_search_cache_hit). Refs #2141 --- tradein-mvp/backend/app/services/estimator.py | 22 +++++++ .../tests/test_estimator_expected_sold.py | 9 ++- .../tests/test_estimator_price_spine.py | 59 +++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) 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