All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 57s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m32s
Deploy Trade-In / deploy (push) Successful in 51s
248 lines
12 KiB
Python
248 lines
12 KiB
Python
"""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,
|
||
floor: int | None = 5,
|
||
) -> 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=floor,
|
||
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, floor: int | None = 5) -> float:
|
||
"""Reproduce the production factor from the live settings (no hard-coding).
|
||
|
||
``floor`` mirrors the production gate: the ground-floor term is added only when
|
||
``floor == 1`` (default 5 ⇒ no first-floor term, the year+area factor).
|
||
"""
|
||
s = estimator.settings
|
||
yr = ((target_year - 2000) / 20.0) if target_year else 0.0
|
||
first = s.estimate_hedonic_first_floor_coef if floor == 1 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)
|
||
+ first
|
||
)
|
||
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
|
||
|
||
|
||
def test_ground_floor_applies_extra_discount(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""floor==1 → the extra exp(-0.1248)≈0.88 discount vs floor=3 (#2002).
|
||
|
||
Same year/area/ratio; only the floor differs. The mid case sits strictly inside
|
||
the clamp band so the first-floor term is fully observable (no clamp confound).
|
||
``floor`` only feeds the (empty) same-building anchor here, so the hedonic-OFF
|
||
base expected_sold is identical for floor=1 and floor=3 — the only delta is the
|
||
ground-floor coefficient in the factor.
|
||
"""
|
||
# OFF baseline (no factor at all) — floor-independent in the radius-only spine.
|
||
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", False)
|
||
off = _price(area_m2=50.0, target_year=2010, ratio=0.85, floor=3)
|
||
# ON.
|
||
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True)
|
||
ground = _price(area_m2=50.0, target_year=2010, ratio=0.85, floor=1)
|
||
upper = _price(area_m2=50.0, target_year=2010, ratio=0.85, floor=3)
|
||
|
||
f_ground = _expected_factor(50.0, 2010, floor=1)
|
||
f_upper = _expected_factor(50.0, 2010, floor=3)
|
||
extra = math.exp(estimator.settings.estimate_hedonic_first_floor_coef)
|
||
|
||
# ground-floor multiplies the year+area factor by the extra ~0.88 discount.
|
||
assert extra == pytest.approx(0.8827, abs=1e-3)
|
||
assert f_ground == pytest.approx(f_upper * extra)
|
||
# both factors strictly inside the clamp band → the term is fully observable.
|
||
assert estimator.settings.estimate_hedonic_factor_min < f_ground < f_upper
|
||
assert f_upper < estimator.settings.estimate_hedonic_factor_max
|
||
# expected_sold tracks each factor vs the shared OFF baseline (ratio<1 → le_asking
|
||
# re-clamp is a no-op, no confound).
|
||
assert off.expected_sold_price is not None
|
||
assert ground.expected_sold_price == round(off.expected_sold_price * f_ground)
|
||
assert upper.expected_sold_price == round(off.expected_sold_price * f_upper)
|
||
assert ground.expected_sold_per_m2 == round(off.expected_sold_per_m2 * f_ground)
|
||
# the headline mechanic: a ground-floor unit sells below the upper-floor estimate.
|
||
assert upper.expected_sold_price is not None
|
||
assert ground.expected_sold_price < upper.expected_sold_price
|
||
|
||
|
||
def test_non_ground_floors_unchanged_vs_year_area_factor(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""floor in {None, 2, 3, 99} stays byte-identical to the prior 2-term factor."""
|
||
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True)
|
||
ref = _price(area_m2=50.0, target_year=2010, ratio=0.85, floor=5)
|
||
for fl in (None, 2, 3, 99):
|
||
other = _price(area_m2=50.0, target_year=2010, ratio=0.85, floor=fl)
|
||
assert other.expected_sold_price == ref.expected_sold_price
|
||
assert other.expected_sold_per_m2 == ref.expected_sold_per_m2
|