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
389 lines
17 KiB
Python
389 lines
17 KiB
Python
"""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)×(1−tol); 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
|