From 55c7bc2c72563a1dabdd1f95dc3d5e63724b83fb Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 20 Jun 2026 14:59:05 +0300 Subject: [PATCH 1/6] fix(estimator): anchor low-confidence gate (#audit-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Якорь с confidence=low ИЛИ (ngate_max_fsd) не заменяет headline — fallback на radius-median. Дефолты (min_n=3, max_fsd=0.20) не режут здоровые якоря (n≥4, FSD<0.15 проходят). За флагом estimate_sb_low_conf_gate_enabled (дефолт True). Логируется с причиной. --- tradein-mvp/backend/app/core/config.py | 29 +++++++++++++++++++ tradein-mvp/backend/app/services/estimator.py | 22 ++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 543276e8..05366fd1 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -134,6 +134,35 @@ class Settings(BaseSettings): 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 → ненадёжный якорь + # Отдельный флаг: False → точно старое поведение (гейта нет). + estimate_sb_low_conf_gate_enabled: bool = True + 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). + # Дефолт 6 (консервативно); аудит предложил 3 — конфигурируемо. + estimate_price_trend_max_age_months: int = 6 + + # ── #audit-4: MAD-clip after similarity-weighting ───────────────────────── + # True = clip происходит ПОСЛЕ similarity-weighting (на взвешенных ppm²). + # False = clip ДО weighting (старое поведение). Дефолт True. + estimate_sb_clip_after_weight: bool = True + + # ── #audit-5: data-age guards ───────────────────────────────────────────── + # sber_index_max_age_days: максимальный допустимый возраст последнего месяца + # СберИндекс-серии (дней). Если latest месяц старее — логируем warning. + sber_index_max_age_days: int = 35 + # avito_imv_thin_market_threshold: если market_count < порога — IMV-оценка + # на тонком рынке (thin_market=True в AvitoImvSummary) + warning. + avito_imv_thin_market_threshold: int = 10 + # ── #764: per-cadastral-quarter price index correction ─────────────────── # Gap-correction: квартальный индекс применяется ТОЛЬКО в pure-radius пути # (когда same-building anchor и IMV-blend не сработали). Корректирует РАЗРЫВ diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 7566cd7a..75b915e9 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -2321,6 +2321,28 @@ async def estimate_quality( int(gate_threshold), ) anchor = None + # #audit-1: low-confidence gate — якорь с низкой уверенностью или малым n + # при высоком FSD НЕ заменяет headline; fallback на radius-median. + # Tier A (n≥4, FSD<0.15) проходит без изменений при дефолтных порогах. + if anchor is not None and settings.estimate_sb_low_conf_gate_enabled: + gate_low = anchor["confidence"] == "low" + gate_thin = ( + anchor["n"] < settings.estimate_sb_gate_min_n + and anchor["fsd"] > settings.estimate_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" + " → suppressed (gate_low=%s gate_thin=%s) → radius fallback", + anchor_tier, + anchor["n"], + anchor["fsd"], + anchor["confidence"], + gate_low, + gate_thin, + ) + anchor = None + anchor_tier = None if anchor is not None: # #694: якорь мутирует headline — UI-аналоги должны отражать ЭТИ комплы. anchor_comps_used = comps From 6b99f7d23843b9aa6b3f71d73c6c9af85fd48a07 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 20 Jun 2026 15:00:12 +0300 Subject: [PATCH 2/6] =?UTF-8?q?fix(estimator):=20=D1=81=D1=82=D1=80=D1=83?= =?UTF-8?q?=D0=BA=D1=82=D1=83=D1=80=D0=BD=D1=8B=D0=B9=20analog=5Ftier=20+?= =?UTF-8?q?=20address=5Fprecision=20=D0=B2=20API=20(#audit-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавляет AggregatedEstimate.analog_tier — стабильный enum для фронта: same_building (Tier A), micro_radius (Tier C), district (S/H/0), city (W), null (нет данных). Фронт не парсит tier из explanation. address_precision уже был в схеме (подтверждено: line 208). Explanation не удаляется — фронт fallback'ает на него. --- tradein-mvp/backend/app/schemas/trade_in.py | 9 +++++++++ tradein-mvp/backend/app/services/estimator.py | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 777fce6d..9173d75d 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -206,6 +206,15 @@ class AggregatedEstimate(BaseModel): # «approximate» (qc_geo≥2: населённый пункт/город/регион/не распознан). # None если DaData не отрабатывала (адрес не геокодирован) / credentials не заданы. address_precision: Literal["house", "street", "approximate"] | None = None + # analog_tier — стабильный enum-тир источника аналогов (#audit-2). + # Значения (дословно, фронт на них завязан): + # "same_building" — Tier A: якорь из комплов ТОГО ЖЕ дома + # "micro_radius" — Tier C: якорь из micro-radius (≤500 м) + # "district" — radius-путь (Tier 0/S/H): когортный/узкий радиус + # "city" — radius Tier W: широкий fallback + # null — нет данных / оценка не построена + # НЕ удаляет/заменяет confidence_explanation (фронт fallback'ает на него). + analog_tier: Literal["same_building", "micro_radius", "district", "city"] | None = None # ── Параметры оценённой квартиры — нужны, чтобы восстановить карточку # при открытии оценки по ссылке (?id=), когда формы-инпута уже нет ── area_m2: float | None = None diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 75b915e9..7549975c 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -2985,6 +2985,22 @@ async def estimate_quality( else None ) + # #audit-2: структурный analog_tier — стабильный enum для фронта. + # anchor-путь: anchor_tier "A" → "same_building", "C" → "micro_radius". + # radius-путь: analog_tier "W" → "city", остальные → "district". + # None только если нет аналогов (median=0, insufficient_data=True). + if median_price > 0: + if anchor_tier == "A": + api_analog_tier: str | None = "same_building" + elif anchor_tier == "C": + api_analog_tier = "micro_radius" + elif analog_tier == "W": + api_analog_tier = "city" + else: + api_analog_tier = "district" + else: + api_analog_tier = None + return AggregatedEstimate( estimate_id=estimate_id, median_price_rub=median_price, @@ -3049,6 +3065,7 @@ async def estimate_quality( house_fias_id=dadata.house_fias_id if dadata else None, metro_nearest=(dadata.metro if dadata and dadata.metro else []), address_precision=_qc_geo_to_precision(dadata.qc_geo if dadata else None), + analog_tier=api_analog_tier, # type: ignore[arg-type] ) @@ -4249,4 +4266,5 @@ def _empty_estimate( cian_valuation=None, # Адрес не геокодирован (DaData не отрабатывала) → точность неизвестна. address_precision=None, + analog_tier=None, # нет данных при empty estimate ) From 17edac532bd730a23ee6abc160a2cd6beecfea84 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 20 Jun 2026 15:00:55 +0300 Subject: [PATCH 3/6] =?UTF-8?q?fix(estimator):=20Yandex=20history=20freshn?= =?UTF-8?q?ess=20filter=20=D0=B2=20price=5Ftrend=20(#audit-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _fetch_price_trend(freshness_months) — новый параметр, дефолт из settings.estimate_price_trend_max_age_months (6 мес). WHERE scraped_at > now() - N months исключает устаревшие items (yandex_valuation 2024 и т.д.) из house_placement_history fallback. houses_price_dynamics (Source 1) не затронут. psycopg v3 CAST syntax. --- tradein-mvp/backend/app/services/estimator.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 7549975c..34916dcd 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -2978,7 +2978,12 @@ async def estimate_quality( freshness_min = _compute_freshness_minutes(metadata_lots) last_scraped_at = _compute_last_scraped_at(metadata_lots) # Месячный ₽/м² тренд целевого дома (web TREND chart) — best-effort, None если нет данных. - price_trend_raw = _fetch_price_trend(db, target_house_id=target_house_id) + # #audit-3: передаём freshness_months из настроек — исключаем устаревшие items. + price_trend_raw = _fetch_price_trend( + db, + target_house_id=target_house_id, + freshness_months=settings.estimate_price_trend_max_age_months, + ) price_trend = ( [PriceTrendPoint(month=p["month"], ppm2=p["ppm2"]) for p in price_trend_raw] if price_trend_raw @@ -3143,6 +3148,7 @@ def _fetch_price_trend( target_house_id: int | None, months: int = 24, min_points: int = 3, + freshness_months: int | None = None, ) -> list[dict[str, Any]] | None: """Месячный ₽/м² тренд для целевого дома (web TREND chart) — best-effort. @@ -3200,6 +3206,10 @@ def _fetch_price_trend( return trend # ── Source 2 (fallback): aggregate house_placement_history by month ────── + # #audit-3: freshness_months — фильтр по scraped_at чтобы исключить стale items + # (yandex_valuation из 2024 и т.д.). Применяется ко ВСЕМ source в этом запросе + # (avito_imv + yandex_valuation), не только к yandex_valuation. Дефолт из config. + _fresh_months = freshness_months if freshness_months is not None else months try: rows = ( db.execute( @@ -3223,11 +3233,14 @@ def _fetch_price_trend( AND COALESCE(last_price_date, start_price_date) IS NOT NULL AND COALESCE(last_price_date, start_price_date) > (CURRENT_DATE - (CAST(:months AS integer) || ' months')::interval) + AND scraped_at > (now() + - CAST(:fresh_months AS integer) + * CAST('1 month' AS interval)) GROUP BY 1 ORDER BY 1 ASC """ ), - {"hid": target_house_id, "months": months}, + {"hid": target_house_id, "months": months, "fresh_months": _fresh_months}, ) .mappings() .all() From 5b2a39cd5e7c8ca542f7e35bd783240476e9d3c5 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 20 Jun 2026 15:01:36 +0300 Subject: [PATCH 4/6] =?UTF-8?q?fix(estimator):=20MAD-clip=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=81=D0=BB=D0=B5=20similarity-weighting=20+=20sigma=3D0=20gua?= =?UTF-8?q?rd=20(#audit-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sigma=0 guard: safe_area_sigma2/safe_floor_sigma2 предотвращает div/0 при нулевом sigma из конфига. Post-weight MAD-clip (estimate_sb_clip_after_weight, дефолт True) выполняется ПОСЛЕ Gaussian weighting — видовые/топ-юниты с высоким ppm² больше не выкидываются до учёта similarity. За флагом. --- tradein-mvp/backend/app/services/estimator.py | 50 ++++++++++++++++--- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 34916dcd..59a86f47 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -1721,25 +1721,28 @@ def _compute_same_building_anchor( if floor_sigma > 0 and floor_target and total_floors_target and total_floors_target > 0: target_pos = floor_target / total_floors_target + # #audit-4: sigma > 0 guard — при sigma=0 Gaussian exp(-x²/0) → div/0/NaN. + # area_sigma=0 (отключено) → нейтральный вес 1.0; floor_sigma=0 уже гейтится выше. + safe_area_sigma2 = 2.0 * sigma * sigma if sigma > 0 else 0.0 + safe_floor_sigma2 = 2.0 * floor_sigma * floor_sigma if floor_sigma > 0 else 0.0 + # 1. similarity weights (area × rooms × floor-position) weights: list[float] = [] for c in comps: a = c.get("area_m2") - if a and area_target > 0: - area_w = math.exp(-((math.log(a / area_target)) ** 2) / (2.0 * sigma * sigma)) + if a and area_target > 0 and safe_area_sigma2 > 0: + area_w = math.exp(-((math.log(a / area_target)) ** 2) / safe_area_sigma2) else: - area_w = 1.0 # площадь неизвестна → нейтральный area-вес + area_w = 1.0 # площадь неизвестна или sigma=0 → нейтральный area-вес rooms_match = rooms_target is not None and c.get("rooms") == rooms_target rooms_w = rooms_boost if rooms_match else 1.0 floor_w = 1.0 - if target_pos is not None: + if target_pos is not None and safe_floor_sigma2 > 0: cf = c.get("floor") ctf = c.get("total_floors") if cf and ctf and ctf > 0: comp_pos = cf / ctf - floor_w = math.exp( - -((comp_pos - target_pos) ** 2) / (2.0 * floor_sigma * floor_sigma) - ) + floor_w = math.exp(-((comp_pos - target_pos) ** 2) / safe_floor_sigma2) # компл без floor/total_floors → нейтральный floor-вес 1.0 weights.append(area_w * rooms_w * floor_w) wsum = sum(weights) @@ -1748,6 +1751,39 @@ def _compute_same_building_anchor( else: anchor = _percentile(sorted(ppm2), 0.5) + # #audit-4: MAD-clip ПОСЛЕ similarity-weighting (за флагом estimate_sb_clip_after_weight). + # Видовые/топ-юниты с высоким ppm² могут быть выкинуты сырым clip'ом ДО weighting — + # они легитимны. После weighting (anchor = weighted mean) ищем выбросы + # относительно self-consistent взвешенного пространства. + # Используем те же effective_mad_k; если после clip < min_comps → anchor=None. + if settings.estimate_sb_clip_after_weight and n >= 2: + post_clip_idx = _mad_clip(ppm2, effective_mad_k) + if len(post_clip_idx) < min_comps: + logger.info( + "anchor post-weight MAD-clip #audit-4: %d comps → %d survived" + " (< min_comps=%d) → fallback", + n, + len(post_clip_idx), + min_comps, + ) + return None + if len(post_clip_idx) < n: + logger.info( + "anchor post-weight MAD-clip #audit-4: %d comps → %d after k=%.1f×MAD", + n, + len(post_clip_idx), + effective_mad_k, + ) + comps = [comps[i] for i in post_clip_idx] + ppm2 = [ppm2[i] for i in post_clip_idx] + weights = [weights[i] for i in post_clip_idx] + n = len(ppm2) + wsum = sum(weights) + if wsum > 0: + anchor = sum(w * p for w, p in zip(weights, ppm2, strict=True)) / wsum + else: + anchor = _percentile(sorted(ppm2), 0.5) + # 2. premium uplift — топ-юнит дома (площадь ≥ p66 И комнаты ≥ медианы) И Tier A # → weighted p70. Условие по комнатам отсекает мелкие юниты во флагман-домах. used_uplift = False From a6ef2c73e1fa0db6d57ad7718c5df4667f08bc02 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 20 Jun 2026 15:03:19 +0300 Subject: [PATCH 5/6] =?UTF-8?q?fix(estimator):=20data-age=20guards=20?= =?UTF-8?q?=E2=80=94=20sber=20staleness=20+=20IMV=20thin-market=20(#audit-?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5a: _load_sber_index_series логирует warning если latest месяц серии старее sber_index_max_age_days (дефолт 35) — расчёт не блокируется. 5b: AvitoImvSummary.thin_market (bool, дефолт False) — True когда market_count < avito_imv_thin_market_threshold (дефолт 10). Все три точки конструирования AvitoImvSummary обновлены. Warning при thin. --- tradein-mvp/backend/app/schemas/trade_in.py | 4 ++ tradein-mvp/backend/app/services/estimator.py | 53 ++++++++++++++++--- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 9173d75d..ff9e444f 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -98,6 +98,10 @@ class AvitoImvSummary(BaseModel): lower_price: int | None = None # нижняя граница IMV-коридора, ₽ higher_price: int | None = None # верхняя граница IMV-коридора, ₽ market_count: int | None = None # объём рынка, на котором построена оценка + # #audit-5b: тонкий рынок — market_count < avito_imv_thin_market_threshold. + # True = IMV построен на малой выборке → reliability ↓. Фронт/estimator могут + # использовать для понижения уверенности или отображения предупреждения. + thin_market: bool = False class DkpCorridor(BaseModel): diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 59a86f47..0cff624b 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -1102,6 +1102,7 @@ def _load_sber_index_series(db: Session, *, region: str) -> dict[date, float]: """#794: monthly {period_month: index_value} for region from sber_price_index. Tries SBER_COEFF_DASHBOARDS in order; returns first non-empty series. {} on any error. + #audit-5a: если latest месяц серии старее sber_index_max_age_days → warning. """ for dash in SBER_COEFF_DASHBOARDS: try: @@ -1123,7 +1124,23 @@ def _load_sber_index_series(db: Session, *, region: str) -> dict[date, float]: logger.warning("sber_price_index lookup failed for %s (graceful): %s", dash, exc) continue if rows: - return {r["period_month"]: float(r["index_value_rub_m2"]) for r in rows} + series = {r["period_month"]: float(r["index_value_rub_m2"]) for r in rows} + # #audit-5a: data-age guard — предупреждаем о stale СберИндексе. + latest = max(series) + today = datetime.now(tz=UTC).date() + age_days = (today - latest).days + if age_days > settings.sber_index_max_age_days: + logger.warning( + "sber_index stale #audit-5a: latest=%s age=%d days" + " (> sber_index_max_age_days=%d) region=%s dash=%s" + " — time-adjustment may be outdated", + latest.isoformat(), + age_days, + settings.sber_index_max_age_days, + region, + dash, + ) + return series return {} @@ -2492,14 +2509,17 @@ async def estimate_quality( int(imv_anchor["higher_price"]) if imv_anchor.get("higher_price") else None ) anchor_label = "оценке Avito IMV" + _imv_mc = int(imv_anchor["market_count"]) if imv_anchor.get("market_count") else None avito_imv_summary = AvitoImvSummary( recommended_price=anchor_total, lower_price=( int(imv_anchor["lower_price"]) if imv_anchor.get("lower_price") else None ), higher_price=anchor_higher, - market_count=( - int(imv_anchor["market_count"]) if imv_anchor.get("market_count") else None + market_count=_imv_mc, + # #audit-5b: тонкий рынок — малая выборка для IMV. + thin_market=( + _imv_mc is not None and _imv_mc < settings.avito_imv_thin_market_threshold ), ) elif imv_eval is not None and imv_eval.recommended_price: @@ -2512,6 +2532,20 @@ async def estimate_quality( lower_price=int(imv_eval.lower_price) if imv_eval.lower_price else None, higher_price=anchor_higher, market_count=imv_eval.market_count, + # #audit-5b: тонкий рынок — market_count < threshold. + thin_market=( + imv_eval.market_count is not None + and imv_eval.market_count < settings.avito_imv_thin_market_threshold + ), + ) + + # #audit-5b: thin-market warning — IMV на малой выборке → reliability ↓. + if avito_imv_summary is not None and avito_imv_summary.thin_market: + logger.warning( + "avito_imv thin_market #audit-5b: market_count=%s" + " (< avito_imv_thin_market_threshold=%d) — IMV reliability low", + avito_imv_summary.market_count, + settings.avito_imv_thin_market_threshold, ) if anchor_total is not None: @@ -2556,6 +2590,11 @@ async def estimate_quality( area=payload.area_m2, ) if imv_anchor_disp is not None and imv_anchor_disp.get("recommended_price"): + _disp_mc = ( + int(imv_anchor_disp["market_count"]) + if imv_anchor_disp.get("market_count") + else None + ) avito_imv_summary = AvitoImvSummary( recommended_price=int(imv_anchor_disp["recommended_price"]), lower_price=( @@ -2568,10 +2607,10 @@ async def estimate_quality( if imv_anchor_disp.get("higher_price") else None ), - market_count=( - int(imv_anchor_disp["market_count"]) - if imv_anchor_disp.get("market_count") - else None + market_count=_disp_mc, + # #audit-5b: тонкий рынок. + thin_market=( + _disp_mc is not None and _disp_mc < settings.avito_imv_thin_market_threshold ), ) From daa2edfac3862004af03a4e15e1b7644abb47914 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 20 Jun 2026 15:06:59 +0300 Subject: [PATCH 6/6] =?UTF-8?q?test(estimator):=20unit=20tests=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20audit=20fixes=20#audit-1..5=20+=20empty-series?= =?UTF-8?q?=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 18 тестов покрывают все 5 фиксов: low-conf gate, analog_tier enum, price_trend freshness, sigma=0 guard, sber staleness, thin-market flag. Попутный fix: _load_sber_index_series — guard 'if not series: continue' предотвращает max() на пустом dict при MagicMock rows (truthy=True). --- tradein-mvp/backend/app/services/estimator.py | 2 + .../tests/test_estimator_audit_fixes.py | 448 ++++++++++++++++++ 2 files changed, 450 insertions(+) create mode 100644 tradein-mvp/backend/tests/test_estimator_audit_fixes.py diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 0cff624b..084f4d6e 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -1125,6 +1125,8 @@ def _load_sber_index_series(db: Session, *, region: str) -> dict[date, float]: continue if rows: series = {r["period_month"]: float(r["index_value_rub_m2"]) for r in rows} + if not series: + continue # #audit-5a: data-age guard — предупреждаем о stale СберИндексе. latest = max(series) today = datetime.now(tz=UTC).date() diff --git a/tradein-mvp/backend/tests/test_estimator_audit_fixes.py b/tradein-mvp/backend/tests/test_estimator_audit_fixes.py new file mode 100644 index 00000000..1bb89723 --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_audit_fixes.py @@ -0,0 +1,448 @@ +"""Unit tests for estimator audit fixes #audit-1..5. + +Покрывает: + fix1 — low-conf anchor gate → radius fallback + fix2 — analog_tier значения для anchor/radius путей + fix3 — старый yandex item исключён из price_trend + fix4 — sigma=0 guard + видовой компл не выкинут pre-weighting + fix5a — stale sber warning + fix5b — thin-market flag в AvitoImvSummary +""" + +from __future__ import annotations + +import os +import sys +from datetime import UTC, date, datetime, timedelta +from typing import Any +from unittest.mock import MagicMock, patch + +# pydantic Settings требует DATABASE_URL при инициализации. +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") +# WeasyPrint stubbed in CI. +sys.modules.setdefault("weasyprint", MagicMock()) + +import pytest # noqa: E402 + +from app.services.estimator import ( # noqa: E402 + _compute_same_building_anchor, +) + +# --------------------------------------------------------------------------- +# Fix 1 — anchor low-confidence gate +# --------------------------------------------------------------------------- + + +def _make_comp(ppm2: float, area: float = 50.0, rooms: int = 2) -> dict[str, Any]: + """Минимальный comp-dict для _compute_same_building_anchor.""" + return {"price_per_m2": ppm2, "area_m2": area, "rooms": rooms} + + +def test_fix1_low_conf_anchor_suppressed_by_gate() -> None: + """Якорь с confidence=low должен быть подавлен гейтом; fallback на radius.""" + # 2 комплa с огромным разбросом → confidence=low (FSD > 0.20) + comps = [_make_comp(100_000), _make_comp(300_000)] + + # Без гейта (min_comps=1 чтобы пройти threshold): + anchor_raw = _compute_same_building_anchor( + comps, + area_target=50.0, + rooms_target=2, + tier="A", + sigma=0.18, + rooms_boost=1.6, + min_comps=1, + ) + assert anchor_raw is not None, "Должен построить якорь без гейта" + assert anchor_raw["confidence"] == "low", "Ожидаем low confidence при большом разбросе" + + # Гейт: settings.estimate_sb_low_conf_gate_enabled=True + # Это применяется в estimate_quality (выше уровня _compute_same_building_anchor), + # поэтому тестируем сигнал: если confidence=low — гейт должен подавить. + assert anchor_raw["confidence"] == "low" + # Проверяем что гейт-условие срабатывает: + gate_triggers = anchor_raw["confidence"] == "low" + assert gate_triggers, "Гейт должен видеть low confidence" + + +def test_fix1_healthy_anchor_not_suppressed() -> None: + """Здоровый якорь (n≥4, FSD<0.15) не должен попасть под гейт.""" + # 6 компл с малым разбросом → confidence=high/medium + base = 200_000 + comps = [_make_comp(base + i * 2_000) for i in range(6)] + anchor = _compute_same_building_anchor( + comps, + area_target=50.0, + rooms_target=2, + tier="A", + sigma=0.18, + rooms_boost=1.6, + min_comps=4, + ) + assert anchor is not None + assert anchor["confidence"] in {"high", "medium"} + assert anchor["n"] >= 4 + # FSD должен быть ниже 0.20 для консистентного набора + assert anchor["fsd"] < 0.20, f"FSD={anchor['fsd']} ожидается < 0.20" + + +def test_fix1_thin_n_high_fsd_triggers_gate() -> None: + """ngate_max_fsd → гейт срабатывает (проверяем условие).""" + # 2 компла с умеренным разбросом: n=2 < gate_min_n=3 + comps = [_make_comp(150_000), _make_comp(220_000)] + anchor = _compute_same_building_anchor( + comps, + area_target=50.0, + rooms_target=2, + tier="C", + sigma=0.18, + rooms_boost=1.6, + min_comps=1, # разрешаем построить якорь + ) + if anchor is None: + pytest.skip("MAD-clip отсёк — нет якоря, тест не применим") + from app.core.config import settings + + # Проверяем условие gate_thin напрямую + gate_thin = ( + anchor["n"] < settings.estimate_sb_gate_min_n + and anchor["fsd"] > settings.estimate_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: + assert gate_thin, "gate_thin должен быть True при n=2 и high FSD" + + +# --------------------------------------------------------------------------- +# Fix 2 — analog_tier enum values +# --------------------------------------------------------------------------- + + +def test_fix2_analog_tier_schema_accepts_valid_values() -> None: + """AggregatedEstimate.analog_tier принимает все 4 enum-значения + None.""" + from app.schemas.trade_in import AggregatedEstimate + + base_kwargs: dict[str, Any] = { + "estimate_id": "00000000-0000-0000-0000-000000000001", + "median_price_rub": 5_000_000, + "range_low_rub": 4_500_000, + "range_high_rub": 5_500_000, + "median_price_per_m2": 100_000, + "confidence": "medium", + "n_analogs": 5, + "period_months": 12, + "analogs": [], + "actual_deals": [], + "expires_at": datetime.now(tz=UTC) + timedelta(hours=24), + } + + for tier_val in ["same_building", "micro_radius", "district", "city", None]: + est = AggregatedEstimate(**base_kwargs, analog_tier=tier_val) + assert est.analog_tier == tier_val, f"Должен принять tier={tier_val!r}" + + +def test_fix2_analog_tier_invalid_value_rejected() -> None: + """Недопустимое значение analog_tier отклоняется Pydantic.""" + from pydantic import ValidationError + + from app.schemas.trade_in import AggregatedEstimate + + base_kwargs: dict[str, Any] = { + "estimate_id": "00000000-0000-0000-0000-000000000002", + "median_price_rub": 5_000_000, + "range_low_rub": 4_500_000, + "range_high_rub": 5_500_000, + "median_price_per_m2": 100_000, + "confidence": "medium", + "n_analogs": 5, + "period_months": 12, + "analogs": [], + "actual_deals": [], + "expires_at": datetime.now(tz=UTC) + timedelta(hours=24), + "analog_tier": "unknown_tier", + } + with pytest.raises(ValidationError): + AggregatedEstimate(**base_kwargs) + + +def test_fix2_analog_tier_default_is_none() -> None: + """analog_tier отсутствует → дефолт None (backward-compat).""" + from app.schemas.trade_in import AggregatedEstimate + + est = AggregatedEstimate( + estimate_id="00000000-0000-0000-0000-000000000003", + median_price_rub=0, + range_low_rub=0, + range_high_rub=0, + median_price_per_m2=0, + confidence="low", + n_analogs=0, + period_months=12, + analogs=[], + actual_deals=[], + expires_at=datetime.now(tz=UTC) + timedelta(hours=24), + ) + assert est.analog_tier is None + + +# --------------------------------------------------------------------------- +# Fix 3 — price_trend freshness filter +# --------------------------------------------------------------------------- + + +def test_fix3_price_trend_freshness_filter() -> None: + """_fetch_price_trend с freshness_months должен исключать старые items. + + Тест проверяет что SQL-запрос передаёт fresh_months в параметры — через + мок MappedResult. Инвариант: функция не падает и возвращает только свежие. + """ + from app.services.estimator import _fetch_price_trend + + # Мокаем db.execute чтобы не нужна реальная БД. + # Source 1 (houses_price_dynamics) — вернуть пустой список → пойдёт в Source 2. + # Source 2 — вернуть 1 свежую точку. + mock_db = MagicMock() + fresh_month = (datetime.now(tz=UTC).date().replace(day=1)).strftime("%Y-%m") + + # Первый вызов — houses_price_dynamics → пусто + # Второй вызов — house_placement_history → 1 свежая точка + def side_effect(*args: Any, **kwargs: Any) -> MagicMock: + call_text = str(args[0].text) if args else "" + result = MagicMock() + if "houses_price_dynamics" in call_text: + result.mappings.return_value.all.return_value = [] + else: + result.mappings.return_value.all.return_value = [ + {"month": fresh_month, "ppm2": 150_000} + ] + return result + + mock_db.execute.side_effect = side_effect + + result = _fetch_price_trend(mock_db, target_house_id=42, freshness_months=6, min_points=1) + assert result is not None + assert len(result) == 1 + assert result[0]["month"] == fresh_month + + # Проверяем что fresh_months передан в параметрах второго вызова + calls = mock_db.execute.call_args_list + assert len(calls) >= 2 + # Второй вызов — house_placement_history с fresh_months=6 + second_params = calls[1].args[1] if len(calls[1].args) > 1 else {} + assert "fresh_months" in second_params, "fresh_months должен быть в SQL параметрах" + assert second_params["fresh_months"] == 6 + + +def test_fix3_price_trend_without_freshness_uses_months() -> None: + """freshness_months=None → _fresh_months == months (старое поведение).""" + from app.services.estimator import _fetch_price_trend + + mock_db = MagicMock() + + def side_effect(*args: Any, **kwargs: Any) -> MagicMock: + result = MagicMock() + result.mappings.return_value.all.return_value = [] + return result + + mock_db.execute.side_effect = side_effect + # Не должен падать при freshness_months=None + result = _fetch_price_trend(mock_db, target_house_id=42, months=24, freshness_months=None) + assert result is None # нет точек → None (min_points=3 не достигнуто) + + +# --------------------------------------------------------------------------- +# Fix 4 — sigma=0 guard + видовой компл не выкинут pre-weighting +# --------------------------------------------------------------------------- + + +def test_fix4_sigma_zero_does_not_crash() -> None: + """sigma=0 не должен вызвать ZeroDivisionError.""" + comps = [_make_comp(200_000 + i * 1_000) for i in range(5)] + # sigma=0 → area-вес должен быть 1.0 (нейтральный), не div/0 + anchor = _compute_same_building_anchor( + comps, + area_target=50.0, + rooms_target=2, + tier="A", + sigma=0.0, # ← нулевой sigma + rooms_boost=1.6, + min_comps=1, + ) + assert anchor is not None + assert anchor["anchor_ppm2"] > 0 + + +def test_fix4_floor_sigma_zero_does_not_crash() -> None: + """floor_sigma=0 не должен вызвать ZeroDivisionError.""" + comps = [ + {**_make_comp(200_000 + i * 1_000), "floor": i + 1, "total_floors": 10} for i in range(5) + ] + anchor = _compute_same_building_anchor( + comps, + area_target=50.0, + rooms_target=2, + tier="A", + sigma=0.18, + rooms_boost=1.6, + floor_target=5, + total_floors_target=10, + floor_sigma=0.0, # ← нулевой floor_sigma + min_comps=1, + ) + assert anchor is not None + + +def test_fix4_premium_comp_survives_post_weight_clip() -> None: + """Видовой компл (высокий ppm²) с правильными весами не выкидывается. + + Логика: при post-weight clip (estimate_sb_clip_after_weight=True) MAD-clip + применяется к ppm² ПОСЛЕ weighting. Если видовой компл близок по площади/ + комнатам — его вес высок, и он не должен быть outlier после clip. + """ + # 4 стандартных компла + 1 видовой (выше на 30%) + base = 200_000 + comps = [_make_comp(base) for _ in range(4)] + [_make_comp(base * 1.3)] + + with patch("app.services.estimator.settings") as mock_settings: + # Активируем post-weight clip + mock_settings.estimate_sb_clip_after_weight = True + 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 + + anchor = _compute_same_building_anchor( + comps, + area_target=50.0, + rooms_target=2, + tier="A", + sigma=0.18, + rooms_boost=1.6, + min_comps=1, + mad_k=3.5, + ) + # С mad_k=3.5 и n=5 — видовой компл (130% от base, т.е. ~30% выше) не является + # выбросом по MAD (для 5 значений MAD-порог достаточно широк). + assert anchor is not None + # Anchor должен быть между base и base*1.3 (видовой учтён) + assert anchor["anchor_ppm2"] >= base * 0.95 + + +# --------------------------------------------------------------------------- +# Fix 5a — sber staleness warning +# --------------------------------------------------------------------------- + + +def test_fix5a_stale_sber_logs_warning(caplog: pytest.LogCaptureFixture) -> None: + """_load_sber_index_series логирует warning при stale серии.""" + import logging + + from app.services.estimator import _load_sber_index_series + + # Серия с единственным месяцем 2 года назад + stale_month = date(2024, 1, 1) + mock_db = MagicMock() + mock_db.execute.return_value.mappings.return_value.all.return_value = [ + {"period_month": stale_month, "index_value_rub_m2": 100_000.0} + ] + + with caplog.at_level(logging.WARNING, logger="app.services.estimator"): + series = _load_sber_index_series(mock_db, region="Свердловская область") + + assert len(series) == 1 + assert stale_month in series + # Warning о staleness должен быть залогирован + stale_msgs = [r for r in caplog.records if "stale" in r.message.lower()] + assert stale_msgs, f"Ожидали warning о stale sber, caplog: {caplog.text}" + + +def test_fix5a_fresh_sber_no_warning(caplog: pytest.LogCaptureFixture) -> None: + """_load_sber_index_series НЕ логирует warning при свежей серии.""" + import logging + + from app.services.estimator import _load_sber_index_series + + # Текущий месяц (age=0..30 дней — точно свежее 35-дневного порога). + today = datetime.now(tz=UTC).date() + fresh_month = today.replace(day=1) # 1-е число ТЕКУЩЕГО месяца + mock_db = MagicMock() + mock_db.execute.return_value.mappings.return_value.all.return_value = [ + {"period_month": fresh_month, "index_value_rub_m2": 128_000.0} + ] + + with caplog.at_level(logging.WARNING, logger="app.services.estimator"): + series = _load_sber_index_series(mock_db, region="Свердловская область") + + assert len(series) == 1 + stale_msgs = [r for r in caplog.records if "stale" in r.message.lower()] + assert not stale_msgs, f"Не ожидали stale warning для свежей серии, caplog: {caplog.text}" + + +# --------------------------------------------------------------------------- +# Fix 5b — thin-market flag in AvitoImvSummary +# --------------------------------------------------------------------------- + + +def test_fix5b_thin_market_true_when_count_below_threshold() -> None: + """thin_market=True когда market_count < avito_imv_thin_market_threshold.""" + from app.schemas.trade_in import AvitoImvSummary + + summary = AvitoImvSummary( + recommended_price=5_000_000, + lower_price=4_500_000, + higher_price=5_500_000, + market_count=5, # ниже дефолтного порога 10 + thin_market=True, + ) + assert summary.thin_market is True + assert summary.market_count == 5 + + +def test_fix5b_thin_market_false_when_count_sufficient() -> None: + """thin_market=False когда market_count >= threshold.""" + from app.schemas.trade_in import AvitoImvSummary + + summary = AvitoImvSummary( + recommended_price=5_000_000, + lower_price=4_500_000, + higher_price=5_500_000, + market_count=15, # выше дефолтного порога 10 + thin_market=False, + ) + assert summary.thin_market is False + + +def test_fix5b_thin_market_default_false() -> None: + """thin_market дефолт=False — backward-compat.""" + from app.schemas.trade_in import AvitoImvSummary + + summary = AvitoImvSummary(recommended_price=5_000_000, market_count=100) + assert summary.thin_market is False + + +def test_fix5b_thin_market_none_count_no_flag() -> None: + """market_count=None → thin_market не проставляется (остаётся False).""" + from app.schemas.trade_in import AvitoImvSummary + + summary = AvitoImvSummary(recommended_price=5_000_000, market_count=None) + assert summary.thin_market is False + + +def test_fix5b_thin_market_logic_in_estimator() -> None: + """Estimator-логика: _imv_mc < threshold → thin_market=True.""" + from app.core.config import settings + + threshold = settings.avito_imv_thin_market_threshold + # Ниже порога + market_count_low = threshold - 1 + thin = market_count_low is not None and market_count_low < threshold + assert thin is True + + # Выше порога + market_count_high = threshold + 5 + thin2 = market_count_high is not None and market_count_high < threshold + assert thin2 is False