Когда demand_normalization honesty-gate отбивает β rate-sensitivity (`sensitivity.confidence=='low'`, ЕКБ-регрессия не проходит n≥30/R²≥0.1/slope<0), все 393 прод-отчёта получают coefficient=1.0 для всех трёх сценариев conservative/base/aggressive. По дизайну backend честно деградирует, но фронт рисует 3 разноцветные карточки/линии одинаковых чисел без caveat. Backend (scenarios.py, report.py, report_assembler.py, orchestrator.py): - compute_scenarios теперь возвращает (list, collapsed: bool, reason: str|None) - _detect_collapsed(): math.isclose(rel_tol=1e-9, abs_tol=1e-6) сравнивает projected_demand_units И deficit_index на всех горизонтах между conservative и aggressive — расхождение в любой метрике на любом h → False - _COLLAPSE_REASON_LOW_BETA — единственный источник истины каноничного русского предложения - ReportScenarios получает поля scenarios_collapsed + scenarios_collapse_reason - demand_normalization.py НЕ ТРОНУТ — поведение корректно по контракту Backend экспортёры (report_pdf.py, excel.py): - PDF: при collapse — тёмная плашка-параграф вместо таблицы трёх столбцов - Excel: ОДНА строка «base» + caveat-ячейка вместо трёх идентичных столбцов Frontend (forecast.ts, ScenariosBlock, ScenarioCards, ScenarioCompareTable, ForecastChart, Section6Forecast, ptica.module.css): - Тип ReportScenarios расширен двумя optional-полями - ScenariosBlock (light): headline-bar + одна grid-карточка base + caveat - ScenarioCards (ptica dark): sub-component ScenarioBaseCard, одна карточка + collapse-note, ScenarioCompareTable return null - ForecastChart: effectiveScenarios filter — одна линия viz-1 «Базовый» вместо трёх перекрывающихся - Section6Forecast: caveat выше графика, читается ТОЛЬКО из scenarios_collapse_reason (источник истины — backend constant) Тесты: +18 новых (TestMetricsEqual×6, TestDetectCollapsed×7, TestComputeScenariosCollapseDetection×4 + 1 проверка graceful + corner). 102 forecasting passed, 88 cross-module passed. Refs #1871
427 lines
26 KiB
Python
427 lines
26 KiB
Python
"""§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
|
||
import math
|
||
from collections.abc import Sequence
|
||
from dataclasses import dataclass
|
||
from typing import Any, Final, 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
|
||
|
||
# §22 audit-issue #1871 P1: RU-объяснение, почему сценарии conservative/aggressive
|
||
# идентичны base. Срабатывает, когда §9.6 rate_sensitivity gate (n≥30 ∧ R²≥0.1 ∧
|
||
# slope<0) НЕ пройден на текущих данных ЕКБ — §9.4 demand_normalization возвращает
|
||
# coefficient=1.0 на любой rate_path → сценарии схлопываются. Это by-design (защита
|
||
# от шумной β), но фронт обязан показать ПОЧЕМУ три колонки совпадают, иначе таблица
|
||
# читается как баг. Текст уходит в отчёт через ReportScenarios.scenarios_collapse_reason
|
||
# и далее в PDF/Excel-плашку вместо таблицы трёх одинаковых столбцов.
|
||
_COLLAPSE_REASON_LOW_BETA: Final[str] = (
|
||
"β rate-sensitivity не прошёл gate (n≥30 ∧ R²≥0.1 ∧ slope<0) на текущих "
|
||
"данных ЕКБ — сценарная дифференциация conservative/aggressive восстановится "
|
||
"после расширения окна по ключевой ставке."
|
||
)
|
||
|
||
# Допуски сравнения metric'ов сценариев при детекции collapse (math.isclose). Берём
|
||
# rel_tol=1e-9 (~машинная точность float — после прогона #952 с одинаковым coefficient
|
||
# любые расхождения = только численный шум) + abs_tol=1e-6 (страхует около нуля, где
|
||
# rel_tol вырождается). Шире 1e-6 нельзя — рискуем затереть подлинные slight
|
||
# расхождения, когда β только-только прошёл gate.
|
||
_COLLAPSE_REL_TOL: Final[float] = 1e-9
|
||
_COLLAPSE_ABS_TOL: Final[float] = 1e-6
|
||
|
||
|
||
@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}
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# Collapse-детектор (§22 audit-issue #1871 P1). PURE, без БД, юнит-тестируется.
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _metrics_equal(a: float | None, b: float | None) -> bool:
|
||
"""Сравнить две метрики (projected_demand_units / deficit_index) на «равенство». PURE.
|
||
|
||
Оба None → True (одинаково «нет данных»). Один None, другой число → False (это
|
||
подлинное расхождение — нельзя считать схлопнутым). Оба числа → math.isclose с
|
||
_COLLAPSE_REL_TOL / _COLLAPSE_ABS_TOL (после §9.4 coefficient=1.0 любые расхождения
|
||
= численный шум; шире — рискуем замаскировать настоящие slight расхождения).
|
||
"""
|
||
if a is None and b is None:
|
||
return True
|
||
if a is None or b is None:
|
||
return False
|
||
return math.isclose(a, b, rel_tol=_COLLAPSE_REL_TOL, abs_tol=_COLLAPSE_ABS_TOL)
|
||
|
||
|
||
def _detect_collapsed(out: list[ScenarioForecast]) -> bool:
|
||
"""§22 audit-issue #1871 P1: схлопнулись ли conservative/aggressive в base? PURE.
|
||
|
||
«Схлопнулось» = на КАЖДОМ пересекающемся горизонте `projected_demand_units` И
|
||
`deficit_index` conservative совпадают с aggressive (с допуском `_metrics_equal`).
|
||
Это маркер «§9.4 demand_normalization вернул coefficient=1.0» — что бывает, когда
|
||
§9.6 rate_sensitivity gate не пройден и β-ом нельзя двигать спрос. Тогда три
|
||
сценария идентичны base, и фронт обязан показать ПОЧЕМУ.
|
||
|
||
Защитная семантика:
|
||
• Любой из сценариев отсутствует → False (без полной пары вердикта нет).
|
||
• Пустой `forecasts` у одного из сценариев → False (нет данных → нет вердикта
|
||
«схлопнулось»; иначе пустой отчёт ложно помечался бы как collapsed).
|
||
• Хоть один горизонт даёт расхождение по demand ИЛИ deficit → False
|
||
(защита от ЧАСТИЧНОГО collapse: если хотя бы один metric разошёлся, β
|
||
уже работает).
|
||
• Только когда ОБА metric'а совпадают на ВСЕХ пересекающихся горизонтах → True.
|
||
|
||
NB: base сравнивать не обязательно — §9.4 при failed gate возвращает 1.0 для
|
||
ВСЕХ rate_path, так что conservative ≡ base ≡ aggressive автоматически.
|
||
Проверяем только пару conservative/aggressive (крайние конверты — самый сильный
|
||
разнос ставки, поэтому самое чувствительное место для β).
|
||
|
||
Args:
|
||
out: три ScenarioForecast (conservative/base/aggressive) — выход
|
||
`compute_scenarios`.
|
||
|
||
Returns:
|
||
True, если crash β-gate схлопнул сценарии; False иначе.
|
||
"""
|
||
by_name = {s.scenario: s for s in out}
|
||
conservative = by_name.get("conservative")
|
||
aggressive = by_name.get("aggressive")
|
||
if conservative is None or aggressive is None:
|
||
return False
|
||
if not conservative.forecasts or not aggressive.forecasts:
|
||
return False
|
||
|
||
cons_by_h = {f.horizon_months: f for f in conservative.forecasts}
|
||
aggr_by_h = {f.horizon_months: f for f in aggressive.forecasts}
|
||
shared = set(cons_by_h.keys()) & set(aggr_by_h.keys())
|
||
if not shared:
|
||
return False
|
||
|
||
for h in shared:
|
||
cons_f = cons_by_h[h]
|
||
aggr_f = aggr_by_h[h]
|
||
if not _metrics_equal(cons_f.projected_demand_units, aggr_f.projected_demand_units):
|
||
return False
|
||
if not _metrics_equal(cons_f.deficit_index, aggr_f.deficit_index):
|
||
return False
|
||
return True
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# DB-оркестратор — тонкий, graceful. Pure-логика выше тестируется без него.
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def compute_scenarios(
|
||
db: Session,
|
||
*,
|
||
spec: SegmentSpec,
|
||
district: str | None,
|
||
cad_num: str,
|
||
horizons: Sequence[int] = _DEFAULT_HORIZONS,
|
||
) -> tuple[list[ScenarioForecast], bool, str | None]:
|
||
"""§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').
|
||
|
||
COLLAPSE-ДЕТЕКЦИЯ (§22 audit-issue #1871 P1): когда §9.6 rate_sensitivity gate
|
||
не пройден на текущих данных ЕКБ, §9.4 demand_normalization возвращает
|
||
coefficient=1.0 для ЛЮБОГО rate_path → conservative/aggressive prognosis
|
||
идентичны base. Это by-design, но в payload отчёта нужен флаг, чтобы фронт/
|
||
экспортёры показали ПОЧЕМУ три колонки совпадают (иначе таблица читается как баг).
|
||
Возвращаем кортеж `(scenarios, collapsed, collapse_reason)`, где
|
||
`collapse_reason = _COLLAPSE_REASON_LOW_BETA` ПРИ collapsed=True, иначе None.
|
||
|
||
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:
|
||
Кортеж `(scenarios, collapsed, collapse_reason)`:
|
||
• scenarios — список из ТРЁХ ScenarioForecast (conservative/base/aggressive);
|
||
• collapsed — True, если §22 audit-issue #1871 P1 collapse-детектор поймал
|
||
идентичность conservative/aggressive (failed β-gate);
|
||
• collapse_reason — `_COLLAPSE_REASON_LOW_BETA` при collapsed=True; None иначе.
|
||
"""
|
||
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,
|
||
)
|
||
)
|
||
|
||
collapsed = _detect_collapsed(out)
|
||
collapse_reason = _COLLAPSE_REASON_LOW_BETA if collapsed else None
|
||
|
||
logger.info(
|
||
"scenarios: district=%s cad_num=%s base_rate=%s horizons=%s scenarios=%d "
|
||
"collapsed=%s (ADVISORY)",
|
||
district,
|
||
cad_num,
|
||
_round_or_none(base_rate, 2),
|
||
horizon_list,
|
||
len(out),
|
||
collapsed,
|
||
)
|
||
return out, collapsed, collapse_reason
|
||
|
||
|
||
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
|