gendesign/backend/app/services/forecasting/scenarios.py
Light1YT 6322d10199 feat(forecasting): §11 macro-scenarios (conservative/base/aggressive) (#984, 954-A)
Add scenarios.py: run the #952 demand↔supply engine under three key-rate
envelopes (base = hold_last_rate flat; conservative +2.0 п.п.; aggressive
−3.0 п.п., widening with horizon, clamped ≥0) → one ScenarioForecast each.
Single lever = rate_path into #952 (β stays counted-once in §9.4). Pure
build_rate_envelopes separated from DB orchestrator; advisory inherits #952
cap; graceful no-macro. Deterministic, no LLM, no SQL. 47 tests.
2026-06-03 12:59:17 +05:00

313 lines
19 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.

"""§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). На h=12 мес
конверт = base_delta, на h=24 мес ≈ base_delta × (1 + 0.5×_HORIZON_WIDEN_PER_YEAR).
Держим ПРОСТО (один линейный множитель) и 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.51.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