fix(tradein/estimator): честный asking_to_sold_ratio — бейдж «−N% к рынку» больше не врёт
Оценщик клиента жаловался на «большой интервал между рекомендованной ценой и оценкой». Разбор: бейдж «−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
This commit is contained in:
parent
1c184959a8
commit
ecc3ab5aab
3 changed files with 89 additions and 1 deletions
|
|
@ -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_MIN = 0.7 # clamp guards against bad/sparse index months
|
||||||
SBER_TIME_FACTOR_MAX = 1.6
|
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`). В сырых сделках встречаются
|
# #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются
|
||||||
# нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади —
|
# нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади —
|
||||||
# которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные
|
# которые шумят 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_low = round(range_low * effective_ratio)
|
||||||
expected_sold_range_high = round(range_high * 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) ─────────────────────────
|
# ── #652: ДКП-коридор реальных сделок (advisory) ─────────────────────────
|
||||||
dkp_corridor: DkpCorridor | None = None
|
dkp_corridor: DkpCorridor | None = None
|
||||||
if dkp_raw is not None:
|
if dkp_raw is not None:
|
||||||
|
|
|
||||||
|
|
@ -281,8 +281,15 @@ def test_graceful_when_ratio_none() -> None:
|
||||||
assert est.median_price_rub == int(150_000.0 * 40.0)
|
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."""
|
"""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"))
|
est = _run_estimate((0.79, "global_fallback"))
|
||||||
assert est.ratio_basis == "global_fallback"
|
assert est.ratio_basis == "global_fallback"
|
||||||
assert est.asking_to_sold_ratio == 0.79
|
assert est.asking_to_sold_ratio == 0.79
|
||||||
|
|
|
||||||
|
|
@ -407,3 +407,62 @@ def test_coarse_geo_downgrades_confidence_to_low() -> None:
|
||||||
|
|
||||||
assert pr.confidence == "low"
|
assert pr.confidence == "low"
|
||||||
assert "населённого пункта" in pr.explanation
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue