All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 45s
Deploy Trade-In / build-backend (push) Successful in 58s
Deploy Trade-In / deploy (push) Successful in 47s
1191 lines
54 KiB
Python
1191 lines
54 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) headline = ASKING после якоря, expected_sold = headline × ratio (DISTINCT).
|
||
|
||
#755 safeguards: min_comps 2→4, confidence cap n<5→medium, MAD-clip.
|
||
Чистые unit-тесты anchor-математики используют min_comps=1 (bypass threshold —
|
||
threshold тестируется отдельно); full-path тесты используют ≥4 комплов.
|
||
"""
|
||
|
||
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
|
||
import pytest
|
||
|
||
from app.services import estimator
|
||
from app.services.estimator import (
|
||
_compute_same_building_anchor,
|
||
_geocode_is_coarse,
|
||
_mad_clip,
|
||
_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)
|
||
|
||
|
||
# ── #677 hardening: real prod address-format variants → same key ─────────────
|
||
|
||
|
||
def test_normalize_tkachei13_all_db_variants_same_key() -> None:
|
||
"""Все prod-форматы «Ткачей 13» (запятая/«д.»/город/·-хвост/р-н/мкр) → ('ткачей', 13)."""
|
||
variants = [
|
||
"Екатеринбург, ул. Ткачёва, 13", # golden alias + ё
|
||
"Екатеринбург, улица Ткачей, 13",
|
||
"ул. Ткачей,13", # no space after comma
|
||
"ул. Ткачей,д. 13", # «д.» variant
|
||
"улица Ткачей, 13 · р-н Октябрьский", # ·-tail district
|
||
"р-н Октябрьский, мкр. Парковый, улица Ткачей, 13", # district + mkr prefix
|
||
]
|
||
for v in variants:
|
||
assert _normalize_building_key(v) == ("ткачей", 13, None), v
|
||
|
||
|
||
def test_normalize_house_13_not_130_or_131() -> None:
|
||
"""Дом 13 не должен совпасть с соседями 130/131 (разные base_no)."""
|
||
assert _normalize_building_key("улица Ткачей, 13")[1] == 13
|
||
assert _normalize_building_key("улица Ткачей, 130")[1] == 130
|
||
assert _normalize_building_key("улица Ткачей, 131")[1] == 131
|
||
|
||
|
||
def test_normalize_naberezhnaya_type_word_either_side() -> None:
|
||
"""Type-слово до ИЛИ после имени → один street_core «олимпийская»; наб.↔набережная."""
|
||
keys = {
|
||
_normalize_building_key("Олимпийская наб.,13"),
|
||
_normalize_building_key("набережная Олимпийская, 13"),
|
||
_normalize_building_key("Екатеринбург, Олимпийская набережная, 13"),
|
||
_normalize_building_key("р-н Центр, набережная Олимпийская, 13"),
|
||
}
|
||
assert keys == {("олимпийская", 13, None)}
|
||
|
||
|
||
def test_normalize_borisa_eltsina_multiword_street() -> None:
|
||
"""Многословное имя «Бориса Ельцина» сохраняется целиком; «д. 6»/город/·-хвост → 6."""
|
||
for v in (
|
||
"ул. Бориса Ельцина,д. 6",
|
||
"ул. Бориса Ельцина,6",
|
||
"Екатеринбург, улица Бориса Ельцина, 6",
|
||
"Бориса Ельцина, 6",
|
||
"улица Бориса Ельцина, 6 · р-н Центр",
|
||
):
|
||
assert _normalize_building_key(v) == ("бориса ельцина", 6, None), v
|
||
|
||
|
||
def test_normalize_sakko_multiword_with_conjunction() -> None:
|
||
"""«Сакко и Ванцетти» (с союзом) → стабильный core; 99 ≠ 100/105к1 (разные дома)."""
|
||
assert _normalize_building_key("ул. Сакко и Ванцетти,99") == ("сакко и ванцетти", 99, None)
|
||
assert _normalize_building_key("р-н Центр, улица Сакко и Ванцетти, 99") == (
|
||
"сакко и ванцетти",
|
||
99,
|
||
None,
|
||
)
|
||
assert _normalize_building_key("ул. Сакко и Ванцетти,100")[1] == 100 # separate building
|
||
assert _normalize_building_key("р-н Центр, улица Сакко и Ванцетти, 105к1") == (
|
||
"сакко и ванцетти",
|
||
105, # «105к1» корпус схлопнут к base
|
||
None,
|
||
)
|
||
|
||
|
||
def test_normalize_fevralskoy_revolyutsii_no_space_house() -> None:
|
||
"""«ул. Февральской Революции,21» (genitive multiword, no-space house) → core+21."""
|
||
assert _normalize_building_key("ул. Февральской Революции,21") == (
|
||
"февральской революции",
|
||
21,
|
||
None,
|
||
)
|
||
|
||
|
||
def test_normalize_8_marta_numeric_street_prefix() -> None:
|
||
"""Числовой префикс имени («8 Марта») НЕ съедается как номер дома — номер в хвосте."""
|
||
assert _normalize_building_key("Екатеринбург, улица 8 Марта, 204Г") == ("8 марта", 204, "г")
|
||
# без литеры
|
||
assert _normalize_building_key("улица 8 Марта, 50") == ("8 марта", 50, None)
|
||
|
||
|
||
def test_normalize_corpus_letter_distinct_from_plain() -> None:
|
||
"""Литера корпуса — РАЗНЫЕ дома: 16А (letter 'а') ≠ 16 (None)."""
|
||
assert _normalize_building_key("Екатеринбург, улица Ткачей, 16А") == ("ткачей", 16, "а")
|
||
assert _normalize_building_key("улица Ткачей, 16") == ("ткачей", 16, None)
|
||
|
||
|
||
def test_normalize_slash_corpus_collapses() -> None:
|
||
"""Корпус «/N» схлопывается к base (тот же дом): 9/1 → 9, 4/2 → 4, 19/2 → 19."""
|
||
assert _normalize_building_key("Олимпийская набережная, 9/1")[1] == 9
|
||
assert _normalize_building_key("Екатеринбург, ул. Мраморская, 4/2")[1] == 4
|
||
assert _normalize_building_key("улица Ткачей, 19/2")[1] == 19
|
||
|
||
|
||
def test_normalize_alias_applied_to_full_core_with_yo() -> None:
|
||
"""ё→е применяется ДО alias; alias матчит целый core (ткачёва→ткачева→ткачей)."""
|
||
assert _normalize_building_key("ул. Ткачёва, 13")[0] == "ткачей"
|
||
|
||
|
||
def test_normalize_no_house_number_returns_none_base() -> None:
|
||
"""Адрес без номера дома («р-н Центр, улица Бориса Ельцина») → base_no None (no match)."""
|
||
assert _normalize_building_key("р-н Центр, улица Бориса Ельцина") == (
|
||
"бориса ельцина",
|
||
None,
|
||
None,
|
||
)
|
||
|
||
|
||
# ── Pure anchor compute ─────────────────────────────────────────────────────
|
||
|
||
|
||
def _comp(
|
||
ppm2: int,
|
||
area: float | None = None,
|
||
rooms: int | None = None,
|
||
floor: int | None = None,
|
||
total_floors: int | None = None,
|
||
) -> dict[str, Any]:
|
||
return {
|
||
"price_per_m2": ppm2,
|
||
"area_m2": area,
|
||
"rooms": rooms,
|
||
"floor": floor,
|
||
"total_floors": total_floors,
|
||
}
|
||
|
||
|
||
def test_anchor_premium_lift_hohryakova48() -> None:
|
||
"""Зеркало Хохрякова 48: комплы 399k/472k/684k, target 4к/146 → est_ppm² поднят,
|
||
реал 684k попадает в диапазон. Радиусная медиана сильно ниже — якорь её заменяет.
|
||
min_comps=1: тест проверяет anchor-математику, не threshold."""
|
||
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,
|
||
min_comps=1,
|
||
)
|
||
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 не нужен (был бы избыточен).
|
||
min_comps=1: тест проверяет anchor-математику, не threshold."""
|
||
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,
|
||
min_comps=1,
|
||
mad_k=999.0, # mad_k=999: тест проверяет area-вес, не MAD-clip
|
||
)
|
||
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.
|
||
min_comps=1: тест проверяет uplift-логику, не threshold."""
|
||
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,
|
||
min_comps=1,
|
||
mad_k=999.0, # mad_k=999: тест проверяет uplift-логику, не MAD-clip
|
||
)
|
||
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) не применяется (комплы не из дома).
|
||
min_comps=1: тест проверяет uplift-логику, не threshold."""
|
||
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,
|
||
min_comps=1,
|
||
mad_k=999.0, # mad_k=999: тест проверяет Tier-C uplift skip, не MAD-clip
|
||
)
|
||
assert res is not None
|
||
assert res["used_uplift"] is False
|
||
|
||
|
||
def test_anchor_economy_no_overshoot() -> None:
|
||
"""Эконом (Ильича 28): дешёвые комплы ~112k → guardrail не раздувает, anchor ~112k.
|
||
min_comps=1: тест проверяет anchor-математику, не threshold."""
|
||
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,
|
||
min_comps=1,
|
||
)
|
||
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.
|
||
min_comps=1: тест проверяет comp_min_ppm2, не threshold."""
|
||
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,
|
||
min_comps=1,
|
||
)
|
||
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, не падает.
|
||
min_comps=1: тест проверяет area-вес, не threshold."""
|
||
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,
|
||
min_comps=1,
|
||
)
|
||
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 внутри).
|
||
# 4 комплов — удовлетворяет estimate_sb_min_comps=4 (#755).
|
||
_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},
|
||
{"price_per_m2": 510_000, "area_m2": 125.0, "rooms": 3},
|
||
]
|
||
|
||
|
||
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,
|
||
ratio_tuple: tuple[float | None, str | None] = (0.92, "per_rooms"),
|
||
payload=None,
|
||
radius_analogs: list[dict[str, Any]] | None = None,
|
||
dadata_result: Any | None = None,
|
||
geo_result: Any | None = None,
|
||
):
|
||
"""estimate_quality со всеми I/O застабленными; _fetch_anchor_comps форсирован.
|
||
|
||
radius_analogs=[] моделирует прод-сценарий #691: геокод дал lat/lon, но
|
||
ST_DWithin не нашёл радиусных аналогов (median=0) — якорь обязан сработать
|
||
по same-building комплам независимо.
|
||
"""
|
||
from app.services.estimator import estimate_quality
|
||
|
||
db = MagicMock()
|
||
pl = payload or _make_payload()
|
||
radius = list(_RADIUS_ANALOGS if radius_analogs is None else radius_analogs)
|
||
|
||
async def _run():
|
||
with (
|
||
patch(
|
||
"app.services.estimator.geocode",
|
||
new=AsyncMock(return_value=geo_result or _make_fake_geo()),
|
||
),
|
||
patch(
|
||
"app.services.estimator.dadata_clean_address",
|
||
new=AsyncMock(return_value=dadata_result),
|
||
),
|
||
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=(radius, 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_anchor_fires_without_radius_analogs() -> None:
|
||
"""#691: радиусных аналогов НЕТ (median=0, прод-кейс провала ST_DWithin), но
|
||
same-building комплы есть → якорь обязан сработать и дать median>0.
|
||
До фикса гейт требовал listings_clean+median>0 → якорь скипался."""
|
||
est = _run_estimate(
|
||
anchor_comps=_SB_COMPS_PREMIUM,
|
||
anchor_tier="A",
|
||
radius_analogs=[],
|
||
)
|
||
# Якорь дал ненулевой headline несмотря на пустой радиус.
|
||
assert est.median_price_rub > 0
|
||
assert est.median_price_per_m2 >= 450_000
|
||
# range валиден (low ≤ point ≤ high), не вырожден в 0.
|
||
assert 0 < est.range_low_rub <= est.median_price_rub <= est.range_high_rub
|
||
|
||
|
||
def test_estimate_no_anchor_no_radius_stays_insufficient() -> None:
|
||
"""#691 контроль: ни радиуса, ни same-building комплов → median остаётся 0
|
||
(insufficient), фикс НЕ фабрикует число из воздуха."""
|
||
est = _run_estimate(anchor_comps=[], anchor_tier=None, radius_analogs=[])
|
||
assert est.median_price_rub == 0
|
||
assert est.median_price_per_m2 == 0
|
||
|
||
|
||
def test_estimate_economy_no_regression() -> None:
|
||
"""(b) Эконом-комплы ~112k → guardrail не раздувает, headline ≈ комплов.
|
||
4 компла — satisfies estimate_sb_min_comps=4 (#755).
|
||
Комплы специально подобраны так, что MAD-clip не удаляет ни один из них
|
||
(все отклонения << 3.5×MAD при tight spread)."""
|
||
eco_comps = [
|
||
{"price_per_m2": 112_500, "area_m2": 64.0, "rooms": 3},
|
||
{"price_per_m2": 112_500, "area_m2": 63.0, "rooms": 3},
|
||
{"price_per_m2": 113_000, "area_m2": 65.0, "rooms": 3},
|
||
{"price_per_m2": 112_000, "area_m2": 62.0, "rooms": 3},
|
||
]
|
||
est = _run_estimate(
|
||
anchor_comps=eco_comps,
|
||
anchor_tier="A",
|
||
payload=_make_payload(area=63.0, rooms=3),
|
||
)
|
||
# ~112k × haircut 7% × area; НЕ раздут вверх (overshoot-guard).
|
||
assert est.median_price_per_m2 <= 113_000
|
||
assert est.median_price_per_m2 >= 100_000
|
||
|
||
|
||
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
|
||
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.
|
||
assert est.expected_sold_price_rub == round(est.median_price_rub * ratio)
|
||
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.
|
||
|
||
assert est.expected_sold_range_high_rub == round(
|
||
est.expected_sold_price_rub * settings.estimate_pi_high_mult
|
||
)
|
||
assert est.expected_sold_range_low_rub == round(
|
||
est.expected_sold_price_rub * settings.estimate_pi_low_mult
|
||
)
|
||
# Distinct: sold строго ниже asking (ratio < 1) — фикс двойных идентичных чисел.
|
||
assert est.expected_sold_price_rub < est.median_price_rub
|
||
assert est.expected_sold_per_m2 < est.median_price_per_m2
|
||
|
||
|
||
def test_estimate_expected_sold_none_without_ratio_after_anchor() -> None:
|
||
"""Нет migration-080 ratio (None) → expected_sold_* = None даже при якоре
|
||
(UI не показывает badge — не фабрикуем)."""
|
||
est = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A", ratio_tuple=(None, None))
|
||
assert est.expected_sold_price_rub is None
|
||
assert est.expected_sold_per_m2 is None
|
||
assert est.expected_sold_range_low_rub is None
|
||
assert est.expected_sold_range_high_rub is None
|
||
|
||
|
||
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-кейса).
|
||
4 компла — satisfies estimate_sb_min_comps=4 (#755)."""
|
||
# 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},
|
||
{"price_per_m2": 185_000, "area_m2": 58.0, "rooms": 2},
|
||
]
|
||
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).
|
||
min_comps=1: тест проверяет comp_max_ppm2, не threshold."""
|
||
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,
|
||
min_comps=1,
|
||
)
|
||
assert res is not None
|
||
assert res["comp_max_ppm2"] == 500_000
|
||
assert res["comp_min_ppm2"] == 300_000
|
||
|
||
|
||
# ── #694: UI-аналоги отражают комплы, на которых построен headline ──────────
|
||
|
||
|
||
def test_estimate_analogs_reflect_anchor_comps_when_fired() -> None:
|
||
"""#694: якорь сработал (tier=A) → est.analogs строятся из SAME-BUILDING комплов
|
||
(399k/472k/684k/510k), а НЕ из дешёвых радиусных (_RADIUS_ANALOGS 200k-220k).
|
||
price_rub вычислен ppm²×area (комплы без price_rub/address — fallback-путь).
|
||
_SB_COMPS_PREMIUM содержит 4 компла (#755 min_comps=4)."""
|
||
est = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A")
|
||
assert len(est.analogs) == 4
|
||
ppm2_shown = {a.price_per_m2 for a in est.analogs}
|
||
assert ppm2_shown == {399_478, 472_298, 683_995, 510_000}
|
||
# НЕ радиусные дешёвые лоты.
|
||
assert ppm2_shown.isdisjoint({200_000, 210_000, 220_000})
|
||
# price_rub = round(ppm² × area) для каждого компла (нет price_rub в моке).
|
||
by_ppm2 = {a.price_per_m2: a for a in est.analogs}
|
||
for c in _SB_COMPS_PREMIUM:
|
||
a = by_ppm2[c["price_per_m2"]]
|
||
assert a.price_rub == round(c["price_per_m2"] * c["area_m2"])
|
||
|
||
|
||
def test_estimate_analogs_stay_radius_when_no_anchor() -> None:
|
||
"""#694: якорь НЕ сработал (tier=None, Tier D) → est.analogs отражают радиусные
|
||
аналоги (_RADIUS_ANALOGS 200k-220k) — существующее поведение сохранено."""
|
||
est = _run_estimate(anchor_comps=[], anchor_tier=None)
|
||
assert len(est.analogs) == len(_RADIUS_ANALOGS)
|
||
ppm2_shown = {a.price_per_m2 for a in est.analogs}
|
||
assert ppm2_shown == {200_000, 210_000, 220_000}
|
||
|
||
|
||
def test_estimate_analogs_pass_through_display_fields() -> None:
|
||
"""#694: комплы С display-полями (address/source/source_url/price_rub) пробрасывают
|
||
их в AnalogLot напрямую (без вычисления price_rub из ppm²×area).
|
||
4 компла — satisfies estimate_sb_min_comps=4 (#755)."""
|
||
comps_with_display = [
|
||
{
|
||
"price_per_m2": 399_478,
|
||
"area_m2": 153.2,
|
||
"rooms": 3,
|
||
"address": "ЕКБ, ул. Хохрякова, 48",
|
||
"source": "cian",
|
||
"source_url": "https://cian.ru/offer/42",
|
||
"price_rub": 61_000_000,
|
||
},
|
||
{
|
||
"price_per_m2": 472_298,
|
||
"area_m2": 110.1,
|
||
"rooms": 3,
|
||
"address": "ЕКБ, ул. Хохрякова, 48",
|
||
"source": "avito",
|
||
"source_url": "https://avito.ru/offer/7",
|
||
"price_rub": 52_000_000,
|
||
},
|
||
{"price_per_m2": 683_995, "area_m2": 146.2, "rooms": 4}, # без display → fallback
|
||
{"price_per_m2": 510_000, "area_m2": 125.0, "rooms": 3}, # 4-й компл (#755 min_comps)
|
||
]
|
||
est = _run_estimate(anchor_comps=comps_with_display, anchor_tier="A")
|
||
by_ppm2 = {a.price_per_m2: a for a in est.analogs}
|
||
a1 = by_ppm2[399_478]
|
||
assert a1.address == "ЕКБ, ул. Хохрякова, 48"
|
||
assert a1.source == "cian"
|
||
assert a1.source_url == "https://cian.ru/offer/42"
|
||
assert a1.price_rub == 61_000_000 # прямой price_rub, не ppm²×area
|
||
# компл без display → price_rub вычислен, source None.
|
||
a3 = by_ppm2[683_995]
|
||
assert a3.price_rub == round(683_995 * 146.2)
|
||
assert a3.source is None
|
||
|
||
|
||
# ── #680-WB: within-building heterogeneity refine (uplift-gate + floor weight) ──
|
||
|
||
|
||
def test_anchor_uplift_gated_by_rooms_median_hohryakova_3k() -> None:
|
||
"""Зеркало Хохрякова 48 3к/153: дом с 4-комн. флагманом (комплы rooms=4 кроме
|
||
одного 3к). target 3-комн. 153м² (топ по площади, ≥p66) — БЕЗ refine получил бы
|
||
p70-uplift и унаследовал цену флагмана. С rooms-gate (3 < медианы=4) uplift НЕ
|
||
срабатывает → anchor падает к similar-комплам, не к headline-флагману."""
|
||
comps = [
|
||
_comp(399_477, area=153.2, rooms=4, floor=8, total_floors=8),
|
||
_comp(683_995, area=146.2, rooms=4, floor=5, total_floors=14),
|
||
_comp(502_846, area=105.4, rooms=3, floor=6, total_floors=9),
|
||
_comp(683_994, area=146.2, rooms=4, floor=5, total_floors=15),
|
||
]
|
||
res = _compute_same_building_anchor(
|
||
comps,
|
||
area_target=153.2,
|
||
rooms_target=3,
|
||
tier="A",
|
||
sigma=0.18,
|
||
rooms_boost=1.6,
|
||
floor_target=8,
|
||
total_floors_target=8,
|
||
floor_sigma=0.25,
|
||
)
|
||
assert res is not None
|
||
# rooms_target 3 < median comp rooms 4 → uplift gated off.
|
||
assert res["used_uplift"] is False
|
||
# anchor НЕ должен быть на уровне флагмана (684k); прижат к похожим юнитам.
|
||
assert res["anchor_ppm2"] < 550_000
|
||
|
||
|
||
def test_anchor_uplift_still_fires_for_genuine_top_unit() -> None:
|
||
"""Топ-юнит дома (rooms ≥ медианы И площадь ≥p66) в Tier A → uplift ВСЁ ЕЩЁ
|
||
срабатывает, когда дорогой компл area-несимметричен и weighted mean занижает
|
||
(do-no-harm для настоящих флагманов). Зеркало test_anchor_premium_uplift_when_*
|
||
с rooms_target == медиане комнат комплов (gate проходит).
|
||
min_comps=1: тест проверяет uplift-логику, не threshold."""
|
||
comps = [
|
||
_comp(900_000, area=60.0, rooms=3), # дорогой, малая площадь → 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, # топ по площади (≥p66)
|
||
rooms_target=3, # == median comp rooms 3 → rooms-gate проходит
|
||
tier="A",
|
||
sigma=0.18,
|
||
rooms_boost=1.6,
|
||
min_comps=1,
|
||
mad_k=999.0, # mad_k=999: тест проверяет uplift-логику, не MAD-clip
|
||
)
|
||
assert res is not None
|
||
# mean занижен дил. дорогим малометражным → uplift подтягивает к p70.
|
||
assert res["used_uplift"] is True
|
||
assert res["anchor_ppm2"] >= 500_000
|
||
|
||
|
||
def test_anchor_floor_weight_tilts_toward_similar_floor() -> None:
|
||
"""floor_sigma>0 → комплы с похожей вертикальной позицией (floor/total_floors)
|
||
весят больше. Дорогой видовой топ-этаж и дешёвый средний этаж той же площади;
|
||
target на среднем этаже → anchor тянется к среднеэтажному компла (не к видовому).
|
||
target — НЕ топ-юнит по площади (есть компл крупнее) → uplift не маскирует floor.
|
||
min_comps=1: тест проверяет floor-вес, не threshold."""
|
||
comps = [
|
||
_comp(300_000, area=60.0, rooms=2, floor=5, total_floors=20), # mid, как target
|
||
_comp(600_000, area=60.0, rooms=2, floor=20, total_floors=20), # видовой топ
|
||
_comp(310_000, area=120.0, rooms=3, floor=4, total_floors=20), # крупнее target
|
||
]
|
||
target_kw = dict(
|
||
area_target=60.0,
|
||
rooms_target=2,
|
||
tier="A",
|
||
sigma=0.18,
|
||
rooms_boost=1.6,
|
||
floor_target=5,
|
||
total_floors_target=20,
|
||
min_comps=1,
|
||
)
|
||
off = _compute_same_building_anchor(comps, floor_sigma=0.0, **target_kw)
|
||
on = _compute_same_building_anchor(comps, floor_sigma=0.25, **target_kw)
|
||
assert off is not None and on is not None
|
||
assert off["used_uplift"] is False # target не топ по площади
|
||
# floor-вес прижимает к среднеэтажному (300k), уводя от видового топа (600k).
|
||
assert on["anchor_ppm2"] < off["anchor_ppm2"]
|
||
|
||
|
||
def test_anchor_floor_weight_neutral_when_data_missing() -> None:
|
||
"""Нет floor у компла/target → floor-вес 1.0 (нейтрально), не падает,
|
||
результат идентичен floor_sigma=0 (no regress на Yandex-листингах без этажа).
|
||
min_comps=1: тест проверяет floor-вес, не threshold."""
|
||
comps = [_comp(300_000, area=60.0, rooms=2), _comp(340_000, area=62.0, rooms=2)]
|
||
a = _compute_same_building_anchor(
|
||
comps,
|
||
area_target=60.0,
|
||
rooms_target=2,
|
||
tier="A",
|
||
sigma=0.18,
|
||
rooms_boost=1.6,
|
||
floor_target=None,
|
||
total_floors_target=None,
|
||
floor_sigma=0.25,
|
||
min_comps=1,
|
||
)
|
||
b = _compute_same_building_anchor(
|
||
comps,
|
||
area_target=60.0,
|
||
rooms_target=2,
|
||
tier="A",
|
||
sigma=0.18,
|
||
rooms_boost=1.6,
|
||
floor_sigma=0.0,
|
||
min_comps=1,
|
||
)
|
||
assert a is not None and b is not None
|
||
assert abs(a["anchor_ppm2"] - b["anchor_ppm2"]) < 1.0
|
||
|
||
|
||
# ── #693: coarse-geo (centroid) confidence downgrade ─────────────────────────
|
||
|
||
_APPROX_FLAG = "оценка ориентировочная"
|
||
|
||
|
||
def _make_dadata(qc_geo: int | None):
|
||
"""Минимальный DadataAddressResult с заданным qc_geo (прочие поля None/пусто)."""
|
||
from app.services.dadata import DadataAddressResult
|
||
|
||
return DadataAddressResult(
|
||
canonical_address=None,
|
||
house_cadnum=None,
|
||
house_fias_id=None,
|
||
lat=None,
|
||
lon=None,
|
||
qc_geo=qc_geo,
|
||
qc_house=None,
|
||
kladr_id=None,
|
||
okato=None,
|
||
oktmo=None,
|
||
metro=[],
|
||
raw={},
|
||
)
|
||
|
||
|
||
def test_estimate_coarse_geo_no_anchor_downgrades_to_low() -> None:
|
||
"""#693: qc_geo=3 (город) + НЕТ якоря, но радиусные аналоги есть (median>0) →
|
||
confidence='low' + флаг «ориентировочной» оценки (центроид НП, не дом)."""
|
||
est = _run_estimate(
|
||
anchor_comps=[],
|
||
anchor_tier=None,
|
||
dadata_result=_make_dadata(qc_geo=3),
|
||
)
|
||
assert est.median_price_rub > 0 # радиусное число есть → было что квалифицировать
|
||
assert est.confidence == "low"
|
||
assert _APPROX_FLAG in est.confidence_explanation
|
||
assert "города" in est.confidence_explanation
|
||
|
||
|
||
def test_estimate_coarse_geo_with_anchor_not_downgraded() -> None:
|
||
"""#693 protect #691: qc_geo=3 грубый, НО якорь дома сработал (tier=A) →
|
||
confidence НЕ даунгрейдится (оценка стоит на реальных комплах дома)."""
|
||
est = _run_estimate(
|
||
anchor_comps=_SB_COMPS_PREMIUM,
|
||
anchor_tier="A",
|
||
dadata_result=_make_dadata(qc_geo=3),
|
||
)
|
||
# Якорь сам выставил confidence — не "low" от #693-даунгрейда, и без флага.
|
||
assert _APPROX_FLAG not in (est.confidence_explanation or "")
|
||
|
||
|
||
def test_estimate_precise_geo_no_downgrade() -> None:
|
||
"""#693: qc_geo=0 (дом, точно) + нет якоря → НЕТ даунгрейда (точный адрес)."""
|
||
est = _run_estimate(
|
||
anchor_comps=[],
|
||
anchor_tier=None,
|
||
dadata_result=_make_dadata(qc_geo=0),
|
||
)
|
||
assert _APPROX_FLAG not in (est.confidence_explanation or "")
|
||
|
||
|
||
def test_estimate_no_dadata_no_false_downgrade() -> None:
|
||
"""#693: dadata=None (нет сигнала) → НЕ фабрикуем даунгрейд (no false positive)."""
|
||
est = _run_estimate(
|
||
anchor_comps=[],
|
||
anchor_tier=None,
|
||
dadata_result=None,
|
||
)
|
||
assert _APPROX_FLAG not in (est.confidence_explanation or "")
|
||
|
||
|
||
# ── #693 prod-fix: coarse geocode detected WITHOUT DaData (QA-fail #693) ──────
|
||
|
||
|
||
def _make_fake_geo_coarse(
|
||
full_address: str = "Россия, Свердловская область, Екатеринбург",
|
||
):
|
||
"""Геокод-centroid без дома (город/регион) — прод-сценарий, когда DaData off.
|
||
|
||
full_address не содержит house-number токена → _geocode_is_coarse → True.
|
||
"""
|
||
from app.services.geocoder import GeocodeResult
|
||
|
||
return GeocodeResult(
|
||
lat=56.838,
|
||
lon=60.605,
|
||
full_address=full_address,
|
||
provider="yandex",
|
||
)
|
||
|
||
|
||
def test_geocode_is_coarse_signal() -> None:
|
||
"""Юнит: detector ловит centroid (нет дома) и пропускает реальные адреса."""
|
||
from app.services.geocoder import GeocodeResult
|
||
|
||
def _geo(addr: str, conf: str = "approximate"):
|
||
return GeocodeResult(
|
||
lat=56.8, lon=60.6, full_address=addr, provider="yandex", confidence=conf
|
||
) # type: ignore[arg-type]
|
||
|
||
# Coarse — нет house-number токена.
|
||
assert _geocode_is_coarse(_geo("Россия, Свердловская область, Екатеринбург")) is True
|
||
assert _geocode_is_coarse(_geo("Свердловская область")) is True
|
||
# Почтовый индекс (6 цифр) НЕ считается домом → всё ещё coarse.
|
||
assert _geocode_is_coarse(_geo("Екатеринбург, 620000")) is True
|
||
# provider confidence == 'locality' → coarse даже при наличии цифр.
|
||
assert _geocode_is_coarse(_geo("улица 8 Марта, 12", conf="locality")) is True
|
||
# Precise — есть номер дома → НЕ coarse (zero false downgrade).
|
||
assert _geocode_is_coarse(_geo("Екатеринбург, ул. Малышева, 30")) is False
|
||
assert _geocode_is_coarse(_geo("ЕКБ, ул. Хохрякова, 48")) is False
|
||
assert _geocode_is_coarse(_geo("Екатеринбург, ул.白, 16а")) is False
|
||
|
||
|
||
def test_estimate_coarse_geo_no_dadata_downgrades_to_low() -> None:
|
||
"""#693 QA-fail: DaData off (dadata=None), геокод свалился к city-centroid
|
||
(нет дома в full_address), якоря нет, но радиусные аналоги есть (median>0) →
|
||
confidence='low' + флаг «ориентировочной» оценки. Это прод-кейс «фывапролд 999»."""
|
||
est = _run_estimate(
|
||
anchor_comps=[],
|
||
anchor_tier=None,
|
||
dadata_result=None,
|
||
geo_result=_make_fake_geo_coarse(),
|
||
)
|
||
assert est.median_price_rub > 0
|
||
assert est.confidence == "low"
|
||
assert _APPROX_FLAG in (est.confidence_explanation or "")
|
||
|
||
|
||
def test_estimate_coarse_geo_region_no_dadata_downgrades() -> None:
|
||
"""#693: регион-centroid («Свердловская область») без DaData → low + флаг."""
|
||
est = _run_estimate(
|
||
anchor_comps=[],
|
||
anchor_tier=None,
|
||
dadata_result=None,
|
||
geo_result=_make_fake_geo_coarse(full_address="Свердловская область"),
|
||
)
|
||
assert est.confidence == "low"
|
||
assert _APPROX_FLAG in (est.confidence_explanation or "")
|
||
|
||
|
||
def test_estimate_coarse_geo_with_anchor_no_dadata_not_downgraded() -> None:
|
||
"""#693 protect #691: coarse-геокод без DaData, НО якорь дома (tier=A) сработал →
|
||
НЕ даунгрейдим (оценка стоит на реальных комплах дома, центроид не важен)."""
|
||
est = _run_estimate(
|
||
anchor_comps=_SB_COMPS_PREMIUM,
|
||
anchor_tier="A",
|
||
dadata_result=None,
|
||
geo_result=_make_fake_geo_coarse(),
|
||
)
|
||
assert _APPROX_FLAG not in (est.confidence_explanation or "")
|
||
|
||
|
||
# ── #693 fixup 2/3: Tier C micro-radius must NOT shield a coarse-geo estimate ─
|
||
|
||
|
||
def test_estimate_coarse_geo_tier_c_downgrades() -> None:
|
||
"""#693 QA-fail 2/3 (canonical 'фывапролд 999' → ЕКБ-центроид): грубый геокод +
|
||
Tier C micro-radius (≤500 м) набрал комплы в плотном центре. РАНЬШЕ guard
|
||
`anchor_tier is None` блокировал downgrade → medium. Теперь защищаем ТОЛЬКО
|
||
Tier A; coarse-геокод + Tier C → low + флаг «ориентировочная»."""
|
||
est = _run_estimate(
|
||
anchor_comps=_SB_COMPS_PREMIUM,
|
||
anchor_tier="C",
|
||
dadata_result=None,
|
||
geo_result=_make_fake_geo_coarse(),
|
||
)
|
||
assert est.median_price_rub > 0 # Tier C дал headline — было что квалифицировать
|
||
assert est.confidence == "low"
|
||
assert _APPROX_FLAG in (est.confidence_explanation or "")
|
||
|
||
|
||
def test_estimate_precise_geo_tier_c_not_downgraded() -> None:
|
||
"""#693 no false-downgrade: ТОЧНЫЙ геокод (есть дом) + Tier C → НЕ понижаем
|
||
(Tier C — легитимный fallback для реального адреса; центроид-сигнала нет)."""
|
||
est = _run_estimate(
|
||
anchor_comps=_SB_COMPS_PREMIUM,
|
||
anchor_tier="C",
|
||
dadata_result=None,
|
||
# default geo = '…ул. Хохрякова, 48' → есть номер дома → не coarse
|
||
)
|
||
assert _APPROX_FLAG not in (est.confidence_explanation or "")
|
||
|
||
|
||
# ── #695: explanation согласован при сработавшем якоре (без радиус-противоречия) ──
|
||
|
||
|
||
def test_explanation_anchor_no_radius_contradiction() -> None:
|
||
"""#695: якорь дома построил headline → explanation описывает якорные комплы
|
||
(anchor['n']=4 из того же дома), БЕЗ противоречащего радиусного «Найдено N из M
|
||
разных адресов» и без analog_tier-note про «нет аналогов в том же доме».
|
||
_SB_COMPS_PREMIUM содержит 4 компла (#755 min_comps=4)."""
|
||
est = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A")
|
||
exp = est.confidence_explanation or ""
|
||
assert "Оценка построена по 4 аналогам из того же дома" in exp
|
||
assert "Найдено" not in exp # радиусный base-текст не конкатенируется
|
||
assert "разных адресов" not in exp # нет противоречия «того же дома» vs «разных»
|
||
|
||
|
||
def test_explanation_radius_path_keeps_base_text() -> None:
|
||
"""#695 контроль: без якоря (radius-путь) base-текст «Найдено N…» сохраняется."""
|
||
est = _run_estimate(anchor_comps=[], anchor_tier=None)
|
||
exp = est.confidence_explanation or ""
|
||
assert "Найдено" in exp
|
||
assert "Оценка построена по" not in exp # якорной фразы нет на чистом радиусе
|
||
|
||
|
||
# ── #695 QA fixup: n_analogs синхронизируется с anchor-популяцией (radius≠anchor) ──
|
||
|
||
|
||
def test_anchor_n_analogs_syncs_to_anchor_count() -> None:
|
||
"""#695 QA-fail: anchor подменяет headline+список на комплы дома, но n_analogs
|
||
раньше оставался радиусным → «Показано 5 из 4». radius=5 ≠ anchor=4 намеренно;
|
||
n_analogs ДОЛЖЕН стать 4 (anchor), согласуясь с «по 4 аналогам» в explanation.
|
||
_SB_COMPS_PREMIUM содержит 4 компла (#755 min_comps=4)."""
|
||
radius = [_make_listing(price_per_m2=200_000.0) for _ in range(5)]
|
||
est = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A", radius_analogs=radius)
|
||
assert est.n_analogs == 4 # = len(_SB_COMPS_PREMIUM), НЕ 5 (радиус)
|
||
assert "по 4 аналогам" in (est.confidence_explanation or "")
|
||
|
||
|
||
def test_radius_path_n_analogs_unchanged(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""#695 контроль: без якоря n_analogs = радиусный счёт (поведение не меняется).
|
||
|
||
Проверяет radius-passthrough vs anchor-override — ортогонально кросс-source
|
||
дедупу (#2087 H4, default ON с #2173). 5 ИДЕНТИЧНЫХ листингов — это ровно
|
||
патологический кросс-пост, который дедуп схлопнул бы в 1; пиним флаг OFF,
|
||
чтобы мерить именно #695-инвариант, не полагаясь на прод-default (теперь ON).
|
||
"""
|
||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", False)
|
||
radius = [_make_listing(price_per_m2=200_000.0) for _ in range(5)]
|
||
est = _run_estimate(anchor_comps=[], anchor_tier=None, radius_analogs=radius)
|
||
assert est.n_analogs == 5
|
||
|
||
|
||
# ── #755: anchor safeguards (min_comps, confidence cap, MAD-clip) ─────────────
|
||
|
||
|
||
def test_755_min_comps_3_anchor_does_not_fire() -> None:
|
||
"""#755 param-1: 3 same-building комплов < min_comps=4 → anchor НЕ срабатывает,
|
||
estimate использует радиусный median-путь (headline = 210k, как в _RADIUS_ANALOGS)."""
|
||
three_comps = [
|
||
{"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},
|
||
]
|
||
est = _run_estimate(anchor_comps=three_comps, anchor_tier="A")
|
||
# min_comps=4 (settings default) → 3 comps → якорь не сработал → радиусный путь.
|
||
assert est.median_price_per_m2 == 210_000
|
||
|
||
|
||
def test_755_min_comps_4_anchor_fires() -> None:
|
||
"""#755 param-1: 4 same-building комплов == min_comps=4 → anchor срабатывает,
|
||
headline выше радиусной медианы (радиусные аналоги ~210k, комплы дома ≥399k)."""
|
||
four_comps = [
|
||
{"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},
|
||
{"price_per_m2": 510_000, "area_m2": 125.0, "rooms": 3},
|
||
]
|
||
est = _run_estimate(anchor_comps=four_comps, anchor_tier="A")
|
||
# 4 comps == min_comps=4 → якорь сработал → headline выше радиусной медианы.
|
||
assert est.median_price_per_m2 > 210_000
|
||
|
||
|
||
def test_755_confidence_cap_n4_not_high() -> None:
|
||
"""#755 param-2: anchor с n=4 comps → confidence НЕ может быть 'high' (capped → medium/low).
|
||
Тест использует min_comps=1 и n=4 явно (pure unit test без БД)."""
|
||
four_comps = [
|
||
_comp(300_000, area=60.0, rooms=2),
|
||
_comp(302_000, area=61.0, rooms=2),
|
||
_comp(298_000, area=59.0, rooms=2),
|
||
_comp(301_000, area=60.5, rooms=2),
|
||
]
|
||
res = _compute_same_building_anchor(
|
||
four_comps,
|
||
area_target=60.0,
|
||
rooms_target=2,
|
||
tier="A",
|
||
sigma=0.18,
|
||
rooms_boost=1.6,
|
||
min_comps=1,
|
||
)
|
||
assert res is not None
|
||
assert res["n"] == 4
|
||
# n < 5 → confidence capped to medium (даже при очень tight spread).
|
||
assert res["confidence"] != "high"
|
||
assert res["confidence"] in ("medium", "low")
|
||
|
||
|
||
def test_755_confidence_cap_n5_may_be_high() -> None:
|
||
"""#755 param-2: anchor с n>=5 comps и очень tight spread → 'high' разрешено.
|
||
Тест использует min_comps=1 (pure unit test без БД)."""
|
||
five_comps = [
|
||
_comp(300_000, area=60.0, rooms=2),
|
||
_comp(302_000, area=61.0, rooms=2),
|
||
_comp(298_000, area=59.0, rooms=2),
|
||
_comp(301_000, area=60.5, rooms=2),
|
||
_comp(299_000, area=59.5, rooms=2),
|
||
]
|
||
res = _compute_same_building_anchor(
|
||
five_comps,
|
||
area_target=60.0,
|
||
rooms_target=2,
|
||
tier="A",
|
||
sigma=0.18,
|
||
rooms_boost=1.6,
|
||
min_comps=1,
|
||
)
|
||
assert res is not None
|
||
assert res["n"] == 5
|
||
# n >= 5 → high допускается при tight spread (CV ≈ 0.005, fsd ≤ 0.13).
|
||
assert res["confidence"] == "high"
|
||
|
||
|
||
def test_755_mad_clip_removes_obvious_outlier() -> None:
|
||
"""#755 param-3: набор [10M, 10.2M, 9.9M, 25M] — 25M явная опечатка.
|
||
MAD-clip с k=3.5 удаляет её; anchor median не раздут до ~14M."""
|
||
prices = [10_000_000, 10_200_000, 9_900_000, 25_000_000]
|
||
comps = [_comp(p, area=60.0, rooms=2) for p in prices]
|
||
# С outlier-ом: 25M оставляет только 3 компла (если клипнут) → при min_comps=4 → None.
|
||
# Проверяем через _mad_clip напрямую.
|
||
surviving = _mad_clip([float(p) for p in prices], k=3.5)
|
||
# 25M — выброс: median ≈ 10.1M, MAD ≈ 0.15M, threshold ≈ 0.525M.
|
||
# |25M - 10.1M| ≈ 14.9M >> 0.525M → отсечён.
|
||
assert 3 not in surviving # индекс 25M (последний) отсечён
|
||
assert len(surviving) == 3
|
||
|
||
# Если запустить anchor с min_comps=3 → срабатывает на выживших 3 комплах,
|
||
# median не раздут (≈10M, не ≈14M среднего с outlier'ом).
|
||
res = _compute_same_building_anchor(
|
||
comps,
|
||
area_target=60.0,
|
||
rooms_target=2,
|
||
tier="A",
|
||
sigma=0.18,
|
||
rooms_boost=1.6,
|
||
min_comps=3,
|
||
mad_k=3.5,
|
||
)
|
||
assert res is not None
|
||
assert res["n"] == 3 # outlier отброшен
|
||
# anchor_ppm2 ≈ 10M (не раздут к ~14M)
|
||
assert res["anchor_ppm2"] < 11_000_000
|
||
|
||
|
||
def test_755_mad_clip_no_outlier_set_unchanged() -> None:
|
||
"""#755 param-3: набор без выбросов → все комплы выживают, clip не удаляет ничего."""
|
||
prices = [300_000, 310_000, 295_000, 305_000, 308_000]
|
||
surviving = _mad_clip([float(p) for p in prices], k=3.5)
|
||
assert len(surviving) == 5
|
||
assert sorted(surviving) == [0, 1, 2, 3, 4]
|
||
|
||
|
||
def test_755_mad_clip_drops_below_min_comps_returns_none() -> None:
|
||
"""#755 param-3: MAD-clip удаляет выброс и оставшихся < min_comps → anchor НЕ срабатывает.
|
||
Тест: 2 нормальных компла + 1 огромный выброс, min_comps=3 → clip даёт 2 < 3 → None."""
|
||
comps = [
|
||
_comp(300_000, area=60.0, rooms=2),
|
||
_comp(310_000, area=61.0, rooms=2),
|
||
_comp(5_000_000, area=60.0, rooms=2), # явный выброс
|
||
]
|
||
res = _compute_same_building_anchor(
|
||
comps,
|
||
area_target=60.0,
|
||
rooms_target=2,
|
||
tier="A",
|
||
sigma=0.18,
|
||
rooms_boost=1.6,
|
||
min_comps=3,
|
||
mad_k=3.5,
|
||
)
|
||
# После clip выживает 2 < min_comps=3 → None (fallback к radius-median).
|
||
assert res is None
|