fix(tradein/estimator): lock honest asking_to_sold_ratio invariant + disambiguate torg bases (#2087 M2/M3) #2387

Merged
lekss361 merged 1 commit from fix/tradein-2087-ratio-reconciliation into main 2026-07-04 06:48:52 +00:00
3 changed files with 56 additions and 1 deletions

View file

@ -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.720.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

View file

@ -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:

View file

@ -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_ratio1)×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 (160k220k) → 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
)