"""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, ) d = m.as_dict() assert d["month"] == "2024-03-01" assert d["key_rate"] == 16.12 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["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'. assert gs.called for call in gs.call_args_list: assert call.kwargs["region"] == "sverdl" read_types = {call.args[1] for call in gs.call_args_list} assert "mortgage_rate_weighted" in read_types assert "mortgage_issued_count" in read_types 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 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)