All checks were successful
CI / frontend-tests (pull_request) Has been skipped
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m45s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m42s
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
Deploy / deploy (push) Successful in 1m15s
CI / backend-tests (push) Successful in 6m30s
CI / backend-tests (pull_request) Successful in 6m32s
fedstat ИПЦ is reCAPTCHA-blocked; CBR publishes inflation openly. Add fetch_inflation + parse_inflation_xlsx (CBR UniDbQuery DownloadExcel/132934, monthly % г/г, region=rf, source=cbr) to cbr_macro.py; upsert indicator_type=inflation_yoy via the existing cbr_macro_sync task (per-series guard, SAVEPOINT-per-row, CAST not ::, ON CONFLICT on the PK). Surface inflation_yoy in MonthlyMacro (frozen, carry-forward) and ACTIVATE the reserved §9.5 inflation channel (macro_coefficient f_inflation: level-vs-4%-target nudge, non-positive to avoid double-counting f_rate, excluded from _RATE_DRIVEN_FACTORS). Channel was DEGRADED (no data) -> now BACKED + consumed; _CONF_HIGH_MIN_BACKED 4->5. Deterministic (§16/§26); renorm claims the reserved 0.08 slice as designed. Live-verified (2026-04 5.58%); 194 macro + 902 forecasting tests green. No migration, no new deps. Refs #946.
478 lines
21 KiB
Python
478 lines
21 KiB
Python
"""Unit-тесты monthly макро-ряда + классификатора режима ставки (#951b, ТЗ §9.5/§9.6).
|
||
|
||
Чистые тесты (без живой БД):
|
||
• classify_regime — синтетический путь down→stable→up, пороги eps, None-дыры.
|
||
• macro_at_lag — точный месяц, отсутствующий месяц → None, лаг за пределами → None.
|
||
• is_confounded_window — пересекает / не пересекает confounder-даты.
|
||
• MonthlyMacro.as_dict — округление + None survive.
|
||
• get_monthly_macro через MagicMock-сессию: форма SQL (DISTINCT ON ресэмпл,
|
||
CAST(:x AS type) не :x::type), carry-forward, graceful empty → [].
|
||
|
||
psycopg v3 правило проверяется явно: bind-параметры — CAST(:x AS type).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import datetime as dt
|
||
import os
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from app.services.forecasting.macro_series import (
|
||
_CONFOUNDER_DATES,
|
||
_REGIME_EPS_PP,
|
||
MonthlyMacro,
|
||
_carry_forward,
|
||
_month_grid,
|
||
_shift_months,
|
||
classify_regime,
|
||
get_monthly_macro,
|
||
is_confounded_window,
|
||
macro_at_lag,
|
||
)
|
||
|
||
_GET_SERIES = "app.services.forecasting.macro_series.get_macro_series"
|
||
|
||
|
||
def _macro(month: dt.date, **kw: float | None) -> MonthlyMacro:
|
||
"""Хелпер: MonthlyMacro с None-полями по умолчанию (переопределяем нужные)."""
|
||
defaults: dict[str, float | None] = {
|
||
"key_rate": None,
|
||
"mortgage_rate_weighted": None,
|
||
"mortgage_issued_count": None,
|
||
"mortgage_issued_volume": None,
|
||
"mortgage_debt": None,
|
||
"mortgage_overdue": None,
|
||
}
|
||
defaults.update(kw)
|
||
return MonthlyMacro(month=month, **defaults)
|
||
|
||
|
||
# ── pure: _carry_forward (LOCF) ───────────────────────────────────────────────
|
||
|
||
|
||
class TestCarryForward:
|
||
def test_fills_gaps_with_last_known(self) -> None:
|
||
assert _carry_forward([1.0, None, None, 2.0, None]) == [1.0, 1.0, 1.0, 2.0, 2.0]
|
||
|
||
def test_leading_none_stays_none(self) -> None:
|
||
# До первой известной точки carry невозможен.
|
||
assert _carry_forward([None, None, 5.0, None]) == [None, None, 5.0, 5.0]
|
||
|
||
def test_all_none(self) -> None:
|
||
assert _carry_forward([None, None]) == [None, None]
|
||
|
||
def test_empty(self) -> None:
|
||
assert _carry_forward([]) == []
|
||
|
||
def test_does_not_mutate_input(self) -> None:
|
||
src: list[float | None] = [1.0, None]
|
||
_carry_forward(src)
|
||
assert src == [1.0, None]
|
||
|
||
|
||
# ── pure: classify_regime ─────────────────────────────────────────────────────
|
||
|
||
|
||
class TestClassifyRegime:
|
||
def test_down_stable_up_path(self) -> None:
|
||
# Синтетический путь: смягчение → плато → ужесточение.
|
||
# 9.0 → 8.5 (down) → 8.5 (stable) → 8.5 (stable) → 9.0 (up) → 9.5 (up)
|
||
rates: list[float | None] = [9.0, 8.5, 8.5, 8.5, 9.0, 9.5]
|
||
out = classify_regime(rates)
|
||
assert out == ["stable", "down", "stable", "stable", "up", "up"]
|
||
|
||
def test_first_month_stable(self) -> None:
|
||
# i=0 нет базы сравнения → 'stable' (нейтрально, не выдумываем тренд).
|
||
assert classify_regime([10.0])[0] == "stable"
|
||
|
||
def test_eps_threshold_exact_down(self) -> None:
|
||
# Δ ровно -eps → 'down' (граница включительно: delta <= -eps).
|
||
assert classify_regime([10.0, 10.0 - _REGIME_EPS_PP]) == ["stable", "down"]
|
||
|
||
def test_eps_threshold_exact_up(self) -> None:
|
||
# Δ ровно +eps → 'up' (delta >= +eps).
|
||
assert classify_regime([10.0, 10.0 + _REGIME_EPS_PP]) == ["stable", "up"]
|
||
|
||
def test_just_below_eps_is_stable(self) -> None:
|
||
# |Δ| < eps → шум → 'stable'.
|
||
below = _REGIME_EPS_PP - 0.01
|
||
assert classify_regime([10.0, 10.0 + below]) == ["stable", "stable"]
|
||
assert classify_regime([10.0, 10.0 - below]) == ["stable", "stable"]
|
||
|
||
def test_custom_eps(self) -> None:
|
||
# При eps=1.0 шаг 0.5 п.п. больше не тренд.
|
||
assert classify_regime([10.0, 10.5], eps_pp=1.0) == ["stable", "stable"]
|
||
assert classify_regime([10.0, 11.0], eps_pp=1.0) == ["stable", "up"]
|
||
|
||
def test_none_gap_carried_then_classified(self) -> None:
|
||
# 9.0 → None(carry 9.0, stable) → 9.5 (up vs carried 9.0).
|
||
out = classify_regime([9.0, None, 9.5])
|
||
assert out == ["stable", "stable", "up"]
|
||
|
||
def test_leading_none_is_stable(self) -> None:
|
||
# None до первой точки: нет ни cur, ни базы → 'stable'.
|
||
out = classify_regime([None, None, 7.0, 8.0])
|
||
assert out == ["stable", "stable", "stable", "up"]
|
||
|
||
def test_empty(self) -> None:
|
||
assert classify_regime([]) == []
|
||
|
||
def test_window_months_two(self) -> None:
|
||
# window=2: сравниваем с точкой 2 месяца назад.
|
||
# i=2: 9.0 vs rates[0]=8.0 → +1.0 → up; i=3: 9.5 vs rates[1]=8.5 → up.
|
||
out = classify_regime([8.0, 8.5, 9.0, 9.5], window_months=2)
|
||
assert out == ["stable", "stable", "up", "up"]
|
||
|
||
|
||
# ── pure: macro_at_lag ────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestMacroAtLag:
|
||
def _series(self) -> list[MonthlyMacro]:
|
||
return [
|
||
_macro(dt.date(2024, 1, 1), key_rate=16.0),
|
||
_macro(dt.date(2024, 2, 1), key_rate=16.0),
|
||
_macro(dt.date(2024, 3, 1), key_rate=16.0),
|
||
_macro(dt.date(2024, 4, 1), key_rate=16.0),
|
||
]
|
||
|
||
def test_lag_zero_returns_target(self) -> None:
|
||
m = macro_at_lag(self._series(), dt.date(2024, 3, 1), 0)
|
||
assert m is not None and m.month == dt.date(2024, 3, 1)
|
||
|
||
def test_lag_two_months(self) -> None:
|
||
m = macro_at_lag(self._series(), dt.date(2024, 3, 1), 2)
|
||
assert m is not None and m.month == dt.date(2024, 1, 1)
|
||
|
||
def test_target_normalised_to_first_of_month(self) -> None:
|
||
# Передаём середину месяца — нормализуется к 1-му числу.
|
||
m = macro_at_lag(self._series(), dt.date(2024, 3, 17), 0)
|
||
assert m is not None and m.month == dt.date(2024, 3, 1)
|
||
|
||
def test_lag_beyond_series_returns_none(self) -> None:
|
||
# 2024-03 минус 6 мес = 2023-09 — нет в series.
|
||
assert macro_at_lag(self._series(), dt.date(2024, 3, 1), 6) is None
|
||
|
||
def test_missing_intermediate_month_returns_none(self) -> None:
|
||
# Дыра в ряду: лаг указывает на отсутствующий месяц → None.
|
||
sparse = [
|
||
_macro(dt.date(2024, 1, 1), key_rate=16.0),
|
||
_macro(dt.date(2024, 3, 1), key_rate=16.0),
|
||
]
|
||
assert macro_at_lag(sparse, dt.date(2024, 3, 1), 1) is None # 2024-02 отсутствует
|
||
|
||
def test_empty_series(self) -> None:
|
||
assert macro_at_lag([], dt.date(2024, 3, 1), 0) is None
|
||
|
||
def test_lag_six_present(self) -> None:
|
||
# Полугодовой лаг, когда месяц есть.
|
||
long_series = [_macro(_shift_months(dt.date(2024, 7, 1), -k)) for k in range(8)]
|
||
m = macro_at_lag(long_series, dt.date(2024, 7, 1), 6)
|
||
assert m is not None and m.month == dt.date(2024, 1, 1)
|
||
|
||
|
||
# ── pure: is_confounded_window ────────────────────────────────────────────────
|
||
|
||
|
||
class TestIsConfoundedWindow:
|
||
def test_straddles_covid_2020_03(self) -> None:
|
||
assert is_confounded_window(dt.date(2020, 1, 1), dt.date(2020, 6, 1)) is True
|
||
|
||
def test_straddles_lgota_cut_2024_07(self) -> None:
|
||
assert is_confounded_window(dt.date(2024, 5, 1), dt.date(2024, 9, 1)) is True
|
||
|
||
def test_straddles_geopolitics_2022_02(self) -> None:
|
||
assert is_confounded_window(dt.date(2022, 1, 1), dt.date(2022, 4, 1)) is True
|
||
|
||
def test_clean_window_not_confounded(self) -> None:
|
||
# 2023-Q1 — между шоками, чисто.
|
||
assert is_confounded_window(dt.date(2023, 1, 1), dt.date(2023, 4, 1)) is False
|
||
|
||
def test_boundary_inclusive_start(self) -> None:
|
||
# confounder ровно на start → попадает (включительно).
|
||
assert is_confounded_window(dt.date(2024, 7, 1), dt.date(2024, 8, 1)) is True
|
||
|
||
def test_boundary_inclusive_end(self) -> None:
|
||
# confounder ровно на end → попадает.
|
||
assert is_confounded_window(dt.date(2024, 6, 1), dt.date(2024, 7, 1)) is True
|
||
|
||
def test_mid_month_dates_normalised(self) -> None:
|
||
# Даты не на 1-м числе нормализуются к месяцу; шок 2024-07 внутри.
|
||
assert is_confounded_window(dt.date(2024, 6, 15), dt.date(2024, 7, 20)) is True
|
||
|
||
def test_reversed_args_normalised(self) -> None:
|
||
# start>end → меняем местами, не падаем.
|
||
assert is_confounded_window(dt.date(2020, 6, 1), dt.date(2020, 1, 1)) is True
|
||
|
||
def test_confounder_dates_have_rationale(self) -> None:
|
||
# Каждая hardcoded дата несёт «почему» (sql.md «нужен why»).
|
||
assert len(_CONFOUNDER_DATES) >= 3
|
||
for cdate, why in _CONFOUNDER_DATES:
|
||
assert isinstance(cdate, dt.date)
|
||
assert isinstance(why, str) and len(why) > 10
|
||
|
||
|
||
# ── pure: _shift_months / _month_grid ─────────────────────────────────────────
|
||
|
||
|
||
class TestShiftMonths:
|
||
def test_forward(self) -> None:
|
||
assert _shift_months(dt.date(2024, 1, 1), 2) == dt.date(2024, 3, 1)
|
||
|
||
def test_backward(self) -> None:
|
||
assert _shift_months(dt.date(2024, 1, 1), -1) == dt.date(2023, 12, 1)
|
||
|
||
def test_year_wrap_back(self) -> None:
|
||
assert _shift_months(dt.date(2024, 3, 1), -6) == dt.date(2023, 9, 1)
|
||
|
||
def test_normalises_day(self) -> None:
|
||
assert _shift_months(dt.date(2024, 3, 17), 0) == dt.date(2024, 3, 1)
|
||
|
||
|
||
class TestMonthGrid:
|
||
def test_inclusive_range(self) -> None:
|
||
grid = _month_grid(dt.date(2024, 1, 1), dt.date(2024, 4, 1))
|
||
assert grid == [
|
||
dt.date(2024, 1, 1),
|
||
dt.date(2024, 2, 1),
|
||
dt.date(2024, 3, 1),
|
||
dt.date(2024, 4, 1),
|
||
]
|
||
|
||
def test_single_month(self) -> None:
|
||
assert _month_grid(dt.date(2024, 5, 1), dt.date(2024, 5, 1)) == [dt.date(2024, 5, 1)]
|
||
|
||
def test_start_after_end_empty(self) -> None:
|
||
assert _month_grid(dt.date(2024, 5, 1), dt.date(2024, 1, 1)) == []
|
||
|
||
|
||
# ── MonthlyMacro.as_dict ──────────────────────────────────────────────────────
|
||
|
||
|
||
class TestAsDict:
|
||
def test_rounds_and_serialises(self) -> None:
|
||
m = MonthlyMacro(
|
||
month=dt.date(2024, 3, 1),
|
||
key_rate=16.123456,
|
||
mortgage_rate_weighted=7.987654,
|
||
mortgage_issued_count=1234.6,
|
||
mortgage_issued_volume=98765.4321,
|
||
mortgage_debt=123456.789,
|
||
mortgage_overdue=234.5678,
|
||
inflation_yoy=5.876543,
|
||
)
|
||
d = m.as_dict()
|
||
assert d["month"] == "2024-03-01"
|
||
assert d["key_rate"] == 16.12
|
||
assert d["inflation_yoy"] == 5.88 # round to 2 digits
|
||
assert d["mortgage_rate_weighted"] == 7.99
|
||
assert d["mortgage_issued_count"] == 1235 # round to 0 digits
|
||
assert d["mortgage_issued_volume"] == 98765.4
|
||
assert d["mortgage_debt"] == 123456.8
|
||
assert d["mortgage_overdue"] == 234.6
|
||
|
||
def test_none_fields_survive(self) -> None:
|
||
m = _macro(dt.date(2024, 3, 1))
|
||
d = m.as_dict()
|
||
assert d["month"] == "2024-03-01"
|
||
assert d["key_rate"] is None
|
||
assert d["inflation_yoy"] is None
|
||
assert d["mortgage_rate_weighted"] is None
|
||
assert d["mortgage_debt"] is None
|
||
|
||
|
||
# ── get_monthly_macro: MagicMock-сессия (форма SQL + carry + graceful) ────────
|
||
|
||
|
||
def _key_rate_result(rows: list[tuple[dt.date, float]]) -> MagicMock:
|
||
"""Результат db.execute(_KEY_RATE_MONTHLY_SQL): .all() → [(month, value), ...]."""
|
||
res = MagicMock()
|
||
res.all.return_value = rows
|
||
return res
|
||
|
||
|
||
class TestGetMonthlyMacroShape:
|
||
def test_key_rate_sql_uses_distinct_on_resample(self) -> None:
|
||
db = MagicMock()
|
||
db.execute.return_value = _key_rate_result([])
|
||
with patch(_GET_SERIES, return_value=[]):
|
||
get_monthly_macro(db, months_back=3)
|
||
sql = str(db.execute.call_args_list[0].args[0])
|
||
# Ресэмпл daily→monthly: DISTINCT ON по месяцу + последняя точка месяца.
|
||
assert "DISTINCT ON (date_trunc('month', obs_date))" in sql
|
||
assert "ORDER BY date_trunc('month', obs_date), obs_date DESC" in sql
|
||
|
||
def test_key_rate_sql_uses_cast_not_double_colon(self) -> None:
|
||
db = MagicMock()
|
||
db.execute.return_value = _key_rate_result([])
|
||
with patch(_GET_SERIES, return_value=[]):
|
||
get_monthly_macro(db, months_back=3)
|
||
sql = str(db.execute.call_args_list[0].args[0])
|
||
assert "CAST(:itype AS text)" in sql
|
||
assert "CAST(:region AS text)" in sql
|
||
assert "CAST(:since AS date)" in sql
|
||
# psycopg v3 trap: никаких :name::type
|
||
assert ":itype::" not in sql
|
||
assert ":region::" not in sql
|
||
assert ":since::" not in sql
|
||
|
||
def test_key_rate_query_params(self) -> None:
|
||
db = MagicMock()
|
||
db.execute.return_value = _key_rate_result([])
|
||
with patch(_GET_SERIES, return_value=[]):
|
||
get_monthly_macro(db, months_back=12)
|
||
params = db.execute.call_args_list[0].args[1]
|
||
assert params["itype"] == "key_rate"
|
||
assert params["region"] == "rf"
|
||
assert isinstance(params["since"], dt.date)
|
||
|
||
def test_mortgage_series_read_from_sverdl(self) -> None:
|
||
db = MagicMock()
|
||
db.execute.return_value = _key_rate_result([])
|
||
with patch(_GET_SERIES, return_value=[]) as gs:
|
||
get_monthly_macro(db, months_back=3)
|
||
# MORTGAGE-ряды читаются регионом 'sverdl' (inflation_yoy — отдельно 'rf', ниже).
|
||
assert gs.called
|
||
region_by_type = {call.args[1]: call.kwargs["region"] for call in gs.call_args_list}
|
||
assert region_by_type.get("mortgage_rate_weighted") == "sverdl"
|
||
assert region_by_type.get("mortgage_issued_count") == "sverdl"
|
||
|
||
def test_inflation_series_read_from_rf(self) -> None:
|
||
# #946: inflation_yoy — национальный ряд (region 'rf'), читается через get_macro_series.
|
||
db = MagicMock()
|
||
db.execute.return_value = _key_rate_result([])
|
||
with patch(_GET_SERIES, return_value=[]) as gs:
|
||
get_monthly_macro(db, months_back=3)
|
||
region_by_type = {call.args[1]: call.kwargs["region"] for call in gs.call_args_list}
|
||
assert region_by_type.get("inflation_yoy") == "rf"
|
||
|
||
|
||
class TestGetMonthlyMacroLogic:
|
||
def test_grid_is_continuous_and_carries_key_rate(self) -> None:
|
||
# key_rate известен только в первом и последнем месяце сетки → carry между.
|
||
today = dt.date.today()
|
||
first = _shift_months(today, -3)
|
||
last = _shift_months(today, -1)
|
||
db = MagicMock()
|
||
db.execute.return_value = _key_rate_result([(first, 16.0), (last, 18.0)])
|
||
with patch(_GET_SERIES, return_value=[]):
|
||
out = get_monthly_macro(db, months_back=3)
|
||
by_month = {m.month: m for m in out}
|
||
# Сетка непрерывна (4 месяца: -3..0).
|
||
assert len(out) == 4
|
||
assert out == sorted(out, key=lambda m: m.month)
|
||
# carry-forward: месяц между first и last наследует 16.0; последний = 18.0.
|
||
mid = _shift_months(today, -2)
|
||
assert by_month[first].key_rate == 16.0
|
||
assert by_month[mid].key_rate == 16.0 # LOCF из first
|
||
assert by_month[last].key_rate == 18.0
|
||
|
||
def test_leading_months_without_key_rate_are_none(self) -> None:
|
||
# key_rate появляется только в последнем месяце → ранние = None (carry невозможен).
|
||
today = dt.date.today()
|
||
last = _shift_months(today, -1)
|
||
db = MagicMock()
|
||
db.execute.return_value = _key_rate_result([(last, 20.0)])
|
||
with patch(_GET_SERIES, return_value=[]):
|
||
out = get_monthly_macro(db, months_back=3)
|
||
assert out[0].key_rate is None
|
||
assert out[-2].key_rate == 20.0 # last месяц сетки (idx -2 т.к. -1 = today)
|
||
|
||
def test_mortgage_joined_by_month(self) -> None:
|
||
today = dt.date.today()
|
||
target = _shift_months(today, -2)
|
||
|
||
def fake_series(_db: object, indicator_type: str, **_kw: object) -> list:
|
||
if indicator_type == "mortgage_rate_weighted":
|
||
return [(target, 7.5)]
|
||
if indicator_type == "mortgage_issued_count":
|
||
return [(target, 1200.0)]
|
||
return []
|
||
|
||
db = MagicMock()
|
||
db.execute.return_value = _key_rate_result([])
|
||
with patch(_GET_SERIES, side_effect=fake_series):
|
||
out = get_monthly_macro(db, months_back=3)
|
||
row = next(m for m in out if m.month == target)
|
||
assert row.mortgage_rate_weighted == 7.5
|
||
assert row.mortgage_issued_count == 1200.0
|
||
# Месяц без mortgage-данных → None по этим полям.
|
||
other = next(m for m in out if m.month != target)
|
||
assert other.mortgage_rate_weighted is None
|
||
|
||
def test_inflation_carries_forward_on_grid(self) -> None:
|
||
# #946: inflation_yoy известен в первом месяце сетки → carry-forward вперёд
|
||
# (медленный месячный ряд, последняя известная точка = текущий режим).
|
||
today = dt.date.today()
|
||
first = _shift_months(today, -3)
|
||
|
||
def fake_series(_db: object, indicator_type: str, **_kw: object) -> list:
|
||
if indicator_type == "inflation_yoy":
|
||
return [(first, 6.0)]
|
||
return []
|
||
|
||
db = MagicMock()
|
||
db.execute.return_value = _key_rate_result([])
|
||
with patch(_GET_SERIES, side_effect=fake_series):
|
||
out = get_monthly_macro(db, months_back=3)
|
||
by_month = {m.month: m for m in out}
|
||
assert by_month[first].inflation_yoy == 6.0
|
||
# LOCF: последующий месяц наследует 6.0 (нет своей точки).
|
||
mid = _shift_months(today, -2)
|
||
assert by_month[mid].inflation_yoy == 6.0
|
||
|
||
def test_leading_months_without_inflation_are_none(self) -> None:
|
||
# inflation появляется только в последнем месяце → ранние = None (carry невозможен).
|
||
today = dt.date.today()
|
||
last = _shift_months(today, -1)
|
||
|
||
def fake_series(_db: object, indicator_type: str, **_kw: object) -> list:
|
||
if indicator_type == "inflation_yoy":
|
||
return [(last, 5.5)]
|
||
return []
|
||
|
||
db = MagicMock()
|
||
db.execute.return_value = _key_rate_result([])
|
||
with patch(_GET_SERIES, side_effect=fake_series):
|
||
out = get_monthly_macro(db, months_back=3)
|
||
assert out[0].inflation_yoy is None
|
||
assert out[-2].inflation_yoy == 5.5
|
||
|
||
|
||
class TestGetMonthlyMacroGraceful:
|
||
def test_empty_table_returns_rows_with_none(self) -> None:
|
||
# Пустые данные, но months_back>0 → сетка строится, поля None (не crash).
|
||
db = MagicMock()
|
||
db.execute.return_value = _key_rate_result([])
|
||
with patch(_GET_SERIES, return_value=[]):
|
||
out = get_monthly_macro(db, months_back=2)
|
||
assert len(out) == 3 # -2..0
|
||
assert all(m.key_rate is None for m in out)
|
||
assert all(m.mortgage_debt is None for m in out)
|
||
|
||
def test_months_back_zero_single_month(self) -> None:
|
||
db = MagicMock()
|
||
db.execute.return_value = _key_rate_result([])
|
||
with patch(_GET_SERIES, return_value=[]):
|
||
out = get_monthly_macro(db, months_back=0)
|
||
assert len(out) == 1 # только текущий месяц
|
||
|
||
def test_key_rate_query_exception_graceful(self) -> None:
|
||
# Сбой key_rate-запроса → пустой ресэмпл, но ряд строится (НЕ crash).
|
||
db = MagicMock()
|
||
db.execute.side_effect = RuntimeError("db down")
|
||
with patch(_GET_SERIES, return_value=[]):
|
||
out = get_monthly_macro(db, months_back=2)
|
||
assert len(out) == 3
|
||
assert all(m.key_rate is None for m in out)
|
||
|
||
def test_mortgage_series_exception_graceful(self) -> None:
|
||
# Сбой одного mortgage-ряда не валит весь сбор.
|
||
db = MagicMock()
|
||
db.execute.return_value = _key_rate_result([])
|
||
with patch(_GET_SERIES, side_effect=RuntimeError("boom")):
|
||
out = get_monthly_macro(db, months_back=1)
|
||
assert len(out) == 2
|
||
assert all(m.mortgage_rate_weighted is None for m in out)
|