gendesign/backend/tests/services/forecasting/test_macro_series.py
Light1YT 6a79b99c29 feat(forecasting): monthly macro series + key-rate regime classifier (#951b)
New backend/app/services/forecasting package (Site Finder v2 EPIC 7, §9.5/§9.6
foundation). Deterministic, no LLM. MonthlyMacro dataclass + get_monthly_macro
(daily key_rate resampled to monthly last-known-value via SQL DISTINCT ON, reuses
macro.get_macro_series for the already-monthly sverdl mortgage series, carry-forward
key_rate over a continuous month grid, graceful on empty/DB error). Pure helpers:
classify_regime (down/up/stable, eps 0.25pp), macro_at_lag (0/1/2/3/6),
is_confounded_window (COVID 2020-03, geopolitics 2022-02, subsidy cutback 2024-07).
51 unit tests, no live DB. psycopg v3 CAST(:x AS type) throughout.
2026-06-03 10:37:24 +05:00

430 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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)