From f5bfa3dca5b7982e7ba453913c012ee029c8baaf Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 4 Jul 2026 09:44:09 +0300 Subject: [PATCH] fix(tradein/estimator): lock honest asking_to_sold_ratio invariant thru segment-multiplier + disambiguate torg bases (#2087 M2/M3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M2 root-cause fix already landed in PR #2152/#2141 — honest-ratio recompute runs unconditionally after all median mutations. Gap found: segment-multiplier (#2255) mutates both sides after that recompute with independent rounding, untested at the ratio-invariant level. Added regression guard. M3 — median_bargain_pct (house-level exposure discount) and asking_to_sold_ratio (estimate-level asking-vs-sold) are genuinely different metrics with no honest common basis. Chose explicit cross-referencing docstrings over merging. --- tradein-mvp/backend/app/schemas/trade_in.py | 21 +++++++++++++++- tradein-mvp/backend/app/services/estimator.py | 11 ++++++++ .../tests/test_estimator_price_spine.py | 25 +++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 07524fd0..1cd0ce13 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -213,6 +213,14 @@ class AggregatedEstimate(BaseModel): expected_sold_range_low_rub: int | None = None expected_sold_range_high_rub: int | None = None expected_sold_per_m2: int | None = None + # #2087 M3: этот ratio — asking→sold дисконт ДЛЯ ЭТОГО эстимейта (показанная + # asking-медиана median_price_rub vs ожидаемая цена продажи expected_sold_price_rub + # по ДКП-корректировке Росреестра, per-rooms/tier коэффициент; #2141 гарантирует + # asking_to_sold_ratio == expected_sold_price_rub/median_price_rub байт-в-байт). + # НЕ путать с HouseAnalyticsKpi.median_bargain_pct — это торг ВНУТРИ жизни + # объявления (start_price→last_price до снятия, house_placement_history по + # конкретному дому за 12 мес) — другая база и другой смысл «торга», см. докстринг + # HouseAnalyticsKpi. asking_to_sold_ratio: float | None = None # =sold/asking, ~0.72–0.93 ratio_basis: str | None = None # 'per_rooms' | 'global_fallback' # ── DaData enrichment (PR Q1) — on-demand для target адреса ── @@ -432,12 +440,23 @@ class RecentSoldEntry(BaseModel): class HouseAnalyticsKpi(BaseModel): - """Агрегированные KPI по дому(ам) из house_placement_history.""" + """Агрегированные KPI по дому(ам) из house_placement_history. + + #2087 M3: median_bargain_pct — торг ВНУТРИ жизни объявления (медиана + (start_price-last_price)/start_price×100 по ВСЕМ лотам house_placement_history + для дома/домов — не только снятым, без ограничения по давности; 12-мес окно + есть только у отдельного recent_sold_rows-запроса, не у этого поля). Другая база + и другой смысл «торга», чем AggregatedEstimate.asking_to_sold_ratio (asking-медиана + ЭТОГО эстимейта vs ожидаемая ДКП-цена продажи, per-rooms/tier коэффициент) — + не суммировать и не подписывать в UI как одну и ту же метрику. + """ total_lots: int sold_count: int sold_rate_pct: float median_exposure_days: int | None + # медиана (start_price-last_price)/start_price×100 по всем лотам дома, без sold-фильтра + # и без ограничения по давности (см. докстринг класса) median_bargain_pct: float | None diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 952c90f4..00d9e2fd 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -2681,6 +2681,17 @@ def _price_from_inputs( # expected_sold_price/median_price — дескриптор становится честным, а сам # expected_sold (выкуп) НЕ трогаем: hedonic-uplift остаётся прибит к sale-модели. # Нет сдвига (expected == median×ratio, hedonic OFF) → табличный ratio байт-в-байт. + # + # #2087 M2 (донат-виджет «−N% к рынку» расходился с показанными ценами): + # тот же корень, что #2141 выше — закрывается ЭТИМ recompute'ом, т.к. он + # безусловный (выполняется всегда, когда expected_sold_price задан) и стоит + # ПОСЛЕ anchor/IMV-blend/quarter-index/corridor-clamp/radius-floor (все они + # мутируют median_price ДО этой точки, см. "AFTER all headline mutations" + # выше). #2255 segment-multiplier ниже — единственная мутация ПОСЛЕ этого + # recompute'а — умножает median_price И expected_sold_price ОДНИМ и тем же + # factor, так что их отношение (= сохранённый ratio) инвариантно с точностью + # до независимого round() на каждой стороне (см. + # test_honest_ratio_invariant_under_segment_multiplier). 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: diff --git a/tradein-mvp/backend/tests/test_estimator_price_spine.py b/tradein-mvp/backend/tests/test_estimator_price_spine.py index b61f79b7..fd38466b 100644 --- a/tradein-mvp/backend/tests/test_estimator_price_spine.py +++ b/tradein-mvp/backend/tests/test_estimator_price_spine.py @@ -466,3 +466,28 @@ def test_honest_ratio_byte_identical_without_shift(monkeypatch: pytest.MonkeyPat 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 + + +def test_honest_ratio_invariant_under_segment_multiplier(monkeypatch: pytest.MonkeyPatch) -> None: + """#2087 M2 regression: донат-виджет («−N% к рынку») считается фронтом как + (asking_to_sold_ratio−1)×100 — рисуется мимо показанных median/expected_sold, если + сохранённый ratio разошёлся с фактическим expected_sold_price/median_price. #2255 + segment-multiplier — ЕДИНСТВЕННАЯ ценовая мутация ПОСЛЕ honest_ratio-пересчёта + (#2141): множит median_price И expected_sold_price ОДНИМ и тем же factor, но + round() на каждой стороне — независимый. Проверяем, что инвариант держится и + здесь (с точностью до шума округления), а не только до мутации.""" + monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", False) + monkeypatch.setattr(estimator.settings, "estimate_segment_multiplier_enabled", True) + monkeypatch.setattr( + estimator.settings, + "estimate_segment_multipliers", + {"эконом": 1.00, "комфорт": 1.00, "бизнес": 1.08, "элит": 1.06}, + ) + # 213_777 ₽/м² falls in the "бизнес" band (160k–220k) → factor 1.08 fires. + pr = _call(listings=_lots(213_777.0, n=7), area_m2=53.4, rooms=2, ratio=0.83) + + assert "segment_multiplier" in (pr.sources_used_pre or []) # confirm the mult fired + assert pr.expected_sold_price is not None and pr.median_price > 0 + assert pr.asking_to_sold_ratio == pytest.approx( + pr.expected_sold_price / pr.median_price, abs=1e-3 + )