Когда 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
778 lines
36 KiB
Python
778 lines
36 KiB
Python
"""Unit-тесты §11 макро-сценариев (#984, 954-A, ADVISORY).
|
||
|
||
Чистые тесты — БЕЗ живой БД (мок #952 compute_demand_supply_forecast +
|
||
get_monthly_macro):
|
||
• pure build_rate_envelopes: три именованных path; инвариант conservative ≥ base ≥
|
||
aggressive на КАЖДОМ горизонте; кламп ставки ≥ 0; расширение конверта с
|
||
горизонтом; base None → все пути None.
|
||
• pure helpers: _horizon_widen_factor (монотонно, h≤0/выкл → 1.0), _shift_rate
|
||
(знак + кламп ≥0 + None passthrough), _first_non_none, _drop_none_rates.
|
||
• compute_scenarios через MagicMock-сессию + @patch #952 и get_monthly_macro:
|
||
ТРИ сценария; у каждого свой rate_path; advisory ВСЕГДА True; conservative
|
||
rate_path выше aggressive; rate_path проброшен в #952; graceful (нет макро →
|
||
три сценария с None-rate_path, не crash).
|
||
|
||
Детерминированно, без LLM. Мокаем #952 + get_monthly_macro + db (нет живой БД).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from typing import Any
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
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,
|
||
compute_scenarios,
|
||
)
|
||
|
||
# Пути патча #952 и макро-ридера (импортированы в модуль scenarios).
|
||
_FORECAST = "app.services.forecasting.scenarios.compute_demand_supply_forecast"
|
||
_MACRO = "app.services.forecasting.scenarios.get_monthly_macro"
|
||
_HOLD = "app.services.forecasting.scenarios.hold_last_rate"
|
||
|
||
|
||
# ── pure: _horizon_widen_factor ───────────────────────────────────────────────
|
||
|
||
|
||
class TestHorizonWidenFactor:
|
||
def test_twelve_months_is_one_year(self) -> None:
|
||
# h=12 → 1 + widen×1 (один год расширения).
|
||
assert _horizon_widen_factor(12, widen_per_year=0.5) == pytest.approx(1.5)
|
||
|
||
def test_monotonic_in_horizon(self) -> None:
|
||
f6 = _horizon_widen_factor(6)
|
||
f12 = _horizon_widen_factor(12)
|
||
f24 = _horizon_widen_factor(24)
|
||
assert f6 < f12 < f24
|
||
|
||
def test_nonpositive_horizon_no_widening(self) -> None:
|
||
assert _horizon_widen_factor(0) == 1.0
|
||
assert _horizon_widen_factor(-5) == 1.0
|
||
|
||
def test_widen_off_when_zero(self) -> None:
|
||
# widen_per_year ≤ 0 → плоский конверт (множитель 1.0 на любом горизонте).
|
||
assert _horizon_widen_factor(24, widen_per_year=0.0) == 1.0
|
||
|
||
def test_at_least_one(self) -> None:
|
||
assert _horizon_widen_factor(6) >= 1.0
|
||
|
||
|
||
# ── pure: _shift_rate ─────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestShiftRate:
|
||
def test_up_shift(self) -> None:
|
||
assert _shift_rate(16.0, +2.0) == pytest.approx(18.0)
|
||
|
||
def test_down_shift(self) -> None:
|
||
assert _shift_rate(16.0, -3.0) == pytest.approx(13.0)
|
||
|
||
def test_clamps_at_min(self) -> None:
|
||
# Сдвиг вниз ниже нуля → кламп к _MIN_RATE_PCT (нет отрицательной ставки).
|
||
assert _shift_rate(2.0, -5.0) == _MIN_RATE_PCT
|
||
|
||
def test_none_passthrough(self) -> None:
|
||
assert _shift_rate(None, +2.0) is None
|
||
|
||
def test_zero_shift_is_base(self) -> None:
|
||
assert _shift_rate(16.0, 0.0) == pytest.approx(16.0)
|
||
|
||
|
||
# ── pure: build_rate_envelopes (центральный §11 конверт) ──────────────────────
|
||
|
||
|
||
class TestBuildRateEnvelopes:
|
||
def test_three_named_paths(self) -> None:
|
||
env = build_rate_envelopes(16.0, [6, 12])
|
||
assert set(env.keys()) == {"conservative", "base", "aggressive"}
|
||
|
||
def test_each_path_covers_all_horizons(self) -> None:
|
||
env = build_rate_envelopes(16.0, [6, 12, 18, 24])
|
||
for name in ("conservative", "base", "aggressive"):
|
||
assert set(env[name].keys()) == {6, 12, 18, 24}
|
||
|
||
def test_base_is_flat_hold(self) -> None:
|
||
# base = base_rate на КАЖДОМ горизонте (ставка не меняется).
|
||
env = build_rate_envelopes(16.0, [6, 12, 24])
|
||
assert env["base"] == {6: 16.0, 12: 16.0, 24: 16.0}
|
||
|
||
def test_conservative_above_base_above_aggressive(self) -> None:
|
||
# ИНВАРИАНТ §11: conservative ≥ base ≥ aggressive на любом горизонте.
|
||
env = build_rate_envelopes(16.0, [6, 12, 18, 24])
|
||
for h in (6, 12, 18, 24):
|
||
cons = env["conservative"][h]
|
||
base = env["base"][h]
|
||
aggr = env["aggressive"][h]
|
||
assert cons is not None and base is not None and aggr is not None
|
||
assert cons > base > aggr
|
||
|
||
def test_conservative_uses_up_delta_at_one_year(self) -> None:
|
||
# На h=12 widen=1.5 → conservative = base + delta×1.5.
|
||
env = build_rate_envelopes(16.0, [12])
|
||
expected = 16.0 + _CONSERVATIVE_RATE_DELTA_PP * 1.5
|
||
assert env["conservative"][12] == pytest.approx(expected)
|
||
|
||
def test_aggressive_uses_down_delta_at_one_year(self) -> None:
|
||
env = build_rate_envelopes(16.0, [12])
|
||
expected = 16.0 - _AGGRESSIVE_RATE_DELTA_PP * 1.5
|
||
assert env["aggressive"][12] == pytest.approx(expected)
|
||
|
||
def test_envelope_widens_with_horizon(self) -> None:
|
||
# Разброс conservative−aggressive РАСТЁТ с горизонтом (дальше неопределённее).
|
||
env = build_rate_envelopes(16.0, [6, 24])
|
||
spread_6 = env["conservative"][6] - env["aggressive"][6] # type: ignore[operator]
|
||
spread_24 = env["conservative"][24] - env["aggressive"][24] # type: ignore[operator]
|
||
assert spread_24 > spread_6
|
||
|
||
def test_aggressive_clamped_non_negative(self) -> None:
|
||
# Низкая база + большой down-сдвиг на дальнем горизонте → кламп ≥0.
|
||
env = build_rate_envelopes(1.0, [24])
|
||
assert env["aggressive"][24] == _MIN_RATE_PCT
|
||
# base/conservative при этом остаются положительными.
|
||
assert env["base"][24] == 1.0
|
||
assert env["conservative"][24] is not None and env["conservative"][24] > 1.0
|
||
|
||
def test_none_base_all_paths_none(self) -> None:
|
||
# Нет базовой ставки → все пути None на всех горизонтах (graceful).
|
||
env = build_rate_envelopes(None, [6, 12, 24])
|
||
for name in ("conservative", "base", "aggressive"):
|
||
assert all(v is None for v in env[name].values())
|
||
|
||
def test_custom_deltas(self) -> None:
|
||
env = build_rate_envelopes(10.0, [12], conservative_delta_pp=1.0, aggressive_delta_pp=1.0)
|
||
# widen(12)=1.5 → ±1.5 вокруг базы.
|
||
assert env["conservative"][12] == pytest.approx(11.5)
|
||
assert env["aggressive"][12] == pytest.approx(8.5)
|
||
|
||
def test_pure_no_mutation_of_input(self) -> None:
|
||
horizons = [6, 12]
|
||
build_rate_envelopes(16.0, horizons)
|
||
assert horizons == [6, 12]
|
||
|
||
|
||
# ── pure: _first_non_none ─────────────────────────────────────────────────────
|
||
|
||
|
||
class TestFirstNonNone:
|
||
def test_returns_first_present(self) -> None:
|
||
assert _first_non_none({6: 16.0, 12: 16.0}) == 16.0
|
||
|
||
def test_skips_none(self) -> None:
|
||
assert _first_non_none({6: None, 12: 17.0}) == 17.0
|
||
|
||
def test_all_none_returns_none(self) -> None:
|
||
assert _first_non_none({6: None, 12: None}) is None
|
||
|
||
def test_empty_returns_none(self) -> None:
|
||
assert _first_non_none({}) is None
|
||
|
||
|
||
# ── pure: _drop_none_rates ────────────────────────────────────────────────────
|
||
|
||
|
||
class TestDropNoneRates:
|
||
def test_keeps_present_rates(self) -> None:
|
||
assert _drop_none_rates({6: 18.0, 12: 16.0}) == {6: 18.0, 12: 16.0}
|
||
|
||
def test_drops_none_values(self) -> None:
|
||
assert _drop_none_rates({6: None, 12: 16.0}) == {12: 16.0}
|
||
|
||
def test_all_none_returns_none(self) -> None:
|
||
# Path целиком из None → None (#952 берёт свой дефолт hold_last_rate).
|
||
assert _drop_none_rates({6: None, 12: None}) is None
|
||
|
||
def test_empty_returns_none(self) -> None:
|
||
assert _drop_none_rates({}) is None
|
||
|
||
|
||
# ── pure: _round_or_none ──────────────────────────────────────────────────────
|
||
|
||
|
||
class TestRoundOrNone:
|
||
def test_rounds(self) -> None:
|
||
assert _round_or_none(0.123456, 3) == 0.123
|
||
|
||
def test_none_passthrough(self) -> None:
|
||
assert _round_or_none(None, 3) is None
|
||
|
||
|
||
# ── dataclass 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.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
|
||
|
||
|
||
class TestScenarioForecastAsDict:
|
||
def test_as_dict_shape(self) -> None:
|
||
sf = ScenarioForecast(
|
||
scenario="base",
|
||
rate_path={12: 16.0, 6: 16.0},
|
||
forecasts=[_forecast_stub(horizon=6), _forecast_stub(horizon=12)],
|
||
advisory=True,
|
||
)
|
||
d = sf.as_dict()
|
||
assert d["scenario"] == "base"
|
||
assert d["advisory"] is True
|
||
assert len(d["forecasts"]) == 2
|
||
|
||
def test_rate_path_sorted_and_rounded(self) -> None:
|
||
sf = ScenarioForecast(
|
||
scenario="conservative",
|
||
rate_path={12: 18.123456, 6: 17.987654},
|
||
forecasts=[],
|
||
advisory=True,
|
||
)
|
||
d = sf.as_dict()
|
||
# Отсортирован по горизонту, округлён до 2 знаков.
|
||
assert list(d["rate_path"].keys()) == [6, 12]
|
||
assert d["rate_path"][6] == 17.99
|
||
assert d["rate_path"][12] == 18.12
|
||
|
||
def test_rate_path_none_preserved(self) -> None:
|
||
sf = ScenarioForecast(
|
||
scenario="base", rate_path={6: None, 12: None}, forecasts=[], advisory=True
|
||
)
|
||
d = sf.as_dict()
|
||
assert d["rate_path"] == {6: None, 12: None}
|
||
|
||
|
||
# ── orchestrator helpers (стаб #952 + макро) ──────────────────────────────────
|
||
|
||
_BASE_KEY_RATE = 16.0
|
||
|
||
|
||
def _macro_stub_with_rate(rate: float | None) -> list[MagicMock]:
|
||
"""Макро-ряд из одной точки с заданной key_rate (hold_last_rate возьмёт её)."""
|
||
m = MagicMock()
|
||
m.key_rate = rate
|
||
return [m]
|
||
|
||
|
||
def _forecast_side_effect() -> Any:
|
||
"""side_effect #952: вернуть по forecast-стабу на каждый горизонт; запомнить rate_path."""
|
||
|
||
def _side(db: Any, *, spec: Any, horizons: Any, rate_path: Any = None, **_: Any) -> Any:
|
||
return [_forecast_stub(horizon=h) for h in horizons]
|
||
|
||
return _side
|
||
|
||
|
||
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": "Академический",
|
||
"cad_num": "66:41:0303161:123",
|
||
}
|
||
kwargs.update(over)
|
||
return compute_scenarios(MagicMock(), **kwargs) # type: ignore[arg-type]
|
||
|
||
|
||
# ── orchestrator: три сценария ────────────────────────────────────────────────
|
||
|
||
|
||
class TestComputeScenariosShape:
|
||
def test_returns_three_scenarios(self) -> None:
|
||
with (
|
||
patch(_MACRO, return_value=_macro_stub_with_rate(_BASE_KEY_RATE)),
|
||
patch(_FORECAST, side_effect=_forecast_side_effect()),
|
||
):
|
||
res = _run(horizons=[6, 12])
|
||
assert len(res) == 3
|
||
assert [s.scenario for s in res] == ["conservative", "base", "aggressive"]
|
||
|
||
def test_each_scenario_advisory_true(self) -> None:
|
||
with (
|
||
patch(_MACRO, return_value=_macro_stub_with_rate(_BASE_KEY_RATE)),
|
||
patch(_FORECAST, side_effect=_forecast_side_effect()),
|
||
):
|
||
res = _run(horizons=[6, 12])
|
||
assert all(s.advisory is True for s in res)
|
||
|
||
def test_each_scenario_has_forecast_per_horizon(self) -> None:
|
||
with (
|
||
patch(_MACRO, return_value=_macro_stub_with_rate(_BASE_KEY_RATE)),
|
||
patch(_FORECAST, side_effect=_forecast_side_effect()),
|
||
):
|
||
res = _run(horizons=[6, 12, 18, 24])
|
||
for s in res:
|
||
assert len(s.forecasts) == 4
|
||
assert [f.horizon_months for f in s.forecasts] == [6, 12, 18, 24]
|
||
|
||
def test_default_horizons_used(self) -> None:
|
||
with (
|
||
patch(_MACRO, return_value=_macro_stub_with_rate(_BASE_KEY_RATE)),
|
||
patch(_FORECAST, side_effect=_forecast_side_effect()),
|
||
):
|
||
res = _run() # без horizons → _DEFAULT_HORIZONS (6,12,18,24)
|
||
assert all(len(s.forecasts) == 4 for s in res)
|
||
|
||
|
||
# ── orchestrator: rate_path per-scenario ──────────────────────────────────────
|
||
|
||
|
||
class TestComputeScenariosRatePath:
|
||
def test_each_scenario_carries_its_rate_path(self) -> None:
|
||
with (
|
||
patch(_MACRO, return_value=_macro_stub_with_rate(_BASE_KEY_RATE)),
|
||
patch(_FORECAST, side_effect=_forecast_side_effect()),
|
||
):
|
||
res = _run(horizons=[12])
|
||
by_name = {s.scenario: s for s in res}
|
||
# base = голая база; conservative выше; aggressive ниже.
|
||
assert by_name["base"].rate_path[12] == pytest.approx(_BASE_KEY_RATE)
|
||
assert by_name["conservative"].rate_path[12] > _BASE_KEY_RATE # type: ignore[operator]
|
||
assert by_name["aggressive"].rate_path[12] < _BASE_KEY_RATE # type: ignore[operator]
|
||
|
||
def test_conservative_rate_path_higher_than_aggressive(self) -> None:
|
||
with (
|
||
patch(_MACRO, return_value=_macro_stub_with_rate(_BASE_KEY_RATE)),
|
||
patch(_FORECAST, side_effect=_forecast_side_effect()),
|
||
):
|
||
res = _run(horizons=[6, 12, 18, 24])
|
||
by_name = {s.scenario: s for s in res}
|
||
for h in (6, 12, 18, 24):
|
||
assert by_name["conservative"].rate_path[h] > by_name["aggressive"].rate_path[h] # type: ignore[operator]
|
||
|
||
def test_rate_path_passed_to_forecast_engine(self) -> None:
|
||
# rate_path, переданный в #952, совпадает с конвертом сценария (None убраны).
|
||
captured: dict[str, Any] = {}
|
||
|
||
def _side(db: Any, *, spec: Any, horizons: Any, rate_path: Any = None, **_: Any) -> Any:
|
||
captured[rate_path[12]] = rate_path # type: ignore[index]
|
||
return [_forecast_stub(horizon=h) for h in horizons]
|
||
|
||
with (
|
||
patch(_MACRO, return_value=_macro_stub_with_rate(_BASE_KEY_RATE)),
|
||
patch(_FORECAST, side_effect=_side),
|
||
):
|
||
res = _run(horizons=[12])
|
||
by_name = {s.scenario: s for s in res}
|
||
# Каждый сценарный путь (без None) дошёл до #952.
|
||
assert by_name["base"].rate_path[12] in captured
|
||
assert by_name["conservative"].rate_path[12] in captured
|
||
assert by_name["aggressive"].rate_path[12] in captured
|
||
|
||
|
||
# ── orchestrator: graceful (нет макро / нет базовой ставки) ────────────────────
|
||
|
||
|
||
class TestComputeScenariosGraceful:
|
||
def test_no_macro_still_three_scenarios(self) -> None:
|
||
# Пустой макро-ряд → база None → конверты None, но ТРИ сценария (не crash).
|
||
with (
|
||
patch(_MACRO, return_value=[]),
|
||
patch(_FORECAST, side_effect=_forecast_side_effect()) as fc,
|
||
):
|
||
res = _run(horizons=[6, 12])
|
||
assert len(res) == 3
|
||
assert all(s.advisory is True for s in res)
|
||
# Все rate_path None при отсутствии базы.
|
||
for s in res:
|
||
assert all(v is None for v in s.rate_path.values())
|
||
# #952 всё равно вызван по разу на сценарий (с rate_path=None → его дефолт).
|
||
assert fc.call_count == 3
|
||
|
||
def test_no_key_rate_point_still_three_scenarios(self) -> None:
|
||
# Макро есть, но key_rate None во всех точках → база None.
|
||
with (
|
||
patch(_MACRO, return_value=_macro_stub_with_rate(None)),
|
||
patch(_FORECAST, side_effect=_forecast_side_effect()),
|
||
):
|
||
res = _run(horizons=[12])
|
||
assert len(res) == 3
|
||
for s in res:
|
||
assert s.rate_path[12] is None
|
||
|
||
def test_no_base_passes_none_rate_path_to_engine(self) -> None:
|
||
# Нет базы → #952 получает rate_path=None (берёт свой hold_last_rate-дефолт).
|
||
captured: list[Any] = []
|
||
|
||
def _side(db: Any, *, spec: Any, horizons: Any, rate_path: Any = None, **_: Any) -> Any:
|
||
captured.append(rate_path)
|
||
return [_forecast_stub(horizon=h) for h in horizons]
|
||
|
||
with patch(_MACRO, return_value=[]), patch(_FORECAST, side_effect=_side):
|
||
_run(horizons=[6, 12])
|
||
assert captured == [None, None, None]
|
||
|
||
def test_forecast_engine_exception_propagates_not_swallowed(self) -> None:
|
||
# #952 сам graceful; если он всё же кинул — НЕ глотаем молча (честный провал).
|
||
with (
|
||
patch(_MACRO, return_value=_macro_stub_with_rate(_BASE_KEY_RATE)),
|
||
patch(_FORECAST, side_effect=RuntimeError("engine boom")),
|
||
):
|
||
with pytest.raises(RuntimeError, match="engine boom"):
|
||
_run(horizons=[12])
|
||
|
||
|
||
# ── orchestrator: база читается через hold_last_rate ──────────────────────────
|
||
|
||
|
||
class TestComputeScenariosBaseRate:
|
||
def test_base_rate_from_last_key_rate(self) -> None:
|
||
# hold_last_rate берёт ПОСЛЕДНЮЮ непустую key_rate ряда → она и есть база.
|
||
m_old = MagicMock()
|
||
m_old.key_rate = 14.0
|
||
m_new = MagicMock()
|
||
m_new.key_rate = 18.0 # свежее (ряд ASC) → база
|
||
with (
|
||
patch(_MACRO, return_value=[m_old, m_new]),
|
||
patch(_FORECAST, side_effect=_forecast_side_effect()),
|
||
):
|
||
res = _run(horizons=[12])
|
||
by_name = {s.scenario: s for s in res}
|
||
assert by_name["base"].rate_path[12] == pytest.approx(18.0)
|
||
|
||
def test_hold_last_rate_invoked(self) -> None:
|
||
# Поток явно опирается на #952 hold_last_rate (база = его выход).
|
||
macro = _macro_stub_with_rate(_BASE_KEY_RATE)
|
||
with (
|
||
patch(_MACRO, return_value=macro),
|
||
patch(_HOLD, return_value={12: 15.5}) as hold,
|
||
patch(_FORECAST, side_effect=_forecast_side_effect()),
|
||
):
|
||
res = _run(horizons=[12])
|
||
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
|