parent
691ccef4b7
commit
8057468c13
3 changed files with 781 additions and 0 deletions
|
|
@ -17,6 +17,8 @@
|
||||||
(прогон #980 per-cell → DESC «что строить»; СБОРКА, ADVISORY).
|
(прогон #980 per-cell → DESC «что строить»; СБОРКА, ADVISORY).
|
||||||
• affordability (#981/952-B) — §7.9 MAI: ДЕГРАДИРОВАННЫЙ прокси платёжной
|
• affordability (#981/952-B) — §7.9 MAI: ДЕГРАДИРОВАННЫЙ прокси платёжной
|
||||||
нагрузки (субсид. ставка, дохода нет → low-confidence; СБОРКА, ADVISORY).
|
нагрузки (субсид. ставка, дохода нет → low-confidence; СБОРКА, ADVISORY).
|
||||||
|
• scenarios (#984/954-A) — §11 три макро-сценария (conservative/base/
|
||||||
|
aggressive) прогоном #952 под тремя конвертами ставки (СБОРКА, ADVISORY).
|
||||||
|
|
||||||
Источники данных:
|
Источники данных:
|
||||||
• макро — таблица macro_indicator через reader site_finder/macro.py (reuse).
|
• макро — таблица macro_indicator через reader site_finder/macro.py (reuse).
|
||||||
|
|
@ -73,6 +75,11 @@ from app.services.forecasting.sales_series import (
|
||||||
price_bucket_of,
|
price_bucket_of,
|
||||||
room_area_bucket_of,
|
room_area_bucket_of,
|
||||||
)
|
)
|
||||||
|
from app.services.forecasting.scenarios import (
|
||||||
|
ScenarioForecast,
|
||||||
|
build_rate_envelopes,
|
||||||
|
compute_scenarios,
|
||||||
|
)
|
||||||
from app.services.forecasting.what_to_build import (
|
from app.services.forecasting.what_to_build import (
|
||||||
RankedSegment,
|
RankedSegment,
|
||||||
WhatToBuildRanking,
|
WhatToBuildRanking,
|
||||||
|
|
@ -88,10 +95,12 @@ __all__ = [
|
||||||
"RankedSegment",
|
"RankedSegment",
|
||||||
"RateSensitivity",
|
"RateSensitivity",
|
||||||
"SalesSeries",
|
"SalesSeries",
|
||||||
|
"ScenarioForecast",
|
||||||
"SegmentSpec",
|
"SegmentSpec",
|
||||||
"WhatToBuildRanking",
|
"WhatToBuildRanking",
|
||||||
"assemble_coefficient",
|
"assemble_coefficient",
|
||||||
"best_lag",
|
"best_lag",
|
||||||
|
"build_rate_envelopes",
|
||||||
"build_sales_series",
|
"build_sales_series",
|
||||||
"classify_regime",
|
"classify_regime",
|
||||||
"compute_affordability",
|
"compute_affordability",
|
||||||
|
|
@ -99,6 +108,7 @@ __all__ = [
|
||||||
"compute_demand_supply_forecast",
|
"compute_demand_supply_forecast",
|
||||||
"compute_macro_coefficient",
|
"compute_macro_coefficient",
|
||||||
"compute_rate_sensitivity",
|
"compute_rate_sensitivity",
|
||||||
|
"compute_scenarios",
|
||||||
"f_issuance",
|
"f_issuance",
|
||||||
"f_mortgage_rate",
|
"f_mortgage_rate",
|
||||||
"f_overdue",
|
"f_overdue",
|
||||||
|
|
|
||||||
313
backend/app/services/forecasting/scenarios.py
Normal file
313
backend/app/services/forecasting/scenarios.py
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
"""§11 макро-сценарии: прогон #952 движка под тремя конвертами ключевой ставки.
|
||||||
|
|
||||||
|
#984 (954-A, Site Finder v2 / «GG-форсайт» ТЗ §11), EPIC 10 «Сценарный анализ».
|
||||||
|
Это **СБОРОЧНЫЙ слой над #952**: он НЕ пересобирает §9.x-математику и НЕ строит
|
||||||
|
своих rate-path вручную per-горизонт. Он берёт ОДИН рычаг — `rate_path` входа
|
||||||
|
`compute_demand_supply_forecast` (#952) — и прогоняет тот же движок ТРИЖДЫ под
|
||||||
|
тремя конвертами ключевой ставки, давая по `ScenarioForecast` на сценарий:
|
||||||
|
|
||||||
|
• base — текущая ключевая ставка, удержанная ПЛОСКО (#952 hold_last_rate).
|
||||||
|
• conservative — ставка ВЫШЕ базы (+_CONSERVATIVE_RATE_DELTA_PP п.п.): дороже
|
||||||
|
ипотека → спрос МЯГЧЕ → консервативный (осторожный) прогноз.
|
||||||
|
• aggressive — ставка НИЖЕ базы (−_AGGRESSIVE_RATE_DELTA_PP п.п.): дешевле
|
||||||
|
ипотека → спрос СИЛЬНЕЕ → агрессивный (оптимистичный) прогноз.
|
||||||
|
|
||||||
|
ЕДИНСТВЕННОЕ ОТЛИЧИЕ сценариев — это rate_path; всё остальное (сегмент, район,
|
||||||
|
участок, горизонты) общее. Сам §9.4 demand_normalization внутри #952 превращает
|
||||||
|
Δ ставки в Δ спроса (через β, учтённый там РОВНО ОДИН РАЗ), поэтому здесь мы лишь
|
||||||
|
сдвигаем ставку — НИКАКОЙ арифметики над спросом тут нет.
|
||||||
|
|
||||||
|
Всё ДЕТЕРМИНИРОВАННО, БЕЗ LLM (чистый сдвиг ставки + reuse #952; своего SQL НЕТ).
|
||||||
|
|
||||||
|
ADVISORY-СТАТУС: каждый сценарий наследует advisory-статус #952 и его жёсткий
|
||||||
|
потолок confidence ≤ 'medium' (движок не провалидирован до бэктеста #951). Поэтому
|
||||||
|
и весь сценарный слой СОВЕТУЮЩИЙ: `advisory` ВСЕГДА True. Цифры — для
|
||||||
|
explainability/прототипа, не основание для инвест-решения.
|
||||||
|
|
||||||
|
КОНВЕРТ ШИРИТСЯ С ГОРИЗОНТОМ (сознательно, документируем): дальние горизонты
|
||||||
|
неопределённее, поэтому Δ ставки растёт линейно с горизонтом —
|
||||||
|
delta_eff(h) = base_delta × (1 + _HORIZON_WIDEN_PER_YEAR × h/12). На h=12 мес
|
||||||
|
конверт = base_delta, на h=24 мес ≈ base_delta × (1 + 0.5×_HORIZON_WIDEN_PER_YEAR).
|
||||||
|
Держим ПРОСТО (один линейный множитель) и tunable. Ставка КЛАМПится ≥ 0 (нет
|
||||||
|
отрицательной ключевой ставки) — на дальнем агрессивном горизонте конверт может
|
||||||
|
упереть ставку в 0.
|
||||||
|
|
||||||
|
Graceful-on-thin-data (дух #952): нет макро / нет базовой ставки → база None →
|
||||||
|
конверты None на всех горизонтах → #952 деградирует к нейтрали ВНУТРИ себя
|
||||||
|
(прогнозы с None-полями), но всё равно возвращаем ТРИ сценария. НИКОГДА не crash.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.services.forecasting.demand_supply_forecast import (
|
||||||
|
DemandSupplyForecast,
|
||||||
|
compute_demand_supply_forecast,
|
||||||
|
hold_last_rate,
|
||||||
|
)
|
||||||
|
from app.services.forecasting.macro_series import get_monthly_macro
|
||||||
|
from app.services.forecasting.sales_series import SegmentSpec
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
Scenario = Literal["conservative", "base", "aggressive"]
|
||||||
|
|
||||||
|
# ── Named-константы (§11 конверт ставки) ──────────────────────────────────────
|
||||||
|
|
||||||
|
# Горизонты прогноза по умолчанию (мес) — зеркало #952 _DEFAULT_HORIZONS
|
||||||
|
# (competitors / future_supply горизонт-сетка): полгода…2 года.
|
||||||
|
_DEFAULT_HORIZONS: tuple[int, ...] = (6, 12, 18, 24)
|
||||||
|
|
||||||
|
# КОНСЕРВАТИВНЫЙ сдвиг ставки ВВЕРХ относительно базы (п.п.). +2.0 п.п. ≈ один
|
||||||
|
# крупный цикл ужесточения ДКП (совет директоров двигает ставку шагами 0.5–1.0 п.п.;
|
||||||
|
# +2.0 п.п. = «ещё пара шоковых повышений» — осторожный, но не апокалиптичный
|
||||||
|
# сценарий). Ставка выше → ипотека дороже → спрос мягче (§9.4 внутри #952). Tunable.
|
||||||
|
_CONSERVATIVE_RATE_DELTA_PP: float = 2.0
|
||||||
|
|
||||||
|
# АГРЕССИВНЫЙ сдвиг ставки ВНИЗ относительно базы (п.п.). −3.0 п.п. ≈ полноценный
|
||||||
|
# цикл смягчения ДКП (несимметрично шире консервативного: ЦБ снижает медленнее, чем
|
||||||
|
# поднимает, поэтому «оптимизм» требует большего хода ставки, чтобы заметно
|
||||||
|
# разогнать спрос). Ставка ниже → ипотека дешевле → спрос сильнее. Кламп ≥0. Tunable.
|
||||||
|
_AGGRESSIVE_RATE_DELTA_PP: float = 3.0
|
||||||
|
|
||||||
|
# Расширение конверта с горизонтом (доля от base_delta на КАЖДЫЙ год горизонта).
|
||||||
|
# delta_eff(h) = base_delta × (1 + _HORIZON_WIDEN_PER_YEAR × h/12): дальние горизонты
|
||||||
|
# неопределённее, поэтому разброс ставки растёт. 0.5 = «+50% к Δ за каждый год»
|
||||||
|
# (на 24 мес конверт ×2 шире, чем на 12 мес). Простой линейный множитель. Tunable.
|
||||||
|
_HORIZON_WIDEN_PER_YEAR: float = 0.5
|
||||||
|
|
||||||
|
# Минимально допустимая ключевая ставка (%). Отрицательной ключевой ставки не
|
||||||
|
# бывает — кламп снизу при агрессивном (вниз) сдвиге на дальнем горизонте.
|
||||||
|
_MIN_RATE_PCT: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ScenarioForecast:
|
||||||
|
"""Один §11 макро-сценарий: #952 движок под конкретным конвертом ставки.
|
||||||
|
|
||||||
|
Все три сценария (conservative / base / aggressive) отличаются ТОЛЬКО `rate_path`
|
||||||
|
(сдвиг ключевой ставки относительно базы); `forecasts` — выход #952 под этим
|
||||||
|
путём, по одному DemandSupplyForecast на горизонт. `advisory` ВСЕГДА True —
|
||||||
|
сценарий наследует advisory-статус #952 (движок не для production-решений до
|
||||||
|
бэктеста #951). rate_path может содержать None-значения (нет базовой ставки —
|
||||||
|
graceful: #952 деградирует к нейтрали внутри себя).
|
||||||
|
"""
|
||||||
|
|
||||||
|
scenario: Scenario
|
||||||
|
rate_path: dict[int, float | None] # {горизонт: ставка сценария}; None = нет базы
|
||||||
|
forecasts: list[DemandSupplyForecast] # выход #952 (по одному на горизонт)
|
||||||
|
advisory: bool # ВСЕГДА True (наследует advisory #952)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"scenario": self.scenario,
|
||||||
|
"rate_path": {h: _round_or_none(rate, 2) for h, rate in sorted(self.rate_path.items())},
|
||||||
|
"forecasts": [f.as_dict() for f in self.forecasts],
|
||||||
|
"advisory": self.advisory,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _round_or_none(value: float | None, digits: int) -> float | None:
|
||||||
|
return round(value, digits) if value is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Pure-логика — без БД, полностью юнит-тестируемо.
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _horizon_widen_factor(
|
||||||
|
horizon_months: int, *, widen_per_year: float = _HORIZON_WIDEN_PER_YEAR
|
||||||
|
) -> float:
|
||||||
|
"""Множитель расширения конверта на горизонте ∈ [1, ∞). PURE.
|
||||||
|
|
||||||
|
1 + widen_per_year × h/12: на 12 мес = 1 + widen_per_year×1; нормируем так, чтобы
|
||||||
|
h=12 давал «один год расширения». h ≤ 0 → 1.0 (нет расширения). widen_per_year ≤ 0
|
||||||
|
→ 1.0 (расширение выключено — плоский конверт по горизонтам). PURE.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
horizon_months: горизонт прогноза (мес); ≤0 → 1.0.
|
||||||
|
widen_per_year: доля расширения base_delta на каждый год горизонта; ≤0 → 1.0.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Множитель ≥ 1.0.
|
||||||
|
"""
|
||||||
|
if horizon_months <= 0 or widen_per_year <= 0.0:
|
||||||
|
return 1.0
|
||||||
|
return 1.0 + widen_per_year * (horizon_months / 12.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _shift_rate(base_rate: float | None, delta_pp: float) -> float | None:
|
||||||
|
"""Сдвинуть базовую ставку на delta_pp п.п., кламп ≥ _MIN_RATE_PCT. PURE.
|
||||||
|
|
||||||
|
base None → None (нет базы — нечего сдвигать; #952 деградирует к нейтрали).
|
||||||
|
delta_pp > 0 = вверх (консервативный), < 0 = вниз (агрессивный). Результат
|
||||||
|
клампится снизу к _MIN_RATE_PCT (отрицательной ключевой ставки не бывает). PURE.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_rate: базовая ключевая ставка (%); None → None.
|
||||||
|
delta_pp: сдвиг в п.п. (знаковый).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Сдвинутая ставка (≥ _MIN_RATE_PCT) или None.
|
||||||
|
"""
|
||||||
|
if base_rate is None:
|
||||||
|
return None
|
||||||
|
return max(_MIN_RATE_PCT, base_rate + delta_pp)
|
||||||
|
|
||||||
|
|
||||||
|
def build_rate_envelopes(
|
||||||
|
base_rate: float | None,
|
||||||
|
horizons: Sequence[int],
|
||||||
|
*,
|
||||||
|
conservative_delta_pp: float = _CONSERVATIVE_RATE_DELTA_PP,
|
||||||
|
aggressive_delta_pp: float = _AGGRESSIVE_RATE_DELTA_PP,
|
||||||
|
) -> dict[str, dict[int, float | None]]:
|
||||||
|
"""Построить ТРИ именованных rate-path (§11 конверт ставки) из базовой ставки. PURE.
|
||||||
|
|
||||||
|
Конверт по сценариям (на КАЖДЫЙ горизонт h):
|
||||||
|
• base = base_rate (плоско — ставка не меняется).
|
||||||
|
• conservative = base_rate + conservative_delta_pp × widen(h) (ставка ВЫШЕ).
|
||||||
|
• aggressive = base_rate − aggressive_delta_pp × widen(h) (ставка НИЖЕ).
|
||||||
|
где widen(h) = _horizon_widen_factor(h) расширяет конверт с горизонтом (дальние
|
||||||
|
горизонты неопределённее). Все ставки клампятся ≥ _MIN_RATE_PCT.
|
||||||
|
|
||||||
|
base_rate None → ВСЕ пути None на всех горизонтах (graceful: #952 деградирует к
|
||||||
|
нейтрали внутри себя; мы всё равно отдаём три именованных конверта). PURE.
|
||||||
|
|
||||||
|
ИНВАРИАНТ (при заданной базе): conservative[h] ≥ base[h] ≥ aggressive[h] на любом
|
||||||
|
горизонте (равенство возможно лишь у aggressive при упоре в кламп _MIN_RATE_PCT).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_rate: базовая ключевая ставка (%); None → все пути None.
|
||||||
|
horizons: горизонты (мес), под которые строим пути.
|
||||||
|
conservative_delta_pp: сдвиг вверх для conservative (по умолч.
|
||||||
|
_CONSERVATIVE_RATE_DELTA_PP).
|
||||||
|
aggressive_delta_pp: сдвиг вниз для aggressive (по умолч.
|
||||||
|
_AGGRESSIVE_RATE_DELTA_PP).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"conservative"|"base"|"aggressive": {горизонт: ставка|None}}.
|
||||||
|
"""
|
||||||
|
conservative: dict[int, float | None] = {}
|
||||||
|
base: dict[int, float | None] = {}
|
||||||
|
aggressive: dict[int, float | None] = {}
|
||||||
|
for h in horizons:
|
||||||
|
widen = _horizon_widen_factor(h)
|
||||||
|
base[h] = _shift_rate(base_rate, 0.0)
|
||||||
|
conservative[h] = _shift_rate(base_rate, +conservative_delta_pp * widen)
|
||||||
|
aggressive[h] = _shift_rate(base_rate, -aggressive_delta_pp * widen)
|
||||||
|
return {"conservative": conservative, "base": base, "aggressive": aggressive}
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# DB-оркестратор — тонкий, graceful. Pure-логика выше тестируется без него.
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def compute_scenarios(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
spec: SegmentSpec,
|
||||||
|
district: str | None,
|
||||||
|
cad_num: str,
|
||||||
|
horizons: Sequence[int] = _DEFAULT_HORIZONS,
|
||||||
|
) -> list[ScenarioForecast]:
|
||||||
|
"""§11 три макро-сценария: прогон #952 движка под тремя конвертами ставки.
|
||||||
|
|
||||||
|
ADVISORY (каждый сценарий наследует advisory-статус #952, см. module docstring) —
|
||||||
|
НЕ подключать в production-эндпоинт. СБОРКА над #952, НЕ пересчёт §9.x.
|
||||||
|
|
||||||
|
Поток:
|
||||||
|
1. base_rate = последняя известная key_rate (get_monthly_macro → hold_last_rate),
|
||||||
|
удержанная плоско. Нет макро / нет точки key_rate → base_rate None.
|
||||||
|
2. build_rate_envelopes(base_rate, horizons) → три именованных rate-path.
|
||||||
|
3. Для КАЖДОГО сценария прогон compute_demand_supply_forecast ОДИН раз с его
|
||||||
|
rate_path → list[DemandSupplyForecast] (по одному на горизонт).
|
||||||
|
4. Обернуть в ScenarioForecast (advisory=True).
|
||||||
|
|
||||||
|
⚠️ Сценарии отличаются ТОЛЬКО rate_path. Δ ставки → Δ спроса делает §9.4 внутри
|
||||||
|
#952 (β учтён там РОВНО ОДИН РАЗ); здесь НИКАКОЙ арифметики над спросом нет.
|
||||||
|
|
||||||
|
Graceful: нет макро / нет базовой ставки → конверты None → #952 отдаёт прогнозы
|
||||||
|
с None-полями, но ТРИ сценария возвращаются всегда. НИКОГДА не crash. advisory
|
||||||
|
всегда True (наследует #952 cap ≤ 'medium').
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: SQLAlchemy sync Session.
|
||||||
|
spec: целевой сегмент рынка (любой subset осей) — общий для всех сценариев.
|
||||||
|
district: район для §9.3 supply + §9.2 metrics (None → ЕКБ-wide).
|
||||||
|
cad_num: кадастровый номер участка — вход #952 (§9.7 конкуренты).
|
||||||
|
horizons: горизонты прогноза (мес; по умолчанию _DEFAULT_HORIZONS).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список из ТРЁХ ScenarioForecast (conservative, base, aggressive) — всегда.
|
||||||
|
"""
|
||||||
|
horizon_list = list(horizons)
|
||||||
|
|
||||||
|
# ── База: последняя известная key_rate, удержанная плоско (graceful → None) ──
|
||||||
|
macro = get_monthly_macro(db)
|
||||||
|
held = hold_last_rate(macro, horizon_list)
|
||||||
|
base_rate = _first_non_none(held)
|
||||||
|
|
||||||
|
# ── Три именованных конверта ставки (§11) ──────────────────────────────────
|
||||||
|
envelopes = build_rate_envelopes(base_rate, horizon_list)
|
||||||
|
|
||||||
|
out: list[ScenarioForecast] = []
|
||||||
|
for scenario in ("conservative", "base", "aggressive"):
|
||||||
|
rate_path = envelopes[scenario]
|
||||||
|
forecasts = compute_demand_supply_forecast(
|
||||||
|
db,
|
||||||
|
spec=spec,
|
||||||
|
district=district,
|
||||||
|
cad_num=cad_num,
|
||||||
|
horizons=horizon_list,
|
||||||
|
rate_path=_drop_none_rates(rate_path),
|
||||||
|
)
|
||||||
|
out.append(
|
||||||
|
ScenarioForecast(
|
||||||
|
scenario=scenario, # type: ignore[arg-type]
|
||||||
|
rate_path=rate_path,
|
||||||
|
forecasts=forecasts,
|
||||||
|
advisory=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"scenarios: district=%s cad_num=%s base_rate=%s horizons=%s scenarios=%d (ADVISORY)",
|
||||||
|
district,
|
||||||
|
cad_num,
|
||||||
|
_round_or_none(base_rate, 2),
|
||||||
|
horizon_list,
|
||||||
|
len(out),
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _first_non_none(rate_path: dict[int, float | None]) -> float | None:
|
||||||
|
"""Первая НЕ-None ставка из path (hold_last_rate отдаёт одно значение на всё). PURE.
|
||||||
|
|
||||||
|
hold_last_rate присваивает одну и ту же last key_rate всем горизонтам, поэтому
|
||||||
|
любая не-None — это и есть база. Все None (нет макро) → None (graceful).
|
||||||
|
"""
|
||||||
|
for rate in rate_path.values():
|
||||||
|
if rate is not None:
|
||||||
|
return rate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_none_rates(rate_path: dict[int, float | None]) -> dict[int, float] | None:
|
||||||
|
"""Свести сценарный path к {горизонт: ставка} для #952 (None-значения убрать). PURE.
|
||||||
|
|
||||||
|
#952 ждёт `rate_path: dict[int, float] | None`; None-значения (нет базы) туда не
|
||||||
|
кладём — пусть #952 сам деградирует к hold_last_rate / нейтрали для этих
|
||||||
|
горизонтов. Path целиком из None → None (передаём None → #952 берёт свой дефолт).
|
||||||
|
"""
|
||||||
|
cleaned = {h: rate for h, rate in rate_path.items() if rate is not None}
|
||||||
|
return cleaned or None
|
||||||
458
backend/tests/services/forecasting/test_scenarios.py
Normal file
458
backend/tests/services/forecasting/test_scenarios.py
Normal file
|
|
@ -0,0 +1,458 @@
|
||||||
|
"""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,
|
||||||
|
_CONSERVATIVE_RATE_DELTA_PP,
|
||||||
|
_MIN_RATE_PCT,
|
||||||
|
ScenarioForecast,
|
||||||
|
_drop_none_rates,
|
||||||
|
_first_non_none,
|
||||||
|
_horizon_widen_factor,
|
||||||
|
_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) -> MagicMock:
|
||||||
|
"""Одиночный DemandSupplyForecast-стаб (нужны horizon_months + as_dict)."""
|
||||||
|
f = MagicMock()
|
||||||
|
f.horizon_months = horizon
|
||||||
|
f.deficit_index = deficit_index
|
||||||
|
f.as_dict.return_value = {"horizon_months": horizon, "deficit_index": deficit_index}
|
||||||
|
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]:
|
||||||
|
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)
|
||||||
Loading…
Add table
Reference in a new issue