gendesign/tradein-mvp/backend/tests/test_same_building_anchor.py
lekss361 bd7f40428a feat(tradein): same-building anchor estimator (#651/#652 v2) behind flag
Replaces radius-median ppm2 with a same-building anchor (Tier A: normalized
street+house comps; Tier C: <=500m segment-matched; Tier D: existing radius
fallback). Similarity-weighted mean + guarded premium uplift + ppm2-banded
asking->sold haircut + hard guardrail + tightened FSD range. Fixes ~2.5x
premium underestimate / 15-25% comfort dilution. Address normalizer handles
e->e, Tkacheva->Tkachey alias, base-house-number across corpus letters.
Behind estimate_same_building_anchor_enabled (default on); OFF = prior behavior.
Validated offline (55 golden: coverage 53->95%, premium medAPE 53->18%) + 4 live cases.
19 new tests; full suite 1106 passed; ruff clean.

Refs #651 #652
2026-05-29 23:51:34 +03:00

389 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Unit tests for #651/#652 v2 — same-building anchor (validated, 55 golden cases).
Покрываем чистые helpers (без БД): нормализатор адреса, свёртку комплов в anchor,
hard guardrail; и full estimate path с замоканным `_fetch_anchor_comps`:
(a) premium lift (Хохрякова 48: 399k/472k/684k → est ~550k, real 684k в range),
(b) economy NO overshoot (guardrail не раздувает),
(c) address alias («Ткачёва 13» → «Ткачей 13»),
(d) base-house-number match через corpus-литеры,
(e) флаг OFF ⇒ неизменный радиусный результат,
(f) expected_sold консистентен после якоря (no double-discount).
"""
import os
from datetime import UTC, datetime
from typing import Any
# Settings требует DATABASE_URL при инициализации (fail-fast, C-3).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from unittest.mock import AsyncMock, MagicMock, patch
import anyio
from app.services.estimator import (
_compute_same_building_anchor,
_normalize_building_key,
)
# ── Address normalizer ──────────────────────────────────────────────────────
def test_normalize_alias_tkacheva_to_tkachei() -> None:
"""golden «Ткачёва 13» нормализуется к БД-улице «ткачей» + дом 13 (ё→е + alias)."""
street, base, letter = _normalize_building_key("Екатеринбург, ул. Ткачёва, 13")
assert street == "ткачей"
assert base == 13
assert letter is None
def test_normalize_base_house_across_corpus_letter() -> None:
"""«8 Марта 204Г» → street «8 марта», base 204, letter 'г' (литера сохранена)."""
street, base, letter = _normalize_building_key("Екатеринбург, улица 8 Марта, 204Г")
assert street == "8 марта"
assert base == 204
assert letter == "г"
def test_normalize_slash_corpus_collapses_to_base() -> None:
"""«4/2», «9/1» → base 4/9, корпус-slash отброшен (тот же дом)."""
_, base1, letter1 = _normalize_building_key("Екатеринбург, ул. Мраморская, 4/2")
assert base1 == 4
assert letter1 is None
_, base2, _ = _normalize_building_key("Екатеринбург, Олимпийская набережная, 9/1")
assert base2 == 9
def test_normalize_empty_address() -> None:
assert _normalize_building_key(None) == (None, None, None)
assert _normalize_building_key("") == (None, None, None)
# ── Pure anchor compute ─────────────────────────────────────────────────────
def _comp(ppm2: int, area: float | None = None, rooms: int | None = None) -> dict[str, Any]:
return {"price_per_m2": ppm2, "area_m2": area, "rooms": rooms}
def test_anchor_premium_lift_hohryakova48() -> None:
"""Зеркало Хохрякова 48: комплы 399k/472k/684k, target 4к/146 → est_ppm² поднят,
реал 684k попадает в диапазон. Радиусная медиана сильно ниже — якорь её заменяет."""
comps = [
_comp(399_478, area=153.2, rooms=3),
_comp(472_298, area=110.1, rooms=3),
_comp(683_995, area=146.2, rooms=4),
]
res = _compute_same_building_anchor(
comps,
area_target=146.2,
rooms_target=4,
tier="A",
sigma=0.18,
rooms_boost=1.6,
)
assert res is not None
# rooms-boost на флагман-компл (rooms=4 == target) + area-вес тянут anchor к ~566k
# (sim дал ~552k) — заметно выше радиусной медианы (~210k), что и есть фикс.
assert res["anchor_ppm2"] >= 470_000
assert 520_000 <= res["anchor_sold_ppm2"] <= 600_000
# реал 684k должен попасть в диапазон point ± k·fsd (range_high ≥ real×0.95).
point_ppm2 = res["anchor_sold_ppm2"]
half = 1.65 * res["fsd"]
assert point_ppm2 * (1.0 + half) >= 683_995 * 0.95
def test_anchor_olimp13_flagship_area_matched_no_uplift_needed() -> None:
"""Олимп 13 пентхаус: флагман 996k сам area+rooms-matched с target 207.9 →
weighted mean уже ≈996k (реал 996k), uplift не нужен (был бы избыточен)."""
comps = [
_comp(373_444, area=96.4, rooms=3),
_comp(468_750, area=96.0, rooms=2),
_comp(995_671, area=208.0, rooms=3),
]
res = _compute_same_building_anchor(
comps, area_target=207.9, rooms_target=3, tier="A", sigma=0.18, rooms_boost=1.6
)
assert res is not None
# Флагман доминирует вес → anchor ≈ 996k, реал 996k подтверждён.
assert res["anchor_ppm2"] >= 900_000
assert res["used_uplift"] is False # mean уже ≥ p70 → uplift избыточен
def test_anchor_premium_uplift_when_mean_dilutes() -> None:
"""premium uplift срабатывает когда премиум-компл НЕ area-similar (mean занижает),
а target — топ-юнит дома (≥p66) И Tier A → берём верхний квантиль p70."""
comps = [
_comp(900_000, area=60.0, rooms=1), # дорогой, но малая площадь → area-вес низкий
_comp(300_000, area=200.0, rooms=3),
_comp(320_000, area=210.0, rooms=3),
]
res = _compute_same_building_anchor(
comps, area_target=205.0, rooms_target=3, tier="A", sigma=0.18, rooms_boost=1.6
)
assert res is not None
assert res["used_uplift"] is True
assert res["anchor_ppm2"] >= 500_000 # подтянут к p70, не к дил. mean ~310k
def test_anchor_uplift_skipped_off_tier_a() -> None:
"""uplift только Tier A — на Tier C (micro-radius) не применяется (комплы не из дома)."""
comps = [
_comp(900_000, area=60.0, rooms=1),
_comp(300_000, area=200.0, rooms=3),
_comp(320_000, area=210.0, rooms=3),
]
res = _compute_same_building_anchor(
comps, area_target=205.0, rooms_target=3, tier="C", sigma=0.18, rooms_boost=1.6
)
assert res is not None
assert res["used_uplift"] is False
def test_anchor_economy_no_overshoot() -> None:
"""Эконом (Ильича 28): дешёвые комплы ~112k → guardrail не раздувает, anchor ~112k."""
comps = [_comp(112_500, area=64.0, rooms=3), _comp(112_500, area=63.0, rooms=3)]
res = _compute_same_building_anchor(
comps,
area_target=63.0,
rooms_target=3,
tier="A",
sigma=0.18,
rooms_boost=1.6,
)
assert res is not None
# mean ~112.5k; haircut эконом-band 7% → sold ~104.6k. НЕ раздут вверх.
assert 100_000 <= res["anchor_sold_ppm2"] <= 113_000
# uplift не сработал (все комплы одной площади, p66≈target, p70≈mean → no lift вверх).
assert res["anchor_sold_ppm2"] < res["anchor_ppm2"] + 1 # haircut только вниз
def test_anchor_guardrail_floor_on_min_comp() -> None:
"""Guardrail-floor (применяется у caller'а) = min(comp)×(1tol); helper отдаёт comp_min."""
comps = [_comp(400_000, area=100.0, rooms=2), _comp(600_000, area=100.0, rooms=2)]
res = _compute_same_building_anchor(
comps, area_target=100.0, rooms_target=2, tier="A", sigma=0.18, rooms_boost=1.6
)
assert res is not None
assert res["comp_min_ppm2"] == 400_000
def test_anchor_none_when_no_comps() -> None:
assert (
_compute_same_building_anchor(
[], area_target=50.0, rooms_target=1, tier="A", sigma=0.18, rooms_boost=1.6
)
is None
)
def test_anchor_null_area_neutral_weight() -> None:
"""Комплы без площади (Yandex) судятся только по комнатам — area-вес 1.0, не падает."""
comps = [_comp(300_000, area=None, rooms=2), _comp(320_000, area=None, rooms=2)]
res = _compute_same_building_anchor(
comps, area_target=70.0, rooms_target=2, tier="A", sigma=0.18, rooms_boost=1.6
)
assert res is not None
# rooms совпали у обоих → equal weights → anchor ≈ mean 310k, haircut mid 5%.
assert abs(res["anchor_ppm2"] - 310_000) < 1_000
# ── Full estimate path (mocked I/O) ─────────────────────────────────────────
def _make_listing(*, price_per_m2: float, area_m2: float = 60.0) -> dict[str, Any]:
return {
"source": "cian",
"source_url": "https://cian.ru/offer/1",
"address": "ЕКБ, ул. Хохрякова, 48",
"lat": 56.830,
"lon": 60.592,
"rooms": 4,
"area_m2": area_m2,
"floor": 5,
"total_floors": 14,
"price_rub": price_per_m2 * area_m2,
"price_per_m2": price_per_m2,
"listing_date": datetime(2026, 5, 1),
"days_on_market": 10,
"photo_urls": [],
"scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
"distance_m": 0.0,
"relevance_score": 0.0,
"listing_segment": "premium",
}
# Радиусные аналоги — НИЗКИЕ (массовая застройка рядом размывает премиум).
_RADIUS_ANALOGS: list[dict[str, Any]] = [
_make_listing(price_per_m2=200_000.0),
_make_listing(price_per_m2=210_000.0),
_make_listing(price_per_m2=220_000.0),
]
# Same-building комплы Хохрякова 48 (флагман 684k внутри).
_SB_COMPS_PREMIUM: list[dict[str, Any]] = [
{"price_per_m2": 399_478, "area_m2": 153.2, "rooms": 3},
{"price_per_m2": 472_298, "area_m2": 110.1, "rooms": 3},
{"price_per_m2": 683_995, "area_m2": 146.2, "rooms": 4},
]
def _make_fake_geo():
from app.services.geocoder import GeocodeResult
return GeocodeResult(
lat=56.830,
lon=60.592,
full_address="Свердловская обл., Екатеринбург, ул. Хохрякова, 48",
provider="nominatim",
)
def _make_payload(area: float = 146.2, rooms: int = 4):
from app.schemas.trade_in import TradeInEstimateInput
return TradeInEstimateInput(
address="Екатеринбург, ул. Хохрякова, 48",
area_m2=area,
rooms=rooms,
floor=5,
total_floors=14,
)
def _run_estimate(
*,
anchor_comps: list[dict[str, Any]],
anchor_tier: str | None,
flag_enabled: bool = True,
ratio_tuple: tuple[float | None, str | None] = (0.92, "per_rooms"),
payload=None,
):
"""estimate_quality со всеми I/O застабленными; _fetch_anchor_comps форсирован."""
from app.core.config import settings
from app.services.estimator import estimate_quality
db = MagicMock()
pl = payload or _make_payload()
async def _run():
with (
patch.object(settings, "estimate_same_building_anchor_enabled", flag_enabled),
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),
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
patch(
"app.services.estimator._fetch_analogs",
return_value=(list(_RADIUS_ANALOGS), False, "W"),
),
patch("app.services.estimator._fetch_deals", return_value=[]),
patch("app.services.estimator._fetch_dkp_corridor", return_value=None),
patch(
"app.services.estimator._fetch_anchor_comps",
return_value=(list(anchor_comps), anchor_tier),
),
patch("app.services.estimator._fetch_house_imv_anchor", return_value=None),
patch(
"app.services.estimator._get_or_fetch_imv_cached", new=AsyncMock(return_value=None)
),
patch(
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator.estimate_via_cian_valuation",
new=AsyncMock(return_value=None),
),
patch("app.services.estimator._get_asking_sold_ratio", return_value=ratio_tuple),
):
return await estimate_quality(pl, db)
return anyio.run(_run)
def test_estimate_premium_lift_real_in_range() -> None:
"""(a) Хохрякова 48: радиус ~210k размывает → якорь поднимает, реал 684k в range."""
est = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A")
radius_median = int(210_000 * 146.2) # старый радиусный headline
# Якорь заменил радиусную медиану и поднял её.
assert est.median_price_rub > radius_median
assert est.median_price_per_m2 >= 450_000
# Реальная цена флагмана 684k×146.2 ≈ 100М должна попасть в [range_low, range_high].
real = int(683_995 * 146.2)
assert est.range_low_rub <= est.median_price_rub <= est.range_high_rub
assert est.range_high_rub >= int(real * 0.9)
def test_estimate_economy_no_regression() -> None:
"""(b) Эконом-комплы ~112k → guardrail не раздувает, headline ≈ комплов."""
eco_comps = [
{"price_per_m2": 112_500, "area_m2": 64.0, "rooms": 3},
{"price_per_m2": 112_500, "area_m2": 63.0, "rooms": 3},
]
est = _run_estimate(
anchor_comps=eco_comps,
anchor_tier="A",
payload=_make_payload(area=63.0, rooms=3),
)
# ~112.5k × haircut 7% × area; НЕ раздут вверх (overshoot-guard).
assert est.median_price_per_m2 <= 113_000
assert est.median_price_per_m2 >= 100_000
def test_estimate_flag_off_unchanged_radius_result() -> None:
"""(e) Флаг OFF ⇒ headline = радиусная медиана (210k×146.2), якорь не трогает."""
est_off = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A", flag_enabled=False)
# Радиусная медиана из _RADIUS_ANALOGS = median(200,210,220)=210k.
assert est_off.median_price_per_m2 == 210_000
assert est_off.median_price_rub == int(210_000 * 146.2)
def test_estimate_expected_sold_consistency_after_anchor() -> None:
"""(f) При сработавшем якоре expected_sold == headline (haircut уже внутри —
no double-discount per-rooms ratio'м)."""
est = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A")
assert est.expected_sold_price_rub == est.median_price_rub
assert est.expected_sold_per_m2 == est.median_price_per_m2
assert est.expected_sold_range_high_rub == est.range_high_rub
assert est.expected_sold_range_low_rub == est.range_low_rub
def test_estimate_tier_d_fallback_keeps_radius() -> None:
"""anchor_tier=None (Tier D) → headline остаётся радиусной медианой (210k)."""
est = _run_estimate(anchor_comps=[], anchor_tier=None)
assert est.median_price_per_m2 == 210_000
def test_estimate_range_covers_same_building_comp_spread() -> None:
"""range_high покрывает RAW max same-building компла (видовой/топ-юнит дома не
вылетает за диапазон — зеркало 8 Марта 204Г view-кейса)."""
# comp max 255_459 ppm² — самый дорогой лот в доме (видовой). target — рядовой.
view_comps = [
{"price_per_m2": 124_309, "area_m2": 54.3, "rooms": 2},
{"price_per_m2": 200_000, "area_m2": 63.0, "rooms": 2},
{"price_per_m2": 255_459, "area_m2": 34.8, "rooms": 1},
]
est = _run_estimate(
anchor_comps=view_comps,
anchor_tier="A",
payload=_make_payload(area=63.4, rooms=2),
)
# range_high покрывает comp max в asking-пространстве (255_459 × area).
assert est.range_high_rub >= int(255_459 * 63.4)
def test_anchor_exposes_comp_max() -> None:
"""anchor dict отдаёт comp_max_ppm2 (нужен caller'у для spread-coverage)."""
res = _compute_same_building_anchor(
[_comp(300_000, area=60.0, rooms=2), _comp(500_000, area=60.0, rooms=2)],
area_target=60.0,
rooms_target=2,
tier="A",
sigma=0.18,
rooms_boost=1.6,
)
assert res is not None
assert res["comp_max_ppm2"] == 500_000
assert res["comp_min_ppm2"] == 300_000