tech-debt(tradein/estimator): collapse won estimate_* flags into defaults (#1970)
All checks were successful
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 53s
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 8s
All checks were successful
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 53s
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 8s
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).
This commit is contained in:
parent
346767ba90
commit
d5df5fdb9c
18 changed files with 132 additions and 527 deletions
|
|
@ -106,9 +106,7 @@ class Settings(BaseSettings):
|
||||||
# house_imv_evaluations, либо Yandex sale) выше нашей медианы более чем в
|
# house_imv_evaluations, либо Yandex sale) выше нашей медианы более чем в
|
||||||
# `threshold` раз — подмешиваем якорь к медиане с весом `weight` и
|
# `threshold` раз — подмешиваем якорь к медиане с весом `weight` и
|
||||||
# расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем (баг — занижение).
|
# расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем (баг — занижение).
|
||||||
# Полностью за флагами — безопасно выкатить до демо; при отсутствии IMV/Yandex
|
# При отсутствии IMV/Yandex no-op (медиана не меняется).
|
||||||
# no-op (медиана не меняется).
|
|
||||||
estimate_imv_blend_enabled: bool = True
|
|
||||||
estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w
|
estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w
|
||||||
estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15
|
estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15
|
||||||
|
|
||||||
|
|
@ -117,9 +115,8 @@ class Settings(BaseSettings):
|
||||||
# комфорт −15-25%. v2 берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A),
|
# комфорт −15-25%. v2 берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A),
|
||||||
# similarity-weighted по площади/комнатам, premium-uplift к ~p70 для топ-юнита
|
# similarity-weighted по площади/комнатам, premium-uplift к ~p70 для топ-юнита
|
||||||
# дома, asking→sold haircut (banded по ppm²), hard guardrail (est ≥ min-comp×0.95)
|
# дома, 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.
|
# Спек+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_min_comps: int = 4 # стоп на первом тире с ≥ N активных комплов
|
||||||
estimate_sb_area_sigma: float = 0.18 # σ log-нормального area-веса (Gaussian)
|
estimate_sb_area_sigma: float = 0.18 # σ log-нормального area-веса (Gaussian)
|
||||||
estimate_sb_rooms_match_boost: float = 1.6 # ×вес если rooms компла == target
|
estimate_sb_rooms_match_boost: float = 1.6 # ×вес если rooms компла == target
|
||||||
|
|
@ -138,8 +135,7 @@ class Settings(BaseSettings):
|
||||||
# интервал, выданный за полный). Эмпирически отношение actual_sold/expected_sold
|
# интервал, выданный за полный). Эмпирически отношение actual_sold/expected_sold
|
||||||
# по 2366 прод-сделкам имеет p10=0.649, p90=1.392 → band
|
# по 2366 прод-сделкам имеет p10=0.649, p90=1.392 → band
|
||||||
# expected_sold × [low_mult, high_mult] = настоящий ~80% prediction interval
|
# expected_sold × [low_mult, high_mult] = настоящий ~80% prediction interval
|
||||||
# (проверено: 80.0% coverage на тех же 2366). OFF ⇒ точно старое IQR-поведение.
|
# (проверено: 80.0% coverage на тех же 2366).
|
||||||
estimate_calibrated_pi_enabled: bool = True
|
|
||||||
estimate_pi_low_mult: float = 0.649 # empirical p10 of sold/expected_sold (#1966, n=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)
|
estimate_pi_high_mult: float = 1.392 # empirical p90 of sold/expected_sold (#1966, n=2366)
|
||||||
# ── #2002: hedonic year+area correction на точку expected_sold ─────────────
|
# ── #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"
|
# median_ppm2 > high_ppm2×(1+slack) И count≥min_n И anchor_tier != "A"
|
||||||
# (Tier A = реальные комплы того же дома → EXEMPT) — жёстко прижимаем headline
|
# (Tier A = реальные комплы того же дома → EXEMPT) — жёстко прижимаем headline
|
||||||
# к high_ppm2×(1+slack) и пропорционально пересчитываем price/range/expected_sold.
|
# к high_ppm2×(1+slack) и пропорционально пересчитываем price/range/expected_sold.
|
||||||
estimate_corridor_clamp_enabled: bool = True
|
|
||||||
estimate_corridor_clamp_min_n: int = 10
|
estimate_corridor_clamp_min_n: int = 10
|
||||||
# cap = corridor_high×(1+slack) = ×1.40; даёт премиум-домам без own-листингов
|
# cap = corridor_high×(1+slack) = ×1.40; даёт премиум-домам без own-листингов
|
||||||
# (tier-C) больше воздуха над sold-коридором, не возвращая исходную 2× инфляцию
|
# (tier-C) больше воздуха над sold-коридором, не возвращая исходную 2× инфляцию
|
||||||
|
|
@ -181,9 +176,8 @@ class Settings(BaseSettings):
|
||||||
# Если итоговый median_ppm2 < dkp_low_ppm2 × factor — поднимаем до floor.
|
# Если итоговый median_ppm2 < dkp_low_ppm2 × factor — поднимаем до floor.
|
||||||
# Применяется ТОЛЬКО на radius-пути (anchor_tier is None) и при dkp_raw.
|
# Применяется ТОЛЬКО на radius-пути (anchor_tier is None) и при dkp_raw.
|
||||||
# factor=0.8: 20% зазор ниже P10 коридора → floor достаточно мягкий для эконома
|
# factor=0.8: 20% зазор ниже P10 коридора → floor достаточно мягкий для эконома
|
||||||
# (избегаем ложных подъёмов) и ловит явный undershoot. False → no-op.
|
# (избегаем ложных подъёмов) и ловит явный undershoot.
|
||||||
# ENV: ESTIMATE_RADIUS_FLOOR_ENABLED, ESTIMATE_RADIUS_FLOOR_FACTOR.
|
# ENV: ESTIMATE_RADIUS_FLOOR_FACTOR.
|
||||||
estimate_radius_floor_enabled: bool = True
|
|
||||||
estimate_radius_floor_factor: float = 0.8
|
estimate_radius_floor_factor: float = 0.8
|
||||||
# Шаг 5 — clamp expected_sold <= asking: ratio > 1.0 физически невозможен для
|
# Шаг 5 — clamp expected_sold <= asking: ratio > 1.0 физически невозможен для
|
||||||
# trade-in (ожидаемая цена сделки не должна превышать цену объявления).
|
# trade-in (ожидаемая цена сделки не должна превышать цену объявления).
|
||||||
|
|
@ -215,7 +209,6 @@ class Settings(BaseSettings):
|
||||||
# "novostroyki"-тег = переуступки/перепродажи собственниками (sale_type=free).
|
# "novostroyki"-тег = переуступки/перепродажи собственниками (sale_type=free).
|
||||||
# Чисто-первичный дом (0 вторички) → гард #1186 сохраняется. Tier C / радиус /
|
# Чисто-первичный дом (0 вторички) → гард #1186 сохраняется. Tier C / радиус /
|
||||||
# ratio — не затрагиваются.
|
# ratio — не затрагиваются.
|
||||||
estimate_sb_tier_a_allow_primary_if_secondary_present: bool = True
|
|
||||||
asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²)
|
asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²)
|
||||||
estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона
|
estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона
|
||||||
|
|
||||||
|
|
@ -225,8 +218,6 @@ class Settings(BaseSettings):
|
||||||
# здоровые якоря (n≥4 с FSD<0.15) проходят без изменений.
|
# здоровые якоря (n≥4 с FSD<0.15) проходят без изменений.
|
||||||
# estimate_sb_gate_min_n=3 : при n<3 И FSD>max_fsd гейт срабатывает
|
# estimate_sb_gate_min_n=3 : при n<3 И FSD>max_fsd гейт срабатывает
|
||||||
# estimate_sb_gate_max_fsd=0.20: FSD>0.20 при малом n → ненадёжный якорь
|
# estimate_sb_gate_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_min_n: int = 3
|
||||||
estimate_sb_gate_max_fsd: float = 0.20
|
estimate_sb_gate_max_fsd: float = 0.20
|
||||||
|
|
||||||
|
|
@ -235,25 +226,12 @@ class Settings(BaseSettings):
|
||||||
# Дефолт 6 (консервативно); аудит предложил 3 — конфигурируемо.
|
# Дефолт 6 (консервативно); аудит предложил 3 — конфигурируемо.
|
||||||
estimate_price_trend_max_age_months: int = 6
|
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 ─────────────────────────────
|
# ── #1871 P1.2: ghost-anchor confidence floor ─────────────────────────────
|
||||||
# True (дефолт) = форсировать confidence='low' + добавлять caveat в explanation
|
# True (дефолт) = форсировать confidence='low' + добавлять caveat в explanation
|
||||||
# когда n_analogs == 0 (нет радиусных/anchor-аналогов) но confidence не 'low'.
|
# когда n_analogs == 0 (нет радиусных/anchor-аналогов) но confidence не 'low'.
|
||||||
# Защита от ghost-anchor: внешние оценочные сервисы (yandex_valuation,
|
# Защита от ghost-anchor: внешние оценочные сервисы (yandex_valuation,
|
||||||
# cian_valuation, avito_imv) могут дать median без единого реального рыночного
|
# cian_valuation, avito_imv) могут дать median без единого реального рыночного
|
||||||
# аналога → headline выглядит достоверным при нулевой реальной базе.
|
# аналога → headline выглядит достоверным при нулевой реальной базе.
|
||||||
# False = старое поведение без принудительного понижения (для отката).
|
|
||||||
# ENV: ESTIMATE_CONFIDENCE_FLOOR_NO_ANALOGS.
|
|
||||||
estimate_confidence_floor_no_analogs: bool = True
|
|
||||||
|
|
||||||
# ── #2002 #4: manual-review recommendation (derived FLAG, НЕ ценовой сигнал) ─
|
# ── #2002 #4: manual-review recommendation (derived FLAG, НЕ ценовой сигнал) ─
|
||||||
# Помечает оценки, которые НЕ стоит авто-оффэрить — нужна ручная оценка
|
# Помечает оценки, которые НЕ стоит авто-оффэрить — нужна ручная оценка
|
||||||
|
|
@ -261,8 +239,6 @@ class Settings(BaseSettings):
|
||||||
# от отделки/вида, чего нет в данных сделок). Триггеры: премиальный дом,
|
# от отделки/вида, чего нет в данных сделок). Триггеры: премиальный дом,
|
||||||
# высокая стоимость, низкая уверенность, слишком широкий диапазон цены.
|
# высокая стоимость, низкая уверенность, слишком широкий диапазон цены.
|
||||||
# Чисто метаданные — не трогает median/expected_sold/ranges (gate byte-stable).
|
# Чисто метаданные — не трогает 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_high_value_rub: int = 20_000_000 # ≥ этого — ручная оценка
|
||||||
estimate_manual_review_wide_range_ratio: float = 1.9 # range_high/range_low ≥ — неопределённо
|
estimate_manual_review_wide_range_ratio: float = 1.9 # range_high/range_low ≥ — неопределённо
|
||||||
# asking ₽/м² ≥ этого → дорогой сегмент, авто-оценка консервативна
|
# asking ₽/м² ≥ этого → дорогой сегмент, авто-оценка консервативна
|
||||||
|
|
@ -274,12 +250,8 @@ class Settings(BaseSettings):
|
||||||
# (rn_addr <= MAX_ANALOGS_PER_ADDRESS), но (source, source_id)-дубли делят один
|
# (rn_addr <= MAX_ANALOGS_PER_ADDRESS), но (source, source_id)-дубли делят один
|
||||||
# address и выживают на разных rn_addr рангах → раздувают n_analogs (prod
|
# address и выживают на разных rn_addr рангах → раздувают n_analogs (prod
|
||||||
# 2026-06-23: yandex 48, cian 9, n1 5 excess). Anchor-путь дедупит по
|
# 2026-06-23: yandex 48, cian 9, n1 5 excess). Anchor-путь дедупит по
|
||||||
# (source, source_id) — radius нет. True (дефолт) добавляет rn_dup=1 фильтр в
|
# (source, source_id) — radius нет. Добавляет rn_dup=1 фильтр в каждом тире
|
||||||
# каждом тире (freshest scraped_at на (source, source_id|source_url|ctid)).
|
# (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
|
|
||||||
|
|
||||||
# ── #2087 H4: кросс-source физический дедуп аналогов ──────────────────────
|
# ── #2087 H4: кросс-source физический дедуп аналогов ──────────────────────
|
||||||
# Radius-дедуп выше ловит только повторы ВНУТРИ одного source (source, source_id).
|
# Radius-дедуп выше ловит только повторы ВНУТРИ одного source (source, source_id).
|
||||||
|
|
@ -302,7 +274,7 @@ class Settings(BaseSettings):
|
||||||
# ТУ ЖЕ цену → нулевой вклад в дисперсию) → cv/коридор НЕ сужаются. Это фикс
|
# ТУ ЖЕ цену → нулевой вклад в дисперсию) → cv/коридор НЕ сужаются. Это фикс
|
||||||
# ЧЕСТНОСТИ СЧЁТА (n_analogs не раздут ×3 кросс-постами, source_counts по
|
# ЧЕСТНОСТИ СЧЁТА (n_analogs не раздут ×3 кросс-постами, source_counts по
|
||||||
# физлотам), accuracy-нейтральный, а НЕ рычаг сужения cv (рычаг cv→коридор —
|
# физлотам), 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
|
# ON accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias −3.83%, median width/cv
|
||||||
# без изменений), меняется только user-visible n_analogs — перестаёт быть раздутым
|
# без изменений), меняется только user-visible n_analogs — перестаёт быть раздутым
|
||||||
# кросс-постингом ×3. ENV: ESTIMATE_DEDUP_ANALOGS_ENABLED (=false откатывает).
|
# кросс-постингом ×3. ENV: ESTIMATE_DEDUP_ANALOGS_ENABLED (=false откатывает).
|
||||||
|
|
@ -367,11 +339,9 @@ class Settings(BaseSettings):
|
||||||
# Tier A (same-building) матчит по address-regex (намеренно НЕ house_id — дом
|
# Tier A (same-building) матчит по address-regex (намеренно НЕ house_id — дом
|
||||||
# дробится на несколько house_id). На split-доме разной этажности comp_min..max
|
# дробится на несколько house_id). На split-доме разной этажности comp_min..max
|
||||||
# растягивается через несколько ценовых режимов → коридор range_low/high
|
# растягивается через несколько ценовых режимов → коридор range_low/high
|
||||||
# 148%/170%. Коридор честно широкий, но юзер видит 170% без объяснения. True +
|
# 148%/170%. Коридор честно широкий, но юзер видит 170% без объяснения. Tier A +
|
||||||
# Tier A + corridor_pct > threshold → понижаем confidence на ступень и дописываем
|
# corridor_pct > threshold → понижаем confidence на ступень и дописываем
|
||||||
# disclosure в explanation. НЕ трогает point/median/range.
|
# 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.
|
# Порог ширины коридора (range_high-range_low)/median_price для disclosure.
|
||||||
# 1.2 (120%): по prod-данным corridor_pct median≈0.48, p90≈0.93 — порог 0.6
|
# 1.2 (120%): по prod-данным corridor_pct median≈0.48, p90≈0.93 — порог 0.6
|
||||||
# фаерил бы на ~31% оценок (широкий коридор ≠ split-дом, ложная атрибуция).
|
# фаерил бы на ~31% оценок (широкий коридор ≠ split-дом, ложная атрибуция).
|
||||||
|
|
@ -388,11 +358,6 @@ class Settings(BaseSettings):
|
||||||
cian_valuation_min_rub: float = 500_000
|
cian_valuation_min_rub: float = 500_000
|
||||||
cian_valuation_max_rub: float = 500_000_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 ─────────────────────────────────────────────
|
# ── #audit-5: data-age guards ─────────────────────────────────────────────
|
||||||
# sber_index_max_age_days: максимальный допустимый возраст последнего месяца
|
# sber_index_max_age_days: максимальный допустимый возраст последнего месяца
|
||||||
# СберИндекс-серии (дней). Если latest месяц старее — логируем warning.
|
# СберИндекс-серии (дней). Если latest месяц старее — логируем warning.
|
||||||
|
|
@ -410,8 +375,6 @@ class Settings(BaseSettings):
|
||||||
# между квартальным уровнем целевого объекта и усреднённым квартальным уровнем
|
# между квартальным уровнем целевого объекта и усреднённым квартальным уровнем
|
||||||
# аналогов — не дублирует location, уже заложенный в медиану аналогов.
|
# аналогов — не дублирует location, уже заложенный в медиану аналогов.
|
||||||
# Формула: adjusted_ppm2 = base_ppm2 × target_index / avg_analog_index.
|
# Формула: adjusted_ppm2 = base_ppm2 × target_index / avg_analog_index.
|
||||||
# За флагом: False → точно текущее поведение (backward-compatible).
|
|
||||||
estimate_quarter_index_enabled: bool = True
|
|
||||||
# Минимальное число сделок в квартале (sparse fallback: меньше — no-op).
|
# Минимальное число сделок в квартале (sparse fallback: меньше — no-op).
|
||||||
estimate_quarter_index_min_n_deals: int = 10
|
estimate_quarter_index_min_n_deals: int = 10
|
||||||
# Guard-2 (no double-count): если доля аналогов ИЗ ТОГО ЖЕ квартала > порога —
|
# Guard-2 (no double-count): если доля аналогов ИЗ ТОГО ЖЕ квартала > порога —
|
||||||
|
|
|
||||||
|
|
@ -266,7 +266,7 @@ class AggregatedEstimate(BaseModel):
|
||||||
# оценка человеком (премиальный дом / высокая стоимость / низкая уверенность /
|
# оценка человеком (премиальный дом / высокая стоимость / низкая уверенность /
|
||||||
# широкий диапазон). Дефолт False — не трогает median/expected_sold/ranges.
|
# широкий диапазон). Дефолт False — не трогает median/expected_sold/ranges.
|
||||||
# manual_review_reasons — человекочитаемые RU-причины для ревьюера; пусто, когда
|
# manual_review_reasons — человекочитаемые RU-причины для ревьюера; пусто, когда
|
||||||
# рекомендация не сработала (или флаг estimate_manual_review_enabled выключен).
|
# рекомендация не сработала.
|
||||||
manual_review_recommended: bool = False
|
manual_review_recommended: bool = False
|
||||||
manual_review_reasons: list[str] = Field(default_factory=list)
|
manual_review_reasons: list[str] = Field(default_factory=list)
|
||||||
# ── #2043 (BE-1): метрики достоверности выборки — уже считаются, отдаём наружу ──
|
# ── #2043 (BE-1): метрики достоверности выборки — уже считаются, отдаём наружу ──
|
||||||
|
|
|
||||||
|
|
@ -1571,11 +1571,7 @@ def _fetch_anchor_comps(
|
||||||
1 for r in raw_rows if r.get("listing_segment") in (None, "vtorichka")
|
1 for r in raw_rows if r.get("listing_segment") in (None, "vtorichka")
|
||||||
)
|
)
|
||||||
primary_count = len(raw_rows) - secondary_count
|
primary_count = len(raw_rows) - secondary_count
|
||||||
include_primary = (
|
include_primary = secondary_count >= 1 and secondary_count >= primary_count
|
||||||
settings.estimate_sb_tier_a_allow_primary_if_secondary_present
|
|
||||||
and secondary_count >= 1
|
|
||||||
and secondary_count >= primary_count
|
|
||||||
)
|
|
||||||
if include_primary:
|
if include_primary:
|
||||||
kept = raw_rows # mixed/delivered house: include novostroyki resales
|
kept = raw_rows # mixed/delivered house: include novostroyki resales
|
||||||
else:
|
else:
|
||||||
|
|
@ -1819,12 +1815,12 @@ def _compute_same_building_anchor(
|
||||||
else:
|
else:
|
||||||
anchor = _percentile(sorted(ppm2), 0.5)
|
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 —
|
# Видовые/топ-юниты с высоким ppm² могут быть выкинуты сырым clip'ом ДО weighting —
|
||||||
# они легитимны. После weighting (anchor = weighted mean) ищем выбросы
|
# они легитимны. После weighting (anchor = weighted mean) ищем выбросы
|
||||||
# относительно self-consistent взвешенного пространства.
|
# относительно self-consistent взвешенного пространства.
|
||||||
# Используем те же effective_mad_k; если после clip < min_comps → anchor=None.
|
# Используем те же 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)
|
post_clip_idx = _mad_clip(ppm2, effective_mad_k)
|
||||||
if len(post_clip_idx) < min_comps:
|
if len(post_clip_idx) < min_comps:
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -2233,7 +2229,7 @@ def _price_from_inputs(
|
||||||
anchor = None
|
anchor = None
|
||||||
|
|
||||||
# #audit-1: low-confidence gate.
|
# #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_low = anchor["confidence"] == "low"
|
||||||
gate_thin = (
|
gate_thin = (
|
||||||
anchor["n"] < settings.estimate_sb_gate_min_n
|
anchor["n"] < settings.estimate_sb_gate_min_n
|
||||||
|
|
@ -2315,11 +2311,7 @@ def _price_from_inputs(
|
||||||
"точность ориентировочная."
|
"точность ориентировочная."
|
||||||
)
|
)
|
||||||
# #1871 P2: split-дома wide-corridor disclosure.
|
# #1871 P2: split-дома wide-corridor disclosure.
|
||||||
if (
|
if anchor_tier == "A" and median_price > 0:
|
||||||
settings.estimate_wide_corridor_disclosure_enabled
|
|
||||||
and anchor_tier == "A"
|
|
||||||
and median_price > 0
|
|
||||||
):
|
|
||||||
corridor_pct = (range_high - range_low) / median_price
|
corridor_pct = (range_high - range_low) / median_price
|
||||||
if corridor_pct > settings.estimate_wide_corridor_threshold:
|
if corridor_pct > settings.estimate_wide_corridor_threshold:
|
||||||
confidence = _downgrade_confidence(confidence)
|
confidence = _downgrade_confidence(confidence)
|
||||||
|
|
@ -2332,12 +2324,7 @@ def _price_from_inputs(
|
||||||
# ── #651: IMV / Yandex blend (Tier D only, anchor_tier is None) ──────────
|
# ── #651: IMV / Yandex blend (Tier D only, anchor_tier is None) ──────────
|
||||||
imv_anchor_present: bool = False
|
imv_anchor_present: bool = False
|
||||||
avito_imv_summary: AvitoImvSummary | None = None
|
avito_imv_summary: AvitoImvSummary | None = None
|
||||||
if (
|
if anchor_tier is None and listings_clean and median_price > 0:
|
||||||
anchor_tier is None
|
|
||||||
and settings.estimate_imv_blend_enabled
|
|
||||||
and listings_clean
|
|
||||||
and median_price > 0
|
|
||||||
):
|
|
||||||
anchor_total: int | None = None
|
anchor_total: int | None = None
|
||||||
anchor_higher: int | None = None
|
anchor_higher: int | None = None
|
||||||
anchor_label: str | None = None
|
anchor_label: str | None = None
|
||||||
|
|
@ -2435,8 +2422,7 @@ def _price_from_inputs(
|
||||||
|
|
||||||
# ── #764: per-cadastral-quarter price index gap-correction ───────────────
|
# ── #764: per-cadastral-quarter price index gap-correction ───────────────
|
||||||
if (
|
if (
|
||||||
settings.estimate_quarter_index_enabled
|
anchor_tier is None # Guard-1a
|
||||||
and anchor_tier is None # Guard-1a
|
|
||||||
and not imv_anchor_present # Guard-1b
|
and not imv_anchor_present # Guard-1b
|
||||||
and median_price > 0
|
and median_price > 0
|
||||||
and area_m2
|
and area_m2
|
||||||
|
|
@ -2562,7 +2548,6 @@ def _price_from_inputs(
|
||||||
anchor_tier=anchor_tier,
|
anchor_tier=anchor_tier,
|
||||||
slack=slack,
|
slack=slack,
|
||||||
min_n=settings.estimate_corridor_clamp_min_n,
|
min_n=settings.estimate_corridor_clamp_min_n,
|
||||||
enabled=settings.estimate_corridor_clamp_enabled,
|
|
||||||
)
|
)
|
||||||
if clamped:
|
if clamped:
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -2581,8 +2566,7 @@ def _price_from_inputs(
|
||||||
|
|
||||||
# ── Radius-path нижний floor от DKP-коридора ─────────────────────────────
|
# ── Radius-path нижний floor от DKP-коридора ─────────────────────────────
|
||||||
if (
|
if (
|
||||||
settings.estimate_radius_floor_enabled
|
anchor_tier is None
|
||||||
and anchor_tier is None
|
|
||||||
and dkp_raw is not None
|
and dkp_raw is not None
|
||||||
and dkp_raw.get("low_ppm2", 0) > 0
|
and dkp_raw.get("low_ppm2", 0) > 0
|
||||||
and median_ppm2 > 0
|
and median_ppm2 > 0
|
||||||
|
|
@ -2663,7 +2647,7 @@ def _price_from_inputs(
|
||||||
expected_sold_price = min(expected_sold_price, median_price)
|
expected_sold_price = min(expected_sold_price, median_price)
|
||||||
if expected_sold_per_m2:
|
if expected_sold_per_m2:
|
||||||
expected_sold_per_m2 = min(expected_sold_per_m2, round(median_ppm2))
|
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.
|
# #1966: калиброванный ~80% prediction interval вокруг ТОЧКИ expected_sold.
|
||||||
# Эмпирически отношение actual_sold_ppm2 / expected_sold_per_m2 по 2366
|
# Эмпирически отношение actual_sold_ppm2 / expected_sold_per_m2 по 2366
|
||||||
# прод-сделкам: p10=0.649, p90=1.392 → band [×low_mult, ×high_mult] даёт
|
# прод-сделкам: 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).
|
# Guard mirrors the original in-block guard exactly; when false → ([], None).
|
||||||
_anchor_comps_pre: list[dict]
|
_anchor_comps_pre: list[dict]
|
||||||
_anchor_tier_pre: str | None
|
_anchor_tier_pre: str | None
|
||||||
if (
|
if payload.area_m2 and (payload.address or (geo is not None and geo.full_address)):
|
||||||
settings.estimate_same_building_anchor_enabled
|
|
||||||
and 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(
|
_anchor_comps_pre, _anchor_tier_pre = await asyncio.to_thread(
|
||||||
_fetch_anchor_comps,
|
_fetch_anchor_comps,
|
||||||
db,
|
db,
|
||||||
|
|
@ -3516,16 +3496,13 @@ async def estimate_quality(
|
||||||
# #1871 P1.2 — defensive invariant guard перед сборкой ответа: n_analogs == 0
|
# #1871 P1.2 — defensive invariant guard перед сборкой ответа: n_analogs == 0
|
||||||
# не может сосуществовать с confidence != 'low' (ghost-anchor). Mainline уже
|
# не может сосуществовать с confidence != 'low' (ghost-anchor). Mainline уже
|
||||||
# честен — это belt-and-suspenders на будущие external-valuation/rehydrate пути.
|
# честен — это belt-and-suspenders на будущие external-valuation/rehydrate пути.
|
||||||
# За флагом estimate_confidence_floor_no_analogs (дефолт True). При False —
|
confidence, explanation = _enforce_zero_analog_low(
|
||||||
# старое поведение без принудительного понижения (escape-hatch для отката).
|
confidence,
|
||||||
if settings.estimate_confidence_floor_no_analogs:
|
n_analogs,
|
||||||
confidence, explanation = _enforce_zero_analog_low(
|
explanation,
|
||||||
confidence,
|
median_price=median_price,
|
||||||
n_analogs,
|
sources_used=sources_used,
|
||||||
explanation,
|
)
|
||||||
median_price=median_price,
|
|
||||||
sources_used=sources_used,
|
|
||||||
)
|
|
||||||
|
|
||||||
# #2002 #4: manual-review routing — производный ФЛАГ на основе уже
|
# #2002 #4: manual-review routing — производный ФЛАГ на основе уже
|
||||||
# вычисленных premium_building / confidence / range (asking-IQR). НЕ влияет
|
# вычисленных premium_building / confidence / range (asking-IQR). НЕ влияет
|
||||||
|
|
@ -3697,7 +3674,7 @@ def _manual_review(
|
||||||
RU-причины. Чистая функция — НЕ трогает median/expected_sold/ranges
|
RU-причины. Чистая функция — НЕ трогает median/expected_sold/ranges
|
||||||
(regression-gate остаётся byte-identical).
|
(regression-gate остаётся byte-identical).
|
||||||
|
|
||||||
Триггеры (когда estimate_manual_review_enabled):
|
Триггеры:
|
||||||
- premium_building — премиальный дом;
|
- premium_building — премиальный дом;
|
||||||
- expected_sold_price ≥ high_value_rub — элит-зона по абсолютной стоимости;
|
- expected_sold_price ≥ high_value_rub — элит-зона по абсолютной стоимости;
|
||||||
- confidence == "low" — низкая уверенность оценки;
|
- confidence == "low" — низкая уверенность оценки;
|
||||||
|
|
@ -3710,11 +3687,9 @@ def _manual_review(
|
||||||
не занижена и чисто маркирует дорогой сегмент.
|
не занижена и чисто маркирует дорогой сегмент.
|
||||||
|
|
||||||
Возвращает (recommended, reasons), где recommended == bool(reasons).
|
Возвращает (recommended, reasons), где recommended == bool(reasons).
|
||||||
При выключенном флаге — (False, []). Деление на ноль исключено: проверка
|
Деление на ноль исключено: проверка
|
||||||
`range_low and range_high` отсекает None/0 до деления.
|
`range_low and range_high` отсекает None/0 до деления.
|
||||||
"""
|
"""
|
||||||
if not settings.estimate_manual_review_enabled:
|
|
||||||
return (False, [])
|
|
||||||
reasons: list[str] = []
|
reasons: list[str] = []
|
||||||
if premium_building:
|
if premium_building:
|
||||||
reasons.append("премиальный дом — премия зависит от отделки/вида (не в данных сделок)")
|
reasons.append("премиальный дом — премия зависит от отделки/вида (не в данных сделок)")
|
||||||
|
|
@ -3858,68 +3833,24 @@ def _fetch_price_trend(
|
||||||
# #audit-3: freshness_months — фильтр по scraped_at чтобы исключить стale items
|
# #audit-3: freshness_months — фильтр по scraped_at чтобы исключить стale items
|
||||||
# (yandex_valuation из 2024 и т.д.). Применяется ко ВСЕМ source в этом запросе
|
# (yandex_valuation из 2024 и т.д.). Применяется ко ВСЕМ source в этом запросе
|
||||||
# (avito_imv + yandex_valuation), не только к yandex_valuation. Дефолт из config.
|
# (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
|
_fresh_months = freshness_months if freshness_months is not None else months
|
||||||
_dedup_enabled = settings.estimate_price_trend_dedup_enabled
|
|
||||||
try:
|
try:
|
||||||
if _dedup_enabled:
|
trend_sql = """
|
||||||
# Дедупликация near-дублей: один объект на avito_imv + yandex_valuation
|
WITH deduped AS (
|
||||||
# c разными ext_item_id → double-count в медиане. DISTINCT ON по ключу
|
SELECT DISTINCT ON (
|
||||||
# (round(area_m2,0), floor, price, price_date) с приоритетом avito_imv
|
ROUND(area_m2),
|
||||||
# (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,
|
|
||||||
floor,
|
floor,
|
||||||
COALESCE(last_price, start_price) AS price,
|
COALESCE(last_price, start_price),
|
||||||
COALESCE(last_price_date, start_price_date) AS price_date
|
COALESCE(last_price_date, start_price_date)
|
||||||
FROM house_placement_history
|
)
|
||||||
WHERE house_id = CAST(:hid AS bigint)
|
area_m2,
|
||||||
AND area_m2 > 0
|
floor,
|
||||||
AND COALESCE(last_price, start_price) > 0
|
COALESCE(last_price, start_price) AS price,
|
||||||
AND COALESCE(last_price_date, start_price_date) IS NOT NULL
|
COALESCE(last_price_date, start_price_date) AS price_date
|
||||||
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
|
|
||||||
FROM house_placement_history
|
FROM house_placement_history
|
||||||
WHERE house_id = CAST(:hid AS bigint)
|
WHERE house_id = CAST(:hid AS bigint)
|
||||||
AND area_m2 > 0
|
AND area_m2 > 0
|
||||||
|
|
@ -3930,9 +3861,24 @@ def _fetch_price_trend(
|
||||||
AND scraped_at > (now()
|
AND scraped_at > (now()
|
||||||
- CAST(:fresh_months AS integer)
|
- CAST(:fresh_months AS integer)
|
||||||
* CAST('1 month' AS interval))
|
* CAST('1 month' AS interval))
|
||||||
GROUP BY 1
|
ORDER BY
|
||||||
ORDER BY 1 ASC
|
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 = (
|
rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(trend_sql),
|
text(trend_sql),
|
||||||
|
|
@ -4324,7 +4270,7 @@ def _fetch_analogs(
|
||||||
# (безвредно без фильтра); статический фрагмент управляет только применением
|
# (безвредно без фильтра); статический фрагмент управляет только применением
|
||||||
# `AND rn_dup = 1` в outer WHERE. Это SQL-литерал (static), НЕ data — psycopg3
|
# `AND rn_dup = 1` в outer WHERE. Это SQL-литерал (static), НЕ data — psycopg3
|
||||||
# bind-параметры не задействованы, инъекции нет.
|
# 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] = {
|
base_params: dict[str, Any] = {
|
||||||
"rooms": rooms,
|
"rooms": rooms,
|
||||||
"area_min": area_min,
|
"area_min": area_min,
|
||||||
|
|
@ -4809,7 +4755,6 @@ def _apply_corridor_clamp(
|
||||||
anchor_tier: str | None,
|
anchor_tier: str | None,
|
||||||
slack: float,
|
slack: float,
|
||||||
min_n: int,
|
min_n: int,
|
||||||
enabled: bool,
|
|
||||||
) -> tuple[float, int, int, int, bool]:
|
) -> tuple[float, int, int, int, bool]:
|
||||||
"""#1795 шаг 1: soft-кламп headline к коридору реальных ДКП-сделок Росреестра.
|
"""#1795 шаг 1: soft-кламп headline к коридору реальных ДКП-сделок Росреестра.
|
||||||
|
|
||||||
|
|
@ -4817,16 +4762,13 @@ def _apply_corridor_clamp(
|
||||||
cap = corridor_high_ppm2 × (1 + slack), когда ВСЕ условия:
|
cap = corridor_high_ppm2 × (1 + slack), когда ВСЕ условия:
|
||||||
- median_ppm2 > cap (headline выше потолка коридора + slack),
|
- median_ppm2 > cap (headline выше потолка коридора + slack),
|
||||||
- corridor_count >= min_n (достаточно сделок для доверия коридору),
|
- corridor_count >= min_n (достаточно сделок для доверия коридору),
|
||||||
- anchor_tier != "A" (Tier A = реальные комплы ТОГО ЖЕ дома → EXEMPT),
|
- anchor_tier != "A" (Tier A = реальные комплы ТОГО ЖЕ дома → EXEMPT).
|
||||||
- enabled (флаг estimate_corridor_clamp_enabled).
|
|
||||||
Тогда price/range_low/range_high пересчитываются ПРОПОРЦИОНАЛЬНО
|
Тогда price/range_low/range_high пересчитываются ПРОПОРЦИОНАЛЬНО
|
||||||
(множитель = cap / median_ppm2), чтобы asking↔sold↔range остались
|
(множитель = cap / median_ppm2), чтобы asking↔sold↔range остались
|
||||||
консистентны. Возвращает (median_ppm2, median_price, range_low,
|
консистентны. Возвращает (median_ppm2, median_price, range_low,
|
||||||
range_high, clamped: bool). clamped=False → значения не изменены
|
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":
|
if anchor_tier == "A":
|
||||||
# Tier A — реальные комплы того же дома: коридор по улице может быть
|
# Tier A — реальные комплы того же дома: коридор по улице может быть
|
||||||
# систематически ниже (старые/малометражные сделки) → не клампим.
|
# систематически ниже (старые/малометражные сделки) → не клампим.
|
||||||
|
|
|
||||||
|
|
@ -1518,9 +1518,9 @@ def _predict_full_spine(
|
||||||
# ── Pre-fetch the spine inputs (same calls estimate_quality hoists) ───────
|
# ── 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)
|
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
|
# #1966 prod parity: same-building anchor pre-fetch is GATED exactly like
|
||||||
# estimate_quality (estimator.py L2862-2881) — disabled / no-area / no-address
|
# estimate_quality — no-area / no-address → ([], None) instead of an
|
||||||
# → ([], None) instead of an unconditional fetch.
|
# unconditional fetch.
|
||||||
if settings.estimate_same_building_anchor_enabled and deal.area_m2 and deal.address:
|
if deal.area_m2 and deal.address:
|
||||||
# NB(#2002): kept at None deliberately — the deliverable scopes the
|
# NB(#2002): kept at None deliberately — the deliverable scopes the
|
||||||
# resolved-id threading to the Tier-S analog ladder + the house IMV anchor.
|
# 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
|
# Prod ALSO resolves this same-building anchor (estimator.py:2782); flip to
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,6 @@ def _run_qa_estimate(
|
||||||
ratio_tuple: tuple[float | None, str | None] = (0.92, "per_rooms"),
|
ratio_tuple: tuple[float | None, str | None] = (0.92, "per_rooms"),
|
||||||
radius_analogs: list[dict[str, Any]] | None = None,
|
radius_analogs: list[dict[str, Any]] | None = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
from app.core.config import settings
|
|
||||||
from app.schemas.trade_in import TradeInEstimateInput
|
from app.schemas.trade_in import TradeInEstimateInput
|
||||||
from app.services.estimator import estimate_quality
|
from app.services.estimator import estimate_quality
|
||||||
from app.services.geocoder import GeocodeResult
|
from app.services.geocoder import GeocodeResult
|
||||||
|
|
@ -105,7 +104,6 @@ def _run_qa_estimate(
|
||||||
|
|
||||||
async def _run() -> Any:
|
async def _run() -> Any:
|
||||||
with (
|
with (
|
||||||
patch.object(settings, "estimate_same_building_anchor_enabled", True),
|
|
||||||
patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)),
|
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.dadata_clean_address", new=AsyncMock(return_value=None)),
|
||||||
patch("app.services.estimator.match_house_readonly", return_value=None),
|
patch("app.services.estimator.match_house_readonly", return_value=None),
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,8 @@ def test_fix1_low_conf_anchor_suppressed_by_gate() -> None:
|
||||||
assert anchor_raw is not None, "Должен построить якорь без гейта"
|
assert anchor_raw is not None, "Должен построить якорь без гейта"
|
||||||
assert anchor_raw["confidence"] == "low", "Ожидаем low confidence при большом разбросе"
|
assert anchor_raw["confidence"] == "low", "Ожидаем low confidence при большом разбросе"
|
||||||
|
|
||||||
# Гейт: settings.estimate_sb_low_conf_gate_enabled=True
|
# Гейт low-confidence якоря применяется в estimate_quality (выше уровня
|
||||||
# Это применяется в estimate_quality (выше уровня _compute_same_building_anchor),
|
# _compute_same_building_anchor),
|
||||||
# поэтому тестируем сигнал: если confidence=low — гейт должен подавить.
|
# поэтому тестируем сигнал: если confidence=low — гейт должен подавить.
|
||||||
assert anchor_raw["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:
|
def test_fix4_premium_comp_survives_post_weight_clip() -> None:
|
||||||
"""Видовой компл (высокий ppm²) с правильными весами не выкидывается.
|
"""Видовой компл (высокий ppm²) с правильными весами не выкидывается.
|
||||||
|
|
||||||
Логика: при post-weight clip (estimate_sb_clip_after_weight=True) MAD-clip
|
Логика: post-weight MAD-clip применяется к ppm² ПОСЛЕ weighting. Если видовой
|
||||||
применяется к ppm² ПОСЛЕ weighting. Если видовой компл близок по площади/
|
компл близок по площади/комнатам — его вес высок, и он не должен быть outlier
|
||||||
комнатам — его вес высок, и он не должен быть outlier после clip.
|
после clip.
|
||||||
"""
|
"""
|
||||||
# 4 стандартных компла + 1 видовой (выше на 30%)
|
# 4 стандартных компла + 1 видовой (выше на 30%)
|
||||||
base = 200_000
|
base = 200_000
|
||||||
comps = [_make_comp(base) for _ in range(4)] + [_make_comp(base * 1.3)]
|
comps = [_make_comp(base) for _ in range(4)] + [_make_comp(base * 1.3)]
|
||||||
|
|
||||||
with patch("app.services.estimator.settings") as mock_settings:
|
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_mad_k_small_n = 2.5
|
||||||
mock_settings.estimate_sb_small_n_threshold = 10
|
mock_settings.estimate_sb_small_n_threshold = 10
|
||||||
mock_settings.avito_imv_thin_market_threshold = 10
|
mock_settings.avito_imv_thin_market_threshold = 10
|
||||||
|
|
|
||||||
|
|
@ -313,7 +313,6 @@ def _run_estimate_anchor_only(
|
||||||
anchor_tier: str | None = "A",
|
anchor_tier: str | None = "A",
|
||||||
):
|
):
|
||||||
"""estimate_quality: пустой радиус (listings_clean=[]), якорь задаёт median_price."""
|
"""estimate_quality: пустой радиус (listings_clean=[]), якорь задаёт median_price."""
|
||||||
from app.core.config import settings
|
|
||||||
from app.services.estimator import estimate_quality
|
from app.services.estimator import estimate_quality
|
||||||
|
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
|
|
@ -322,7 +321,6 @@ def _run_estimate_anchor_only(
|
||||||
|
|
||||||
async def _run():
|
async def _run():
|
||||||
with (
|
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.geocode", new=AsyncMock(return_value=_make_fake_geo())),
|
||||||
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
|
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.match_house_readonly", return_value=None),
|
||||||
|
|
|
||||||
|
|
@ -5,22 +5,19 @@
|
||||||
blend, rehydrate, миграция), который наполнит median/sources_used без единого
|
blend, rehydrate, миграция), который наполнит median/sources_used без единого
|
||||||
реального аналога ("ghost-anchor", напр. legacy-строка 5fcc1e99: n=0, high,
|
реального аналога ("ghost-anchor", напр. legacy-строка 5fcc1e99: n=0, high,
|
||||||
38.45M, yandex_valuation). Mainline уже честен — это belt-and-suspenders.
|
38.45M, yandex_valuation). Mainline уже честен — это belt-and-suspenders.
|
||||||
|
|
||||||
Гард за флагом settings.estimate_confidence_floor_no_analogs (дефолт True).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
# pydantic Settings требует DATABASE_URL при инициализации.
|
# pydantic Settings требует DATABASE_URL при инициализации.
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||||
# WeasyPrint stubbed in CI.
|
# WeasyPrint stubbed in CI.
|
||||||
sys.modules.setdefault("weasyprint", MagicMock())
|
sys.modules.setdefault("weasyprint", MagicMock())
|
||||||
|
|
||||||
import app.services.estimator as est # noqa: E402
|
|
||||||
from app.services.estimator import _enforce_zero_analog_low # noqa: E402
|
from app.services.estimator import _enforce_zero_analog_low # noqa: E402
|
||||||
|
|
||||||
# Suffix-маркер из гарда — проверяем его наличие/отсутствие, не дублирование.
|
# Suffix-маркер из гарда — проверяем его наличие/отсутствие, не дублирование.
|
||||||
|
|
@ -108,23 +105,8 @@ def test_none_explanation_returns_empty_string_not_none() -> None:
|
||||||
assert isinstance(explanation, str)
|
assert isinstance(explanation, str)
|
||||||
|
|
||||||
|
|
||||||
def test_flag_default_true() -> None:
|
def test_enforce_zero_analog_low_always_downgrades() -> None:
|
||||||
"""estimate_confidence_floor_no_analogs по умолчанию True (гард активен)."""
|
"""_enforce_zero_analog_low всегда понижает high→low при n_analogs=0 (гард всегда ON)."""
|
||||||
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'а)
|
|
||||||
confidence, _ = _enforce_zero_analog_low(
|
confidence, _ = _enforce_zero_analog_low(
|
||||||
"high",
|
"high",
|
||||||
0,
|
0,
|
||||||
|
|
@ -133,19 +115,3 @@ def test_flag_off_does_not_downgrade() -> None:
|
||||||
sources_used=["yandex_valuation"],
|
sources_used=["yandex_valuation"],
|
||||||
)
|
)
|
||||||
assert confidence == "low" # сама функция не смотрит на settings
|
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()
|
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,7 @@
|
||||||
- комбинированный случай (несколько причин сразу);
|
- комбинированный случай (несколько причин сразу);
|
||||||
- all-clear (нет причин → recommended False);
|
- all-clear (нет причин → recommended False);
|
||||||
- граничные значения порогов (>= high_value, >= wide_ratio);
|
- граничные значения порогов (>= high_value, >= wide_ratio);
|
||||||
- div-guard на диапазон (range_low None/0 → без деления на ноль);
|
- div-guard на диапазон (range_low None/0 → без деления на ноль).
|
||||||
- выключенный флаг estimate_manual_review_enabled → (False, []).
|
|
||||||
|
|
||||||
Плюс smoke-проверка дефолтов AggregatedEstimate (флаг — метаданные: сериализация
|
Плюс smoke-проверка дефолтов AggregatedEstimate (флаг — метаданные: сериализация
|
||||||
без изменений, когда ручная оценка не рекомендована).
|
без изменений, когда ручная оценка не рекомендована).
|
||||||
|
|
@ -36,14 +35,12 @@ ELITE_REASON = (
|
||||||
|
|
||||||
def _settings(
|
def _settings(
|
||||||
*,
|
*,
|
||||||
enabled: bool = True,
|
|
||||||
high_value: int = 20_000_000,
|
high_value: int = 20_000_000,
|
||||||
wide_ratio: float = 1.9,
|
wide_ratio: float = 1.9,
|
||||||
elite_ppm2: int = 250_000,
|
elite_ppm2: int = 250_000,
|
||||||
) -> SimpleNamespace:
|
) -> SimpleNamespace:
|
||||||
"""Duck-typed заглушка Settings — helper читает только эти поля."""
|
"""Duck-typed заглушка Settings — helper читает только эти поля."""
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
estimate_manual_review_enabled=enabled,
|
|
||||||
estimate_manual_review_high_value_rub=high_value,
|
estimate_manual_review_high_value_rub=high_value,
|
||||||
estimate_manual_review_wide_range_ratio=wide_ratio,
|
estimate_manual_review_wide_range_ratio=wide_ratio,
|
||||||
estimate_manual_review_elite_ppm2=elite_ppm2,
|
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
|
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) ───────────────────────────────────
|
# ── invariant recommended == bool(reasons) ───────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""Mera-audit fix-3: cross-source dedup в _fetch_price_trend.
|
"""Mera-audit fix-3: cross-source dedup в _fetch_price_trend.
|
||||||
|
|
||||||
Проверяет что при dedup_enabled=True один объект на avito_imv + yandex_valuation
|
Проверяет что один объект на avito_imv + yandex_valuation с разными ext_item_id
|
||||||
с разными ext_item_id не double-count'ится в месячной медиане.
|
не double-count'ится в месячной медиане (dedup всегда включён).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
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:
|
def test_price_trend_dedup_sql_uses_distinct_on() -> None:
|
||||||
"""estimate_price_trend_dedup_enabled дефолт True."""
|
"""SQL-запрос к house_placement_history всегда содержит DISTINCT ON (dedup)."""
|
||||||
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."""
|
|
||||||
mock_db = _make_mock_db([], [{"month": "2026-01", "ppm2": 150_000}])
|
mock_db = _make_mock_db([], [{"month": "2026-01", "ppm2": 150_000}])
|
||||||
|
|
||||||
with patch("app.services.estimator.settings") as mock_settings:
|
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
|
mock_settings.estimate_price_trend_max_age_months = 6
|
||||||
|
|
||||||
_fetch_price_trend(mock_db, target_house_id=42, min_points=1)
|
_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()
|
assert "DISTINCT ON" in second_sql.upper() or "distinct on" in second_sql.lower()
|
||||||
|
|
||||||
|
|
||||||
def test_price_trend_dedup_disabled_no_distinct_on() -> None:
|
def test_price_trend_returns_result_with_dedup() -> None:
|
||||||
"""Когда dedup выключен, SQL НЕ содержит DISTINCT ON (legacy path)."""
|
"""Функция корректно возвращает точки тренда (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 = 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."""
|
|
||||||
rows = [
|
rows = [
|
||||||
{"month": "2025-11", "ppm2": 148_000},
|
{"month": "2025-11", "ppm2": 148_000},
|
||||||
{"month": "2025-12", "ppm2": 152_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)
|
mock_db = _make_mock_db([], rows)
|
||||||
|
|
||||||
with patch("app.services.estimator.settings") as mock_settings:
|
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
|
mock_settings.estimate_price_trend_max_age_months = 6
|
||||||
|
|
||||||
result = _fetch_price_trend(mock_db, target_house_id=42, min_points=3)
|
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
|
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:
|
def test_price_trend_source1_preferred_over_dedup_path() -> None:
|
||||||
"""Если Source1 (houses_price_dynamics) вернул ≥ min_points → Source2 не вызывается."""
|
"""Если Source1 (houses_price_dynamics) вернул ≥ min_points → Source2 не вызывается."""
|
||||||
source1_rows = [
|
source1_rows = [
|
||||||
|
|
@ -130,7 +86,6 @@ def test_price_trend_source1_preferred_over_dedup_path() -> None:
|
||||||
mock_db = _make_mock_db(source1_rows, [])
|
mock_db = _make_mock_db(source1_rows, [])
|
||||||
|
|
||||||
with patch("app.services.estimator.settings") as mock_settings:
|
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
|
mock_settings.estimate_price_trend_max_age_months = 6
|
||||||
|
|
||||||
result = _fetch_price_trend(mock_db, target_house_id=42, min_points=3)
|
result = _fetch_price_trend(mock_db, target_house_id=42, min_points=3)
|
||||||
|
|
|
||||||
|
|
@ -313,7 +313,6 @@ def _clamp(
|
||||||
tier: str | None,
|
tier: str | None,
|
||||||
slack: float = 0.25,
|
slack: float = 0.25,
|
||||||
min_n: int = 10,
|
min_n: int = 10,
|
||||||
enabled: bool = True,
|
|
||||||
median_price: int = 0,
|
median_price: int = 0,
|
||||||
range_low: int = 0,
|
range_low: int = 0,
|
||||||
range_high: int = 0,
|
range_high: int = 0,
|
||||||
|
|
@ -328,7 +327,6 @@ def _clamp(
|
||||||
anchor_tier=tier,
|
anchor_tier=tier,
|
||||||
slack=slack,
|
slack=slack,
|
||||||
min_n=min_n,
|
min_n=min_n,
|
||||||
enabled=enabled,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -374,15 +372,6 @@ def test_corridor_clamp_low_n_is_noop() -> None:
|
||||||
assert new_ppm2 == 296_000
|
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:
|
def test_corridor_clamp_tier_none_clamps() -> None:
|
||||||
# anchor не сработал (tier=None, чистый радиус) — клампим как и Tier C.
|
# anchor не сработал (tier=None, чистый радиус) — клампим как и Tier C.
|
||||||
new_ppm2, _, _, _, clamped = _clamp(
|
new_ppm2, _, _, _, clamped = _clamp(
|
||||||
|
|
|
||||||
|
|
@ -379,7 +379,6 @@ def _run_estimate_qi(
|
||||||
dadata_cadnum: str | None,
|
dadata_cadnum: str | None,
|
||||||
qi_lookup_result: tuple[float, int] | None,
|
qi_lookup_result: tuple[float, int] | None,
|
||||||
*,
|
*,
|
||||||
flag_enabled: bool = True,
|
|
||||||
anchor_tier_override: str | None = None,
|
anchor_tier_override: str | None = None,
|
||||||
):
|
):
|
||||||
"""Запускает estimate_quality с полным stub-пачем I/O; возвращает AggregatedEstimate."""
|
"""Запускает estimate_quality с полным stub-пачем I/O; возвращает AggregatedEstimate."""
|
||||||
|
|
@ -442,10 +441,6 @@ def _run_estimate_qi(
|
||||||
"app.services.estimator._lookup_quarter_indexes",
|
"app.services.estimator._lookup_quarter_indexes",
|
||||||
side_effect=_fake_lookup_indexes,
|
side_effect=_fake_lookup_indexes,
|
||||||
),
|
),
|
||||||
patch(
|
|
||||||
"app.services.estimator.settings.estimate_quarter_index_enabled",
|
|
||||||
flag_enabled,
|
|
||||||
),
|
|
||||||
):
|
):
|
||||||
return await estimate_quality(payload, db)
|
return await estimate_quality(payload, db)
|
||||||
|
|
||||||
|
|
@ -488,7 +483,6 @@ def test_quarter_index_correction_applied() -> None:
|
||||||
analogs=analogs_no_cadnum,
|
analogs=analogs_no_cadnum,
|
||||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||||
qi_lookup_result=(1.2, 30),
|
qi_lookup_result=(1.2, 30),
|
||||||
flag_enabled=True,
|
|
||||||
)
|
)
|
||||||
expected_median = round(base_median * 1.2)
|
expected_median = round(base_median * 1.2)
|
||||||
assert est.median_price_rub == expected_median
|
assert est.median_price_rub == expected_median
|
||||||
|
|
@ -497,21 +491,6 @@ def test_quarter_index_correction_applied() -> None:
|
||||||
assert "квартал" in est.confidence_explanation.lower()
|
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
|
# Guard-2: >0.6 аналогов в целевом квартале → no-op
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -534,7 +513,6 @@ def test_guard2_skip_when_majority_analogs_in_target_quarter() -> None:
|
||||||
analogs=analogs,
|
analogs=analogs,
|
||||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||||
qi_lookup_result=(1.5, 30), # high index — but guard-2 should skip
|
qi_lookup_result=(1.5, 30), # high index — but guard-2 should skip
|
||||||
flag_enabled=True,
|
|
||||||
)
|
)
|
||||||
# Медиана НЕ должна изменяться
|
# Медиана НЕ должна изменяться
|
||||||
assert est.median_price_rub == base_median
|
assert est.median_price_rub == base_median
|
||||||
|
|
@ -555,7 +533,6 @@ def test_sparse_fallback_no_row_noop() -> None:
|
||||||
analogs=analogs_no_cadnum,
|
analogs=analogs_no_cadnum,
|
||||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||||
qi_lookup_result=None, # sparse
|
qi_lookup_result=None, # sparse
|
||||||
flag_enabled=True,
|
|
||||||
)
|
)
|
||||||
assert est.median_price_rub == base_median
|
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,
|
analogs=analogs_no_cadnum,
|
||||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||||
qi_lookup_result=(3.5, 20), # index>2.0 AND n<50 → bimodal guard
|
qi_lookup_result=(3.5, 20), # index>2.0 AND n<50 → bimodal guard
|
||||||
flag_enabled=True,
|
|
||||||
)
|
)
|
||||||
assert est.median_price_rub == base_median
|
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,
|
analogs=analogs_no_cadnum,
|
||||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||||
qi_lookup_result=(2.5, 60), # index>2.0 но n=60>=50 → bimodal guard не срабатывает
|
qi_lookup_result=(2.5, 60), # index>2.0 но n=60>=50 → bimodal guard не срабатывает
|
||||||
flag_enabled=True,
|
|
||||||
)
|
)
|
||||||
# Коррекция применена: медиана != base_median (bimodal guard не заблокировал).
|
# Коррекция применена: медиана != base_median (bimodal guard не заблокировал).
|
||||||
# factor=2.5 > max_factor=1.8 → зажат до 1.8 (#859).
|
# factor=2.5 > max_factor=1.8 → зажат до 1.8 (#859).
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ Radius-путь кэпит только per-address (rn_addr <= MAX_ANALOGS_PER_
|
||||||
(source, source_id)-дубли делят один address и выживают на разных rn_addr рангах →
|
(source, source_id)-дубли делят один address и выживают на разных rn_addr рангах →
|
||||||
раздувают n_analogs. Anchor-путь дедупит по (source, source_id); radius — нет.
|
раздувают n_analogs. Anchor-путь дедупит по (source, source_id); radius — нет.
|
||||||
|
|
||||||
Эти тесты проверяют SQL-фрагмент (rn_dup-окно всегда; `AND rn_dup = 1` фильтр за
|
Эти тесты проверяют SQL-фрагмент (rn_dup-окно + `AND rn_dup = 1` фильтр) во ВСЕХ
|
||||||
флагом) во ВСЕХ четырёх тирах (S-canonical, S-fallback, H, W) + дедупную семантику
|
четырёх тирах (S-canonical, S-fallback, H, W) + дедупную семантику
|
||||||
окна на уровне Python-симуляции (freshest scraped_at, namespaced ключ, NULL-guard).
|
окна на уровне Python-симуляции (freshest scraped_at, namespaced ключ, NULL-guard).
|
||||||
Полный radius-путь требует PostGIS+БД, поэтому SQL проверяется на сгенерированном
|
Полный radius-путь требует PostGIS+БД, поэтому SQL проверяется на сгенерированном
|
||||||
тексте (как test_estimator_price_trend_dedup.py).
|
тексте (как test_estimator_price_trend_dedup.py).
|
||||||
|
|
@ -14,7 +14,7 @@ Radius-путь кэпит только per-address (rn_addr <= MAX_ANALOGS_PER_
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
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")
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
|
@ -23,7 +23,7 @@ import pytest
|
||||||
import app.services.estimator as est
|
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-текст каждого тира.
|
"""Прогоняет _fetch_analogs с mock db, возвращает SQL-текст каждого тира.
|
||||||
|
|
||||||
target_house_id + short_addr + year/floors заданы так, чтобы 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
|
db.execute.side_effect = side_effect
|
||||||
|
|
||||||
with patch.object(est.settings, "estimate_radius_dedup_enabled", dedup_enabled):
|
est._fetch_analogs(
|
||||||
est._fetch_analogs(
|
db,
|
||||||
db,
|
lat=56.83,
|
||||||
lat=56.83,
|
lon=60.6,
|
||||||
lon=60.6,
|
rooms=2,
|
||||||
rooms=2,
|
area=50.0,
|
||||||
area=50.0,
|
radius_m=2000,
|
||||||
radius_m=2000,
|
full_address="г Екатеринбург, ул Малышева, д 30",
|
||||||
full_address="г Екатеринбург, ул Малышева, д 30",
|
year_built=2010,
|
||||||
year_built=2010,
|
house_type="монолит",
|
||||||
house_type="монолит",
|
total_floors=20,
|
||||||
total_floors=20,
|
target_house_id=123,
|
||||||
target_house_id=123,
|
)
|
||||||
)
|
|
||||||
return captured
|
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-фрагмент во всех тирах
|
# SQL-фрагмент во всех тирах
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_all_four_tiers_render() -> None:
|
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"
|
assert len(sqls) == 4, "ожидаем S-canonical, S-fallback, H, W"
|
||||||
|
|
||||||
|
|
||||||
def test_rn_dup_window_present_in_every_tier_when_enabled() -> None:
|
def test_rn_dup_window_present_in_every_tier() -> None:
|
||||||
sqls = _capture_tier_sql(dedup_enabled=True)
|
sqls = _capture_tier_sql()
|
||||||
for i, sql in enumerate(sqls):
|
for i, sql in enumerate(sqls):
|
||||||
assert "AS rn_dup" in sql, f"tier#{i} без rn_dup-окна"
|
assert "AS rn_dup" in sql, f"tier#{i} без rn_dup-окна"
|
||||||
assert "rn_dup = 1" in sql, f"tier#{i} без `AND rn_dup = 1` фильтра"
|
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:
|
def test_rn_dup_key_namespaced_and_freshest() -> None:
|
||||||
"""Ключ окна namespaced (id:/url:/ctid:) + ORDER BY scraped_at DESC."""
|
"""Ключ окна 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 "PARTITION BY source" in sql
|
||||||
assert "'id:' || source_id::text" in sql
|
assert "'id:' || source_id::text" in sql
|
||||||
assert "'url:' || source_url" in sql
|
assert "'url:' || source_url" in sql
|
||||||
|
|
@ -117,7 +96,7 @@ def test_no_bind_param_double_colon_cast() -> None:
|
||||||
import re
|
import re
|
||||||
|
|
||||||
bad = re.compile(r":[a-z_]+::[a-z]")
|
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"
|
assert not bad.search(sql), f"tier#{i}: найден запрещённый :bind::type"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@
|
||||||
- radius median выше dkp_low × factor → no-op (медиана не изменена)
|
- radius median выше dkp_low × factor → no-op (медиана не изменена)
|
||||||
- dkp_raw is None → no-op (нет базы для floor)
|
- dkp_raw is None → no-op (нет базы для floor)
|
||||||
- anchor-путь (anchor_tier != None) → не затронут floor'ом
|
- anchor-путь (anchor_tier != None) → не затронут floor'ом
|
||||||
- флаг estimate_radius_floor_enabled=False → no-op
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -75,7 +74,6 @@ def _run_estimate(
|
||||||
analogs: list[dict[str, Any]],
|
analogs: list[dict[str, Any]],
|
||||||
dkp_raw: dict[str, Any] | None,
|
dkp_raw: dict[str, Any] | None,
|
||||||
*,
|
*,
|
||||||
radius_floor_enabled: bool = True,
|
|
||||||
radius_floor_factor: float = 0.8,
|
radius_floor_factor: float = 0.8,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
from app.core.config import settings
|
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._fetch_dkp_corridor", return_value=dkp_raw),
|
||||||
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
|
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),
|
patch.object(settings, "estimate_radius_floor_factor", radius_floor_factor),
|
||||||
):
|
):
|
||||||
return await estimate_quality(payload, db)
|
return await estimate_quality(payload, db)
|
||||||
|
|
@ -188,62 +185,3 @@ def test_no_dkp_raw_no_floor() -> None:
|
||||||
assert (
|
assert (
|
||||||
est.median_price_per_m2 < 100_000
|
est.median_price_per_m2 < 100_000
|
||||||
), f"median_ppm2={est.median_price_per_m2} без dkp_raw не должна расти"
|
), 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} не должна расти"
|
|
||||||
|
|
|
||||||
|
|
@ -47,11 +47,8 @@ def _payload(): # type: ignore[no-untyped-def]
|
||||||
return _h._make_payload(area=60.0, rooms=2)
|
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]
|
def _run(*, anchor_tier: str, comps=None, threshold: float = 0.6): # type: ignore[no-untyped-def]
|
||||||
with (
|
with patch.object(settings, "estimate_wide_corridor_threshold", threshold):
|
||||||
patch.object(settings, "estimate_wide_corridor_disclosure_enabled", flag),
|
|
||||||
patch.object(settings, "estimate_wide_corridor_threshold", threshold),
|
|
||||||
):
|
|
||||||
return _h._run_estimate(
|
return _h._run_estimate(
|
||||||
anchor_comps=_WIDE_SPLIT_COMPS if comps is None else comps,
|
anchor_comps=_WIDE_SPLIT_COMPS if comps is None else comps,
|
||||||
anchor_tier=anchor_tier,
|
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:
|
def test_threshold_default_1_2() -> None:
|
||||||
# 1.2: 0.6 фаерил бы на ~31% оценок (широкий коридор ≠ split-дом);
|
# 1.2: 0.6 фаерил бы на ~31% оценок (широкий коридор ≠ split-дом);
|
||||||
# genuine split-дома аудита = 148-170% → 1.2 ловит только экстремальный хвост.
|
# 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:
|
def test_tier_a_wide_corridor_downgrades_and_discloses() -> None:
|
||||||
"""Tier A + corridor_pct > threshold + флаг ON → confidence понижен + disclosure."""
|
"""Tier A + corridor_pct > threshold → confidence понижен + disclosure."""
|
||||||
on = _run(flag=True, anchor_tier="A")
|
on = _run(anchor_tier="A")
|
||||||
pct = (on.range_high_rub - on.range_low_rub) / on.median_price_rub
|
pct = (on.range_high_rub - on.range_low_rub) / on.median_price_rub
|
||||||
assert pct > 0.6, "fixture должен давать коридор > 60%"
|
assert pct > 0.6, "fixture должен давать коридор > 60%"
|
||||||
assert _DISCLOSURE_MARKER in (on.confidence_explanation or "")
|
assert _DISCLOSURE_MARKER in (on.confidence_explanation or "")
|
||||||
# confidence понижен относительно flag-OFF baseline (medium → low).
|
# confidence понижен относительно no-fire baseline (порог выше коридора).
|
||||||
off = _run(flag=False, anchor_tier="A")
|
off = _run(anchor_tier="A", threshold=99.0)
|
||||||
assert _downgrade_confidence(off.confidence) == on.confidence
|
assert _downgrade_confidence(off.confidence) == on.confidence
|
||||||
assert on.confidence != off.confidence
|
assert on.confidence != off.confidence
|
||||||
|
|
||||||
|
|
||||||
def test_flag_off_no_change() -> None:
|
def test_threshold_above_corridor_no_change() -> None:
|
||||||
"""Флаг OFF → ничего не делает (ни confidence, ни explanation)."""
|
"""Порог выше фактического коридора → ничего не делает (ни confidence, ни explanation)."""
|
||||||
off = _run(flag=False, anchor_tier="A")
|
off = _run(anchor_tier="A", threshold=99.0)
|
||||||
assert _DISCLOSURE_MARKER not in (off.confidence_explanation or "")
|
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:
|
def test_corridor_fires_when_above_threshold() -> None:
|
||||||
"""corridor_pct (≈1.045) > threshold (1.0) → disclosure фаерит (механизм работает)."""
|
"""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
|
pct = (on.range_high_rub - on.range_low_rub) / on.median_price_rub
|
||||||
assert pct > 1.0
|
assert pct > 1.0
|
||||||
assert _DISCLOSURE_MARKER in (on.confidence_explanation or "")
|
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:
|
def test_moderate_corridor_no_fire_at_prod_threshold_1_2() -> None:
|
||||||
"""Прод-дефолт 1.2: умеренный коридор (0.6<pct<1.2) НЕ фаерит — нет ложной
|
"""Прод-дефолт 1.2: умеренный коридор (0.6<pct<1.2) НЕ фаерит — нет ложной
|
||||||
атрибуции split-дома на ~31% оценок, которую дал бы старый порог 0.6."""
|
атрибуции split-дома на ~31% оценок, которую дал бы старый порог 0.6."""
|
||||||
on = _run(flag=True, anchor_tier="A", threshold=1.2) # _WIDE_SPLIT_COMPS ≈ 1.045
|
on = _run(anchor_tier="A", threshold=1.2) # _WIDE_SPLIT_COMPS ≈ 1.045
|
||||||
pct = (on.range_high_rub - on.range_low_rub) / on.median_price_rub
|
pct = (on.range_high_rub - on.range_low_rub) / on.median_price_rub
|
||||||
assert 0.6 < pct < 1.2, "moderate-fixture должен быть между 0.6 и 1.2"
|
assert 0.6 < pct < 1.2, "moderate-fixture должен быть между 0.6 и 1.2"
|
||||||
assert _DISCLOSURE_MARKER not in (on.confidence_explanation or "")
|
assert _DISCLOSURE_MARKER not in (on.confidence_explanation or "")
|
||||||
|
|
@ -125,8 +117,8 @@ def test_moderate_corridor_no_fire_at_prod_threshold_1_2() -> None:
|
||||||
|
|
||||||
def test_does_not_touch_point_median_range() -> None:
|
def test_does_not_touch_point_median_range() -> None:
|
||||||
"""Disclosure НЕ трогает point/median/range — только confidence+explanation."""
|
"""Disclosure НЕ трогает point/median/range — только confidence+explanation."""
|
||||||
off = _run(flag=False, anchor_tier="A")
|
off = _run(anchor_tier="A", threshold=99.0)
|
||||||
on = _run(flag=True, anchor_tier="A")
|
on = _run(anchor_tier="A")
|
||||||
assert on.median_price_rub == off.median_price_rub
|
assert on.median_price_rub == off.median_price_rub
|
||||||
assert on.range_low_rub == off.range_low_rub
|
assert on.range_low_rub == off.range_low_rub
|
||||||
assert on.range_high_rub == off.range_high_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:
|
def test_below_threshold_no_disclosure() -> None:
|
||||||
"""corridor_pct ниже порога → без изменений (порог поднят выше фактического)."""
|
"""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 "")
|
assert _DISCLOSURE_MARKER not in (high_threshold.confidence_explanation or "")
|
||||||
|
|
||||||
|
|
||||||
def test_non_tier_a_not_affected() -> None:
|
def test_non_tier_a_not_affected() -> None:
|
||||||
"""Не-Tier-A (Tier C) → disclosure не применяется (даже с флагом ON)."""
|
"""Не-Tier-A (Tier C) → disclosure не применяется."""
|
||||||
c = _run(flag=True, anchor_tier="C")
|
c = _run(anchor_tier="C")
|
||||||
assert _DISCLOSURE_MARKER not in (c.confidence_explanation or "")
|
assert _DISCLOSURE_MARKER not in (c.confidence_explanation or "")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -426,7 +426,6 @@ def _run_estimate(
|
||||||
*,
|
*,
|
||||||
anchor_comps: list[dict[str, Any]],
|
anchor_comps: list[dict[str, Any]],
|
||||||
anchor_tier: str | None,
|
anchor_tier: str | None,
|
||||||
flag_enabled: bool = True,
|
|
||||||
ratio_tuple: tuple[float | None, str | None] = (0.92, "per_rooms"),
|
ratio_tuple: tuple[float | None, str | None] = (0.92, "per_rooms"),
|
||||||
payload=None,
|
payload=None,
|
||||||
radius_analogs: list[dict[str, Any]] | None = None,
|
radius_analogs: list[dict[str, Any]] | None = None,
|
||||||
|
|
@ -439,7 +438,6 @@ def _run_estimate(
|
||||||
ST_DWithin не нашёл радиусных аналогов (median=0) — якорь обязан сработать
|
ST_DWithin не нашёл радиусных аналогов (median=0) — якорь обязан сработать
|
||||||
по same-building комплам независимо.
|
по same-building комплам независимо.
|
||||||
"""
|
"""
|
||||||
from app.core.config import settings
|
|
||||||
from app.services.estimator import estimate_quality
|
from app.services.estimator import estimate_quality
|
||||||
|
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
|
|
@ -448,7 +446,6 @@ def _run_estimate(
|
||||||
|
|
||||||
async def _run():
|
async def _run():
|
||||||
with (
|
with (
|
||||||
patch.object(settings, "estimate_same_building_anchor_enabled", flag_enabled),
|
|
||||||
patch(
|
patch(
|
||||||
"app.services.estimator.geocode",
|
"app.services.estimator.geocode",
|
||||||
new=AsyncMock(return_value=geo_result or _make_fake_geo()),
|
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
|
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:
|
def test_estimate_expected_sold_distinct_after_anchor() -> None:
|
||||||
"""(f) При сработавшем якоре headline = ASKING (anchor_ppm2, pre-haircut), а
|
"""(f) При сработавшем якоре headline = ASKING (anchor_ppm2, pre-haircut), а
|
||||||
expected_sold = headline × per-rooms ratio → DISTINCT, строго ниже median.
|
expected_sold = headline × per-rooms ratio → DISTINCT, строго ниже median.
|
||||||
|
|
|
||||||
|
|
@ -67,16 +67,13 @@ def test_tier_a_anchor_gated_python_side_not_sql_guard() -> None:
|
||||||
relaxation (впускает novostroyki только если в доме есть вторичка/NULL).
|
relaxation (впускает novostroyki только если в доме есть вторичка/NULL).
|
||||||
|
|
||||||
Assert: (a) Tier C всё ещё несёт канон-guard в SQL, (b) Tier A делает
|
Assert: (a) Tier C всё ещё несёт канон-guard в SQL, (b) Tier A делает
|
||||||
Python-side gating — источник содержит и флаг настройки, и hardened
|
Python-side gating — источник содержит hardened secondary_count/primary_count
|
||||||
secondary_count/primary_count gate (#1774, после code-review).
|
gate (#1774, после code-review).
|
||||||
"""
|
"""
|
||||||
src = inspect.getsource(est_mod._fetch_anchor_comps)
|
src = inspect.getsource(est_mod._fetch_anchor_comps)
|
||||||
# (a) Tier C SQL guard сохранён (поиск находит единственное вхождение — Tier C).
|
# (a) Tier C SQL guard сохранён (поиск находит единственное вхождение — Tier C).
|
||||||
assert _GUARD_RE.search(src), "Tier C anchor SQL lost the canonical novostroyki guard"
|
assert _GUARD_RE.search(src), "Tier C anchor SQL lost the canonical novostroyki guard"
|
||||||
# (b) Tier A теперь gated Python-side (#1774).
|
# (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).
|
# Hardened gate: secondary_count ≥ primary_count (заменил has_secondary).
|
||||||
assert "secondary_count" in src, "Tier A lacks secondary_count Python-side gate (#1774)"
|
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)"
|
assert "primary_count" in src, "Tier A lacks primary_count comparison in hardened gate (#1774)"
|
||||||
|
|
|
||||||
|
|
@ -105,12 +105,7 @@ def test_tier_a_includes_novostroyki_when_secondary_present() -> None:
|
||||||
_row(source="cian", source_id="c2", listing_segment="novostroyki"),
|
_row(source="cian", source_id="c2", listing_segment="novostroyki"),
|
||||||
]
|
]
|
||||||
db = _db_mock(rows)
|
db = _db_mock(rows)
|
||||||
with (
|
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||||
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
|
|
||||||
),
|
|
||||||
):
|
|
||||||
comps, tier = _fetch(db)
|
comps, tier = _fetch(db)
|
||||||
assert tier == "A"
|
assert tier == "A"
|
||||||
# Все 6 (4 вторички + 2 novostroyki-переуступки) учтены.
|
# Все 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"),
|
_row(source="cian", source_id="c5", listing_segment="novostroyki"),
|
||||||
]
|
]
|
||||||
db = _db_mock(rows)
|
db = _db_mock(rows)
|
||||||
with (
|
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||||
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
|
|
||||||
),
|
|
||||||
):
|
|
||||||
comps, tier = _fetch(db)
|
comps, tier = _fetch(db)
|
||||||
# primary-dominated → 5 novostroyki отброшены, 1 vtorichka < min_comps → Tier A skip.
|
# primary-dominated → 5 novostroyki отброшены, 1 vtorichka < min_comps → Tier A skip.
|
||||||
assert tier is None
|
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"),
|
_row(source="cian", source_id="c4", listing_segment="novostroyki"),
|
||||||
]
|
]
|
||||||
db = _db_mock(rows)
|
db = _db_mock(rows)
|
||||||
with (
|
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||||
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
|
|
||||||
),
|
|
||||||
):
|
|
||||||
comps, tier = _fetch(db)
|
comps, tier = _fetch(db)
|
||||||
# Все отброшены гардом → 0 comps < 4 → Tier A skip (lat/lon None → Tier D).
|
# Все отброшены гардом → 0 comps < 4 → Tier A skip (lat/lon None → Tier D).
|
||||||
assert tier is None
|
assert tier is None
|
||||||
assert comps == []
|
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:
|
def test_tier_a_dedup_same_source_id_collapses() -> None:
|
||||||
"""#1774 dedup: дубликат одного объявления (source_id=330047129, 2 active-строки —
|
"""#1774 dedup: дубликат одного объявления (source_id=330047129, 2 active-строки —
|
||||||
одна с house_id_fk, одна NULL) схлопывается в 1 comp по PRIMARY ключу
|
одна с 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),
|
_row(source="cian", source_url=dup_url, source_id="330047129", listing_segment=None),
|
||||||
]
|
]
|
||||||
db = _db_mock(rows)
|
db = _db_mock(rows)
|
||||||
with (
|
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||||
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
|
|
||||||
),
|
|
||||||
):
|
|
||||||
comps, tier = _fetch(db)
|
comps, tier = _fetch(db)
|
||||||
assert tier == "A"
|
assert tier == "A"
|
||||||
# 6 строк, но 2 cian-строки с одинаковым source_id → 1 comp → итого 5.
|
# 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)
|
db = _db_mock(rows)
|
||||||
with (
|
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||||
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
|
|
||||||
),
|
|
||||||
):
|
|
||||||
comps, tier = _fetch(db)
|
comps, tier = _fetch(db)
|
||||||
assert tier == "A"
|
assert tier == "A"
|
||||||
# source_id-primary схлопывает несмотря на разные url → 5 comps.
|
# 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),
|
_row(source="avito", source_url=None, source_id="a4", area_m2=80.0),
|
||||||
]
|
]
|
||||||
db = _db_mock(rows)
|
db = _db_mock(rows)
|
||||||
with (
|
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||||
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
|
|
||||||
),
|
|
||||||
):
|
|
||||||
comps, tier = _fetch(db)
|
comps, tier = _fetch(db)
|
||||||
assert tier == "A"
|
assert tier == "A"
|
||||||
# 4 разных лота (разные source_id/площадь) → 4 comps, ничего не схлопнуто.
|
# 4 разных лота (разные source_id/площадь) → 4 comps, ничего не схлопнуто.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue