feat(forecasting): monthly macro series + regime classifier (#951b) (#1007)
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m23s
Deploy / build-worker (push) Successful in 2m43s
Deploy / deploy (push) Successful in 1m5s

This commit is contained in:
bot-backend 2026-06-03 05:37:43 +00:00
parent a10592847d
commit ace3b99508
4 changed files with 841 additions and 0 deletions

View file

@ -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",
]

View file

@ -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', дневной) ресэмплится dailymonthly как 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

View file

@ -0,0 +1,430 @@
"""Unit-тесты monthly макро-ряда + классификатора режима ставки (#951b, ТЗ §9.5/§9.6).
Чистые тесты (без живой БД):
classify_regime синтетический путь downstableup, пороги 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)