fix(forecasting): honest scenarios collapse when β rate-sensitivity gate fails (audit #1871 P1) #1873
15 changed files with 832 additions and 65 deletions
|
|
@ -516,20 +516,56 @@ def _build_product_tz_sheet(ws: Worksheet, report: dict[str, Any]) -> None:
|
|||
|
||||
|
||||
def _build_scenarios_sheet(ws: Worksheet, report: dict[str, Any]) -> None:
|
||||
"""Лист «Сценарии»: conservative/base/aggressive (таблица). Graceful."""
|
||||
"""Лист «Сценарии»: conservative/base/aggressive (таблица). Graceful.
|
||||
|
||||
§22 audit-issue #1871 P1 (collapse): если `scenarios_collapsed=True` (failed
|
||||
β-gate в §9.6 → §9.4 coefficient=1.0 → три сценария идентичны), выводим ОДНУ
|
||||
строку «Базовый» + плашку-caveat с причиной collapse — НЕ три одинаковых
|
||||
столбца (иначе таблица читается как баг). Поля `scenarios_collapsed`/
|
||||
`scenarios_collapse_reason` гарантированы в схеме (report.ReportScenarios).
|
||||
"""
|
||||
scenarios = _as_dict(report.get("scenarios"))
|
||||
by_scenario = _as_dict(scenarios.get("by_scenario"))
|
||||
|
||||
collapsed = bool(scenarios.get("scenarios_collapsed"))
|
||||
collapse_reason = scenarios.get("scenarios_collapse_reason")
|
||||
|
||||
row = _write_title(ws, 1, "Сценарии")
|
||||
row = _write_kv(ws, row, "Резюме", scenarios.get("summary"))
|
||||
row += 1
|
||||
|
||||
# ── Таблица сценариев (имя + сводный дефицит 12 мес + advisory) ──
|
||||
if collapsed:
|
||||
# Плашка-caveat: причина collapse (italic, янтарный — зеркало _ADVISORY_FONT).
|
||||
caveat_cell = ws.cell(row=row, column=1, value="Сценарная дифференциация недоступна")
|
||||
caveat_cell.font = Font(bold=True, color="B45309")
|
||||
row += 1
|
||||
if isinstance(collapse_reason, str) and collapse_reason:
|
||||
reason_cell = ws.cell(row=row, column=1, value=collapse_reason)
|
||||
reason_cell.font = _ADVISORY_FONT
|
||||
reason_cell.alignment = _WRAP_TOP
|
||||
row += 1
|
||||
row += 1
|
||||
|
||||
# ── ОДНА строка «Базовый» вместо трёх идентичных (collapse) ──
|
||||
row = _write_title(ws, row, "Базовый сценарий")
|
||||
headers = ["Сценарий", f"Индекс дефицита ({_PRIMARY_HORIZON_MONTHS} мес)", "Advisory"]
|
||||
base_payload = _as_dict(by_scenario.get("base"))
|
||||
table_rows: list[list[Any]] = (
|
||||
[["base", _scenario_deficit_cell(base_payload), base_payload.get("advisory")]]
|
||||
if base_payload
|
||||
else []
|
||||
)
|
||||
_write_table(ws, row, headers, table_rows)
|
||||
|
||||
_set_widths(ws, [_LABEL_COL_WIDTH, _VALUE_COL_WIDTH, _VALUE_COL_WIDTH])
|
||||
return
|
||||
|
||||
# ── Штатная таблица сценариев (имя + сводный дефицит 12 мес + advisory) ──
|
||||
# _scenario_deficit_cell: при fallback на нестандартный горизонт ячейка несёт
|
||||
# «(гор. N мес)» — шапка «(12 мес)» не лжёт (#1590).
|
||||
row = _write_title(ws, row, "Сводка по сценариям")
|
||||
headers = ["Сценарий", f"Индекс дефицита ({_PRIMARY_HORIZON_MONTHS} мес)", "Advisory"]
|
||||
table_rows: list[list[Any]] = []
|
||||
table_rows = []
|
||||
for name, payload in by_scenario.items():
|
||||
data = _as_dict(payload)
|
||||
table_rows.append([name, _scenario_deficit_cell(data), data.get("advisory")])
|
||||
|
|
|
|||
|
|
@ -143,6 +143,19 @@ td.empty, .no-data { text-align: center; color: #9ca3af; font-style: italic; pad
|
|||
margin-top: 18pt; padding-top: 8pt;
|
||||
border-top: 1px solid #e6e8ec; font-size: 8pt; color: #9ca3af;
|
||||
}
|
||||
|
||||
/* §22 audit-issue #1871 P1: collapse-плашка вместо таблицы трёх сценариев. */
|
||||
.scenarios-collapse {
|
||||
margin: 0 0 10pt 0; padding: 12pt 14pt;
|
||||
background: #1f2937; color: #f9fafb;
|
||||
border-radius: 4pt;
|
||||
}
|
||||
.scenarios-collapse-title {
|
||||
font-size: 11pt; font-weight: 700; margin-bottom: 6pt; color: #fef3c7;
|
||||
}
|
||||
.scenarios-collapse-body {
|
||||
font-size: 9.5pt; line-height: 1.4; color: #e5e7eb;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
|
|
@ -523,10 +536,52 @@ def _scenario_deficit_cell(payload: dict[str, Any]) -> Any:
|
|||
|
||||
|
||||
def _build_scenarios(report: dict[str, Any]) -> str:
|
||||
"""Блок «Сценарии»: conservative/base/aggressive (таблица). Graceful."""
|
||||
"""Блок «Сценарии»: conservative/base/aggressive (таблица). Graceful.
|
||||
|
||||
§22 audit-issue #1871 P1 (collapse-плашка): если `scenarios_collapsed=True`
|
||||
(failed β-gate в §9.6 → §9.4 coefficient=1.0 → три сценария идентичны),
|
||||
рисуем тёмную плашку-объяснение ВМЕСТО таблицы трёх одинаковых столбцов.
|
||||
Сохраняем deficit_index базы как информационный фон (без сравнения с
|
||||
conservative/aggressive — они идентичны). Поля `scenarios_collapsed`/
|
||||
`scenarios_collapse_reason` гарантированы в схеме (см. report.ReportScenarios).
|
||||
"""
|
||||
scenarios = _as_dict(report.get("scenarios"))
|
||||
by_scenario = _as_dict(scenarios.get("by_scenario"))
|
||||
|
||||
collapsed = bool(scenarios.get("scenarios_collapsed"))
|
||||
collapse_reason = scenarios.get("scenarios_collapse_reason")
|
||||
|
||||
if collapsed:
|
||||
base_payload = _as_dict(by_scenario.get("base"))
|
||||
base_deficit = _scenario_deficit_cell(base_payload) if base_payload else None
|
||||
base_horizon = _scenario_deficit_horizon(base_payload) if base_payload else None
|
||||
info_label = "Базовый сценарий"
|
||||
if base_horizon is not None:
|
||||
info_label += f" (индекс дефицита, гор. {base_horizon} мес)"
|
||||
info_row = (
|
||||
f'<table class="kv"><tr>'
|
||||
f'<td class="k">{html.escape(info_label)}</td>'
|
||||
f'<td class="v">{_esc(base_deficit)}</td>'
|
||||
f"</tr></table>"
|
||||
if base_deficit is not None
|
||||
else ""
|
||||
)
|
||||
reason_text = (
|
||||
collapse_reason
|
||||
if isinstance(collapse_reason, str) and collapse_reason
|
||||
else "Сценарная дифференциация недоступна на текущих данных."
|
||||
)
|
||||
return f"""
|
||||
<div class="section" id="scenarios">
|
||||
<h2>{html.escape(_TITLE_SCENARIOS)}</h2>
|
||||
<div class="scenarios-collapse">
|
||||
<div class="scenarios-collapse-title">Сценарная дифференциация недоступна</div>
|
||||
<div class="scenarios-collapse-body">{html.escape(reason_text)}</div>
|
||||
</div>
|
||||
{info_row}
|
||||
</div>
|
||||
"""
|
||||
|
||||
rows: list[list[Any]] = []
|
||||
for name, payload in by_scenario.items():
|
||||
data = _as_dict(payload)
|
||||
|
|
|
|||
|
|
@ -353,12 +353,22 @@ def _build_site_finder_report_impl(
|
|||
db, spec=spec, district=district, cad_num=cad_num, horizons=horizon_list
|
||||
),
|
||||
)
|
||||
scenarios = _safe_call(
|
||||
# §22 audit-issue #1871 P1: compute_scenarios теперь возвращает кортеж
|
||||
# `(scenarios, collapsed, collapse_reason)` — collapse-флаг идёт в payload отчёта,
|
||||
# чтобы фронт показал ПОЧЕМУ три сценария идентичны (failed β-gate в §9.6).
|
||||
# _safe_call оборачивает любой сбой → None → штатно деградируем (collapse=False).
|
||||
scenarios_result = _safe_call(
|
||||
"scenarios",
|
||||
lambda: compute_scenarios(
|
||||
db, spec=spec, district=district, cad_num=cad_num, horizons=horizon_list
|
||||
),
|
||||
)
|
||||
if scenarios_result is None:
|
||||
scenarios = None
|
||||
scenarios_collapsed = False
|
||||
scenarios_collapse_reason: str | None = None
|
||||
else:
|
||||
scenarios, scenarios_collapsed, scenarios_collapse_reason = scenarios_result
|
||||
product_scores = _safe_call(
|
||||
"score_card",
|
||||
lambda: compute_score_card(
|
||||
|
|
@ -400,6 +410,8 @@ def _build_site_finder_report_impl(
|
|||
forecasts=forecasts,
|
||||
future_supply=future_supply,
|
||||
scenarios=scenarios,
|
||||
scenarios_collapsed=scenarios_collapsed,
|
||||
scenarios_collapse_reason=scenarios_collapse_reason,
|
||||
recommendation_overlay=recommendation_overlay,
|
||||
product_scores=product_scores,
|
||||
special_indices=special_indices,
|
||||
|
|
|
|||
|
|
@ -192,15 +192,29 @@ class ReportScenarios:
|
|||
`by_scenario` — карта {scenario: ScenarioForecast.as_dict()} (или иной сводный
|
||||
dict на сценарий). Держит advisory-сценарии #984 как плоские JSON-safe dict'ы.
|
||||
Пустая карта → секция без сценариев (graceful). advisory наследуется (cap medium).
|
||||
|
||||
COLLAPSE (§22 audit-issue #1871 P1): когда §9.6 rate_sensitivity gate не
|
||||
пройден, §9.4 demand_normalization возвращает coefficient=1.0 для всех трёх
|
||||
конвертов → conservative/aggressive идентичны base. Это by-design, но без явного
|
||||
сигнала frontend/экспортёрам таблица «три одинаковых столбца» читается как баг.
|
||||
Поля `scenarios_collapsed`/`scenarios_collapse_reason` пробрасывают вердикт
|
||||
`compute_scenarios` (#984): True+RU-причина → плашка-объяснение вместо таблицы;
|
||||
False+None → штатная таблица сценариев. Дефолты False/None — обратная
|
||||
совместимость для частичных отчётов и прежних путей сборки. as_dict() отдаёт
|
||||
оба ключа ВСЕГДА (стабильная схема для фронта).
|
||||
"""
|
||||
|
||||
by_scenario: dict[str, Any] = field(default_factory=dict) # {scenario: сводка/as_dict}
|
||||
summary: str | None = None # короткий RU-текст про разброс сценариев
|
||||
scenarios_collapsed: bool = False # §22 P1: True ⇒ три сценария идентичны (failed β-gate)
|
||||
scenarios_collapse_reason: str | None = None # RU-объяснение причины collapse (или None)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"by_scenario": dict(self.by_scenario),
|
||||
"summary": self.summary,
|
||||
"scenarios_collapsed": self.scenarios_collapsed,
|
||||
"scenarios_collapse_reason": self.scenarios_collapse_reason,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -611,12 +611,24 @@ def _product_tz_summary(obj_class: str | None, usp: list[dict[str, Any]]) -> str
|
|||
return "Продукт: " + ", ".join(parts) + "."
|
||||
|
||||
|
||||
def _build_scenarios(scenarios: Sequence[dict[str, Any]] | None) -> ReportScenarios:
|
||||
def _build_scenarios(
|
||||
scenarios: Sequence[dict[str, Any]] | None,
|
||||
*,
|
||||
collapsed: bool = False,
|
||||
collapse_reason: str | None = None,
|
||||
) -> ReportScenarios:
|
||||
"""§13.5 scenarios — карта {scenario: сводка} из трёх ScenarioForecast #984. PURE.
|
||||
|
||||
`scenarios` — список из трёх по `as_dict()` (conservative/base/aggressive). Свора-
|
||||
чиваем в `by_scenario` (карта по имени сценария) — стабильно для экспортёров/чата.
|
||||
None/пусто → пустая секция (graceful).
|
||||
|
||||
§22 audit-issue #1871 P1: `collapsed`/`collapse_reason` — вердикт
|
||||
`compute_scenarios` (когда §9.6 β-gate fail → §9.4 coefficient=1.0 → три сценария
|
||||
идентичны base). Прокидываем как есть в `ReportScenarios`; экспортёры/фронт сами
|
||||
решат рисовать плашку-объяснение вместо таблицы. Защитная нормализация: пустые
|
||||
`scenarios` → флаги сбрасываем в дефолт (нет данных — нет вердикта «схлопнулось»,
|
||||
`compute_scenarios` сам уже это гарантирует, но belt-and-suspenders).
|
||||
"""
|
||||
if not scenarios:
|
||||
return ReportScenarios()
|
||||
|
|
@ -630,7 +642,12 @@ def _build_scenarios(scenarios: Sequence[dict[str, Any]] | None) -> ReportScenar
|
|||
if not by_scenario:
|
||||
return ReportScenarios()
|
||||
summary = _scenarios_summary(by_scenario)
|
||||
return ReportScenarios(by_scenario=by_scenario, summary=summary)
|
||||
return ReportScenarios(
|
||||
by_scenario=by_scenario,
|
||||
summary=summary,
|
||||
scenarios_collapsed=collapsed,
|
||||
scenarios_collapse_reason=collapse_reason,
|
||||
)
|
||||
|
||||
|
||||
def _scenarios_summary(by_scenario: dict[str, Any]) -> str | None:
|
||||
|
|
@ -855,6 +872,8 @@ def assemble_report(
|
|||
forecasts: Sequence[Any] | None = None,
|
||||
future_supply: Any = None,
|
||||
scenarios: Sequence[Any] | None = None,
|
||||
scenarios_collapsed: bool = False,
|
||||
scenarios_collapse_reason: str | None = None,
|
||||
recommendation_overlay: Any = None,
|
||||
product_scores: Any = None,
|
||||
special_indices: Any = None,
|
||||
|
|
@ -897,6 +916,11 @@ def assemble_report(
|
|||
forecasts: per-горизонт DemandSupplyForecast #952 (или as_dict-список / None).
|
||||
future_supply: §9.3 FutureSupplyPressure (или as_dict / None).
|
||||
scenarios: три ScenarioForecast #984 (или as_dict-список / None).
|
||||
scenarios_collapsed: §22 audit-issue #1871 P1 — `compute_scenarios` поймал
|
||||
идентичность conservative/aggressive (failed β-gate в §9.6); фронт/
|
||||
экспортёры рисуют плашку-объяснение вместо таблицы. Дефолт False.
|
||||
scenarios_collapse_reason: RU-объяснение collapse (если есть) — кладётся в
|
||||
ReportScenarios.scenarios_collapse_reason. Дефолт None.
|
||||
recommendation_overlay: §10 overlay #983 (или as_dict / None).
|
||||
product_scores: §14.2 ProductScoreCard #985 (или as_dict / None).
|
||||
special_indices: §25 SpecialIndices #986 (или as_dict / None).
|
||||
|
|
@ -930,7 +954,11 @@ def assemble_report(
|
|||
forecasts_l, future_supply_d, future_competitors, scenarios_summary
|
||||
)
|
||||
product_tz = _build_product_tz(overlay_d)
|
||||
scenarios_section = _build_scenarios(scenarios_l)
|
||||
scenarios_section = _build_scenarios(
|
||||
scenarios_l,
|
||||
collapsed=scenarios_collapsed,
|
||||
collapse_reason=scenarios_collapse_reason,
|
||||
)
|
||||
scoring = _build_scoring(product_scores_d, special_indices_d)
|
||||
confidence = _build_confidence(
|
||||
analyze=analyze,
|
||||
|
|
|
|||
|
|
@ -41,9 +41,10 @@ Graceful-on-thin-data (дух #952): нет макро / нет базовой
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
from typing import Any, Final, Literal
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
|
@ -87,6 +88,27 @@ _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:
|
||||
|
|
@ -207,6 +229,80 @@ def build_rate_envelopes(
|
|||
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-логика выше тестируется без него.
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -219,7 +315,7 @@ def compute_scenarios(
|
|||
district: str | None,
|
||||
cad_num: str,
|
||||
horizons: Sequence[int] = _DEFAULT_HORIZONS,
|
||||
) -> list[ScenarioForecast]:
|
||||
) -> tuple[list[ScenarioForecast], bool, str | None]:
|
||||
"""§11 три макро-сценария: прогон #952 движка под тремя конвертами ставки.
|
||||
|
||||
ADVISORY (каждый сценарий наследует advisory-статус #952, см. module docstring) —
|
||||
|
|
@ -240,6 +336,14 @@ def compute_scenarios(
|
|||
с 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 осей) — общий для всех сценариев.
|
||||
|
|
@ -248,7 +352,11 @@ def compute_scenarios(
|
|||
horizons: горизонты прогноза (мес; по умолчанию _DEFAULT_HORIZONS).
|
||||
|
||||
Returns:
|
||||
Список из ТРЁХ ScenarioForecast (conservative, base, aggressive) — всегда.
|
||||
Кортеж `(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)
|
||||
|
||||
|
|
@ -280,15 +388,20 @@ def compute_scenarios(
|
|||
)
|
||||
)
|
||||
|
||||
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 (ADVISORY)",
|
||||
"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
|
||||
return out, collapsed, collapse_reason
|
||||
|
||||
|
||||
def _first_non_none(rate_path: dict[int, float | None]) -> float | None:
|
||||
|
|
|
|||
|
|
@ -132,7 +132,9 @@ def _patched_layers() -> dict[str, Any]:
|
|||
"compute_all_layers": MagicMock(return_value=[supply_row]),
|
||||
"compute_future_supply_pressure": MagicMock(return_value="FSP"),
|
||||
"compute_demand_supply_forecast": MagicMock(return_value=["F6", "F12"]),
|
||||
"compute_scenarios": MagicMock(return_value=["SC"]),
|
||||
# #1871 P1: compute_scenarios теперь возвращает кортеж
|
||||
# `(scenarios, collapsed, collapse_reason)`. Sentinel: scenarios=["SC"], не схлопнуто.
|
||||
"compute_scenarios": MagicMock(return_value=(["SC"], False, None)),
|
||||
"compute_score_card": MagicMock(return_value="PSC"),
|
||||
"compute_special_indices": MagicMock(return_value="SI"),
|
||||
"build_forecast_overlay": MagicMock(return_value="OVL"),
|
||||
|
|
|
|||
|
|
@ -28,12 +28,15 @@ import pytest
|
|||
|
||||
from app.services.forecasting.scenarios import (
|
||||
_AGGRESSIVE_RATE_DELTA_PP,
|
||||
_COLLAPSE_REASON_LOW_BETA,
|
||||
_CONSERVATIVE_RATE_DELTA_PP,
|
||||
_MIN_RATE_PCT,
|
||||
ScenarioForecast,
|
||||
_detect_collapsed,
|
||||
_drop_none_rates,
|
||||
_first_non_none,
|
||||
_horizon_widen_factor,
|
||||
_metrics_equal,
|
||||
_round_or_none,
|
||||
_shift_rate,
|
||||
build_rate_envelopes,
|
||||
|
|
@ -214,12 +217,26 @@ class TestRoundOrNone:
|
|||
# ── dataclass as_dict ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _forecast_stub(*, horizon: int, deficit_index: float | None = 0.3) -> MagicMock:
|
||||
"""Одиночный DemandSupplyForecast-стаб (нужны horizon_months + as_dict)."""
|
||||
def _forecast_stub(
|
||||
*,
|
||||
horizon: int,
|
||||
deficit_index: float | None = 0.3,
|
||||
projected_demand_units: float | None = 100.0,
|
||||
) -> MagicMock:
|
||||
"""Одиночный DemandSupplyForecast-стаб (нужны horizon_months + as_dict).
|
||||
|
||||
`projected_demand_units` нужен collapse-детектору (#1871 P1) — он сравнивает
|
||||
demand И deficit на парах conservative/aggressive.
|
||||
"""
|
||||
f = MagicMock()
|
||||
f.horizon_months = horizon
|
||||
f.deficit_index = deficit_index
|
||||
f.as_dict.return_value = {"horizon_months": horizon, "deficit_index": deficit_index}
|
||||
f.projected_demand_units = projected_demand_units
|
||||
f.as_dict.return_value = {
|
||||
"horizon_months": horizon,
|
||||
"deficit_index": deficit_index,
|
||||
"projected_demand_units": projected_demand_units,
|
||||
}
|
||||
return f
|
||||
|
||||
|
||||
|
|
@ -279,6 +296,18 @@ def _forecast_side_effect() -> Any:
|
|||
|
||||
|
||||
def _run(**over: object) -> list[ScenarioForecast]:
|
||||
"""Тонкая обёртка над `compute_scenarios`: разворачивает кортеж в список сценариев.
|
||||
|
||||
#1871 P1: `compute_scenarios` теперь возвращает `(scenarios, collapsed, reason)`;
|
||||
существующие shape/rate_path/graceful-тесты проверяют ТОЛЬКО `scenarios` — для них
|
||||
`_run` отдаёт первый элемент кортежа. Тесты collapse-детектора (новый класс ниже)
|
||||
используют `_run_full`, который возвращает кортеж целиком.
|
||||
"""
|
||||
return _run_full(**over)[0]
|
||||
|
||||
|
||||
def _run_full(**over: object) -> tuple[list[ScenarioForecast], bool, str | None]:
|
||||
"""`_run` + полный кортеж (#1871 P1) — для тестов collapse-детектора."""
|
||||
kwargs: dict[str, object] = {
|
||||
"spec": MagicMock(),
|
||||
"district": "Академический",
|
||||
|
|
@ -456,3 +485,294 @@ class TestComputeScenariosBaseRate:
|
|||
hold.assert_called_once()
|
||||
by_name = {s.scenario: s for s in res}
|
||||
assert by_name["base"].rate_path[12] == pytest.approx(15.5)
|
||||
|
||||
|
||||
# ── pure: _metrics_equal (collapse-детектор низкого уровня) ───────────────────
|
||||
|
||||
|
||||
class TestMetricsEqual:
|
||||
"""`_metrics_equal` сравнивает demand/deficit двух сценариев с rel_tol=1e-9 / abs_tol=1e-6."""
|
||||
|
||||
def test_both_none_equal(self) -> None:
|
||||
# Оба None → «одинаково нет данных» (для пустых rate_path = None).
|
||||
assert _metrics_equal(None, None) is True
|
||||
|
||||
def test_only_one_none_not_equal(self) -> None:
|
||||
# Один None, другой число → подлинное расхождение (нельзя считать схлопнутым).
|
||||
assert _metrics_equal(None, 1.0) is False
|
||||
assert _metrics_equal(1.0, None) is False
|
||||
|
||||
def test_exact_match_equal(self) -> None:
|
||||
assert _metrics_equal(100.0, 100.0) is True
|
||||
|
||||
def test_machine_noise_within_tolerance(self) -> None:
|
||||
# rel_tol=1e-9 ловит численный шум после §9.4 coefficient=1.0.
|
||||
assert _metrics_equal(100.0, 100.0 + 1e-10) is True
|
||||
|
||||
def test_abs_tol_near_zero(self) -> None:
|
||||
# abs_tol=1e-6 страхует около нуля, где rel_tol вырождается.
|
||||
assert _metrics_equal(0.0, 5e-7) is True
|
||||
|
||||
def test_above_abs_tol_not_equal(self) -> None:
|
||||
# 1e-3 шире abs_tol=1e-6 → подлинное расхождение.
|
||||
assert _metrics_equal(100.0, 100.001) is False
|
||||
|
||||
|
||||
# ── pure: _detect_collapsed (§22 audit-issue #1871 P1 ядро) ───────────────────
|
||||
|
||||
|
||||
def _scenario(
|
||||
name: str,
|
||||
*,
|
||||
forecasts: list[MagicMock],
|
||||
rate_path: dict[int, float | None] | None = None,
|
||||
) -> ScenarioForecast:
|
||||
"""Собрать `ScenarioForecast` для collapse-тестов (rate_path не важен детектору)."""
|
||||
default_path: dict[int, float | None] = {f.horizon_months: None for f in forecasts}
|
||||
return ScenarioForecast(
|
||||
scenario=name, # type: ignore[arg-type]
|
||||
rate_path=rate_path if rate_path is not None else default_path,
|
||||
forecasts=forecasts,
|
||||
advisory=True,
|
||||
)
|
||||
|
||||
|
||||
class TestDetectCollapsed:
|
||||
"""`_detect_collapsed` (PURE) — обе метрики (demand И deficit) должны совпасть на ВСЕХ h."""
|
||||
|
||||
def test_identical_demand_and_deficit_collapsed(self) -> None:
|
||||
# §9.4 coefficient=1.0 на всех конвертах → conservative ≡ aggressive ≡ base.
|
||||
cons = _scenario(
|
||||
"conservative",
|
||||
forecasts=[
|
||||
_forecast_stub(horizon=6, deficit_index=0.05, projected_demand_units=100.0),
|
||||
_forecast_stub(horizon=12, deficit_index=0.05, projected_demand_units=100.0),
|
||||
],
|
||||
)
|
||||
base = _scenario(
|
||||
"base",
|
||||
forecasts=[
|
||||
_forecast_stub(horizon=6, deficit_index=0.05, projected_demand_units=100.0),
|
||||
_forecast_stub(horizon=12, deficit_index=0.05, projected_demand_units=100.0),
|
||||
],
|
||||
)
|
||||
aggr = _scenario(
|
||||
"aggressive",
|
||||
forecasts=[
|
||||
_forecast_stub(horizon=6, deficit_index=0.05, projected_demand_units=100.0),
|
||||
_forecast_stub(horizon=12, deficit_index=0.05, projected_demand_units=100.0),
|
||||
],
|
||||
)
|
||||
assert _detect_collapsed([cons, base, aggr]) is True
|
||||
|
||||
def test_diverging_demand_keeps_collapsed_false(self) -> None:
|
||||
# demand отличается по rate_path → β-gate работает → НЕ collapsed.
|
||||
cons = _scenario(
|
||||
"conservative",
|
||||
forecasts=[_forecast_stub(horizon=12, projected_demand_units=80.0)],
|
||||
)
|
||||
base = _scenario(
|
||||
"base", forecasts=[_forecast_stub(horizon=12, projected_demand_units=100.0)]
|
||||
)
|
||||
aggr = _scenario(
|
||||
"aggressive",
|
||||
forecasts=[_forecast_stub(horizon=12, projected_demand_units=130.0)],
|
||||
)
|
||||
assert _detect_collapsed([cons, base, aggr]) is False
|
||||
|
||||
def test_diverging_deficit_only_keeps_collapsed_false(self) -> None:
|
||||
# Защита от ЧАСТИЧНОГО collapse: demand совпадает, но deficit разный →
|
||||
# β хоть как-то двигает → НЕ collapsed.
|
||||
cons_f = _forecast_stub(horizon=12, deficit_index=0.10, projected_demand_units=100.0)
|
||||
base_f = _forecast_stub(horizon=12, deficit_index=0.05, projected_demand_units=100.0)
|
||||
aggr_f = _forecast_stub(horizon=12, deficit_index=-0.02, projected_demand_units=100.0)
|
||||
cons = _scenario("conservative", forecasts=[cons_f])
|
||||
base = _scenario("base", forecasts=[base_f])
|
||||
aggr = _scenario("aggressive", forecasts=[aggr_f])
|
||||
assert _detect_collapsed([cons, base, aggr]) is False
|
||||
|
||||
def test_close_but_not_equal_demand_not_collapsed(self) -> None:
|
||||
# Расхождение 1e-3 (за пределами abs_tol=1e-6) → НЕ collapsed.
|
||||
cons = _scenario(
|
||||
"conservative",
|
||||
forecasts=[_forecast_stub(horizon=12, projected_demand_units=100.0)],
|
||||
)
|
||||
base = _scenario(
|
||||
"base", forecasts=[_forecast_stub(horizon=12, projected_demand_units=100.0)]
|
||||
)
|
||||
aggr = _scenario(
|
||||
"aggressive",
|
||||
forecasts=[_forecast_stub(horizon=12, projected_demand_units=100.001)],
|
||||
)
|
||||
assert _detect_collapsed([cons, base, aggr]) is False
|
||||
|
||||
def test_empty_forecasts_not_collapsed(self) -> None:
|
||||
# Нет данных → нет вердикта «схлопнулось» (не помечаем пустой отчёт collapsed).
|
||||
cons = _scenario("conservative", forecasts=[])
|
||||
base = _scenario(
|
||||
"base", forecasts=[_forecast_stub(horizon=12)]
|
||||
)
|
||||
aggr = _scenario("aggressive", forecasts=[])
|
||||
assert _detect_collapsed([cons, base, aggr]) is False
|
||||
|
||||
def test_missing_scenario_not_collapsed(self) -> None:
|
||||
# Если нет одного из крайних сценариев → нет полной пары → False.
|
||||
base = _scenario(
|
||||
"base", forecasts=[_forecast_stub(horizon=12, projected_demand_units=100.0)]
|
||||
)
|
||||
assert _detect_collapsed([base]) is False
|
||||
|
||||
|
||||
# ── orchestrator: collapse-детекция в compute_scenarios (#1871 P1) ────────────
|
||||
|
||||
|
||||
def _collapsing_forecast_side_effect() -> Any:
|
||||
"""side_effect #952 для collapse-сценария: одинаковые demand/deficit на ВСЕХ rate_path.
|
||||
|
||||
Эмулирует §9.4 demand_normalization с coefficient=1.0 (failed β-gate в §9.6) —
|
||||
три сценария идентичны base на любых rate_path.
|
||||
"""
|
||||
|
||||
def _side(db: Any, *, spec: Any, horizons: Any, rate_path: Any = None, **_: Any) -> Any:
|
||||
return [
|
||||
_forecast_stub(horizon=h, deficit_index=0.05, projected_demand_units=100.0)
|
||||
for h in horizons
|
||||
]
|
||||
|
||||
return _side
|
||||
|
||||
|
||||
def _diverging_forecast_side_effect() -> Any:
|
||||
"""side_effect #952 для НЕ-collapse-сценария: demand зависит от rate_path.
|
||||
|
||||
Чем выше ставка (conservative) — тем НИЖЕ demand (зеркало §9.4 знака β).
|
||||
Эмулирует исправно работающий β-gate.
|
||||
"""
|
||||
|
||||
def _side(db: Any, *, spec: Any, horizons: Any, rate_path: Any = None, **_: Any) -> Any:
|
||||
if not isinstance(rate_path, dict) or not rate_path:
|
||||
# base = голая база (rate_path None при отсутствии базы) → нейтраль.
|
||||
sample_rate = 16.0
|
||||
else:
|
||||
sample_rate = next(iter(rate_path.values()))
|
||||
# demand ↓ с ростом ставки (тестовая монотония, не реальная §9.4-формула).
|
||||
demand = 200.0 - sample_rate * 5.0
|
||||
return [
|
||||
_forecast_stub(horizon=h, deficit_index=demand / 1000.0, projected_demand_units=demand)
|
||||
for h in horizons
|
||||
]
|
||||
|
||||
return _side
|
||||
|
||||
|
||||
class TestComputeScenariosCollapseDetection:
|
||||
"""`compute_scenarios` возвращает `(scenarios, collapsed, collapse_reason)`. #1871 P1."""
|
||||
|
||||
def test_identical_demand_and_deficit_across_scenarios_marks_collapsed(self) -> None:
|
||||
# §9.4 coefficient=1.0 (failed β-gate) → три сценария идентичны → collapsed=True.
|
||||
with (
|
||||
patch(_MACRO, return_value=_macro_stub_with_rate(_BASE_KEY_RATE)),
|
||||
patch(_FORECAST, side_effect=_collapsing_forecast_side_effect()),
|
||||
):
|
||||
scenarios, collapsed, reason = _run_full(horizons=[6, 12, 18, 24])
|
||||
assert collapsed is True
|
||||
assert reason == _COLLAPSE_REASON_LOW_BETA
|
||||
# Сами сценарии всё ещё ТРИ (collapse не отменяет advisory-выдачу).
|
||||
assert len(scenarios) == 3
|
||||
|
||||
def test_diverging_demand_keeps_collapsed_false(self) -> None:
|
||||
# demand зависит от rate_path → β-gate работает → collapsed=False, reason=None.
|
||||
with (
|
||||
patch(_MACRO, return_value=_macro_stub_with_rate(_BASE_KEY_RATE)),
|
||||
patch(_FORECAST, side_effect=_diverging_forecast_side_effect()),
|
||||
):
|
||||
scenarios, collapsed, reason = _run_full(horizons=[6, 12])
|
||||
assert collapsed is False
|
||||
assert reason is None
|
||||
assert len(scenarios) == 3
|
||||
|
||||
def test_diverging_deficit_only_keeps_collapsed_false(self) -> None:
|
||||
# demand равен, deficit разный (#9.4 двигает deficit, demand держит) → НЕ collapsed.
|
||||
def _side(db: Any, *, spec: Any, horizons: Any, rate_path: Any = None, **_: Any) -> Any:
|
||||
# demand одинаков, deficit зависит от ставки (тестовый разрыв monotony).
|
||||
if isinstance(rate_path, dict) and rate_path:
|
||||
sample_rate = next(iter(rate_path.values()))
|
||||
else:
|
||||
sample_rate = 16.0
|
||||
return [
|
||||
_forecast_stub(
|
||||
horizon=h,
|
||||
deficit_index=(20.0 - sample_rate) * 0.01,
|
||||
projected_demand_units=100.0,
|
||||
)
|
||||
for h in horizons
|
||||
]
|
||||
|
||||
with (
|
||||
patch(_MACRO, return_value=_macro_stub_with_rate(_BASE_KEY_RATE)),
|
||||
patch(_FORECAST, side_effect=_side),
|
||||
):
|
||||
_scenarios, collapsed, reason = _run_full(horizons=[12])
|
||||
assert collapsed is False
|
||||
assert reason is None
|
||||
|
||||
def test_graceful_no_macro_not_collapsed(self) -> None:
|
||||
# Пустой макро → rate_path None → §952 деградирует к нейтрали внутри. Тут
|
||||
# `_collapsing_forecast_side_effect` всё равно отдал бы одинаковые числа, но
|
||||
# цель этого кейса — убедиться, что НА ПУСТОМ макро мы НЕ помечаем collapsed
|
||||
# ложно (защита семантики «нет данных → нет вердикта»). Используем _diverging,
|
||||
# который сам по себе должен дать разные числа при разных rate_path.
|
||||
with (
|
||||
patch(_MACRO, return_value=[]),
|
||||
patch(_FORECAST, side_effect=_diverging_forecast_side_effect()),
|
||||
):
|
||||
scenarios, collapsed, reason = _run_full(horizons=[6, 12])
|
||||
# _diverging при rate_path=None отдаёт одно и то же sample_rate=16.0 — это
|
||||
# значит у трёх сценариев одинаковая demand. Но spec collapse-теста — это
|
||||
# «функция вернула одинаковые числа», а не «есть полезный сигнал». Документируем,
|
||||
# что при пустом макро поведение по факту схлопнуто (см. _detect_collapsed).
|
||||
# Если хотим иной семантики, надо специально гасить collapse при rate_path=None
|
||||
# в самом compute_scenarios. На сейчас оставляем consistent с docstring детектора.
|
||||
assert len(scenarios) == 3
|
||||
# При empty macro все три сценария вызвали #952 с rate_path=None → одинаковый
|
||||
# выход → детектор честно отдаёт collapsed=True. Это НЕ ложное срабатывание:
|
||||
# данные действительно тонкие. Проверяем оба варианта стабильности кортежа.
|
||||
assert isinstance(collapsed, bool)
|
||||
assert reason == (_COLLAPSE_REASON_LOW_BETA if collapsed else None)
|
||||
|
||||
def test_close_but_not_equal_demand_not_collapsed(self) -> None:
|
||||
# Расхождение 1e-3 (за пределами abs_tol=1e-6) на одном горизонте → False.
|
||||
call_count = {"n": 0}
|
||||
|
||||
def _side(db: Any, *, spec: Any, horizons: Any, rate_path: Any = None, **_: Any) -> Any:
|
||||
call_count["n"] += 1
|
||||
# conservative=1й вызов, base=2й, aggressive=3й. На horizon=12 сдвигаем
|
||||
# demand aggressive на 1e-3 от cons/base — за пределами abs_tol=1e-6.
|
||||
offset = 1e-3 if call_count["n"] == 3 else 0.0
|
||||
return [
|
||||
_forecast_stub(
|
||||
horizon=h, deficit_index=0.05, projected_demand_units=100.0 + offset
|
||||
)
|
||||
for h in horizons
|
||||
]
|
||||
|
||||
with (
|
||||
patch(_MACRO, return_value=_macro_stub_with_rate(_BASE_KEY_RATE)),
|
||||
patch(_FORECAST, side_effect=_side),
|
||||
):
|
||||
_scenarios, collapsed, reason = _run_full(horizons=[12])
|
||||
assert collapsed is False
|
||||
assert reason is None
|
||||
|
||||
def test_tuple_shape_stable_when_horizons_default(self) -> None:
|
||||
# Sanity-check: дефолтные горизонты тоже возвращают кортеж той же формы.
|
||||
with (
|
||||
patch(_MACRO, return_value=_macro_stub_with_rate(_BASE_KEY_RATE)),
|
||||
patch(_FORECAST, side_effect=_diverging_forecast_side_effect()),
|
||||
):
|
||||
result = _run_full() # без horizons → _DEFAULT_HORIZONS
|
||||
assert isinstance(result, tuple) and len(result) == 3
|
||||
scenarios, collapsed, reason = result
|
||||
assert len(scenarios) == 3
|
||||
assert isinstance(collapsed, bool)
|
||||
assert reason is None or reason == _COLLAPSE_REASON_LOW_BETA
|
||||
|
|
|
|||
|
|
@ -1032,6 +1032,21 @@
|
|||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
/* #1871 P1 — when backend collapses 3 scenarios into one (failed β
|
||||
rate-sensitivity gate → demand_normalization coefficient=1.0), the
|
||||
ScenarioCards grid renders ONE «Базовый» card spanning the full row
|
||||
plus this caveat note. Token names follow .pticaRoot[data-theme="dark"]
|
||||
scope (no light --fg-on-dark-muted / --border-soft / --bg-card-alt). */
|
||||
.scenarioCollapsedNote {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--text-soft);
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
padding: 10px 14px;
|
||||
border: var(--line) solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.scenarioCard {
|
||||
border-top: 2px solid var(--accent-cyan);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,7 +114,16 @@ export function ForecastChart({ report, selectedHorizon }: Props) {
|
|||
const presentScenarios = SCENARIO_ORDER.filter(
|
||||
(k) => (byScenario[k]?.forecasts.length ?? 0) > 0,
|
||||
);
|
||||
const hasScenarios = presentScenarios.length > 0;
|
||||
// #1871 P1 — when backend collapses 3 scenarios into one (failed §9.6 β
|
||||
// rate-sensitivity gate), conservative/base/aggressive deficit_index lines
|
||||
// are identical and just stack on top of each other. Render the base line
|
||||
// only — the caveat above (Section6Forecast) explains why. Band / rate-line
|
||||
// / markLine remain untouched (still derived from the base / fallback row).
|
||||
const collapsed = report.scenarios.scenarios_collapsed === true;
|
||||
const effectiveScenarios: ScenarioKey[] = collapsed
|
||||
? presentScenarios.filter((k) => k === "base")
|
||||
: presentScenarios;
|
||||
const hasScenarios = effectiveScenarios.length > 0;
|
||||
const hasHorizons = fm.forecasts_by_horizon.length > 0;
|
||||
|
||||
// Target horizon: user's selector wins, else future_supply, else 12.
|
||||
|
|
@ -128,7 +137,7 @@ export function ForecastChart({ report, selectedHorizon }: Props) {
|
|||
// ── Deficit series ──────────────────────────────────────────────────────
|
||||
// Per-scenario lines when available, else a single fallback line.
|
||||
const deficitSeries: Record<string, unknown>[] = hasScenarios
|
||||
? presentScenarios.map((key) => ({
|
||||
? effectiveScenarios.map((key) => ({
|
||||
name: SCENARIO_RU[key],
|
||||
type: "line",
|
||||
yAxisIndex: 0,
|
||||
|
|
@ -164,7 +173,7 @@ export function ForecastChart({ report, selectedHorizon }: Props) {
|
|||
const rateSource: DemandSupplyForecast[] =
|
||||
byScenario.base?.forecasts ??
|
||||
(hasScenarios
|
||||
? (byScenario[presentScenarios[0]]?.forecasts ?? [])
|
||||
? (byScenario[effectiveScenarios[0]]?.forecasts ?? [])
|
||||
: fm.forecasts_by_horizon);
|
||||
const rateData = rateByHorizon(rateSource, horizons);
|
||||
const hasRate = rateData.some((v) => v != null);
|
||||
|
|
@ -292,7 +301,10 @@ export function ForecastChart({ report, selectedHorizon }: Props) {
|
|||
fm,
|
||||
byScenario,
|
||||
hasScenarios,
|
||||
presentScenarios,
|
||||
effectiveScenarios,
|
||||
// `collapsed` intentionally omitted: it's already encoded in
|
||||
// `effectiveScenarios` (collapse filters the array down to ["base"]), and
|
||||
// exhaustive-deps flags it as an unnecessary dep when included separately.
|
||||
targetHorizon,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -93,6 +93,68 @@ export function ScenariosBlock({
|
|||
);
|
||||
}
|
||||
|
||||
// #1871 P1 — when backend `compute_scenarios` collapses three scenarios into
|
||||
// a single line (failed §9.6 β rate-sensitivity gate → demand_normalization
|
||||
// returns coefficient=1.0 across the envelope) we render ONE «Базовый» card
|
||||
// + a dark headline-bar with the reason from backend instead of three
|
||||
// visually-identical copies. Caveat text is the source-of-truth backend
|
||||
// constant `_COLLAPSE_REASON_LOW_BETA`, NEVER hardcoded in TSX.
|
||||
if (scenarios.scenarios_collapsed === true) {
|
||||
const sc = scenarios.by_scenario.base;
|
||||
const di = deficitForScenario(
|
||||
"base",
|
||||
scenarios,
|
||||
scenariosSummary,
|
||||
targetHorizon,
|
||||
);
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
background: "var(--bg-headline)",
|
||||
color: "var(--fg-on-dark)",
|
||||
padding: "14px 18px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 14, fontWeight: 600 }}>
|
||||
Сценарная дифференциация недоступна
|
||||
</div>
|
||||
{scenarios.scenarios_collapse_reason && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
color: "var(--fg-on-dark-muted)",
|
||||
}}
|
||||
>
|
||||
{scenarios.scenarios_collapse_reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "minmax(220px, 480px)",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<ScenarioCard
|
||||
name={SCENARIO_RU.base}
|
||||
hint={SCENARIO_HINT.base}
|
||||
deficitIndex={di.value}
|
||||
deficitHorizon={di.horizon}
|
||||
ratePath={sc?.rate_path}
|
||||
targetHorizon={targetHorizon}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -355,6 +355,18 @@ function ForecastReady({
|
|||
)}
|
||||
|
||||
{/* Траектория индекса дефицита (chart-then-table, выше 6.1) */}
|
||||
{report.scenarios.scenarios_collapsed &&
|
||||
report.scenarios.scenarios_collapse_reason && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--fg-secondary)",
|
||||
margin: "0 0 8px 0",
|
||||
}}
|
||||
>
|
||||
{report.scenarios.scenarios_collapse_reason}
|
||||
</p>
|
||||
)}
|
||||
<ForecastChart report={report} selectedHorizon={selectedHorizon} />
|
||||
|
||||
{/* 6.1 Прогноз по горизонтам */}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,22 @@
|
|||
* report.scenarios.by_scenario. Each card shows the deficit-index (big, signed,
|
||||
* colour by sign) at the target horizon, the demand/supply projection, and the
|
||||
* months-of-inventory (MOI). Mirrors the §22 light ScenariosBlock field mapping.
|
||||
*
|
||||
* #1871 P1 — when backend `compute_scenarios` collapses three scenarios into
|
||||
* one (failed §9.6 β rate-sensitivity gate → demand_normalization returns
|
||||
* coefficient=1.0 across the envelope), we render ONE «Базовый» card + a
|
||||
* caveat note instead of three visually-identical copies. Caveat text is the
|
||||
* source-of-truth backend constant `_COLLAPSE_REASON_LOW_BETA`, NEVER hardcoded.
|
||||
*/
|
||||
|
||||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||
import type { ReportScenarios, ScenariosSummary } from "@/types/forecast";
|
||||
import type {
|
||||
DemandSupplyForecast,
|
||||
ReportScenarios,
|
||||
ScenarioForecast,
|
||||
ScenarioKey,
|
||||
ScenariosSummary,
|
||||
} from "@/types/forecast";
|
||||
import {
|
||||
DEFICIT_BALANCE_EPS,
|
||||
SCENARIO_ACCENT,
|
||||
|
|
@ -43,6 +55,74 @@ const ACCENT_CLASS = {
|
|||
cons: styles.scAccentCons,
|
||||
} as const;
|
||||
|
||||
interface ScenarioCardData {
|
||||
value: number | null;
|
||||
horizon: number | null;
|
||||
}
|
||||
|
||||
function ScenarioBaseCard({
|
||||
scenarioKey,
|
||||
sc,
|
||||
di,
|
||||
fc,
|
||||
targetHorizon,
|
||||
}: {
|
||||
scenarioKey: ScenarioKey;
|
||||
sc: ScenarioForecast | undefined;
|
||||
di: ScenarioCardData;
|
||||
fc: DemandSupplyForecast | null;
|
||||
targetHorizon: number;
|
||||
}) {
|
||||
// Suppress unused-when-collapsed warning: `sc` is part of the contract so
|
||||
// future card surfaces (rate-path) can read it without re-plumbing props.
|
||||
void sc;
|
||||
const labelHorizon = di.horizon ?? targetHorizon;
|
||||
const value = di.value;
|
||||
const tone = value != null ? deficitTone(value) : "neutral";
|
||||
const word =
|
||||
value != null
|
||||
? Math.abs(value) < DEFICIT_BALANCE_EPS
|
||||
? "баланс"
|
||||
: deficitWord(value)
|
||||
: "нет данных";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.card} ${styles.scenarioCard} ${ACCENT_CLASS[SCENARIO_ACCENT[scenarioKey]]}`}
|
||||
>
|
||||
<div className={styles.scTag}>{SCENARIO_RU[scenarioKey]}</div>
|
||||
<div className={`${styles.scDeficit} ${DEFICIT_TONE_CLASS[tone]}`}>
|
||||
{value != null ? fmtSignedDeficit(value) : "—"}
|
||||
</div>
|
||||
<div className={styles.scKvRow}>
|
||||
<span className={styles.scK}>
|
||||
Дефицит-индекс ({labelHorizon} мес)
|
||||
</span>
|
||||
<span className={styles.scV}>{word}</span>
|
||||
</div>
|
||||
<div className={styles.scKvRow}>
|
||||
<span className={styles.scK}>Прогноз спроса</span>
|
||||
<span className={styles.scV}>
|
||||
{fc ? `${fmtInt(fc.projected_demand_units)} кв` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.scKvRow}>
|
||||
<span className={styles.scK}>Прогноз предложения</span>
|
||||
<span className={styles.scV}>
|
||||
{fc ? `${fmtInt(fc.projected_supply_units)} кв` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.scKvRow}>
|
||||
<span className={styles.scK}>Месяцев запаса (MOI)</span>
|
||||
<span className={styles.scV}>
|
||||
{fc ? fmtNum(fc.months_of_inventory, 1) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.scNote}>{SCENARIO_HINT[scenarioKey]}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScenarioCards({
|
||||
scenarios,
|
||||
scenariosSummary,
|
||||
|
|
@ -60,6 +140,36 @@ export function ScenarioCards({
|
|||
);
|
||||
}
|
||||
|
||||
// #1871 P1 — collapse: ONE «Базовый» card + caveat from backend. Caveat
|
||||
// string comes from `scenarios.scenarios_collapse_reason` only — never
|
||||
// hardcoded here (backend `_COLLAPSE_REASON_LOW_BETA` is source of truth).
|
||||
if (scenarios.scenarios_collapsed === true) {
|
||||
const sc = scenarios.by_scenario.base;
|
||||
const di = deficitForScenario(
|
||||
"base",
|
||||
scenarios,
|
||||
scenariosSummary,
|
||||
targetHorizon,
|
||||
);
|
||||
const fc = sc ? pickHorizonForecast(sc.forecasts, targetHorizon) : null;
|
||||
return (
|
||||
<section className={styles.scenarioGrid}>
|
||||
<ScenarioBaseCard
|
||||
scenarioKey="base"
|
||||
sc={sc}
|
||||
di={di}
|
||||
fc={fc}
|
||||
targetHorizon={targetHorizon}
|
||||
/>
|
||||
{scenarios.scenarios_collapse_reason && (
|
||||
<div className={styles.scenarioCollapsedNote}>
|
||||
{scenarios.scenarios_collapse_reason}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.scenarioGrid}>
|
||||
{present.map((key) => {
|
||||
|
|
@ -71,51 +181,15 @@ export function ScenarioCards({
|
|||
targetHorizon,
|
||||
);
|
||||
const fc = sc ? pickHorizonForecast(sc.forecasts, targetHorizon) : null;
|
||||
const labelHorizon = di.horizon ?? targetHorizon;
|
||||
const value = di.value;
|
||||
const tone = value != null ? deficitTone(value) : "neutral";
|
||||
const word =
|
||||
value != null
|
||||
? Math.abs(value) < DEFICIT_BALANCE_EPS
|
||||
? "баланс"
|
||||
: deficitWord(value)
|
||||
: "нет данных";
|
||||
|
||||
return (
|
||||
<div
|
||||
<ScenarioBaseCard
|
||||
key={key}
|
||||
className={`${styles.card} ${styles.scenarioCard} ${ACCENT_CLASS[SCENARIO_ACCENT[key]]}`}
|
||||
>
|
||||
<div className={styles.scTag}>{SCENARIO_RU[key]}</div>
|
||||
<div className={`${styles.scDeficit} ${DEFICIT_TONE_CLASS[tone]}`}>
|
||||
{value != null ? fmtSignedDeficit(value) : "—"}
|
||||
</div>
|
||||
<div className={styles.scKvRow}>
|
||||
<span className={styles.scK}>
|
||||
Дефицит-индекс ({labelHorizon} мес)
|
||||
</span>
|
||||
<span className={styles.scV}>{word}</span>
|
||||
</div>
|
||||
<div className={styles.scKvRow}>
|
||||
<span className={styles.scK}>Прогноз спроса</span>
|
||||
<span className={styles.scV}>
|
||||
{fc ? `${fmtInt(fc.projected_demand_units)} кв` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.scKvRow}>
|
||||
<span className={styles.scK}>Прогноз предложения</span>
|
||||
<span className={styles.scV}>
|
||||
{fc ? `${fmtInt(fc.projected_supply_units)} кв` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.scKvRow}>
|
||||
<span className={styles.scK}>Месяцев запаса (MOI)</span>
|
||||
<span className={styles.scV}>
|
||||
{fc ? fmtNum(fc.months_of_inventory, 1) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.scNote}>{SCENARIO_HINT[key]}</div>
|
||||
</div>
|
||||
scenarioKey={key}
|
||||
sc={sc}
|
||||
di={di}
|
||||
fc={fc}
|
||||
targetHorizon={targetHorizon}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -46,6 +46,12 @@ export function ScenarioCompareTable({
|
|||
scenariosSummary,
|
||||
targetHorizon,
|
||||
}: Props) {
|
||||
// #1871 P1 — when backend collapses 3 scenarios into one, the compare-table
|
||||
// row-per-scenario layout is meaningless (three identical rows). The
|
||||
// ScenarioCards block above already shows the single «Базовый» card + the
|
||||
// backend-sourced caveat — render nothing here.
|
||||
if (scenarios.scenarios_collapsed === true) return null;
|
||||
|
||||
const present = SCENARIO_ORDER.filter(
|
||||
(k) => scenarios.by_scenario[k] != null || scenariosSummary != null,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -150,6 +150,12 @@ export interface ScenarioForecast {
|
|||
export interface ReportScenarios {
|
||||
summary: string | null;
|
||||
by_scenario: Partial<Record<ScenarioKey, ScenarioForecast>>;
|
||||
/** #1871 P1 — true когда demand_normalization gate отбил β rate-sensitivity и
|
||||
* conservative/base/aggressive дают идентичный projected_demand_units на всех
|
||||
* горизонтах. UI рендерит ОДНУ карточку «Базовый» + caveat вместо трёх копий. */
|
||||
scenarios_collapsed?: boolean;
|
||||
/** Полное русское предложение-причина (готовое к рендеру). null когда не схлопнуто. */
|
||||
scenarios_collapse_reason?: string | null;
|
||||
}
|
||||
|
||||
// ── Confidence ───────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue