feat(tradein/estimator): hedonic year+area correction on expected_sold (#2002) #2004
11 changed files with 347 additions and 62 deletions
|
|
@ -106,6 +106,25 @@ class Settings(BaseSettings):
|
||||||
estimate_calibrated_pi_enabled: bool = True
|
estimate_calibrated_pi_enabled: bool = True
|
||||||
estimate_pi_low_mult: float = 0.649 # empirical p10 of sold/expected_sold (#1966, n=2366)
|
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)
|
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 фикса, каждый за флагом) ──────
|
# ── #1795: premium headline anti-inflation (4 фикса, каждый за флагом) ──────
|
||||||
# Диагноз: бизнес/премиум headline завышается ~2× vs медиана реальных ДКП
|
# Диагноз: бизнес/премиум headline завышается ~2× vs медиана реальных ДКП
|
||||||
# (Малышева 30 = 296k при median сделок 138k). Эконом/комфорт сходятся ±5%.
|
# (Малышева 30 = 296k при median сделок 138k). Эконом/комфорт сходятся ±5%.
|
||||||
|
|
|
||||||
|
|
@ -2507,6 +2507,39 @@ def _price_from_inputs(
|
||||||
effective_ratio = 1.0
|
effective_ratio = 1.0
|
||||||
expected_sold_per_m2 = round(median_ppm2 * effective_ratio)
|
expected_sold_per_m2 = round(median_ppm2 * effective_ratio)
|
||||||
expected_sold_price = round(median_price * 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:
|
if settings.estimate_calibrated_pi_enabled and expected_sold_price:
|
||||||
# #1966: калиброванный ~80% prediction interval вокруг ТОЧКИ expected_sold.
|
# #1966: калиброванный ~80% prediction interval вокруг ТОЧКИ expected_sold.
|
||||||
# Эмпирически отношение actual_sold_ppm2 / expected_sold_per_m2 по 2366
|
# Эмпирически отношение actual_sold_ppm2 / expected_sold_per_m2 по 2366
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,14 @@
|
||||||
"n_covered": 0
|
"n_covered": 0
|
||||||
},
|
},
|
||||||
"low": {
|
"low": {
|
||||||
"coverage_pct": 80.0,
|
"coverage_pct": 81.82,
|
||||||
"mape_pct": 18.63,
|
"mape_pct": 13.23,
|
||||||
"n": 275,
|
"n": 275,
|
||||||
"n_covered": 220
|
"n_covered": 225
|
||||||
},
|
},
|
||||||
"medium": {
|
"medium": {
|
||||||
"coverage_pct": 100.0,
|
"coverage_pct": 100.0,
|
||||||
"mape_pct": 19.18,
|
"mape_pct": 14.64,
|
||||||
"n": 2,
|
"n": 2,
|
||||||
"n_covered": 2
|
"n_covered": 2
|
||||||
}
|
}
|
||||||
|
|
@ -26,95 +26,95 @@
|
||||||
],
|
],
|
||||||
"expected_sold": {
|
"expected_sold": {
|
||||||
"overall": {
|
"overall": {
|
||||||
"mape_pct": 18.63,
|
"mape_pct": 13.23,
|
||||||
"median_bias_pct": -1.2,
|
"median_bias_pct": -2.87,
|
||||||
"n": 277,
|
"n": 277,
|
||||||
"n_no_analogs": 0,
|
"n_no_analogs": 0,
|
||||||
"p25_pct": -16.25,
|
"p25_pct": -14.43,
|
||||||
"p75_pct": 20.26
|
"p75_pct": 12.51
|
||||||
},
|
},
|
||||||
"per_rooms": {
|
"per_rooms": {
|
||||||
"0": {
|
"0": {
|
||||||
"label": "студия",
|
"label": "студия",
|
||||||
"mape_pct": 27.97,
|
"mape_pct": 19.38,
|
||||||
"median_bias_pct": 27.56,
|
"median_bias_pct": 18.1,
|
||||||
"n": 37,
|
"n": 37,
|
||||||
"n_no_analogs": 0,
|
"n_no_analogs": 0,
|
||||||
"p25_pct": -8.97,
|
"p25_pct": 1.66,
|
||||||
"p75_pct": 38.45
|
"p75_pct": 33.53
|
||||||
},
|
},
|
||||||
"1": {
|
"1": {
|
||||||
"label": "1к",
|
"label": "1к",
|
||||||
"mape_pct": 19.55,
|
"mape_pct": 10.97,
|
||||||
"median_bias_pct": -8.08,
|
"median_bias_pct": -3.47,
|
||||||
"n": 93,
|
"n": 93,
|
||||||
"n_no_analogs": 0,
|
"n_no_analogs": 0,
|
||||||
"p25_pct": -21.27,
|
"p25_pct": -12.48,
|
||||||
"p75_pct": 13.3
|
"p75_pct": 6.98
|
||||||
},
|
},
|
||||||
"2": {
|
"2": {
|
||||||
"label": "2к",
|
"label": "2к",
|
||||||
"mape_pct": 16.22,
|
"mape_pct": 17.39,
|
||||||
"median_bias_pct": -8.82,
|
"median_bias_pct": -11.71,
|
||||||
"n": 74,
|
"n": 74,
|
||||||
"n_no_analogs": 0,
|
"n_no_analogs": 0,
|
||||||
"p25_pct": -21.01,
|
"p25_pct": -23.37,
|
||||||
"p75_pct": 6.13
|
"p75_pct": -0.36
|
||||||
},
|
},
|
||||||
"3": {
|
"3": {
|
||||||
"label": "3к",
|
"label": "3к",
|
||||||
"mape_pct": 10.52,
|
"mape_pct": 9.77,
|
||||||
"median_bias_pct": 4.08,
|
"median_bias_pct": -3.08,
|
||||||
"n": 43,
|
"n": 43,
|
||||||
"n_no_analogs": 0,
|
"n_no_analogs": 0,
|
||||||
"p25_pct": -6.54,
|
"p25_pct": -10.26,
|
||||||
"p75_pct": 11.96
|
"p75_pct": 5.77
|
||||||
},
|
},
|
||||||
"4": {
|
"4": {
|
||||||
"label": "4+",
|
"label": "4+",
|
||||||
"mape_pct": 23.53,
|
"mape_pct": 20.27,
|
||||||
"median_bias_pct": 14.35,
|
"median_bias_pct": 8.54,
|
||||||
"n": 30,
|
"n": 30,
|
||||||
"n_no_analogs": 0,
|
"n_no_analogs": 0,
|
||||||
"p25_pct": -0.72,
|
"p25_pct": -3.78,
|
||||||
"p75_pct": 35.27
|
"p75_pct": 23.54
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"per_segment": {
|
"per_segment": {
|
||||||
"бизнес": {
|
"бизнес": {
|
||||||
"mape_pct": 22.14,
|
"mape_pct": 12.87,
|
||||||
"median_bias_pct": -21.49,
|
"median_bias_pct": -10.19,
|
||||||
"n": 46,
|
"n": 46,
|
||||||
"p25_pct": -28.22,
|
"p25_pct": -22.6,
|
||||||
"p75_pct": -9.82
|
"p75_pct": -1.31
|
||||||
},
|
},
|
||||||
"комфорт": {
|
"комфорт": {
|
||||||
"mape_pct": 16.74,
|
"mape_pct": 11.61,
|
||||||
"median_bias_pct": -8.05,
|
"median_bias_pct": -4.07,
|
||||||
"n": 104,
|
"n": 104,
|
||||||
"p25_pct": -20.63,
|
"p25_pct": -15.64,
|
||||||
"p75_pct": 7.55
|
"p75_pct": 7.38
|
||||||
},
|
},
|
||||||
"премиум": {
|
"премиум": {
|
||||||
"mape_pct": 59.37,
|
"mape_pct": 68.92,
|
||||||
"median_bias_pct": -59.37,
|
"median_bias_pct": -68.92,
|
||||||
"n": 1,
|
"n": 1,
|
||||||
"p25_pct": -59.37,
|
"p25_pct": -68.92,
|
||||||
"p75_pct": -59.37
|
"p75_pct": -68.92
|
||||||
},
|
},
|
||||||
"эконом": {
|
"эконом": {
|
||||||
"mape_pct": 18.01,
|
"mape_pct": 15.17,
|
||||||
"median_bias_pct": 17.17,
|
"median_bias_pct": 4.28,
|
||||||
"n": 120,
|
"n": 120,
|
||||||
"p25_pct": 1.73,
|
"p25_pct": -6.59,
|
||||||
"p75_pct": 46.96
|
"p75_pct": 26.09
|
||||||
},
|
},
|
||||||
"элит": {
|
"элит": {
|
||||||
"mape_pct": 38.62,
|
"mape_pct": 33.2,
|
||||||
"median_bias_pct": -38.62,
|
"median_bias_pct": -33.2,
|
||||||
"n": 6,
|
"n": 6,
|
||||||
"p25_pct": -47.98,
|
"p25_pct": -42.75,
|
||||||
"p75_pct": -33.18
|
"p75_pct": -22.4
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -125,9 +125,9 @@
|
||||||
},
|
},
|
||||||
"range_coverage": {
|
"range_coverage": {
|
||||||
"overall": {
|
"overall": {
|
||||||
"coverage_pct": 80.14,
|
"coverage_pct": 81.95,
|
||||||
"n": 277,
|
"n": 277,
|
||||||
"n_covered": 222
|
"n_covered": 227
|
||||||
},
|
},
|
||||||
"per_confidence": {
|
"per_confidence": {
|
||||||
"high": {
|
"high": {
|
||||||
|
|
@ -136,9 +136,9 @@
|
||||||
"n_covered": 0
|
"n_covered": 0
|
||||||
},
|
},
|
||||||
"low": {
|
"low": {
|
||||||
"coverage_pct": 80.0,
|
"coverage_pct": 81.82,
|
||||||
"n": 275,
|
"n": 275,
|
||||||
"n_covered": 220
|
"n_covered": 225
|
||||||
},
|
},
|
||||||
"medium": {
|
"medium": {
|
||||||
"coverage_pct": 100.0,
|
"coverage_pct": 100.0,
|
||||||
|
|
|
||||||
|
|
@ -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
|
"""#773 quality-gate: anchor-only path (radius_analogs=[]) with ratio present
|
||||||
-> expected_sold_price_rub > 0 (not NULL).
|
-> 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`.
|
produced a valid median_price. The fix changed the guard to `and median_price > 0`.
|
||||||
Refs: PR #784 / commit ec84637.
|
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(
|
est = _run_qa_estimate(
|
||||||
anchor_comps=_SB_COMPS_QG,
|
anchor_comps=_SB_COMPS_QG,
|
||||||
anchor_tier="A",
|
anchor_tier="A",
|
||||||
|
|
|
||||||
|
|
@ -227,12 +227,17 @@ def test_replay_fixture_segments_span_multiple_bands() -> None:
|
||||||
assert len(non_empty) >= 3
|
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
|
# 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
|
# 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
|
# 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
|
# robustness contract: a Linux-captured fixture must replay off-Linux even when
|
||||||
# libm last-ulp jitter shifts the computed median_ppm2 by an ulp.
|
# 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 = _build_fixture()
|
||||||
fixture["deals"][0]["ratio_calls"] = [[999_999.0, [0.95, "per_rooms_all"]]]
|
fixture["deals"][0]["ratio_calls"] = [[999_999.0, [0.95, "per_rooms_all"]]]
|
||||||
metrics = bt.replay_fixture(fixture)
|
metrics = bt.replay_fixture(fixture)
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
|
import pytest
|
||||||
|
|
||||||
# Settings requires DATABASE_URL at init time. Set dummy DSN before any app import.
|
# 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")
|
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)
|
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."""
|
"""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
|
ratio = 0.74
|
||||||
est = _run_estimate((ratio, "per_rooms"))
|
est = _run_estimate((ratio, "per_rooms"))
|
||||||
|
|
||||||
|
|
@ -341,9 +347,15 @@ def _run_estimate_anchor_only(
|
||||||
return anyio.run(_run)
|
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
|
"""#773: listings_clean=[], anchor sets median_price>0, ratio present
|
||||||
→ expected_sold_price > 0 (old guard `and listings_clean` blocked this)."""
|
→ 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
|
ratio = 0.82
|
||||||
est = _run_estimate_anchor_only((ratio, "per_rooms"))
|
est = _run_estimate_anchor_only((ratio, "per_rooms"))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,9 @@ def _run_estimate(
|
||||||
async def _run() -> Any:
|
async def _run() -> Any:
|
||||||
with (
|
with (
|
||||||
patch("app.core.config.settings.estimate_expected_sold_le_asking", new=clamp_enabled),
|
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.geocode", new=AsyncMock(return_value=_make_geo())),
|
||||||
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
|
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
|
||||||
patch("app.services.estimator.match_house_readonly", return_value=None),
|
patch("app.services.estimator.match_house_readonly", return_value=None),
|
||||||
|
|
|
||||||
191
tradein-mvp/backend/tests/test_estimator_hedonic.py
Normal file
191
tradein-mvp/backend/tests/test_estimator_hedonic.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -362,6 +362,9 @@ def _run_estimate_with_anchor(
|
||||||
|
|
||||||
async def _run():
|
async def _run():
|
||||||
with (
|
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.geocode", new=AsyncMock(return_value=_make_fake_geo())),
|
||||||
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
|
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
|
||||||
patch("app.services.estimator.match_house_readonly", return_value=None),
|
patch("app.services.estimator.match_house_readonly", return_value=None),
|
||||||
|
|
|
||||||
|
|
@ -152,8 +152,11 @@ def _call(
|
||||||
# ── Tests ────────────────────────────────────────────────────────────────────
|
# ── 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."""
|
"""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)
|
pr = _call(listings=_lots(100_000, n=7), ratio=0.95)
|
||||||
|
|
||||||
assert pr.median_price == int(100_000 * 50.0) # 5_000_000
|
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
|
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."""
|
"""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.
|
# Case A: ratio=0.90 → expected_sold fields filled.
|
||||||
pr_ratio = _call(listings=_lots(100_000, n=5), ratio=0.90)
|
pr_ratio = _call(listings=_lots(100_000, n=5), ratio=0.90)
|
||||||
assert pr_ratio.asking_to_sold_ratio == 0.90
|
assert pr_ratio.asking_to_sold_ratio == 0.90
|
||||||
|
|
|
||||||
|
|
@ -556,10 +556,15 @@ def test_estimate_expected_sold_distinct_after_anchor() -> None:
|
||||||
"""(f) При сработавшем якоре headline = ASKING (anchor_ppm2, pre-haircut), а
|
"""(f) При сработавшем якоре headline = ASKING (anchor_ppm2, pre-haircut), а
|
||||||
expected_sold = headline × per-rooms ratio → DISTINCT, строго ниже median.
|
expected_sold = headline × per-rooms ratio → DISTINCT, строго ниже median.
|
||||||
Single asking→sold механизм (ratio); band-haircut больше не в headline."""
|
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
|
ratio = 0.92
|
||||||
est = _run_estimate(
|
with patch.object(settings, "estimate_hedonic_correction_enabled", False):
|
||||||
anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A", ratio_tuple=(ratio, "per_rooms")
|
est = _run_estimate(
|
||||||
)
|
anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A", ratio_tuple=(ratio, "per_rooms")
|
||||||
|
)
|
||||||
# expected_sold выведен из POST-anchor headline × ratio (не равен headline).
|
# expected_sold выведен из POST-anchor headline × ratio (не равен headline).
|
||||||
# per_m2 берётся от внутренней float-медианы (схема отдаёт int(median_ppm2)),
|
# per_m2 берётся от внутренней float-медианы (схема отдаёт int(median_ppm2)),
|
||||||
# поэтому сравниваем с допуском ±1 на округление float→int.
|
# поэтому сравниваем с допуском ±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
|
assert abs(est.expected_sold_per_m2 - round(est.median_price_per_m2 * ratio)) <= 1
|
||||||
# #1966: expected_sold range — калиброванный ~80% PI вокруг точки (point × [p10,p90]
|
# #1966: expected_sold range — калиброванный ~80% PI вокруг точки (point × [p10,p90]
|
||||||
# sold/expected_sold), не asking-IQR × ratio.
|
# sold/expected_sold), не asking-IQR × ratio.
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
assert est.expected_sold_range_high_rub == round(
|
assert est.expected_sold_range_high_rub == round(
|
||||||
est.expected_sold_price_rub * settings.estimate_pi_high_mult
|
est.expected_sold_price_rub * settings.estimate_pi_high_mult
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue