"""Минимальный settings для standalone trade-in MVP.""" from typing import Literal from urllib.parse import quote from pydantic import Field, SecretStr, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict # ── Дефолтные части DSN БД `auth` (общий реестр людей, эпик «единый вход») ────── # Вынесены константами, потому что используются ДВАЖДЫ: как `Field(default=...)` # и как запасное значение, если переменная окружения задана пустой строкой # (`AUTH_DB_HOST=` в .env.runtime не должен давать DSN вида `...@:5432/auth`). # # ⚠️ ХОСТ — главная ловушка. Внутри стека «Меры» имя `postgres` резолвится в ЕЁ # СОБСТВЕННЫЙ контейнер: tradein-mvp/docker-compose.prod.yml объявляет сервис # `postgres` (container_name `tradein-postgres`, сети `tradein-net` + # `gendesign_shared`) и собирает им продуктовый DATABASE_URL — # `postgresql+psycopg://...@postgres:5432/tradein`. БД `auth` живёт НЕ там, а на # постгресе главного стека: корневой docker-compose.prod.yml вешает своему # сервису `postgres` в сети `shared` (external, name `gendesign_shared`) алиас # `gendesign-postgres`. tradein-backend к `gendesign_shared` подписан, поэтому # `gendesign-postgres:5432` из него резолвится, а `postgres:5432` увело бы в # чужую (свою же продуктовую) БД — там ни роли auth_app, ни таблиц реестра. # Порт 5432 — ВНУТРИСЕТЕВОЙ порт контейнера; публикация `127.0.0.1:5432:5432` в # корневом compose существует только ради SSH-туннеля с хоста и к этому пути # отношения не имеет. _AUTH_DB_DEFAULT_HOST = "gendesign-postgres" _AUTH_DB_DEFAULT_PORT = 5432 _AUTH_DB_DEFAULT_NAME = "auth" # Роль приложения из data/sql/auth/002_auth_app_role.sql (least privilege). _AUTH_DB_DEFAULT_USER = "auth_app" # ── Окно свежести объявлений (#2656) ────────────────────────────────────────── # ЕДИНСТВЕННОЕ место, где живёт это число. Читают: estimator (_COMMON_WHERE # радиусного пути, inline-копия Tier W, оба SQL якоря дома) и ночной пересчёт # asking_to_sold_ratios (знаменатель коэффициента выкупа). Лежит здесь, а не в # estimator.py, потому что estimator сам импортирует area_bucket из # app.tasks.asking_to_sold_ratio — обратный импорт дал бы цикл. # # ЗАЧЕМ фильтр вообще: `is_active` означает РАЗНОЕ для разных источников (TTL # деактивации 30 дней у cian/yandex-вторички, NULL-сегмент не деактивируется # никогда), а `scraped_at` — одно и то же. Ослабление этого окна или подмена # `scraped_at` на `last_seen_at` впускает в ценовые выборки объявления, которых # никто не видел месяц (прод 2026-08: 21 132 из 37 497 активных строк). LISTINGS_FRESH_DAYS = 14 # объявления не старше 14 дней 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") # ── strangler-миграция scheduler → scraper_kit (#2192, завершена #2397 Part C) ──── # scheduler_main.py теперь безусловно идёт kit-путём (scraper_kit.orchestration.scheduler # + product_handlers) — legacy app.services.scheduler.scheduler_loop fallback удалён # (был мёртвым грузом: прод давно на USE_KIT_SCHEDULER=true). Поле оставлено (extra= # "ignore" в model_config защищает от startup-краха на leftover env var), но больше # ни на что не влияет. use_kit_scheduler: bool = Field(default=False, validation_alias="USE_KIT_SCHEDULER") # Proxy-pool флаги — объявлены заранее (потребители P3/P4 отдельно), дефолт False. use_proxy_pool_curl: bool = Field(default=False, validation_alias="USE_PROXY_POOL_CURL") use_proxy_pool_browser: bool = Field(default=False, validation_alias="USE_PROXY_POOL_BROWSER") cors_origins: list[str] = ["http://localhost", "http://localhost:3000", "http://localhost:8080"] environment: str = "dev" # ── #2213: defense-in-depth для trusted-header auth ─────────────────────── # Backend доверяет заголовку X-Authenticated-User (его проставляет Caddy после # basic_auth). Но backend сидит на общей docker-сети gendesign_shared вместе с # обоими стеками — любой контейнер мог бы отправить поддельный # `X-Authenticated-User: admin` напрямую на tradein-backend:8000, минуя Caddy, # и получить админ-доступ. Общий секрет закрывает эту дыру: Caddy добавляет # X-Internal-Auth-Secret из env, backend требует точного совпадения на любом # запросе с X-Authenticated-User (constant-time compare). # # Дизайн — fail-open до провижининга: пусто (дефолт) → защита НЕ активна # (поведение как раньше + один WARNING на старте). Секрет задаётся руками в # .env.runtime ОБОИХ стедов (Caddy главного стека + tradein-backend) и НИКОГДА # не коммитится. ENV: TRADEIN_INTERNAL_AUTH_SECRET. tradein_internal_auth_secret: str = Field( default="", validation_alias="TRADEIN_INTERNAL_AUTH_SECRET" ) # ── #2550: DB-auth foundation (bcrypt password hashing + session cookie) ──── # Подготовительные поля для #2549 (эпик). Enforcement непустого session_secret # (fail-fast при пустом значении в prod) добавится в #2552 — здесь дефолт # намеренно пустой, чтобы прод-контейнер не падал на старте до того как # секрет проставлен в .env.runtime. ENV: SESSION_SECRET. session_secret: str = Field(default="", validation_alias="SESSION_SECRET") # Имя cookie для DB-based сессии (отдельно от Caddy basic_auth / trusted-header). session_cookie_name: str = Field( default="tradein_session", validation_alias="SESSION_COOKIE_NAME" ) # TTL сессии в часах. Дефолт 720ч (30 дней). session_ttl_hours: int = Field(default=720, validation_alias="SESSION_TTL_HOURS") # "dual" — переходный режим (Caddy trusted-header ИЛИ DB-сессия оба валидны); # "db_only" — только DB-сессия (Caddy basic_auth убран). Переключение — #2552+. auth_mode: Literal["dual", "db_only"] = Field(default="dual", validation_alias="AUTH_MODE") # Rate-limit на /login: не более login_rate_limit попыток за # login_rate_limit_window_s секунд на ключ (обычно IP или username). # # 27.08: было 5. Пилот «Практика» — ОФИС, все сотрудники выходят из-под # одного NAT, то есть пять попыток делились на всю компанию сразу. Хватило # одного человека, перепутавшего пароль, чтобы запереть остальных: «Слишком # много попыток» получали те, кто вообще ещё не пробовал войти. # # Защита от перебора при этом не ослабевает: настоящий предохранитель — # login_username_fail_threshold (счёт по ЛОГИНУ, 20 за час), и он не тронут. # Лимит по адресу нужен против всплеска с одной машины, а не против офиса. login_rate_limit: int = Field(default=20, validation_alias="LOGIN_RATE_LIMIT") login_rate_limit_window_s: int = Field( default=300, validation_alias="LOGIN_RATE_LIMIT_WINDOW_S" ) # Глобальный (независимый от IP) счётчик неудачных входов НА ИМЯ (#2571). # Лимит выше по паре (username, IP) распределённый перебор обходит: с каждого # нового адреса ему дают свежие login_rate_limit попыток. Здесь ключ — ТОЛЬКО # имя, поэтому попытки со всех адресов складываются. # # Превышение порога НЕ блокирует учётку (это был бы вектор DoS против # конкретного человека — злоумышленник выключал бы чужой вход по своему # желанию), а растит задержку ответа: 1с, 2с, 4с… до потолка. Порог 20/час # выбран так, чтобы живой человек с опечатками до него не доходил. login_username_fail_threshold: int = Field( default=20, validation_alias="LOGIN_USERNAME_FAIL_THRESHOLD" ) login_username_fail_window_s: int = Field( default=3600, validation_alias="LOGIN_USERNAME_FAIL_WINDOW_S" ) # Потолок задержки одного ответа. Держим невысоким сознательно: задержка — # это ещё и цена, которую платит легитимный владелец имени, пока его # перебирают. 8с ощутимо режут перебор, но не выглядят как «сайт лёг». login_username_throttle_max_delay_s: float = Field( default=8.0, validation_alias="LOGIN_USERNAME_THROTTLE_MAX_DELAY_S" ) # ── #2665: проверка пароля вне событийного цикла + СОЗНАТЕЛЬНЫЙ потолок ──── # Замер в прод-контейнере 2026-08-06: bcrypt cost 12 (все живые хеши — # `$2b$12$`) = 282 мс медиана. Пока `verify_password` звался прямо в # `async def login`, эти 282 мс были простоем ВСЕГО API, и они же были # единственным настоящим потолком темпа логинов — замерено 3.6 попытки/с при # стойле событийного цикла до 836 мс. Обе половины чинятся вместе, см. # `app.core.password.verify_password_bounded`. # # `workers` — это и есть потолок темпа: не больше workers/282мс проверок в # секунду, сколько бы соединений ни пришло. Дефолт 1 выбран так, чтобы # ПОСЛЕ выноса в пул потолок остался тем же (~3.5/с), что случайно давала # блокировка цикла: вынос не должен ускорять перебор. Поднимать имеет смысл # только вместе с осознанным ответом «во сколько раз мы согласны ускорить # перебор ради параллельных входов». # ge=1: 0 или -1 роняют ThreadPoolExecutor прямо НА ИМПОРТЕ («max_workers must # be greater than 0») — контейнер уходит в crash-loop, и причина видна только # в трейсбеке старта. Пусть отказ будет на валидации настроек, с именем поля. login_password_verify_workers: int = Field( default=1, ge=1, validation_alias="LOGIN_PASSWORD_VERIFY_WORKERS" ) # Сколько запросов одновременно допускаются к проверке (считая тех, кто ждёт # очереди в пуле). Сверх — сразу 429, без ожидания. Не режет темп (его режут # workers), а держит конечной ОЧЕРЕДЬ: каждый ждущий запрос удерживает # соединение к БД (сессия реестра открыта после SELECT в # `get_user_by_username`), а в QueuePool их всего 5+10. Неограниченная # очередь выбрала бы пул и положила API ровно так же, как блокировка цикла, # только другим способом. 4 из 15 соединений и худшее ожидание # 4/1×282мс ≈ 1.1с — цена, которую живой вход переживает. # ge=1: 0 читается как «выключить лимит», а означал бы обратное — КАЖДЫЙ вход # получает 429 навсегда и молча (слотов нет ни одного). Выключать тут нечего: # потолок — это workers, а очередь без границы выбирает пул соединений к БД. login_password_verify_max_inflight: int = Field( default=4, ge=1, validation_alias="LOGIN_PASSWORD_VERIFY_MAX_INFLIGHT" ) # ── Эпик «единый вход»: общий реестр людей в БД `auth` ───────────────────── # DSN БД `auth` (роль auth_app) — единый реестр людей «Меры» (trade-in) и # «Птицы» (Site Finder); схема — data/sql/auth/001-004. # # ПУСТО ПО УМОЛЧАНИЮ, И ЭТО НЕ ОШИБКА. На проде пароль роли auth_app ещё не # заведён (переменной AUTH_DATABASE_URL там нет), данные (хеши/роли/живые # сессии) в `auth` ещё не скопированы. Пока identity_store="tradein" (дефолт) # к этой БД не обращается ни одна строка кода: engine не создаётся, # соединение не открывается, пустой DSN на старте ничего не роняет — см. # app.core.auth_db (ленивое создание engine). ENV: AUTH_DATABASE_URL. # # Задавать его РУКАМИ больше не обязательно — см. `resolved_auth_database_url` # ниже: при пустом AUTH_DATABASE_URL и заданном AUTH_DB_PASSWORD DSN собирается # из частей. Явное значение, если оно есть, по-прежнему выигрывает. auth_database_url: str = Field(default="", validation_alias="AUTH_DATABASE_URL") # ── Части DSN БД `auth` — чтобы пароль жил в ОДНОМ месте ──────────────────── # Пароль роли auth_app уже лежит в .env.runtime отдельной переменной # AUTH_DB_PASSWORD: её читает .forgejo/workflows/deploy.yml, чтобы выполнить # ALTER ROLE (ops/db-bootstrap/set_auth_app_password.sql). Требовать вдобавок # целиковый AUTH_DATABASE_URL значило бы держать ОДИН секрет в ДВУХ местах: # сменили пароль роли, забыли переписать DSN — и вход ложится молча и целиком # (аутентификация к БД `auth` отваливается для всех сразу). # # ⚠️ ops-нюанс: deploy.yml делает ALTER ROLE, читая AUTH_DB_PASSWORD из # backend/.env.runtime ГЛАВНОГО стека, а этот контейнер читает # tradein-mvp/backend/.env.runtime (env_file в tradein-mvp/docker-compose.prod.yml). # Файлы разные — переменная должна быть в обоих. Зато их значение сравнимо # глазами, чего нельзя сказать про пароль, замурованный внутрь DSN. # # Пусто по умолчанию — как и AUTH_DATABASE_URL: в дефолтном режиме # IDENTITY_STORE=tradein ничего из этого не читается. ENV: AUTH_DB_PASSWORD. # # SecretStr, а не str: это единственное поле-секрет, добавленное здесь, и # обёртка бесплатно закрывает канал утечки, которого не видно глазами — # `repr(settings)` и `settings.model_dump()` печатают обычные str-поля # ДОСЛОВНО. Сегодня их никто не рендерит (grep по app: ни дампа env, ни # `/debug`; sentry_sdk в app/main.py идёт с include_local_variables=False), # но появиться такой рендер может в любой момент и тихо — с SecretStr он # напечатает `SecretStr('**********')`. Значение достаётся ровно в одном # месте — `.get_secret_value()` в резолвере ниже. # ⚠️ Соседние секреты (database_url, telegram_bot_token, …) остались str — # это предсуществующее положение, а не «здесь безопасно, а там нет». auth_db_password: SecretStr = Field(default=SecretStr(""), validation_alias="AUTH_DB_PASSWORD") # Остальные части — с дефолтами, верными для прод-стека (см. константы выше). # Переопределяются через ENV для dev/локального запуска (напр. AUTH_DB_HOST= # localhost + AUTH_DB_PORT=15432 поверх SSH-туннеля). # ENV: AUTH_DB_HOST, AUTH_DB_PORT, AUTH_DB_NAME, AUTH_DB_USER. auth_db_host: str = Field(default=_AUTH_DB_DEFAULT_HOST, validation_alias="AUTH_DB_HOST") auth_db_port: int = Field(default=_AUTH_DB_DEFAULT_PORT, validation_alias="AUTH_DB_PORT") auth_db_name: str = Field(default=_AUTH_DB_DEFAULT_NAME, validation_alias="AUTH_DB_NAME") auth_db_user: str = Field(default=_AUTH_DB_DEFAULT_USER, validation_alias="AUTH_DB_USER") @field_validator("auth_db_port", mode="before") @classmethod def _blank_port_means_default(cls, value: object) -> object: """`AUTH_DB_PORT=` (пустая строка) → прод-дефолт, а не падение на импорте. Симметрия с host/name/user, у которых пустое значение переменной падает обратно на дефолт в резолвере. Для порта того же добиться нельзя: он типизирован `int` и валидируется pydantic'ом ДО всякой нашей логики, а `settings = Settings()` выполняется на уровне модуля — то есть `AUTH_DB_PORT=` в .env.runtime роняло бы ValidationError на импорте конфига и уводило контейнер в restart-loop. Причём В ЛЮБОМ режиме, включая дефолтный IDENTITY_STORE=tradein, где к БД `auth` не идёт ни одного обращения — ровно тот инвариант «дефолт не трогаем», который держит остальной код. Сценарий не гипотетический: ops копирует блок AUTH_DB_* в .env.runtime и заполняет только пароль — остальные строки остаются пустыми намеренно. `mode="before"` — потому что вмешаться надо ДО приведения к int. Непустой мусор (`AUTH_DB_PORT=abc`) по-прежнему валится, и правильно: это опечатка со смыслом, а не «оставил пустым». """ if isinstance(value, str) and not value.strip(): return _AUTH_DB_DEFAULT_PORT return value @property def resolved_auth_database_url(self) -> str: """DSN БД `auth` — единственный источник правды для `app.core.auth_db`. Приоритет: 1. `AUTH_DATABASE_URL`, если задан — выигрывает всегда. Обратная совместимость (так настроено «до») плюс аварийный обход: если DSN понадобился нестандартный (другой хост, sslmode, пул-байпас), его можно вписать целиком, не трогая код. 2. Иначе, если задан `AUTH_DB_PASSWORD` — DSN собирается из частей. 3. Иначе — пустая строка, то есть «не сконфигурировано». Это НЕ ошибка сама по себе: при `IDENTITY_STORE=tradein` (дефолт) сюда не заходит никто. Ошибку — явную, а не тихий фолбэк — поднимает `app.core.auth_db` и только когда реестр реально понадобился. ⚠️ Возвращаемое значение СОДЕРЖИТ ПАРОЛЬ: не логировать, не класть в текст исключений, не отдавать наружу (`/health`, `/debug`, метрики). Пароль экранируется `quote(..., safe="")`: спецсимвол (`@`, `:`, `/`, `?`, `#`, `%`) внутри пароля иначе порвал бы URL по своей грамматике — `@` сдвинул бы границу host, `/` открыл бы path. Разбор дал бы либо ошибку, либо, что хуже, МОЛЧА другой хост/базу. По той же причине экранируется имя пользователя. А вот имя БД и хост — НЕ экранируются, и это не забывчивость: SQLAlchemy раскодирует обратно только userinfo (user/password), а path отдаёт как есть. Прогони мы имя БД через `quote`, в сервер уехало бы литеральное `c%2Fd` вместо `c/d` (проверено round-trip'ом в тестах). Хосту %-кодирование тоже только мешает — оно поломало бы IPv6-скобки. """ explicit = self.auth_database_url.strip() if explicit: return explicit # `.strip()` только для ПРОВЕРКИ «задан ли»: пробельная строка в .env — это # опечатка, а не пароль. В сам DSN идёт значение КАК ЕСТЬ (не стриппится): # ведущий/хвостовой пробел может быть частью настоящего пароля. # Единственная точка распаковки SecretStr во всём коде — см. поле выше. password = self.auth_db_password.get_secret_value() if not password.strip(): return "" user = quote(self.auth_db_user.strip() or _AUTH_DB_DEFAULT_USER, safe="") secret = quote(password, safe="") host = self.auth_db_host.strip() or _AUTH_DB_DEFAULT_HOST port = self.auth_db_port name = self.auth_db_name.strip() or _AUTH_DB_DEFAULT_NAME # Схема — ровно та же, что у продуктового DATABASE_URL (psycopg v3; # `postgresql://` без суффикса увёл бы SQLAlchemy на psycopg2, которого в # зависимостях нет). return f"postgresql+psycopg://{user}:{secret}@{host}:{port}/{name}" # Где живут identity (люди + сессии): # "tradein" (ДЕФОЛТ) — БД tradein, таблицы tradein_users/tradein_sessions # (ровно сегодняшний прод, поведение не меняется); # "auth" — БД auth, таблицы users/sessions (единый реестр). # Переключать ТОЛЬКО после того, как на проде заведён пароль auth_app и # перенесены данные. Дефолт = старое поведение: включить новый путь можно # исключительно явной сменой этого флага. Единственный потребитель — # app.services.identity_store. ENV: IDENTITY_STORE. identity_store: Literal["tradein", "auth"] = Field( default="tradein", validation_alias="IDENTITY_STORE" ) # для 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-user / per-IP sliding window). ENV: # RATE_LIMIT, RATE_LIMIT_WINDOW_S. Не более rate_limit запросов за # rate_limit_window_s секунд на КЛЮЧ (ключ = authenticated username, либо # client IP для анонимов). См. ratelimit.py (#655, #2213). rate_limit: int = 300 rate_limit_window_s: float = 60.0 # #2213: аутентифицированный трафик БОЛЬШЕ не освобождается целиком (это было # плацебо — заголовок X-Authenticated-User клиент-контролируем). Вместо этого # per-user лимит = rate_limit × этот множитель. Живой пилот (kopylov/admin) не # должен упираться — множитель щедрый (×5 = 1500/60с при дефолте), но подделка # заголовка уже не даёт безлимит. ENV: RATE_LIMIT_AUTHENTICATED_MULTIPLIER. rate_limit_authenticated_multiplier: int = Field( default=5, validation_alias="RATE_LIMIT_AUTHENTICATED_MULTIPLIER" ) # ── B2C anti-abuse этап 2: отдельный жёсткий лимит частоты на POST /estimate ── # Общий rate_limit (300/60с) рассчитан на дешёвые запросы; один вызов /estimate # запускает цепочку внешних вызовов (geocode → Overpass → IMV → Yandex → Cian), # каждый забюджетирован, но суммарно может занимать десятки секунд. Отдельный, # куда более строгий бюджет burst'а поверх общего — не даёт одному ключу # (user:/ip:, тот же принцип что и general-лимит) запустить много параллельных # дорогих цепочек за короткое окно. НЕ заменяет account_quota (месячная квота, # персистентная в Postgres) — это защита от burst, а не от abuse за месяц. # Применяется К ЛЮБОМУ ключу (auth и анон одинаково) — цель защитить capacity # сервера/upstream-скрейперов, а не различать роли. ENV: ESTIMATE_RATE_LIMIT, # ESTIMATE_RATE_LIMIT_WINDOW_S. estimate_rate_limit: int = Field(default=5, validation_alias="ESTIMATE_RATE_LIMIT") estimate_rate_limit_window_s: float = Field( default=300.0, validation_alias="ESTIMATE_RATE_LIMIT_WINDOW_S" ) # 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_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-диапазон. # Спек+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%-й # интервал, выданный за полный). Эмпирически отношение 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). 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 # #3248 (перефит 2026-08-30, n=1269 из свежей прод-фикстуры ЕКБ 1600 сделок). # # Прежние значения (b0=0.6146, year=0.1220, larea=-0.1603, first=-0.1248) зафичены # 2026-06-27 (#2002), когда asking→sold ratio ключевался ПО КОМНАТАМ. 2026-08-05 # (#2620) ratio переключили на area-бакеты — то есть под хедонику подставили ДРУГУЮ # базу, а её саму не пересчитали. Хедоника по определению чинит ОСТАТОК # log(actual_sold / expected_sold), поэтому её коэффициенты верны только для той # базы, на которой фитились. Итог: площадь штрафовалась дважды, крупные лоты # занижались на 21% (bias 4+ комнат = -21.4%). # # larea = 0.0 ВЫСТАВЛЕН НАМЕРЕННО, это не «не задан». После #2620 площадь несёт # area-бакетный ratio, и его форма — перевёрнутая U (факт sold/ask по бакетам: # 0.786 / 0.829 / 0.899 / 0.954 / 0.864), которую монотонный ln(area) выразить не # может в принципе: он тянет крупное жильё вниз ровно там, где рынок его не # дисконтирует. Член стал избыточным и вредным — обнуляем, оставляя код-путь. # # Замер вариантов на той же фикстуре (bias по комнатам, median): # текущие MAPE 13.90 | студия +7.6 1к -0.5 2к -2.8 3к -5.3 4+ -21.4 # OLS все 4 члена MAPE 14.11 | студия -0.9 1к -1.6 2к -0.7 3к -4.0 4+ -14.1 # БЕЗ larea (тут) MAPE 14.28 | студия -3.1 1к -2.3 2к -0.4 3к -1.9 4+ -10.4 # хедоника OFF MAPE 15.88 | студия -1.5 1к +4.4 2к +4.2 3к -2.3 4+ -5.0 # Берём «без larea»: худший перекос вдвое меньше, все прочие классы в пределах # ±3.1%, цена — +0.38 п.п. общего MAPE (хедоника сжимает разброс за счёт year). # # ОСТАТОК -10.4% по 4+ конфигом НЕ закрывается: ratio бакета 4 = 0.8211 при # фактических sold/ask = 0.8640, т.е. занижен на ~5% ДО всякой хедоники. Это # пересчёт самой таблицы (app/tasks/asking_to_sold_ratio.py), см. follow-up. estimate_hedonic_b0: float = -0.0140 # #3248 перефит поверх area-бакетного ratio estimate_hedonic_year_coef: float = 0.0769 # per (year-2000)/20 estimate_hedonic_larea_coef: float = 0.0 # НАМЕРЕННО 0 — площадь несёт ratio (#2620) 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 / # ceiling_height_m / is_apartments НЕ читал вовсе для отбора/скоринга # аналогов. Три НЕЗАВИСИМЫХ флага (по одному на фичу, все default OFF — # см. scripts/backtest_estimator.py --engine full для A/B измерения; каждый # PR/issue #2012 обязан задокументировать MAPE/coverage/calibration до # включения любого в default ON): # # kitchen_area_m2 / ceiling_height_m ("мягкие корректировки"): в отличие от # house_type/year_built (сравниваются с target_house_type/target_year, # известными из payload или OSM house_metadata fallback) — у kitchen/ceiling # НЕТ target-значения: ни TradeInEstimateInput (форма пользователя), ни # `deals` (backtest ground truth, rosreestr ДКП) их не несут. Поэтому # релевантность штрафуется отклонением кандидата от МЕДИАНЫ САМОГО ПУЛА # кандидатов текущего запроса (self-referential), а НЕ сравнением с внешней # "типичной" константой — так сигнал не зависит от непроверенных допущений # о типичном размере кухни/высоте потолка. NULL-safe и sparse-safe вдвойне: # (1) кандидат без значения колонки не штрафуется и не участвует в подсчёте # медианы пула; (2) если кандидатов пула с непустым значением меньше # estimate_kitchen_ceiling_signal_min_n — сигнал пропускается ЦЕЛИКОМ для # всего пула (слишком мало данных для честной "типичной" медианы — риск шума # на sparse-колонках, который явно называет issue #2012: kitchen 4-99%, # ceiling ~10% покрытия по источникам). # ENV: ESTIMATE_KITCHEN_AREA_SIGNAL_ENABLED, ESTIMATE_CEILING_HEIGHT_SIGNAL_ENABLED. estimate_kitchen_area_signal_enabled: bool = False estimate_ceiling_height_signal_enabled: bool = False # Масштаб (м² / м): во сколько "единиц отклонения" превращается 1.0 очко # relevance_score — симметрично house_type-штрафу (1.5 очка за несовпадение) # и year_built-штрафу (abs(delta)/12.0). Кухня ~3м² и потолок ~0.3м — # консервативные масштабы, дающие умеренный штраф на типичном разбросе пула. estimate_kitchen_area_scale: float = 3.0 estimate_ceiling_height_scale: float = 0.3 # Максимальный штраф за отклонение (та же единица очков, что house_type=1.5) — # клампим, чтобы редкий выброс пула (напр. кухня 25м² в студийной подборке) # не выбрасывал кандидата из top-50 целиком одним лишь этим сигналом. estimate_kitchen_ceiling_signal_max_penalty: float = 1.0 # Минимум кандидатов пула с НЕ-NULL значением колонки, чтобы доверять её # медиане как "типичной" для этого пула (иначе сигнал пропускается — см. риск # sparse-coverage выше). estimate_kitchen_ceiling_signal_min_n: int = 5 # # ── #2936: штраф за НЕИЗВЕСТНЫЙ year_built / house_type (default OFF) ────── # В SQL-формуле relevance_score кандидат без year_built получает штраф 0 — # столько же, сколько точное попадание в год, и ЛУЧШЕ, чем кандидат с # известным годом, отличающимся на 24 (2.0). То же с house_type. Отсутствие # данных выигрывает у знания, и это возвышает источник с худшей полнотой: # замер 19.08 — avito (год 44 %, тип 0 %) берёт 47 % слотов топ-20 при 21 % # доли в пуле, yandex (год 99 %) — 9 % при 37 %. # # Влияние на цену измерено дважды (#2936): систематического смещения НЕТ # (медиана сдвига 0.29 %), но у 39 % целей медиана сопоставимых уходит >5 % # в зависимости от того, как оценено незнание — шум от полноты сбора. # Чего замеры НЕ говорят: какой из двух отборов ТОЧНЕЕ. Поэтому флаг, а не # правка формулы: включать — только после бэктеста на сделках (MAPE), как # требует тот же контракт, что у kitchen/ceiling (#2012) выше. # # Механизм: НЕ наказание, но и не награда — кандидат с NULL получает # МЕДИАННЫЙ по пулу штраф того же признака среди кандидатов, у которых он # известен (self-referential, как kitchen/ceiling). Пул = кандидаты тира # после SQL (до 300), ДО сортировки и LIMIT 50. Известное ограничение: # per-address cap (rn_addr ≤ MAX_ANALOGS_PER_ADDRESS) в SQL уже отработал # без штрафа — Python-слой переранжирует то, что SQL оставил, как и #2012. # Sparse-safe: если известных значений меньше min_n — сигнал пропускается # целиком (медиана по трём строкам — не «типичный штраф», а шум). # ENV: ESTIMATE_UNKNOWN_ATTR_PENALTY_ENABLED. estimate_unknown_attr_penalty_enabled: bool = False estimate_unknown_attr_penalty_min_n: int = 5 # # is_apartments (#2008): концептуально ОТДЕЛЬНАЯ фича — не "мягкая # корректировка", а hard-filter сегмент-guard, симметричный novostroyki-guard # #1186 (`listing_segment`) в _COMMON_WHERE. Апартаменты — юридически иной # статус недвижимости (не жилое помещение, нет постоянной регистрации по # месту жительства), заметно иная ценовая модель vs обычная квартира. Target # trade-in объект почти всегда обычная квартира (TradeInEstimateInput не # даёт признака "апартаменты"), поэтому при включении флага HARD-исключаем # явно known is_apartments=true кандидатов из вторичка-пула. NULL-safe: # неизвестный статус (подавляющее большинство строк, sparse coverage) # участвует БЕЗ штрафа — фильтруются только явные True. # 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, не кэшируется). # 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-5: data-age guards ───────────────────────────────────────────── # #2846: sber_index_max_age_days УДАЛЁН (был 35). Порог недостижим по построению # (period_month — метка первого числа + лаг публикации источника ⇒ пол 46 суток), # guard был истинным 100% времени. Свежесть СберИндекса теперь считает ровно одно # место — tasks/sber_freshness_monitor, и считает по отставанию ЗАГРУЗКИ, а порог # берёт из такта самой загрузки (scrape_schedules.default_params.interval_days), # так что второму порогу тут больше неоткуда взяться и не с чем разъезжаться. # extra="ignore" в model_config защищает от startup-краха на leftover env var. # 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. # Минимальное число сделок в квартале (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 # Квартал ЦЕЛИ по её координатам (ближайшее здание в cad_buildings_local), # когда dadata.house_cadnum пуст — а он пуст в 15 из 15 применений на проде. # ВЫКЛЮЧЕН по умолчанию (ENV: ESTIMATE_QUARTER_FROM_COORDS_ENABLED). # # Почему dormant. Точность самого резолва измерена (2544 дома ЕКБ, где кадастр # известен независимо — ответ DaData на адрес, не KNN-подсказка): 92.1% на 25 м, # 79.8% на 50 м. То есть механизм работоспособен. Но ЭФФЕКТ поправки на точность # цены НЕ измерен: бэктест-гейт реплеит фикстуру с target_house_cadnum=None и # координатный резолв не проходит. Точность резолва ≠ польза поправки, а тракт # денежный — поэтому включение отдельным решением, после замера. # # Критерий приёмки (записан ДО факта, 2026-08-12): перезахватить фикстуру с # заполненным координатным кварталом и получить overall MAPE не хуже 12.63 И # сегмент эконом не хуже 14.20 при доле затронутых сделок >= 5%. Если к # 2026-09-12 замер не сделан — флаг и `_lookup_target_quarter_by_coords` удалить, # а не оставлять «на вырост». estimate_quarter_from_coords_enabled: bool = False # ── Сегментная поправка эстиматора по ценовому бэнду (#2255) ────────────── # # ⚠ НЕ ВКЛЮЧАТЬ БЕЗ ПЕРЕЗАМЕРА. Посылка, на которой откалиброваны множители # ниже («движок занижает верхние сегменты»), 2026-08-29 признана АРТЕФАКТОМ # измерения, а не свойством движка. # # Причина: `scripts/backtest_estimator.py::_segment_metrics` группирует ошибки # ПО SOLD ₽/м² — по той самой величине, которую движок предсказывает. Группировка # по исходу даёт регрессию к среднему: в дешёвых корзинах любой предиктор выглядит # завышающим, в дорогих — занижающим, даже при нулевом перекосе. # # Контрольный опыт (13 074 сделки ЕКБ с 2025-06): предиктор БЕЗ сегментного # перекоса по построению — медиана окружения 1 км ÷ общий уровень — даёт в тех же # корзинах эконом +22.28%, бизнес −17.04%, элит −30.06%. Это БОЛЬШЕ наблюдавшегося # перекоса движка (бизнес −9.74%, элит −18.45% на n=1191), т.е. движок «занижает» # ровно настолько, насколько этого требует способ замера, и даже меньше. # # Перекрёстная проверка: те же сделки, но бэнд задан НЕЗАВИСИМЫМ прокси (медиана # активных объявлений собственного дома, own_n>=3) — градиент исчезает, остаётся # ровный уровень +20…+27% (это разрыв asking↔sold, его чинит asking_to_sold_ratio). # # Следствие: включение флага подняло бы бизнес/элит на 6-8% БЕЗ ОСНОВАНИЯ. # Прежде чем трогать — перевести `_segment_metrics` на независимый бэнд и # перемерить. Абзац «band-mismatch» ниже описывает симптом того же артефакта. # Разбор: vault `segment-bias-is-regression-to-the-mean-artifact`. # # ИСТОРИЧЕСКАЯ ПОСЫЛКА (сохранена как есть, НЕ опирайся на неё): # Эстиматор систематически занижает верхние сегменты (live-бэктест n=561, # 2026-07-03): эконом +3.2%, комфорт −4.5%, бизнес −16.3% (n=76), # элит −25.7% (n=16). Множитель применяется к median_price/median_ppm2 и # пропорционально к range_low/range_high СРАЗУ ПЕРЕД min-width floor, после # IMV-blend / corridor-clamp / quarter-index / hedonic / PI. Бэнд — по # median_ppm2 границами PRICE_SEGMENTS_PPM2 (единый источник в estimator.py). # Флаг default OFF → путь байт-в-байт идентичен (frozen gate не двигается). estimate_segment_multiplier_enabled: bool = False # Множители: калибровка live-бэктест OFF/ON sample=300 2026-07-03 (#2255). # Поправка применяется ТОЛЬКО к бизнес/элит — где занижение крупное и # однонаправленное. эконом/комфорт = 1.00 (no-op). # # Бэнд считается по PREDICTED ppm² (при оценке истинный сегмент неизвестен), # а точность меряется по SOLD ppm² → band-mismatch: эконом-sold сделки, # PREDICTED в бизнес (и так завышаемые +7.7%), получают ×множитель и # завышаются дальше. Поэтому агрессивный v4 (бизнес 1.12 / элит 1.10) # ОТВЕРГНУТ: overall MAPE +3.08pp, эконом MAPE 17.9→20.2. # # V2 (бизнес 1.08 / элит 1.06) — лучший трейд-офф: overall MAPE +1.87pp, # бизнес bias −10.4→−3.2, элит −30→−24.6, эконом почти intact (MAPE 18.0). # Дальнейшее сжатие bias без роста MAPE — только с confidence-гейтом # (не применять множитель на low-confidence предсказаниях) → follow-up issue. # NB (2026-08-29): «bias −10.4→−3.2» выше меряет схлопывание АРТЕФАКТА, а не # исправление ошибки — см. предупреждение в начале блока. Рост общего MAPE # +1.87pp при этом настоящий: множитель двигает реальные цены. # Пересчёт биасов — см. scripts/backtest_estimator.py --calibrate-segments. estimate_segment_multipliers: dict[str, float] = { "эконом": 1.00, "комфорт": 1.00, "бизнес": 1.08, "элит": 1.06, } # ── 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 # Внешние оценки (Yandex/Cian) не ждать в запросе, а догружать в фоне. # # Замер на проде 2026-08-22: расчёт по НОВОМУ адресу занимает 8-12 с, из них # ~6 с ждёт Yandex и ~1.5 с Cian. Собственные запросы к базе и сам расчёт # укладываются в секунду. По уже виденному адресу (кэш 24 ч) — 0.4-0.8 с. # # True: в запросе делается только чтение кэша; при промахе источник # деградирует в None, а свежая загрузка уходит в фон и наполняет кэш к # следующему обращению по тому же адресу. Ответ отдаётся за ~1 с. # # Деградация в None — НЕ новое состояние ответа: ровно так же ведёт себя # таймаут `estimate_*_valuation_timeout_s`, этот путь работает в проде # сегодня. Поэтому переключение не меняет контракт API. # # False (дефолт) — прежнее поведение: ждать источники в запросе. # ENV: ESTIMATE_EXTERNAL_SOURCES_BACKGROUND. estimate_external_sources_background: bool = Field( default=False, validation_alias="ESTIMATE_EXTERNAL_SOURCES_BACKGROUND" ) estimate_geocode_budget_s: float = 12.0 estimate_house_meta_timeout_s: float = 8.0 # #b2c-antiabuse-2: Avito IMV (evaluate_via_imv) была ЕДИНСТВЕННЫМ внешним # вызовом в /estimate БЕЗ _with_budget — до 3 последовательных HTTP-запросов # (warm-up + geocode + evaluate), каждый со своим таймаутом 25s # (_HTTP_TIMEOUT_SEC в scraper_kit.providers.avito.imv), плюс возможен ОДИН # internal retry с "очищенным" адресом на IMVAddressNotFoundError — необёрнутый # worst-case доходил до ~150s. 20s щедрее соседних бюджетов (8s Yandex/Cian/ # house_meta) намеренно — IMV делает МНОГО последовательных round-trip'ов, а # не один запрос, поэтому реалистичный "медленный, но живой" ответ длиннее. # ENV: ESTIMATE_AVITO_IMV_TIMEOUT_S. estimate_avito_imv_timeout_s: float = Field( default=20.0, validation_alias="ESTIMATE_AVITO_IMV_TIMEOUT_S" ) # Лимит успешных оценок trade-in за календарный месяц на аккаунт (#658). # Конфигурируется через env ESTIMATE_QUOTA_LIMIT. Default 15. estimate_quota_limit: int = 15 # ── B2C anti-abuse этап 2 (#b2c-antiabuse-2) ────────────────────────────── # Продукт открывается для анонимных пользователей — анонимный запрос БЕЗ # X-Authenticated-User (Caddy basic_auth) раньше трактовался как unlimited # безусловно (dev без Caddy). Недопустимо для публичного пути: любой # анонимный клиент получал бы безлимитные дорогие оценки. # # quota_dev_fail_open: явный флаг ТОЛЬКО для локальной разработки без Caddy. # По умолчанию ВЫКЛЮЧЕН — анонимный запрос без заголовка получает анонимную # квоту (anon-session cookie + IP), а не безлимит. True включает старое # fail-open поведение (username=None → unlimited) — задавай только в dev. # ENV: QUOTA_DEV_FAIL_OPEN. quota_dev_fail_open: bool = Field(default=False, validation_alias="QUOTA_DEV_FAIL_OPEN") # anon_estimate_quota_limit: месячный лимит успешных оценок на связку # (anon-session-cookie + client IP) для запросов БЕЗ X-Authenticated-User. # Существенно строже пилот-лимита (estimate_quota_limit=15) — анонимный # трафик не аутентифицирован и открыт всему интернету. Честно: смена IP или # чистка cookie обходит этот лимит — цель поднять стоимость злоупотребления, # а не сделать его невозможным (тот же принцип, что и account_estimate_usage # для пилотов). ENV: ANON_ESTIMATE_QUOTA_LIMIT. # # Значение 5 выбрано владельцем 16.08.2026 при разборе PR #2546 (в первой # редакции стояло 3). Компромисс продуктовый, а не технический: пять проб — # это больше шансов, что человек дойдёт до ценности и купит платный отчёт за # 150 ₽, ценой чуть более высокого потолка злоупотребления. Число легко # пересматривается переменной окружения без выкатки кода. anon_estimate_quota_limit: int = Field(default=5, validation_alias="ANON_ESTIMATE_QUOTA_LIMIT") # anon_session_secret: ключ HMAC-подписи анонимного session-cookie (см. # app/core/anon_session.py). Пусто (дефолт) → используется process-local # эфемерный секрет, сгенерированный при старте (secrets.token_bytes) — # криптографически стойкий (клиент его не знает и не может подделать # session_id), но НЕ переживает рестарт/не общий между несколькими # воркерами (каждый рестарт — новая генерация → старые cookie невалидны, # клиенты просто получают новую anon-сессию, деградация допустима). Для # стабильности между рестартами (текущий деплой — single-worker uvicorn) # задай явный секрет в .env.runtime. ENV: ANON_SESSION_SECRET. anon_session_secret: str = Field(default="", validation_alias="ANON_SESSION_SECRET") # Фильтр 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", "domclick.ru", "www.domclick.ru", } # ── Scraper mobile proxy (#806, #2616 шаг 2) ───────────────────────────── # Мобильный резидентный прокси (ASocks) используется ВСЕМИ scraper-сессиями: # Avito (#623) + Cian (#806) + Yandex. Datacenter-IP блокируется всеми тремя. # Пусто = прямое подключение (dev/staging без прокси). # # #2616 шаг 2: per-provider legacy-переменные (AVITO_PROXY_URL/CIAN_PROXY_URL/ # YANDEX_PROXY_URL и их *_ROTATE_URL, changeip mobileproxy) удалены — указывали # на закрытые аккаунты (407/connection refused, проверено вживую #2613). # 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") @property def scraper_proxy_url(self) -> str | None: """Единый прокси URL для всех scraper-сессий (Avito + Cian + Yandex). Прямая проекция SCRAPER_PROXY_URL (#2616 шаг 2: legacy AVITO_PROXY_URL fallback снят — мёртвая mobileproxy-переменная). """ return self.scraper_proxy_url_env # ── Ban-recovery budget knobs (changeip снят #2616 шаг 2) ──────────────── # Живая ротация IP — ASOCKS_API_TOKEN / app.services.proxy_rotation (#2611); # `_rotate_ip`/`_rotate_proxy_ip` остались no-op после удаления changeip-ссылки. # Обе ручки читаются: `*_proxy_max_rotations` гейтит бюджет попыток в # ban-rotation (scraper_kit.orchestration.pipeline._max_rotations), # `avito_proxy_rotate_settle_s` — верхняя граница asyncio.wait_for в # app.tasks.avito_detail_backfill. # #3212: proxy_rotate_attempts / proxy_rotate_attempt_timeout_s удалены — # ретраи changeip-GET, которых больше нет; ни одного читателя не осталось. avito_proxy_max_rotations: int = 4 avito_proxy_rotate_settle_s: float = 9.0 # ── ASocks pool-proxy rotation (#2600) ─────────────────────────────────── # Bearer-токен веб-кабинета ASocks для POST .../unlimited-proxy/{portId}/refresh-ip # (app.services.proxy_rotation). Документированный публичный API (GET # /v2/proxy/refresh/{portId}?apiKey=) для безлимитных портов не работает — # подтверждено владельцем аккаунта; единственный рабочий путь — эта ручка # веб-кабинета с сессионным токеном. Токен разово протухнет (осознанное # решение владельца) — тогда provider вернёт 401, proxy_rotation.rotate_proxy # логирует error + шлёт Sentry/GlitchTip alert. Пусто = ротация для всех # прокси недоступна (rotate_proxy возвращает внятный отказ, не падает). # ENV: ASOCKS_API_TOKEN. НИКОГДА не логировать / не возвращать в HTTP-ответе. asocks_api_token: str = Field(default="", validation_alias="ASOCKS_API_TOKEN") # #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 proxy budget (#2616 шаг 2: dedicated CIAN_PROXY_URL/ROTATE_URL снят) ── # Раньше Cian мог получить СВОЙ мобильный прокси отдельно от Avito (контеншен на # общем egress); CIAN_PROXY_URL указывал на закрытый аккаунт — удалён, # cian_proxy_url ниже теперь = scraper_proxy_url. cian_proxy_max_rotations # остаётся: гейтит бюджет в ban-rotation state machine наравне с avito/yandex # (см. комментарий у avito_proxy_max_rotations выше — сама ротация no-op). # 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 # #1949: если run_cian_city_sweep уже сохранил лоты (ins+upd > 0) и abort случился # из-за anchor-timeout/consecutive SERP failures ПОСЛЕ этого, ставим 'done' а не # 'banned' — основной сбор состоялся, 'banned' лишний шум в мониторинге. # Зеркалит avito_serp_ok_not_banned (#1950), но отдельный флаг — не смешиваем # cian/avito семантику. False = старое поведение. ENV: CIAN_SWEEP_OK_NOT_BANNED. cian_sweep_ok_not_banned: bool = True @property def cian_proxy_url(self) -> str | None: """Прокси для Cian-скраперов (#2616 шаг 2: = scraper_proxy_url, per-provider override снят — свойство оставлено для scraper_kit.contracts.ScraperConfig совместимости).""" return self.scraper_proxy_url # ── Yandex proxy budget (#2616 шаг 2: dedicated YANDEX_PROXY_URL/ROTATE_URL снят) ── # Симметрично Cian выше — YANDEX_PROXY_URL указывал на закрытый аккаунт. # yandex_proxy_max_rotations остаётся для ban-rotation budget-гейта. # ENV: YANDEX_PROXY_MAX_ROTATIONS. yandex_proxy_max_rotations: int = 4 # ── Порог полноты detail-страницы Яндекса (#3191) ──────────────────────── # Полная карточка оффера — 3-5 МБ; недорендеренная приходит с HTTP 200, валидным # HTML и БЕЗ блока контактов (наблюдалось 1,8 МБ). Размер — второй признак к # структурному (scraper_kit...yandex.detail.DETAIL_CONTACTS_MARKERS); любой из двух # даёт отказ, объявление остаётся в очереди (detail_enriched_at не проставляется). # ENV: YANDEX_DETAIL_MIN_HTML_BYTES. yandex_detail_min_html_bytes: int = 1_000_000 @property def yandex_proxy_url(self) -> str | None: """Прокси для Yandex-скраперов (#2616 шаг 2: = scraper_proxy_url).""" return 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 # ── ЭТАП 4 B2C launch — retention / erasure (152-ФЗ) ──────────────────── # trade_in_estimates.expires_at TTL (часы от момента создания). Раньше был # хардкод `timedelta(hours=24)` в estimator.py (x2: главный INSERT + # _empty_estimate fallback) — вынесено в настройку, чтобы retention-период # не требовал правки кода. 24ч — продуктовое решение MVP (оценка живёт # "сессию" клиента, не архив); юридически обоснованный срок хранения адреса # физлица для анонимного B2C — решение не инженера, см. итоговый комментарий # к задаче. ENV: TRADE_IN_ESTIMATE_RETENTION_HOURS. # # 27.08 — поднято 24 → 720 (30 суток) по решению владельца. Прод-факт: # у пилота «Практика» ВСЕ 75 оценок оказались недоступны, включая # позавчерашние, и это выглядело как пропажа данных. Строки были целы — # истёк `expires_at`, и карточка отдавала «Ссылка устарела». # # Обоснование «оценка живёт сессию клиента, не архив» писалось под # анонимный B2C. «Практика» — пилот-юрлицо: менеджер возвращается к # оценке через день-два, когда клиент перезванивает. Для такого сценария # суточный срок означает, что работа исчезает раньше, чем её успевают # использовать. # # NB: настройка ОДНА на оба контура. Юридический мотив 152-ФЗ (хранение # адреса физлица) относится к анонимному B2C, и для него 30 суток — # решение не инженерное. Разделение сроков по контурам — отдельная # задача; здесь сознательно поднят общий срок, а не сделан вид, что # контуры уже разведены. trade_in_estimate_retention_hours: int = 720 # trade_in_leads.expires_at TTL (дни от момента создания, migration 231). # У trade_in_leads раньше вообще не было срока хранения — лид (телефон + # согласие) жил в БД бессрочно. 180 дней (6 месяцев) — рабочий default для # НЕконвертированных маркетинговых лидов (типичный индустриальный диапазон # 90-180 дней при отсутствии дальнейшего договорного отношения с клиентом); # если лид конвертировался в реальную сделку/договор — для него должен # действовать ДРУГОЙ (договорной) срок хранения, но в кодовой базе нет # механизма отметки "лид конвертирован" — этого разграничения здесь НЕТ, # см. итоговый комментарий к задаче (конкретный юридически обоснованный # срок — решение DPO/юриста, не инженера). ENV: TRADE_IN_LEAD_RETENTION_DAYS. trade_in_lead_retention_days: int = 180 # ── Платный отчёт живёт год (retain_until, migration 240, PR #2754) ───── # trade_in_estimates.retain_until TTL (дни ОТ ОПЛАТЫ) — срок жизни ССЫЛКИ/ # СТРОКИ для оплаченной оценки, независимый от expires_at (актуальность # расчёта, 24ч, глобальный для ВСЕХ строк). НЕ трогает expires_at — см. # migration 240 докстринг. Отдельная колонка, а не подъём expires_at: # expires_at печатается в PDF/UI как «актуальность расчёта» и одинаков # для всех строк, поднять его до года = соврать в документе клиента про # свежесть цифры + нарушить минимизацию ПДн для неоплаченных B2C-адресов. # Единственный источник числа «12 месяцев» на фронте — # `mera-public/content.ts::PAID_REPORT_RETENTION_MONTHS`; текст оферты, # экран после оплаты и SQL продления retain_until при оплате (платёжный # код, отдельный PR) обязаны читать его оттуда, а не хардкодить — иначе # классический исход "в оферте 12 месяцев, в конфиге 365 дней, на экране # «год»". ENV: TRADE_IN_PAID_RETENTION_DAYS. trade_in_paid_retention_days: int = 365 # ── Revival на GET /estimate/{id} (incident 2026-08-10) ───────────────── # Throttle повторных попыток пересчёта «мёртвой» (median_price<=0/NULL) # сохранённой строки — записи, посчитанные ДО фикса оценщика (#oblast-E/F, # PR #2823/#2825) и навсегда застрявшие с median_price=0. GET пытается # пересчитать такую строку через тот же estimate_quality(), что и POST # (app/api/v1/trade_in.py::_try_revive_dead_estimate), не чаще одного раза # в это число минут на строку — иначе каждый refresh страницы бил бы по # геокодеру/DaData для объективно мёртвого адреса. 10 минут — компромисс: # достаточно редко, чтобы не спамить внешние сервисы, достаточно быстро, # чтобы повторный визит клиента после нашего фикса увидел живую цену. ENV: # TRADE_IN_REVIVAL_THROTTLE_MINUTES. trade_in_revival_throttle_minutes: int = 10 # Батч-размер физического DELETE в purge_expired_trade_in_data (нельзя одним # DELETE по всей таблице — долгая блокировка на большом бэклоге). Задача сама # крутит цикл батчей за один прогон (см. _DEFAULT_MAX_BATCHES в таске) — # это ограничивает ОДНУ транзакцию, не общий прогресс. ENV: # TRADE_IN_PURGE_BATCH_SIZE. trade_in_purge_batch_size: int = 500 # ── 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. # # ⚠️ Обоснование выше УСТАРЕЛО для Авито (замер 2026-08-21). Авито за QRATOR # отдаёт JS proof-of-work челлендж, который curl_cffi не решает: прогоны # 4348/4394/4508 — 3-4 обогащённых из 46-53 попыток (~6%) против ~74% на # браузерном пути. Опасение «browser превышает cap прокси-аккаунта auv» снято: # браузер сериализован BROWSER_CONCURRENCY=1 и ходит через тот же backconnect. # Прод переведён на браузер через docker-compose.prod.yml (environment # перекрывает env_file). Дефолт оставлен True, чтобы не менять поведение # других окружений вслепую. 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" ) # Ротация exit-IP по счётчику попыток на арендованном прокси (задача поверх #3298 # app.services.proxy_rotation.rotate_proxy). Замер вживую: билайновский порт держит # ~15 карточек до полного обвала (13/39, последняя четверть 0%), настоящий # мобильный оператор — ~30 (27/39, без обвала). Порог зависит от оператора порта, # поэтому настройка, а не константа — подстройка под конкретный IP без релиза. # Считаются ПОПЫТКИ (успех ИЛИ отказ), не только успехи: неудачная карточка тратит # бюджет IP так же, как удачная. ENV: AVITO_DETAIL_BACKFILL_ROTATE_AFTER_ATTEMPTS. avito_detail_backfill_rotate_after_attempts: int = Field( default=15, ge=1, validation_alias="AVITO_DETAIL_BACKFILL_ROTATE_AFTER_ATTEMPTS" ) # #3283g: ротация exit-IP НА САМ БАН площадки, а не только по счётчику попыток. # Бан привязан к IP (замерено вживую: rotate_proxy() лечит забаненный узел за # секунды, clear_source_bans гасит бан в scrape_proxy_source_bans — строка живёт # до purge, #3404), но # #3251/#3212 запрещают сбрасывать browser-context на КАЖДЫЙ блок -- сброс без # смены IP выбрасывает пройденный QRATOR-PoW и запускает самоподдерживающийся # каскад блоков на том же адресе. rotate_on_ban МЕНЯЕТ IP вместе со сбросом, # поэтому не подвержена этому каскаду и может срабатывать больше одного раза за # прогон -- но всё равно ограничена бюджетом, чтобы не выжигать прокси-пул на # длинной серии блоков. 0 = выключено, полный no-op (никаких доп. ротаций/логов). # ENV: AVITO_DETAIL_BACKFILL_ROTATE_ON_BAN_MAX. avito_detail_backfill_rotate_on_ban_max: int = Field( default=2, ge=0, validation_alias="AVITO_DETAIL_BACKFILL_ROTATE_ON_BAN_MAX" ) # Минимум попыток между ЛЮБЫМИ двумя ротациями (по счётчику ИЛИ по бану) -- # не даёт двум сбросам контекста идти подряд, даже если оба бюджета формально # ещё не исчерпаны. ENV: AVITO_DETAIL_BACKFILL_ROTATE_ON_BAN_MIN_GAP. avito_detail_backfill_rotate_on_ban_min_gap: int = Field( default=10, ge=0, validation_alias="AVITO_DETAIL_BACKFILL_ROTATE_ON_BAN_MIN_GAP" ) # #3184: доля блоков в скользящем окне последних N попыток -- критерий обрыва # avito_detail_backfill (app.services.backfill_block_breaker.BlockRatioBreaker), # взамен голого "N блоков подряд". ТОЛЬКО avito -- изначальный план распространить # тот же критерий на domclick_detail_backfill снят ревью (#3184 review MAJOR 1): # у Домклика один выделенный residential-прокси БЕЗ ротации (см. # data/sql/175_scrape_schedules_seed_domclick_detail_backfill.sql), калибровка # 20/0.7 сделана на пуле С ротацией (avito) и для него не годится без своего # замера -- отдельная задача. # Калибровка по 40 прогонам avito за 14 суток (2026-08-14..28): ВСЕ 40 # закончились 'banned', доля блоков колебалась 25-100% (25%/190 попыток честно # обогащённых 125, 39%/100, 46%/71, 32%/63, 100%/5) -- "N подряд" не отличал # выгоревший прокси-пул от здорового прогона, потому что блоки автокоррелированы # и пачки 5-6 подряд встречаются в каждом прогоне при базовой доле 25-46%. Окно # 20 выбрано заметно больше типичной пачки, порог 0.7 -- между здоровыми (25-48%) # и выгоревшими (100%) прогонами большой запас. ENV: DETAIL_BACKFILL_BLOCK_RATIO_ # WINDOW / _THRESHOLD -- подкрутка без релиза. # ge/le (#3184 review MINOR 2): WINDOW=0 или THRESHOLD в процентах (70 вместо # 0.7, частая опечатка оператора) иначе молча выключают критерий навсегда -- # 0.7 > 70 никогда не бывает True, а окно 0 схлопывает should_abort() в no-op. detail_backfill_block_ratio_window: int = Field( default=20, ge=1, validation_alias="DETAIL_BACKFILL_BLOCK_RATIO_WINDOW" ) detail_backfill_block_ratio_threshold: float = Field( default=0.7, ge=0.0, le=1.0, validation_alias="DETAIL_BACKFILL_BLOCK_RATIO_THRESHOLD" ) # ── #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 # ── #1995: sell-time-sensitivity — честная маркировка малой выборки ──────── # /estimate/{id}/sell-time-sensitivity бьёт лоты на 4 price-бакета (cheap/ # median/plus5/plus10) и считает median/p25/p75 exposure_days по каждому. # При n_lots ниже порога результат бакета шумный (замечена немонотонность: # +10% продаётся быстрее +5% — артефакт малой выборки, не data-баг). Вместо # тихого шума бакет с n_lots < порога помечается insufficient_data=True # (SellTimeBucket) — тот же паттерн, что и AvitoImvSummary.thin_market # (avito_imv_thin_market_threshold). НЕ сглаживаем/не выдумываем статистику — # честная маркировка. sell_time_sensitivity_min_n_lots: int = 10 # ── Telegram support bridge (@MERAsupport_bot) ─────────────────────────── # Клиент пишет боту в личку → зеркалится в топик support-группы → оператор # отвечает реплаем в топике → бот доставляет ответ клиенту. Standalone # long-polling воркер (app.tgbot_main), НЕ webhook — см. app/services/tgbot/. # Пусто/0 = бот выключен: tgbot_main логирует «disabled» и выходит с кодом 0 # (чтобы контейнер без секрета не крутил рестарт-луп). ENV: TELEGRAM_BOT_TOKEN, # TELEGRAM_SUPPORT_CHAT_ID, TELEGRAM_SUPPORT_TOPIC_ID. telegram_bot_token: str = Field(default="", validation_alias="TELEGRAM_BOT_TOKEN") # Telegram id форум-группы (супергруппы с включёнными топиками), куда # зеркалятся обращения клиентов. Отрицательный для supergroup id (напр. -100...). telegram_support_chat_id: int = Field(default=0, validation_alias="TELEGRAM_SUPPORT_CHAT_ID") # message_thread_id топика внутри support-группы, в который идут зеркала. telegram_support_topic_id: int = Field(default=0, validation_alias="TELEGRAM_SUPPORT_TOPIC_ID") # ── GlitchTip → Telegram алерты (мониторинг был нем, аудит 2026-08-15) ── # Отдельная тема от TELEGRAM_SUPPORT_TOPIC_ID выше — алерты об ошибках прода # НЕ должны литься в топик, куда пишут живые клиенты. См. app/api/v1/glitchtip.py. # Пусто/0 = вебхук отвечает 503 "not configured" (fail-closed, не fail-open — # это единственный auth-рубеж эндпоинта, в отличие от rbac-путей). # ENV: TELEGRAM_ALERTS_CHAT_ID, TELEGRAM_ALERTS_TOPIC_ID. telegram_alerts_chat_id: int = Field(default=0, validation_alias="TELEGRAM_ALERTS_CHAT_ID") telegram_alerts_topic_id: int = Field(default=0, validation_alias="TELEGRAM_ALERTS_TOPIC_ID") # Общий (не per-тему) лимит частоты отправки в ОДНУ группу — Telegram # считает ~20 сообщений/минуту на группу суммарно по всем её темам (#3471: # всплеск GlitchTip-алертов + поток поддержки в ту же группу давали 429 и # потерю сообщений). `TELEGRAM_SUPPORT_CHAT_ID`/`TELEGRAM_ALERTS_CHAT_ID` на # проде равны (одна группа, темы разные) — обе половины делят один # площадочный бюджет. # # РАЗДЕЛЕНО ПО РОЛЯМ (review H2, #3471), а не одна общая константа: лимитер # живёт in-memory В ЭКЗЕМПЛЯРЕ `TelegramClient`, а в эту группу пишут ДВА # независимых процесса — API под uvicorn (`app/services/tgbot/shared.py`, # интерактивные ручки + GlitchTip-вебхук) и контейнер бота (`app/tgbot_main.py`, # long-polling воркер). У них НЕТ общего счётчика (это отдельная задача — # Redis-based распределённый лимитер), поэтому если каждому дать по 18, # сумма (2×18=36) УДВОИТ площадочный лимit и 429 вернётся ровно там же. # Бюджет делится статически так, чтобы СУММА была заметно НИЖЕ ~20: у API # больше — там же интерактивные ответы клиентам, у бота меньше — там же # обычно только зеркалирование/уведомления, которые могут подождать дольше # (см. `TelegramGroupRateLimiter.acquire` про `max_wait=None` для фона). # 0/отрицательное значение выключает лимитер для соответствующего процесса. # # ЧЕСТНО ПРО ОГРАНИЧЕНИЕ ЭТОГО ДИЗАЙНА (review, #3471): # `telegram_group_rate_limit_api_per_minute` — ОДИН общий бюджет на ВСЕ # отправки процесса API в эту группу, а туда # пишут И зеркала веб-чата поддержки (`app/api/v1/support.py`), И # GlitchTip-алерты (`app/api/v1/glitchtip.py`) — обе ручки идут через один # и тот же `get_telegram_client()` (см. `app/services/tgbot/shared.py`). # Приоритета между ними НЕТ: кто первый встал в очередь `TelegramGroupRateLimiter`, # тот и получил слот. Оба пути передают узкий `timeout` (5с у support, 8с у # glitchtip) — он же становится потолком ожидания слота (см. # `TelegramClient._request`, review H1). Значит при всплеске алертов (пачка # ошибок прода бьёт в вебхук залпом) реально возможен сценарий: бюджет # 12/мин исчерпан алертами → следующая отправка живого клиента в веб-чате # ждёт до 5с и получает `TelegramRateLimitedError` → 502 клиенту поддержки. # То есть при достаточно большом всплеске алертов веб-чат ДЕЙСТВИТЕЛЬНО # может временно вставать. Разделить бюджет по ИСТОЧНИКУ (не по процессу) — # отдельная задача: нужен свой `TelegramGroupRateLimiter` на алерты с явно # малой квотой и/или приоритет для support-трафика; здесь НЕ сделано # (вне бюджета этой правки). telegram_group_rate_limit_api_per_minute: int = Field( default=12, validation_alias="TELEGRAM_GROUP_RATE_LIMIT_API_PER_MINUTE" ) telegram_group_rate_limit_bot_per_minute: int = Field( default=6, validation_alias="TELEGRAM_GROUP_RATE_LIMIT_BOT_PER_MINUTE" ) # ── Ретранслятор Bot API через Beget (#3471) ───────────────────────────── # Замер 12.09.2026, оба хоста в одни и те же минуты: `getMe` с Selectel — 9 # успешных из 12 (три ConnectTimeout), TCP-443 до адреса Selectel — 5/6, TCP-443 # до адреса Beget — 8/8; за сутки 508 строк `network error` в логе бота, за 30 # дней 92 обрыва итерации poll loop. Путь до Telegram с Selectel лоссовый, с # Beget чистый (Alertmanager там же шлёт без проблем) — поэтому продуктовый # трафик Bot API идёт через маленький HTTP-ретранслятор на Beget # (`ops/metrics/tg-relay`), а не напрямую. # # Пусто (дефолт) = прежнее поведение, прямой путь к api.telegram.org — это и # есть механизм отката, если ретранслятор сам подведёт. При заданном адресе # клиент (`app/services/tgbot/client.py`) всё равно делает одну попытку # напрямую при транспортном отказе похода на ретранслятор — хуже прямого # пути быть не должно ни при каких условиях. # ENV: TELEGRAM_RELAY_BASE_URL, TELEGRAM_RELAY_SECRET. telegram_relay_base_url: str = Field(default="", validation_alias="TELEGRAM_RELAY_BASE_URL") telegram_relay_secret: str = Field(default="", validation_alias="TELEGRAM_RELAY_SECRET") # ── Платёжный контур МЕРЫ (Т-Банк эквайринг) — схема-only PR-B ────────── # См. `mera-tbank-acquiring-recon.md` в корне репо. Этот PR НЕ содержит # роутеров/httpx-клиента/подписи Token — только поля конфига и kill-switch. # PAYMENTS_ENABLED=false (дефолт) держит контур выключенным полностью: # ни один из последующих PR (C/D/E) не должен активироваться без явного # включения в .env.runtime прод-стека. tbank_terminal_key: str = Field(default="", validation_alias="TBANK_TERMINAL_KEY") # Пароль терминала — участвует в подписи Token (Init) и проверке подписи # входящих нотификаций. SecretStr по прецеденту auth_db_password (строка # 197 выше): не должен всплыть в логах/repr/Sentry breadcrumbs. tbank_password: SecretStr = Field(default=SecretStr(""), validation_alias="TBANK_PASSWORD") tbank_api_base_url: str = Field( default="https://securepay.tinkoff.ru", validation_alias="TBANK_API_BASE_URL" ) tbank_notification_url: str = Field(default="", validation_alias="TBANK_NOTIFICATION_URL") tbank_success_url: str = Field(default="", validation_alias="TBANK_SUCCESS_URL") tbank_fail_url: str = Field(default="", validation_alias="TBANK_FAIL_URL") # "O" — одностадийная (оплата сразу), "T" — двухстадийная (холд + Confirm). # Дефолт "T": выбрана схема с холдом (гибрид «Проба → холд → отчёт по # ссылке», ядро — вариант B) — источник решения `mera-b2c-paid-flow- # decision.md` §1 в корне репо, НЕ recon-док (тот сам по себе выбирает # "O" — устарел этим решением). Не переставляй дефолт обратно на "O", не # сверившись с decision-доком. tbank_pay_type: Literal["O", "T"] = Field(default="T", validation_alias="TBANK_PAY_TYPE") tbank_receipt_enabled: bool = Field(default=False, validation_alias="TBANK_RECEIPT_ENABLED") tbank_taxation: str = Field(default="", validation_alias="TBANK_TAXATION") tbank_ffd_version: str = Field(default="", validation_alias="TBANK_FFD_VERSION") # Kill-switch всего контура. false — checkout/notify (появятся в PR-D) # обязаны отказывать сразу, ничего не вызывая у T-Bank. payments_enabled: bool = Field(default=False, validation_alias="PAYMENTS_ENABLED") # Kill-switch анонимного расчёта на публичном домене (POST/GET # /api/public/mera/estimate*). false — обе ручки отвечают 404, как будто их # нет: включение публичного расчёта открывает запись адреса физлица в # trade_in_estimates, и решение это продуктовое (нужна правка п.5.5 политики # обработки ПДн), а не «смержили код». Тот же приём и та же причина, что у # payments_enabled выше. ENV: PUBLIC_ESTIMATE_ENABLED. public_estimate_enabled: bool = Field(default=False, validation_alias="PUBLIC_ESTIMATE_ENABLED") settings = Settings()