feat(tradein): same-building anchor safe-guard (#755) #877

Merged
lekss361 merged 1 commit from feat/755-anchor-safeguard into main 2026-05-31 10:36:22 +00:00
4 changed files with 303 additions and 34 deletions

View file

@ -76,7 +76,7 @@ class Settings(BaseSettings):
# и tighter FSD-диапазон. Полностью за флагом — OFF ⇒ точно текущее поведение.
# Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2.
estimate_same_building_anchor_enabled: bool = True
estimate_sb_min_comps: int = 2 # стоп на первом тире с ≥ N активных комплов
estimate_sb_min_comps: int = 4 # стоп на первом тире с ≥ N активных комплов
estimate_sb_area_sigma: float = 0.18 # σ log-нормального area-веса (Gaussian)
estimate_sb_rooms_match_boost: float = 1.6 # ×вес если rooms компла == target
# #680-WB within-building heterogeneity refine: floor-similarity Gaussian по
@ -87,6 +87,7 @@ class Settings(BaseSettings):
# покрытия; Хохрякова 3к/153 overshoot 64%→1.5%, флагман 4к 17.5%→5.4%.
estimate_sb_floor_sigma: float = 0.25
estimate_sb_guardrail_tol: float = 0.05 # hard floor: est ≥ min(comp ppm²)×(1tol)
estimate_sb_mad_k: float = 3.5 # MAD-clip: drop comps с |ppm2median| > k×MAD
asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²)
estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона

View file

@ -1325,6 +1325,28 @@ def _band_haircut(anchor_ppm2: float) -> float:
return 0.07
def _mad_clip(values: list[float], k: float) -> list[int]:
"""MAD-клип: возвращает индексы элементов values, не являющихся выбросами.
Выброс: |v median| > k × MAD, где MAD = median(|v median|).
Чистая функция без side-effect'ов — возвращает список индексов выживших
(не сами значения, чтобы caller мог фильтровать list[dict] по ним).
При MAD == 0 (все значения одинаковы) все элементы проходят (ни один
не считается выбросом в вырожденном случае).
Ожидает непустой список; caller гарантирует len(values) >= 1.
"""
sorted_v = sorted(values)
median = _percentile(sorted_v, 0.5)
deviations = sorted([abs(v - median) for v in values])
mad = _percentile(deviations, 0.5)
if mad == 0.0:
# Все значения идентичны — нечего отсекать.
return list(range(len(values)))
threshold = k * mad
return [i for i, v in enumerate(values) if abs(v - median) <= threshold]
def _compute_same_building_anchor(
comps: list[dict[str, Any]],
*,
@ -1336,6 +1358,8 @@ def _compute_same_building_anchor(
floor_target: int | None = None,
total_floors_target: int | None = None,
floor_sigma: float = 0.0,
min_comps: int = 4,
mad_k: float = 3.5,
) -> dict[str, Any] | None:
"""Чистая (testable без БД) свёртка комплов в anchor-оценку.
@ -1360,9 +1384,33 @@ def _compute_same_building_anchor(
"""
if not comps:
return None
ppm2 = [float(c["price_per_m2"]) for c in comps if c.get("price_per_m2")]
if not ppm2:
# Строим параллельные списки comps/ppm2 с гарантированным соответствием индексов.
priced_pairs = [(c, float(c["price_per_m2"])) for c in comps if c.get("price_per_m2")]
if not priced_pairs:
return None
# #755 param-3: MAD-clip — отсекаем выбросы по price_per_m2 до агрегации.
# Если после клипа выживает < min_comps — якорь НЕ срабатывает (→ None → fallback).
raw_ppm2 = [p for _, p in priced_pairs]
surviving_idx = _mad_clip(raw_ppm2, mad_k)
if len(surviving_idx) < min_comps:
logger.info(
"anchor MAD-clip: %d comps → %d survived (< min_comps=%d) → fallback",
len(priced_pairs),
len(surviving_idx),
min_comps,
)
return None
if len(surviving_idx) < len(priced_pairs):
logger.info(
"anchor MAD-clip: %d comps → %d after k=%.1f×MAD clip",
len(priced_pairs),
len(surviving_idx),
mad_k,
)
priced_pairs = [priced_pairs[i] for i in surviving_idx]
comps = [c for c, _ in priced_pairs]
ppm2 = [p for _, p in priced_pairs]
n = len(ppm2)
# target relative vertical position (None → floor-вес отключён/нейтрален).
@ -1436,6 +1484,11 @@ def _compute_same_building_anchor(
else:
confidence = "low"
# #755 param-2: confidence cap — при n < 5 комплах anchor не может быть "high"
# даже если FSD укладывается в 0.13 (мало данных — самоуверенный headline опасен).
if n < 5 and confidence == "high":
confidence = "medium"
return {
"anchor_ppm2": anchor,
"anchor_sold_ppm2": anchor_sold,
@ -1899,6 +1952,8 @@ async def estimate_quality(
floor_target=payload.floor,
total_floors_target=payload.total_floors,
floor_sigma=settings.estimate_sb_floor_sigma,
min_comps=settings.estimate_sb_min_comps,
mad_k=settings.estimate_sb_mad_k,
)
if anchor is not None:
# #694: якорь мутирует headline — UI-аналоги должны отражать ЭТИ комплы.

View file

@ -275,6 +275,7 @@ def test_global_fallback_basis_carried_through() -> None:
# Minimal anchor comp to produce a deterministic median_price via same-building anchor.
_ANCHOR_COMP_ONLY: list[dict] = [
{"price_per_m2": 150_000, "area_m2": 40.0, "rooms": 1},
{"price_per_m2": 155_000, "area_m2": 40.0, "rooms": 1}, # 4-й компл (#755 min_comps=4)
{"price_per_m2": 160_000, "area_m2": 40.0, "rooms": 1},
{"price_per_m2": 170_000, "area_m2": 40.0, "rooms": 1},
]

View file

@ -8,6 +8,10 @@ hard guardrail; и full estimate path с замоканным `_fetch_anchor_com
(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
@ -24,6 +28,7 @@ import anyio
from app.services.estimator import (
_compute_same_building_anchor,
_geocode_is_coarse,
_mad_clip,
_normalize_building_key,
)
@ -187,7 +192,8 @@ def _comp(
def test_anchor_premium_lift_hohryakova48() -> None:
"""Зеркало Хохрякова 48: комплы 399k/472k/684k, target 4к/146 → est_ppm² поднят,
реал 684k попадает в диапазон. Радиусная медиана сильно ниже якорь её заменяет."""
реал 684k попадает в диапазон. Радиусная медиана сильно ниже якорь её заменяет.
min_comps=1: тест проверяет anchor-математику, не threshold."""
comps = [
_comp(399_478, area=153.2, rooms=3),
_comp(472_298, area=110.1, rooms=3),
@ -200,6 +206,7 @@ def test_anchor_premium_lift_hohryakova48() -> None:
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
@ -214,14 +221,22 @@ def test_anchor_premium_lift_hohryakova48() -> None:
def test_anchor_olimp13_flagship_area_matched_no_uplift_needed() -> None:
"""Олимп 13 пентхаус: флагман 996k сам area+rooms-matched с target 207.9 →
weighted mean уже 996k (реал 996k), uplift не нужен (был бы избыточен)."""
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
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 подтверждён.
@ -231,14 +246,22 @@ def test_anchor_olimp13_flagship_area_matched_no_uplift_needed() -> None:
def test_anchor_premium_uplift_when_mean_dilutes() -> None:
"""premium uplift срабатывает когда премиум-компл НЕ area-similar (mean занижает),
а target топ-юнит дома (p66) И Tier A берём верхний квантиль p70."""
а 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
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
@ -246,21 +269,30 @@ def test_anchor_premium_uplift_when_mean_dilutes() -> None:
def test_anchor_uplift_skipped_off_tier_a() -> None:
"""uplift только Tier A — на Tier C (micro-radius) не применяется (комплы не из дома)."""
"""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
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."""
"""Эконом (Ильича 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,
@ -269,6 +301,7 @@ def test_anchor_economy_no_overshoot() -> None:
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. НЕ раздут вверх.
@ -278,10 +311,17 @@ def test_anchor_economy_no_overshoot() -> None:
def test_anchor_guardrail_floor_on_min_comp() -> None:
"""Guardrail-floor (применяется у caller'а) = min(comp)×(1tol); helper отдаёт comp_min."""
"""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
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
@ -297,10 +337,17 @@ def test_anchor_none_when_no_comps() -> None:
def test_anchor_null_area_neutral_weight() -> None:
"""Комплы без площади (Yandex) судятся только по комнатам — area-вес 1.0, не падает."""
"""Комплы без площади (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
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%.
@ -341,10 +388,12 @@ _RADIUS_ANALOGS: list[dict[str, Any]] = [
]
# 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},
]
@ -475,17 +524,22 @@ def test_estimate_no_anchor_no_radius_stays_insufficient() -> None:
def test_estimate_economy_no_regression() -> None:
"""(b) Эконом-комплы ~112k → guardrail не раздувает, headline ≈ комплов."""
"""(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),
)
# ~112.5k × haircut 7% × area; НЕ раздут вверх (overshoot-guard).
# ~112k × haircut 7% × area; НЕ раздут вверх (overshoot-guard).
assert est.median_price_per_m2 <= 113_000
assert est.median_price_per_m2 >= 100_000
@ -536,12 +590,14 @@ def test_estimate_tier_d_fallback_keeps_radius() -> None:
def test_estimate_range_covers_same_building_comp_spread() -> None:
"""range_high покрывает RAW max same-building компла (видовой/топ-юнит дома не
вылетает за диапазон зеркало 8 Марта 204Г view-кейса)."""
вылетает за диапазон зеркало 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,
@ -553,7 +609,8 @@ def test_estimate_range_covers_same_building_comp_spread() -> None:
def test_anchor_exposes_comp_max() -> None:
"""anchor dict отдаёт comp_max_ppm2 (нужен caller'у для spread-coverage)."""
"""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,
@ -561,6 +618,7 @@ def test_anchor_exposes_comp_max() -> None:
tier="A",
sigma=0.18,
rooms_boost=1.6,
min_comps=1,
)
assert res is not None
assert res["comp_max_ppm2"] == 500_000
@ -572,12 +630,13 @@ def test_anchor_exposes_comp_max() -> None:
def test_estimate_analogs_reflect_anchor_comps_when_fired() -> None:
"""#694: якорь сработал (tier=A) → est.analogs строятся из SAME-BUILDING комплов
(399k/472k/684k), а НЕ из дешёвых радиусных (_RADIUS_ANALOGS 200k-220k).
price_rub вычислен ppm²×area (комплы без price_rub/address fallback-путь)."""
(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) == 3
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}
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 в моке).
@ -598,7 +657,8 @@ def test_estimate_analogs_stay_radius_when_no_anchor() -> None:
def test_estimate_analogs_pass_through_display_fields() -> None:
"""#694: комплы С display-полями (address/source/source_url/price_rub) пробрасывают
их в AnalogLot напрямую (без вычисления price_rub из ppm²×area)."""
их в AnalogLot напрямую (без вычисления price_rub из ppm²×area).
4 компла satisfies estimate_sb_min_comps=4 (#755)."""
comps_with_display = [
{
"price_per_m2": 399_478,
@ -619,6 +679,7 @@ def test_estimate_analogs_pass_through_display_fields() -> None:
"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}
@ -669,7 +730,8 @@ 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 проходит)."""
с 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),
@ -682,6 +744,8 @@ def test_anchor_uplift_still_fires_for_genuine_top_unit() -> None:
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.
@ -693,7 +757,8 @@ def test_anchor_floor_weight_tilts_toward_similar_floor() -> None:
"""floor_sigma>0 → комплы с похожей вертикальной позицией (floor/total_floors)
весят больше. Дорогой видовой топ-этаж и дешёвый средний этаж той же площади;
target на среднем этаже anchor тянется к среднеэтажному компла (не к видовому).
target НЕ топ-юнит по площади (есть компл крупнее) uplift не маскирует floor."""
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), # видовой топ
@ -707,6 +772,7 @@ def test_anchor_floor_weight_tilts_toward_similar_floor() -> None:
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)
@ -718,7 +784,8 @@ def test_anchor_floor_weight_tilts_toward_similar_floor() -> None:
def test_anchor_floor_weight_neutral_when_data_missing() -> None:
"""Нет floor у компла/target → floor-вес 1.0 (нейтрально), не падает,
результат идентичен floor_sigma=0 (no regress на Yandex-листингах без этажа)."""
результат идентичен 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,
@ -730,6 +797,7 @@ def test_anchor_floor_weight_neutral_when_data_missing() -> None:
floor_target=None,
total_floors_target=None,
floor_sigma=0.25,
min_comps=1,
)
b = _compute_same_building_anchor(
comps,
@ -739,6 +807,7 @@ def test_anchor_floor_weight_neutral_when_data_missing() -> None:
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
@ -932,11 +1001,12 @@ def test_estimate_precise_geo_tier_c_not_downgraded() -> None:
def test_explanation_anchor_no_radius_contradiction() -> None:
"""#695: якорь дома построил headline → explanation описывает якорные комплы
(anchor['n']=3 из того же дома), БЕЗ противоречащего радиусного «Найдено N из M
разных адресов» и без analog_tier-note про «нет аналогов в том же доме»."""
(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 "Оценка построена по 3 аналогам из того же дома" in exp
assert "Оценка построена по 4 аналогам из того же дома" in exp
assert "Найдено" not in exp # радиусный base-текст не конкатенируется
assert "разных адресов" not in exp # нет противоречия «того же дома» vs «разных»
@ -954,12 +1024,13 @@ def test_explanation_radius_path_keeps_base_text() -> None:
def test_anchor_n_analogs_syncs_to_anchor_count() -> None:
"""#695 QA-fail: anchor подменяет headline+список на комплы дома, но n_analogs
раньше оставался радиусным «Показано 5 из 4». radius=5 anchor=3 намеренно;
n_analogs ДОЛЖЕН стать 3 (anchor), согласуясь с «по 3 аналогам» в explanation."""
раньше оставался радиусным «Показано 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 == 3 # = len(_SB_COMPS_PREMIUM), НЕ 5 (радиус)
assert "по 3 аналогам" in (est.confidence_explanation or "")
assert est.n_analogs == 4 # = len(_SB_COMPS_PREMIUM), НЕ 5 (радиус)
assert "по 4 аналогам" in (est.confidence_explanation or "")
def test_radius_path_n_analogs_unchanged() -> None:
@ -967,3 +1038,144 @@ def test_radius_path_n_analogs_unchanged() -> None:
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