@ -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² поднят,
реал 684 k попадает в диапазон . Радиусная медиана сильно ниже — якорь её заменяет . """
реал 684 k попадает в диапазон . Радиусная медиана сильно ниже — якорь её заменяет .
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 уже ≈ 996 k ( реал 996 k ) , uplift не нужен ( был бы избыточен ) . """
weighted mean уже ≈ 996 k ( реал 996 k ) , 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 .5 k × haircut 7% × area; Н Е раздут вверх (overshoot-guard).
# ~112 k × 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 комплов
( 399 k / 472 k / 684 k ) , а Н Е из дешёвых радиусных ( _RADIUS_ANALOGS 200 k - 220 k ) .
price_rub вычислен ppm² × area ( комплы без price_rub / address — fallback - путь ) . """
( 399 k / 472 k / 684 k / 510 k ) , а Н Е из дешёвых радиусных ( _RADIUS_ANALOGS 200 k - 220 k ) .
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 = 210 k , как в _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 выше радиусной медианы ( радиусные аналоги ~ 210 k , комплы дома ≥ 399 k ) . """
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 не раздут до ~ 14 M . """
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