gendesign/tradein-mvp/backend/app/core/config.py
bot-backend b89788ee99 feat(tradein/proxy): прогон знает свой узел, а снятый бан перестаёт стирать историю (#3404)
Выбор оператора мобильного прокси опирался на две ненадёжные опоры.

Первая: `scrape_runs` не знала, через какой узел шёл прогон — колонка `proxy_id`
была только у банов и ротаций. «Какой узел собрал 5 карточек из 21» не выяснялось
ни одним запросом.

Вторая: `clear_source_bans` делала DELETE, а зовётся она после КАЖДОЙ успешной
ротации exit-IP. У #540723 (МегаФон) 23 успешные ротации и ноль строк банов,
у #540722 (Tele2) ротаций почти не было и 7 банов. «7 против 0» читалось как
«Tele2 хуже», хотя в той же мере это «у МегаФона историю стёрли 23 раза».

Теперь:
- `scrape_runs.proxy_id` — последний выданный прогону узел; полная цепочка
  (если узел менялся mid-run) копится в `counters.proxy_ids`. Пишет
  `proxy_pool.attribute_run_proxy` из единственной точки — сразу после выдачи
  лиза в `acquire()`, поэтому curl-путь, браузерный sticky lease и ре-acquire
  при ротации покрыты одинаково. `run_id` доходит до адаптера через ContextVar
  (`scraper_kit.orchestration.run_context`): протокол `ProxyProvider.acquire`
  его не несёт, а `RealProxyProvider` живёт одним объектом на весь планировщик.
  Best-effort: `lock_timeout` 2с и проглоченное исключение — диагностика не
  вправе ронять выдачу прокси или ждать на блокировке строки прогона.
- `clear_source_bans` гасит строку (`banned_until = now()`, `ban_count = 0`,
  `cleared_at`/`cleared_reason`) вместо удаления. Эскалация сохраняется 1:1:
  формула в `mark_banned` берёт ПРЕДЫДУЩИЙ `ban_count` показателем степени, при
  нуле это ровно `SOURCE_BAN_BASE_HOURS` — как после DELETE. Строка доживает до
  штатного purge по `SOURCE_BAN_PURGE_DAYS`.

Для всех читателей `scrape_proxy_source_bans` погашенная строка неотличима от
отсутствующей: acquire, оба guard-подзапроса `mark_banned`, `proxy_egress`
(ранжирование по `ban_count` даёт 0, как у узла без истории), admin `_active_ban` —
все гейтятся по `banned_until > now()`.

Ничего не бэкфиллится: связать прошедшие прогоны с узлами нечем (`leased_by`
исторически = NON_RUN_LEASE_MARKER), врать восстановленным значением нельзя.

Миграция 287. Тесты: 9 новых на обе части (главный — эскалация после гашения даёт
базовые 6ч, а не удвоенные) + 14 существующих переведены с DELETE-семантики на
гашение, включая проверку, что секрет ротации не утекает в новое `cleared_reason`.
Полный прогон бэкенда: 5600 passed, 37 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011WHFxVPWoBnSZihkdH1Uou
2026-09-06 13:05:20 +03:00

1330 lines
115 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 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²)×(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).
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")
# ── Платёжный контур МЕРЫ (Т-Банк эквайринг) — схема-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()