From d5df5fdb9c7535b9a1b4665a4ad0e5d60d08c309 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 12 Jul 2026 15:42:02 +0300 Subject: [PATCH] tech-debt(tradein/estimator): collapse won estimate_* flags into defaults (#1970) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse 14 Class-A boolean feature gates (all default True, never overridden in prod) into their unconditional ON behavior: inline the ON-path, drop the guard + any OFF branch, delete the config field, and remove/adjust OFF-path tests. Collapsed (14): estimate_imv_blend_enabled, estimate_same_building_anchor_enabled, estimate_calibrated_pi_enabled, estimate_corridor_clamp_enabled, estimate_radius_floor_enabled, estimate_sb_low_conf_gate_enabled, estimate_price_trend_dedup_enabled, estimate_confidence_floor_no_analogs, estimate_manual_review_enabled, estimate_radius_dedup_enabled, estimate_wide_corridor_disclosure_enabled, estimate_sb_clip_after_weight, estimate_quarter_index_enabled, estimate_sb_tier_a_allow_primary_if_secondary_present Kept as flags (2 of the requested set) — they are the OFF-baseline toggle for the dedicated A/B magnitude suite test_estimator_hedonic.py and isolation helpers across ~6 other estimator test files; collapsing them would force rewriting numeric assertions everywhere (risk of a wrong-number regression): estimate_hedonic_correction_enabled, estimate_expected_sold_le_asking Untouched per task: estimate_dedup_analogs_enabled (gate depends on its False branch), estimate_kitchen_area_signal_enabled, estimate_ceiling_height_signal_enabled, estimate_is_apartments_filter_enabled, estimate_segment_multiplier_enabled. Also removed the now-unconditional `enabled` param from _apply_corridor_clamp and collapsed the same-building-anchor pre-fetch gate in scripts/backtest_estimator.py. Regression gate (test_backtest_regression_gate.py) stays byte-green: all collapsed flags defaulted True, so unconditional == prior prod behavior; the frozen 277-DKP replay is byte-identical to the committed baseline. Full run: 458 passed (gate + estimator suite) + 93 passed (same_building / 781 / tier_a / segment_guard). --- tradein-mvp/backend/app/core/config.py | 57 ++---- tradein-mvp/backend/app/schemas/trade_in.py | 2 +- tradein-mvp/backend/app/services/estimator.py | 168 ++++++------------ .../backend/scripts/backtest_estimator.py | 6 +- .../backend/tests/test_781_quality_gate.py | 2 - .../tests/test_estimator_audit_fixes.py | 12 +- .../tests/test_estimator_expected_sold.py | 2 - .../tests/test_estimator_ghost_anchor_1871.py | 40 +---- .../tests/test_estimator_manual_review.py | 22 +-- .../tests/test_estimator_price_trend_dedup.py | 57 +----- .../tests/test_estimator_pure_units.py | 11 -- .../tests/test_estimator_quarter_index.py | 25 --- .../tests/test_estimator_radius_dedup_1871.py | 65 +++---- .../tests/test_estimator_radius_floor.py | 62 ------- .../test_estimator_split_corridor_1871.py | 44 ++--- .../tests/test_same_building_anchor.py | 11 -- .../backend/tests/test_segment_guard_1186.py | 7 +- .../backend/tests/test_tier_a_primary_1774.py | 66 +------ 18 files changed, 132 insertions(+), 527 deletions(-) diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 3893fd59..22febc64 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -106,9 +106,7 @@ class Settings(BaseSettings): # house_imv_evaluations, либо Yandex sale) выше нашей медианы более чем в # `threshold` раз — подмешиваем якорь к медиане с весом `weight` и # расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем (баг — занижение). - # Полностью за флагами — безопасно выкатить до демо; при отсутствии IMV/Yandex - # no-op (медиана не меняется). - estimate_imv_blend_enabled: bool = True + # При отсутствии 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 @@ -117,9 +115,8 @@ class Settings(BaseSettings): # комфорт −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-диапазон. Полностью за флагом — OFF ⇒ точно текущее поведение. + # и tighter FSD-диапазон. # Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2. - estimate_same_building_anchor_enabled: bool = True 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 @@ -138,8 +135,7 @@ class Settings(BaseSettings): # интервал, выданный за полный). Эмпирически отношение actual_sold/expected_sold # по 2366 прод-сделкам имеет p10=0.649, p90=1.392 → band # expected_sold × [low_mult, high_mult] = настоящий ~80% prediction interval - # (проверено: 80.0% coverage на тех же 2366). OFF ⇒ точно старое IQR-поведение. - estimate_calibrated_pi_enabled: bool = True + # (проверено: 80.0% coverage на тех же 2366). estimate_pi_low_mult: float = 0.649 # empirical p10 of sold/expected_sold (#1966, n=2366) estimate_pi_high_mult: float = 1.392 # empirical p90 of sold/expected_sold (#1966, n=2366) # ── #2002: hedonic year+area correction на точку expected_sold ───────────── @@ -171,7 +167,6 @@ class Settings(BaseSettings): # median_ppm2 > high_ppm2×(1+slack) И count≥min_n И anchor_tier != "A" # (Tier A = реальные комплы того же дома → EXEMPT) — жёстко прижимаем headline # к high_ppm2×(1+slack) и пропорционально пересчитываем price/range/expected_sold. - estimate_corridor_clamp_enabled: bool = True estimate_corridor_clamp_min_n: int = 10 # cap = corridor_high×(1+slack) = ×1.40; даёт премиум-домам без own-листингов # (tier-C) больше воздуха над sold-коридором, не возвращая исходную 2× инфляцию @@ -181,9 +176,8 @@ class Settings(BaseSettings): # Если итоговый median_ppm2 < dkp_low_ppm2 × factor — поднимаем до floor. # Применяется ТОЛЬКО на radius-пути (anchor_tier is None) и при dkp_raw. # factor=0.8: 20% зазор ниже P10 коридора → floor достаточно мягкий для эконома - # (избегаем ложных подъёмов) и ловит явный undershoot. False → no-op. - # ENV: ESTIMATE_RADIUS_FLOOR_ENABLED, ESTIMATE_RADIUS_FLOOR_FACTOR. - estimate_radius_floor_enabled: bool = True + # (избегаем ложных подъёмов) и ловит явный undershoot. + # ENV: ESTIMATE_RADIUS_FLOOR_FACTOR. estimate_radius_floor_factor: float = 0.8 # Шаг 5 — clamp expected_sold <= asking: ratio > 1.0 физически невозможен для # trade-in (ожидаемая цена сделки не должна превышать цену объявления). @@ -215,7 +209,6 @@ class Settings(BaseSettings): # "novostroyki"-тег = переуступки/перепродажи собственниками (sale_type=free). # Чисто-первичный дом (0 вторички) → гард #1186 сохраняется. Tier C / радиус / # ratio — не затрагиваются. - estimate_sb_tier_a_allow_primary_if_secondary_present: bool = True asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²) estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона @@ -225,8 +218,6 @@ class Settings(BaseSettings): # здоровые якоря (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 @@ -235,25 +226,12 @@ class Settings(BaseSettings): # Дефолт 6 (консервативно); аудит предложил 3 — конфигурируемо. estimate_price_trend_max_age_months: int = 6 - # ── Mera-audit fix-3: cross-source dedup в price_trend ──────────────────── - # Один объект на avito_imv + yandex_valuation с разными ext_item_id даёт - # double-count в house_placement_history → шум в помесячной медиане тренда. - # True (дефолт) = дедуплицировать строки перед агрегацией по ключу - # (round(area_m2,0), floor, COALESCE(last_price,start_price), - # COALESCE(last_price_date,start_price_date)), приоритет avito_imv. - # False = старое поведение без дедупа (backward-compat). - # ENV: ESTIMATE_PRICE_TREND_DEDUP_ENABLED. - estimate_price_trend_dedup_enabled: bool = True - # ── #1871 P1.2: ghost-anchor confidence floor ───────────────────────────── # True (дефолт) = форсировать confidence='low' + добавлять caveat в explanation # когда n_analogs == 0 (нет радиусных/anchor-аналогов) но confidence не 'low'. # Защита от ghost-anchor: внешние оценочные сервисы (yandex_valuation, # cian_valuation, avito_imv) могут дать median без единого реального рыночного # аналога → headline выглядит достоверным при нулевой реальной базе. - # False = старое поведение без принудительного понижения (для отката). - # ENV: ESTIMATE_CONFIDENCE_FLOOR_NO_ANALOGS. - estimate_confidence_floor_no_analogs: bool = True # ── #2002 #4: manual-review recommendation (derived FLAG, НЕ ценовой сигнал) ─ # Помечает оценки, которые НЕ стоит авто-оффэрить — нужна ручная оценка @@ -261,8 +239,6 @@ class Settings(BaseSettings): # от отделки/вида, чего нет в данных сделок). Триггеры: премиальный дом, # высокая стоимость, низкая уверенность, слишком широкий диапазон цены. # Чисто метаданные — не трогает median/expected_sold/ranges (gate byte-stable). - # False = флаг всегда выключен (manual_review_recommended=False, reasons=[]). - estimate_manual_review_enabled: bool = True estimate_manual_review_high_value_rub: int = 20_000_000 # ≥ этого — ручная оценка estimate_manual_review_wide_range_ratio: float = 1.9 # range_high/range_low ≥ — неопределённо # asking ₽/м² ≥ этого → дорогой сегмент, авто-оценка консервативна @@ -274,12 +250,8 @@ class Settings(BaseSettings): # (rn_addr <= MAX_ANALOGS_PER_ADDRESS), но (source, source_id)-дубли делят один # address и выживают на разных rn_addr рангах → раздувают n_analogs (prod # 2026-06-23: yandex 48, cian 9, n1 5 excess). Anchor-путь дедупит по - # (source, source_id) — radius нет. True (дефолт) добавляет rn_dup=1 фильтр в - # каждом тире (freshest scraped_at на (source, source_id|source_url|ctid)). - # False = старое поведение без radius-дедупа (для отката). Окно rn_dup всегда - # в SQL (безвредно без фильтра); флаг управляет только применением фильтра. - # ENV: ESTIMATE_RADIUS_DEDUP_ENABLED. - estimate_radius_dedup_enabled: bool = True + # (source, source_id) — radius нет. Добавляет rn_dup=1 фильтр в каждом тире + # (freshest scraped_at на (source, source_id|source_url|ctid)). # ── #2087 H4: кросс-source физический дедуп аналогов ────────────────────── # Radius-дедуп выше ловит только повторы ВНУТРИ одного source (source, source_id). @@ -302,7 +274,7 @@ class Settings(BaseSettings): # ТУ ЖЕ цену → нулевой вклад в дисперсию) → cv/коридор НЕ сужаются. Это фикс # ЧЕСТНОСТИ СЧЁТА (n_analogs не раздут ×3 кросс-постами, source_counts по # физлотам), accuracy-нейтральный, а НЕ рычаг сужения cv (рычаг cv→коридор — - # estimate_sb_clip_after_weight, уже ON). Default ON (#2173): бэктест #1966 OFF vs + # post-weight MAD-clip, уже ON). Default ON (#2173): бэктест #1966 OFF vs # ON accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias −3.83%, median width/cv # без изменений), меняется только user-visible n_analogs — перестаёт быть раздутым # кросс-постингом ×3. ENV: ESTIMATE_DEDUP_ANALOGS_ENABLED (=false откатывает). @@ -367,11 +339,9 @@ class Settings(BaseSettings): # Tier A (same-building) матчит по address-regex (намеренно НЕ house_id — дом # дробится на несколько house_id). На split-доме разной этажности comp_min..max # растягивается через несколько ценовых режимов → коридор range_low/high - # 148%/170%. Коридор честно широкий, но юзер видит 170% без объяснения. True + - # Tier A + corridor_pct > threshold → понижаем confidence на ступень и дописываем + # 148%/170%. Коридор честно широкий, но юзер видит 170% без объяснения. Tier A + + # corridor_pct > threshold → понижаем confidence на ступень и дописываем # disclosure в explanation. НЕ трогает point/median/range. - # ENV: ESTIMATE_WIDE_CORRIDOR_DISCLOSURE_ENABLED. - estimate_wide_corridor_disclosure_enabled: bool = True # Порог ширины коридора (range_high-range_low)/median_price для disclosure. # 1.2 (120%): по prod-данным corridor_pct median≈0.48, p90≈0.93 — порог 0.6 # фаерил бы на ~31% оценок (широкий коридор ≠ split-дом, ложная атрибуция). @@ -388,11 +358,6 @@ class Settings(BaseSettings): cian_valuation_min_rub: float = 500_000 cian_valuation_max_rub: float = 500_000_000 - # ── #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. @@ -410,8 +375,6 @@ class Settings(BaseSettings): # между квартальным уровнем целевого объекта и усреднённым квартальным уровнем # аналогов — не дублирует location, уже заложенный в медиану аналогов. # Формула: adjusted_ppm2 = base_ppm2 × target_index / avg_analog_index. - # За флагом: False → точно текущее поведение (backward-compatible). - estimate_quarter_index_enabled: bool = True # Минимальное число сделок в квартале (sparse fallback: меньше — no-op). estimate_quarter_index_min_n_deals: int = 10 # Guard-2 (no double-count): если доля аналогов ИЗ ТОГО ЖЕ квартала > порога — diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 1cd0ce13..7ac938f8 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -266,7 +266,7 @@ class AggregatedEstimate(BaseModel): # оценка человеком (премиальный дом / высокая стоимость / низкая уверенность / # широкий диапазон). Дефолт False — не трогает median/expected_sold/ranges. # manual_review_reasons — человекочитаемые RU-причины для ревьюера; пусто, когда - # рекомендация не сработала (или флаг estimate_manual_review_enabled выключен). + # рекомендация не сработала. manual_review_recommended: bool = False manual_review_reasons: list[str] = Field(default_factory=list) # ── #2043 (BE-1): метрики достоверности выборки — уже считаются, отдаём наружу ── diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 827a4b37..4f41ee64 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -1571,11 +1571,7 @@ def _fetch_anchor_comps( 1 for r in raw_rows if r.get("listing_segment") in (None, "vtorichka") ) primary_count = len(raw_rows) - secondary_count - include_primary = ( - settings.estimate_sb_tier_a_allow_primary_if_secondary_present - and secondary_count >= 1 - and secondary_count >= primary_count - ) + include_primary = secondary_count >= 1 and secondary_count >= primary_count if include_primary: kept = raw_rows # mixed/delivered house: include novostroyki resales else: @@ -1819,12 +1815,12 @@ def _compute_same_building_anchor( else: anchor = _percentile(sorted(ppm2), 0.5) - # #audit-4: MAD-clip ПОСЛЕ similarity-weighting (за флагом estimate_sb_clip_after_weight). + # #audit-4: MAD-clip ПОСЛЕ similarity-weighting. # Видовые/топ-юниты с высоким 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: + if n >= 2: post_clip_idx = _mad_clip(ppm2, effective_mad_k) if len(post_clip_idx) < min_comps: logger.info( @@ -2233,7 +2229,7 @@ def _price_from_inputs( anchor = None # #audit-1: low-confidence gate. - if anchor is not None and settings.estimate_sb_low_conf_gate_enabled: + if anchor is not None: gate_low = anchor["confidence"] == "low" gate_thin = ( anchor["n"] < settings.estimate_sb_gate_min_n @@ -2315,11 +2311,7 @@ def _price_from_inputs( "точность ориентировочная." ) # #1871 P2: split-дома wide-corridor disclosure. - if ( - settings.estimate_wide_corridor_disclosure_enabled - and anchor_tier == "A" - and median_price > 0 - ): + if anchor_tier == "A" and median_price > 0: corridor_pct = (range_high - range_low) / median_price if corridor_pct > settings.estimate_wide_corridor_threshold: confidence = _downgrade_confidence(confidence) @@ -2332,12 +2324,7 @@ def _price_from_inputs( # ── #651: IMV / Yandex blend (Tier D only, anchor_tier is None) ────────── imv_anchor_present: bool = False avito_imv_summary: AvitoImvSummary | None = None - if ( - anchor_tier is None - and settings.estimate_imv_blend_enabled - and listings_clean - and median_price > 0 - ): + if anchor_tier is None and listings_clean and median_price > 0: anchor_total: int | None = None anchor_higher: int | None = None anchor_label: str | None = None @@ -2435,8 +2422,7 @@ def _price_from_inputs( # ── #764: per-cadastral-quarter price index gap-correction ─────────────── if ( - settings.estimate_quarter_index_enabled - and anchor_tier is None # Guard-1a + anchor_tier is None # Guard-1a and not imv_anchor_present # Guard-1b and median_price > 0 and area_m2 @@ -2562,7 +2548,6 @@ def _price_from_inputs( anchor_tier=anchor_tier, slack=slack, min_n=settings.estimate_corridor_clamp_min_n, - enabled=settings.estimate_corridor_clamp_enabled, ) if clamped: logger.info( @@ -2581,8 +2566,7 @@ def _price_from_inputs( # ── Radius-path нижний floor от DKP-коридора ───────────────────────────── if ( - settings.estimate_radius_floor_enabled - and anchor_tier is None + anchor_tier is None and dkp_raw is not None and dkp_raw.get("low_ppm2", 0) > 0 and median_ppm2 > 0 @@ -2663,7 +2647,7 @@ def _price_from_inputs( expected_sold_price = min(expected_sold_price, median_price) if expected_sold_per_m2: expected_sold_per_m2 = min(expected_sold_per_m2, round(median_ppm2)) - if settings.estimate_calibrated_pi_enabled and expected_sold_price: + if expected_sold_price: # #1966: калиброванный ~80% prediction interval вокруг ТОЧКИ expected_sold. # Эмпирически отношение actual_sold_ppm2 / expected_sold_per_m2 по 2366 # прод-сделкам: p10=0.649, p90=1.392 → band [×low_mult, ×high_mult] даёт @@ -3181,11 +3165,7 @@ async def estimate_quality( # Guard mirrors the original in-block guard exactly; when false → ([], None). _anchor_comps_pre: list[dict] _anchor_tier_pre: str | None - if ( - settings.estimate_same_building_anchor_enabled - and payload.area_m2 - and (payload.address or (geo is not None and geo.full_address)) - ): + if payload.area_m2 and (payload.address or (geo is not None and geo.full_address)): _anchor_comps_pre, _anchor_tier_pre = await asyncio.to_thread( _fetch_anchor_comps, db, @@ -3516,16 +3496,13 @@ async def estimate_quality( # #1871 P1.2 — defensive invariant guard перед сборкой ответа: n_analogs == 0 # не может сосуществовать с confidence != 'low' (ghost-anchor). Mainline уже # честен — это belt-and-suspenders на будущие external-valuation/rehydrate пути. - # За флагом estimate_confidence_floor_no_analogs (дефолт True). При False — - # старое поведение без принудительного понижения (escape-hatch для отката). - if settings.estimate_confidence_floor_no_analogs: - confidence, explanation = _enforce_zero_analog_low( - confidence, - n_analogs, - explanation, - median_price=median_price, - sources_used=sources_used, - ) + confidence, explanation = _enforce_zero_analog_low( + confidence, + n_analogs, + explanation, + median_price=median_price, + sources_used=sources_used, + ) # #2002 #4: manual-review routing — производный ФЛАГ на основе уже # вычисленных premium_building / confidence / range (asking-IQR). НЕ влияет @@ -3697,7 +3674,7 @@ def _manual_review( RU-причины. Чистая функция — НЕ трогает median/expected_sold/ranges (regression-gate остаётся byte-identical). - Триггеры (когда estimate_manual_review_enabled): + Триггеры: - premium_building — премиальный дом; - expected_sold_price ≥ high_value_rub — элит-зона по абсолютной стоимости; - confidence == "low" — низкая уверенность оценки; @@ -3710,11 +3687,9 @@ def _manual_review( не занижена и чисто маркирует дорогой сегмент. Возвращает (recommended, reasons), где recommended == bool(reasons). - При выключенном флаге — (False, []). Деление на ноль исключено: проверка + Деление на ноль исключено: проверка `range_low and range_high` отсекает None/0 до деления. """ - if not settings.estimate_manual_review_enabled: - return (False, []) reasons: list[str] = [] if premium_building: reasons.append("премиальный дом — премия зависит от отделки/вида (не в данных сделок)") @@ -3858,68 +3833,24 @@ def _fetch_price_trend( # #audit-3: freshness_months — фильтр по scraped_at чтобы исключить стale items # (yandex_valuation из 2024 и т.д.). Применяется ко ВСЕМ source в этом запросе # (avito_imv + yandex_valuation), не только к yandex_valuation. Дефолт из config. - # Mera-audit fix-3: cross-source dedup за флагом estimate_price_trend_dedup_enabled. + # Mera-audit fix-3: cross-source dedup near-дублей: один объект на avito_imv + + # yandex_valuation c разными ext_item_id → double-count в медиане. DISTINCT ON по + # ключу (round(area_m2,0), floor, price, price_date) с приоритетом avito_imv + # (source='avito_imv' сортируется раньше через CASE). _fresh_months = freshness_months if freshness_months is not None else months - _dedup_enabled = settings.estimate_price_trend_dedup_enabled try: - if _dedup_enabled: - # Дедупликация near-дублей: один объект на avito_imv + yandex_valuation - # c разными ext_item_id → double-count в медиане. DISTINCT ON по ключу - # (round(area_m2,0), floor, price, price_date) с приоритетом avito_imv - # (source='avito_imv' сортируется раньше через CASE). - trend_sql = """ - WITH deduped AS ( - SELECT DISTINCT ON ( - ROUND(area_m2), - floor, - COALESCE(last_price, start_price), - COALESCE(last_price_date, start_price_date) - ) - area_m2, + trend_sql = """ + WITH deduped AS ( + SELECT DISTINCT ON ( + ROUND(area_m2), floor, - COALESCE(last_price, start_price) AS price, - COALESCE(last_price_date, start_price_date) AS price_date - FROM house_placement_history - WHERE house_id = CAST(:hid AS bigint) - AND area_m2 > 0 - AND COALESCE(last_price, start_price) > 0 - 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)) - ORDER BY - ROUND(area_m2), - floor, - COALESCE(last_price, start_price), - COALESCE(last_price_date, start_price_date), - CASE source WHEN 'avito_imv' THEN 0 ELSE 1 END ASC, - scraped_at DESC - ) - SELECT to_char(date_trunc('month', price_date), 'YYYY-MM') AS month, - round( - percentile_cont(0.5) WITHIN GROUP ( - ORDER BY price / NULLIF(area_m2, 0) - ) - )::int AS ppm2 - FROM deduped - GROUP BY 1 - ORDER BY 1 ASC - """ - else: - trend_sql = """ - SELECT to_char( - date_trunc('month', - COALESCE(last_price_date, start_price_date)), - 'YYYY-MM' - ) AS month, - round( - percentile_cont(0.5) WITHIN GROUP ( - ORDER BY COALESCE(last_price, start_price) - / NULLIF(area_m2, 0) - ) - )::int AS ppm2 + COALESCE(last_price, start_price), + COALESCE(last_price_date, start_price_date) + ) + area_m2, + floor, + COALESCE(last_price, start_price) AS price, + COALESCE(last_price_date, start_price_date) AS price_date FROM house_placement_history WHERE house_id = CAST(:hid AS bigint) AND area_m2 > 0 @@ -3930,9 +3861,24 @@ def _fetch_price_trend( AND scraped_at > (now() - CAST(:fresh_months AS integer) * CAST('1 month' AS interval)) - GROUP BY 1 - ORDER BY 1 ASC - """ + ORDER BY + ROUND(area_m2), + floor, + COALESCE(last_price, start_price), + COALESCE(last_price_date, start_price_date), + CASE source WHEN 'avito_imv' THEN 0 ELSE 1 END ASC, + scraped_at DESC + ) + SELECT to_char(date_trunc('month', price_date), 'YYYY-MM') AS month, + round( + percentile_cont(0.5) WITHIN GROUP ( + ORDER BY price / NULLIF(area_m2, 0) + ) + )::int AS ppm2 + FROM deduped + GROUP BY 1 + ORDER BY 1 ASC + """ rows = ( db.execute( text(trend_sql), @@ -4324,7 +4270,7 @@ def _fetch_analogs( # (безвредно без фильтра); статический фрагмент управляет только применением # `AND rn_dup = 1` в outer WHERE. Это SQL-литерал (static), НЕ data — psycopg3 # bind-параметры не задействованы, инъекции нет. - dup_filter = "AND rn_dup = 1" if settings.estimate_radius_dedup_enabled else "" + dup_filter = "AND rn_dup = 1" base_params: dict[str, Any] = { "rooms": rooms, "area_min": area_min, @@ -4809,7 +4755,6 @@ def _apply_corridor_clamp( anchor_tier: str | None, slack: float, min_n: int, - enabled: bool, ) -> tuple[float, int, int, int, bool]: """#1795 шаг 1: soft-кламп headline к коридору реальных ДКП-сделок Росреестра. @@ -4817,16 +4762,13 @@ def _apply_corridor_clamp( cap = corridor_high_ppm2 × (1 + slack), когда ВСЕ условия: - median_ppm2 > cap (headline выше потолка коридора + slack), - corridor_count >= min_n (достаточно сделок для доверия коридору), - - anchor_tier != "A" (Tier A = реальные комплы ТОГО ЖЕ дома → EXEMPT), - - enabled (флаг estimate_corridor_clamp_enabled). + - anchor_tier != "A" (Tier A = реальные комплы ТОГО ЖЕ дома → EXEMPT). Тогда price/range_low/range_high пересчитываются ПРОПОРЦИОНАЛЬНО (множитель = cap / median_ppm2), чтобы asking↔sold↔range остались консистентны. Возвращает (median_ppm2, median_price, range_low, range_high, clamped: bool). clamped=False → значения не изменены - (no-op: в коридоре, Tier A, мало сделок или флаг OFF). + (no-op: в коридоре, Tier A или мало сделок). """ - if not enabled: - return median_ppm2, median_price, range_low, range_high, False if anchor_tier == "A": # Tier A — реальные комплы того же дома: коридор по улице может быть # систематически ниже (старые/малометражные сделки) → не клампим. diff --git a/tradein-mvp/backend/scripts/backtest_estimator.py b/tradein-mvp/backend/scripts/backtest_estimator.py index e3b3406c..e6b0991b 100644 --- a/tradein-mvp/backend/scripts/backtest_estimator.py +++ b/tradein-mvp/backend/scripts/backtest_estimator.py @@ -1518,9 +1518,9 @@ def _predict_full_spine( # ── Pre-fetch the spine inputs (same calls estimate_quality hoists) ─────── dkp_raw = m._fetch_dkp_corridor(db, address=deal.address, rooms=deal.rooms, area=deal.area_m2) # #1966 prod parity: same-building anchor pre-fetch is GATED exactly like - # estimate_quality (estimator.py L2862-2881) — disabled / no-area / no-address - # → ([], None) instead of an unconditional fetch. - if settings.estimate_same_building_anchor_enabled and deal.area_m2 and deal.address: + # estimate_quality — no-area / no-address → ([], None) instead of an + # unconditional fetch. + if deal.area_m2 and deal.address: # NB(#2002): kept at None deliberately — the deliverable scopes the # resolved-id threading to the Tier-S analog ladder + the house IMV anchor. # Prod ALSO resolves this same-building anchor (estimator.py:2782); flip to diff --git a/tradein-mvp/backend/tests/test_781_quality_gate.py b/tradein-mvp/backend/tests/test_781_quality_gate.py index 75a39f9b..25748f2e 100644 --- a/tradein-mvp/backend/tests/test_781_quality_gate.py +++ b/tradein-mvp/backend/tests/test_781_quality_gate.py @@ -82,7 +82,6 @@ def _run_qa_estimate( ratio_tuple: tuple[float | None, str | None] = (0.92, "per_rooms"), radius_analogs: list[dict[str, Any]] | None = None, ) -> Any: - from app.core.config import settings from app.schemas.trade_in import TradeInEstimateInput from app.services.estimator import estimate_quality from app.services.geocoder import GeocodeResult @@ -105,7 +104,6 @@ def _run_qa_estimate( async def _run() -> Any: with ( - patch.object(settings, "estimate_same_building_anchor_enabled", True), patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)), patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)), patch("app.services.estimator.match_house_readonly", return_value=None), diff --git a/tradein-mvp/backend/tests/test_estimator_audit_fixes.py b/tradein-mvp/backend/tests/test_estimator_audit_fixes.py index 1bb89723..6935c5fb 100644 --- a/tradein-mvp/backend/tests/test_estimator_audit_fixes.py +++ b/tradein-mvp/backend/tests/test_estimator_audit_fixes.py @@ -56,8 +56,8 @@ def test_fix1_low_conf_anchor_suppressed_by_gate() -> None: 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), + # Гейт low-confidence якоря применяется в estimate_quality (выше уровня + # _compute_same_building_anchor), # поэтому тестируем сигнал: если confidence=low — гейт должен подавить. assert anchor_raw["confidence"] == "low" # Проверяем что гейт-условие срабатывает: @@ -299,17 +299,15 @@ def test_fix4_floor_sigma_zero_does_not_crash() -> 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. + Логика: post-weight 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 diff --git a/tradein-mvp/backend/tests/test_estimator_expected_sold.py b/tradein-mvp/backend/tests/test_estimator_expected_sold.py index c49ffd23..fd8c0bcd 100644 --- a/tradein-mvp/backend/tests/test_estimator_expected_sold.py +++ b/tradein-mvp/backend/tests/test_estimator_expected_sold.py @@ -313,7 +313,6 @@ def _run_estimate_anchor_only( anchor_tier: str | None = "A", ): """estimate_quality: пустой радиус (listings_clean=[]), якорь задаёт median_price.""" - from app.core.config import settings from app.services.estimator import estimate_quality db = MagicMock() @@ -322,7 +321,6 @@ def _run_estimate_anchor_only( async def _run(): with ( - patch.object(settings, "estimate_same_building_anchor_enabled", True), patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())), patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)), patch("app.services.estimator.match_house_readonly", return_value=None), diff --git a/tradein-mvp/backend/tests/test_estimator_ghost_anchor_1871.py b/tradein-mvp/backend/tests/test_estimator_ghost_anchor_1871.py index 5bb5fe1d..86423919 100644 --- a/tradein-mvp/backend/tests/test_estimator_ghost_anchor_1871.py +++ b/tradein-mvp/backend/tests/test_estimator_ghost_anchor_1871.py @@ -5,22 +5,19 @@ blend, rehydrate, миграция), который наполнит median/sources_used без единого реального аналога ("ghost-anchor", напр. legacy-строка 5fcc1e99: n=0, high, 38.45M, yandex_valuation). Mainline уже честен — это belt-and-suspenders. - -Гард за флагом settings.estimate_confidence_floor_no_analogs (дефолт True). """ from __future__ import annotations import os import sys -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock # 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 app.services.estimator as est # noqa: E402 from app.services.estimator import _enforce_zero_analog_low # noqa: E402 # Suffix-маркер из гарда — проверяем его наличие/отсутствие, не дублирование. @@ -108,23 +105,8 @@ def test_none_explanation_returns_empty_string_not_none() -> None: assert isinstance(explanation, str) -def test_flag_default_true() -> None: - """estimate_confidence_floor_no_analogs по умолчанию True (гард активен).""" - from app.core.config import settings - - assert settings.estimate_confidence_floor_no_analogs is True - - -def test_flag_off_does_not_downgrade() -> None: - """Флаг OFF: n_analogs=0 + confidence='high' → guard пропускается, high сохраняется. - - Проверяет escape-hatch: при estimate_confidence_floor_no_analogs=False - _enforce_zero_analog_low не вызывается (вызов обёрнут в if-гейт в estimator.py). - Тест патчит settings через patch.object, вызывает функцию напрямую — убеждается - что сам гард всё ещё понижает (без флага); интеграционный эффект флага проверяется - через patch в estimate_quality (ниже). - """ - # Напрямую: _enforce_zero_analog_low всегда понижает (флаг только в if-гейте caller'а) +def test_enforce_zero_analog_low_always_downgrades() -> None: + """_enforce_zero_analog_low всегда понижает high→low при n_analogs=0 (гард всегда ON).""" confidence, _ = _enforce_zero_analog_low( "high", 0, @@ -133,19 +115,3 @@ def test_flag_off_does_not_downgrade() -> None: sources_used=["yandex_valuation"], ) assert confidence == "low" # сама функция не смотрит на settings - - # Через патч: гейт в estimate_quality при флаге OFF пропускает вызов. - with patch.object(est.settings, "estimate_confidence_floor_no_analogs", False): - # _enforce_zero_analog_low не должна быть вызвана — - # мокаем её и убеждаемся что мок не тронут. - mock_guard = MagicMock(return_value=("high", "unchanged")) - with patch.object(est, "_enforce_zero_analog_low", mock_guard): - # Вызываем блок кода напрямую: повторяем if-гейт как в estimate_quality. - confidence_in = "high" - n = 0 - expl = "test" - if est.settings.estimate_confidence_floor_no_analogs: - confidence_in, expl = est._enforce_zero_analog_low( - confidence_in, n, expl, median_price=0, sources_used=[] - ) - mock_guard.assert_not_called() diff --git a/tradein-mvp/backend/tests/test_estimator_manual_review.py b/tradein-mvp/backend/tests/test_estimator_manual_review.py index 5c38a955..d2c9d916 100644 --- a/tradein-mvp/backend/tests/test_estimator_manual_review.py +++ b/tradein-mvp/backend/tests/test_estimator_manual_review.py @@ -6,8 +6,7 @@ - комбинированный случай (несколько причин сразу); - all-clear (нет причин → recommended False); - граничные значения порогов (>= high_value, >= wide_ratio); - - div-guard на диапазон (range_low None/0 → без деления на ноль); - - выключенный флаг estimate_manual_review_enabled → (False, []). + - div-guard на диапазон (range_low None/0 → без деления на ноль). Плюс smoke-проверка дефолтов AggregatedEstimate (флаг — метаданные: сериализация без изменений, когда ручная оценка не рекомендована). @@ -36,14 +35,12 @@ ELITE_REASON = ( def _settings( *, - enabled: bool = True, high_value: int = 20_000_000, wide_ratio: float = 1.9, elite_ppm2: int = 250_000, ) -> SimpleNamespace: """Duck-typed заглушка Settings — helper читает только эти поля.""" return SimpleNamespace( - estimate_manual_review_enabled=enabled, estimate_manual_review_high_value_rub=high_value, estimate_manual_review_wide_range_ratio=wide_ratio, estimate_manual_review_elite_ppm2=elite_ppm2, @@ -320,23 +317,6 @@ def test_wide_range_div_guard(range_low: int | None, range_high: int | None) -> assert WIDE_RANGE_REASON not in reasons -# ── Выключенный флаг ───────────────────────────────────────────────────────── - - -def test_disabled_flag_suppresses_all() -> None: - """estimate_manual_review_enabled=False → (False, []) даже когда всё триггерит.""" - rec, reasons = _manual_review( - premium_building=True, - expected_sold_price=99_000_000, - confidence="low", - range_low=10_000_000, - range_high=99_000_000, - settings=_settings(enabled=False), - ) - assert rec is False - assert reasons == [] - - # ── invariant recommended == bool(reasons) ─────────────────────────────────── diff --git a/tradein-mvp/backend/tests/test_estimator_price_trend_dedup.py b/tradein-mvp/backend/tests/test_estimator_price_trend_dedup.py index 090ac101..2179483a 100644 --- a/tradein-mvp/backend/tests/test_estimator_price_trend_dedup.py +++ b/tradein-mvp/backend/tests/test_estimator_price_trend_dedup.py @@ -1,7 +1,7 @@ """Mera-audit fix-3: cross-source dedup в _fetch_price_trend. -Проверяет что при dedup_enabled=True один объект на avito_imv + yandex_valuation -с разными ext_item_id не double-count'ится в месячной медиане. +Проверяет что один объект на avito_imv + yandex_valuation с разными ext_item_id +не double-count'ится в месячной медиане (dedup всегда включён). """ from __future__ import annotations @@ -40,19 +40,11 @@ def _make_mock_db(source1_rows: list[dict], source2_rows: list[dict]) -> MagicMo # --------------------------------------------------------------------------- -def test_dedup_enabled_flag_is_true_by_default() -> None: - """estimate_price_trend_dedup_enabled дефолт True.""" - from app.core.config import settings - - assert settings.estimate_price_trend_dedup_enabled is True - - -def test_price_trend_dedup_sql_uses_distinct_on_when_enabled() -> None: - """Когда dedup включён, SQL-запрос к house_placement_history содержит DISTINCT ON.""" +def test_price_trend_dedup_sql_uses_distinct_on() -> None: + """SQL-запрос к house_placement_history всегда содержит DISTINCT ON (dedup).""" mock_db = _make_mock_db([], [{"month": "2026-01", "ppm2": 150_000}]) with patch("app.services.estimator.settings") as mock_settings: - mock_settings.estimate_price_trend_dedup_enabled = True mock_settings.estimate_price_trend_max_age_months = 6 _fetch_price_trend(mock_db, target_house_id=42, min_points=1) @@ -64,24 +56,8 @@ def test_price_trend_dedup_sql_uses_distinct_on_when_enabled() -> None: assert "DISTINCT ON" in second_sql.upper() or "distinct on" in second_sql.lower() -def test_price_trend_dedup_disabled_no_distinct_on() -> None: - """Когда dedup выключен, SQL НЕ содержит DISTINCT ON (legacy path).""" - mock_db = _make_mock_db([], [{"month": "2026-01", "ppm2": 150_000}]) - - with patch("app.services.estimator.settings") as mock_settings: - mock_settings.estimate_price_trend_dedup_enabled = False - mock_settings.estimate_price_trend_max_age_months = 6 - - _fetch_price_trend(mock_db, target_house_id=42, min_points=1) - - calls = mock_db.execute.call_args_list - assert len(calls) >= 2 - second_sql = str(calls[1].args[0].text) if calls[1].args else "" - assert "distinct on" not in second_sql.lower() - - -def test_price_trend_returns_result_with_dedup_enabled() -> None: - """Функция корректно возвращает точки тренда при dedup_enabled=True.""" +def test_price_trend_returns_result_with_dedup() -> None: + """Функция корректно возвращает точки тренда (dedup включён).""" rows = [ {"month": "2025-11", "ppm2": 148_000}, {"month": "2025-12", "ppm2": 152_000}, @@ -90,7 +66,6 @@ def test_price_trend_returns_result_with_dedup_enabled() -> None: mock_db = _make_mock_db([], rows) with patch("app.services.estimator.settings") as mock_settings: - mock_settings.estimate_price_trend_dedup_enabled = True mock_settings.estimate_price_trend_max_age_months = 6 result = _fetch_price_trend(mock_db, target_house_id=42, min_points=3) @@ -101,25 +76,6 @@ def test_price_trend_returns_result_with_dedup_enabled() -> None: assert result[0]["ppm2"] == 148_000 -def test_price_trend_dedup_disabled_returns_same_points() -> None: - """Флаг OFF не ломает возврат данных (backward-compat).""" - rows = [ - {"month": "2025-11", "ppm2": 148_000}, - {"month": "2025-12", "ppm2": 152_000}, - {"month": "2026-01", "ppm2": 155_000}, - ] - mock_db = _make_mock_db([], rows) - - with patch("app.services.estimator.settings") as mock_settings: - mock_settings.estimate_price_trend_dedup_enabled = False - mock_settings.estimate_price_trend_max_age_months = 6 - - result = _fetch_price_trend(mock_db, target_house_id=42, min_points=3) - - assert result is not None - assert len(result) == 3 - - def test_price_trend_source1_preferred_over_dedup_path() -> None: """Если Source1 (houses_price_dynamics) вернул ≥ min_points → Source2 не вызывается.""" source1_rows = [ @@ -130,7 +86,6 @@ def test_price_trend_source1_preferred_over_dedup_path() -> None: mock_db = _make_mock_db(source1_rows, []) with patch("app.services.estimator.settings") as mock_settings: - mock_settings.estimate_price_trend_dedup_enabled = True mock_settings.estimate_price_trend_max_age_months = 6 result = _fetch_price_trend(mock_db, target_house_id=42, min_points=3) diff --git a/tradein-mvp/backend/tests/test_estimator_pure_units.py b/tradein-mvp/backend/tests/test_estimator_pure_units.py index df58a1cb..ca791a6f 100644 --- a/tradein-mvp/backend/tests/test_estimator_pure_units.py +++ b/tradein-mvp/backend/tests/test_estimator_pure_units.py @@ -313,7 +313,6 @@ def _clamp( tier: str | None, slack: float = 0.25, min_n: int = 10, - enabled: bool = True, median_price: int = 0, range_low: int = 0, range_high: int = 0, @@ -328,7 +327,6 @@ def _clamp( anchor_tier=tier, slack=slack, min_n=min_n, - enabled=enabled, ) @@ -374,15 +372,6 @@ def test_corridor_clamp_low_n_is_noop() -> None: assert new_ppm2 == 296_000 -def test_corridor_clamp_disabled_flag_is_noop() -> None: - # enabled=False → точно старое поведение. - new_ppm2, _, _, _, clamped = _clamp( - median_ppm2=296_000, corridor_high=138_000, count=20, tier="C", enabled=False - ) - assert clamped is False - assert new_ppm2 == 296_000 - - def test_corridor_clamp_tier_none_clamps() -> None: # anchor не сработал (tier=None, чистый радиус) — клампим как и Tier C. new_ppm2, _, _, _, clamped = _clamp( diff --git a/tradein-mvp/backend/tests/test_estimator_quarter_index.py b/tradein-mvp/backend/tests/test_estimator_quarter_index.py index 956d3f3e..d0f7cc83 100644 --- a/tradein-mvp/backend/tests/test_estimator_quarter_index.py +++ b/tradein-mvp/backend/tests/test_estimator_quarter_index.py @@ -379,7 +379,6 @@ def _run_estimate_qi( dadata_cadnum: str | None, qi_lookup_result: tuple[float, int] | None, *, - flag_enabled: bool = True, anchor_tier_override: str | None = None, ): """Запускает estimate_quality с полным stub-пачем I/O; возвращает AggregatedEstimate.""" @@ -442,10 +441,6 @@ def _run_estimate_qi( "app.services.estimator._lookup_quarter_indexes", side_effect=_fake_lookup_indexes, ), - patch( - "app.services.estimator.settings.estimate_quarter_index_enabled", - flag_enabled, - ), ): return await estimate_quality(payload, db) @@ -488,7 +483,6 @@ def test_quarter_index_correction_applied() -> None: analogs=analogs_no_cadnum, dadata_cadnum=f"{_TARGET_QUARTER}:350", qi_lookup_result=(1.2, 30), - flag_enabled=True, ) expected_median = round(base_median * 1.2) assert est.median_price_rub == expected_median @@ -497,21 +491,6 @@ def test_quarter_index_correction_applied() -> None: assert "квартал" in est.confidence_explanation.lower() -def test_quarter_index_flag_off_exact_old_behavior() -> None: - """При estimate_quarter_index_enabled=False — точное старое поведение.""" - analogs_no_cadnum = [ - _make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None) for _ in range(3) - ] - est = _run_estimate_qi( - analogs=analogs_no_cadnum, - dadata_cadnum=f"{_TARGET_QUARTER}:350", - qi_lookup_result=(1.2, 30), - flag_enabled=False, - ) - base_median = round(_BASE_PPM2 * _AREA) - assert est.median_price_rub == base_median - - # ───────────────────────────────────────────────────────────────────────────── # Guard-2: >0.6 аналогов в целевом квартале → no-op # ───────────────────────────────────────────────────────────────────────────── @@ -534,7 +513,6 @@ def test_guard2_skip_when_majority_analogs_in_target_quarter() -> None: analogs=analogs, dadata_cadnum=f"{_TARGET_QUARTER}:350", qi_lookup_result=(1.5, 30), # high index — but guard-2 should skip - flag_enabled=True, ) # Медиана НЕ должна изменяться assert est.median_price_rub == base_median @@ -555,7 +533,6 @@ def test_sparse_fallback_no_row_noop() -> None: analogs=analogs_no_cadnum, dadata_cadnum=f"{_TARGET_QUARTER}:350", qi_lookup_result=None, # sparse - flag_enabled=True, ) assert est.median_price_rub == base_median @@ -575,7 +552,6 @@ def test_bimodal_guard_skips_high_index_small_n() -> None: analogs=analogs_no_cadnum, dadata_cadnum=f"{_TARGET_QUARTER}:350", qi_lookup_result=(3.5, 20), # index>2.0 AND n<50 → bimodal guard - flag_enabled=True, ) assert est.median_price_rub == base_median @@ -594,7 +570,6 @@ def test_bimodal_guard_allows_high_index_large_n() -> None: analogs=analogs_no_cadnum, dadata_cadnum=f"{_TARGET_QUARTER}:350", qi_lookup_result=(2.5, 60), # index>2.0 но n=60>=50 → bimodal guard не срабатывает - flag_enabled=True, ) # Коррекция применена: медиана != base_median (bimodal guard не заблокировал). # factor=2.5 > max_factor=1.8 → зажат до 1.8 (#859). diff --git a/tradein-mvp/backend/tests/test_estimator_radius_dedup_1871.py b/tradein-mvp/backend/tests/test_estimator_radius_dedup_1871.py index 5525a3cc..1465f9c3 100644 --- a/tradein-mvp/backend/tests/test_estimator_radius_dedup_1871.py +++ b/tradein-mvp/backend/tests/test_estimator_radius_dedup_1871.py @@ -4,8 +4,8 @@ Radius-путь кэпит только per-address (rn_addr <= MAX_ANALOGS_PER_ (source, source_id)-дубли делят один address и выживают на разных rn_addr рангах → раздувают n_analogs. Anchor-путь дедупит по (source, source_id); radius — нет. -Эти тесты проверяют SQL-фрагмент (rn_dup-окно всегда; `AND rn_dup = 1` фильтр за -флагом) во ВСЕХ четырёх тирах (S-canonical, S-fallback, H, W) + дедупную семантику +Эти тесты проверяют SQL-фрагмент (rn_dup-окно + `AND rn_dup = 1` фильтр) во ВСЕХ +четырёх тирах (S-canonical, S-fallback, H, W) + дедупную семантику окна на уровне Python-симуляции (freshest scraped_at, namespaced ключ, NULL-guard). Полный radius-путь требует PostGIS+БД, поэтому SQL проверяется на сгенерированном тексте (как test_estimator_price_trend_dedup.py). @@ -14,7 +14,7 @@ Radius-путь кэпит только per-address (rn_addr <= MAX_ANALOGS_PER_ from __future__ import annotations import os -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") @@ -23,7 +23,7 @@ import pytest import app.services.estimator as est -def _capture_tier_sql(*, dedup_enabled: bool) -> list[str]: +def _capture_tier_sql() -> list[str]: """Прогоняет _fetch_analogs с mock db, возвращает SQL-текст каждого тира. target_house_id + short_addr + year/floors заданы так, чтобы SQL ВСЕХ четырёх @@ -40,63 +40,42 @@ def _capture_tier_sql(*, dedup_enabled: bool) -> list[str]: db.execute.side_effect = side_effect - with patch.object(est.settings, "estimate_radius_dedup_enabled", dedup_enabled): - est._fetch_analogs( - db, - lat=56.83, - lon=60.6, - rooms=2, - area=50.0, - radius_m=2000, - full_address="г Екатеринбург, ул Малышева, д 30", - year_built=2010, - house_type="монолит", - total_floors=20, - target_house_id=123, - ) + est._fetch_analogs( + db, + lat=56.83, + lon=60.6, + rooms=2, + area=50.0, + radius_m=2000, + full_address="г Екатеринбург, ул Малышева, д 30", + year_built=2010, + house_type="монолит", + total_floors=20, + target_house_id=123, + ) return captured -# --------------------------------------------------------------------------- -# Флаг по умолчанию -# --------------------------------------------------------------------------- - - -def test_radius_dedup_flag_default_true() -> None: - from app.core.config import settings - - assert settings.estimate_radius_dedup_enabled is True - - # --------------------------------------------------------------------------- # SQL-фрагмент во всех тирах # --------------------------------------------------------------------------- def test_all_four_tiers_render() -> None: - sqls = _capture_tier_sql(dedup_enabled=True) + sqls = _capture_tier_sql() assert len(sqls) == 4, "ожидаем S-canonical, S-fallback, H, W" -def test_rn_dup_window_present_in_every_tier_when_enabled() -> None: - sqls = _capture_tier_sql(dedup_enabled=True) +def test_rn_dup_window_present_in_every_tier() -> None: + sqls = _capture_tier_sql() for i, sql in enumerate(sqls): assert "AS rn_dup" in sql, f"tier#{i} без rn_dup-окна" assert "rn_dup = 1" in sql, f"tier#{i} без `AND rn_dup = 1` фильтра" -def test_dup_filter_dropped_when_disabled_but_window_stays() -> None: - """Флаг OFF: фильтр `AND rn_dup = 1` убран, окно остаётся (безвредно).""" - sqls = _capture_tier_sql(dedup_enabled=False) - assert len(sqls) == 4 - for i, sql in enumerate(sqls): - assert "AS rn_dup" in sql, f"tier#{i}: окно должно оставаться даже при OFF" - assert "rn_dup = 1" not in sql, f"tier#{i}: фильтр должен исчезнуть при OFF" - - def test_rn_dup_key_namespaced_and_freshest() -> None: """Ключ окна namespaced (id:/url:/ctid:) + ORDER BY scraped_at DESC.""" - sql = _capture_tier_sql(dedup_enabled=True)[0] + sql = _capture_tier_sql()[0] assert "PARTITION BY source" in sql assert "'id:' || source_id::text" in sql assert "'url:' || source_url" in sql @@ -117,7 +96,7 @@ def test_no_bind_param_double_colon_cast() -> None: import re bad = re.compile(r":[a-z_]+::[a-z]") - for i, sql in enumerate(_capture_tier_sql(dedup_enabled=True)): + for i, sql in enumerate(_capture_tier_sql()): assert not bad.search(sql), f"tier#{i}: найден запрещённый :bind::type" diff --git a/tradein-mvp/backend/tests/test_estimator_radius_floor.py b/tradein-mvp/backend/tests/test_estimator_radius_floor.py index 21f39e1c..1551978e 100644 --- a/tradein-mvp/backend/tests/test_estimator_radius_floor.py +++ b/tradein-mvp/backend/tests/test_estimator_radius_floor.py @@ -8,7 +8,6 @@ - radius median выше dkp_low × factor → no-op (медиана не изменена) - dkp_raw is None → no-op (нет базы для floor) - anchor-путь (anchor_tier != None) → не затронут floor'ом -- флаг estimate_radius_floor_enabled=False → no-op """ from __future__ import annotations @@ -75,7 +74,6 @@ def _run_estimate( analogs: list[dict[str, Any]], dkp_raw: dict[str, Any] | None, *, - radius_floor_enabled: bool = True, radius_floor_factor: float = 0.8, ) -> Any: from app.core.config import settings @@ -109,7 +107,6 @@ def _run_estimate( ), patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw), patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), - patch.object(settings, "estimate_radius_floor_enabled", radius_floor_enabled), patch.object(settings, "estimate_radius_floor_factor", radius_floor_factor), ): return await estimate_quality(payload, db) @@ -188,62 +185,3 @@ def test_no_dkp_raw_no_floor() -> None: assert ( est.median_price_per_m2 < 100_000 ), f"median_ppm2={est.median_price_per_m2} без dkp_raw не должна расти" - - -# ── тест 4: флаг OFF → no-op ───────────────────────────────────────────────── - - -def test_radius_floor_disabled_no_op() -> None: - """estimate_radius_floor_enabled=False → floor не применяется.""" - analogs = [ - _make_listing(price_per_m2=75_000.0), - _make_listing(price_per_m2=80_000.0), - _make_listing(price_per_m2=85_000.0), - ] - dkp_raw = { - "count": 15, - "low_ppm2": 150_000, - "median_ppm2": 180_000, - "high_ppm2": 220_000, - "period_months": 12, - } - # Без флага — median не должна подняться - from app.core.config import settings - from app.services.estimator import estimate_quality - - db = MagicMock() - payload = _make_payload() - - async def _run() -> Any: - with ( - patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_geo())), - patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)), - patch("app.services.estimator.match_house_readonly", return_value=None), - patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), - patch( - "app.services.estimator._fetch_analogs", - return_value=(list(analogs), False, "S"), - ), - patch("app.services.estimator._fetch_deals", return_value=[]), - patch( - "app.services.estimator._get_or_fetch_imv_cached", - new=AsyncMock(return_value=None), - ), - patch( - "app.services.estimator._get_or_fetch_yandex_valuation_cached", - new=AsyncMock(return_value=None), - ), - patch( - "app.services.estimator.estimate_via_cian_valuation", - new=AsyncMock(return_value=None), - ), - patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw), - patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), - patch.object(settings, "estimate_radius_floor_enabled", False), - ): - return await estimate_quality(payload, db) - - est = anyio.run(_run) - assert ( - est.median_price_per_m2 < 100_000 - ), f"Флаг OFF: median_ppm2={est.median_price_per_m2} не должна расти" diff --git a/tradein-mvp/backend/tests/test_estimator_split_corridor_1871.py b/tradein-mvp/backend/tests/test_estimator_split_corridor_1871.py index bc7214af..f594deb4 100644 --- a/tradein-mvp/backend/tests/test_estimator_split_corridor_1871.py +++ b/tradein-mvp/backend/tests/test_estimator_split_corridor_1871.py @@ -47,11 +47,8 @@ def _payload(): # type: ignore[no-untyped-def] return _h._make_payload(area=60.0, rooms=2) -def _run(*, flag: bool, anchor_tier: str, comps=None, threshold: float = 0.6): # type: ignore[no-untyped-def] - with ( - patch.object(settings, "estimate_wide_corridor_disclosure_enabled", flag), - patch.object(settings, "estimate_wide_corridor_threshold", threshold), - ): +def _run(*, anchor_tier: str, comps=None, threshold: float = 0.6): # type: ignore[no-untyped-def] + with patch.object(settings, "estimate_wide_corridor_threshold", threshold): return _h._run_estimate( anchor_comps=_WIDE_SPLIT_COMPS if comps is None else comps, anchor_tier=anchor_tier, @@ -63,15 +60,10 @@ _DISCLOSURE_MARKER = "разбит на секции" # --------------------------------------------------------------------------- -# Флаг по умолчанию +# Порог по умолчанию # --------------------------------------------------------------------------- -def test_disclosure_flag_default_on() -> None: - # Включено после валидации порога на prod-данных (corridor_pct median≈0.48). - assert settings.estimate_wide_corridor_disclosure_enabled is True - - def test_threshold_default_1_2() -> None: # 1.2: 0.6 фаерил бы на ~31% оценок (широкий коридор ≠ split-дом); # genuine split-дома аудита = 148-170% → 1.2 ловит только экстремальный хвост. @@ -83,21 +75,21 @@ def test_threshold_default_1_2() -> None: # --------------------------------------------------------------------------- -def test_tier_a_wide_corridor_flag_on_downgrades_and_discloses() -> None: - """Tier A + corridor_pct > threshold + флаг ON → confidence понижен + disclosure.""" - on = _run(flag=True, anchor_tier="A") +def test_tier_a_wide_corridor_downgrades_and_discloses() -> None: + """Tier A + corridor_pct > threshold → confidence понижен + disclosure.""" + on = _run(anchor_tier="A") pct = (on.range_high_rub - on.range_low_rub) / on.median_price_rub assert pct > 0.6, "fixture должен давать коридор > 60%" assert _DISCLOSURE_MARKER in (on.confidence_explanation or "") - # confidence понижен относительно flag-OFF baseline (medium → low). - off = _run(flag=False, anchor_tier="A") + # confidence понижен относительно no-fire baseline (порог выше коридора). + off = _run(anchor_tier="A", threshold=99.0) assert _downgrade_confidence(off.confidence) == on.confidence assert on.confidence != off.confidence -def test_flag_off_no_change() -> None: - """Флаг OFF → ничего не делает (ни confidence, ни explanation).""" - off = _run(flag=False, anchor_tier="A") +def test_threshold_above_corridor_no_change() -> None: + """Порог выше фактического коридора → ничего не делает (ни confidence, ни explanation).""" + off = _run(anchor_tier="A", threshold=99.0) assert _DISCLOSURE_MARKER not in (off.confidence_explanation or "") @@ -108,7 +100,7 @@ def test_flag_off_no_change() -> None: def test_corridor_fires_when_above_threshold() -> None: """corridor_pct (≈1.045) > threshold (1.0) → disclosure фаерит (механизм работает).""" - on = _run(flag=True, anchor_tier="A", threshold=1.0) + on = _run(anchor_tier="A", threshold=1.0) pct = (on.range_high_rub - on.range_low_rub) / on.median_price_rub assert pct > 1.0 assert _DISCLOSURE_MARKER in (on.confidence_explanation or "") @@ -117,7 +109,7 @@ def test_corridor_fires_when_above_threshold() -> None: def test_moderate_corridor_no_fire_at_prod_threshold_1_2() -> None: """Прод-дефолт 1.2: умеренный коридор (0.6 None: def test_does_not_touch_point_median_range() -> None: """Disclosure НЕ трогает point/median/range — только confidence+explanation.""" - off = _run(flag=False, anchor_tier="A") - on = _run(flag=True, anchor_tier="A") + off = _run(anchor_tier="A", threshold=99.0) + on = _run(anchor_tier="A") assert on.median_price_rub == off.median_price_rub assert on.range_low_rub == off.range_low_rub assert on.range_high_rub == off.range_high_rub @@ -134,13 +126,13 @@ def test_does_not_touch_point_median_range() -> None: def test_below_threshold_no_disclosure() -> None: """corridor_pct ниже порога → без изменений (порог поднят выше фактического).""" - high_threshold = _run(flag=True, anchor_tier="A", threshold=5.0) + high_threshold = _run(anchor_tier="A", threshold=5.0) assert _DISCLOSURE_MARKER not in (high_threshold.confidence_explanation or "") def test_non_tier_a_not_affected() -> None: - """Не-Tier-A (Tier C) → disclosure не применяется (даже с флагом ON).""" - c = _run(flag=True, anchor_tier="C") + """Не-Tier-A (Tier C) → disclosure не применяется.""" + c = _run(anchor_tier="C") assert _DISCLOSURE_MARKER not in (c.confidence_explanation or "") diff --git a/tradein-mvp/backend/tests/test_same_building_anchor.py b/tradein-mvp/backend/tests/test_same_building_anchor.py index c01d283f..af5c3fd7 100644 --- a/tradein-mvp/backend/tests/test_same_building_anchor.py +++ b/tradein-mvp/backend/tests/test_same_building_anchor.py @@ -426,7 +426,6 @@ def _run_estimate( *, anchor_comps: list[dict[str, Any]], anchor_tier: str | None, - flag_enabled: bool = True, ratio_tuple: tuple[float | None, str | None] = (0.92, "per_rooms"), payload=None, radius_analogs: list[dict[str, Any]] | None = None, @@ -439,7 +438,6 @@ def _run_estimate( ST_DWithin не нашёл радиусных аналогов (median=0) — якорь обязан сработать по same-building комплам независимо. """ - from app.core.config import settings from app.services.estimator import estimate_quality db = MagicMock() @@ -448,7 +446,6 @@ def _run_estimate( async def _run(): with ( - patch.object(settings, "estimate_same_building_anchor_enabled", flag_enabled), patch( "app.services.estimator.geocode", new=AsyncMock(return_value=geo_result or _make_fake_geo()), @@ -546,14 +543,6 @@ def test_estimate_economy_no_regression() -> None: assert est.median_price_per_m2 >= 100_000 -def test_estimate_flag_off_unchanged_radius_result() -> None: - """(e) Флаг OFF ⇒ headline = радиусная медиана (210k×146.2), якорь не трогает.""" - est_off = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A", flag_enabled=False) - # Радиусная медиана из _RADIUS_ANALOGS = median(200,210,220)=210k. - assert est_off.median_price_per_m2 == 210_000 - assert est_off.median_price_rub == int(210_000 * 146.2) - - def test_estimate_expected_sold_distinct_after_anchor() -> None: """(f) При сработавшем якоре headline = ASKING (anchor_ppm2, pre-haircut), а expected_sold = headline × per-rooms ratio → DISTINCT, строго ниже median. diff --git a/tradein-mvp/backend/tests/test_segment_guard_1186.py b/tradein-mvp/backend/tests/test_segment_guard_1186.py index 999e41c4..289ff36d 100644 --- a/tradein-mvp/backend/tests/test_segment_guard_1186.py +++ b/tradein-mvp/backend/tests/test_segment_guard_1186.py @@ -67,16 +67,13 @@ def test_tier_a_anchor_gated_python_side_not_sql_guard() -> None: relaxation (впускает novostroyki только если в доме есть вторичка/NULL). Assert: (a) Tier C всё ещё несёт канон-guard в SQL, (b) Tier A делает - Python-side gating — источник содержит и флаг настройки, и hardened - secondary_count/primary_count gate (#1774, после code-review). + Python-side gating — источник содержит hardened secondary_count/primary_count + gate (#1774, после code-review). """ src = inspect.getsource(est_mod._fetch_anchor_comps) # (a) Tier C SQL guard сохранён (поиск находит единственное вхождение — Tier C). assert _GUARD_RE.search(src), "Tier C anchor SQL lost the canonical novostroyki guard" # (b) Tier A теперь gated Python-side (#1774). - assert ( - "estimate_sb_tier_a_allow_primary_if_secondary_present" in src - ), "Tier A no longer references the #1774 gating flag — gated relaxation missing" # Hardened gate: secondary_count ≥ primary_count (заменил has_secondary). assert "secondary_count" in src, "Tier A lacks secondary_count Python-side gate (#1774)" assert "primary_count" in src, "Tier A lacks primary_count comparison in hardened gate (#1774)" 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 0a36f5df..262fc372 100644 --- a/tradein-mvp/backend/tests/test_tier_a_primary_1774.py +++ b/tradein-mvp/backend/tests/test_tier_a_primary_1774.py @@ -105,12 +105,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), - patch.object( - est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True - ), - ): + with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): comps, tier = _fetch(db) assert tier == "A" # Все 6 (4 вторички + 2 novostroyki-переуступки) учтены. @@ -135,12 +130,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), - patch.object( - est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True - ), - ): + with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): comps, tier = _fetch(db) # primary-dominated → 5 novostroyki отброшены, 1 vtorichka < min_comps → Tier A skip. assert tier is None @@ -162,42 +152,13 @@ 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), - patch.object( - est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True - ), - ): + with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): comps, tier = _fetch(db) # Все отброшены гардом → 0 comps < 4 → Tier A skip (lat/lon None → Tier D). assert tier is None assert comps == [] -def test_tier_a_flag_off_excludes_novostroyki_even_with_secondary() -> None: - """Flag OFF → оригинальное поведение #1186: novostroyki ВСЕГДА исключены в Tier A, - даже если в доме есть вторичка. Остаются только 4 vtorichka/NULL.""" - rows = [ - _row(source="avito", source_id="a1", listing_segment="vtorichka"), - _row(source="avito", source_id="a2", listing_segment=None), - _row(source="yandex", source_id="y1", listing_segment="vtorichka"), - _row(source="yandex", source_id="y2", listing_segment=None), - _row(source="cian", source_id="c1", listing_segment="novostroyki"), - _row(source="cian", source_id="c2", listing_segment="novostroyki"), - ] - db = _db_mock(rows) - with ( - patch.object(est_mod.settings, "estimate_sb_min_comps", 4), - patch.object( - est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", False - ), - ): - comps, tier = _fetch(db) - assert tier == "A" - # novostroyki отброшены → только 4 вторички. - assert len(comps) == 4 - - def test_tier_a_dedup_same_source_id_collapses() -> None: """#1774 dedup: дубликат одного объявления (source_id=330047129, 2 active-строки — одна с house_id_fk, одна NULL) схлопывается в 1 comp по PRIMARY ключу @@ -216,12 +177,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), - patch.object( - est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True - ), - ): + with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): comps, tier = _fetch(db) assert tier == "A" # 6 строк, но 2 cian-строки с одинаковым source_id → 1 comp → итого 5. @@ -257,12 +213,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), - patch.object( - est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True - ), - ): + with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): comps, tier = _fetch(db) assert tier == "A" # source_id-primary схлопывает несмотря на разные url → 5 comps. @@ -281,12 +232,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), - patch.object( - est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True - ), - ): + with patch.object(est_mod.settings, "estimate_sb_min_comps", 4): comps, tier = _fetch(db) assert tier == "A" # 4 разных лота (разные source_id/площадь) → 4 comps, ничего не схлопнуто. -- 2.45.3