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