From ec82742456439ce7d27768ac6cc248202f61cfa9 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 31 May 2026 12:19:50 +0300 Subject: [PATCH] feat(tradein): same-building anchor safe-guard (#755) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLIENT-VISIBLE pricing (killer-factor, demo 2026-06-01). The same-building anchor replaced headline median/range/confidence with an over-confident premium on as few as 2 same-building asking comps. Human-chosen (Anton) 3-part safe-guard, all implemented: 1. min_comps 2 -> 4: anchor fires only with >=4 same-building comps. 2. confidence cap: at <5 anchor comps, confidence cannot be 'high' -> medium. 3. MAD-clip: drop price_per_m2 outliers among anchor comps before aggregation (k=estimate_sb_mad_k=3.5); if k×MAD asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²) estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 96e8c456..e60e167b 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -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-аналоги должны отражать ЭТИ комплы. diff --git a/tradein-mvp/backend/tests/test_estimator_expected_sold.py b/tradein-mvp/backend/tests/test_estimator_expected_sold.py index b4ac5e8a..7a84485b 100644 --- a/tradein-mvp/backend/tests/test_estimator_expected_sold.py +++ b/tradein-mvp/backend/tests/test_estimator_expected_sold.py @@ -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}, ] diff --git a/tradein-mvp/backend/tests/test_same_building_anchor.py b/tradein-mvp/backend/tests/test_same_building_anchor.py index b555b546..898ac7a9 100644 --- a/tradein-mvp/backend/tests/test_same_building_anchor.py +++ b/tradein-mvp/backend/tests/test_same_building_anchor.py @@ -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)×(1−tol); helper отдаёт comp_min.""" + """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 + 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