From 6322d10199cf847f0cc8d2bcad4c4dd4d482ba08 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Wed, 3 Jun 2026 12:59:17 +0500 Subject: [PATCH] =?UTF-8?q?feat(forecasting):=20=C2=A711=20macro-scenarios?= =?UTF-8?q?=20(conservative/base/aggressive)=20(#984,=20954-A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add scenarios.py: run the #952 demand↔supply engine under three key-rate envelopes (base = hold_last_rate flat; conservative +2.0 п.п.; aggressive −3.0 п.п., widening with horizon, clamped ≥0) → one ScenarioForecast each. Single lever = rate_path into #952 (β stays counted-once in §9.4). Pure build_rate_envelopes separated from DB orchestrator; advisory inherits #952 cap; graceful no-macro. Deterministic, no LLM, no SQL. 47 tests. --- backend/app/services/forecasting/__init__.py | 10 + backend/app/services/forecasting/scenarios.py | 313 ++++++++++++ .../services/forecasting/test_scenarios.py | 458 ++++++++++++++++++ 3 files changed, 781 insertions(+) create mode 100644 backend/app/services/forecasting/scenarios.py create mode 100644 backend/tests/services/forecasting/test_scenarios.py diff --git a/backend/app/services/forecasting/__init__.py b/backend/app/services/forecasting/__init__.py index 2eedfbf8..ee7ea6fd 100644 --- a/backend/app/services/forecasting/__init__.py +++ b/backend/app/services/forecasting/__init__.py @@ -17,6 +17,8 @@ (прогон #980 per-cell → DESC «что строить»; СБОРКА, ADVISORY). • affordability (#981/952-B) — §7.9 MAI: ДЕГРАДИРОВАННЫЙ прокси платёжной нагрузки (субсид. ставка, дохода нет → low-confidence; СБОРКА, ADVISORY). + • scenarios (#984/954-A) — §11 три макро-сценария (conservative/base/ + aggressive) прогоном #952 под тремя конвертами ставки (СБОРКА, ADVISORY). Источники данных: • макро — таблица macro_indicator через reader site_finder/macro.py (reuse). @@ -73,6 +75,11 @@ from app.services.forecasting.sales_series import ( price_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 ( RankedSegment, WhatToBuildRanking, @@ -88,10 +95,12 @@ __all__ = [ "RankedSegment", "RateSensitivity", "SalesSeries", + "ScenarioForecast", "SegmentSpec", "WhatToBuildRanking", "assemble_coefficient", "best_lag", + "build_rate_envelopes", "build_sales_series", "classify_regime", "compute_affordability", @@ -99,6 +108,7 @@ __all__ = [ "compute_demand_supply_forecast", "compute_macro_coefficient", "compute_rate_sensitivity", + "compute_scenarios", "f_issuance", "f_mortgage_rate", "f_overdue", diff --git a/backend/app/services/forecasting/scenarios.py b/backend/app/services/forecasting/scenarios.py new file mode 100644 index 00000000..ae802c0a --- /dev/null +++ b/backend/app/services/forecasting/scenarios.py @@ -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 diff --git a/backend/tests/services/forecasting/test_scenarios.py b/backend/tests/services/forecasting/test_scenarios.py new file mode 100644 index 00000000..68bcb0c3 --- /dev/null +++ b/backend/tests/services/forecasting/test_scenarios.py @@ -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)