gendesign/tradein-mvp/backend/app/core/config.py
bot-backend 3505a4e3e5
All checks were successful
CI / frontend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
feat(tradein/estimator): включить кросс-source физдедуп аналогов по умолчанию (#2087 H4, #2173)
estimate_dedup_analogs_enabled: False → True. Бэктест #1966 OFF vs ON
accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias −3.83%, median
width/cv без изменений) — включение меняет только user-visible n_analogs:
перестаёт быть раздутым кросс-постингом одного физлота на avito+cian+domklik
×3. Откат — ENV ESTIMATE_DEDUP_ANALOGS_ENABLED=false.

Тесты разведены default vs explicit (не ослаблены):
- test_estimator_dedup_cross_source_2087: добавлен test_dedup_default_is_on
  (без monkeypatch — дедуп активен по умолчанию); test_dedup_flag_off_is_noop
  оставляет ЯВНЫЙ OFF-override.
- test_backtest_regression_gate: пиним флаг OFF — гейт это байт-идентичный
  replay frozen OFF-capture (recorded call-sequence фикстуры без дедупа); с
  ON _dedup_cross_source триммит listings до quarter_indexes_lookup и
  control-flow расходится с записью.
- test_estimator_n_analogs_priced (autouse) + test_radius_path_n_analogs_unchanged:
  пиним OFF — инвариант «n_analogs = число priced-аналогов»/radius-passthrough
  ортогонален дедупу, а синтетические аналоги делят адрес/этаж/площадь и
  схлопнулись бы как кросс-посты.

Refs #2087, #2173
2026-07-02 20:56:39 +03:00

566 lines
45 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Минимальный settings для standalone trade-in MVP."""
from typing import Literal
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
# required — задаётся через env DATABASE_URL. Нет дефолта: fail-fast при старте
# если переменная не задана (C-3 security audit).
database_url: str
# In-app asyncio scheduler enable flag (#581).
# Set SCHEDULER_ENABLE=false when using the systemd timer trigger instead.
# Default true preserves existing behaviour.
scheduler_enable: bool = Field(default=True, validation_alias="SCHEDULER_ENABLE")
cors_origins: list[str] = ["http://localhost", "http://localhost:3000", "http://localhost:8080"]
environment: str = "dev"
# Geocoder. Env var name `YANDEX_GEOCODER_API_KEY` — consistent с scripts/
# backfill_house_coords.py + audit_address_mismatch.py + main backend
# OpenRouteService_API_KEY pattern. Renamed from YANDEX_GEOCODER_KEY (PR F).
yandex_geocoder_api_key: str | None = None # 25K req/day free после регистрации
yandex_suggest_key: str | None = None # для frontend autocomplete (proxy через backend)
# для User-Agent в Nominatim (Nominatim Usage Policy)
contact_email: str = "erginrajpopxbe@outlook.com"
# Public URL — для QR-кода в PDF, shareable links, etc.
public_url: str = "http://127.0.0.1:8080"
# GlitchTip DSN — мониторинг ошибок (Sentry-совместимый). #396.
# Пусто = мониторинг выключен (dev). В prod — env GLITCHTIP_DSN из .env.runtime.
glitchtip_dsn: str | None = None
# Ключ шифрования для pgp_sym_encrypt (Cian session cookies).
# Задаётся через env COOKIE_ENCRYPTION_KEY. Пусто = шифрование не работает.
cookie_encryption_key: str = ""
# Redis URL для hot-cache (Phase 3.2). Задаётся через env REDIS_URL.
redis_url: str = "redis://localhost:6379/0"
# Rate-limit публичного /api/* (per-IP sliding window). ENV: RATE_LIMIT,
# RATE_LIMIT_WINDOW_S. Не более rate_limit запросов за rate_limit_window_s
# секунд с одного IP. Аутентифицированный трафик (X-Authenticated-User от
# Caddy basic_auth) не лимитируется — см. ratelimit.py (#655).
rate_limit: int = 300
rate_limit_window_s: float = 60.0
# Password for tradein_fdw_reader role — used by backend startup to create/refresh
# USER MAPPING for postgres_fdw → gendesign DB (gendesign_remote server).
# Пусто = USER MAPPING не создаётся, gendesign_cad_buildings не работает (dev).
gendesign_fdw_password: str | None = None
# DaData /clean/address — обогащение target-адреса канонической формой,
# kadastr_num, ФИАС, координатами, метро. Используется в estimator для
# on-demand enrichment (PR Q1). Demo tier: 100 req/день. Если хотя бы один
# не задан — service возвращает None gracefully, estimator продолжает.
# ENV: DADATA_API_TOKEN, DADATA_API_SECRET.
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_enabled: bool = True
estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w
estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15
# ── #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-диапазон. Полностью за флагом — OFF ⇒ точно текущее поведение.
# Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2.
estimate_same_building_anchor_enabled: bool = True
estimate_sb_min_comps: int = 4 # стоп на первом тире с ≥ N активных комплов
estimate_sb_area_sigma: float = 0.18 # σ log-нормального area-веса (Gaussian)
estimate_sb_rooms_match_boost: float = 1.6 # ×вес если rooms компла == target
# #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²)×(1tol)
estimate_sb_mad_k: float = 3.5 # MAD-clip: drop comps с |ppm2median| > k×MAD
# ── #1966: honest calibrated prediction-interval для expected_sold range ─────
# Старый expected_sold_range производился из IQR аналогов (asking-IQR × ratio):
# ~55% реальных продаж попадали в заявленный «диапазон оценки» (де-факто 50%-й
# интервал, выданный за полный). Эмпирически отношение actual_sold/expected_sold
# по 2366 прод-сделкам имеет p10=0.649, p90=1.392 → band
# expected_sold × [low_mult, high_mult] = настоящий ~80% prediction interval
# (проверено: 80.0% coverage на тех же 2366). OFF ⇒ точно старое IQR-поведение.
estimate_calibrated_pi_enabled: bool = True
estimate_pi_low_mult: float = 0.649 # empirical p10 of sold/expected_sold (#1966, n=2366)
estimate_pi_high_mult: float = 1.392 # empirical p90 of sold/expected_sold (#1966, n=2366)
# ── #2002: hedonic year+area correction на точку expected_sold ─────────────
# Диагноз: estimator систематически промахивается по эре дома + размеру —
# недооценивает новостройки, плохо держит крупные лоты. Held-out fit (n=2366
# прод-сделок, 2026-06-27) регрессии log(actual_sold/expected_sold) ~ year +
# ln(area) даёт мультипликативный фактор, применяемый к expected_sold.
# factor = exp(b0 + b_year*(year-2000)/20 + b_larea*ln(area)), clamp [min,max].
# ВАЖНО: цифры ниже — метрики ТОГО ЖЕ 2366-сделочного held-out FIT, а НЕ
# 277-сделочного frozen backtest-фикстура, на котором гоняется regression-gate
# (там overall expected_sold MAPE 18.63→14.24):
# held-out median-abs-error 18.5%→16.1%; бизнес bias 22%→14% (MAPE 22.3→15.5),
# эконом/комфорт/премиум лучше, элит без изменений (no harm).
# После фактора заново применяется le_asking-кламп (expected_sold ≤ asking).
# OFF ⇒ точно старое поведение expected_sold.
estimate_hedonic_correction_enabled: bool = True
estimate_hedonic_b0: float = 0.6146 # fit log(sold/es) ~ year + ln(area), n=2366 (#2002)
estimate_hedonic_year_coef: float = 0.1220 # per (year-2000)/20
estimate_hedonic_larea_coef: float = -0.1603 # per ln(area_m2)
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_enabled: bool = True
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. False → no-op.
# ENV: ESTIMATE_RADIUS_FLOOR_ENABLED, ESTIMATE_RADIUS_FLOOR_FACTOR.
estimate_radius_floor_enabled: bool = True
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 — не затрагиваются.
estimate_sb_tier_a_allow_primary_if_secondary_present: bool = True
asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²)
estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона
# ── #audit-1: anchor low-confidence gate ─────────────────────────────────
# Якорь с низкой уверенностью (confidence="low" ИЛИ n < min_n И FSD > max_fsd)
# НЕ заменяет headline — fallback на radius-median. Дефолты подобраны так, что
# здоровые якоря (n≥4 с FSD<0.15) проходят без изменений.
# estimate_sb_gate_min_n=3 : при n<3 И FSD>max_fsd гейт срабатывает
# estimate_sb_gate_max_fsd=0.20: FSD>0.20 при малом n → ненадёжный якорь
# Отдельный флаг: False → точно старое поведение (гейта нет).
estimate_sb_low_conf_gate_enabled: bool = True
estimate_sb_gate_min_n: int = 3
estimate_sb_gate_max_fsd: float = 0.20
# ── #audit-3: price_trend freshness filter ────────────────────────────────
# Исключать items старше N месяцев из price_trend (house_placement_history).
# Дефолт 6 (консервативно); аудит предложил 3 — конфигурируемо.
estimate_price_trend_max_age_months: int = 6
# ── Mera-audit fix-3: cross-source dedup в price_trend ────────────────────
# Один объект на avito_imv + yandex_valuation с разными ext_item_id даёт
# double-count в house_placement_history → шум в помесячной медиане тренда.
# True (дефолт) = дедуплицировать строки перед агрегацией по ключу
# (round(area_m2,0), floor, COALESCE(last_price,start_price),
# COALESCE(last_price_date,start_price_date)), приоритет avito_imv.
# False = старое поведение без дедупа (backward-compat).
# ENV: ESTIMATE_PRICE_TREND_DEDUP_ENABLED.
estimate_price_trend_dedup_enabled: bool = True
# ── #1871 P1.2: ghost-anchor confidence floor ─────────────────────────────
# True (дефолт) = форсировать confidence='low' + добавлять caveat в explanation
# когда n_analogs == 0 (нет радиусных/anchor-аналогов) но confidence не 'low'.
# Защита от ghost-anchor: внешние оценочные сервисы (yandex_valuation,
# cian_valuation, avito_imv) могут дать median без единого реального рыночного
# аналога → headline выглядит достоверным при нулевой реальной базе.
# False = старое поведение без принудительного понижения (для отката).
# ENV: ESTIMATE_CONFIDENCE_FLOOR_NO_ANALOGS.
estimate_confidence_floor_no_analogs: bool = True
# ── #2002 #4: manual-review recommendation (derived FLAG, НЕ ценовой сигнал) ─
# Помечает оценки, которые НЕ стоит авто-оффэрить — нужна ручная оценка
# человеком. Research: элит/премиум-премия unit-level и под-доверена (зависит
# от отделки/вида, чего нет в данных сделок). Триггеры: премиальный дом,
# высокая стоимость, низкая уверенность, слишком широкий диапазон цены.
# Чисто метаданные — не трогает median/expected_sold/ranges (gate byte-stable).
# False = флаг всегда выключен (manual_review_recommended=False, reasons=[]).
estimate_manual_review_enabled: bool = True
estimate_manual_review_high_value_rub: int = 20_000_000 # ≥ этого — ручная оценка
estimate_manual_review_wide_range_ratio: float = 1.9 # range_high/range_low ≥ — неопределённо
# asking ₽/м² ≥ этого → дорогой сегмент, авто-оценка консервативна
# (премия за отделку/вид/класс — 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 нет. True (дефолт) добавляет rn_dup=1 фильтр в
# каждом тире (freshest scraped_at на (source, source_id|source_url|ctid)).
# False = старое поведение без radius-дедупа (для отката). Окно rn_dup всегда
# в SQL (безвредно без фильтра); флаг управляет только применением фильтра.
# ENV: ESTIMATE_RADIUS_DEDUP_ENABLED.
estimate_radius_dedup_enabled: bool = True
# ── #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→коридор
# estimate_sb_clip_after_weight, уже 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
# ── #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% без объяснения. True +
# Tier A + corridor_pct > threshold → понижаем confidence на ступень и дописываем
# disclosure в explanation. НЕ трогает point/median/range.
# ENV: ESTIMATE_WIDE_CORRIDOR_DISCLOSURE_ENABLED.
estimate_wide_corridor_disclosure_enabled: bool = True
# Порог ширины коридора (range_high-range_low)/median_price для disclosure.
# 1.2 (120%): по prod-данным corridor_pct median≈0.48, p90≈0.93 — порог 0.6
# фаерил бы на ~31% оценок (широкий коридор ≠ split-дом, ложная атрибуция).
# 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, не кэшируется).
# low_price > high_price или отрицательные значения → также сброс.
# Дефолты: 500_000 (мин. рыночная квартира ЕКБ) и 500_000_000 (500 Мабс. потолок).
# ENV: CIAN_VALUATION_MIN_RUB, CIAN_VALUATION_MAX_RUB.
cian_valuation_min_rub: float = 500_000
cian_valuation_max_rub: float = 500_000_000
# ── #audit-4: MAD-clip after similarity-weighting ─────────────────────────
# True = clip происходит ПОСЛЕ similarity-weighting (на взвешенных ppm²).
# False = clip ДО weighting (старое поведение). Дефолт True.
estimate_sb_clip_after_weight: bool = True
# ── #audit-5: data-age guards ─────────────────────────────────────────────
# sber_index_max_age_days: максимальный допустимый возраст последнего месяца
# СберИндекс-серии (дней). Если latest месяц старее — логируем warning.
sber_index_max_age_days: int = 35
# avito_imv_thin_market_threshold: если market_count < порога — IMV-оценка
# на тонком рынке (thin_market=True в AvitoImvSummary) + warning.
avito_imv_thin_market_threshold: int = 10
# #915 Stage 3: route IMV backfill через /fetch-json sidecar (обходит
# 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.
# За флагом: False → точно текущее поведение (backward-compatible).
estimate_quarter_index_enabled: bool = True
# Минимальное число сделок в квартале (sparse fallback: меньше — no-op).
estimate_quarter_index_min_n_deals: int = 10
# Guard-2 (no double-count): если доля аналогов ИЗ ТОГО ЖЕ квартала > порога —
# аналоги уже несут локацию квартала → 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
# ── Estimate enrichment time-budgets (#654) ──────────────────────────────
# POST /estimate делает несколько ПОСЛЕДОВАТЕЛЬНЫХ блокирующих сетевых
# вызовов (geocode → Overpass → Yandex valuation → IMV → Cian). Yandex
# valuation (внутренний httpx timeout 30s) НЕ gated на наличие floor и
# выполняется на каждой оценке — главный подозреваемый на gateway-таймаут
# (Caddy 502/504). Эти budget'ы оборачивают самые медленные ungated-вызовы
# в asyncio.wait_for(): при превышении источник деградирует в None (тот же
# graceful-путь что и сетевая ошибка), а НЕ роняет весь /estimate в 5xx.
# Держать суммарный budget ниже Caddy read/write timeout (см.
# deploy/Caddyfile.tradein-fragment). ENV: ESTIMATE_YANDEX_VALUATION_TIMEOUT_S,
# ESTIMATE_CIAN_VALUATION_TIMEOUT_S, ESTIMATE_GEOCODE_BUDGET_S,
# ESTIMATE_HOUSE_META_TIMEOUT_S.
estimate_yandex_valuation_timeout_s: float = 8.0
estimate_cian_valuation_timeout_s: float = 8.0
estimate_geocode_budget_s: float = 12.0
estimate_house_meta_timeout_s: float = 8.0
# Лимит успешных оценок trade-in за календарный месяц на аккаунт (#658).
# Конфигурируется через env ESTIMATE_QUOTA_LIMIT. Default 15.
estimate_quota_limit: int = 15
# Фильтр junk-/премиум-порога для asking→sold derivation (#767).
# Нижняя граница 30 000 ₽/м² отсекает нежилые/технические сделки; менять не стоит.
# Верхняя граница — поднята с 600 000 до 1 200 000 ₽/м², чтобы покрыть ЕКБ-premium
# (>600k). Точное значение стоит сверить с `SELECT max(price_per_m2) FROM deals
# WHERE source='rosreestr'` на проде — см. QA-note в asking_to_sold_ratio.py.
# ENV: ASKING_RATIO_PPM2_MAX.
asking_ratio_ppm2_max: int = 1_200_000
# SSRF-защита для admin scrape endpoints (#756).
# Список хостов которым разрешено передавать абсолютные URL в параметрах *_url.
# Относительные пути (без netloc) проходят без проверки — хост подставляется
# фиксированным в самом scraper'е. Задаётся через env SCRAPE_ALLOWED_HOSTS
# (comma-separated). По умолчанию — все хосты используемые scrapers/*.py.
scrape_allowed_hosts: set[str] = {
"www.avito.ru",
"avito.ru",
"www.cian.ru",
"cian.ru",
"ekb.cian.ru",
"realty.yandex.ru",
"ekaterinburg.n1.ru",
"n1.ru",
"domclick.ru",
"www.domclick.ru",
}
# ── Scraper mobile proxy (#806) ──────────────────────────────────────────
# Мобильный прокси (RU, mobileproxy.space) используется ВСЕМИ scraper-сессиями:
# Avito (#623) + Cian (#806). Datacenter-IP блокируется обоими сайтами.
# Пусто = прямое подключение (dev/staging без прокси).
#
# Приоритет ENV-переменных (precedence):
# 1. SCRAPER_PROXY_URL — новый общий ENV; когда задан — используется первым.
# 2. AVITO_PROXY_URL — legacy ENV; fallback, чтобы prod-серверы с уже
# настроенным AVITO_PROXY_URL работали без изменений .env.runtime (#806).
# property `scraper_proxy_url` реализует эту логику; используй его везде.
# validation_alias привязывает поле к env SCRAPER_PROXY_URL (без него
# pydantic-settings читал бы SCRAPER_PROXY_URL_ENV по имени поля — #806 fixup).
scraper_proxy_url_env: str | None = Field(default=None, validation_alias="SCRAPER_PROXY_URL")
avito_proxy_url: str | None = None # ENV: AVITO_PROXY_URL (legacy fallback)
@property
def scraper_proxy_url(self) -> str | None:
"""Единый прокси URL для всех scraper-сессий (Avito + Cian).
Приоритет: SCRAPER_PROXY_URL > AVITO_PROXY_URL > None (прямое подключение).
Prod-серверы с существующим AVITO_PROXY_URL работают без изменений env.
"""
return self.scraper_proxy_url_env or self.avito_proxy_url
# changeip-ссылка mobileproxy: GET меняет мобильный IP за ~9с. Дёргается при
# детекте бана Avito перед повтором. Пусто = ротация выключена (raise сразу).
# ENV: AVITO_PROXY_ROTATE_URL.
avito_proxy_rotate_url: str | None = None
# Сколько раз сменить IP при блоке прежде чем сдаться (на одну страницу).
# #1731: 2→4 — больше шансов восстановиться mid-sweep после проактивной
# ротации на старте (Datadome ban recovery).
avito_proxy_max_rotations: int = 4
# Settle-sleep после changeip-вызова: мобильный модем поднимает новый IP.
# ~9с по умолчанию (эмпирика mobileproxy.space). ENV: AVITO_PROXY_ROTATE_SETTLE_S.
avito_proxy_rotate_settle_s: float = 9.0
# #1950: retry-параметры changeip-GET (_rotate_proxy_ip). Вместо одношотного 30s-timeout
# делаем proxy_rotate_attempts попыток по proxy_rotate_attempt_timeout_s каждая.
# Короткий timeout (8s) означает, что зависший changeip не блокирует весь run на 30s.
# ENV: PROXY_ROTATE_ATTEMPT_TIMEOUT_S / PROXY_ROTATE_ATTEMPTS.
proxy_rotate_attempt_timeout_s: float = 8.0
proxy_rotate_attempts: int = 3
# #1950: если SERP уже сохранил лоты (ins+upd > 0) и упали только detail/houses,
# ставим 'done' а не 'banned' — partial intake сохранён, 'banned' лишний.
# False = старое поведение. ENV: AVITO_SERP_OK_NOT_BANNED.
avito_serp_ok_not_banned: bool = True
# ── Cian dedicated mobile proxy (separate egress from Avito) ──────────────
# Cian и Avito делят один мобильный IP при общем scraper_proxy_url → конкуренция
# за единственный egress → взаимные таймауты/баны при параллельных прогонах.
# Отдельный прокси для Cian устраняет contention. Если не задан — fallback на
# общий scraper_proxy_url (backward-compat). ENV: CIAN_PROXY_URL.
cian_proxy_url_env: str | None = Field(default=None, validation_alias="CIAN_PROXY_URL")
# changeip-ссылка для Cian-прокси (ротация IP при бане/таймауте). Если не задан —
# fallback на avito_proxy_rotate_url. ENV: CIAN_PROXY_ROTATE_URL.
cian_proxy_rotate_url: str | None = None
# Максимум IP-ротаций для Cian на один sweep-прогон. Аналог avito_proxy_max_rotations.
# ENV: CIAN_PROXY_MAX_ROTATIONS.
cian_proxy_max_rotations: int = 4
# #1949: per-fetch hard-cancel для browser-fetch'ей внутри bucket-gather Cian full_load.
# asyncio.wait_for(fetch, timeout=X) отменяет зависший fetch → TimeoutError →
# _fetch_page_html ловит как Exception → None → _one_page → [] → gather завершается.
# 0 = отключить (старое поведение). Дефолт 90.0s щедрее nормального fetch ~12s.
# ENV: CIAN_FULL_LOAD_PER_FETCH_TIMEOUT_S.
cian_full_load_per_fetch_timeout_s: float = 90.0
@property
def cian_proxy_url(self) -> str | None:
"""Прокси для Cian-скраперов. CIAN_PROXY_URL > scraper_proxy_url (fallback)."""
return self.cian_proxy_url_env or self.scraper_proxy_url
# ── Yandex dedicated mobile proxy (separate egress from Avito/Cian) ────────
# Отдельный прокси для Yandex устраняет contention при параллельных прогонах.
# Если не задан — fallback на общий scraper_proxy_url (backward-compat).
# ENV: YANDEX_PROXY_URL.
yandex_proxy_url_env: str | None = Field(default=None, validation_alias="YANDEX_PROXY_URL")
# changeip-ссылка для Yandex-прокси (ротация IP при капче/таймауте). Если не задан —
# fallback на avito_proxy_rotate_url. ENV: YANDEX_PROXY_ROTATE_URL.
yandex_proxy_rotate_url: str | None = None
# Максимум IP-ротаций для Yandex на один sweep-прогон. Аналог avito_proxy_max_rotations.
# ENV: YANDEX_PROXY_MAX_ROTATIONS.
yandex_proxy_max_rotations: int = 4
@property
def yandex_proxy_url(self) -> str | None:
"""Прокси для Yandex-скраперов. YANDEX_PROXY_URL > scraper_proxy_url (fallback)."""
return self.yandex_proxy_url_env or self.scraper_proxy_url
# full_load повторный прогон в день пропускает листинги уже обновлённые сегодня
# (last_seen_at MSK) — экономит upsert + price-trigger churn; False = всегда
# обновлять (старое поведение). ENV: SCRAPER_SKIP_SEEN_TODAY.
scraper_skip_seen_today: bool = True
# ── #759: deactivate stale avito listings ───────────────────────────────
# Avito-объявления, не виденные scraper'ом более avito_stale_ttl_days дней,
# помечаются is_active=false ночной задачей deactivate_stale_avito_listings.
# TTL=10 — баланс между "снятое объявление" (обычно 3-7 дней молчания) и
# допуском на перерыв в работе scraper'а. ENV: AVITO_STALE_TTL_DAYS.
avito_stale_ttl_days: int = 10
# ── Avito SERP ЕКБ гео-фильтр (per-card city-slug) ─────────────────────
# Avito при редких/дорогих комбо (4+ комн.) добивает выдачу «по всей России»
# (Москва/Челябинск/Омск и т.д.). Каждая карточка несёт СВОЙ href с city-slug
# (/ekaterinburg/, /moskva/, /ufa/ и пр.). True = отбрасывать карточки, у
# которых source_url не содержит /ekaterinburg/ (padding по России). Карточки
# без распознанного city-slug (href без /city/) пропускаются консервативно.
# False = старое поведение (без фильтра). ENV: AVITO_SERP_EKB_ONLY.
avito_serp_ekb_only: bool = Field(default=True, validation_alias="AVITO_SERP_EKB_ONLY")
# ── Yandex SERP cookies (#801/T4) ───────────────────────────────────────
# Путь к JSON-файлу с cookies браузера (формат: [{name, value, ...}, ...]).
# Если задан и файл существует — cookies передаются в curl_cffi-сессию при
# Yandex SERP-запросах; снижает вероятность captcha на datacenter IP.
# Пусто / файл не найден = запросы без cookies (не падаем, только warning).
# ENV: YANDEX_COOKIES_FILE.
yandex_cookies_file: str | None = None
# ── #639: Cian browser auto-login (Variant B) ────────────────────────────
# Провалидировано вживую 2026-05-31: email+пароль, без SMS/капчи. Флоу 2-шаговый
# (после 1-го сабмита экран «Введите пароль» → повтор). Селекторы env-overridable.
cian_login_email: str | None = None
cian_login_password: str | None = None
cian_login_url: str = "https://ekb.cian.ru/"
# Последовательность кликов до формы: открыть модалку → (опц.) другой аккаунт →
# переключить на email-вход. AnotherAccountBtn на fresh headless отсутствует (скипнется).
cian_login_pre_click_selectors: list[str] = [
"[data-name='LoginButton']",
"[data-name='AnotherAccountBtn']",
"[data-name='SwitchToEmailAuthBtn']",
]
cian_login_email_selector: str = "input[name='username']"
cian_login_password_selector: str = "input[name='password']"
cian_login_submit_selector: str = "button[data-name='ContinueAuthBtn']"
cian_login_success_cookie: str = "DMIR_AUTH"
cian_login_wait_ms: int = 4000
# detail_backfill через curl_cffi+backconnect (mproxy) вместо браузера/auv.
# SERP (full_load/city_sweep) и detail_backfill делят один прокси-аккаунт auv
# (~5 параллельных коннектов); browser-фетч в backfill открывает десятки коннектов
# → cap превышается → HTTP 500 / краши. Backconnect (mproxy, авто-ротация,
# 1 коннект/запрос) развязывает прокси-аккаунты.
# True (дефолт) = curl_cffi через settings.scraper_proxy_url (backconnect mproxy).
# False = старое browser-поведение (BrowserFetcher/auv, как scraper_fetch_mode).
# ENV: AVITO_DETAIL_BACKFILL_USE_CURL.
avito_detail_backfill_use_curl: bool = Field(
default=True, validation_alias="AVITO_DETAIL_BACKFILL_USE_CURL"
)
# #1950: hard-timeout на один detail-fetch внутри avito_detail_backfill. Зависший
# fetch_detail (camoufox/browser hang или curl-stall) блокирует loop навсегда →
# budget-guard (раз в итерацию) не срабатывает → heartbeat не обновляется → run
# reaped как zombie (run 423 завис 7.7ч). asyncio.wait_for(fetch, timeout=X)
# отменяет зависший fetch → TimeoutError → листинг failed, loop идёт дальше.
# 90s щедрее нормального detail-fetch (~10-15s). ENV: AVITO_DETAIL_FETCH_TIMEOUT_S.
avito_detail_fetch_timeout_s: float = Field(
default=90.0, validation_alias="AVITO_DETAIL_FETCH_TIMEOUT_S"
)
# ── #884/#905/#1805: BrowserFetcher — HTTP-клиент к tradein-browser ─────────
# scraper_fetch_mode: "browser" (дефолт с #1805 — HTTP POST к tradein-browser
# /fetch через per-provider camoufox + ротирующий backconnect-прокси) или
# "curl_cffi" (legacy TLS-impersonate путь). Phase 2 epic #883: avito SERP-фетч
# по умолчанию через браузер; curl_cffi сохранён как fallback (см.
# AvitoScraper._fetch_serp_html) и явный opt-out через ENV SCRAPER_FETCH_MODE.
scraper_fetch_mode: Literal["curl_cffi", "browser"] = "browser"
# HTTP-эндпоинт tradein-browser сервиса. В Docker-сети — имя сервиса из compose.
# ENV: BROWSER_HTTP_ENDPOINT.
browser_http_endpoint: str = "http://tradein-browser:3000"
# Сколько страниц обработать в одном browser-сеансе перед перезапуском браузера
# (ограничение утечек памяти). Читается сервером из env BROWSER_RECYCLE_PAGES.
# Оставлено для справки / compat. ENV: BROWSER_RECYCLE_PAGES.
browser_recycle_pages: int = 15
# Таймаут навигации (page.goto) в мс. Читается сервером из env BROWSER_NAV_TIMEOUT_MS.
# ENV: BROWSER_NAV_TIMEOUT_MS.
browser_nav_timeout_ms: int = 60000
# Ожидание после DOMContentLoaded для JS-гидрации в мс. Читается сервером.
# ENV: BROWSER_WAIT_MS.
browser_wait_ms: int = 6000
settings = Settings()