МЕРА: настройки оценщика, которые никогда не менялись, стали кодом — 4 кластера флагов #1970 #3556
23 changed files with 513 additions and 599 deletions
|
|
@ -44,6 +44,12 @@ _AUTH_DB_DEFAULT_USER = "auth_app"
|
|||
# никто не видел месяц (прод 2026-08: 21 132 из 37 497 активных строк).
|
||||
LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней
|
||||
|
||||
# #1795 шаг 1 / #3452: минимум ДКП-сделок, с которого коридор держит цену — soft-кламп
|
||||
# headline сверху и radius-floor снизу (estimator.py). Живёт здесь, а не в estimator.py:
|
||||
# тот же порог читает схема (DkpCorridor.advisory_only), а schemas остаётся import-light.
|
||||
# Бывшее поле Settings estimate_corridor_clamp_min_n (#2380), на проде не переопределялось.
|
||||
CORRIDOR_CLAMP_MIN_N = 10
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
|
@ -378,17 +384,6 @@ class Settings(BaseSettings):
|
|||
dadata_api_token: str | None = None
|
||||
dadata_api_secret: str | None = None
|
||||
|
||||
# ── #651: IMV / Yandex blend (killer accuracy fix) ──────────────────────
|
||||
# Радиусная медиана ₽/м² системно недооценивает премиум/видовые квартиры
|
||||
# (нет class/segment/IMV-коррекции → premium ~2x underestimate, case 50М vs
|
||||
# факт ~100М). Если внешний якорь (Avito IMV recommended_price из
|
||||
# house_imv_evaluations, либо Yandex sale) выше нашей медианы более чем в
|
||||
# `threshold` раз — подмешиваем якорь к медиане с весом `weight` и
|
||||
# расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем (баг — занижение).
|
||||
# При отсутствии IMV/Yandex no-op (медиана не меняется).
|
||||
estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w
|
||||
estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15
|
||||
|
||||
# #estimate-zero-analogs (прод-дефект, 2026-09-16): цена обязана опираться на
|
||||
# данные ЭТОГО адреса, а не на среднее по городу.
|
||||
# Репро: «Красногорск, Янтарная» отдавал 236 766 ₽/м², не имея рядом ни одного
|
||||
|
|
@ -408,25 +403,6 @@ class Settings(BaseSettings):
|
|||
# False ⇒ прежнее поведение целиком, без релиза.
|
||||
estimate_require_local_evidence: bool = True
|
||||
|
||||
# ── #651/#652 v2: same-building anchor (validated, 55 golden cases) ──────────
|
||||
# Радиусная медиана размывает премию дома/ЖК → премиум ~2.5x недооценка,
|
||||
# комфорт −15-25%. v2 берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A),
|
||||
# similarity-weighted по площади/комнатам, premium-uplift к ~p70 для топ-юнита
|
||||
# дома, asking→sold haircut (banded по ppm²), hard guardrail (est ≥ min-comp×0.95)
|
||||
# и tighter FSD-диапазон.
|
||||
# Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2.
|
||||
estimate_sb_min_comps: int = 4 # стоп на первом тире с ≥ N активных комплов
|
||||
estimate_sb_area_sigma: float = 0.18 # σ log-нормального area-веса (Gaussian)
|
||||
estimate_sb_rooms_match_boost: float = 1.6 # ×вес если rooms компла == target
|
||||
# #680-WB within-building heterogeneity refine: floor-similarity Gaussian по
|
||||
# ОТНОСИТЕЛЬНОЙ вертикальной позиции (floor/total_floors). Прижимает якорь к
|
||||
# комплам с похожим этажом — мелкокомнатный/нижний юнит во флагман-доме больше
|
||||
# не наследует цену видового топ-этажа. 0.0 → выключено (точно старое поведение).
|
||||
# Откалибровано на 55 golden (offline): σ_f=0.25 даёт лучший medAPE без потери
|
||||
# покрытия; Хохрякова 3к/153 overshoot 64%→1.5%, флагман 4к 17.5%→5.4%.
|
||||
estimate_sb_floor_sigma: float = 0.25
|
||||
estimate_sb_guardrail_tol: float = 0.05 # hard floor: est ≥ min(comp ppm²)×(1−tol)
|
||||
estimate_sb_mad_k: float = 3.5 # MAD-clip: drop comps с |ppm2−median| > k×MAD
|
||||
# ── #1966: honest calibrated prediction-interval для expected_sold range ─────
|
||||
# Старый expected_sold_range производился из IQR аналогов (asking-IQR × ratio):
|
||||
# ~55% реальных продаж попадали в заявленный «диапазон оценки» (де-факто 50%-й
|
||||
|
|
@ -521,128 +497,13 @@ class Settings(BaseSettings):
|
|||
estimate_hedonic_first_floor_coef: float = -0.0745 # floor==1 ground-floor ≈ -7%
|
||||
estimate_hedonic_factor_min: float = 0.75
|
||||
estimate_hedonic_factor_max: float = 1.30
|
||||
# ── #1795: premium headline anti-inflation (4 фикса, каждый за флагом) ──────
|
||||
# Диагноз: бизнес/премиум headline завышается ~2× vs медиана реальных ДКП
|
||||
# (Малышева 30 = 296k при median сделок 138k). Эконом/комфорт сходятся ±5%.
|
||||
# Каждый флаг в no-op/OFF положении восстанавливает ТОЧНО старое поведение.
|
||||
#
|
||||
# Шаг 1 — soft-кламп headline к коридору ДКП-сделок Росреестра. Когда
|
||||
# median_ppm2 > high_ppm2×(1+slack) И count≥min_n И anchor_tier != "A"
|
||||
# (Tier A = реальные комплы того же дома → EXEMPT) — жёстко прижимаем headline
|
||||
# к high_ppm2×(1+slack) и пропорционально пересчитываем price/range/expected_sold.
|
||||
estimate_corridor_clamp_min_n: int = 10
|
||||
# cap = corridor_high×(1+slack) = ×1.40; даёт премиум-домам без own-листингов
|
||||
# (tier-C) больше воздуха над sold-коридором, не возвращая исходную 2× инфляцию
|
||||
# (tier-C гейт ×1.5 ловит явную контаминацию выше).
|
||||
estimate_corridor_clamp_slack: float = 0.40
|
||||
# Нижний floor для radius-пути: симметрично corridor-clamp сверху, но снизу.
|
||||
# Если итоговый median_ppm2 < dkp_low_ppm2 × factor — поднимаем до floor.
|
||||
# Применяется ТОЛЬКО на radius-пути (anchor_tier is None) и при dkp_raw.
|
||||
# factor=0.8: 20% зазор ниже P10 коридора → floor достаточно мягкий для эконома
|
||||
# (избегаем ложных подъёмов) и ловит явный undershoot.
|
||||
# ENV: ESTIMATE_RADIUS_FLOOR_FACTOR.
|
||||
estimate_radius_floor_factor: float = 0.8
|
||||
# Шаг 5 — clamp expected_sold <= asking: ratio > 1.0 физически невозможен для
|
||||
# trade-in (ожидаемая цена сделки не должна превышать цену объявления).
|
||||
# Диагноз: в high-price tier asking->sold ratio > 1.0 (product artefact, не реальные
|
||||
# сделки выше прайса) -> expected_sold = headline x ratio > headline.
|
||||
# При флаге True: если ratio > 1.0 — клампаем до 1.0 и логируем. Применяется
|
||||
# к point И range (expected_sold_low/high/price) консистентно.
|
||||
# False -> старое поведение без clamp (backward-compat).
|
||||
# ENV: ESTIMATE_EXPECTED_SOLD_LE_ASKING.
|
||||
estimate_expected_sold_le_asking: bool = Field(
|
||||
default=True, validation_alias="ESTIMATE_EXPECTED_SOLD_LE_ASKING"
|
||||
)
|
||||
# Шаг 2 — ужесточённый MAD-clip на малых выборках в same-building anchor:
|
||||
# при n < small_n_threshold используем mad_k_small вместо estimate_sb_mad_k
|
||||
# (3.5 слишком мягкий при n=7 → элитные хвосты не срезаются, mean тянется вверх).
|
||||
# mad_k_small >= estimate_sb_mad_k → no-op (старое поведение).
|
||||
estimate_sb_mad_k_small_n: float = 2.5
|
||||
estimate_sb_small_n_threshold: int = 10
|
||||
# Шаг 3 — гейт Tier C: micro-radius anchor (НЕ тот же дом) с
|
||||
# anchor_ppm2 > corridor_high×mult НЕ заменяет консервативную радиусную медиану.
|
||||
# Очень большой mult (напр. 1e9) → гейт никогда не срабатывает (старое поведение).
|
||||
estimate_anchor_tier_c_corridor_mult: float = 1.5
|
||||
# Шаг 4 — жёстче Tukey outlier-cut на малых выборках: при n < threshold
|
||||
# k уменьшается с 1.5 до tukey_k_small. threshold=0 → выключено (старое поведение).
|
||||
estimate_outlier_small_n_threshold: int = 15
|
||||
estimate_outlier_tukey_k_small: float = 1.0
|
||||
# #1774: в Tier A (тот же дом) впускаем novostroyki-листинги ТОЛЬКО если в этом же
|
||||
# доме есть ≥1 вторичный (vtorichka/NULL) листинг — признак сданного дома, где
|
||||
# "novostroyki"-тег = переуступки/перепродажи собственниками (sale_type=free).
|
||||
# Чисто-первичный дом (0 вторички) → гард #1186 сохраняется. Tier C / радиус /
|
||||
# ratio — не затрагиваются.
|
||||
asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²)
|
||||
estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона
|
||||
|
||||
# ── #audit-1: anchor low-confidence gate ─────────────────────────────────
|
||||
# Якорь с низкой уверенностью (confidence="low" ИЛИ n < min_n И FSD > max_fsd)
|
||||
# НЕ заменяет headline — fallback на radius-median. Дефолты подобраны так, что
|
||||
# здоровые якоря (n≥4 с FSD<0.15) проходят без изменений.
|
||||
# estimate_sb_gate_min_n=3 : при n<3 И FSD>max_fsd гейт срабатывает
|
||||
# estimate_sb_gate_max_fsd=0.20: FSD>0.20 при малом n → ненадёжный якорь
|
||||
estimate_sb_gate_min_n: int = 3
|
||||
estimate_sb_gate_max_fsd: float = 0.20
|
||||
|
||||
# ── #audit-3: price_trend freshness filter ────────────────────────────────
|
||||
# Исключать items старше N месяцев из price_trend (house_placement_history).
|
||||
# Дефолт 6 (консервативно); аудит предложил 3 — конфигурируемо.
|
||||
estimate_price_trend_max_age_months: int = 6
|
||||
|
||||
# ── #1871 P1.2: ghost-anchor confidence floor ─────────────────────────────
|
||||
# True (дефолт) = форсировать confidence='low' + добавлять caveat в explanation
|
||||
# когда n_analogs == 0 (нет радиусных/anchor-аналогов) но confidence не 'low'.
|
||||
# Защита от ghost-anchor: внешние оценочные сервисы (yandex_valuation,
|
||||
# cian_valuation, avito_imv) могут дать median без единого реального рыночного
|
||||
# аналога → headline выглядит достоверным при нулевой реальной базе.
|
||||
|
||||
# ── #2002 #4: manual-review recommendation (derived FLAG, НЕ ценовой сигнал) ─
|
||||
# Помечает оценки, которые НЕ стоит авто-оффэрить — нужна ручная оценка
|
||||
# человеком. Research: элит/премиум-премия unit-level и под-доверена (зависит
|
||||
# от отделки/вида, чего нет в данных сделок). Триггеры: премиальный дом,
|
||||
# высокая стоимость, низкая уверенность, слишком широкий диапазон цены.
|
||||
# Чисто метаданные — не трогает median/expected_sold/ranges (gate byte-stable).
|
||||
estimate_manual_review_high_value_rub: int = 20_000_000 # ≥ этого — ручная оценка
|
||||
estimate_manual_review_wide_range_ratio: float = 1.9 # range_high/range_low ≥ — неопределённо
|
||||
# asking ₽/м² ≥ этого → дорогой сегмент, авто-оценка консервативна
|
||||
# (премия за отделку/вид/класс — unit-level, отсутствует в данных сделок).
|
||||
estimate_manual_review_elite_ppm2: int = 250000
|
||||
|
||||
# ── #1871 P2: radius-tier (source, source_id) dedup ───────────────────────
|
||||
# Radius-путь _fetch_analogs (Tier S/H/W) кэпит только per-address
|
||||
# (rn_addr <= MAX_ANALOGS_PER_ADDRESS), но (source, source_id)-дубли делят один
|
||||
# address и выживают на разных rn_addr рангах → раздувают n_analogs (prod
|
||||
# 2026-06-23: yandex 48, cian 9, n1 5 excess). Anchor-путь дедупит по
|
||||
# (source, source_id) — radius нет. Добавляет rn_dup=1 фильтр в каждом тире
|
||||
# (freshest scraped_at на (source, source_id|source_url|ctid)).
|
||||
|
||||
# ── #2087 H4: кросс-source физический дедуп аналогов ──────────────────────
|
||||
# Radius-дедуп выше ловит только повторы ВНУТРИ одного source (source, source_id).
|
||||
# Один физический лот кросс-постится на avito+cian+domklik (разные source, разные
|
||||
# source_id) → radius-дедуп его НЕ схлопывает → он считается несколько раз →
|
||||
# раздувает n_analogs И cv (→ шире коридор), может смещать медиану. Прод-аудит
|
||||
# #2087: лот 80м²/265000₽/м² = N1+Домклик+Циан (×3); «14 аналогов» → ~6-7 уникальных.
|
||||
# True схлопывает дубли по ФИЗИЧЕСКОМУ ключу до подсчёта n_analogs/median/cv:
|
||||
# building (building_cadastral_number | нормализованный address)
|
||||
# + floor + area_bucket (round(area_m2), ~±0.5 м²)
|
||||
# + price_bucket (round(price_rub / 100000), ~±0.5% @21М / ~±2% @2.5М).
|
||||
# Из группы остаётся ОДИН представитель (свежайший scraped_at), НЕ суммируем;
|
||||
# n_analogs/median/cv/source_counts/sources_used считаются по физическим лотам
|
||||
# («лот считается один раз»).
|
||||
#
|
||||
# Бэктест #1966 (400 ДКП, radius-путь, full spine, OFF vs ON): MAPE 13.89% →
|
||||
# 13.89%, coverage 83.33% → 83.33%, bias −3.83% → −3.83%, median width 0.743 →
|
||||
# 0.743, median cv 0.0988 → 0.0988; avg n_analogs 27.64 → 27.57. Дедуп отработал
|
||||
# 107× на 335 оценках, но снимает лишь identical-price кросс-посты (дубли имеют
|
||||
# ТУ ЖЕ цену → нулевой вклад в дисперсию) → cv/коридор НЕ сужаются. Это фикс
|
||||
# ЧЕСТНОСТИ СЧЁТА (n_analogs не раздут ×3 кросс-постами, source_counts по
|
||||
# физлотам), accuracy-нейтральный, а НЕ рычаг сужения cv (рычаг cv→коридор —
|
||||
# post-weight MAD-clip, уже ON). Default ON (#2173): бэктест #1966 OFF vs
|
||||
# ON accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias −3.83%, median width/cv
|
||||
# без изменений), меняется только user-visible n_analogs — перестаёт быть раздутым
|
||||
# кросс-постингом ×3. ENV: ESTIMATE_DEDUP_ANALOGS_ENABLED (=false откатывает).
|
||||
estimate_dedup_analogs_enabled: bool = True
|
||||
|
||||
# ── #2012: kitchen_area_m2 / ceiling_height_m / is_apartments comp-scoring ──
|
||||
# Follow-up к #2007/#2008/#2009 (промоутят поля в колонки). До этой правки
|
||||
# estimator читал house_type ТОЛЬКО как soft-penalty, а kitchen_area_m2 /
|
||||
|
|
@ -725,20 +586,6 @@ class Settings(BaseSettings):
|
|||
# ENV: ESTIMATE_IS_APARTMENTS_FILTER_ENABLED.
|
||||
estimate_is_apartments_filter_enabled: bool = False
|
||||
|
||||
# ── #1871 P2: split-дома wide-corridor disclosure (default ON, порог 1.2) ──
|
||||
# Tier A (same-building) матчит по address-regex (намеренно НЕ house_id — дом
|
||||
# дробится на несколько house_id). На split-доме разной этажности comp_min..max
|
||||
# растягивается через несколько ценовых режимов → коридор range_low/high
|
||||
# 148%/170%. Коридор честно широкий, но юзер видит 170% без объяснения. Tier A +
|
||||
# corridor_pct > threshold → понижаем confidence на ступень и дописываем
|
||||
# disclosure в explanation. НЕ трогает point/median/range.
|
||||
# Порог ширины коридора (range_high-range_low)/median_price для disclosure.
|
||||
# 1.2 (120%): по prod-данным corridor_pct median≈0.48, p90≈0.93 — порог 0.6
|
||||
# фаерил бы на ~31% оценок (широкий коридор ≠ split-дом, ложная атрибуция).
|
||||
# Genuine split-дома из аудита = 148-170% (1.48-1.70) → 1.2 ловит только
|
||||
# экстремальный хвост (>p99), не трогая нормальную оценочную неопределённость.
|
||||
estimate_wide_corridor_threshold: float = 1.2
|
||||
|
||||
# ── Mera-audit fix-1: Cian valuation sanity bounds ────────────────────────
|
||||
# API-ответ Cian иногда возвращает garbage-значения (999_999 или 9_999_999_999).
|
||||
# sale_price_rub вне [min, max] → результат отбрасывается (return None, не кэшируется).
|
||||
|
|
@ -763,26 +610,7 @@ class Settings(BaseSettings):
|
|||
# datacenter-403, #562). Dormant по умолчанию (ENV: AVITO_IMV_USE_BROWSER_FETCHER).
|
||||
avito_imv_use_browser_fetcher: bool = False
|
||||
|
||||
# ── #764: per-cadastral-quarter price index correction ───────────────────
|
||||
# Gap-correction: квартальный индекс применяется ТОЛЬКО в pure-radius пути
|
||||
# (когда same-building anchor и IMV-blend не сработали). Корректирует РАЗРЫВ
|
||||
# между квартальным уровнем целевого объекта и усреднённым квартальным уровнем
|
||||
# аналогов — не дублирует location, уже заложенный в медиану аналогов.
|
||||
# Формула: adjusted_ppm2 = base_ppm2 × target_index / avg_analog_index.
|
||||
# Минимальное число сделок в квартале (sparse fallback: меньше — no-op).
|
||||
estimate_quarter_index_min_n_deals: int = 10
|
||||
# Guard-2 (no double-count): если доля аналогов ИЗ ТОГО ЖЕ квартала > порога —
|
||||
# аналоги уже несут локацию квартала → skip (location in median).
|
||||
estimate_quarter_match_skip_ratio: float = 0.6
|
||||
# Bimodal/nominal guard (backtest 2026-05-31): структурно неоднородные кварталы
|
||||
# дают индекс > 2.0 при малой выборке → no-op чтобы избежать регрессию.
|
||||
estimate_quarter_index_max_for_small_n: float = 2.0
|
||||
estimate_quarter_index_small_n_threshold: int = 50
|
||||
# Sanity-clamp на factor = target_index / avg_analog_index (#859).
|
||||
# Belt-and-suspenders против патологичных FDW-данных. Нормальные квартальные
|
||||
# индексы РФ лежат в [0.6, 1.8]; за этими порогами — артефакт, а не сигнал.
|
||||
estimate_quarter_index_factor_min: float = 0.6
|
||||
estimate_quarter_index_factor_max: float = 1.8
|
||||
# ── #764: квартальный индекс цены (пороги — константы QUARTER_* в estimator.py) ──
|
||||
# Квартал ЦЕЛИ по её координатам (ближайшее здание в cad_buildings_local),
|
||||
# когда dadata.house_cadnum пуст — а он пуст в 15 из 15 применений на проде.
|
||||
# ВЫКЛЮЧЕН по умолчанию (ENV: ESTIMATE_QUARTER_FROM_COORDS_ENABLED).
|
||||
|
|
@ -971,7 +799,7 @@ class Settings(BaseSettings):
|
|||
# регион 50 n=35 264 sold_median 162 303 → 179 314 (×1.1048, factor 1.065..1.138)
|
||||
# регион 66 n=33 907 sold_median 94 655 → 99 059 (×1.0465, factor 1.032..1.062)
|
||||
# Ожидаемые ratio после первого пересчёта: 0.729→0.811 (77), 0.811→0.896 (50),
|
||||
# 0.728→0.762 (66) — все ниже 1.0, клампа estimate_expected_sold_le_asking не задевают.
|
||||
# 0.728→0.762 (66) — все ниже 1.0, клампа expected_sold ≤ asking не задевают.
|
||||
# NB: бэктест меряет предсказание против СЫРЫХ исторических цен сделок, поэтому его
|
||||
# bias после включения уедет в плюс примерно на эти же 4-11%. Это не регресс: прод
|
||||
# обязан отдавать цену СЕГОДНЯШНЕГО рынка, а таргет бэктеста — цена декабря 2025.
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ class AvitoImvSummary(BaseModel):
|
|||
|
||||
Источник: `house_imv_evaluations` (per house_id, обновляется регулярно).
|
||||
Это РЕАЛЬНАЯ рыночная оценка Avito по дому — служит anchor'ом для blend'а
|
||||
(см. estimate_imv_blend_*). Сурфейсится в UI как референсный маркер на
|
||||
(см. IMV_BLEND_* в estimator.py). Сурфейсится в UI как референсный маркер на
|
||||
ценовой шкале. None если для дома нет свежей IMV-записи.
|
||||
"""
|
||||
|
||||
|
|
@ -163,7 +163,7 @@ class DkpCorridor(BaseModel):
|
|||
[low,high]×slack — добавляется текстовая пометка.
|
||||
None / count=0 если по улице нет сопоставимых сделок.
|
||||
|
||||
#3452: «advisory» здесь НЕ безусловно. При count >= estimate_corridor_clamp_min_n
|
||||
#3452: «advisory» здесь НЕ безусловно. При count >= CORRIDOR_CLAMP_MIN_N
|
||||
коридор участвует в цене (soft-кламп headline + radius-floor, estimator.py), ниже
|
||||
порога — не участвует. Что именно случилось с ЭТОЙ выборкой, говорит advisory_only.
|
||||
"""
|
||||
|
|
@ -192,7 +192,7 @@ class DkpCorridor(BaseModel):
|
|||
def advisory_only(self) -> bool:
|
||||
"""#3452: True = сделок меньше порога, ценовые страховки коридора выключены.
|
||||
|
||||
Порог один и тот же (`estimate_corridor_clamp_min_n`) у обоих СТРАХОВОЧНЫХ
|
||||
Порог один и тот же (`CORRIDOR_CLAMP_MIN_N`) у обоих СТРАХОВОЧНЫХ
|
||||
путей коридора: soft-кламп headline сверху и radius-floor снизу
|
||||
(estimator.py). Ниже него коридор всё ещё виден клиенту, но не держит
|
||||
цену — зона n=3..9 на экране была неотличима от работающей.
|
||||
|
|
@ -207,9 +207,9 @@ class DkpCorridor(BaseModel):
|
|||
автоматически (POST /estimate и GET-rehydrate) и не дублирует порог
|
||||
вторым числом.
|
||||
"""
|
||||
from app.core.config import settings # локально: schemas остаётся import-light
|
||||
from app.core.config import CORRIDOR_CLAMP_MIN_N # локально: schemas import-light
|
||||
|
||||
return self.count < settings.estimate_corridor_clamp_min_n
|
||||
return self.count < CORRIDOR_CLAMP_MIN_N
|
||||
|
||||
|
||||
class PriceTrendPoint(BaseModel):
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ from scraper_kit.proxy_errors import caused_by_no_proxy
|
|||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import LISTINGS_FRESH_DAYS, Settings, settings
|
||||
from app.core.config import CORRIDOR_CLAMP_MIN_N, LISTINGS_FRESH_DAYS, settings
|
||||
from app.core.db import SessionLocal, run_db_thread
|
||||
from app.schemas.trade_in import (
|
||||
AggregatedEstimate,
|
||||
|
|
@ -316,7 +316,7 @@ def rehydrate_search_radius_m(
|
|||
# Замер 2026-09-10: средневзвешенный по 69 138 московским сделкам за 12 месяцев
|
||||
# time-фактор равен 1.0313 по свердловскому ряду против 1.0917 по московскому —
|
||||
# московский коридор занижен на ~5.9%. Занижен НЕ advisory: при
|
||||
# n>=estimate_corridor_clamp_min_n коридор участвует в clamp headline, radius-floor
|
||||
# n>=CORRIDOR_CLAMP_MIN_N коридор участвует в clamp headline, radius-floor
|
||||
# и Tier-C anchor gate, т.е. едет итоговая цена.
|
||||
SBER_TIME_ADJUST_REGION = "Свердловская область" # ряд региона 66 (= DEFAULT_REGION_CODE)
|
||||
|
||||
|
|
@ -416,6 +416,107 @@ PRICE_SEGMENTS_PPM2: tuple[tuple[str, float], ...] = (
|
|||
("премиум", float("inf")),
|
||||
)
|
||||
|
||||
# ── #1970: калибровочные константы движка (бывшие поля Settings estimate_*) ──
|
||||
# Раньше жили в Settings и формально переопределялись через ENV. Ни одно ни разу
|
||||
# не переопределялось: на проде 17.09.2026 (tradein-backend/scraper/tgbot) живые
|
||||
# значения совпадают с дефолтами, перенесёнными сюда. Переключателей, которые
|
||||
# реально переключаются, среди них нет, поэтому это код, а не конфиг: поменять
|
||||
# число = правка здесь + регрессионный гейт бэктеста
|
||||
# (tests/test_backtest_regression_gate.py).
|
||||
|
||||
# #1871 P2 (#2378): split-дома, disclosure широкого коридора. Tier A матчит по
|
||||
# address-regex (намеренно НЕ house_id — дом дробится на несколько house_id), и на
|
||||
# split-доме разной этажности comp_min..max растягивается через несколько ценовых
|
||||
# режимов → коридор 148%/170%. Tier A + (range_high-range_low)/median_price > порога
|
||||
# → confidence на ступень ниже + disclosure в explanation; point/median/range не
|
||||
# трогает. 1.2: по прод-данным corridor_pct median≈0.48, p90≈0.93 — порог 0.6 фаерил
|
||||
# бы на ~31% оценок; genuine split-дома аудита = 1.48-1.70 → 1.2 ловит только хвост.
|
||||
WIDE_CORRIDOR_THRESHOLD = 1.2
|
||||
|
||||
# #2002 #4 (#2378): пороги рекомендации ручной оценки (_manual_review). Чисто
|
||||
# метаданные — median/expected_sold/ranges не трогают.
|
||||
MANUAL_REVIEW_HIGH_VALUE_RUB = 20_000_000 # expected_sold ≥ этого — ручная оценка
|
||||
MANUAL_REVIEW_WIDE_RANGE_RATIO = 1.9 # range_high/range_low ≥ — неопределённо
|
||||
# asking ₽/м² ≥ этого → дорогой сегмент, авто-оценка консервативна (премия за
|
||||
# отделку/вид/класс — unit-level, отсутствует в данных сделок).
|
||||
MANUAL_REVIEW_ELITE_PPM2 = 250_000
|
||||
|
||||
# #764 (#2379): поправка на квартальный индекс цены. Применяется ТОЛЬКО на
|
||||
# pure-radius пути (same-building anchor и IMV-blend не сработали) и корректирует
|
||||
# РАЗРЫВ между квартальным уровнем цели и средним уровнем аналогов:
|
||||
# adjusted_ppm2 = base_ppm2 × target_index / avg_analog_index.
|
||||
# Минимум сделок в квартале, чтобы индексу верить (меньше — поправки нет).
|
||||
QUARTER_INDEX_MIN_N_DEALS = 10
|
||||
# Guard-2 (no double-count): доля аналогов ИЗ ТОГО ЖЕ квартала выше порога —
|
||||
# локация квартала уже в медиане → skip.
|
||||
QUARTER_MATCH_SKIP_RATIO = 0.6
|
||||
# Bimodal/nominal guard (бэктест 2026-05-31): структурно неоднородные кварталы дают
|
||||
# индекс > 2.0 на малой выборке (< 50 сделок) → no-op, иначе регрессия.
|
||||
QUARTER_INDEX_MAX_FOR_SMALL_N = 2.0
|
||||
QUARTER_INDEX_SMALL_N_THRESHOLD = 50
|
||||
# #859: sanity-clamp factor = target_index / avg_analog_index. Нормальные
|
||||
# квартальные индексы РФ лежат в [0.6, 1.8]; за порогами — артефакт FDW, не сигнал.
|
||||
QUARTER_INDEX_FACTOR_MIN = 0.6
|
||||
QUARTER_INDEX_FACTOR_MAX = 1.8
|
||||
|
||||
# #1795 (#2380): premium headline anti-inflation. Диагноз: бизнес/премиум headline
|
||||
# завышался ~2× против медианы реальных ДКП (Малышева 30 = 296k при медиане сделок
|
||||
# 138k), эконом/комфорт сходились ±5%. Порог числа сделок CORRIDOR_CLAMP_MIN_N — в
|
||||
# app.core.config (его же читает DkpCorridor.advisory_only, #3452).
|
||||
# Шаг 1 — soft-кламп headline к коридору ДКП-сделок: median_ppm2 > high_ppm2×(1+slack)
|
||||
# И count ≥ CORRIDOR_CLAMP_MIN_N И anchor_tier != "A" (комплы того же дома EXEMPT) →
|
||||
# headline прижимается к high_ppm2×(1+slack). 0.40 → cap ×1.40 даёт премиум-домам без
|
||||
# своих листингов (tier-C) воздух над sold-коридором, не возвращая 2× инфляцию.
|
||||
CORRIDOR_CLAMP_SLACK = 0.40
|
||||
# Нижний floor radius-пути (anchor_tier is None, коридор с count ≥ min_n):
|
||||
# median_ppm2 < dkp_low_ppm2 × factor → поднимаем до floor. 0.8 — 20% зазор ниже P10
|
||||
# коридора: достаточно мягко для эконома, ловит явный undershoot.
|
||||
RADIUS_FLOOR_FACTOR = 0.8
|
||||
# Шаг 4 — жёстче Tukey outlier-cut на малых выборках: при n < порога k = 1.0 вместо
|
||||
# 1.5, иначе несколько элитных листингов тонкой выборки тянут медиану вверх.
|
||||
OUTLIER_SMALL_N_THRESHOLD = 15
|
||||
OUTLIER_TUKEY_K_SMALL = 1.0
|
||||
# Шаг 5 — expected_sold ≤ asking — выключателя не имеет (_price_from_inputs): ratio > 1.0
|
||||
# для trade-in физически невозможен, это артефакт high-price бакета.
|
||||
|
||||
# #651 (#2381): IMV / Yandex blend. Радиусная медиана ₽/м² системно недооценивает
|
||||
# премиум/видовые квартиры (premium ~2x underestimate). Если внешний якорь (Avito IMV
|
||||
# recommended_price из house_imv_evaluations либо Yandex sale) выше медианы более чем
|
||||
# в IMV_BLEND_THRESHOLD раз — подмешиваем его с весом IMV_BLEND_WEIGHT
|
||||
# (median×(1-w)+A×w) и расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем.
|
||||
IMV_BLEND_WEIGHT = 0.5
|
||||
IMV_BLEND_THRESHOLD = 1.15
|
||||
|
||||
# #651/#652 v2 (#2381): same-building anchor (validated, 55 golden cases). Радиусная
|
||||
# медиана размывает премию дома/ЖК → премиум ~2.5x недооценка, комфорт −15-25%. v2
|
||||
# берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A), similarity-weighted по
|
||||
# площади/комнатам, premium-uplift к ~p70 для топ-юнита, asking→sold haircut (banded
|
||||
# по ppm²), hard guardrail и tighter FSD-диапазон.
|
||||
# Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2.
|
||||
SB_MIN_COMPS = 4 # стоп на первом тире с ≥ N активных комплов
|
||||
SB_AREA_SIGMA = 0.18 # σ log-нормального area-веса (Gaussian)
|
||||
SB_ROOMS_MATCH_BOOST = 1.6 # ×вес если rooms компла == target
|
||||
# #680-WB: floor-similarity Gaussian по ОТНОСИТЕЛЬНОЙ вертикальной позиции
|
||||
# (floor/total_floors) — мелкокомнатный/нижний юнит во флагман-доме не наследует цену
|
||||
# видового топ-этажа. σ_f=0.25 — лучший medAPE на 55 golden без потери покрытия
|
||||
# (Хохрякова 3к/153 overshoot 64%→1.5%, флагман 4к 17.5%→5.4%).
|
||||
SB_FLOOR_SIGMA = 0.25
|
||||
SB_GUARDRAIL_TOL = 0.05 # hard floor: est ≥ min(comp ppm²)×(1−tol)
|
||||
SB_MAD_K = 3.5 # MAD-clip: drop comps с |ppm2−median| > k×MAD
|
||||
# #1795 шаг 2: на малых выборках (n < SB_SMALL_N_THRESHOLD, кроме Tier A) MAD-clip
|
||||
# жёстче — 3.5 при n=7 не срезает элитные хвосты, weighted mean тянется вверх.
|
||||
SB_MAD_K_SMALL_N = 2.5
|
||||
SB_SMALL_N_THRESHOLD = 10
|
||||
# #1795 шаг 3: гейт Tier C — micro-radius якорь (НЕ тот же дом) с
|
||||
# anchor_ppm2 > corridor_high × mult не заменяет радиусную медиану.
|
||||
ANCHOR_TIER_C_CORRIDOR_MULT = 1.5
|
||||
FSD_K = 1.65 # множитель FSD → полуширина диапазона якоря
|
||||
# #audit-1: low-confidence гейт якоря — n < SB_GATE_MIN_N И FSD > SB_GATE_MAX_FSD
|
||||
# (или confidence="low") → fallback на радиусную медиану. Здоровые якоря (n≥4,
|
||||
# FSD<0.15) проходят без изменений.
|
||||
SB_GATE_MIN_N = 3
|
||||
SB_GATE_MAX_FSD = 0.20
|
||||
|
||||
# #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются
|
||||
# нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади —
|
||||
# которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные
|
||||
|
|
@ -2013,7 +2114,7 @@ def _resolve_target_city(address_text: str | None) -> str | None:
|
|||
Returns None если ни один город не распознан (адрес вне известного
|
||||
списка) — caller (_fetch_dkp_corridor) откатывается на behaviour без
|
||||
city-фильтра. NB: коридор НЕ чисто advisory — при
|
||||
n>=estimate_corridor_clamp_min_n участвует в clamp headline + radius-floor +
|
||||
n>=CORRIDOR_CLAMP_MIN_N участвует в clamp headline + radius-floor +
|
||||
Tier-C anchor gate, поэтому city-scope влияет на итоговую цену.
|
||||
"""
|
||||
if not address_text:
|
||||
|
|
@ -2223,7 +2324,7 @@ def _fetch_dkp_corridor(
|
|||
latest_deal = dd
|
||||
ppm2_values = sorted(adjusted)
|
||||
# #3452: чем получен коридор — улицей или расширением до города (widen ниже).
|
||||
# Нужен логу зоны n < estimate_corridor_clamp_min_n: «мало сделок на улице» и
|
||||
# Нужен логу зоны n < CORRIDOR_CLAMP_MIN_N: «мало сделок на улице» и
|
||||
# «мало сделок во всём городе» — разные новости. В DkpCorridor ключ не уходит
|
||||
# (pydantic игнорирует лишние kwargs), это служебная метка для логов.
|
||||
scope = "street"
|
||||
|
|
@ -2592,7 +2693,7 @@ def _fetch_anchor_comps(
|
|||
ключами price_per_m2 (int>0), area_m2 (float|None), rooms (int|None),
|
||||
floor (int|None), total_floors (int|None) — последние два для floor-веса (#680-WB).
|
||||
"""
|
||||
min_comps = settings.estimate_sb_min_comps
|
||||
min_comps = SB_MIN_COMPS
|
||||
|
||||
# ── Tier A: same building ────────────────────────────────────────────────
|
||||
street, base_no, letter = _normalize_building_key(address)
|
||||
|
|
@ -2975,12 +3076,8 @@ def _compute_same_building_anchor(
|
|||
# легитимен, агрессивный clip там съел бы реальные топ-юниты и обрушил бы якорь
|
||||
# < min_comps → fallback на заниженную радиусную медиану. Tier C/прочие — clip.
|
||||
effective_mad_k = mad_k
|
||||
if (
|
||||
tier != "A"
|
||||
and settings.estimate_sb_mad_k_small_n < mad_k
|
||||
and len(raw_ppm2) < settings.estimate_sb_small_n_threshold
|
||||
):
|
||||
effective_mad_k = settings.estimate_sb_mad_k_small_n
|
||||
if tier != "A" and SB_MAD_K_SMALL_N < mad_k and len(raw_ppm2) < SB_SMALL_N_THRESHOLD:
|
||||
effective_mad_k = SB_MAD_K_SMALL_N
|
||||
surviving_idx = _mad_clip(raw_ppm2, effective_mad_k)
|
||||
if len(surviving_idx) < min_comps:
|
||||
logger.info(
|
||||
|
|
@ -3394,7 +3491,7 @@ def _price_from_inputs(
|
|||
"""
|
||||
# 2b. Кросс-source физический дедуп (#2087 H4) — ДО outlier-фильтра и
|
||||
# агрегации, чтобы n_analogs/median/cv считались по уникальным лотам, а не
|
||||
# по кросс-постам. No-op при estimate_dedup_analogs_enabled=False.
|
||||
# по кросс-постам.
|
||||
listings = _dedup_cross_source(listings)
|
||||
# #2265 gap (live QA 2026-07-03): якорный пул (_fetch_anchor_comps, Tier A
|
||||
# same_building / Tier C micro_radius) строится ОТДЕЛЬНЫМ raw SQL и раньше
|
||||
|
|
@ -3591,32 +3688,28 @@ def _price_from_inputs(
|
|||
area_target=area_m2,
|
||||
rooms_target=rooms,
|
||||
tier=anchor_tier or "",
|
||||
sigma=settings.estimate_sb_area_sigma,
|
||||
rooms_boost=settings.estimate_sb_rooms_match_boost,
|
||||
sigma=SB_AREA_SIGMA,
|
||||
rooms_boost=SB_ROOMS_MATCH_BOOST,
|
||||
floor_target=floor,
|
||||
total_floors_target=total_floors,
|
||||
floor_sigma=settings.estimate_sb_floor_sigma,
|
||||
min_comps=settings.estimate_sb_min_comps,
|
||||
mad_k=settings.estimate_sb_mad_k,
|
||||
floor_sigma=SB_FLOOR_SIGMA,
|
||||
min_comps=SB_MIN_COMPS,
|
||||
mad_k=SB_MAD_K,
|
||||
)
|
||||
|
||||
# #1795 шаг 3: гейт Tier C.
|
||||
if (
|
||||
anchor is not None
|
||||
and anchor_tier == "C"
|
||||
and settings.estimate_anchor_tier_c_corridor_mult > 0
|
||||
):
|
||||
if anchor is not None and anchor_tier == "C":
|
||||
if dkp_raw is not None and dkp_raw.get("high_ppm2", 0) > 0:
|
||||
corridor_high_for_gate = float(dkp_raw["high_ppm2"])
|
||||
else:
|
||||
corridor_high_for_gate = (median_ppm2 / repair_coef) * 1.3 if repair_coef else 0.0
|
||||
gate_threshold = corridor_high_for_gate * settings.estimate_anchor_tier_c_corridor_mult
|
||||
gate_threshold = corridor_high_for_gate * ANCHOR_TIER_C_CORRIDOR_MULT
|
||||
if gate_threshold > 0 and anchor["anchor_ppm2"] > gate_threshold:
|
||||
logger.info(
|
||||
"sb_anchor Tier C gate #1795: anchor_ppm2=%d > corridor_high×%.1f=%d"
|
||||
" → keep radius median (anchor suppressed)",
|
||||
int(anchor["anchor_ppm2"]),
|
||||
settings.estimate_anchor_tier_c_corridor_mult,
|
||||
ANCHOR_TIER_C_CORRIDOR_MULT,
|
||||
int(gate_threshold),
|
||||
)
|
||||
anchor = None
|
||||
|
|
@ -3624,10 +3717,7 @@ def _price_from_inputs(
|
|||
# #audit-1: low-confidence gate.
|
||||
if anchor is not None:
|
||||
gate_low = anchor["confidence"] == "low"
|
||||
gate_thin = (
|
||||
anchor["n"] < settings.estimate_sb_gate_min_n
|
||||
and anchor["fsd"] > settings.estimate_sb_gate_max_fsd
|
||||
)
|
||||
gate_thin = anchor["n"] < SB_GATE_MIN_N and anchor["fsd"] > SB_GATE_MAX_FSD
|
||||
if gate_low or gate_thin:
|
||||
logger.info(
|
||||
"sb_anchor low-conf gate #audit-1: tier=%s n=%d fsd=%.3f conf=%s"
|
||||
|
|
@ -3667,13 +3757,13 @@ def _price_from_inputs(
|
|||
anchor_comps_used = anchor.get("comps") or anchor_comps
|
||||
est_ppm2 = anchor["anchor_ppm2"]
|
||||
# PREMIUM GUARDRAIL (hard).
|
||||
floor_ppm2 = anchor["comp_min_ppm2"] * (1.0 - settings.estimate_sb_guardrail_tol)
|
||||
floor_ppm2 = anchor["comp_min_ppm2"] * (1.0 - SB_GUARDRAIL_TOL)
|
||||
if est_ppm2 < floor_ppm2:
|
||||
est_ppm2 = floor_ppm2
|
||||
new_ppm2 = est_ppm2 * repair_coef
|
||||
point = int(new_ppm2 * area_m2)
|
||||
# FSD-диапазон.
|
||||
half = settings.estimate_fsd_k * anchor["fsd"]
|
||||
half = FSD_K * anchor["fsd"]
|
||||
new_range_low = int(point * max(0.0, 1.0 - half))
|
||||
new_range_high = int(point * (1.0 + half))
|
||||
# Спред комплов.
|
||||
|
|
@ -3726,7 +3816,7 @@ def _price_from_inputs(
|
|||
# #1871 P2: split-дома wide-corridor disclosure.
|
||||
if anchor_tier == "A" and median_price > 0:
|
||||
corridor_pct = (range_high - range_low) / median_price
|
||||
if corridor_pct > settings.estimate_wide_corridor_threshold:
|
||||
if corridor_pct > WIDE_CORRIDOR_THRESHOLD:
|
||||
confidence = _downgrade_confidence(confidence)
|
||||
explanation = (explanation or "") + (
|
||||
" Очень широкий ценовой диапазон по дому (вероятно, дом "
|
||||
|
|
@ -3830,8 +3920,8 @@ def _price_from_inputs(
|
|||
area=area_m2,
|
||||
anchor_total=anchor_total,
|
||||
anchor_higher=anchor_higher,
|
||||
weight=settings.estimate_imv_blend_weight,
|
||||
threshold=settings.estimate_imv_blend_threshold,
|
||||
weight=IMV_BLEND_WEIGHT,
|
||||
threshold=IMV_BLEND_THRESHOLD,
|
||||
market_count=(
|
||||
avito_imv_summary.market_count if avito_imv_summary is not None else None
|
||||
),
|
||||
|
|
@ -3843,7 +3933,7 @@ def _price_from_inputs(
|
|||
median_price,
|
||||
new_median,
|
||||
anchor_used,
|
||||
settings.estimate_imv_blend_weight,
|
||||
IMV_BLEND_WEIGHT,
|
||||
range_high,
|
||||
new_range_high,
|
||||
)
|
||||
|
|
@ -3911,15 +4001,15 @@ def _price_from_inputs(
|
|||
|
||||
# Bimodal/nominal guard (Guard-4).
|
||||
if (
|
||||
target_qi > settings.estimate_quarter_index_max_for_small_n
|
||||
and target_n_deals < settings.estimate_quarter_index_small_n_threshold
|
||||
target_qi > QUARTER_INDEX_MAX_FOR_SMALL_N
|
||||
and target_n_deals < QUARTER_INDEX_SMALL_N_THRESHOLD
|
||||
):
|
||||
logger.info(
|
||||
"quarter_index: bimodal guard triggered "
|
||||
"(index=%.3f n=%d < %d) for %s — no-op",
|
||||
target_qi,
|
||||
target_n_deals,
|
||||
settings.estimate_quarter_index_small_n_threshold,
|
||||
QUARTER_INDEX_SMALL_N_THRESHOLD,
|
||||
target_quarter,
|
||||
)
|
||||
else:
|
||||
|
|
@ -3944,11 +4034,11 @@ def _price_from_inputs(
|
|||
if lot_quarters_for_guard2
|
||||
else 0.0
|
||||
)
|
||||
if same_quarter_ratio > settings.estimate_quarter_match_skip_ratio:
|
||||
if same_quarter_ratio > QUARTER_MATCH_SKIP_RATIO:
|
||||
logger.info(
|
||||
"quarter_index: Guard-2 skip (same-quarter ratio=%.2f > %.2f) for %s",
|
||||
same_quarter_ratio,
|
||||
settings.estimate_quarter_match_skip_ratio,
|
||||
QUARTER_MATCH_SKIP_RATIO,
|
||||
target_quarter,
|
||||
)
|
||||
else:
|
||||
|
|
@ -4001,8 +4091,8 @@ def _price_from_inputs(
|
|||
base_range_high=range_high,
|
||||
target_index=target_qi,
|
||||
avg_analog_index=avg_analog_index,
|
||||
min_factor=settings.estimate_quarter_index_factor_min,
|
||||
max_factor=settings.estimate_quarter_index_factor_max,
|
||||
min_factor=QUARTER_INDEX_FACTOR_MIN,
|
||||
max_factor=QUARTER_INDEX_FACTOR_MAX,
|
||||
)
|
||||
analogs_with_qi = sum(
|
||||
1 for lq, _lp in analog_quarters if lq in analog_index_map
|
||||
|
|
@ -4024,7 +4114,7 @@ def _price_from_inputs(
|
|||
sources_used_pre = sorted(set(sources_used_pre) | {"quarter_index"})
|
||||
|
||||
# ── #1795 шаг 1: soft-кламп headline к коридору ДКП-сделок ──────────────
|
||||
slack = settings.estimate_corridor_clamp_slack
|
||||
slack = CORRIDOR_CLAMP_SLACK
|
||||
if dkp_raw is not None and median_ppm2 > 0:
|
||||
old_ppm2 = median_ppm2
|
||||
median_ppm2, median_price, range_low, range_high, clamped = _apply_corridor_clamp(
|
||||
|
|
@ -4036,7 +4126,7 @@ def _price_from_inputs(
|
|||
corridor_count=dkp_raw["count"],
|
||||
anchor_tier=anchor_tier,
|
||||
slack=slack,
|
||||
min_n=settings.estimate_corridor_clamp_min_n,
|
||||
min_n=CORRIDOR_CLAMP_MIN_N,
|
||||
)
|
||||
if clamped:
|
||||
logger.info(
|
||||
|
|
@ -4059,9 +4149,9 @@ def _price_from_inputs(
|
|||
and dkp_raw is not None
|
||||
and dkp_raw.get("low_ppm2", 0) > 0
|
||||
and median_ppm2 > 0
|
||||
and dkp_raw.get("count", 0) >= settings.estimate_corridor_clamp_min_n
|
||||
and dkp_raw.get("count", 0) >= CORRIDOR_CLAMP_MIN_N
|
||||
):
|
||||
radius_floor_ppm2 = float(dkp_raw["low_ppm2"]) * settings.estimate_radius_floor_factor
|
||||
radius_floor_ppm2 = float(dkp_raw["low_ppm2"]) * RADIUS_FLOOR_FACTOR
|
||||
if median_ppm2 < radius_floor_ppm2:
|
||||
floor_factor = radius_floor_ppm2 / median_ppm2
|
||||
logger.info(
|
||||
|
|
@ -4069,7 +4159,7 @@ def _price_from_inputs(
|
|||
" → lifting (factor=%.3f)",
|
||||
int(median_ppm2),
|
||||
dkp_raw["low_ppm2"],
|
||||
settings.estimate_radius_floor_factor,
|
||||
RADIUS_FLOOR_FACTOR,
|
||||
int(radius_floor_ppm2),
|
||||
floor_factor,
|
||||
)
|
||||
|
|
@ -4089,7 +4179,7 @@ def _price_from_inputs(
|
|||
|
||||
if asking_to_sold_ratio is not None and median_price > 0:
|
||||
effective_ratio = asking_to_sold_ratio
|
||||
if settings.estimate_expected_sold_le_asking and effective_ratio > 1.0:
|
||||
if effective_ratio > 1.0:
|
||||
logger.info(
|
||||
"expected_sold ratio clamped %.3f->1.0 (rooms=%s)",
|
||||
effective_ratio,
|
||||
|
|
@ -4132,7 +4222,7 @@ def _price_from_inputs(
|
|||
# which is an overpay risk for trade-in. Re-clamp the corrected point back to
|
||||
# the headline so the calibrated PI range below wraps the clamped point.
|
||||
# round(median_ppm2) keeps expected_sold_per_m2 an int (median_ppm2 is float).
|
||||
if settings.estimate_expected_sold_le_asking and expected_sold_price:
|
||||
if expected_sold_price:
|
||||
expected_sold_price = min(expected_sold_price, median_price)
|
||||
if expected_sold_per_m2:
|
||||
expected_sold_per_m2 = min(expected_sold_per_m2, round(median_ppm2))
|
||||
|
|
@ -4214,7 +4304,7 @@ def _price_from_inputs(
|
|||
# #oblast-E: guards on `anchor is None` (the actual computed anchor dict),
|
||||
# NOT `anchor_tier is None`. Found via live backtest-fixture regen: when
|
||||
# `_compute_same_building_anchor` rejects a candidate outright (e.g. its
|
||||
# own MAD-clip drops comps below estimate_sb_min_comps), it returns None
|
||||
# own MAD-clip drops comps below SB_MIN_COMPS), it returns None
|
||||
# WITHOUT the caller resetting `anchor_tier` back to None (it stays
|
||||
# whatever `anchor_tier_fetched` was, e.g. "C") — the anchor never fired,
|
||||
# but the stale tier flag falsely reads as "anchor claimed the headline"
|
||||
|
|
@ -5166,14 +5256,14 @@ async def estimate_quality(
|
|||
return _lookup_quarter_index(
|
||||
db,
|
||||
quarter_cad_number=q,
|
||||
min_n_deals=settings.estimate_quarter_index_min_n_deals,
|
||||
min_n_deals=QUARTER_INDEX_MIN_N_DEALS,
|
||||
)
|
||||
|
||||
def _qis_lookup(qs: list[str]) -> dict[str, float]:
|
||||
return _lookup_quarter_indexes(
|
||||
db,
|
||||
quarter_cad_numbers=qs,
|
||||
min_n_deals=settings.estimate_quarter_index_min_n_deals,
|
||||
min_n_deals=QUARTER_INDEX_MIN_N_DEALS,
|
||||
)
|
||||
|
||||
# ── Deterministic pricing orchestration ──────────────────────────────────
|
||||
|
|
@ -5311,7 +5401,7 @@ async def estimate_quality(
|
|||
estimate_id = uuid4()
|
||||
|
||||
# #3452: коридор ДКП показан, но его ценовые страховки выключены — сделок
|
||||
# меньше порога доверия (тот же estimate_corridor_clamp_min_n гейтит и
|
||||
# меньше порога доверия (тот же CORRIDOR_CLAMP_MIN_N гейтит и
|
||||
# soft-кламп headline, и radius-floor; гейт Tier C порога не имеет вовсе).
|
||||
# Зона n=3..9 на экране неотличима от работающего коридора, поэтому
|
||||
# попадание в неё пишется явной строкой: число оценок за сутки —
|
||||
|
|
@ -5322,7 +5412,7 @@ async def estimate_quality(
|
|||
"corridor_advisory_zone #3452: id=%s n=%d min_n=%d scope=%s city=%s",
|
||||
estimate_id,
|
||||
dkp_corridor.count,
|
||||
settings.estimate_corridor_clamp_min_n,
|
||||
CORRIDOR_CLAMP_MIN_N,
|
||||
(dkp_raw or {}).get("scope", "unknown"),
|
||||
target_city or "unknown",
|
||||
)
|
||||
|
|
@ -5641,7 +5731,6 @@ async def estimate_quality(
|
|||
confidence,
|
||||
range_low,
|
||||
range_high,
|
||||
settings,
|
||||
# asking-сторона: тот же headline median_price_per_m2 (=int(median_ppm2)),
|
||||
# что уходит в ответ ниже. НЕ expected_sold_per_m2 — он занижен на элите.
|
||||
asking_ppm2=int(median_ppm2),
|
||||
|
|
@ -5781,8 +5870,8 @@ def _is_premium_building(db: Session, house_id: int | None) -> tuple[bool, int |
|
|||
None) — premium_building флаг не выставится, даже если дом объективно элитный по
|
||||
рынку своего города. Это не тихая потеря сигнала: `_manual_review` (см. ниже,
|
||||
строки ~3944-3993) всё равно ловит такие объекты по АБСОЛЮТНОЙ стоимости —
|
||||
`estimate_manual_review_high_value_rub` (expected_sold_price ≥ порога) и
|
||||
`estimate_manual_review_elite_ppm2` (asking ₽/м² ≥ порога) — независимо от
|
||||
`MANUAL_REVIEW_HIGH_VALUE_RUB` (expected_sold_price ≥ порога) и
|
||||
`MANUAL_REVIEW_ELITE_PPM2` (asking ₽/м² ≥ порога) — независимо от
|
||||
premium_building. То есть oblast-элита не помечается как «премиальный дом», но
|
||||
почти всегда подхватывается value-based триггером той же функции.
|
||||
"""
|
||||
|
|
@ -5819,7 +5908,6 @@ def _manual_review(
|
|||
confidence: str,
|
||||
range_low: int | None,
|
||||
range_high: int | None,
|
||||
settings: Settings,
|
||||
asking_ppm2: int | None = None,
|
||||
) -> tuple[bool, list[str]]:
|
||||
"""#2002 #4: рекомендация ручной оценки — производный ФЛАГ, НЕ цена.
|
||||
|
|
@ -5849,15 +5937,13 @@ def _manual_review(
|
|||
reasons: list[str] = []
|
||||
if premium_building:
|
||||
reasons.append("премиальный дом — премия зависит от отделки/вида (не в данных сделок)")
|
||||
high_value = settings.estimate_manual_review_high_value_rub
|
||||
if expected_sold_price and expected_sold_price >= high_value:
|
||||
if expected_sold_price and expected_sold_price >= MANUAL_REVIEW_HIGH_VALUE_RUB:
|
||||
reasons.append("высокая стоимость (≥20 млн ₽)")
|
||||
if confidence == "low":
|
||||
reasons.append("низкая уверенность оценки")
|
||||
wide_ratio = settings.estimate_manual_review_wide_range_ratio
|
||||
if range_low and range_high and range_high / range_low >= wide_ratio:
|
||||
if range_low and range_high and range_high / range_low >= MANUAL_REVIEW_WIDE_RANGE_RATIO:
|
||||
reasons.append("широкий диапазон цены")
|
||||
if asking_ppm2 and asking_ppm2 >= settings.estimate_manual_review_elite_ppm2:
|
||||
if asking_ppm2 and asking_ppm2 >= MANUAL_REVIEW_ELITE_PPM2:
|
||||
reasons.append(
|
||||
"элитный/дорогой сегмент — авто-оценка консервативна: премия за отделку, "
|
||||
"вид и класс дома не отражена в данных сделок, фактическая цена может быть выше"
|
||||
|
|
@ -7655,8 +7741,7 @@ def _union_find_phys_dedup(
|
|||
не дедупятся — остаются уникальными.
|
||||
|
||||
include_price=True — статистический ключ (влияет на n_analogs/median/cv,
|
||||
гейтится флагом estimate_dedup_analogs_enabled со стороны вызывающего
|
||||
_dedup_cross_source). include_price=False — display-only ключ без
|
||||
вызывающий — _dedup_cross_source). include_price=False — display-only ключ без
|
||||
price_bucket (см. _dedup_display_lots) — ценовой дрейф между кросс-постами
|
||||
не мешает схлопнуть их в одну UI-карточку; ВЗАМЕН требует РАЗНЫЙ source у
|
||||
пары для merge (require_diff_source) — без цены единственный оставшийся
|
||||
|
|
@ -7777,13 +7862,14 @@ def _dedup_cross_source(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|||
"""Статистический кросс-source дедуп (#2087 H4 / #2265) — ВЛИЯЕТ на n_analogs/
|
||||
median/cv/source_counts/sources_used (представитель несёт свой source → лот
|
||||
считается один раз; source кросс-поста, не ставший представителем, выпадает
|
||||
из выборки — по нему нет независимой ценовой точки). Флаг
|
||||
estimate_dedup_analogs_enabled OFF → no-op (байт-идентичный проход, нужно
|
||||
для frozen backtest regression gate). Ключ включает price_bucket
|
||||
из выборки — по нему нет независимой ценовой точки). Работает всегда: флаг-выключатель
|
||||
снят (#2378) — бэктест #1966 OFF vs ON был
|
||||
accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias −3.83%), дедуп меняет
|
||||
только честность счёта n_analogs. Ключ включает price_bucket
|
||||
(_lot_dedup_components include_price=True по умолчанию) — см.
|
||||
_union_find_phys_dedup.
|
||||
"""
|
||||
if not settings.estimate_dedup_analogs_enabled or len(lots) < 2:
|
||||
if len(lots) < 2:
|
||||
return lots
|
||||
return _union_find_phys_dedup(lots, include_price=True)
|
||||
|
||||
|
|
@ -7829,9 +7915,8 @@ def _dedup_display_lots(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|||
случай: перевыставили на другой площадке дешевле/дороже) — без такого
|
||||
дубля-выброса, ошибочно попавшего в карточки как «отдельный аналог».
|
||||
|
||||
Независим от settings.estimate_dedup_analogs_enabled: тот флаг управляет
|
||||
статистикой (median/n_analogs), этот — только честностью UI-карточек и
|
||||
может/должен оставаться активным даже если стат-дедуп выключен. НЕ трогает
|
||||
Независим от _dedup_cross_source: тот управляет статистикой
|
||||
(median/n_analogs), этот — только честностью UI-карточек. НЕ трогает
|
||||
n_analogs/median/cv — вызывается ПОСЛЕ того, как эти величины уже вычислены
|
||||
из отдельного (не обязательно совпадающего по итоговой длине) пула.
|
||||
"""
|
||||
|
|
@ -7843,10 +7928,10 @@ def _dedup_display_lots(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|||
def _filter_outliers(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Tukey IQR rule: исключаем точки вне [Q1 - k×IQR, Q3 + k×IQR].
|
||||
|
||||
#1795 шаг 4: на малых выборках (n < estimate_outlier_small_n_threshold)
|
||||
ужесточаем k с 1.5 до estimate_outlier_tukey_k_small — несколько элитных
|
||||
#1795 шаг 4: на малых выборках (n < OUTLIER_SMALL_N_THRESHOLD)
|
||||
ужесточаем k с 1.5 до OUTLIER_TUKEY_K_SMALL — несколько элитных
|
||||
листингов на тонкой выборке премиум-дома иначе остаются в IQR-границах и
|
||||
тянут радиусную медиану вверх. threshold=0 → выключено (старое поведение).
|
||||
тянут радиусную медиану вверх.
|
||||
"""
|
||||
if len(lots) < 5:
|
||||
return lots # на маленькой выборке нечего фильтровать
|
||||
|
|
@ -7856,11 +7941,8 @@ def _filter_outliers(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|||
return lots
|
||||
|
||||
tukey_k = 1.5
|
||||
if (
|
||||
settings.estimate_outlier_tukey_k_small < 1.5
|
||||
and len(prices) < settings.estimate_outlier_small_n_threshold
|
||||
):
|
||||
tukey_k = settings.estimate_outlier_tukey_k_small
|
||||
if len(prices) < OUTLIER_SMALL_N_THRESHOLD:
|
||||
tukey_k = OUTLIER_TUKEY_K_SMALL
|
||||
|
||||
q1 = _percentile(prices, 0.25)
|
||||
q3 = _percentile(prices, 0.75)
|
||||
|
|
|
|||
|
|
@ -1975,12 +1975,11 @@ def replay_fixture(fixture: dict[str, Any], *, pi_report: bool = False) -> dict[
|
|||
/ ``sharpness`` / ``confidence_order`` / ``headline`` and DROPS the volatile
|
||||
``params`` block. Touches NO DB / network and does NOT consult
|
||||
``settings_at_capture`` — it prices against the live committed
|
||||
``estimator.settings`` defaults (so a settings change is caught as a metric
|
||||
drift, not silently honoured). ЕДИНСТВЕННОЕ исключение —
|
||||
``estimate_dedup_analogs_enabled``, пиннится в False на время реплея: это не
|
||||
настройка точности, а условие воспроизводимости ЗАХВАЧЕННОГО контрольного
|
||||
потока (см. комментарий у пина ниже). Deterministic: same fixture → identical
|
||||
dict.
|
||||
``estimator.settings`` defaults and module constants (so a change is caught as a
|
||||
metric drift, not silently honoured). Кросс-source дедуп (#2087 H4) с #2378
|
||||
работает всегда, поэтому реплеится только фикстура, захваченная с дедупом ON
|
||||
(закоммиченная — такая); более старая упадёт на «control flow diverged from
|
||||
capture» — её надо перезахватить. Deterministic: same fixture → identical dict.
|
||||
"""
|
||||
est = _import_estimator_full()
|
||||
m = est.m
|
||||
|
|
@ -1991,26 +1990,6 @@ def replay_fixture(fixture: dict[str, Any], *, pi_report: bool = False) -> dict[
|
|||
pred_ppm2_all: list[float] = []
|
||||
unrecorded: list[int] = [0] # #2661: счётчик lookup-вызовов без записи в фикстуре
|
||||
|
||||
# #2661: фикстура захвачена с ВЫКЛЮЧЕННЫМ кросс-source дедупом (#2087 H4 был
|
||||
# no-op по умолчанию на момент захвата), а с #2173 дефолт ON. Реплей обязан идти
|
||||
# по ЗАХВАЧЕННОМУ контрольному потоку: с активным дедупом _dedup_cross_source
|
||||
# подрезал бы listings до quarter_indexes_lookup и записанная последовательность
|
||||
# вызовов разъехалась бы. Пин ЗДЕСЬ, а не только в CI-гейте: гейт монкипатчил флаг
|
||||
# сам, а документированная регенерация baseline (--from-fixture --update-baseline)
|
||||
# — нет, и с #2173 писала baseline, который тест не совпал бы НИКОГДА.
|
||||
# #3251: РАНЬШЕ здесь стоял жёсткий `= False`. Это верно только для фикстуры,
|
||||
# захваченной ДО #2173 (когда дедуп был выключен по умолчанию), и делает
|
||||
# НЕРЕПЛЕИБЕЛЬНОЙ любую свежую фикстуру: при dedup=True на захвате часть сделок
|
||||
# уходит под sufficiency-гейт с median_ppm2<=0 и ratio_resolver для них не
|
||||
# вызывается, а реплей с dedup=False их оценивает и упирается в
|
||||
# «control flow diverged from capture». Берём значение ИЗ ФИКСТУРЫ (fallback
|
||||
# False — для старых фикстур без settings_at_capture поведение прежнее).
|
||||
_dedup_capture = bool(
|
||||
(fixture.get("settings_at_capture") or {}).get("estimate_dedup_analogs_enabled", False)
|
||||
)
|
||||
_dedup_saved = m.settings.estimate_dedup_analogs_enabled
|
||||
m.settings.estimate_dedup_analogs_enabled = _dedup_capture
|
||||
try:
|
||||
for rec in deals:
|
||||
kw = dict(rec["kwargs"])
|
||||
sold_ppm2_all.append(float(rec["sold_ppm2"]))
|
||||
|
|
@ -2066,9 +2045,6 @@ def replay_fixture(fixture: dict[str, Any], *, pi_report: bool = False) -> dict[
|
|||
predictions.append(prediction)
|
||||
pred_ppm2_all.append(prediction.median_ppm2)
|
||||
|
||||
finally:
|
||||
m.settings.estimate_dedup_analogs_enabled = _dedup_saved
|
||||
|
||||
# The fixture stores ONLY priced deals, so n_no_prediction is 0 here.
|
||||
metrics = _compute_full_metrics(predictions, n_no_prediction=0, pi_report=pi_report)
|
||||
|
||||
|
|
@ -2123,7 +2099,6 @@ def _predict_full_spine(
|
|||
whether the house IMV anchor actually resolved a row for this deal.
|
||||
"""
|
||||
m = est.m
|
||||
settings = est.settings
|
||||
|
||||
listings, analog_tier, fallback_used, area_widened = _select_analogs_full(
|
||||
db, deal, est, target_house_id=target_house_id
|
||||
|
|
@ -2212,7 +2187,7 @@ def _predict_full_spine(
|
|||
res = m._lookup_quarter_index(
|
||||
db,
|
||||
quarter_cad_number=q,
|
||||
min_n_deals=settings.estimate_quarter_index_min_n_deals,
|
||||
min_n_deals=m.QUARTER_INDEX_MIN_N_DEALS,
|
||||
)
|
||||
if capture is not None:
|
||||
ret = _sanitize_json(list(res) if res is not None else None)
|
||||
|
|
@ -2223,7 +2198,7 @@ def _predict_full_spine(
|
|||
res = m._lookup_quarter_indexes(
|
||||
db,
|
||||
quarter_cad_numbers=qs,
|
||||
min_n_deals=settings.estimate_quarter_index_min_n_deals,
|
||||
min_n_deals=m.QUARTER_INDEX_MIN_N_DEALS,
|
||||
)
|
||||
if capture is not None:
|
||||
qis_calls.append([_sanitize_json(list(qs)), _sanitize_json(dict(res))])
|
||||
|
|
@ -2658,7 +2633,9 @@ def _write_fixture(
|
|||
) -> None:
|
||||
"""Freeze captured per-deal replay records into a committed JSON fixture.
|
||||
|
||||
``settings_at_capture`` records every ``estimate_*`` Settings field as an
|
||||
``settings_at_capture`` records every ``estimate_*`` Settings field plus the
|
||||
estimator's numeric module constants (#1970: калибровочные пороги переехали из
|
||||
Settings в код — без них снимок не говорит, с какими порогами захватили) as an
|
||||
informational snapshot (NOT consulted by ``replay_fixture``). A final
|
||||
recursive ``_sanitize_json`` pass guarantees the whole document is finite +
|
||||
JSON-plain before ``json.dump(allow_nan=False)``.
|
||||
|
|
@ -2668,6 +2645,11 @@ def _write_fixture(
|
|||
for name in sorted(type(est.settings).model_fields)
|
||||
if name.startswith("estimate_")
|
||||
}
|
||||
settings_at_capture.update(
|
||||
(name, _sanitize_json(value))
|
||||
for name, value in sorted(vars(est.m).items())
|
||||
if name.isupper() and isinstance(value, int | float)
|
||||
)
|
||||
fixture = {
|
||||
"schema_version": FIXTURE_SCHEMA_VERSION,
|
||||
"engine": "full",
|
||||
|
|
|
|||
77
tradein-mvp/backend/tests/test_1970_estimator_constants.py
Normal file
77
tradein-mvp/backend/tests/test_1970_estimator_constants.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""#1970: калибровочные константы движка, вынесенные из Settings, — значения.
|
||||
|
||||
Поля estimate_* жили в Settings и формально переопределялись через ENV. При
|
||||
переносе в константы estimator.py значения обязаны остаться боевыми. Числа ниже
|
||||
сняты с прода 17.09.2026 (`settings.<поле>` в tradein-backend, tradein-scraper и
|
||||
tradein-tgbot — совпали, ENV-оверрайдов нет), а не скопированы из кода.
|
||||
|
||||
Зачем отдельный тест: замороженная фикстура бэктеста часть механизмов не
|
||||
проходит вовсе (квартальный индекс в реплее применяется 0 раз из 1600 сделок),
|
||||
а поведенческие тесты ловят не все пороги. Поменять число — осознанно: здесь,
|
||||
в estimator.py и с прогоном регрессионного гейта.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.services import estimator
|
||||
|
||||
# имя константы в estimator.py → (бывшее поле Settings, боевое значение 17.09.2026)
|
||||
_PROD_VALUES: dict[str, tuple[str, float]] = {
|
||||
# #2379, #764 квартальный индекс
|
||||
"QUARTER_INDEX_MIN_N_DEALS": ("estimate_quarter_index_min_n_deals", 10),
|
||||
"QUARTER_MATCH_SKIP_RATIO": ("estimate_quarter_match_skip_ratio", 0.6),
|
||||
"QUARTER_INDEX_MAX_FOR_SMALL_N": ("estimate_quarter_index_max_for_small_n", 2.0),
|
||||
"QUARTER_INDEX_SMALL_N_THRESHOLD": ("estimate_quarter_index_small_n_threshold", 50),
|
||||
"QUARTER_INDEX_FACTOR_MIN": ("estimate_quarter_index_factor_min", 0.6),
|
||||
"QUARTER_INDEX_FACTOR_MAX": ("estimate_quarter_index_factor_max", 1.8),
|
||||
# #2380, #1795 anti-inflation (CORRIDOR_CLAMP_MIN_N определён в app.core.config)
|
||||
"CORRIDOR_CLAMP_MIN_N": ("estimate_corridor_clamp_min_n", 10),
|
||||
"CORRIDOR_CLAMP_SLACK": ("estimate_corridor_clamp_slack", 0.40),
|
||||
"RADIUS_FLOOR_FACTOR": ("estimate_radius_floor_factor", 0.8),
|
||||
"OUTLIER_SMALL_N_THRESHOLD": ("estimate_outlier_small_n_threshold", 15),
|
||||
"OUTLIER_TUKEY_K_SMALL": ("estimate_outlier_tukey_k_small", 1.0),
|
||||
# #2381, #651/#652 same-building anchor + IMV-blend
|
||||
"IMV_BLEND_WEIGHT": ("estimate_imv_blend_weight", 0.5),
|
||||
"IMV_BLEND_THRESHOLD": ("estimate_imv_blend_threshold", 1.15),
|
||||
"SB_MIN_COMPS": ("estimate_sb_min_comps", 4),
|
||||
"SB_AREA_SIGMA": ("estimate_sb_area_sigma", 0.18),
|
||||
"SB_ROOMS_MATCH_BOOST": ("estimate_sb_rooms_match_boost", 1.6),
|
||||
"SB_FLOOR_SIGMA": ("estimate_sb_floor_sigma", 0.25),
|
||||
"SB_GUARDRAIL_TOL": ("estimate_sb_guardrail_tol", 0.05),
|
||||
"SB_MAD_K": ("estimate_sb_mad_k", 3.5),
|
||||
"SB_MAD_K_SMALL_N": ("estimate_sb_mad_k_small_n", 2.5),
|
||||
"SB_SMALL_N_THRESHOLD": ("estimate_sb_small_n_threshold", 10),
|
||||
"ANCHOR_TIER_C_CORRIDOR_MULT": ("estimate_anchor_tier_c_corridor_mult", 1.5),
|
||||
"FSD_K": ("estimate_fsd_k", 1.65),
|
||||
"SB_GATE_MIN_N": ("estimate_sb_gate_min_n", 3),
|
||||
"SB_GATE_MAX_FSD": ("estimate_sb_gate_max_fsd", 0.20),
|
||||
}
|
||||
|
||||
# Булевы выключатели, схлопнутые к боевому True: их OFF-ветки из кода удалены.
|
||||
_REMOVED_SWITCHES = (
|
||||
"estimate_dedup_analogs_enabled", # #2378
|
||||
"estimate_expected_sold_le_asking", # #2380
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("const", sorted(_PROD_VALUES))
|
||||
def test_constant_keeps_prod_value(const: str) -> None:
|
||||
assert getattr(estimator, const) == _PROD_VALUES[const][1]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("const", sorted(_PROD_VALUES))
|
||||
def test_settings_field_is_gone(const: str) -> None:
|
||||
# Поле вернулось в Settings → снова два источника одного числа (ENV и код).
|
||||
assert _PROD_VALUES[const][0] not in Settings.model_fields
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", _REMOVED_SWITCHES)
|
||||
def test_switch_is_gone(field: str) -> None:
|
||||
assert field not in Settings.model_fields
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
Два порога на одну выборку. Показ коридора открывается с трёх сделок
|
||||
(`DKP_CORRIDOR_CITY_WIDE_MIN_N`, ниже — city-wide widen), а обе ценовые страховки
|
||||
по коридору — soft-кламп headline сверху и radius-floor снизу — гейтятся
|
||||
`estimate_corridor_clamp_min_n` (10). Между ними лежит зона, где коридор
|
||||
`CORRIDOR_CLAMP_MIN_N` (10). Между ними лежит зона, где коридор
|
||||
существует, показывается и участвует в fallback-путях, а цену не держит; на
|
||||
экране это неотличимо от работающего коридора. PR #3445 (снятие предиката
|
||||
`d.rooms`) переносит в эту зону реальных клиентов: замерено 248 → 3 и 72 → 8
|
||||
|
|
@ -34,8 +34,8 @@ import pytest
|
|||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.estimator import DKP_CORRIDOR_CITY_WIDE_MIN_N
|
||||
from app.core.config import CORRIDOR_CLAMP_MIN_N
|
||||
from app.services.estimator import CORRIDOR_CLAMP_SLACK, DKP_CORRIDOR_CITY_WIDE_MIN_N
|
||||
|
||||
# Медиана аналогов заведомо выше потолка коридора × (1 + slack) — кламп, если он
|
||||
# включён порогом, ОБЯЗАН сработать и прижать headline к cap.
|
||||
|
|
@ -46,7 +46,7 @@ _MARKER = "corridor_advisory_zone"
|
|||
|
||||
def _cap_ppm2() -> float:
|
||||
"""Потолок клампа: corridor_high × (1 + slack) — см. _apply_corridor_clamp."""
|
||||
return _CORRIDOR_HIGH_PPM2 * (1.0 + settings.estimate_corridor_clamp_slack)
|
||||
return _CORRIDOR_HIGH_PPM2 * (1.0 + CORRIDOR_CLAMP_SLACK)
|
||||
|
||||
|
||||
def _make_listing(*, price_per_m2: float, area_m2: float = 50.0) -> dict[str, Any]:
|
||||
|
|
@ -157,7 +157,7 @@ def test_zone_exists_at_all() -> None:
|
|||
Если пороги когда-нибудь сведут в один, этот тест скажет об этом прямо,
|
||||
а не оставит два теста ниже молча проверять пустое множество.
|
||||
"""
|
||||
assert settings.estimate_corridor_clamp_min_n > DKP_CORRIDOR_CITY_WIDE_MIN_N, (
|
||||
assert CORRIDOR_CLAMP_MIN_N > DKP_CORRIDOR_CITY_WIDE_MIN_N, (
|
||||
"зона 3..9 схлопнулась — пороги показа и клампа сравнялись, "
|
||||
"advisory-only решение #3452 больше не описывает реальность"
|
||||
)
|
||||
|
|
@ -167,15 +167,14 @@ def test_corridor_in_zone_is_flagged_and_does_not_clamp(
|
|||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""n на единицу ниже порога: метка стоит, headline НЕ прижат, лог написан."""
|
||||
n = settings.estimate_corridor_clamp_min_n - 1
|
||||
n = CORRIDOR_CLAMP_MIN_N - 1
|
||||
with caplog.at_level(logging.INFO, logger="app.services.estimator"):
|
||||
est = _run_estimate(_corridor(n))
|
||||
|
||||
assert est.dkp_corridor is not None, "коридор обязан остаться видимым — порог показа ниже"
|
||||
assert est.dkp_corridor.count == n
|
||||
assert est.dkp_corridor.advisory_only is True, (
|
||||
f"n={n} < порога {settings.estimate_corridor_clamp_min_n} — коридор справочный, "
|
||||
"ответ обязан это называть"
|
||||
f"n={n} < порога {CORRIDOR_CLAMP_MIN_N} — коридор справочный, ответ обязан это называть"
|
||||
)
|
||||
# ФАКТ, а не только метка: кламп прижал бы headline к cap, но порог ему не дал.
|
||||
assert est.median_price_per_m2 > _cap_ppm2(), (
|
||||
|
|
@ -185,7 +184,7 @@ def test_corridor_in_zone_is_flagged_and_does_not_clamp(
|
|||
hits = [r for r in caplog.records if _MARKER in r.getMessage()]
|
||||
assert len(hits) == 1, f"ожидалась ровно одна строка {_MARKER}, получено {len(hits)}"
|
||||
msg = hits[0].getMessage()
|
||||
assert f"n={n}" in msg and f"min_n={settings.estimate_corridor_clamp_min_n}" in msg, msg
|
||||
assert f"n={n}" in msg and f"min_n={CORRIDOR_CLAMP_MIN_N}" in msg, msg
|
||||
assert "scope=street" in msg, msg
|
||||
|
||||
|
||||
|
|
@ -193,13 +192,13 @@ def test_corridor_above_threshold_is_not_flagged_and_clamps(
|
|||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""n выше порога: метки нет, headline прижат к потолку коридора, лога нет."""
|
||||
n = settings.estimate_corridor_clamp_min_n + 2
|
||||
n = CORRIDOR_CLAMP_MIN_N + 2
|
||||
with caplog.at_level(logging.INFO, logger="app.services.estimator"):
|
||||
est = _run_estimate(_corridor(n))
|
||||
|
||||
assert est.dkp_corridor is not None
|
||||
assert est.dkp_corridor.advisory_only is False, (
|
||||
f"n={n} >= порога {settings.estimate_corridor_clamp_min_n} — коридор в цене участвует, "
|
||||
f"n={n} >= порога {CORRIDOR_CLAMP_MIN_N} — коридор в цене участвует, "
|
||||
"справочным его называть нельзя"
|
||||
)
|
||||
assert est.median_price_per_m2 <= round(_cap_ppm2()), (
|
||||
|
|
|
|||
|
|
@ -297,3 +297,17 @@ def test_load_fixture_plain_and_gzip_roundtrip() -> None:
|
|||
m_plain = bt.replay_fixture(loaded_plain)
|
||||
m_gz = bt.replay_fixture(loaded_gz)
|
||||
assert json.dumps(m_plain, sort_keys=True) == json.dumps(m_gz, sort_keys=True)
|
||||
|
||||
|
||||
def test_write_fixture_records_engine_constants(tmp_path: Path) -> None:
|
||||
"""#1970: пороги движка — константы estimator.py, снимок фикстуры обязан их нести."""
|
||||
est = bt._import_estimator_full()
|
||||
out = tmp_path / "fixture.json"
|
||||
bt._write_fixture(str(out), capture=[], since="2026-09-01", est=est)
|
||||
snap = json.loads(out.read_text(encoding="utf-8"))["settings_at_capture"]
|
||||
assert {
|
||||
k: snap.get(k)
|
||||
for k in ("CORRIDOR_CLAMP_MIN_N", "CORRIDOR_CLAMP_SLACK", "QUARTER_INDEX_MIN_N_DEALS")
|
||||
} == {"CORRIDOR_CLAMP_MIN_N": 10, "CORRIDOR_CLAMP_SLACK": 0.4, "QUARTER_INDEX_MIN_N_DEALS": 10}
|
||||
assert snap.get("SB_ROOMS_MATCH_BOOST") == 1.6
|
||||
assert "estimate_pi_low_mult" in snap # поля Settings по-прежнему в снимке
|
||||
|
|
|
|||
|
|
@ -25,11 +25,8 @@ import math
|
|||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.services import estimator
|
||||
from scripts.backtest_estimator import load_fixture, replay_fixture
|
||||
|
||||
_FIXTURES = Path(__file__).parent / "fixtures"
|
||||
|
|
@ -76,16 +73,7 @@ def test_fixture_and_baseline_committed() -> None:
|
|||
assert _BASELINE_PATH.exists(), f"frozen baseline missing: {_BASELINE_PATH}"
|
||||
|
||||
|
||||
def test_backtest_regression_gate(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# The frozen fixture records the injected-callback control flow captured with
|
||||
# cross-source physical dedup OFF (#2087 H4 was a no-op default at capture time).
|
||||
# Dedup is now default ON (#2173), but this gate is a byte-identical REPLAY of a
|
||||
# frozen OFF capture — with dedup active _dedup_cross_source would trim listings
|
||||
# before quarter_indexes_lookup and the recorded call sequence would diverge. Pin
|
||||
# the flag OFF so the replay follows the captured control flow. (Accuracy-neutral:
|
||||
# backtest #1966 OFF vs ON is identical; dedup only trims user-visible n_analogs,
|
||||
# so the OFF baseline stays the valid spine regression reference.)
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", False)
|
||||
def test_backtest_regression_gate() -> None:
|
||||
fixture = load_fixture(_FIXTURE_PATH)
|
||||
baseline = json.loads(_BASELINE_PATH.read_text(encoding="utf-8"))
|
||||
# Round-trip the replay output through JSON before comparing: the committed
|
||||
|
|
|
|||
|
|
@ -172,7 +172,6 @@ def test_non_outlier_cross_source_duplicate_collapses_in_cards_only() -> None:
|
|||
|
||||
# sanity: стат-дедуп (price_bucket-строгий) ДЕЙСТВИТЕЛЬНО пропускает пару —
|
||||
# иначе тест проверял бы не то, что заявлено в docstring.
|
||||
assert estimator.settings.estimate_dedup_analogs_enabled is True
|
||||
deduped_upstream = estimator._dedup_cross_source([dup_a, dup_b])
|
||||
assert len(deduped_upstream) == 2, "price_bucket dedup ошибочно поймал дрейф — тест не то мерит"
|
||||
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ _DUP_LOT_DOMKLIK = _comp(
|
|||
|
||||
# 3 доп. уникальных компла того же дома/подъезда — ppm2 близки к дубль-лоту
|
||||
# (119k-125k), MAD-clip (k=3.5) их не тронет; вместе с дубль-лотом дают
|
||||
# estimate_sb_min_comps=4 unique комплов ПОСЛЕ дедупа → якорь срабатывает.
|
||||
# SB_MIN_COMPS=4 unique комплов ПОСЛЕ дедупа → якорь срабатывает.
|
||||
_OTHER_COMPS = [
|
||||
_comp(
|
||||
source="avito", address="улица Сыромолотова, 13", area=40.0, price_rub=5_000_000, floor=5
|
||||
|
|
@ -132,10 +132,10 @@ def test_anchor_path_dedups_cross_source_duplicate_lot(monkeypatch: pytest.Monke
|
|||
После фикса: дедуп схлопывает пару в 1 представителя ДО _compute_same_building_
|
||||
anchor → est.analogs содержит физлот ровно ОДИН раз (n=1).
|
||||
"""
|
||||
from app.core.config import settings
|
||||
from app.services import estimator
|
||||
|
||||
with monkeypatch.context() as m:
|
||||
m.setattr(settings, "estimate_sb_min_comps", 1)
|
||||
m.setattr(estimator, "SB_MIN_COMPS", 1)
|
||||
est = _h._run_estimate(anchor_comps=_MINIMAL_DUP_PAIR, anchor_tier="A", payload=_payload())
|
||||
|
||||
assert len(est.analogs) == 1 # НЕ 2 — дубль-лот схлопнут в 1 представителя
|
||||
|
|
@ -151,10 +151,10 @@ def test_anchor_path_n_analogs_matches_shown_count(monkeypatch: pytest.MonkeyPat
|
|||
(постдедуп) analogs, а не сырой недедупленный anchor-пул. До фикса n_analogs=2
|
||||
при одном реальном физлоте (дубль посчитан дважды) — самосогласованно, но
|
||||
нечестно. После фикса n_analogs=1, синхронно с analogs."""
|
||||
from app.core.config import settings
|
||||
from app.services import estimator
|
||||
|
||||
with monkeypatch.context() as m:
|
||||
m.setattr(settings, "estimate_sb_min_comps", 1)
|
||||
m.setattr(estimator, "SB_MIN_COMPS", 1)
|
||||
est = _h._run_estimate(anchor_comps=_MINIMAL_DUP_PAIR, anchor_tier="A", payload=_payload())
|
||||
|
||||
assert est.n_analogs == len(est.analogs) == 1
|
||||
|
|
|
|||
|
|
@ -101,19 +101,16 @@ def test_fix1_thin_n_high_fsd_triggers_gate() -> None:
|
|||
)
|
||||
if anchor is None:
|
||||
pytest.skip("MAD-clip отсёк — нет якоря, тест не применим")
|
||||
from app.core.config import settings
|
||||
from app.services.estimator import SB_GATE_MAX_FSD, SB_GATE_MIN_N
|
||||
|
||||
# Проверяем условие gate_thin напрямую
|
||||
gate_thin = (
|
||||
anchor["n"] < settings.estimate_sb_gate_min_n
|
||||
and anchor["fsd"] > settings.estimate_sb_gate_max_fsd
|
||||
)
|
||||
gate_thin = anchor["n"] < SB_GATE_MIN_N and anchor["fsd"] > SB_GATE_MAX_FSD
|
||||
# n=2 < 3 — должен сработать если FSD тоже высокий
|
||||
assert anchor["n"] == 2
|
||||
# FSD = 0.07 + 0.25*CV + tier_penalty + n_penalty; n_penalty=0.05 при n<3;
|
||||
# tier_penalty=0.05 (C); CV = std/mean для 2 элементов
|
||||
# Ожидаем что n=2 с умеренным разбросом даёт FSD ≈ 0.07+...≥0.20
|
||||
if anchor["fsd"] > settings.estimate_sb_gate_max_fsd:
|
||||
if anchor["fsd"] > SB_GATE_MAX_FSD:
|
||||
assert gate_thin, "gate_thin должен быть True при n=2 и high FSD"
|
||||
|
||||
|
||||
|
|
@ -308,8 +305,6 @@ def test_fix4_premium_comp_survives_post_weight_clip() -> None:
|
|||
comps = [_make_comp(base) for _ in range(4)] + [_make_comp(base * 1.3)]
|
||||
|
||||
with patch("app.services.estimator.settings") as mock_settings:
|
||||
mock_settings.estimate_sb_mad_k_small_n = 2.5
|
||||
mock_settings.estimate_sb_small_n_threshold = 10
|
||||
mock_settings.avito_imv_thin_market_threshold = 10
|
||||
mock_settings.sber_index_max_age_days = 35
|
||||
|
||||
|
|
|
|||
|
|
@ -313,7 +313,7 @@ _EKB_LENINA_LISTING = {
|
|||
|
||||
|
||||
def _ekb_lenina_pool(prices_per_m2: list[float]) -> list[dict[str, Any]]:
|
||||
"""N distinct EKB "ул. Ленина" comps (>= estimate_sb_min_comps=4 needed for
|
||||
"""N distinct EKB "ул. Ленина" comps (>= SB_MIN_COMPS=4 needed for
|
||||
Tier A to actually FIRE — see _fetch_anchor_comps `len(comps) >= min_comps`).
|
||||
Distinct floor/source_id/price_rub per row so `_dedup_cross_source` (#2265,
|
||||
street+floor+area+price physical key) treats them as distinct units, not
|
||||
|
|
@ -407,7 +407,7 @@ def test_non_ekb_anchor_not_leaked_from_ekb_street_collision() -> None:
|
|||
"period_months": 12,
|
||||
}
|
||||
db = MagicMock()
|
||||
# >= estimate_sb_min_comps EKB comps — realistic (40 191 of ~40 200 active
|
||||
# >= SB_MIN_COMPS EKB comps — realistic (40 191 of ~40 200 active
|
||||
# listings are EKB) and necessary for Tier A to actually fire pre-fix.
|
||||
db.execute.side_effect = _fake_anchor_sql_execute(
|
||||
_ekb_lenina_pool([178_000.0, 186_000.0, 190_000.0, 184_000.0]),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Проверяет:
|
||||
- композиты физического ключа (_lot_dedup_components) — что образуют / не образуют;
|
||||
- _dedup_cross_source: кросс-source дубли схлопываются в 1 представителя
|
||||
(свежайший scraped_at), не-дубли не трогаются, флаг OFF = байт-идентично;
|
||||
(свежайший scraped_at), не-дубли не трогаются;
|
||||
- выбор представителя детерминирован (freshest scraped_at).
|
||||
|
||||
No DB / network: помощники оперируют plain-dict'ами. DATABASE_URL нужен только
|
||||
|
|
@ -111,45 +111,28 @@ def test_phys_key_area_bucket_tolerates_rounding() -> None:
|
|||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Флаг: default ON (#2173) vs явный OFF (byte-identical)
|
||||
# Дедуп без выключателя (#2378)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_dedup_default_is_on() -> None:
|
||||
# #2173: default флага теперь ON (в проде дедуп активен без ENV-override).
|
||||
# Без monkeypatch settings отражает config-default → дедуп схлопывает
|
||||
# кросс-пост-тройку одного физлота. Явно НЕ монкипатчим — проверяем именно
|
||||
# поведение по умолчанию.
|
||||
assert estimator.settings.estimate_dedup_analogs_enabled is True
|
||||
# #2378: выключателя больше нет — дедуп работает всегда и схлопывает
|
||||
# кросс-пост-тройку одного физлота.
|
||||
lots = [
|
||||
_lot(source="avito"),
|
||||
_lot(source="cian"),
|
||||
_lot(source="domklik"),
|
||||
]
|
||||
out = estimator._dedup_cross_source(lots)
|
||||
assert len(out) == 1 # дедуп активен по умолчанию
|
||||
|
||||
|
||||
def test_dedup_flag_off_is_noop(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# OFF-путь проверяем ЯВНЫМ override (не полагаясь на default, который теперь ON):
|
||||
# при флаге OFF проход байт-идентичен — возвращается тот же объект.
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", False)
|
||||
lots = [
|
||||
_lot(source="avito"),
|
||||
_lot(source="cian"),
|
||||
_lot(source="domklik"),
|
||||
]
|
||||
out = estimator._dedup_cross_source(lots)
|
||||
assert out is lots # тот же объект → нулевое влияние на выборку
|
||||
assert len(out) == 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# _dedup_cross_source — флаг ON
|
||||
# _dedup_cross_source
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_dedup_collapses_cross_source_triple(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
||||
def test_dedup_collapses_cross_source_triple() -> None:
|
||||
# Один физлот 67м²/21.995М на floor 4 — кросс-пост avito+cian+domklik.
|
||||
lots = [
|
||||
_lot(source="avito", scraped_at=datetime(2026, 6, 1, tzinfo=UTC)),
|
||||
|
|
@ -162,8 +145,7 @@ def test_dedup_collapses_cross_source_triple(monkeypatch: pytest.MonkeyPatch) ->
|
|||
assert out[0]["source"] == "cian"
|
||||
|
||||
|
||||
def test_dedup_keeps_distinct_units(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
||||
def test_dedup_keeps_distinct_units() -> None:
|
||||
# Разные физлоты: разный этаж / площадь / цена / здание → не дубли.
|
||||
lots = [
|
||||
_lot(source="avito", floor=4, area=67.0, price=21_995_000),
|
||||
|
|
@ -176,8 +158,7 @@ def test_dedup_keeps_distinct_units(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||
assert len(out) == 5 # ничего не схлопнулось
|
||||
|
||||
|
||||
def test_dedup_preserves_first_seen_order(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
||||
def test_dedup_preserves_first_seen_order() -> None:
|
||||
# relevance-порядок из _fetch_analogs должен сохраниться: группа занимает
|
||||
# позицию первого появления.
|
||||
lots = [
|
||||
|
|
@ -191,8 +172,7 @@ def test_dedup_preserves_first_seen_order(monkeypatch: pytest.MonkeyPatch) -> No
|
|||
assert [o["address"] for o in out] == ["ул. Мира 3", "ул. Ленина 5", "ул. Гагарина 9"]
|
||||
|
||||
|
||||
def test_dedup_lots_without_key_stay_unique(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
||||
def test_dedup_lots_without_key_stay_unique() -> None:
|
||||
# Нет площади → ключ None → не дедупим, даже если оба «пустые».
|
||||
lots = [
|
||||
_lot(source="avito", area=0),
|
||||
|
|
@ -225,10 +205,9 @@ def test_extract_street_token_strips_house_and_noise() -> None:
|
|||
assert _street_token(None) == ""
|
||||
|
||||
|
||||
def test_dedup_collapses_three_source_formats(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_dedup_collapses_three_source_formats() -> None:
|
||||
# Прод-дефект (оценка d5fc3841): один физлот 33.6 м²/этаж 3/4.0М показан
|
||||
# как 3 аналога — cadnum пуст у всех троих, адреса в трёх форматах.
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
||||
lots = [
|
||||
_lot(
|
||||
source="cian",
|
||||
|
|
@ -259,10 +238,9 @@ def test_dedup_collapses_three_source_formats(monkeypatch: pytest.MonkeyPatch) -
|
|||
assert len(out) == 1 # один физлот, не три
|
||||
|
||||
|
||||
def test_dedup_merges_cadnum_asymmetry_same_street(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_dedup_merges_cadnum_asymmetry_same_street() -> None:
|
||||
# У одного cadnum есть, у другого нет, адрес совпадает → сливаются по
|
||||
# street-композиту (#2265: union по любому совпавшему ключу).
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
||||
lots = [
|
||||
_lot(
|
||||
source="cian",
|
||||
|
|
@ -285,12 +263,9 @@ def test_dedup_merges_cadnum_asymmetry_same_street(monkeypatch: pytest.MonkeyPat
|
|||
assert len(out) == 1
|
||||
|
||||
|
||||
def test_dedup_distinct_cadnums_same_metrics_do_not_merge(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def test_dedup_distinct_cadnums_same_metrics_do_not_merge() -> None:
|
||||
# Две башни на одной улице с одинаковыми floor/area/price, но РАЗНЫМИ
|
||||
# кадастрами — авторитетный сигнал разных зданий → НЕ сливаем.
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
||||
lots = [
|
||||
_lot(
|
||||
source="cian",
|
||||
|
|
@ -313,12 +288,9 @@ def test_dedup_distinct_cadnums_same_metrics_do_not_merge(
|
|||
assert len(out) == 2
|
||||
|
||||
|
||||
def test_dedup_same_street_different_metrics_stay_distinct(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def test_dedup_same_street_different_metrics_stay_distinct() -> None:
|
||||
# Разные лоты на одной улице с разной ценой/площадью/этажом → остаются
|
||||
# разными (защита от over-merge при отсутствии cadnum).
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
||||
lots = [
|
||||
_lot(source="cian", address="улица Сыромолотова, 11", area=33.6, price=4_000_000, floor=3),
|
||||
_lot(source="avito", address="улица Сыромолотова, 11", area=45.0, price=6_500_000, floor=8),
|
||||
|
|
@ -350,9 +322,8 @@ def test_extract_street_token_numbered_not_degraded() -> None:
|
|||
assert _street_token("ул. Белинского 86") == "белинского"
|
||||
|
||||
|
||||
def test_dedup_collapses_numbered_street_three_formats(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_dedup_collapses_numbered_street_three_formats() -> None:
|
||||
# «8 Марта» в трёх source-форматах, cadnum пуст → один физлот.
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
||||
lots = [
|
||||
_lot(
|
||||
source="cian",
|
||||
|
|
@ -383,10 +354,9 @@ def test_dedup_collapses_numbered_street_three_formats(monkeypatch: pytest.Monke
|
|||
assert len(out) == 1
|
||||
|
||||
|
||||
def test_dedup_house_no_guard_blocks_street_union(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_dedup_house_no_guard_blocks_street_union() -> None:
|
||||
# Пустой cadnum, ОДНА улица/этаж/area/price, но РАЗНЫЕ извлечённые номера
|
||||
# дома → house-guard не даёт слить (both-present-different).
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
||||
lots = [
|
||||
_lot(source="cian", address="улица Сыромолотова, 11", area=33.6, price=4_000_000, floor=3),
|
||||
_lot(source="avito", address="улица Сыромолотова, 13", area=33.6, price=4_000_000, floor=3),
|
||||
|
|
@ -395,10 +365,9 @@ def test_dedup_house_no_guard_blocks_street_union(monkeypatch: pytest.MonkeyPatc
|
|||
assert len(out) == 2
|
||||
|
||||
|
||||
def test_dedup_house_no_guard_order_stable(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_dedup_house_no_guard_order_stable() -> None:
|
||||
# Устойчивость к порядку: домклик без номера сливается с одним из номеров,
|
||||
# два разных номера остаются раздельными независимо от позиции «пустого».
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
||||
base = dict(area=33.6, price=4_000_000, floor=3, cad=None)
|
||||
lots = [
|
||||
_lot(source="domklik", address="улица Сыромолотова", **base), # house ""
|
||||
|
|
@ -410,10 +379,9 @@ def test_dedup_house_no_guard_order_stable(monkeypatch: pytest.MonkeyPatch) -> N
|
|||
assert len(out) == 2
|
||||
|
||||
|
||||
def test_dedup_empty_cadnum_and_street_stay_separate(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_dedup_empty_cadnum_and_street_stay_separate() -> None:
|
||||
# Анти-катастрофа: пустой cadnum И нераспознанный адрес (нет типа улицы) при
|
||||
# идентичных метриках → НЕ сливаем (нет ни одного ключа → каждый уникален).
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
||||
lots = [
|
||||
_lot(source="cian", address="ЖК Дружба", area=40.0, price=5_000_000, floor=6, cad=None),
|
||||
_lot(source="avito", address="ЖК Дружба", area=40.0, price=5_000_000, floor=6, cad=None),
|
||||
|
|
@ -485,7 +453,7 @@ def test_extract_street_token_extended_same_token_across_formats() -> None:
|
|||
assert domklik == avito == cian == "рассветная"
|
||||
|
||||
|
||||
def test_dedup_collapses_domklik_cian_name_order(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_dedup_collapses_domklik_cian_name_order() -> None:
|
||||
# Живой пример несклейки (оценка 23b8ada1): domklik «Рассветная улица, 6 к1»
|
||||
# + cian «улица Рассветная, 6к1» — тот же физлот (эт.13, ~38.5 м², 6.599М),
|
||||
# cadnum пуст (domklik всегда NULL). Расширенный парсер даёт обоим токен
|
||||
|
|
@ -493,7 +461,6 @@ def test_dedup_collapses_domklik_cian_name_order(monkeypatch: pytest.MonkeyPatch
|
|||
# адреса используют одну и ту же глухую («к1») family корпуса, и после
|
||||
# #2436 нормализуются к одинаковой «6/1» — house-guard видит равные номера
|
||||
# независимо от пробела перед «к» (см. _normalize_house_no).
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
||||
lots = [
|
||||
_lot(
|
||||
source="domklik",
|
||||
|
|
@ -519,7 +486,7 @@ def test_dedup_collapses_domklik_cian_name_order(monkeypatch: pytest.MonkeyPatch
|
|||
assert out[0]["source"] == "cian" # представитель — свежайший scraped_at
|
||||
|
||||
|
||||
def test_dedup_collapses_domklik_cian_area_straddle(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_dedup_collapses_domklik_cian_area_straddle() -> None:
|
||||
# #2291 класс 3: тот же физлот «Рассветная 6к1», но area округляется в
|
||||
# СОСЕДНИЕ area_bucket банкирским round() — 38.5 → 38 (округление к чётному),
|
||||
# 39.0 → 39. До фикса разные area_bucket → street-owner lookup никогда не
|
||||
|
|
@ -527,7 +494,6 @@ def test_dedup_collapses_domklik_cian_area_straddle(monkeypatch: pytest.MonkeyPa
|
|||
# должен склеить их в один физлот. Оба адреса — глухая («к1») family
|
||||
# корпуса → после #2436 нормализуются к одинаковой «6/1» (см.
|
||||
# _normalize_house_no), house-guard не блокирует merge независимо от пробела.
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
||||
lots = [
|
||||
_lot(
|
||||
source="domklik",
|
||||
|
|
@ -552,11 +518,10 @@ def test_dedup_collapses_domklik_cian_area_straddle(monkeypatch: pytest.MonkeyPa
|
|||
assert len(out) == 1 # склеились несмотря на area straddle 38.5/39.0
|
||||
|
||||
|
||||
def test_dedup_collapses_avito_glued_suffix(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_dedup_collapses_avito_glued_suffix() -> None:
|
||||
# (б) avito с глухим суффиксом «Рассветная ул.,6к1» + cian «улица Рассветная,
|
||||
# 6к1» → тот же токен «рассветная», house у обоих канонизируется в «6/1»
|
||||
# (см. #2436 _normalize_house_no) → guard видит равные номера → склейка.
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
||||
lots = [
|
||||
_lot(source="avito", address="Рассветная ул.,6к1", area=38.5, price=6_599_000, floor=13),
|
||||
_lot(source="cian", address="улица Рассветная, 6к1", area=38.5, price=6_599_000, floor=13),
|
||||
|
|
@ -565,11 +530,10 @@ def test_dedup_collapses_avito_glued_suffix(monkeypatch: pytest.MonkeyPatch) ->
|
|||
assert len(out) == 1
|
||||
|
||||
|
||||
def test_dedup_mkr_fallback_does_not_cross_with_street(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_dedup_mkr_fallback_does_not_cross_with_street() -> None:
|
||||
# Префикс «mkr » изолирует микрорайонный токен от одноимённого уличного:
|
||||
# «Светлый микрорайон» (mkr) и «улица Светлая» (street) — РАЗНЫЕ ключи, не
|
||||
# сливаются даже при совпадении метрик.
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
||||
lots = [
|
||||
_lot(source="cian", address="Светлый микрорайон, 10", area=40.0, price=5_000_000, floor=6),
|
||||
_lot(source="avito", address="улица Светлая, 10", area=40.0, price=5_000_000, floor=6),
|
||||
|
|
@ -622,11 +586,10 @@ def test_parse_street_house_bare_street_requires_earlier_mkr_segment(addr: str)
|
|||
assert estimator._parse_street_house(addr) == ("", "")
|
||||
|
||||
|
||||
def test_dedup_collapses_domclick_bare_street_mkr_misfire(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_dedup_collapses_domclick_bare_street_mkr_misfire() -> None:
|
||||
# Класс 1 (#2291) e2e: DomClick «Заречный м-н, Готвальда, 24» раньше
|
||||
# ключевался по району и никогда не сливался с avito/cian, где «готвальда»
|
||||
# распознаётся явной веткой (тип-слово «улица»).
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
||||
lots = [
|
||||
_lot(
|
||||
source="domclick",
|
||||
|
|
@ -660,10 +623,9 @@ def test_parse_street_house_yo_ye_fold() -> None:
|
|||
)
|
||||
|
||||
|
||||
def test_dedup_collapses_yo_ye_variant(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_dedup_collapses_yo_ye_variant() -> None:
|
||||
# Класс 2 (#2291) e2e: один и тот же физлот в двух source-форматах —
|
||||
# «Шевелёва» (avito) и «Шевелева» (cian) — теперь схлопывается в один.
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
||||
lots = [
|
||||
_lot(source="avito", address="улица Шевелёва, 12", area=42.0, price=5_200_000, floor=4),
|
||||
_lot(source="cian", address="улица Шевелева, 12", area=42.0, price=5_200_000, floor=4),
|
||||
|
|
@ -753,13 +715,10 @@ def test_parse_street_house_ambiguous_glued_digit_runs_limitation() -> None:
|
|||
assert estimator._parse_street_house("ул. Ленина, 7500 м до метро") == ("ленина", "7500")
|
||||
|
||||
|
||||
def test_dedup_collapses_avito_glued_metro_suffix_house_match(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def test_dedup_collapses_avito_glued_metro_suffix_house_match() -> None:
|
||||
# e2e: avito-лот с приклеенным названием станции (дом «2» до фикса терялся,
|
||||
# извлекалось «15») + чистый cian-адрес того же дома «2» → house-guard
|
||||
# больше не блокирует street-union (оба извлекают «2») → один физлот.
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
||||
lots = [
|
||||
_lot(
|
||||
source="avito",
|
||||
|
|
@ -782,14 +741,11 @@ def test_dedup_collapses_avito_glued_metro_suffix_house_match(
|
|||
assert len(out) == 1
|
||||
|
||||
|
||||
def test_dedup_glued_metro_noise_does_not_cause_over_merge(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def test_dedup_glued_metro_noise_does_not_cause_over_merge() -> None:
|
||||
# Анти-регрессия: фикс не должен сделать guard_house снисходительнее для
|
||||
# адресов с глухим метро-шумом в общем случае — ДЕЙСТВИТЕЛЬНО разные дома
|
||||
# («24» vs «26»), каждый одет в приклеенное название станции разной длины,
|
||||
# ДОЛЖНЫ остаться раздельными (guard всё ещё блокирует both-present-different).
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
||||
lots = [
|
||||
_lot(
|
||||
source="avito",
|
||||
|
|
@ -863,15 +819,12 @@ def test_parse_street_house_all_corpus_notations_agree(notation: str) -> None:
|
|||
assert estimator._parse_street_house(f"ул. Ленина, {notation}") == ("ленина", "65/4")
|
||||
|
||||
|
||||
def test_dedup_collapses_avito_glued_vs_domklik_slash_corpus(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def test_dedup_collapses_avito_glued_vs_domklik_slash_corpus() -> None:
|
||||
# Точный репортированный баг: avito-подобный адрес («...65к4...», глухая
|
||||
# нотация) + domklik-подобный адрес («...65/4...», slash-нотация) — тот же
|
||||
# физлот, без кадастра ни у кого, тот же этаж/площадь/цена-бакет — ДОЛЖНЫ
|
||||
# схлопнуться в один. union() зависит от порядка (кто станет root) —
|
||||
# проверяем ОБА порядка следования лотов.
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
||||
|
||||
def _pair(first_source: str, second_source: str) -> list[dict]:
|
||||
by_source = {
|
||||
|
|
@ -903,14 +856,11 @@ def test_dedup_collapses_avito_glued_vs_domklik_slash_corpus(
|
|||
assert len(out_domklik_first) == 1
|
||||
|
||||
|
||||
def test_dedup_liter_suffix_still_blocks_merge_from_different_corpus(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def test_dedup_liter_suffix_still_blocks_merge_from_different_corpus() -> None:
|
||||
# Адверсариальный кейс: нормализация НЕ должна расширять сеть слияний за
|
||||
# пределы genuine corpus-notation эквивалентности. «65к4» (-> «65/4») и
|
||||
# «65г» (литер, не тронут) — РАЗНЫЕ дома на одной улице, слияния быть не
|
||||
# должно.
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
||||
lots = [
|
||||
_lot(
|
||||
source="avito",
|
||||
|
|
@ -933,13 +883,10 @@ def test_dedup_liter_suffix_still_blocks_merge_from_different_corpus(
|
|||
assert len(out) == 2
|
||||
|
||||
|
||||
def test_dedup_house_no_guard_blocks_different_corpus_numbers(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def test_dedup_house_no_guard_blocks_different_corpus_numbers() -> None:
|
||||
# Нормализация — не blanket same-street merge: «65/4» vs «65/5» (или
|
||||
# «65к4» vs «65к5») — genuinely разные здания, guard_house обязан
|
||||
# по-прежнему их разделять после нормализации.
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
|
||||
lots = [
|
||||
_lot(
|
||||
source="domklik",
|
||||
|
|
|
|||
|
|
@ -80,8 +80,6 @@ def _make_payload(rooms: int = 3):
|
|||
|
||||
def _run_estimate(
|
||||
ratio_tuple: tuple[float | None, str | None],
|
||||
*,
|
||||
clamp_enabled: bool = True,
|
||||
) -> Any:
|
||||
from app.services.estimator import estimate_quality
|
||||
|
||||
|
|
@ -90,7 +88,6 @@ def _run_estimate(
|
|||
|
||||
async def _run() -> Any:
|
||||
with (
|
||||
patch("app.core.config.settings.estimate_expected_sold_le_asking", new=clamp_enabled),
|
||||
# #2002: these tests assert the clamp/ratio math exactly. Hold the
|
||||
# orthogonal hedonic year+area correction OFF (OFF ⇒ legacy expected_sold).
|
||||
patch("app.core.config.settings.estimate_hedonic_correction_enabled", new=False),
|
||||
|
|
@ -131,7 +128,7 @@ def test_expected_sold_clamped_to_headline_when_ratio_above_1(
|
|||
) -> None:
|
||||
ratio = 1.15
|
||||
with caplog.at_level(logging.INFO, logger="app.services.estimator"):
|
||||
est = _run_estimate((ratio, "per_rooms"), clamp_enabled=True)
|
||||
est = _run_estimate((ratio, "per_rooms"))
|
||||
assert est.median_price_rub > 0, "headline должен быть задан аналогами"
|
||||
assert est.expected_sold_price_rub is not None
|
||||
assert est.expected_sold_price_rub <= est.median_price_rub, (
|
||||
|
|
@ -167,7 +164,7 @@ def test_expected_sold_clamped_to_headline_when_ratio_above_1(
|
|||
|
||||
def test_expected_sold_not_clamped_when_ratio_below_1() -> None:
|
||||
ratio = 0.8
|
||||
est = _run_estimate((ratio, "per_rooms"), clamp_enabled=True)
|
||||
est = _run_estimate((ratio, "per_rooms"))
|
||||
assert est.median_price_rub > 0
|
||||
assert est.expected_sold_price_rub == round(est.median_price_rub * ratio)
|
||||
assert est.expected_sold_per_m2 == round(est.median_price_per_m2 * ratio)
|
||||
|
|
@ -182,22 +179,10 @@ def test_expected_sold_not_clamped_when_ratio_below_1() -> None:
|
|||
|
||||
|
||||
def test_expected_sold_none_when_ratio_none() -> None:
|
||||
est = _run_estimate((None, None), clamp_enabled=True)
|
||||
est = _run_estimate((None, None))
|
||||
assert est.expected_sold_price_rub is None
|
||||
assert est.expected_sold_per_m2 is None
|
||||
assert est.expected_sold_range_low_rub is None
|
||||
assert est.expected_sold_range_high_rub is None
|
||||
assert est.asking_to_sold_ratio is None
|
||||
assert est.median_price_rub > 0
|
||||
|
||||
|
||||
def test_expected_sold_exceeds_asking_when_clamp_flag_off() -> None:
|
||||
ratio = 1.15
|
||||
est = _run_estimate((ratio, "per_rooms"), clamp_enabled=False)
|
||||
assert est.median_price_rub > 0
|
||||
assert est.expected_sold_price_rub is not None
|
||||
assert est.expected_sold_price_rub > est.median_price_rub, (
|
||||
f"Ожидалось expected_sold > asking при clamp=False, "
|
||||
f"но {est.expected_sold_price_rub} <= {est.median_price_rub}"
|
||||
)
|
||||
assert est.expected_sold_price_rub == round(est.median_price_rub * ratio)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ via ``_price_from_inputs`` (hermetic — no DB, no network). Verifies:
|
|||
* both clamp boundaries (≤ factor_min via huge area, ≥ factor_max via small+new);
|
||||
* the neutral year term when ``target_year`` is None (≡ year 2000);
|
||||
* the le_asking invariant — the corrected expected_sold never exceeds the asking
|
||||
headline (median) when ``estimate_expected_sold_le_asking`` is on.
|
||||
headline (median) — the expected_sold ≤ asking clamp has no switch (#2380).
|
||||
|
||||
NOTE: importing app.services.estimator pulls app.core.config.Settings which
|
||||
requires DATABASE_URL. Set it BEFORE importing app modules.
|
||||
|
|
@ -151,8 +151,9 @@ def test_factor_clamps_to_min_for_huge_area(monkeypatch: pytest.MonkeyPatch) ->
|
|||
def test_factor_clamps_to_max_for_small_new_lot(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Small area + new year → raw factor > factor_max → clamped to the ceiling.
|
||||
|
||||
le_asking is held OFF so the raw ceiling factor is observable on the point
|
||||
(otherwise the re-clamp would cap it at the asking headline).
|
||||
ratio 0.70 keeps 0.70 × factor_max (1.30) = 0.91 < 1.0, so the always-on
|
||||
expected_sold ≤ asking re-clamp does not bind and the raw ceiling factor is
|
||||
observable on the point (#2380: the clamp no longer has an OFF switch).
|
||||
|
||||
#3248: коэффициенты задаются явно — тест про МЕХАНИЗМ потолка, а не про
|
||||
текущую подгонку (после перефита b0 = -0.0140 и потолка сам по себе не
|
||||
|
|
@ -161,11 +162,10 @@ def test_factor_clamps_to_max_for_small_new_lot(monkeypatch: pytest.MonkeyPatch)
|
|||
monkeypatch.setattr(estimator.settings, "estimate_hedonic_b0", 0.6146)
|
||||
monkeypatch.setattr(estimator.settings, "estimate_hedonic_larea_coef", -0.1603)
|
||||
monkeypatch.setattr(estimator.settings, "estimate_hedonic_year_coef", 0.1220)
|
||||
monkeypatch.setattr(estimator.settings, "estimate_expected_sold_le_asking", False)
|
||||
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", False)
|
||||
off = _price(area_m2=15.0, target_year=2025, ratio=0.85)
|
||||
off = _price(area_m2=15.0, target_year=2025, ratio=0.70)
|
||||
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True)
|
||||
on = _price(area_m2=15.0, target_year=2025, ratio=0.85)
|
||||
on = _price(area_m2=15.0, target_year=2025, ratio=0.70)
|
||||
|
||||
factor = _expected_factor(15.0, 2025)
|
||||
assert factor == estimator.settings.estimate_hedonic_factor_max
|
||||
|
|
@ -187,9 +187,8 @@ def test_target_year_none_is_neutral(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||
|
||||
|
||||
def test_le_asking_invariant_holds_under_hedonic(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""With le_asking on, the hedonic-corrected expected_sold never exceeds asking."""
|
||||
"""The hedonic-corrected expected_sold never exceeds asking."""
|
||||
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True)
|
||||
monkeypatch.setattr(estimator.settings, "estimate_expected_sold_le_asking", True)
|
||||
# small area + new year → factor 1.30; ratio 0.95 → 0.95×1.30 ≈ 1.235 > 1 →
|
||||
# uncorrected the point would exceed the asking headline; the re-clamp must bind.
|
||||
res = _price(area_m2=15.0, target_year=2025, ratio=0.95)
|
||||
|
|
@ -202,17 +201,6 @@ def test_le_asking_invariant_holds_under_hedonic(monkeypatch: pytest.MonkeyPatch
|
|||
assert res.expected_sold_price == res.median_price
|
||||
|
||||
|
||||
def test_le_asking_off_allows_hedonic_above_asking(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Control: with le_asking OFF, the hedonic uplift may exceed asking (no clamp)."""
|
||||
monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True)
|
||||
monkeypatch.setattr(estimator.settings, "estimate_expected_sold_le_asking", False)
|
||||
res = _price(area_m2=15.0, target_year=2025, ratio=0.95)
|
||||
|
||||
assert res.expected_sold_price is not None
|
||||
# 0.95 × 1.30 ≈ 1.235 → point is allowed above the asking headline.
|
||||
assert res.expected_sold_price > res.median_price
|
||||
|
||||
|
||||
def test_ground_floor_applies_extra_discount(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""floor==1 → дополнительная скидка exp(first_floor_coef) против floor=3.
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||
|
||||
|
|
@ -33,20 +32,6 @@ ELITE_REASON = (
|
|||
)
|
||||
|
||||
|
||||
def _settings(
|
||||
*,
|
||||
high_value: int = 20_000_000,
|
||||
wide_ratio: float = 1.9,
|
||||
elite_ppm2: int = 250_000,
|
||||
) -> SimpleNamespace:
|
||||
"""Duck-typed заглушка Settings — helper читает только эти поля."""
|
||||
return SimpleNamespace(
|
||||
estimate_manual_review_high_value_rub=high_value,
|
||||
estimate_manual_review_wide_range_ratio=wide_ratio,
|
||||
estimate_manual_review_elite_ppm2=elite_ppm2,
|
||||
)
|
||||
|
||||
|
||||
# ── Каждый триггер в изоляции ────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -57,7 +42,6 @@ def test_premium_building_alone() -> None:
|
|||
confidence="high",
|
||||
range_low=7_500_000,
|
||||
range_high=8_500_000,
|
||||
settings=_settings(),
|
||||
)
|
||||
assert rec is True
|
||||
assert reasons == [PREMIUM_REASON]
|
||||
|
|
@ -70,7 +54,6 @@ def test_high_value_alone() -> None:
|
|||
confidence="high",
|
||||
range_low=24_000_000,
|
||||
range_high=26_000_000,
|
||||
settings=_settings(),
|
||||
)
|
||||
assert rec is True
|
||||
assert reasons == [HIGH_VALUE_REASON]
|
||||
|
|
@ -83,7 +66,6 @@ def test_low_confidence_alone() -> None:
|
|||
confidence="low",
|
||||
range_low=4_500_000,
|
||||
range_high=5_500_000,
|
||||
settings=_settings(),
|
||||
)
|
||||
assert rec is True
|
||||
assert reasons == [LOW_CONF_REASON]
|
||||
|
|
@ -96,7 +78,6 @@ def test_wide_range_alone() -> None:
|
|||
confidence="high",
|
||||
range_low=5_000_000,
|
||||
range_high=10_000_000, # ratio 2.0 ≥ 1.9
|
||||
settings=_settings(),
|
||||
)
|
||||
assert rec is True
|
||||
assert reasons == [WIDE_RANGE_REASON]
|
||||
|
|
@ -114,7 +95,6 @@ def test_elite_ppm2_alone() -> None:
|
|||
confidence="high",
|
||||
range_low=11_000_000,
|
||||
range_high=13_000_000, # ratio ~1.18 < 1.9
|
||||
settings=_settings(),
|
||||
asking_ppm2=300_000, # ≥ 250_000
|
||||
)
|
||||
assert rec is True
|
||||
|
|
@ -129,7 +109,6 @@ def test_elite_ppm2_boundary_inclusive() -> None:
|
|||
confidence="high",
|
||||
range_low=11_000_000,
|
||||
range_high=13_000_000,
|
||||
settings=_settings(),
|
||||
asking_ppm2=250_000,
|
||||
)
|
||||
assert rec is True
|
||||
|
|
@ -144,7 +123,6 @@ def test_elite_ppm2_just_below_threshold() -> None:
|
|||
confidence="high",
|
||||
range_low=11_000_000,
|
||||
range_high=13_000_000,
|
||||
settings=_settings(),
|
||||
asking_ppm2=249_999,
|
||||
)
|
||||
assert rec is False
|
||||
|
|
@ -159,7 +137,6 @@ def test_elite_ppm2_none_safe() -> None:
|
|||
confidence="high",
|
||||
range_low=11_000_000,
|
||||
range_high=13_000_000,
|
||||
settings=_settings(),
|
||||
asking_ppm2=None,
|
||||
)
|
||||
assert rec is False
|
||||
|
|
@ -175,7 +152,6 @@ def test_elite_ppm2_zero_safe() -> None:
|
|||
confidence="high",
|
||||
range_low=11_000_000,
|
||||
range_high=13_000_000,
|
||||
settings=_settings(),
|
||||
asking_ppm2=0,
|
||||
)
|
||||
assert rec is False
|
||||
|
|
@ -190,7 +166,6 @@ def test_elite_ppm2_combined_with_other_triggers() -> None:
|
|||
confidence="low",
|
||||
range_low=10_000_000,
|
||||
range_high=25_000_000, # ratio 2.5 ≥ 1.9
|
||||
settings=_settings(),
|
||||
asking_ppm2=400_000,
|
||||
)
|
||||
assert rec is True
|
||||
|
|
@ -214,7 +189,6 @@ def test_multiple_reasons_combined() -> None:
|
|||
confidence="low",
|
||||
range_low=10_000_000,
|
||||
range_high=25_000_000, # ratio 2.5 ≥ 1.9
|
||||
settings=_settings(),
|
||||
)
|
||||
assert rec is True
|
||||
# порядок детерминирован: premium → high_value → low_conf → wide_range
|
||||
|
|
@ -228,7 +202,6 @@ def test_all_clear_no_reasons() -> None:
|
|||
confidence="medium",
|
||||
range_low=9_000_000,
|
||||
range_high=11_000_000, # ratio ~1.22 < 1.9
|
||||
settings=_settings(),
|
||||
)
|
||||
assert rec is False
|
||||
assert reasons == []
|
||||
|
|
@ -245,7 +218,6 @@ def test_high_value_boundary_inclusive() -> None:
|
|||
confidence="medium",
|
||||
range_low=19_000_000,
|
||||
range_high=21_000_000,
|
||||
settings=_settings(),
|
||||
)
|
||||
assert rec is True
|
||||
assert reasons == [HIGH_VALUE_REASON]
|
||||
|
|
@ -258,7 +230,6 @@ def test_high_value_just_below_threshold() -> None:
|
|||
confidence="medium",
|
||||
range_low=19_000_000,
|
||||
range_high=21_000_000,
|
||||
settings=_settings(),
|
||||
)
|
||||
assert rec is False
|
||||
assert reasons == []
|
||||
|
|
@ -272,7 +243,6 @@ def test_wide_range_boundary_inclusive() -> None:
|
|||
confidence="high",
|
||||
range_low=10_000_000,
|
||||
range_high=19_000_000, # ratio == 1.9
|
||||
settings=_settings(),
|
||||
)
|
||||
assert rec is True
|
||||
assert reasons == [WIDE_RANGE_REASON]
|
||||
|
|
@ -285,7 +255,6 @@ def test_wide_range_just_below_threshold() -> None:
|
|||
confidence="high",
|
||||
range_low=10_000_000,
|
||||
range_high=18_900_000, # ratio 1.89 < 1.9
|
||||
settings=_settings(),
|
||||
)
|
||||
assert rec is False
|
||||
assert reasons == []
|
||||
|
|
@ -311,7 +280,6 @@ def test_wide_range_div_guard(range_low: int | None, range_high: int | None) ->
|
|||
confidence="high",
|
||||
range_low=range_low,
|
||||
range_high=range_high,
|
||||
settings=_settings(),
|
||||
)
|
||||
assert rec is False
|
||||
assert WIDE_RANGE_REASON not in reasons
|
||||
|
|
@ -332,7 +300,6 @@ def test_recommended_equals_bool_reasons_invariant() -> None:
|
|||
confidence=conf,
|
||||
range_low=9_000_000,
|
||||
range_high=11_000_000,
|
||||
settings=_settings(),
|
||||
)
|
||||
assert rec is bool(reasons)
|
||||
|
||||
|
|
|
|||
|
|
@ -26,12 +26,9 @@ from datetime import UTC, datetime
|
|||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
# Settings требует DATABASE_URL при инициализации (fail-fast, C-3).
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||
|
||||
from app.services import estimator
|
||||
|
||||
# Переиспользуем harness _run_estimate / _make_payload из anchor-теста (полный
|
||||
# estimate путь со всеми I/O застабленными). anchor_comps=[] + anchor_tier=None
|
||||
|
|
@ -43,16 +40,6 @@ _h = importlib.util.module_from_spec(_spec)
|
|||
_spec.loader.exec_module(_h)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _pin_dedup_off(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Эти тесты проверяют инвариант #2 «n_analogs = число priced-аналогов»,
|
||||
# ортогональный кросс-source физдедупу (#2087 H4, default ON с #2173).
|
||||
# Синтетические аналоги делят один адрес/этаж/площадь → дедуп схлопнул бы
|
||||
# близкие по цене как кросс-посты и исказил счётчик. Пиним флаг OFF, чтобы
|
||||
# мерить именно #2 в изоляции (не полагаясь на прод-default, который теперь ON).
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", False)
|
||||
|
||||
|
||||
def _listing(price_per_m2: float | None, area_m2: float = 60.0) -> dict[str, Any]:
|
||||
"""Радиусный аналог; price_per_m2=None моделирует листинг без ₽/м².
|
||||
|
||||
|
|
@ -103,8 +90,10 @@ def test_n_analogs_counts_only_priced_radius_analogs() -> None:
|
|||
_listing(price_per_m2=210_000.0),
|
||||
_listing(price_per_m2=215_000.0),
|
||||
_listing(price_per_m2=225_000.0),
|
||||
_listing(price_per_m2=None),
|
||||
_listing(price_per_m2=None),
|
||||
# Разная площадь → разные физлоты: кросс-source дедуп (#2087 H4, всегда ON
|
||||
# с #2378) не схлопывает два безценовых листинга в один.
|
||||
_listing(price_per_m2=None, area_m2=61.0),
|
||||
_listing(price_per_m2=None, area_m2=62.0),
|
||||
]
|
||||
est = _h._run_estimate(
|
||||
anchor_comps=[],
|
||||
|
|
@ -141,7 +130,8 @@ def test_n_analogs_all_priceless_yields_zero_and_low() -> None:
|
|||
def test_n_analogs_all_priced_unchanged() -> None:
|
||||
"""Контроль: все аналоги С ценой → n_analogs = их число (поведение не меняется,
|
||||
зеркало test_radius_path_n_analogs_unchanged для priced-only выборки)."""
|
||||
radius = [_listing(price_per_m2=200_000.0 + i * 1_000) for i in range(5)]
|
||||
# Разная площадь → 5 разных физлотов, кросс-source дедуп их не схлопывает.
|
||||
radius = [_listing(price_per_m2=200_000.0 + i * 1_000, area_m2=60.0 + i) for i in range(5)]
|
||||
est = _h._run_estimate(
|
||||
anchor_comps=[],
|
||||
anchor_tier=None,
|
||||
|
|
|
|||
|
|
@ -252,10 +252,10 @@ def test_anchor_tier_reset_when_anchor_not_built() -> None:
|
|||
|
||||
Третий путь к anchor=None (#2661), отдельный от Tier C гейта и low-conf гейта выше:
|
||||
когда ``_compute_same_building_anchor`` возвращает None САМА (комплов меньше
|
||||
``estimate_sb_min_comps``=4), флаг раньше оставался равным ``anchor_tier_fetched`` —
|
||||
``SB_MIN_COMPS``=4), флаг раньше оставался равным ``anchor_tier_fetched`` —
|
||||
дальше по коду он читается как «headline построил якорь».
|
||||
"""
|
||||
comps = [_anchor_comp(150_000), _anchor_comp(155_000)] # 2 < estimate_sb_min_comps=4
|
||||
comps = [_anchor_comp(150_000), _anchor_comp(155_000)] # 2 < SB_MIN_COMPS=4
|
||||
radius_median_price = int(100_000 * 50.0)
|
||||
pr = _call(
|
||||
listings=_lots(100_000, n=5),
|
||||
|
|
|
|||
|
|
@ -389,6 +389,7 @@ def _run_estimate_qi(
|
|||
anchor_tier_override: str | None = None,
|
||||
analog_indexes: dict[str, float] | None = None,
|
||||
imv_anchor: dict[str, Any] | None = None,
|
||||
seen_min_n: list[int] | None = None,
|
||||
):
|
||||
"""Запускает estimate_quality с полным stub-пачем I/O; возвращает AggregatedEstimate.
|
||||
|
||||
|
|
@ -396,6 +397,9 @@ def _run_estimate_qi(
|
|||
когда тесту нужен ИЗВЕСТНЫЙ уровень аналогов, отличный от целевого: без неё
|
||||
батч-стаб отдаёт всем кварталам тот же индекс, что и цели, то есть factor=1.0.
|
||||
Кварталы вне карты не попадают в ответ — ровно как в FDW при n_deals < порога.
|
||||
|
||||
seen_min_n — сюда пишется min_n_deals каждого вызова обоих lookup'ов: порог
|
||||
фильтрует строки в SQL, и заглушка его иначе теряет.
|
||||
"""
|
||||
from app.services.estimator import estimate_quality
|
||||
|
||||
|
|
@ -406,7 +410,14 @@ def _run_estimate_qi(
|
|||
|
||||
# Батч-хелпер возвращает словарь: для каждого переданного квартала — тот же индекс,
|
||||
# что qi_lookup_result[0], если qi_lookup_result не None; иначе пустой dict.
|
||||
def _fake_lookup_index(db_arg, *, quarter_cad_number, min_n_deals):
|
||||
if seen_min_n is not None:
|
||||
seen_min_n.append(min_n_deals)
|
||||
return qi_lookup_result
|
||||
|
||||
def _fake_lookup_indexes(db_arg, *, quarter_cad_numbers, min_n_deals):
|
||||
if seen_min_n is not None:
|
||||
seen_min_n.append(min_n_deals)
|
||||
if analog_indexes is not None:
|
||||
return {q: analog_indexes[q] for q in quarter_cad_numbers if q in analog_indexes}
|
||||
if qi_lookup_result is None:
|
||||
|
|
@ -451,7 +462,7 @@ def _run_estimate_qi(
|
|||
# Stub singular target-quarter lookup
|
||||
patch(
|
||||
"app.services.estimator._lookup_quarter_index",
|
||||
return_value=qi_lookup_result,
|
||||
side_effect=_fake_lookup_index,
|
||||
),
|
||||
# Stub batched analog-quarter lookup
|
||||
patch(
|
||||
|
|
@ -532,11 +543,51 @@ def test_guard2_skip_when_majority_analogs_in_target_quarter() -> None:
|
|||
analogs=analogs,
|
||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||
qi_lookup_result=(1.5, 30), # high index — but guard-2 should skip
|
||||
# #2379: уровень аналогов 1.0 — без гарда было бы ×1.5. Без явной карты стаб
|
||||
# отдаёт аналогам индекс цели, factor=1.0, и тест зеленел при снятом гарде.
|
||||
analog_indexes={_TARGET_QUARTER: 1.0, _OTHER_QUARTER: 1.0},
|
||||
)
|
||||
# Медиана НЕ должна изменяться
|
||||
assert est.median_price_rub == base_median
|
||||
|
||||
|
||||
def test_guard2_ratio_exactly_0_6_still_corrects() -> None:
|
||||
"""#2379: граница Guard-2 строгая — 3 из 5 аналогов (0.6) в квартале цели не skip."""
|
||||
analogs = [
|
||||
_make_listing_qi(
|
||||
price_per_m2=_BASE_PPM2,
|
||||
building_cadastral_number=f"{_TARGET_QUARTER if i < 3 else _OTHER_QUARTER}:100",
|
||||
floor=1 + i,
|
||||
)
|
||||
for i in range(5)
|
||||
]
|
||||
est = _run_estimate_qi(
|
||||
analogs=analogs,
|
||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||
qi_lookup_result=(1.5, 30),
|
||||
analog_indexes={_TARGET_QUARTER: 1.0, _OTHER_QUARTER: 1.0},
|
||||
)
|
||||
assert est.median_price_rub == round(_BASE_PPM2 * _AREA * 1.5)
|
||||
|
||||
|
||||
def test_quarter_lookups_filter_by_ten_deals() -> None:
|
||||
"""#2379: оба lookup'а (квартал цели и кварталы аналогов) просят индекс от 10 сделок.
|
||||
|
||||
Порог уходит параметром в SQL (`n_deals >= :min_n`), в заглушке его не видно:
|
||||
подмена числа на месте вызова проходила весь сьют.
|
||||
"""
|
||||
seen: list[int] = []
|
||||
est = _run_estimate_qi(
|
||||
analogs=_ANALOGS_OTHER_QUARTER,
|
||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||
qi_lookup_result=(1.2, 30),
|
||||
analog_indexes={_OTHER_QUARTER: 1.0},
|
||||
seen_min_n=seen,
|
||||
)
|
||||
assert est.median_price_rub == round(_BASE_PPM2 * _AREA * 1.2) # оба lookup'а вызваны
|
||||
assert seen == [10, 10]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Sparse fallback: нет строки → no-op
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -659,19 +710,27 @@ def test_c3_one_indexed_analog_is_enough_to_correct() -> None:
|
|||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_bimodal_guard_skips_high_index_small_n() -> None:
|
||||
"""Bimodal guard: price_index=3.5, n_deals=20 → no-op."""
|
||||
analogs_no_cadnum = [
|
||||
_make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None, floor=4 + i)
|
||||
for i in range(5)
|
||||
]
|
||||
base_median = round(_BASE_PPM2 * _AREA)
|
||||
est = _run_estimate_qi(
|
||||
analogs=analogs_no_cadnum,
|
||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||
qi_lookup_result=(3.5, 20), # index>2.0 AND n<50 → bimodal guard
|
||||
@pytest.mark.parametrize(
|
||||
("target_qi", "expected_factor"),
|
||||
[
|
||||
(3.5, 1.0), # index > 2.0 AND n < 50 → bimodal guard, no-op
|
||||
(2.01, 1.0),
|
||||
(2.0, 1.8), # ровно 2.0 — не больше порога: поправка есть, factor 2.0 → кламп 1.8
|
||||
],
|
||||
)
|
||||
assert est.median_price_rub == base_median
|
||||
def test_bimodal_guard_skips_high_index_small_n(target_qi: float, expected_factor: float) -> None:
|
||||
"""Bimodal guard: price_index > 2.0 при n_deals=20 → no-op.
|
||||
|
||||
#2379: аналоги с ИЗВЕСТНЫМ уровнем 1.0. Раньше здесь были аналоги без кадастра —
|
||||
поправку на них гасит и Guard-5 (#2583), и тест зеленел при снятом bimodal-гарде.
|
||||
"""
|
||||
est = _run_estimate_qi(
|
||||
analogs=_ANALOGS_OTHER_QUARTER,
|
||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||
qi_lookup_result=(target_qi, 20),
|
||||
analog_indexes={_OTHER_QUARTER: 1.0},
|
||||
)
|
||||
assert est.median_price_rub == round(_BASE_PPM2 * _AREA * expected_factor)
|
||||
|
||||
|
||||
def test_bimodal_guard_allows_high_index_large_n() -> None:
|
||||
|
|
|
|||
|
|
@ -73,10 +73,7 @@ def _make_payload():
|
|||
def _run_estimate(
|
||||
analogs: list[dict[str, Any]],
|
||||
dkp_raw: dict[str, Any] | None,
|
||||
*,
|
||||
radius_floor_factor: float = 0.8,
|
||||
) -> Any:
|
||||
from app.core.config import settings
|
||||
from app.services.estimator import estimate_quality
|
||||
|
||||
db = MagicMock()
|
||||
|
|
@ -107,7 +104,6 @@ def _run_estimate(
|
|||
),
|
||||
patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw),
|
||||
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
|
||||
patch.object(settings, "estimate_radius_floor_factor", radius_floor_factor),
|
||||
):
|
||||
return await estimate_quality(payload, db)
|
||||
|
||||
|
|
@ -132,7 +128,7 @@ def test_radius_median_below_dkp_floor_is_lifted() -> None:
|
|||
"high_ppm2": 220_000,
|
||||
"period_months": 12,
|
||||
}
|
||||
est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8)
|
||||
est = _run_estimate(analogs, dkp_raw)
|
||||
|
||||
floor_ppm2 = 150_000 * 0.8 # 120_000
|
||||
assert est.median_price_per_m2 >= floor_ppm2, (
|
||||
|
|
@ -157,7 +153,7 @@ def test_radius_median_above_dkp_floor_unchanged() -> None:
|
|||
"high_ppm2": 220_000,
|
||||
"period_months": 12,
|
||||
}
|
||||
est = _run_estimate(analogs, dkp_raw, radius_floor_factor=0.8)
|
||||
est = _run_estimate(analogs, dkp_raw)
|
||||
|
||||
# floor = 150k × 0.8 = 120k; median = 200k > floor → no-op, медиана не изменяется
|
||||
# Медиана должна остаться в диапазоне аналогов (190-210k), а не подняться к floor.
|
||||
|
|
@ -179,7 +175,7 @@ def test_no_dkp_raw_no_floor() -> None:
|
|||
_make_listing(price_per_m2=80_000.0),
|
||||
_make_listing(price_per_m2=85_000.0),
|
||||
]
|
||||
est = _run_estimate(analogs, dkp_raw=None, radius_floor_factor=0.8)
|
||||
est = _run_estimate(analogs, dkp_raw=None)
|
||||
|
||||
# median ~80k, без dkp_raw floor не поднимает
|
||||
assert est.median_price_per_m2 < 100_000, (
|
||||
|
|
|
|||
|
|
@ -22,8 +22,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/
|
|||
|
||||
import pytest
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.estimator import _downgrade_confidence
|
||||
from app.services.estimator import WIDE_CORRIDOR_THRESHOLD, _downgrade_confidence
|
||||
|
||||
# Переиспользуем harness _run_estimate / _make_payload из anchor-теста (полный
|
||||
# estimate путь со всеми I/O застабленными).
|
||||
|
|
@ -48,7 +47,7 @@ def _payload(): # type: ignore[no-untyped-def]
|
|||
|
||||
|
||||
def _run(*, anchor_tier: str, comps=None, threshold: float = 0.6): # type: ignore[no-untyped-def]
|
||||
with patch.object(settings, "estimate_wide_corridor_threshold", threshold):
|
||||
with patch("app.services.estimator.WIDE_CORRIDOR_THRESHOLD", threshold):
|
||||
return _h._run_estimate(
|
||||
anchor_comps=_WIDE_SPLIT_COMPS if comps is None else comps,
|
||||
anchor_tier=anchor_tier,
|
||||
|
|
@ -67,7 +66,7 @@ _DISCLOSURE_MARKER = "разбит на секции"
|
|||
def test_threshold_default_1_2() -> None:
|
||||
# 1.2: 0.6 фаерил бы на ~31% оценок (широкий коридор ≠ split-дом);
|
||||
# genuine split-дома аудита = 148-170% → 1.2 ловит только экстремальный хвост.
|
||||
assert settings.estimate_wide_corridor_threshold == 1.2
|
||||
assert WIDE_CORRIDOR_THRESHOLD == 1.2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -24,9 +24,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/
|
|||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
|
||||
from app.services import estimator
|
||||
from app.services.estimator import (
|
||||
_compute_same_building_anchor,
|
||||
_geocode_is_coarse,
|
||||
|
|
@ -395,7 +393,7 @@ _RADIUS_ANALOGS: list[dict[str, Any]] = [
|
|||
]
|
||||
|
||||
# Same-building комплы Хохрякова 48 (флагман 684k внутри).
|
||||
# 4 комплов — удовлетворяет estimate_sb_min_comps=4 (#755).
|
||||
# 4 комплов — удовлетворяет SB_MIN_COMPS=4 (#755).
|
||||
_SB_COMPS_PREMIUM: list[dict[str, Any]] = [
|
||||
{"price_per_m2": 399_478, "area_m2": 153.2, "rooms": 3},
|
||||
{"price_per_m2": 472_298, "area_m2": 110.1, "rooms": 3},
|
||||
|
|
@ -503,6 +501,29 @@ def test_estimate_premium_lift_real_in_range() -> None:
|
|||
assert est.range_high_rub >= int(real * 0.9)
|
||||
|
||||
|
||||
def test_estimate_anchor_gets_prod_weights() -> None:
|
||||
"""#2381: веса якоря доходят до расчёта боевыми (прод 17.09.2026).
|
||||
|
||||
Тест значений констант держит только число в estimator.py. Подмена
|
||||
rooms_boost/floor_sigma на месте вызова (например, 1.0) проходила весь сьют:
|
||||
веса сдвигают headline внутри тех же допусков, что проверяют тесты выше.
|
||||
"""
|
||||
with patch(
|
||||
"app.services.estimator._compute_same_building_anchor",
|
||||
wraps=_compute_same_building_anchor,
|
||||
) as spy:
|
||||
est = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A")
|
||||
assert est.median_price_per_m2 >= 450_000 # якорь сработал, а не радиус
|
||||
kw = spy.call_args.kwargs
|
||||
assert {k: kw[k] for k in ("sigma", "rooms_boost", "floor_sigma", "min_comps", "mad_k")} == {
|
||||
"sigma": 0.18,
|
||||
"rooms_boost": 1.6,
|
||||
"floor_sigma": 0.25,
|
||||
"min_comps": 4,
|
||||
"mad_k": 3.5,
|
||||
}
|
||||
|
||||
|
||||
def test_estimate_anchor_fires_without_radius_analogs() -> None:
|
||||
"""#691: радиусных аналогов НЕТ (median=0, прод-кейс провала ST_DWithin), но
|
||||
same-building комплы есть → якорь обязан сработать и дать median>0.
|
||||
|
|
@ -529,7 +550,7 @@ def test_estimate_no_anchor_no_radius_stays_insufficient() -> None:
|
|||
|
||||
def test_estimate_economy_no_regression() -> None:
|
||||
"""(b) Эконом-комплы ~112k → guardrail не раздувает, headline ≈ комплов.
|
||||
4 компла — satisfies estimate_sb_min_comps=4 (#755).
|
||||
4 компла — satisfies SB_MIN_COMPS=4 (#755).
|
||||
Комплы специально подобраны так, что MAD-clip не удаляет ни один из них
|
||||
(все отклонения << 3.5×MAD при tight spread)."""
|
||||
eco_comps = [
|
||||
|
|
@ -598,7 +619,7 @@ def test_estimate_tier_d_fallback_keeps_radius() -> None:
|
|||
def test_estimate_range_covers_same_building_comp_spread() -> None:
|
||||
"""range_high покрывает RAW max same-building компла (видовой/топ-юнит дома не
|
||||
вылетает за диапазон — зеркало 8 Марта 204Г view-кейса).
|
||||
4 компла — satisfies estimate_sb_min_comps=4 (#755)."""
|
||||
4 компла — satisfies SB_MIN_COMPS=4 (#755)."""
|
||||
# comp max 255_459 ppm² — самый дорогой лот в доме (видовой). target — рядовой.
|
||||
view_comps = [
|
||||
{"price_per_m2": 124_309, "area_m2": 54.3, "rooms": 2},
|
||||
|
|
@ -665,7 +686,7 @@ def test_estimate_analogs_stay_radius_when_no_anchor() -> None:
|
|||
def test_estimate_analogs_pass_through_display_fields() -> None:
|
||||
"""#694: комплы С display-полями (address/source/source_url/price_rub) пробрасывают
|
||||
их в AnalogLot напрямую (без вычисления price_rub из ppm²×area).
|
||||
4 компла — satisfies estimate_sb_min_comps=4 (#755)."""
|
||||
4 компла — satisfies SB_MIN_COMPS=4 (#755)."""
|
||||
comps_with_display = [
|
||||
{
|
||||
"price_per_m2": 399_478,
|
||||
|
|
@ -1040,16 +1061,14 @@ def test_anchor_n_analogs_syncs_to_anchor_count() -> None:
|
|||
assert "по 4 аналогам" in (est.confidence_explanation or "")
|
||||
|
||||
|
||||
def test_radius_path_n_analogs_unchanged(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_radius_path_n_analogs_unchanged() -> None:
|
||||
"""#695 контроль: без якоря n_analogs = радиусный счёт (поведение не меняется).
|
||||
|
||||
Проверяет radius-passthrough vs anchor-override — ортогонально кросс-source
|
||||
дедупу (#2087 H4, default ON с #2173). 5 ИДЕНТИЧНЫХ листингов — это ровно
|
||||
патологический кросс-пост, который дедуп схлопнул бы в 1; пиним флаг OFF,
|
||||
чтобы мерить именно #695-инвариант, не полагаясь на прод-default (теперь ON).
|
||||
дедупу (#2087 H4, всегда ON с #2378). Площади разные, поэтому это 5 разных
|
||||
физлотов: 5 ИДЕНТИЧНЫХ листингов дедуп честно схлопнул бы в 1.
|
||||
"""
|
||||
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", False)
|
||||
radius = [_make_listing(price_per_m2=200_000.0) for _ in range(5)]
|
||||
radius = [_make_listing(price_per_m2=200_000.0, area_m2=60.0 + i) for i in range(5)]
|
||||
est = _run_estimate(anchor_comps=[], anchor_tier=None, radius_analogs=radius)
|
||||
assert est.n_analogs == 5
|
||||
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ def test_tier_a_includes_novostroyki_when_secondary_present() -> None:
|
|||
_row(source="cian", source_id="c2", listing_segment="novostroyki"),
|
||||
]
|
||||
db = _db_mock(rows)
|
||||
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||
with patch.object(est_mod, "SB_MIN_COMPS", 4):
|
||||
comps, tier = _fetch(db, lat=_LAT, lon=_LON)
|
||||
assert tier == "A"
|
||||
# Все 6 (4 вторички + 2 novostroyki-переуступки) учтены.
|
||||
|
|
@ -146,7 +146,7 @@ def test_tier_a_excludes_novostroyki_when_primary_dominated() -> None:
|
|||
_row(source="cian", source_id="c5", listing_segment="novostroyki"),
|
||||
]
|
||||
db = _db_mock(rows)
|
||||
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||
with patch.object(est_mod, "SB_MIN_COMPS", 4):
|
||||
comps, tier = _fetch(db)
|
||||
# primary-dominated → 5 novostroyki отброшены, 1 vtorichka < min_comps → Tier A skip.
|
||||
assert tier is None
|
||||
|
|
@ -168,7 +168,7 @@ def test_tier_a_excludes_novostroyki_when_pure_primary() -> None:
|
|||
_row(source="cian", source_id="c4", listing_segment="novostroyki"),
|
||||
]
|
||||
db = _db_mock(rows)
|
||||
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||
with patch.object(est_mod, "SB_MIN_COMPS", 4):
|
||||
comps, tier = _fetch(db)
|
||||
# Все отброшены гардом → 0 comps < 4 → Tier A skip (lat/lon None → Tier D).
|
||||
assert tier is None
|
||||
|
|
@ -193,7 +193,7 @@ def test_tier_a_dedup_same_source_id_collapses() -> None:
|
|||
_row(source="cian", source_url=dup_url, source_id="330047129", listing_segment=None),
|
||||
]
|
||||
db = _db_mock(rows)
|
||||
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||
with patch.object(est_mod, "SB_MIN_COMPS", 4):
|
||||
comps, tier = _fetch(db, lat=_LAT, lon=_LON)
|
||||
assert tier == "A"
|
||||
# 6 строк, но 2 cian-строки с одинаковым source_id → 1 comp → итого 5.
|
||||
|
|
@ -229,7 +229,7 @@ def test_tier_a_dedup_same_source_id_different_url_collapses() -> None:
|
|||
),
|
||||
]
|
||||
db = _db_mock(rows)
|
||||
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||
with patch.object(est_mod, "SB_MIN_COMPS", 4):
|
||||
comps, tier = _fetch(db, lat=_LAT, lon=_LON)
|
||||
assert tier == "A"
|
||||
# source_id-primary схлопывает несмотря на разные url → 5 comps.
|
||||
|
|
@ -248,7 +248,7 @@ def test_tier_a_dedup_null_url_keeps_distinct_rows() -> None:
|
|||
_row(source="avito", source_url=None, source_id="a4", area_m2=80.0),
|
||||
]
|
||||
db = _db_mock(rows)
|
||||
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||
with patch.object(est_mod, "SB_MIN_COMPS", 4):
|
||||
comps, tier = _fetch(db, lat=_LAT, lon=_LON)
|
||||
assert tier == "A"
|
||||
# 4 разных лота (разные source_id/площадь) → 4 comps, ничего не схлопнуто.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue