"""Минимальный settings для standalone trade-in MVP.""" 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 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 = 2 # стоп на первом тире с ≥ 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) asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²) estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона # ── 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_GEOCODE_BUDGET_S, ESTIMATE_HOUSE_META_TIMEOUT_S. estimate_yandex_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", } # ── 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 при блоке прежде чем сдаться (на одну страницу). avito_proxy_max_rotations: int = 2 settings = Settings()