"""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 contextlib import datetime as dt import os from collections.abc import Iterator from typing import Any 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, _MORTGAGE_FIELDS, _REGIME_EPS_PP, MonthlyMacro, _carry_forward, _month_grid, _query_inflation_monthly, _query_key_rate_monthly, _query_mortgage_monthly, _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) # ────────────────────────────────────────────────────────────────────────────── # SAVEPOINT (#2464 cluster A finding #2, `_query_mortgage_monthly`): сбой ОДНОГО # mortgage-поля НЕ должен «отравить» общую §22-сессию для остальных 4 полей / для # последующих §9.x-слоёв, переиспользующих ту же `db`-Session. Без `db.begin_nested()` # сбойный `db.execute` (внутри `get_macro_series`) оставляет транзакцию Postgres # aborted — каждый ПОСЛЕДУЮЩИЙ execute на той же сессии тоже падал бы. # ────────────────────────────────────────────────────────────────────────────── class _FakeMortgageResult: """Мини-результат db.execute() — покрывает `.all()` (см. `get_macro_series`).""" def all(self) -> list[Any]: return [] class _PoisonableSession: """Fake Session, симулирующая Postgres 'aborted transaction' семантику. `execute()` бросает РОВНО один раз (на первое поле) и, как реальный Postgres, «отравляет» сессию: БЕЗ SAVEPOINT-отката любой ПОСЛЕДУЮЩИЙ `execute()` (даже несвязанный) тоже бросает — `current transaction is aborted`. `begin_nested()` зеркалит `Session.begin_nested()`: при исключении откатывает ТОЛЬКО SAVEPOINT (сбрасывает poison-флаг) и пробрасывает исключение дальше — как настоящий `ROLLBACK TO SAVEPOINT`, НЕ общий `db.rollback()` (откатил бы всю внешнюю транзакцию). """ def __init__(self) -> None: self.poisoned = False self._raised = False self.calls = 0 def execute(self, *args: Any, **kwargs: Any) -> _FakeMortgageResult: self.calls += 1 if self.poisoned: raise RuntimeError( "current transaction is aborted, commands ignored until end of transaction block" ) if not self._raised: self._raised = True self.poisoned = True raise RuntimeError("simulated mortgage field query failure") return _FakeMortgageResult() @contextlib.contextmanager def begin_nested(self) -> Iterator[None]: try: yield except Exception: # ROLLBACK TO SAVEPOINT — откатывает только вложенный SAVEPOINT, # сессия остаётся рабочей для следующего поля. self.poisoned = False raise class TestQueryMortgageMonthlySavepoint: """`_query_mortgage_monthly` оборачивает КАЖДОЕ поле в `db.begin_nested()`.""" def test_first_field_failure_leaves_session_usable_for_later_fields(self) -> None: db = _PoisonableSession() out = _query_mortgage_monthly(db, since=dt.date(2024, 1, 1)) # все 5 полей присутствуют в результате (контракт функции не изменён). assert set(out.keys()) == {field for _itype, field in _MORTGAGE_FIELDS} # сбойное (первое) поле → пустой fallback-словарь, НЕ crash. first_field = _MORTGAGE_FIELDS[0][1] assert out[first_field] == {} # КЛЮЧЕВОЙ ассерт savepoint-фикса: сессия НЕ отравлена — db.execute ПОСЛЕ # сбойного поля проходит без 'current transaction aborted' (не poisoned). db.execute("SELECT 1") class TestQueryKeyRateMonthlySavepoint: """`_query_key_rate_monthly` оборачивает db.execute в `db.begin_nested()`. Тот же poisoning-класс, что и mortgage-цикл: key_rate — ПЕРВЫЙ запрос get_monthly_macro; без SAVEPOINT его сбой отравил бы транзакцию для inflation + mortgage + всех последующих §9.x-слоёв на общей §22-сессии. """ def test_db_failure_leaves_session_usable(self) -> None: db = _PoisonableSession() out = _query_key_rate_monthly(db, since=dt.date(2024, 1, 1)) # сбой запроса → пустой ресэмпл (fallback, НЕ crash). assert out == {} # КЛЮЧЕВОЙ ассерт savepoint-фикса: сессия НЕ отравлена — db.execute ПОСЛЕ # сбоя проходит без 'current transaction aborted' (не poisoned). db.execute("SELECT 1") class TestQueryInflationMonthlySavepoint: """`_query_inflation_monthly` оборачивает db.execute (через get_macro_series) в SAVEPOINT. Тот же poisoning-класс: inflation — ВТОРОЙ запрос get_monthly_macro; без SAVEPOINT его сбой отравил бы транзакцию для mortgage-полей + последующих §9.x-слоёв. """ def test_db_failure_leaves_session_usable(self) -> None: db = _PoisonableSession() out = _query_inflation_monthly(db, since=dt.date(2024, 1, 1)) # сбой запроса → пустой ряд (fallback, НЕ crash). assert out == {} # КЛЮЧЕВОЙ ассерт savepoint-фикса: сессия НЕ отравлена — db.execute ПОСЛЕ # сбоя проходит без 'current transaction aborted' (не poisoned). db.execute("SELECT 1")