From 2580158d8cf9ff1eaeb373fb6f34ddaf4b9f5469 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 4 Jul 2026 06:48:51 +0000 Subject: [PATCH] fix(tradein/estimator): lock honest asking_to_sold_ratio invariant + disambiguate torg bases (#2087 M2/M3) (#2387) --- 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 + )