From 6a79b99c29f6f5b40a5d24efd765b50a3f43b16a Mon Sep 17 00:00:00 2001 From: Light1YT Date: Wed, 3 Jun 2026 10:37:24 +0500 Subject: [PATCH] feat(forecasting): monthly macro series + key-rate regime classifier (#951b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/app/services/forecasting/__init__.py | 34 ++ .../app/services/forecasting/macro_series.py | 377 +++++++++++++++ .../tests/services/forecasting/__init__.py | 0 .../services/forecasting/test_macro_series.py | 430 ++++++++++++++++++ 4 files changed, 841 insertions(+) create mode 100644 backend/app/services/forecasting/__init__.py create mode 100644 backend/app/services/forecasting/macro_series.py create mode 100644 backend/tests/services/forecasting/__init__.py create mode 100644 backend/tests/services/forecasting/test_macro_series.py diff --git a/backend/app/services/forecasting/__init__.py b/backend/app/services/forecasting/__init__.py new file mode 100644 index 00000000..7f6ac606 --- /dev/null +++ b/backend/app/services/forecasting/__init__.py @@ -0,0 +1,34 @@ +"""Forecasting services — детерминированный форсайт-слой Site Finder v2. + +#951 (Site Finder v2 / «GG-форсайт», EPIC 7 «Чувствительность к ключевой ставке»). + +Этот пакет — фундамент data-independent логики прогноза: monthly макро-ряды, +классификатор режима ставки, лаговые помощники. Всё ДЕТЕРМИНИРОВАННО, БЕЗ LLM. + +Слои (по PR): + • macro_series (этот PR, #951b) — monthly макро-ряд + классификатор режима ставки. + • sales_series (later PR) — monthly ряд продаж по локации. + • rate_sensitivity (later PR) — §9.6 чувствительность продаж к key_rate. + • macro_coefficient (later PR) — §9.5 макро-коэффициент. + +Источник макро-данных — таблица macro_indicator через тонкий reader +backend/app/services/site_finder/macro.py (переиспользуем, не дублируем). +""" + +from __future__ import annotations + +from app.services.forecasting.macro_series import ( + MonthlyMacro, + classify_regime, + get_monthly_macro, + is_confounded_window, + macro_at_lag, +) + +__all__ = [ + "MonthlyMacro", + "classify_regime", + "get_monthly_macro", + "is_confounded_window", + "macro_at_lag", +] diff --git a/backend/app/services/forecasting/macro_series.py b/backend/app/services/forecasting/macro_series.py new file mode 100644 index 00000000..2eea100b --- /dev/null +++ b/backend/app/services/forecasting/macro_series.py @@ -0,0 +1,377 @@ +"""Monthly макро-ряд + детерминированный классификатор режима ключевой ставки. + +#951 (Site Finder v2 / «GG-форсайт» ТЗ §9.5/§9.6), EPIC 7 «Чувствительность к +ключевой ставке», PR B (#951b). Это **data-independent фундамент**: monthly +макро-таймсерия, классификатор тренда ставки (down/up/stable), лаговые помощники +и маркер «зашумлённого» окна. Всё ДЕТЕРМИНИРОВАННО, БЕЗ LLM. + +Потребители (later PRs, НЕ этот): §9.6 key-rate sensitivity, §9.5 макро-коэффициент. + +Источник данных — таблица macro_indicator (миграция data/sql/123_macro_indicator.sql), +читается через тонкий reader app.services.site_finder.macro (переиспользуем): + • key_rate — region 'rf', ДНЕВНОЙ ряд, source 'cbr' (backfill PR B + #945, 2019-01-01 → сегодня). Ресэмплим daily → monthly. + • mortgage_* — region 'sverdl', УЖЕ месячные (obs_date нормализован к + 1-му числу через make_date(...,1)). Берём как есть. + (income/cpi НЕ backfilled — на них не опираемся.) + +Соглашение по дате: каждый MonthlyMacro.month — ПЕРВОЕ число месяца (YYYY-MM-01). +Это зеркалит нормализацию mortgage-рядов в backfill (make_date(year, month, 1)), +поэтому join «месяц-в-месяц» сводится к равенству obs_date без date_trunc. + +Graceful-on-thin-data (КРИТИЧНО, дух market_metrics.py): пустая таблица / сбой БД +→ [] (НЕ crash). Месяцы без точки key_rate → carry-forward последнего известного +значения; до самой первой известной точки carry невозможен → key_rate=None. + +psycopg v3 / SQLAlchemy text: bind-параметры ВСЕГДА через CAST(:x AS type) — +никогда :x::type (парсер psycopg3 даёт SyntaxError на :name::type). +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from datetime import date +from typing import Any, Literal + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.services.site_finder.macro import get_macro_series + +logger = logging.getLogger(__name__) + +Regime = Literal["down", "up", "stable"] + +# ── Named-константы ─────────────────────────────────────────────────────────── + +# Глубина окна по умолчанию для get_monthly_macro (месяцев назад от текущего). +# 48 мес ≈ 4 года: покрывает полный цикл ставки ЦБ (мягкая 2020 → жёсткая 2022 → +# смягчение → новый подъём 2023-2024) — достаточно для лаговых регрессий §9.6. +_DEFAULT_MONTHS_BACK: int = 48 + +# Порог режима ставки: изменение key_rate ≥ этого числа п.п. за окно считается +# трендом (down/up); меньше по модулю → 'stable' (шум/удержание). 0.25 п.п. — +# минимальный «значимый» шаг решения ЦБ (совет директоров двигает ставку шагами +# кратными 0.25-0.50 п.п.; <0.25 п.п. за месяц = фактически плато). +_REGIME_EPS_PP: float = 0.25 + +# Окно (в месяцах) для оценки локального тренда ставки в classify_regime: сравниваем +# текущую ставку со ставкой _REGIME_WINDOW_MONTHS назад. 1 = «месяц-к-месяцу». +# Берём 1: решения ЦБ дискретны и крупны, лишнее сглаживание размывает точку разворота. +_REGIME_WINDOW_MONTHS: int = 1 + +# ── Confounder-даты (hardcoded — с обоснованием КАЖДОЙ, см. sql.md «нужен why») ── +# Окна, ПЕРЕСЕКАЮЩИЕ любую из этих дат, зашумлены: продажи в них двигала не ставка, +# а разовый политический/эпид-шок → downstream (§9.6) понижает по ним confidence. +# Формат: (date, краткое «почему этот разрыв конфаундит rate↔sales»). +_CONFOUNDER_DATES: tuple[tuple[date, str], ...] = ( + ( + date(2020, 3, 1), + "COVID-19 lockdown + запуск льготной ипотеки 6.5%: обвал, затем всплеск " + "спроса, не связанный с динамикой ключевой ставки", + ), + ( + date(2022, 2, 1), + "Геополитический шок 02.2022 + экстренный подъём ставки ЦБ до 20%: " + "паническая динамика сделок, разрыв обычной связи rate↔sales", + ), + ( + date(2024, 7, 1), + "Сворачивание массовой льготной ипотеки с 01.07.2024: ажиотажный «забег» " + "в июне + провал спроса после — продажи двигала отмена льготы, не ставка", + ), +) + + +@dataclass(frozen=True) +class MonthlyMacro: + """Одна месячная точка макро-ряда (ТЗ §9.5/§9.6). + + month — ПЕРВОЕ число месяца (YYYY-MM-01). Любое поле = None при отсутствии + данных за этот месяц (никогда 0-как-заглушка). + """ + + month: date + key_rate: float | None # ставка ЦБ, действовавшая на конец месяца (% годовых) + mortgage_rate_weighted: float | None # средневзвешенная ИЖК-ставка, % + mortgage_issued_count: float | None # выдано ИЖК-кредитов, шт + mortgage_issued_volume: float | None # объём выданных ИЖК, млн руб + mortgage_debt: float | None # задолженность по ИЖК, млн руб + mortgage_overdue: float | None # просроченная задолженность по ИЖК, млн руб + + def as_dict(self) -> dict[str, Any]: + return { + "month": self.month.isoformat(), + "key_rate": _round_or_none(self.key_rate, 2), + "mortgage_rate_weighted": _round_or_none(self.mortgage_rate_weighted, 2), + "mortgage_issued_count": _round_or_none(self.mortgage_issued_count, 0), + "mortgage_issued_volume": _round_or_none(self.mortgage_issued_volume, 1), + "mortgage_debt": _round_or_none(self.mortgage_debt, 1), + "mortgage_overdue": _round_or_none(self.mortgage_overdue, 1), + } + + +def _round_or_none(value: float | None, digits: int) -> float | None: + return round(value, digits) if value is not None else None + + +# ────────────────────────────────────────────────────────────────────────────── +# Pure-логика — без БД, полностью юнит-тестируемо. +# ────────────────────────────────────────────────────────────────────────────── + +# Месячные mortgage-поля, которые join-им из macro_indicator (region 'sverdl'). +# indicator_type → имя поля MonthlyMacro. mortgage_term намеренно НЕ тянем +# (downstream §9.6/§9.5 он не нужен; держим MonthlyMacro узким). +_MORTGAGE_FIELDS: tuple[tuple[str, str], ...] = ( + ("mortgage_rate_weighted", "mortgage_rate_weighted"), + ("mortgage_issued_count", "mortgage_issued_count"), + ("mortgage_issued_volume", "mortgage_issued_volume"), + ("mortgage_debt", "mortgage_debt"), + ("mortgage_overdue", "mortgage_overdue"), +) + + +def _month_start(d: date) -> date: + """Нормализовать дату к 1-му числу её месяца (YYYY-MM-01).""" + return date(d.year, d.month, 1) + + +def _carry_forward(values: list[float | None]) -> list[float | None]: + """Заполнить None-дыры последним известным значением (LOCF). + + Дыры ДО первого известного значения остаются None (carry невозможен). + Чистая функция: не мутирует вход. + """ + out: list[float | None] = [] + last: float | None = None + for v in values: + if v is not None: + last = v + out.append(last) + return out + + +def classify_regime( + rates: list[float | None], + *, + eps_pp: float = _REGIME_EPS_PP, + window_months: int = _REGIME_WINDOW_MONTHS, +) -> list[Regime]: + """Классифицировать локальный тренд ключевой ставки по месяцам. + + Для каждого месяца i сравниваем ставку с ней же window_months назад: + • Δ ≤ -eps_pp → 'down' (смягчение ДКП) + • Δ ≥ +eps_pp → 'up' (ужесточение ДКП) + • |Δ| < eps_pp → 'stable' (плато/удержание; шум < шага решения ЦБ) + + Δ берётся на carry-forward версии ряда (LOCF), поэтому одиночные None-дыры + не рвут тренд. Если на момент i нет ни текущего значения, ни базы для + сравнения (оба None — типично в самом начале до первой точки) → 'stable' + (нейтрально, не выдумываем тренд). PURE, без БД. + + Args: + rates: ставка по месяцам (хронологически ASC), None = нет данных. + eps_pp: порог значимого изменения в п.п. (по умолчанию _REGIME_EPS_PP). + window_months: за сколько месяцев назад берём базу сравнения (≥1). + + Returns: + Список того же размера, что rates: 'down' | 'up' | 'stable' на месяц. + """ + w = max(1, window_months) + filled = _carry_forward(rates) + out: list[Regime] = [] + for i in range(len(filled)): + cur = filled[i] + prev = filled[i - w] if i - w >= 0 else None + if cur is None or prev is None: + out.append("stable") + continue + delta = cur - prev + if delta <= -eps_pp: + out.append("down") + elif delta >= eps_pp: + out.append("up") + else: + out.append("stable") + return out + + +def macro_at_lag( + series: list[MonthlyMacro], target_month: date, lag_months: int +) -> MonthlyMacro | None: + """Вернуть точку макро-ряда на (target_month − lag_months) или None. + + Лаги, используемые downstream §9.6: 0/1/2/3/6. target_month нормализуется к + 1-му числу. Если такого месяца нет в series (дыра / выход за диапазон) → None. + PURE, без БД. + + Args: + series: список MonthlyMacro (порядок не важен — ищем по равенству month). + target_month: целевой месяц (нормализуется к YYYY-MM-01). + lag_months: на сколько месяцев назад сместиться (≥0). + + Returns: + MonthlyMacro искомого месяца либо None. + """ + anchor = _month_start(target_month) + wanted = _shift_months(anchor, -lag_months) + for m in series: + if m.month == wanted: + return m + return None + + +def _shift_months(d: date, delta: int) -> date: + """Сдвинуть дату-1-е-число на delta месяцев (delta может быть отрицательным).""" + base = _month_start(d) + total = (base.year * 12 + (base.month - 1)) + delta + year, month0 = divmod(total, 12) + return date(year, month0 + 1, 1) + + +def is_confounded_window(start: date, end: date) -> bool: + """True, если окно [start, end] пересекает известную дату-конфаундер. + + Эти даты (см. _CONFOUNDER_DATES) — разовые шоки, разрывающие обычную связь + «ключевая ставка ↔ продажи»; downstream §9.6 по таким окнам понижает + confidence. Сравнение по 1-му числу месяца (даты-конфаундеры — начала + месяцев). start>end → нормализуем (меняем местами). PURE, без БД. + + Args: + start: начало окна. + end: конец окна (включительно). + + Returns: + True если хотя бы одна confounder-дата попадает в [start, end]. + """ + lo, hi = (start, end) if start <= end else (end, start) + lo_m = _month_start(lo) + hi_m = _month_start(hi) + return any(lo_m <= cdate <= hi_m for cdate, _why in _CONFOUNDER_DATES) + + +# ────────────────────────────────────────────────────────────────────────────── +# DB-reader — тонкий, graceful. Pure-логика выше тестируется без него. +# ────────────────────────────────────────────────────────────────────────────── + +# Ресэмпл ДНЕВНОГО key_rate (region 'rf') → monthly методом last-known-value +# (ставка, действовавшая на КОНЕЦ месяца). DISTINCT ON (date_trunc('month', ...)) +# + ORDER BY ... obs_date DESC оставляет по одной — самой поздней — точке месяца. +# Делаем в SQL, а не в Python: иначе пришлось бы тянуть ~1.9k дневных строк в +# процесс; DISTINCT ON решает last-value за один set-based проход. Месяц, +# нормализованный к 1-му числу, сразу join-ится к mortgage-рядам. +_KEY_RATE_MONTHLY_SQL = text( + """ + SELECT DISTINCT ON (date_trunc('month', obs_date)) + CAST(date_trunc('month', obs_date) AS date) AS month, + value + FROM macro_indicator + WHERE indicator_type = CAST(:itype AS text) + AND region = CAST(:region AS text) + AND value IS NOT NULL + AND obs_date >= CAST(:since AS date) + ORDER BY date_trunc('month', obs_date), obs_date DESC + """ +) + + +def get_monthly_macro( + db: Session, *, months_back: int = _DEFAULT_MONTHS_BACK +) -> list[MonthlyMacro]: + """Собрать monthly макро-ряд за последние months_back месяцев. + + key_rate (region 'rf', дневной) ресэмплится daily→monthly как last-known-value + (ставка на конец месяца). Месячные mortgage-ряды (region 'sverdl') берутся как + есть и join-ятся по году-месяцу. Дыры key_rate между известными точками + заполняются carry-forward (LOCF); до первой известной точки key_rate=None. + + Ряд строится по непрерывной сетке месяцев [start .. текущий] (а НЕ только по + месяцам, где есть данные), чтобы лаги/режим имели регулярный шаг. Месяц без + ЛЮБЫХ данных всё равно присутствует (все поля None для него — кроме carry key_rate). + + Graceful: при сбое БД или пустой таблице key_rate сетка месяцев всё равно + возвращается, но с None-полями (НЕ crash). Пустой список [] — только если + сама сетка пуста (months_back < 0). + + Args: + db: SQLAlchemy sync Session. + months_back: глубина ряда в месяцах (по умолчанию _DEFAULT_MONTHS_BACK). + + Returns: + Список MonthlyMacro по возрастанию month (по непрерывной сетке); + [] только при пустой сетке (months_back < 0). + """ + # month-bucketing в локальной tz сервера (single-region, как и весь codebase) + today = date.today() + start = _shift_months(today, -max(0, months_back)) + grid = _month_grid(start, _month_start(today)) + if not grid: + return [] + + key_rate_by_month = _query_key_rate_monthly(db, since=start) + mortgage_by_field = _query_mortgage_monthly(db, since=start) + + # key_rate: разворачиваем на сетку + carry-forward по сетке (LOCF). + raw_rates: list[float | None] = [key_rate_by_month.get(m) for m in grid] + filled_rates = _carry_forward(raw_rates) + + rows: list[MonthlyMacro] = [] + for idx, month in enumerate(grid): + kwargs: dict[str, float | None] = { + field: mortgage_by_field.get(field, {}).get(month) for _itype, field in _MORTGAGE_FIELDS + } + rows.append(MonthlyMacro(month=month, key_rate=filled_rates[idx], **kwargs)) + + logger.info( + "get_monthly_macro: months_back=%d grid=%d key_rate_pts=%d", + months_back, + len(grid), + len(key_rate_by_month), + ) + return rows + + +def _month_grid(start: date, end: date) -> list[date]: + """Непрерывный список 1-х чисел месяцев от start до end включительно (ASC).""" + lo = _month_start(start) + hi = _month_start(end) + if lo > hi: + return [] + out: list[date] = [] + cur = lo + while cur <= hi: + out.append(cur) + cur = _shift_months(cur, 1) + return out + + +def _query_key_rate_monthly(db: Session, *, since: date) -> dict[date, float]: + """Ресэмпл дневного key_rate (region 'rf') → {month1st: value}. Graceful → {}.""" + try: + rows = db.execute( + _KEY_RATE_MONTHLY_SQL, + {"itype": "key_rate", "region": "rf", "since": since}, + ).all() + except Exception: + logger.exception("get_monthly_macro: key_rate query failed") + return {} + return {r[0]: float(r[1]) for r in rows if r[0] is not None and r[1] is not None} + + +def _query_mortgage_monthly(db: Session, *, since: date) -> dict[str, dict[date, float]]: + """Месячные mortgage-ряды (region 'sverdl') через reuse get_macro_series. + + Возвращает {field: {month1st: value}}. obs_date уже нормализован к 1-му числу + в backfill, но _month_start применяем повторно (страховка). Сбой одного ряда + не валит остальные (graceful: пустой подсловарь). + """ + out: dict[str, dict[date, float]] = {} + for indicator_type, field in _MORTGAGE_FIELDS: + try: + series = get_macro_series(db, indicator_type, region="sverdl", since=since) + except Exception: + logger.exception("get_monthly_macro: mortgage series %s failed", indicator_type) + series = [] + out[field] = {_month_start(d): float(v) for d, v in series} + return out diff --git a/backend/tests/services/forecasting/__init__.py b/backend/tests/services/forecasting/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/services/forecasting/test_macro_series.py b/backend/tests/services/forecasting/test_macro_series.py new file mode 100644 index 00000000..f22736b1 --- /dev/null +++ b/backend/tests/services/forecasting/test_macro_series.py @@ -0,0 +1,430 @@ +"""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)