feat(forecasting): monthly macro series + regime classifier (#951b) (#1007)
This commit is contained in:
parent
a10592847d
commit
ace3b99508
4 changed files with 841 additions and 0 deletions
34
backend/app/services/forecasting/__init__.py
Normal file
34
backend/app/services/forecasting/__init__.py
Normal 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",
|
||||
]
|
||||
377
backend/app/services/forecasting/macro_series.py
Normal file
377
backend/app/services/forecasting/macro_series.py
Normal 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', дневной) ресэмплится 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
|
||||
0
backend/tests/services/forecasting/__init__.py
Normal file
0
backend/tests/services/forecasting/__init__.py
Normal file
430
backend/tests/services/forecasting/test_macro_series.py
Normal file
430
backend/tests/services/forecasting/test_macro_series.py
Normal file
|
|
@ -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)
|
||||
Loading…
Add table
Reference in a new issue