From 31c31f8f82fd70d952d12f7018ebbb842f74f380 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:38:44 +0500 Subject: [PATCH] =?UTF-8?q?=D0=9C=D0=95=D0=A0=D0=90:=20=D1=8F=D0=BA=D0=BE?= =?UTF-8?q?=D1=80=D1=8C=20=C2=AB=D1=82=D0=BE=D1=82=20=D0=B6=D0=B5=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=BC=C2=BB=20=D0=B8=20IMV-blend=20=E2=80=94=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D1=81=D1=82=D0=B0=D0=BD=D1=82=D1=8B=20=D0=B4=D0=B2?= =?UTF-8?q?=D0=B8=D0=B6=D0=BA=D0=B0,=20=D0=BD=D0=B5=20=D0=BD=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B8=20(#2381)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пять булевых флагов кластера сняты ещё в #2475. Оставшиеся 14 числовых полей Settings перенесены в estimator.py константами с теми же значениями: IMV_BLEND_WEIGHT 0.5, IMV_BLEND_THRESHOLD 1.15, SB_MIN_COMPS 4, SB_AREA_SIGMA 0.18, SB_ROOMS_MATCH_BOOST 1.6, SB_FLOOR_SIGMA 0.25, SB_GUARDRAIL_TOL 0.05, SB_MAD_K 3.5, SB_MAD_K_SMALL_N 2.5, SB_SMALL_N_THRESHOLD 10, ANCHOR_TIER_C_CORRIDOR_MULT 1.5, FSD_K 1.65, SB_GATE_MIN_N 3, SB_GATE_MAX_FSD 0.20. На проде 17.09 все 14 равны дефолтам, ENV-оверрайдов нет. Мёртвая проверка `tier_c_mult > 0` (константа против нуля) убрана. Тесты подменяют SB_MIN_COMPS на модуле вместо поля settings. Реплей бэктеста по сделкам побитово тот же, срабатываний якоря 985, IMV-blend 3, low-conf гейта 11 — как на main. Шесть порогов (rooms_boost, floor_sigma, guardrail_tol, tier_c_mult, fsd_k, gate_max_fsd) не ловит ни один поведенческий тест и ни гейт, их держит только тест боевых значений. Co-Authored-By: Claude Opus 5 --- tradein-mvp/backend/app/core/config.py | 56 ------------ tradein-mvp/backend/app/schemas/trade_in.py | 2 +- tradein-mvp/backend/app/services/estimator.py | 85 ++++++++++++------- .../tests/test_1970_estimator_constants.py | 15 ++++ .../test_estimator_anchor_dedup_gap_2265.py | 10 +-- .../tests/test_estimator_audit_fixes.py | 11 +-- ...imator_deals_headline_fallback_oblast_d.py | 4 +- .../tests/test_estimator_price_spine.py | 4 +- .../tests/test_same_building_anchor.py | 8 +- .../backend/tests/test_tier_a_primary_1774.py | 12 +-- .../frontend/src/app/mera-public/content.ts | 2 +- 11 files changed, 95 insertions(+), 114 deletions(-) diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 763c75fd..53841390 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -384,17 +384,6 @@ class Settings(BaseSettings): dadata_api_token: str | None = None dadata_api_secret: str | None = None - # ── #651: IMV / Yandex blend (killer accuracy fix) ────────────────────── - # Радиусная медиана ₽/м² системно недооценивает премиум/видовые квартиры - # (нет class/segment/IMV-коррекции → premium ~2x underestimate, case 50М vs - # факт ~100М). Если внешний якорь (Avito IMV recommended_price из - # house_imv_evaluations, либо Yandex sale) выше нашей медианы более чем в - # `threshold` раз — подмешиваем якорь к медиане с весом `weight` и - # расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем (баг — занижение). - # При отсутствии IMV/Yandex no-op (медиана не меняется). - estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w - estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15 - # #estimate-zero-analogs (прод-дефект, 2026-09-16): цена обязана опираться на # данные ЭТОГО адреса, а не на среднее по городу. # Репро: «Красногорск, Янтарная» отдавал 236 766 ₽/м², не имея рядом ни одного @@ -414,25 +403,6 @@ class Settings(BaseSettings): # False ⇒ прежнее поведение целиком, без релиза. estimate_require_local_evidence: bool = True - # ── #651/#652 v2: same-building anchor (validated, 55 golden cases) ────────── - # Радиусная медиана размывает премию дома/ЖК → премиум ~2.5x недооценка, - # комфорт −15-25%. v2 берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A), - # similarity-weighted по площади/комнатам, premium-uplift к ~p70 для топ-юнита - # дома, asking→sold haircut (banded по ppm²), hard guardrail (est ≥ min-comp×0.95) - # и tighter FSD-диапазон. - # Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2. - 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 по - # ОТНОСИТЕЛЬНОЙ вертикальной позиции (floor/total_floors). Прижимает якорь к - # комплам с похожим этажом — мелкокомнатный/нижний юнит во флагман-доме больше - # не наследует цену видового топ-этажа. 0.0 → выключено (точно старое поведение). - # Откалибровано на 55 golden (offline): σ_f=0.25 даёт лучший medAPE без потери - # покрытия; Хохрякова 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²)×(1−tol) - estimate_sb_mad_k: float = 3.5 # MAD-clip: drop comps с |ppm2−median| > k×MAD # ── #1966: honest calibrated prediction-interval для expected_sold range ───── # Старый expected_sold_range производился из IQR аналогов (asking-IQR × ratio): # ~55% реальных продаж попадали в заявленный «диапазон оценки» (де-факто 50%-й @@ -527,33 +497,7 @@ class Settings(BaseSettings): estimate_hedonic_first_floor_coef: float = -0.0745 # floor==1 ground-floor ≈ -7% estimate_hedonic_factor_min: float = 0.75 estimate_hedonic_factor_max: float = 1.30 - # ── #1795: premium headline anti-inflation (шаги 1/4/5 — константы estimator.py) ── - # Шаг 2 — ужесточённый MAD-clip на малых выборках в same-building anchor: - # при n < small_n_threshold используем mad_k_small вместо estimate_sb_mad_k - # (3.5 слишком мягкий при n=7 → элитные хвосты не срезаются, mean тянется вверх). - # mad_k_small >= estimate_sb_mad_k → no-op (старое поведение). - estimate_sb_mad_k_small_n: float = 2.5 - estimate_sb_small_n_threshold: int = 10 - # Шаг 3 — гейт Tier C: micro-radius anchor (НЕ тот же дом) с - # anchor_ppm2 > corridor_high×mult НЕ заменяет консервативную радиусную медиану. - # Очень большой mult (напр. 1e9) → гейт никогда не срабатывает (старое поведение). - estimate_anchor_tier_c_corridor_mult: float = 1.5 - # #1774: в Tier A (тот же дом) впускаем novostroyki-листинги ТОЛЬКО если в этом же - # доме есть ≥1 вторичный (vtorichka/NULL) листинг — признак сданного дома, где - # "novostroyki"-тег = переуступки/перепродажи собственниками (sale_type=free). - # Чисто-первичный дом (0 вторички) → гард #1186 сохраняется. Tier C / радиус / - # ratio — не затрагиваются. asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²) - estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона - - # ── #audit-1: anchor low-confidence gate ───────────────────────────────── - # Якорь с низкой уверенностью (confidence="low" ИЛИ n < min_n И FSD > max_fsd) - # НЕ заменяет headline — fallback на radius-median. Дефолты подобраны так, что - # здоровые якоря (n≥4 с FSD<0.15) проходят без изменений. - # estimate_sb_gate_min_n=3 : при n<3 И FSD>max_fsd гейт срабатывает - # estimate_sb_gate_max_fsd=0.20: FSD>0.20 при малом n → ненадёжный якорь - estimate_sb_gate_min_n: int = 3 - estimate_sb_gate_max_fsd: float = 0.20 # ── #audit-3: price_trend freshness filter ──────────────────────────────── # Исключать items старше N месяцев из price_trend (house_placement_history). diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index fd9913e0..2d94c982 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -140,7 +140,7 @@ class AvitoImvSummary(BaseModel): Источник: `house_imv_evaluations` (per house_id, обновляется регулярно). Это РЕАЛЬНАЯ рыночная оценка Avito по дому — служит anchor'ом для blend'а - (см. estimate_imv_blend_*). Сурфейсится в UI как референсный маркер на + (см. IMV_BLEND_* в estimator.py). Сурфейсится в UI как референсный маркер на ценовой шкале. None если для дома нет свежей IMV-записи. """ diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index d826ca12..e7c57415 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -479,6 +479,44 @@ OUTLIER_TUKEY_K_SMALL = 1.0 # Шаг 5 — expected_sold ≤ asking — выключателя не имеет (_price_from_inputs): ratio > 1.0 # для trade-in физически невозможен, это артефакт high-price бакета. +# #651 (#2381): IMV / Yandex blend. Радиусная медиана ₽/м² системно недооценивает +# премиум/видовые квартиры (premium ~2x underestimate). Если внешний якорь (Avito IMV +# recommended_price из house_imv_evaluations либо Yandex sale) выше медианы более чем +# в IMV_BLEND_THRESHOLD раз — подмешиваем его с весом IMV_BLEND_WEIGHT +# (median×(1-w)+A×w) и расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем. +IMV_BLEND_WEIGHT = 0.5 +IMV_BLEND_THRESHOLD = 1.15 + +# #651/#652 v2 (#2381): same-building anchor (validated, 55 golden cases). Радиусная +# медиана размывает премию дома/ЖК → премиум ~2.5x недооценка, комфорт −15-25%. v2 +# берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A), similarity-weighted по +# площади/комнатам, premium-uplift к ~p70 для топ-юнита, asking→sold haircut (banded +# по ppm²), hard guardrail и tighter FSD-диапазон. +# Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2. +SB_MIN_COMPS = 4 # стоп на первом тире с ≥ N активных комплов +SB_AREA_SIGMA = 0.18 # σ log-нормального area-веса (Gaussian) +SB_ROOMS_MATCH_BOOST = 1.6 # ×вес если rooms компла == target +# #680-WB: floor-similarity Gaussian по ОТНОСИТЕЛЬНОЙ вертикальной позиции +# (floor/total_floors) — мелкокомнатный/нижний юнит во флагман-доме не наследует цену +# видового топ-этажа. σ_f=0.25 — лучший medAPE на 55 golden без потери покрытия +# (Хохрякова 3к/153 overshoot 64%→1.5%, флагман 4к 17.5%→5.4%). +SB_FLOOR_SIGMA = 0.25 +SB_GUARDRAIL_TOL = 0.05 # hard floor: est ≥ min(comp ppm²)×(1−tol) +SB_MAD_K = 3.5 # MAD-clip: drop comps с |ppm2−median| > k×MAD +# #1795 шаг 2: на малых выборках (n < SB_SMALL_N_THRESHOLD, кроме Tier A) MAD-clip +# жёстче — 3.5 при n=7 не срезает элитные хвосты, weighted mean тянется вверх. +SB_MAD_K_SMALL_N = 2.5 +SB_SMALL_N_THRESHOLD = 10 +# #1795 шаг 3: гейт Tier C — micro-radius якорь (НЕ тот же дом) с +# anchor_ppm2 > corridor_high × mult не заменяет радиусную медиану. +ANCHOR_TIER_C_CORRIDOR_MULT = 1.5 +FSD_K = 1.65 # множитель FSD → полуширина диапазона якоря +# #audit-1: low-confidence гейт якоря — n < SB_GATE_MIN_N И FSD > SB_GATE_MAX_FSD +# (или confidence="low") → fallback на радиусную медиану. Здоровые якоря (n≥4, +# FSD<0.15) проходят без изменений. +SB_GATE_MIN_N = 3 +SB_GATE_MAX_FSD = 0.20 + # #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются # нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади — # которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные @@ -2655,7 +2693,7 @@ def _fetch_anchor_comps( ключами price_per_m2 (int>0), area_m2 (float|None), rooms (int|None), floor (int|None), total_floors (int|None) — последние два для floor-веса (#680-WB). """ - min_comps = settings.estimate_sb_min_comps + min_comps = SB_MIN_COMPS # ── Tier A: same building ──────────────────────────────────────────────── street, base_no, letter = _normalize_building_key(address) @@ -3038,12 +3076,8 @@ def _compute_same_building_anchor( # легитимен, агрессивный clip там съел бы реальные топ-юниты и обрушил бы якорь # < min_comps → fallback на заниженную радиусную медиану. Tier C/прочие — clip. effective_mad_k = mad_k - if ( - tier != "A" - and settings.estimate_sb_mad_k_small_n < mad_k - and len(raw_ppm2) < settings.estimate_sb_small_n_threshold - ): - effective_mad_k = settings.estimate_sb_mad_k_small_n + if tier != "A" and SB_MAD_K_SMALL_N < mad_k and len(raw_ppm2) < SB_SMALL_N_THRESHOLD: + effective_mad_k = SB_MAD_K_SMALL_N surviving_idx = _mad_clip(raw_ppm2, effective_mad_k) if len(surviving_idx) < min_comps: logger.info( @@ -3654,32 +3688,28 @@ def _price_from_inputs( area_target=area_m2, rooms_target=rooms, tier=anchor_tier or "", - sigma=settings.estimate_sb_area_sigma, - rooms_boost=settings.estimate_sb_rooms_match_boost, + sigma=SB_AREA_SIGMA, + rooms_boost=SB_ROOMS_MATCH_BOOST, floor_target=floor, total_floors_target=total_floors, - floor_sigma=settings.estimate_sb_floor_sigma, - min_comps=settings.estimate_sb_min_comps, - mad_k=settings.estimate_sb_mad_k, + floor_sigma=SB_FLOOR_SIGMA, + min_comps=SB_MIN_COMPS, + mad_k=SB_MAD_K, ) # #1795 шаг 3: гейт Tier C. - if ( - anchor is not None - and anchor_tier == "C" - and settings.estimate_anchor_tier_c_corridor_mult > 0 - ): + if anchor is not None and anchor_tier == "C": if dkp_raw is not None and dkp_raw.get("high_ppm2", 0) > 0: corridor_high_for_gate = float(dkp_raw["high_ppm2"]) else: corridor_high_for_gate = (median_ppm2 / repair_coef) * 1.3 if repair_coef else 0.0 - gate_threshold = corridor_high_for_gate * settings.estimate_anchor_tier_c_corridor_mult + gate_threshold = corridor_high_for_gate * ANCHOR_TIER_C_CORRIDOR_MULT if gate_threshold > 0 and anchor["anchor_ppm2"] > gate_threshold: logger.info( "sb_anchor Tier C gate #1795: anchor_ppm2=%d > corridor_high×%.1f=%d" " → keep radius median (anchor suppressed)", int(anchor["anchor_ppm2"]), - settings.estimate_anchor_tier_c_corridor_mult, + ANCHOR_TIER_C_CORRIDOR_MULT, int(gate_threshold), ) anchor = None @@ -3687,10 +3717,7 @@ def _price_from_inputs( # #audit-1: low-confidence gate. if anchor is not None: gate_low = anchor["confidence"] == "low" - gate_thin = ( - anchor["n"] < settings.estimate_sb_gate_min_n - and anchor["fsd"] > settings.estimate_sb_gate_max_fsd - ) + gate_thin = anchor["n"] < SB_GATE_MIN_N and anchor["fsd"] > SB_GATE_MAX_FSD if gate_low or gate_thin: logger.info( "sb_anchor low-conf gate #audit-1: tier=%s n=%d fsd=%.3f conf=%s" @@ -3730,13 +3757,13 @@ def _price_from_inputs( anchor_comps_used = anchor.get("comps") or anchor_comps est_ppm2 = anchor["anchor_ppm2"] # PREMIUM GUARDRAIL (hard). - floor_ppm2 = anchor["comp_min_ppm2"] * (1.0 - settings.estimate_sb_guardrail_tol) + floor_ppm2 = anchor["comp_min_ppm2"] * (1.0 - SB_GUARDRAIL_TOL) if est_ppm2 < floor_ppm2: est_ppm2 = floor_ppm2 new_ppm2 = est_ppm2 * repair_coef point = int(new_ppm2 * area_m2) # FSD-диапазон. - half = settings.estimate_fsd_k * anchor["fsd"] + half = FSD_K * anchor["fsd"] new_range_low = int(point * max(0.0, 1.0 - half)) new_range_high = int(point * (1.0 + half)) # Спред комплов. @@ -3893,8 +3920,8 @@ def _price_from_inputs( area=area_m2, anchor_total=anchor_total, anchor_higher=anchor_higher, - weight=settings.estimate_imv_blend_weight, - threshold=settings.estimate_imv_blend_threshold, + weight=IMV_BLEND_WEIGHT, + threshold=IMV_BLEND_THRESHOLD, market_count=( avito_imv_summary.market_count if avito_imv_summary is not None else None ), @@ -3906,7 +3933,7 @@ def _price_from_inputs( median_price, new_median, anchor_used, - settings.estimate_imv_blend_weight, + IMV_BLEND_WEIGHT, range_high, new_range_high, ) @@ -4277,7 +4304,7 @@ def _price_from_inputs( # #oblast-E: guards on `anchor is None` (the actual computed anchor dict), # NOT `anchor_tier is None`. Found via live backtest-fixture regen: when # `_compute_same_building_anchor` rejects a candidate outright (e.g. its - # own MAD-clip drops comps below estimate_sb_min_comps), it returns None + # own MAD-clip drops comps below SB_MIN_COMPS), it returns None # WITHOUT the caller resetting `anchor_tier` back to None (it stays # whatever `anchor_tier_fetched` was, e.g. "C") — the anchor never fired, # but the stale tier flag falsely reads as "anchor claimed the headline" diff --git a/tradein-mvp/backend/tests/test_1970_estimator_constants.py b/tradein-mvp/backend/tests/test_1970_estimator_constants.py index 4be8989f..a2421aac 100644 --- a/tradein-mvp/backend/tests/test_1970_estimator_constants.py +++ b/tradein-mvp/backend/tests/test_1970_estimator_constants.py @@ -37,6 +37,21 @@ _PROD_VALUES: dict[str, tuple[str, float]] = { "RADIUS_FLOOR_FACTOR": ("estimate_radius_floor_factor", 0.8), "OUTLIER_SMALL_N_THRESHOLD": ("estimate_outlier_small_n_threshold", 15), "OUTLIER_TUKEY_K_SMALL": ("estimate_outlier_tukey_k_small", 1.0), + # #2381, #651/#652 same-building anchor + IMV-blend + "IMV_BLEND_WEIGHT": ("estimate_imv_blend_weight", 0.5), + "IMV_BLEND_THRESHOLD": ("estimate_imv_blend_threshold", 1.15), + "SB_MIN_COMPS": ("estimate_sb_min_comps", 4), + "SB_AREA_SIGMA": ("estimate_sb_area_sigma", 0.18), + "SB_ROOMS_MATCH_BOOST": ("estimate_sb_rooms_match_boost", 1.6), + "SB_FLOOR_SIGMA": ("estimate_sb_floor_sigma", 0.25), + "SB_GUARDRAIL_TOL": ("estimate_sb_guardrail_tol", 0.05), + "SB_MAD_K": ("estimate_sb_mad_k", 3.5), + "SB_MAD_K_SMALL_N": ("estimate_sb_mad_k_small_n", 2.5), + "SB_SMALL_N_THRESHOLD": ("estimate_sb_small_n_threshold", 10), + "ANCHOR_TIER_C_CORRIDOR_MULT": ("estimate_anchor_tier_c_corridor_mult", 1.5), + "FSD_K": ("estimate_fsd_k", 1.65), + "SB_GATE_MIN_N": ("estimate_sb_gate_min_n", 3), + "SB_GATE_MAX_FSD": ("estimate_sb_gate_max_fsd", 0.20), } # Булевы выключатели, схлопнутые к боевому True: их OFF-ветки из кода удалены. diff --git a/tradein-mvp/backend/tests/test_estimator_anchor_dedup_gap_2265.py b/tradein-mvp/backend/tests/test_estimator_anchor_dedup_gap_2265.py index 1a7386f1..9987f3e9 100644 --- a/tradein-mvp/backend/tests/test_estimator_anchor_dedup_gap_2265.py +++ b/tradein-mvp/backend/tests/test_estimator_anchor_dedup_gap_2265.py @@ -101,7 +101,7 @@ _DUP_LOT_DOMKLIK = _comp( # 3 доп. уникальных компла того же дома/подъезда — ppm2 близки к дубль-лоту # (119k-125k), MAD-clip (k=3.5) их не тронет; вместе с дубль-лотом дают -# estimate_sb_min_comps=4 unique комплов ПОСЛЕ дедупа → якорь срабатывает. +# SB_MIN_COMPS=4 unique комплов ПОСЛЕ дедупа → якорь срабатывает. _OTHER_COMPS = [ _comp( source="avito", address="улица Сыромолотова, 13", area=40.0, price_rub=5_000_000, floor=5 @@ -132,10 +132,10 @@ def test_anchor_path_dedups_cross_source_duplicate_lot(monkeypatch: pytest.Monke После фикса: дедуп схлопывает пару в 1 представителя ДО _compute_same_building_ anchor → est.analogs содержит физлот ровно ОДИН раз (n=1). """ - from app.core.config import settings + from app.services import estimator with monkeypatch.context() as m: - m.setattr(settings, "estimate_sb_min_comps", 1) + m.setattr(estimator, "SB_MIN_COMPS", 1) est = _h._run_estimate(anchor_comps=_MINIMAL_DUP_PAIR, anchor_tier="A", payload=_payload()) assert len(est.analogs) == 1 # НЕ 2 — дубль-лот схлопнут в 1 представителя @@ -151,10 +151,10 @@ def test_anchor_path_n_analogs_matches_shown_count(monkeypatch: pytest.MonkeyPat (постдедуп) analogs, а не сырой недедупленный anchor-пул. До фикса n_analogs=2 при одном реальном физлоте (дубль посчитан дважды) — самосогласованно, но нечестно. После фикса n_analogs=1, синхронно с analogs.""" - from app.core.config import settings + from app.services import estimator with monkeypatch.context() as m: - m.setattr(settings, "estimate_sb_min_comps", 1) + m.setattr(estimator, "SB_MIN_COMPS", 1) est = _h._run_estimate(anchor_comps=_MINIMAL_DUP_PAIR, anchor_tier="A", payload=_payload()) assert est.n_analogs == len(est.analogs) == 1 diff --git a/tradein-mvp/backend/tests/test_estimator_audit_fixes.py b/tradein-mvp/backend/tests/test_estimator_audit_fixes.py index f6cb2430..83833adc 100644 --- a/tradein-mvp/backend/tests/test_estimator_audit_fixes.py +++ b/tradein-mvp/backend/tests/test_estimator_audit_fixes.py @@ -101,19 +101,16 @@ def test_fix1_thin_n_high_fsd_triggers_gate() -> None: ) if anchor is None: pytest.skip("MAD-clip отсёк — нет якоря, тест не применим") - from app.core.config import settings + from app.services.estimator import SB_GATE_MAX_FSD, SB_GATE_MIN_N # Проверяем условие gate_thin напрямую - gate_thin = ( - anchor["n"] < settings.estimate_sb_gate_min_n - and anchor["fsd"] > settings.estimate_sb_gate_max_fsd - ) + gate_thin = anchor["n"] < SB_GATE_MIN_N and anchor["fsd"] > SB_GATE_MAX_FSD # n=2 < 3 — должен сработать если FSD тоже высокий assert anchor["n"] == 2 # FSD = 0.07 + 0.25*CV + tier_penalty + n_penalty; n_penalty=0.05 при n<3; # tier_penalty=0.05 (C); CV = std/mean для 2 элементов # Ожидаем что n=2 с умеренным разбросом даёт FSD ≈ 0.07+...≥0.20 - if anchor["fsd"] > settings.estimate_sb_gate_max_fsd: + if anchor["fsd"] > SB_GATE_MAX_FSD: assert gate_thin, "gate_thin должен быть True при n=2 и high FSD" @@ -308,8 +305,6 @@ def test_fix4_premium_comp_survives_post_weight_clip() -> None: comps = [_make_comp(base) for _ in range(4)] + [_make_comp(base * 1.3)] with patch("app.services.estimator.settings") as mock_settings: - mock_settings.estimate_sb_mad_k_small_n = 2.5 - mock_settings.estimate_sb_small_n_threshold = 10 mock_settings.avito_imv_thin_market_threshold = 10 mock_settings.sber_index_max_age_days = 35 diff --git a/tradein-mvp/backend/tests/test_estimator_deals_headline_fallback_oblast_d.py b/tradein-mvp/backend/tests/test_estimator_deals_headline_fallback_oblast_d.py index 2b8f2a3c..3d7ef194 100644 --- a/tradein-mvp/backend/tests/test_estimator_deals_headline_fallback_oblast_d.py +++ b/tradein-mvp/backend/tests/test_estimator_deals_headline_fallback_oblast_d.py @@ -313,7 +313,7 @@ _EKB_LENINA_LISTING = { def _ekb_lenina_pool(prices_per_m2: list[float]) -> list[dict[str, Any]]: - """N distinct EKB "ул. Ленина" comps (>= estimate_sb_min_comps=4 needed for + """N distinct EKB "ул. Ленина" comps (>= SB_MIN_COMPS=4 needed for Tier A to actually FIRE — see _fetch_anchor_comps `len(comps) >= min_comps`). Distinct floor/source_id/price_rub per row so `_dedup_cross_source` (#2265, street+floor+area+price physical key) treats them as distinct units, not @@ -407,7 +407,7 @@ def test_non_ekb_anchor_not_leaked_from_ekb_street_collision() -> None: "period_months": 12, } db = MagicMock() - # >= estimate_sb_min_comps EKB comps — realistic (40 191 of ~40 200 active + # >= SB_MIN_COMPS EKB comps — realistic (40 191 of ~40 200 active # listings are EKB) and necessary for Tier A to actually fire pre-fix. db.execute.side_effect = _fake_anchor_sql_execute( _ekb_lenina_pool([178_000.0, 186_000.0, 190_000.0, 184_000.0]), diff --git a/tradein-mvp/backend/tests/test_estimator_price_spine.py b/tradein-mvp/backend/tests/test_estimator_price_spine.py index 162fa2cc..b0aa665c 100644 --- a/tradein-mvp/backend/tests/test_estimator_price_spine.py +++ b/tradein-mvp/backend/tests/test_estimator_price_spine.py @@ -252,10 +252,10 @@ def test_anchor_tier_reset_when_anchor_not_built() -> None: Третий путь к anchor=None (#2661), отдельный от Tier C гейта и low-conf гейта выше: когда ``_compute_same_building_anchor`` возвращает None САМА (комплов меньше - ``estimate_sb_min_comps``=4), флаг раньше оставался равным ``anchor_tier_fetched`` — + ``SB_MIN_COMPS``=4), флаг раньше оставался равным ``anchor_tier_fetched`` — дальше по коду он читается как «headline построил якорь». """ - comps = [_anchor_comp(150_000), _anchor_comp(155_000)] # 2 < estimate_sb_min_comps=4 + comps = [_anchor_comp(150_000), _anchor_comp(155_000)] # 2 < SB_MIN_COMPS=4 radius_median_price = int(100_000 * 50.0) pr = _call( listings=_lots(100_000, n=5), diff --git a/tradein-mvp/backend/tests/test_same_building_anchor.py b/tradein-mvp/backend/tests/test_same_building_anchor.py index 2fadaeca..f2d8eba8 100644 --- a/tradein-mvp/backend/tests/test_same_building_anchor.py +++ b/tradein-mvp/backend/tests/test_same_building_anchor.py @@ -393,7 +393,7 @@ _RADIUS_ANALOGS: list[dict[str, Any]] = [ ] # Same-building комплы Хохрякова 48 (флагман 684k внутри). -# 4 комплов — удовлетворяет estimate_sb_min_comps=4 (#755). +# 4 комплов — удовлетворяет 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}, @@ -527,7 +527,7 @@ def test_estimate_no_anchor_no_radius_stays_insufficient() -> None: def test_estimate_economy_no_regression() -> None: """(b) Эконом-комплы ~112k → guardrail не раздувает, headline ≈ комплов. - 4 компла — satisfies estimate_sb_min_comps=4 (#755). + 4 компла — satisfies SB_MIN_COMPS=4 (#755). Комплы специально подобраны так, что MAD-clip не удаляет ни один из них (все отклонения << 3.5×MAD при tight spread).""" eco_comps = [ @@ -596,7 +596,7 @@ 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-кейса). - 4 компла — satisfies estimate_sb_min_comps=4 (#755).""" + 4 компла — satisfies SB_MIN_COMPS=4 (#755).""" # comp max 255_459 ppm² — самый дорогой лот в доме (видовой). target — рядовой. view_comps = [ {"price_per_m2": 124_309, "area_m2": 54.3, "rooms": 2}, @@ -663,7 +663,7 @@ 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). - 4 компла — satisfies estimate_sb_min_comps=4 (#755).""" + 4 компла — satisfies SB_MIN_COMPS=4 (#755).""" comps_with_display = [ { "price_per_m2": 399_478, diff --git a/tradein-mvp/backend/tests/test_tier_a_primary_1774.py b/tradein-mvp/backend/tests/test_tier_a_primary_1774.py index 373ab2b0..abc94f4e 100644 --- a/tradein-mvp/backend/tests/test_tier_a_primary_1774.py +++ b/tradein-mvp/backend/tests/test_tier_a_primary_1774.py @@ -121,7 +121,7 @@ def test_tier_a_includes_novostroyki_when_secondary_present() -> None: _row(source="cian", source_id="c2", listing_segment="novostroyki"), ] db = _db_mock(rows) - with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): + with patch.object(est_mod, "SB_MIN_COMPS", 4): comps, tier = _fetch(db, lat=_LAT, lon=_LON) assert tier == "A" # Все 6 (4 вторички + 2 novostroyki-переуступки) учтены. @@ -146,7 +146,7 @@ def test_tier_a_excludes_novostroyki_when_primary_dominated() -> None: _row(source="cian", source_id="c5", listing_segment="novostroyki"), ] db = _db_mock(rows) - with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): + with patch.object(est_mod, "SB_MIN_COMPS", 4): comps, tier = _fetch(db) # primary-dominated → 5 novostroyki отброшены, 1 vtorichka < min_comps → Tier A skip. assert tier is None @@ -168,7 +168,7 @@ def test_tier_a_excludes_novostroyki_when_pure_primary() -> None: _row(source="cian", source_id="c4", listing_segment="novostroyki"), ] db = _db_mock(rows) - with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): + with patch.object(est_mod, "SB_MIN_COMPS", 4): comps, tier = _fetch(db) # Все отброшены гардом → 0 comps < 4 → Tier A skip (lat/lon None → Tier D). assert tier is None @@ -193,7 +193,7 @@ def test_tier_a_dedup_same_source_id_collapses() -> None: _row(source="cian", source_url=dup_url, source_id="330047129", listing_segment=None), ] db = _db_mock(rows) - with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): + with patch.object(est_mod, "SB_MIN_COMPS", 4): comps, tier = _fetch(db, lat=_LAT, lon=_LON) assert tier == "A" # 6 строк, но 2 cian-строки с одинаковым source_id → 1 comp → итого 5. @@ -229,7 +229,7 @@ def test_tier_a_dedup_same_source_id_different_url_collapses() -> None: ), ] db = _db_mock(rows) - with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): + with patch.object(est_mod, "SB_MIN_COMPS", 4): comps, tier = _fetch(db, lat=_LAT, lon=_LON) assert tier == "A" # source_id-primary схлопывает несмотря на разные url → 5 comps. @@ -248,7 +248,7 @@ def test_tier_a_dedup_null_url_keeps_distinct_rows() -> None: _row(source="avito", source_url=None, source_id="a4", area_m2=80.0), ] db = _db_mock(rows) - with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): + with patch.object(est_mod, "SB_MIN_COMPS", 4): comps, tier = _fetch(db, lat=_LAT, lon=_LON) assert tier == "A" # 4 разных лота (разные source_id/площадь) → 4 comps, ничего не схлопнуто. diff --git a/tradein-mvp/frontend/src/app/mera-public/content.ts b/tradein-mvp/frontend/src/app/mera-public/content.ts index 586e0116..b68988df 100644 --- a/tradein-mvp/frontend/src/app/mera-public/content.ts +++ b/tradein-mvp/frontend/src/app/mera-public/content.ts @@ -370,7 +370,7 @@ export interface SourceGroup { * ПОЧЕМУ ТРЕТЬЯ ГРУППА ЕСТЬ (а не «два типа данных», как было). Оценки площадок * — не украшение экрана: в `backend/app/services/estimator.py` (блок «#651: IMV * / Yandex blend», Tier D — когда якоря по дому/≤500 м нет) медиана - * переписывается на `new_median` с весом `estimate_imv_blend_weight`, + * переписывается на `new_median` с весом `IMV_BLEND_WEIGHT`, * объяснение дополняется «Оценка скорректирована по…», а `sources_used` * пополняется `avito_imv`. Умолчать об этом — значит утверждать на публичной * странице то, чего код не делает.