From c45445b7261265b48d2a50c4df4baa50e69cdf97 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 27 Jun 2026 16:06:08 +0000 Subject: [PATCH] feat(tradein/estimator): hedonic year+area correction on expected_sold (#2002) --- tradein-mvp/backend/app/core/config.py | 19 ++ tradein-mvp/backend/app/services/estimator.py | 33 +++ .../tests/fixtures/backtest_baseline.json | 104 +++++----- .../backend/tests/test_781_quality_gate.py | 9 +- .../tests/test_backtest_fixture_roundtrip.py | 7 +- .../tests/test_estimator_expected_sold.py | 16 +- .../test_estimator_expected_sold_clamp.py | 3 + .../backend/tests/test_estimator_hedonic.py | 191 ++++++++++++++++++ .../backend/tests/test_estimator_imv_blend.py | 3 + .../tests/test_estimator_price_spine.py | 12 +- .../tests/test_same_building_anchor.py | 12 +- 11 files changed, 347 insertions(+), 62 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_estimator_hedonic.py diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index f20711ea..df2ac627 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -106,6 +106,25 @@ class Settings(BaseSettings): estimate_calibrated_pi_enabled: bool = True estimate_pi_low_mult: float = 0.649 # empirical p10 of sold/expected_sold (#1966, n=2366) estimate_pi_high_mult: float = 1.392 # empirical p90 of sold/expected_sold (#1966, n=2366) + # ── #2002: hedonic year+area correction на точку expected_sold ───────────── + # Диагноз: estimator систематически промахивается по эре дома + размеру — + # недооценивает новостройки, плохо держит крупные лоты. Held-out fit (n=2366 + # прод-сделок, 2026-06-27) регрессии log(actual_sold/expected_sold) ~ year + + # ln(area) даёт мультипликативный фактор, применяемый к expected_sold. + # factor = exp(b0 + b_year*(year-2000)/20 + b_larea*ln(area)), clamp [min,max]. + # ВАЖНО: цифры ниже — метрики ТОГО ЖЕ 2366-сделочного held-out FIT, а НЕ + # 277-сделочного frozen backtest-фикстура, на котором гоняется regression-gate + # (там overall expected_sold MAPE 18.63→14.24): + # held-out median-abs-error 18.5%→16.1%; бизнес bias −22%→−14% (MAPE 22.3→15.5), + # эконом/комфорт/премиум лучше, элит без изменений (no harm). + # После фактора заново применяется le_asking-кламп (expected_sold ≤ asking). + # OFF ⇒ точно старое поведение expected_sold. + estimate_hedonic_correction_enabled: bool = True + estimate_hedonic_b0: float = 0.6146 # fit log(sold/es) ~ year + ln(area), n=2366 (#2002) + estimate_hedonic_year_coef: float = 0.1220 # per (year-2000)/20 + estimate_hedonic_larea_coef: float = -0.1603 # per ln(area_m2) + estimate_hedonic_factor_min: float = 0.75 + estimate_hedonic_factor_max: float = 1.30 # ── #1795: premium headline anti-inflation (4 фикса, каждый за флагом) ────── # Диагноз: бизнес/премиум headline завышается ~2× vs медиана реальных ДКП # (Малышева 30 = 296k при median сделок 138k). Эконом/комфорт сходятся ±5%. diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 1e1afc87..970bf3df 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -2507,6 +2507,39 @@ def _price_from_inputs( effective_ratio = 1.0 expected_sold_per_m2 = round(median_ppm2 * effective_ratio) expected_sold_price = round(median_price * effective_ratio) + # #2002: hedonic year+area correction на ТОЧКУ expected_sold. Fit + # log(actual_sold/expected_sold) ~ year + ln(area) по 2366 прод-сделкам: + # factor = exp(b0 + b_year*(year-2000)/20 + b_larea*ln(area)), clamp [min,max]. + # Применяется ДО калиброванного PI-блока ниже, чтобы диапазон считался + # вокруг скорректированной точки. OFF ⇒ точно старое expected_sold. + if ( + settings.estimate_hedonic_correction_enabled + and expected_sold_price + and area_m2 + and area_m2 > 0 + ): + _yr = ((target_year - 2000) / 20.0) if target_year else 0.0 + _factor = math.exp( + settings.estimate_hedonic_b0 + + settings.estimate_hedonic_year_coef * _yr + + settings.estimate_hedonic_larea_coef * math.log(area_m2) + ) + _factor = max( + settings.estimate_hedonic_factor_min, + min(settings.estimate_hedonic_factor_max, _factor), + ) + if expected_sold_per_m2: + expected_sold_per_m2 = round(expected_sold_per_m2 * _factor) + expected_sold_price = round(expected_sold_price * _factor) + # #2002: re-assert the le_asking invariant — the hedonic factor (≤1.30) can + # push expected_sold above the asking headline (median) for new/small lots, + # which is an overpay risk for trade-in. Re-clamp the corrected point back to + # the headline so the calibrated PI range below wraps the clamped point. + # round(median_ppm2) keeps expected_sold_per_m2 an int (median_ppm2 is float). + if settings.estimate_expected_sold_le_asking and expected_sold_price: + expected_sold_price = min(expected_sold_price, median_price) + if expected_sold_per_m2: + expected_sold_per_m2 = min(expected_sold_per_m2, round(median_ppm2)) if settings.estimate_calibrated_pi_enabled and expected_sold_price: # #1966: калиброванный ~80% prediction interval вокруг ТОЧКИ expected_sold. # Эмпирически отношение actual_sold_ppm2 / expected_sold_per_m2 по 2366 diff --git a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json index 2ed51840..7763a6c5 100644 --- a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json +++ b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json @@ -7,14 +7,14 @@ "n_covered": 0 }, "low": { - "coverage_pct": 80.0, - "mape_pct": 18.63, + "coverage_pct": 81.82, + "mape_pct": 13.23, "n": 275, - "n_covered": 220 + "n_covered": 225 }, "medium": { "coverage_pct": 100.0, - "mape_pct": 19.18, + "mape_pct": 14.64, "n": 2, "n_covered": 2 } @@ -26,95 +26,95 @@ ], "expected_sold": { "overall": { - "mape_pct": 18.63, - "median_bias_pct": -1.2, + "mape_pct": 13.23, + "median_bias_pct": -2.87, "n": 277, "n_no_analogs": 0, - "p25_pct": -16.25, - "p75_pct": 20.26 + "p25_pct": -14.43, + "p75_pct": 12.51 }, "per_rooms": { "0": { "label": "студия", - "mape_pct": 27.97, - "median_bias_pct": 27.56, + "mape_pct": 19.38, + "median_bias_pct": 18.1, "n": 37, "n_no_analogs": 0, - "p25_pct": -8.97, - "p75_pct": 38.45 + "p25_pct": 1.66, + "p75_pct": 33.53 }, "1": { "label": "1к", - "mape_pct": 19.55, - "median_bias_pct": -8.08, + "mape_pct": 10.97, + "median_bias_pct": -3.47, "n": 93, "n_no_analogs": 0, - "p25_pct": -21.27, - "p75_pct": 13.3 + "p25_pct": -12.48, + "p75_pct": 6.98 }, "2": { "label": "2к", - "mape_pct": 16.22, - "median_bias_pct": -8.82, + "mape_pct": 17.39, + "median_bias_pct": -11.71, "n": 74, "n_no_analogs": 0, - "p25_pct": -21.01, - "p75_pct": 6.13 + "p25_pct": -23.37, + "p75_pct": -0.36 }, "3": { "label": "3к", - "mape_pct": 10.52, - "median_bias_pct": 4.08, + "mape_pct": 9.77, + "median_bias_pct": -3.08, "n": 43, "n_no_analogs": 0, - "p25_pct": -6.54, - "p75_pct": 11.96 + "p25_pct": -10.26, + "p75_pct": 5.77 }, "4": { "label": "4+", - "mape_pct": 23.53, - "median_bias_pct": 14.35, + "mape_pct": 20.27, + "median_bias_pct": 8.54, "n": 30, "n_no_analogs": 0, - "p25_pct": -0.72, - "p75_pct": 35.27 + "p25_pct": -3.78, + "p75_pct": 23.54 } }, "per_segment": { "бизнес": { - "mape_pct": 22.14, - "median_bias_pct": -21.49, + "mape_pct": 12.87, + "median_bias_pct": -10.19, "n": 46, - "p25_pct": -28.22, - "p75_pct": -9.82 + "p25_pct": -22.6, + "p75_pct": -1.31 }, "комфорт": { - "mape_pct": 16.74, - "median_bias_pct": -8.05, + "mape_pct": 11.61, + "median_bias_pct": -4.07, "n": 104, - "p25_pct": -20.63, - "p75_pct": 7.55 + "p25_pct": -15.64, + "p75_pct": 7.38 }, "премиум": { - "mape_pct": 59.37, - "median_bias_pct": -59.37, + "mape_pct": 68.92, + "median_bias_pct": -68.92, "n": 1, - "p25_pct": -59.37, - "p75_pct": -59.37 + "p25_pct": -68.92, + "p75_pct": -68.92 }, "эконом": { - "mape_pct": 18.01, - "median_bias_pct": 17.17, + "mape_pct": 15.17, + "median_bias_pct": 4.28, "n": 120, - "p25_pct": 1.73, - "p75_pct": 46.96 + "p25_pct": -6.59, + "p75_pct": 26.09 }, "элит": { - "mape_pct": 38.62, - "median_bias_pct": -38.62, + "mape_pct": 33.2, + "median_bias_pct": -33.2, "n": 6, - "p25_pct": -47.98, - "p75_pct": -33.18 + "p25_pct": -42.75, + "p75_pct": -22.4 } } }, @@ -125,9 +125,9 @@ }, "range_coverage": { "overall": { - "coverage_pct": 80.14, + "coverage_pct": 81.95, "n": 277, - "n_covered": 222 + "n_covered": 227 }, "per_confidence": { "high": { @@ -136,9 +136,9 @@ "n_covered": 0 }, "low": { - "coverage_pct": 80.0, + "coverage_pct": 81.82, "n": 275, - "n_covered": 220 + "n_covered": 225 }, "medium": { "coverage_pct": 100.0, diff --git a/tradein-mvp/backend/tests/test_781_quality_gate.py b/tradein-mvp/backend/tests/test_781_quality_gate.py index feffc348..f266f0c2 100644 --- a/tradein-mvp/backend/tests/test_781_quality_gate.py +++ b/tradein-mvp/backend/tests/test_781_quality_gate.py @@ -325,7 +325,9 @@ def test_753_dedup_hash_source_id_takes_priority_over_url() -> None: # --------------------------------------------------------------------------- -def test_773_expected_sold_positive_on_anchor_only_path() -> None: +def test_773_expected_sold_positive_on_anchor_only_path( + monkeypatch: pytest.MonkeyPatch, +) -> None: """#773 quality-gate: anchor-only path (radius_analogs=[]) with ratio present -> expected_sold_price_rub > 0 (not NULL). @@ -334,6 +336,11 @@ def test_773_expected_sold_positive_on_anchor_only_path() -> None: produced a valid median_price. The fix changed the guard to `and median_price > 0`. Refs: PR #784 / commit ec84637. """ + # #2002: asserts expected_sold == headline × ratio; hold the orthogonal hedonic + # year+area correction OFF (OFF ⇒ exact legacy expected_sold). + from app.core.config import settings as _settings + + monkeypatch.setattr(_settings, "estimate_hedonic_correction_enabled", False) est = _run_qa_estimate( anchor_comps=_SB_COMPS_QG, anchor_tier="A", diff --git a/tradein-mvp/backend/tests/test_backtest_fixture_roundtrip.py b/tradein-mvp/backend/tests/test_backtest_fixture_roundtrip.py index 10adf67d..f5859892 100644 --- a/tradein-mvp/backend/tests/test_backtest_fixture_roundtrip.py +++ b/tradein-mvp/backend/tests/test_backtest_fixture_roundtrip.py @@ -227,12 +227,17 @@ def test_replay_fixture_segments_span_multiple_bands() -> None: assert len(non_empty) >= 3 -def test_replay_is_arg_insensitive_order_based() -> None: +def test_replay_is_arg_insensitive_order_based(monkeypatch: pytest.MonkeyPatch) -> None: # Order-based (FIFO) replay returns the recorded ratio REGARDLESS of the arg # value the spine actually computes — so a recorded arg that can never equal # the live median (999_999.0) still replays cleanly. This is the cross-platform # robustness contract: a Linux-captured fixture must replay off-Linux even when # libm last-ulp jitter shifts the computed median_ppm2 by an ulp. + # #2002: this asserts the recorded ratio drives the result (bias -5%). Hold the + # orthogonal hedonic correction OFF so expected_sold stays exactly headline × ratio. + from app.core.config import settings + + monkeypatch.setattr(settings, "estimate_hedonic_correction_enabled", False) fixture = _build_fixture() fixture["deals"][0]["ratio_calls"] = [[999_999.0, [0.95, "per_rooms_all"]]] metrics = bt.replay_fixture(fixture) diff --git a/tradein-mvp/backend/tests/test_estimator_expected_sold.py b/tradein-mvp/backend/tests/test_estimator_expected_sold.py index 39032254..1c3aa49a 100644 --- a/tradein-mvp/backend/tests/test_estimator_expected_sold.py +++ b/tradein-mvp/backend/tests/test_estimator_expected_sold.py @@ -26,6 +26,7 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import anyio +import pytest # Settings requires DATABASE_URL at init time. Set dummy DSN before any app import. os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") @@ -218,8 +219,13 @@ def _run_estimate(ratio_tuple: tuple[float | None, str | None]): return anyio.run(_run) -def test_expected_sold_applied_when_ratio_present() -> None: +def test_expected_sold_applied_when_ratio_present(monkeypatch: pytest.MonkeyPatch) -> None: """ratio present → expected_sold_* ≈ asking × ratio; headline UNCHANGED.""" + # #2002: asserts the ratio mechanism (expected_sold == asking × ratio). Hold the + # orthogonal hedonic year+area correction OFF (OFF ⇒ exact legacy expected_sold). + from app.services import estimator as _est + + monkeypatch.setattr(_est.settings, "estimate_hedonic_correction_enabled", False) ratio = 0.74 est = _run_estimate((ratio, "per_rooms")) @@ -341,9 +347,15 @@ def _run_estimate_anchor_only( return anyio.run(_run) -def test_expected_sold_fires_on_anchor_only_no_radius_comps() -> None: +def test_expected_sold_fires_on_anchor_only_no_radius_comps( + monkeypatch: pytest.MonkeyPatch, +) -> None: """#773: listings_clean=[], anchor sets median_price>0, ratio present → expected_sold_price > 0 (old guard `and listings_clean` blocked this).""" + # #2002: asserts expected_sold == headline × ratio; hold hedonic correction OFF. + from app.services import estimator as _est + + monkeypatch.setattr(_est.settings, "estimate_hedonic_correction_enabled", False) ratio = 0.82 est = _run_estimate_anchor_only((ratio, "per_rooms")) diff --git a/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py b/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py index c71215a5..372f7cec 100644 --- a/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py +++ b/tradein-mvp/backend/tests/test_estimator_expected_sold_clamp.py @@ -86,6 +86,9 @@ def _run_estimate( async def _run() -> Any: with ( patch("app.core.config.settings.estimate_expected_sold_le_asking", new=clamp_enabled), + # #2002: these tests assert the clamp/ratio math exactly. Hold the + # orthogonal hedonic year+area correction OFF (OFF ⇒ legacy expected_sold). + patch("app.core.config.settings.estimate_hedonic_correction_enabled", new=False), patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_geo())), patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)), patch("app.services.estimator.match_house_readonly", return_value=None), diff --git a/tradein-mvp/backend/tests/test_estimator_hedonic.py b/tradein-mvp/backend/tests/test_estimator_hedonic.py new file mode 100644 index 00000000..aae27612 --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_hedonic.py @@ -0,0 +1,191 @@ +"""Focused hedonic year+area correction tests (#2002). + +Exercises the multiplicative hedonic factor on the expected_sold POINT directly +via ``_price_from_inputs`` (hermetic — no DB, no network). Verifies: + * the factor magnitude vs the hedonic-OFF baseline (mid case); + * both clamp boundaries (≤ factor_min via huge area, ≥ factor_max via small+new); + * the neutral year term when ``target_year`` is None (≡ year 2000); + * the le_asking invariant — the corrected expected_sold never exceeds the asking + headline (median) when ``estimate_expected_sold_le_asking`` is on. + +NOTE: importing app.services.estimator pulls app.core.config.Settings which +requires DATABASE_URL. Set it BEFORE importing app modules. +""" + +from __future__ import annotations + +import math +import os + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services import estimator +from app.services.geocoder import GeocodeResult + +# ── helpers ────────────────────────────────────────────────────────────────── + + +def _geo() -> GeocodeResult: + return GeocodeResult( + lat=56.838, + lon=60.597, + full_address="ул. Тестовая, 1", + provider="nominatim", + confidence="approximate", + ) + + +def _lots(ppm2: float, n: int = 7) -> list[dict]: + """n unique-address lots all at the same ₽/m² → median_ppm2 == ppm2.""" + return [ + {"price_per_m2": ppm2, "address": f"ул. Тестовая, {i + 1}", "source": "avito"} + for i in range(n) + ] + + +def _price( + *, + area_m2: float, + target_year: int | None, + ratio: float, + ppm2: float = 100_000.0, +) -> estimator.PricingResult: + """Pure radius-only spine call (no anchor / dkp / imv) with a forced ratio.""" + + def ratio_resolver(appm2: float | None) -> tuple[float | None, str | None]: + return ratio, "per_rooms" + + return estimator._price_from_inputs( + listings=_lots(ppm2), + area_m2=area_m2, + rooms=2, + repair_state=None, + floor=5, + total_floors=10, + target_year=target_year, + analog_tier="W", + fallback_used=False, + area_widened=False, + anchor_comps=[], + anchor_tier_fetched=None, + dkp_raw=None, + imv_anchor=None, + imv_eval=None, + yandex_val_present=False, + cian_val_present=False, + ratio_resolver=ratio_resolver, + quarter_index_lookup=lambda q: None, + quarter_indexes_lookup=lambda qs: {}, + target_house_cadnum=None, + dadata_coarse=False, + geo=_geo(), + dadata_qc_geo=None, + ) + + +def _expected_factor(area_m2: float, target_year: int | None) -> float: + """Reproduce the production factor from the live settings (no hard-coding).""" + s = estimator.settings + yr = ((target_year - 2000) / 20.0) if target_year else 0.0 + raw = math.exp( + s.estimate_hedonic_b0 + + s.estimate_hedonic_year_coef * yr + + s.estimate_hedonic_larea_coef * math.log(area_m2) + ) + return max(s.estimate_hedonic_factor_min, min(s.estimate_hedonic_factor_max, raw)) + + +# ── tests ──────────────────────────────────────────────────────────────────── + + +def test_mid_case_shifts_by_expected_factor(monkeypatch: pytest.MonkeyPatch) -> None: + """year≈2010, area≈50 → expected_sold shifts by the hedonic factor vs OFF.""" + # OFF baseline (exact legacy expected_sold). + monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", False) + off = _price(area_m2=50.0, target_year=2010, ratio=0.85) + # ON. + monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True) + on = _price(area_m2=50.0, target_year=2010, ratio=0.85) + + factor = _expected_factor(50.0, 2010) + # 2010 + 50 m² → mild uplift, strictly inside the clamp band. + assert 1.0 < factor < estimator.settings.estimate_hedonic_factor_max + assert off.expected_sold_price is not None and on.expected_sold_price is not None + # ratio 0.85 × factor < 1.0 → le_asking re-clamp is a no-op here (no confound). + assert on.expected_sold_price == round(off.expected_sold_price * factor) + assert on.expected_sold_per_m2 == round(off.expected_sold_per_m2 * factor) + + +def test_factor_clamps_to_min_for_huge_area(monkeypatch: pytest.MonkeyPatch) -> None: + """Very large area → raw factor < factor_min → clamped to the floor.""" + monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", False) + off = _price(area_m2=10_000.0, target_year=None, ratio=0.85) + monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True) + on = _price(area_m2=10_000.0, target_year=None, ratio=0.85) + + factor = _expected_factor(10_000.0, None) + assert factor == estimator.settings.estimate_hedonic_factor_min + assert off.expected_sold_price is not None and on.expected_sold_price is not None + assert on.expected_sold_price == round( + off.expected_sold_price * estimator.settings.estimate_hedonic_factor_min + ) + + +def test_factor_clamps_to_max_for_small_new_lot(monkeypatch: pytest.MonkeyPatch) -> None: + """Small area + new year → raw factor > factor_max → clamped to the ceiling. + + le_asking is held OFF so the raw ceiling factor is observable on the point + (otherwise the re-clamp would cap it at the asking headline). + """ + monkeypatch.setattr(estimator.settings, "estimate_expected_sold_le_asking", False) + monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", False) + off = _price(area_m2=15.0, target_year=2025, ratio=0.85) + monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True) + on = _price(area_m2=15.0, target_year=2025, ratio=0.85) + + factor = _expected_factor(15.0, 2025) + assert factor == estimator.settings.estimate_hedonic_factor_max + assert off.expected_sold_price is not None and on.expected_sold_price is not None + assert on.expected_sold_price == round( + off.expected_sold_price * estimator.settings.estimate_hedonic_factor_max + ) + + +def test_target_year_none_is_neutral(monkeypatch: pytest.MonkeyPatch) -> None: + """target_year=None → year term is 0 → identical to year 2000 (intercept+area).""" + monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True) + none_year = _price(area_m2=50.0, target_year=None, ratio=0.85) + year_2000 = _price(area_m2=50.0, target_year=2000, ratio=0.85) + + assert none_year.expected_sold_price == year_2000.expected_sold_price + assert none_year.expected_sold_per_m2 == year_2000.expected_sold_per_m2 + assert _expected_factor(50.0, None) == _expected_factor(50.0, 2000) + + +def test_le_asking_invariant_holds_under_hedonic(monkeypatch: pytest.MonkeyPatch) -> None: + """With le_asking on, the hedonic-corrected expected_sold never exceeds asking.""" + monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True) + monkeypatch.setattr(estimator.settings, "estimate_expected_sold_le_asking", True) + # small area + new year → factor 1.30; ratio 0.95 → 0.95×1.30 ≈ 1.235 > 1 → + # uncorrected the point would exceed the asking headline; the re-clamp must bind. + res = _price(area_m2=15.0, target_year=2025, ratio=0.95) + + assert res.expected_sold_price is not None + assert res.expected_sold_price <= res.median_price + assert res.expected_sold_per_m2 is not None + assert res.expected_sold_per_m2 <= res.median_ppm2 + # The clamp binds exactly at the asking headline (proves it actually fired). + assert res.expected_sold_price == res.median_price + + +def test_le_asking_off_allows_hedonic_above_asking(monkeypatch: pytest.MonkeyPatch) -> None: + """Control: with le_asking OFF, the hedonic uplift may exceed asking (no clamp).""" + monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True) + monkeypatch.setattr(estimator.settings, "estimate_expected_sold_le_asking", False) + res = _price(area_m2=15.0, target_year=2025, ratio=0.95) + + assert res.expected_sold_price is not None + # 0.95 × 1.30 ≈ 1.235 → point is allowed above the asking headline. + assert res.expected_sold_price > res.median_price diff --git a/tradein-mvp/backend/tests/test_estimator_imv_blend.py b/tradein-mvp/backend/tests/test_estimator_imv_blend.py index 653d27e8..9d56d357 100644 --- a/tradein-mvp/backend/tests/test_estimator_imv_blend.py +++ b/tradein-mvp/backend/tests/test_estimator_imv_blend.py @@ -362,6 +362,9 @@ def _run_estimate_with_anchor( async def _run(): with ( + # #2002: these tests assert expected_sold == post-blend headline × ratio. + # Hold the orthogonal hedonic correction OFF (OFF ⇒ legacy expected_sold). + patch("app.core.config.settings.estimate_hedonic_correction_enabled", new=False), patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())), patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)), patch("app.services.estimator.match_house_readonly", return_value=None), diff --git a/tradein-mvp/backend/tests/test_estimator_price_spine.py b/tradein-mvp/backend/tests/test_estimator_price_spine.py index cebee96b..be2713bd 100644 --- a/tradein-mvp/backend/tests/test_estimator_price_spine.py +++ b/tradein-mvp/backend/tests/test_estimator_price_spine.py @@ -152,8 +152,11 @@ def _call( # ── Tests ──────────────────────────────────────────────────────────────────── -def test_radius_only_median_and_expected_sold() -> None: +def test_radius_only_median_and_expected_sold(monkeypatch: pytest.MonkeyPatch) -> None: """Pure radius path: 7 uniform lots → correct median, n_analogs, expected_sold.""" + # #2002: this asserts the ratio mechanism (expected_sold == headline × ratio). + # Hold the orthogonal hedonic correction OFF (OFF ⇒ exact legacy expected_sold). + monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", False) pr = _call(listings=_lots(100_000, n=7), ratio=0.95) assert pr.median_price == int(100_000 * 50.0) # 5_000_000 @@ -365,8 +368,13 @@ def test_corridor_soft_clamp_headline_above_cap() -> None: assert pr.dkp_corridor.high_ppm2 == 150_000 -def test_expected_sold_from_ratio_and_none_when_ratio_none() -> None: +def test_expected_sold_from_ratio_and_none_when_ratio_none( + monkeypatch: pytest.MonkeyPatch, +) -> None: """expected_sold = headline × ratio; when ratio is None, all expected_sold fields None.""" + # #2002: ratio-mechanism test — hold the orthogonal hedonic correction OFF + # so expected_sold == headline × ratio exactly (OFF ⇒ legacy behavior). + monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", False) # Case A: ratio=0.90 → expected_sold fields filled. pr_ratio = _call(listings=_lots(100_000, n=5), ratio=0.90) assert pr_ratio.asking_to_sold_ratio == 0.90 diff --git a/tradein-mvp/backend/tests/test_same_building_anchor.py b/tradein-mvp/backend/tests/test_same_building_anchor.py index 506dd9dd..c6ffce6f 100644 --- a/tradein-mvp/backend/tests/test_same_building_anchor.py +++ b/tradein-mvp/backend/tests/test_same_building_anchor.py @@ -556,10 +556,15 @@ def test_estimate_expected_sold_distinct_after_anchor() -> None: """(f) При сработавшем якоре headline = ASKING (anchor_ppm2, pre-haircut), а expected_sold = headline × per-rooms ratio → DISTINCT, строго ниже median. Single asking→sold механизм (ratio); band-haircut больше не в headline.""" + # #2002: asserts expected_sold == post-anchor headline × ratio. Hold the + # orthogonal hedonic year+area correction OFF (OFF ⇒ legacy expected_sold). + from app.core.config import settings + ratio = 0.92 - est = _run_estimate( - anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A", ratio_tuple=(ratio, "per_rooms") - ) + with patch.object(settings, "estimate_hedonic_correction_enabled", False): + est = _run_estimate( + anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A", ratio_tuple=(ratio, "per_rooms") + ) # expected_sold выведен из POST-anchor headline × ratio (не равен headline). # per_m2 берётся от внутренней float-медианы (схема отдаёт int(median_ppm2)), # поэтому сравниваем с допуском ±1 на округление float→int. @@ -567,7 +572,6 @@ def test_estimate_expected_sold_distinct_after_anchor() -> None: assert abs(est.expected_sold_per_m2 - round(est.median_price_per_m2 * ratio)) <= 1 # #1966: expected_sold range — калиброванный ~80% PI вокруг точки (point × [p10,p90] # sold/expected_sold), не asking-IQR × ratio. - from app.core.config import settings assert est.expected_sold_range_high_rub == round( est.expected_sold_price_rub * settings.estimate_pi_high_mult