gendesign/tradein-mvp/backend/tests/test_same_building_anchor.py
bot-backend d22e0e00ae
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
tech-debt(tradein/estimator): collapse won estimate_* flags into defaults (#1970) (#2475)
2026-07-12 12:46:18 +00:00

1191 lines
54 KiB
Python
Raw Permalink 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) 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)×(1tol); 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