"""§11 макро-сценарии: прогон #952 движка под тремя конвертами ключевой ставки. #984 (954-A, Site Finder v2 / «GG-форсайт» ТЗ §11), EPIC 10 «Сценарный анализ». Это **СБОРОЧНЫЙ слой над #952**: он НЕ пересобирает §9.x-математику и НЕ строит своих rate-path вручную per-горизонт. Он берёт ОДИН рычаг — `rate_path` входа `compute_demand_supply_forecast` (#952) — и прогоняет тот же движок ТРИЖДЫ под тремя конвертами ключевой ставки, давая по `ScenarioForecast` на сценарий: • base — текущая ключевая ставка, удержанная ПЛОСКО (#952 hold_last_rate). • conservative — ставка ВЫШЕ базы (+_CONSERVATIVE_RATE_DELTA_PP п.п.): дороже ипотека → спрос МЯГЧЕ → консервативный (осторожный) прогноз. • aggressive — ставка НИЖЕ базы (−_AGGRESSIVE_RATE_DELTA_PP п.п.): дешевле ипотека → спрос СИЛЬНЕЕ → агрессивный (оптимистичный) прогноз. ЕДИНСТВЕННОЕ ОТЛИЧИЕ сценариев — это rate_path; всё остальное (сегмент, район, участок, горизонты) общее. Сам §9.4 demand_normalization внутри #952 превращает Δ ставки в Δ спроса (через β, учтённый там РОВНО ОДИН РАЗ), поэтому здесь мы лишь сдвигаем ставку — НИКАКОЙ арифметики над спросом тут нет. Всё ДЕТЕРМИНИРОВАННО, БЕЗ LLM (чистый сдвиг ставки + reuse #952; своего SQL НЕТ). ADVISORY-СТАТУС: каждый сценарий наследует advisory-статус #952 и его жёсткий потолок confidence ≤ 'medium' (движок не провалидирован до бэктеста #951). Поэтому и весь сценарный слой СОВЕТУЮЩИЙ: `advisory` ВСЕГДА True. Цифры — для explainability/прототипа, не основание для инвест-решения. КОНВЕРТ ШИРИТСЯ С ГОРИЗОНТОМ (сознательно, документируем): дальние горизонты неопределённее, поэтому Δ ставки растёт линейно с горизонтом — delta_eff(h) = base_delta × (1 + _HORIZON_WIDEN_PER_YEAR × h/12). При _HORIZON_WIDEN_PER_YEAR=0.5: на h=12 мес конверт = base_delta × 1.5, на h=24 мес = base_delta × 2.0 (на каждый год горизонта +50% к base_delta). Держим ПРОСТО (один линейный множитель) и tunable. Ставка КЛАМПится ≥ 0 (нет отрицательной ключевой ставки) — на дальнем агрессивном горизонте конверт может упереть ставку в 0. Graceful-on-thin-data (дух #952): нет макро / нет базовой ставки → база None → конверты None на всех горизонтах → #952 деградирует к нейтрали ВНУТРИ себя (прогнозы с None-полями), но всё равно возвращаем ТРИ сценария. НИКОГДА не crash. """ from __future__ import annotations import logging from collections.abc import Sequence from dataclasses import dataclass from typing import Any, Literal from sqlalchemy.orm import Session from app.services.forecasting.demand_supply_forecast import ( DemandSupplyForecast, compute_demand_supply_forecast, hold_last_rate, ) from app.services.forecasting.macro_series import get_monthly_macro from app.services.forecasting.sales_series import SegmentSpec logger = logging.getLogger(__name__) Scenario = Literal["conservative", "base", "aggressive"] # ── Named-константы (§11 конверт ставки) ────────────────────────────────────── # Горизонты прогноза по умолчанию (мес) — зеркало #952 _DEFAULT_HORIZONS # (competitors / future_supply горизонт-сетка): полгода…2 года. _DEFAULT_HORIZONS: tuple[int, ...] = (6, 12, 18, 24) # КОНСЕРВАТИВНЫЙ сдвиг ставки ВВЕРХ относительно базы (п.п.). +2.0 п.п. ≈ один # крупный цикл ужесточения ДКП (совет директоров двигает ставку шагами 0.5–1.0 п.п.; # +2.0 п.п. = «ещё пара шоковых повышений» — осторожный, но не апокалиптичный # сценарий). Ставка выше → ипотека дороже → спрос мягче (§9.4 внутри #952). Tunable. _CONSERVATIVE_RATE_DELTA_PP: float = 2.0 # АГРЕССИВНЫЙ сдвиг ставки ВНИЗ относительно базы (п.п.). −3.0 п.п. ≈ полноценный # цикл смягчения ДКП (несимметрично шире консервативного: ЦБ снижает медленнее, чем # поднимает, поэтому «оптимизм» требует большего хода ставки, чтобы заметно # разогнать спрос). Ставка ниже → ипотека дешевле → спрос сильнее. Кламп ≥0. Tunable. _AGGRESSIVE_RATE_DELTA_PP: float = 3.0 # Расширение конверта с горизонтом (доля от base_delta на КАЖДЫЙ год горизонта). # delta_eff(h) = base_delta × (1 + _HORIZON_WIDEN_PER_YEAR × h/12): дальние горизонты # неопределённее, поэтому разброс ставки растёт. 0.5 = «+50% к Δ за каждый год» # (на 24 мес конверт ×2 шире, чем на 12 мес). Простой линейный множитель. Tunable. _HORIZON_WIDEN_PER_YEAR: float = 0.5 # Минимально допустимая ключевая ставка (%). Отрицательной ключевой ставки не # бывает — кламп снизу при агрессивном (вниз) сдвиге на дальнем горизонте. _MIN_RATE_PCT: float = 0.0 @dataclass(frozen=True) class ScenarioForecast: """Один §11 макро-сценарий: #952 движок под конкретным конвертом ставки. Все три сценария (conservative / base / aggressive) отличаются ТОЛЬКО `rate_path` (сдвиг ключевой ставки относительно базы); `forecasts` — выход #952 под этим путём, по одному DemandSupplyForecast на горизонт. `advisory` ВСЕГДА True — сценарий наследует advisory-статус #952 (движок не для production-решений до бэктеста #951). rate_path может содержать None-значения (нет базовой ставки — graceful: #952 деградирует к нейтрали внутри себя). """ scenario: Scenario rate_path: dict[int, float | None] # {горизонт: ставка сценария}; None = нет базы forecasts: list[DemandSupplyForecast] # выход #952 (по одному на горизонт) advisory: bool # ВСЕГДА True (наследует advisory #952) def as_dict(self) -> dict[str, Any]: return { "scenario": self.scenario, "rate_path": {h: _round_or_none(rate, 2) for h, rate in sorted(self.rate_path.items())}, "forecasts": [f.as_dict() for f in self.forecasts], "advisory": self.advisory, } def _round_or_none(value: float | None, digits: int) -> float | None: return round(value, digits) if value is not None else None # ────────────────────────────────────────────────────────────────────────────── # Pure-логика — без БД, полностью юнит-тестируемо. # ────────────────────────────────────────────────────────────────────────────── def _horizon_widen_factor( horizon_months: int, *, widen_per_year: float = _HORIZON_WIDEN_PER_YEAR ) -> float: """Множитель расширения конверта на горизонте ∈ [1, ∞). PURE. 1 + widen_per_year × h/12: на 12 мес = 1 + widen_per_year×1; нормируем так, чтобы h=12 давал «один год расширения». h ≤ 0 → 1.0 (нет расширения). widen_per_year ≤ 0 → 1.0 (расширение выключено — плоский конверт по горизонтам). PURE. Args: horizon_months: горизонт прогноза (мес); ≤0 → 1.0. widen_per_year: доля расширения base_delta на каждый год горизонта; ≤0 → 1.0. Returns: Множитель ≥ 1.0. """ if horizon_months <= 0 or widen_per_year <= 0.0: return 1.0 return 1.0 + widen_per_year * (horizon_months / 12.0) def _shift_rate(base_rate: float | None, delta_pp: float) -> float | None: """Сдвинуть базовую ставку на delta_pp п.п., кламп ≥ _MIN_RATE_PCT. PURE. base None → None (нет базы — нечего сдвигать; #952 деградирует к нейтрали). delta_pp > 0 = вверх (консервативный), < 0 = вниз (агрессивный). Результат клампится снизу к _MIN_RATE_PCT (отрицательной ключевой ставки не бывает). PURE. Args: base_rate: базовая ключевая ставка (%); None → None. delta_pp: сдвиг в п.п. (знаковый). Returns: Сдвинутая ставка (≥ _MIN_RATE_PCT) или None. """ if base_rate is None: return None return max(_MIN_RATE_PCT, base_rate + delta_pp) def build_rate_envelopes( base_rate: float | None, horizons: Sequence[int], *, conservative_delta_pp: float = _CONSERVATIVE_RATE_DELTA_PP, aggressive_delta_pp: float = _AGGRESSIVE_RATE_DELTA_PP, ) -> dict[str, dict[int, float | None]]: """Построить ТРИ именованных rate-path (§11 конверт ставки) из базовой ставки. PURE. Конверт по сценариям (на КАЖДЫЙ горизонт h): • base = base_rate (плоско — ставка не меняется). • conservative = base_rate + conservative_delta_pp × widen(h) (ставка ВЫШЕ). • aggressive = base_rate − aggressive_delta_pp × widen(h) (ставка НИЖЕ). где widen(h) = _horizon_widen_factor(h) расширяет конверт с горизонтом (дальние горизонты неопределённее). Все ставки клампятся ≥ _MIN_RATE_PCT. base_rate None → ВСЕ пути None на всех горизонтах (graceful: #952 деградирует к нейтрали внутри себя; мы всё равно отдаём три именованных конверта). PURE. ИНВАРИАНТ (при заданной базе): conservative[h] ≥ base[h] ≥ aggressive[h] на любом горизонте (равенство возможно лишь у aggressive при упоре в кламп _MIN_RATE_PCT). Args: base_rate: базовая ключевая ставка (%); None → все пути None. horizons: горизонты (мес), под которые строим пути. conservative_delta_pp: сдвиг вверх для conservative (по умолч. _CONSERVATIVE_RATE_DELTA_PP). aggressive_delta_pp: сдвиг вниз для aggressive (по умолч. _AGGRESSIVE_RATE_DELTA_PP). Returns: {"conservative"|"base"|"aggressive": {горизонт: ставка|None}}. """ conservative: dict[int, float | None] = {} base: dict[int, float | None] = {} aggressive: dict[int, float | None] = {} for h in horizons: widen = _horizon_widen_factor(h) base[h] = _shift_rate(base_rate, 0.0) conservative[h] = _shift_rate(base_rate, +conservative_delta_pp * widen) aggressive[h] = _shift_rate(base_rate, -aggressive_delta_pp * widen) return {"conservative": conservative, "base": base, "aggressive": aggressive} # ────────────────────────────────────────────────────────────────────────────── # DB-оркестратор — тонкий, graceful. Pure-логика выше тестируется без него. # ────────────────────────────────────────────────────────────────────────────── def compute_scenarios( db: Session, *, spec: SegmentSpec, district: str | None, cad_num: str, horizons: Sequence[int] = _DEFAULT_HORIZONS, ) -> list[ScenarioForecast]: """§11 три макро-сценария: прогон #952 движка под тремя конвертами ставки. ADVISORY (каждый сценарий наследует advisory-статус #952, см. module docstring) — НЕ подключать в production-эндпоинт. СБОРКА над #952, НЕ пересчёт §9.x. Поток: 1. base_rate = последняя известная key_rate (get_monthly_macro → hold_last_rate), удержанная плоско. Нет макро / нет точки key_rate → base_rate None. 2. build_rate_envelopes(base_rate, horizons) → три именованных rate-path. 3. Для КАЖДОГО сценария прогон compute_demand_supply_forecast ОДИН раз с его rate_path → list[DemandSupplyForecast] (по одному на горизонт). 4. Обернуть в ScenarioForecast (advisory=True). ⚠️ Сценарии отличаются ТОЛЬКО rate_path. Δ ставки → Δ спроса делает §9.4 внутри #952 (β учтён там РОВНО ОДИН РАЗ); здесь НИКАКОЙ арифметики над спросом нет. Graceful: нет макро / нет базовой ставки → конверты None → #952 отдаёт прогнозы с None-полями, но ТРИ сценария возвращаются всегда. НИКОГДА не crash. advisory всегда True (наследует #952 cap ≤ 'medium'). Args: db: SQLAlchemy sync Session. spec: целевой сегмент рынка (любой subset осей) — общий для всех сценариев. district: район для §9.3 supply + §9.2 metrics (None → ЕКБ-wide). cad_num: кадастровый номер участка — вход #952 (§9.7 конкуренты). horizons: горизонты прогноза (мес; по умолчанию _DEFAULT_HORIZONS). Returns: Список из ТРЁХ ScenarioForecast (conservative, base, aggressive) — всегда. """ horizon_list = list(horizons) # ── База: последняя известная key_rate, удержанная плоско (graceful → None) ── macro = get_monthly_macro(db) held = hold_last_rate(macro, horizon_list) base_rate = _first_non_none(held) # ── Три именованных конверта ставки (§11) ────────────────────────────────── envelopes = build_rate_envelopes(base_rate, horizon_list) out: list[ScenarioForecast] = [] for scenario in ("conservative", "base", "aggressive"): rate_path = envelopes[scenario] forecasts = compute_demand_supply_forecast( db, spec=spec, district=district, cad_num=cad_num, horizons=horizon_list, rate_path=_drop_none_rates(rate_path), ) out.append( ScenarioForecast( scenario=scenario, # type: ignore[arg-type] rate_path=rate_path, forecasts=forecasts, advisory=True, ) ) logger.info( "scenarios: district=%s cad_num=%s base_rate=%s horizons=%s scenarios=%d (ADVISORY)", district, cad_num, _round_or_none(base_rate, 2), horizon_list, len(out), ) return out def _first_non_none(rate_path: dict[int, float | None]) -> float | None: """Первая НЕ-None ставка из path (hold_last_rate отдаёт одно значение на всё). PURE. hold_last_rate присваивает одну и ту же last key_rate всем горизонтам, поэтому любая не-None — это и есть база. Все None (нет макро) → None (graceful). """ for rate in rate_path.values(): if rate is not None: return rate return None def _drop_none_rates(rate_path: dict[int, float | None]) -> dict[int, float] | None: """Свести сценарный path к {горизонт: ставка} для #952 (None-значения убрать). PURE. #952 ждёт `rate_path: dict[int, float] | None`; None-значения (нет базы) туда не кладём — пусть #952 сам деградирует к hold_last_rate / нейтрали для этих горизонтов. Path целиком из None → None (передаём None → #952 берёт свой дефолт). """ cleaned = {h: rate for h, rate in rate_path.items() if rate is not None} return cleaned or None