gendesign/backend/app/services/forecasting/scenarios.py
Light1YT dcccebf2c2
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Successful in 1m17s
CI / openapi-codegen-check (pull_request) Successful in 2m11s
CI / backend-tests (pull_request) Successful in 10m19s
fix(forecasting): honest scenarios collapse when β rate-sensitivity gate fails (audit #1871 P1)
Когда 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
2026-06-22 19:06:22 +05:00

427 lines
26 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). При
_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.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
# §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