feat(macro): CBR inflation (ИПЦ YoY) -> macro_indicator + activate §9.5 channel (#946)
All checks were successful
CI / frontend-tests (pull_request) Has been skipped
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m45s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m42s
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
Deploy / deploy (push) Successful in 1m15s
CI / backend-tests (push) Successful in 6m30s
CI / backend-tests (pull_request) Successful in 6m32s
All checks were successful
CI / frontend-tests (pull_request) Has been skipped
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m45s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m42s
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
Deploy / deploy (push) Successful in 1m15s
CI / backend-tests (push) Successful in 6m30s
CI / backend-tests (pull_request) Successful in 6m32s
fedstat ИПЦ is reCAPTCHA-blocked; CBR publishes inflation openly. Add fetch_inflation + parse_inflation_xlsx (CBR UniDbQuery DownloadExcel/132934, monthly % г/г, region=rf, source=cbr) to cbr_macro.py; upsert indicator_type=inflation_yoy via the existing cbr_macro_sync task (per-series guard, SAVEPOINT-per-row, CAST not ::, ON CONFLICT on the PK). Surface inflation_yoy in MonthlyMacro (frozen, carry-forward) and ACTIVATE the reserved §9.5 inflation channel (macro_coefficient f_inflation: level-vs-4%-target nudge, non-positive to avoid double-counting f_rate, excluded from _RATE_DRIVEN_FACTORS). Channel was DEGRADED (no data) -> now BACKED + consumed; _CONF_HIGH_MIN_BACKED 4->5. Deterministic (§16/§26); renorm claims the reserved 0.08 slice as designed. Live-verified (2026-04 5.58%); 194 macro + 902 forecasting tests green. No migration, no new deps. Refs #946.
This commit is contained in:
parent
b0fe292a63
commit
25e21c2bff
8 changed files with 800 additions and 71 deletions
|
|
@ -22,9 +22,9 @@ PR. Цифры пригодны для explainability/прототипа, но
|
||||||
+ W_MORTG_RATE · f_mortgage_rate(Δ mortgage_rate_weighted) # ↑ → <0
|
+ W_MORTG_RATE · f_mortgage_rate(Δ mortgage_rate_weighted) # ↑ → <0
|
||||||
+ W_ISSUANCE · f_issuance(Δ count & Δ volume ИЖК) # выдачи↓ → <0
|
+ W_ISSUANCE · f_issuance(Δ count & Δ volume ИЖК) # выдачи↓ → <0
|
||||||
+ W_OVERDUE · f_overdue(overdue / debt) # доля↑ → <0
|
+ W_OVERDUE · f_overdue(overdue / debt) # доля↑ → <0
|
||||||
|
+ W_INFLATION · f_inflation(inflation_yoy − цель) # выше цели → <0 (#946)
|
||||||
+ W_GOVSUPPORT · None # DEGRADED — нет поля в MonthlyMacro → не участвует
|
+ W_GOVSUPPORT · None # DEGRADED — нет поля в MonthlyMacro → не участвует
|
||||||
+ W_INCOME · None # DEGRADED
|
+ W_INCOME · None # DEGRADED
|
||||||
+ W_INFLATION · None # DEGRADED
|
|
||||||
+ W_CONFIDENCE · None, # DEGRADED
|
+ W_CONFIDENCE · None, # DEGRADED
|
||||||
_MACRO_COEF_MIN, _MACRO_COEF_MAX)
|
_MACRO_COEF_MIN, _MACRO_COEF_MAX)
|
||||||
|
|
||||||
|
|
@ -41,13 +41,14 @@ f_mortgage_rate — оба про стоимость заёмных денег)
|
||||||
comfort/comfort+ / быстрой нарезки — КРУЧЕ-позитивно на rate↓ (+ господдержка).
|
comfort/comfort+ / быстрой нарезки — КРУЧЕ-позитивно на rate↓ (+ господдержка).
|
||||||
Реализовано простым набором правил с именованными константами-крутизнами (НЕ фит).
|
Реализовано простым набором правил с именованными константами-крутизнами (НЕ фит).
|
||||||
|
|
||||||
BACKED vs DEGRADED входы (сверено с полями MonthlyMacro PR2):
|
BACKED vs DEGRADED входы (сверено с полями MonthlyMacro):
|
||||||
• BACKED : f_rate (key_rate), f_mortgage_rate (mortgage_rate_weighted),
|
• BACKED : f_rate (key_rate), f_mortgage_rate (mortgage_rate_weighted),
|
||||||
f_issuance (mortgage_issued_count + _volume), f_overdue
|
f_issuance (mortgage_issued_count + _volume), f_overdue
|
||||||
(mortgage_overdue / mortgage_debt).
|
(mortgage_overdue / mortgage_debt), f_inflation (inflation_yoy, #946 —
|
||||||
• DEGRADED : gov-support / income / cpi / confidence — этих рядов в MonthlyMacro
|
ИПЦ YoY ЦБ, залит cbr_macro_sync; УРОВЕНЬ относительно цели 4%).
|
||||||
НЕТ (income/cpi не backfilled, см. macro_series docstring; gov-support
|
• DEGRADED : gov-support / income / confidence — этих рядов в MonthlyMacro НЕТ
|
||||||
и consumer-confidence вообще не собираются). Они зарезервированы в
|
(income не backfilled, см. macro_series docstring; gov-support и
|
||||||
|
consumer-confidence вообще не собираются). Они зарезервированы в
|
||||||
формуле/весах для будущего, но СЕЙЧАС всегда contribute None →
|
формуле/весах для будущего, но СЕЙЧАС всегда contribute None →
|
||||||
выпадают из renorm. Помечаем их явно unavailable, не молчаливый 0.
|
выпадают из renorm. Помечаем их явно unavailable, не молчаливый 0.
|
||||||
|
|
||||||
|
|
@ -100,15 +101,15 @@ _TREND_WINDOW_MONTHS: int = 6
|
||||||
# Заданы в ИСХОДНОМ (полном) наборе из 8 каналов; renorm делит на сумму ДОСТУПНЫХ.
|
# Заданы в ИСХОДНОМ (полном) наборе из 8 каналов; renorm делит на сумму ДОСТУПНЫХ.
|
||||||
# Backed-каналы (rate/mortgage_rate/issuance/overdue) несут основную массу: ставка и
|
# Backed-каналы (rate/mortgage_rate/issuance/overdue) несут основную массу: ставка и
|
||||||
# стоимость/доступность ипотеки — доминирующий драйвер первичного спроса в РФ.
|
# стоимость/доступность ипотеки — доминирующий драйвер первичного спроса в РФ.
|
||||||
# Degraded-каналы (gov/income/cpi/confidence) имеют НЕнулевые веса в схеме (резерв
|
# Degraded-каналы (gov/income/confidence) имеют НЕнулевые веса в схеме (резерв
|
||||||
# под будущие ряды), но СЕЙЧАС всегда None → в renorm не попадают.
|
# под будущие ряды), но СЕЙЧАС всегда None → в renorm не попадают.
|
||||||
_W_RATE: float = 0.18 # ключевая ставка — главный канал (стоимость денег)
|
_W_RATE: float = 0.18 # ключевая ставка — главный канал (стоимость денег)
|
||||||
_W_MORTG_RATE: float = 0.12 # ИЖК-ставка — прямой канал доступности ипотеки
|
_W_MORTG_RATE: float = 0.12 # ИЖК-ставка — прямой канал доступности ипотеки
|
||||||
_W_ISSUANCE: float = 0.10 # объём/число выдач ИЖК — фактический спрос-прокси
|
_W_ISSUANCE: float = 0.10 # объём/число выдач ИЖК — фактический спрос-прокси
|
||||||
_W_OVERDUE: float = 0.05 # просрочка/долг — стресс заёмщиков (медленный канал)
|
_W_OVERDUE: float = 0.05 # просрочка/долг — стресс заёмщиков (медленный канал)
|
||||||
|
_W_INFLATION: float = 0.08 # BACKED (#946) — ИПЦ YoY, уровень относительно цели
|
||||||
_W_GOVSUPPORT: float = 0.20 # DEGRADED (нет ряда) — крупный вес-резерв (льготы)
|
_W_GOVSUPPORT: float = 0.20 # DEGRADED (нет ряда) — крупный вес-резерв (льготы)
|
||||||
_W_INCOME: float = 0.12 # DEGRADED — реальные доходы
|
_W_INCOME: float = 0.12 # DEGRADED — реальные доходы
|
||||||
_W_INFLATION: float = 0.08 # DEGRADED — ИПЦ
|
|
||||||
_W_CONFIDENCE: float = 0.15 # DEGRADED — потребит. уверенность
|
_W_CONFIDENCE: float = 0.15 # DEGRADED — потребит. уверенность
|
||||||
|
|
||||||
# Имена sub-factors (ключи breakdown / available / weights). Держим как константы,
|
# Имена sub-factors (ключи breakdown / available / weights). Держим как константы,
|
||||||
|
|
@ -137,9 +138,9 @@ _WEIGHTS: dict[str, float] = {
|
||||||
|
|
||||||
# Каналы, для которых СЕЙЧАС нет ряда в MonthlyMacro → всегда contribute None.
|
# Каналы, для которых СЕЙЧАС нет ряда в MonthlyMacro → всегда contribute None.
|
||||||
# Зарезервированы в _WEIGHTS под будущие данные; помечаем unavailable явно.
|
# Зарезервированы в _WEIGHTS под будущие данные; помечаем unavailable явно.
|
||||||
_DEGRADED_FACTORS: frozenset[str] = frozenset(
|
# inflation БОЛЬШЕ НЕ здесь (#946): ряд inflation_yoy залит cbr_macro_sync и
|
||||||
{_F_GOVSUPPORT, _F_INCOME, _F_INFLATION, _F_CONFIDENCE}
|
# присутствует в MonthlyMacro → канал стал BACKED (f_inflation).
|
||||||
)
|
_DEGRADED_FACTORS: frozenset[str] = frozenset({_F_GOVSUPPORT, _F_INCOME, _F_CONFIDENCE})
|
||||||
|
|
||||||
# Rate-driven каналы — к ним применяется сегментный модификатор крутизны (оба про
|
# Rate-driven каналы — к ним применяется сегментный модификатор крутизны (оба про
|
||||||
# стоимость заёмных денег: ставка ЦБ + ИЖК-ставка).
|
# стоимость заёмных денег: ставка ЦБ + ИЖК-ставка).
|
||||||
|
|
@ -170,6 +171,18 @@ _OVERDUE_RATIO_FULL: float = 0.05
|
||||||
# «Нейтральная» доля просрочки: ниже неё канал не давит (здоровый портфель). 1%.
|
# «Нейтральная» доля просрочки: ниже неё канал не давит (здоровый портфель). 1%.
|
||||||
_OVERDUE_RATIO_NEUTRAL: float = 0.01
|
_OVERDUE_RATIO_NEUTRAL: float = 0.01
|
||||||
|
|
||||||
|
# Инфляция (ИПЦ YoY, #946) — УРОВЕНЬ относительно цели ЦБ (как f_overdue). «Цель по
|
||||||
|
# инфляции» сам ЦБ публикует = 4% — берём её якорем нейтрали. Инфляция ВЫШЕ цели =
|
||||||
|
# макро-стресс (эрозия реальных доходов/доступности + сигнал ужесточения ДКП) →
|
||||||
|
# негативный нудж; на уровне цели и ниже — канал не давит (нейтрален). Намеренно НЕ
|
||||||
|
# даём этому каналу ПОЛОЖИТЕЛЬНЫЙ вклад: «инфляция-как-хедж» (недвижимость как
|
||||||
|
# защита сбережений) спорна и частично уже отражена через ставку — консервативно
|
||||||
|
# ограничиваем канал неположительным вкладом, чтобы не дублировать f_rate.
|
||||||
|
# Полная шкала превышения над целью (п.п.): 8 п.п. сверх цели (т.е. ИПЦ ~12% при
|
||||||
|
# цели 4%) → максимальный нудж −1. Эмпирика РФ 2022-2024; эвристика, не фит.
|
||||||
|
_INFLATION_TARGET_PCT: float = 4.0
|
||||||
|
_INFLATION_EXCESS_FULL_PP: float = 8.0
|
||||||
|
|
||||||
# ── Named-константы: сегментные крутизны (множители rate-driven нуджей) ────────
|
# ── Named-константы: сегментные крутизны (множители rate-driven нуджей) ────────
|
||||||
# Базовая крутизна 1.0 (нейтральный сегмент). Чувствительные форматы получают
|
# Базовая крутизна 1.0 (нейтральный сегмент). Чувствительные форматы получают
|
||||||
# >1.0 (круче реагируют на канал стоимости денег — в ОБЕ стороны: сильнее падают
|
# >1.0 (круче реагируют на канал стоимости денег — в ОБЕ стороны: сильнее падают
|
||||||
|
|
@ -194,10 +207,11 @@ _STEEP_MAX: float = 1.8
|
||||||
|
|
||||||
# ── Named-константы: confidence ───────────────────────────────────────────────
|
# ── Named-константы: confidence ───────────────────────────────────────────────
|
||||||
# Confidence падает, когда мало backed-входов доступно ИЛИ окно конфаундено.
|
# Confidence падает, когда мало backed-входов доступно ИЛИ окно конфаундено.
|
||||||
# 'high' — доступны ВСЕ 4 backed-канала и окно не шок.
|
# 'high' — доступны ВСЕ 5 backed-каналов (rate/mortgage_rate/issuance/overdue/
|
||||||
|
# inflation, #946) и окно не шок.
|
||||||
# 'medium' — доступно ≥ _CONF_MEDIUM_MIN_BACKED backed-каналов (частичный сигнал).
|
# 'medium' — доступно ≥ _CONF_MEDIUM_MIN_BACKED backed-каналов (частичный сигнал).
|
||||||
# 'low' — почти нет сигнала (0-1 канал) ИЛИ деградация ниже medium-порога.
|
# 'low' — почти нет сигнала (0-1 канал) ИЛИ деградация ниже medium-порога.
|
||||||
_CONF_HIGH_MIN_BACKED: int = 4 # все backed-каналы
|
_CONF_HIGH_MIN_BACKED: int = 5 # все backed-каналы (с inflation, #946)
|
||||||
_CONF_MEDIUM_MIN_BACKED: int = 2 # хотя бы половина backed-каналов
|
_CONF_MEDIUM_MIN_BACKED: int = 2 # хотя бы половина backed-каналов
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -332,6 +346,37 @@ def f_overdue(overdue: float | None, debt: float | None) -> float | None:
|
||||||
return _clamp(-excess / span, -1.0, 0.0)
|
return _clamp(-excess / span, -1.0, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def f_inflation(
|
||||||
|
inflation_yoy: float | None,
|
||||||
|
*,
|
||||||
|
target_pct: float = _INFLATION_TARGET_PCT,
|
||||||
|
excess_full_pp: float = _INFLATION_EXCESS_FULL_PP,
|
||||||
|
) -> float | None:
|
||||||
|
"""Нудж от уровня инфляции (ИПЦ YoY, #946): выше цели → негатив. None → недоступен.
|
||||||
|
|
||||||
|
УРОВЕНЬ (не дельта), как f_overdue: инфляция выше цели ЦБ (4%) = макро-стресс
|
||||||
|
(эрозия реальных доходов/доступности + сигнал ужесточения ДКП) → нудж < 0. На
|
||||||
|
уровне цели и НИЖЕ — канал доступен, но нейтрален (нудж 0): положительного
|
||||||
|
вклада намеренно НЕ даём («инфляция-как-хедж» спорна и частично уже в f_rate —
|
||||||
|
не дублируем канал ставки). От цели до цели+_INFLATION_EXCESS_FULL_PP линейно
|
||||||
|
уходит к −1. PURE.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
inflation_yoy: инфляция «% г/г» (напр. 6.0); None = нет данных → недоступен.
|
||||||
|
target_pct: целевая инфляция ЦБ (по умолчанию _INFLATION_TARGET_PCT = 4%).
|
||||||
|
excess_full_pp: превышение над целью (п.п.) для нуджа −1 (по умолчанию 8).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Нудж в [-1, 0] или None (вход None → канал недоступен).
|
||||||
|
"""
|
||||||
|
if inflation_yoy is None:
|
||||||
|
return None
|
||||||
|
excess = inflation_yoy - target_pct
|
||||||
|
if excess <= 0:
|
||||||
|
return 0.0 # инфляция у цели/ниже — канал доступен, но нейтрален
|
||||||
|
return _clamp(-excess / excess_full_pp, -1.0, 0.0)
|
||||||
|
|
||||||
|
|
||||||
def _trend_over_window(
|
def _trend_over_window(
|
||||||
values: list[float | None], *, window: int = _TREND_WINDOW_MONTHS
|
values: list[float | None], *, window: int = _TREND_WINDOW_MONTHS
|
||||||
) -> float | None:
|
) -> float | None:
|
||||||
|
|
@ -667,8 +712,8 @@ def compute_macro_coefficient(
|
||||||
|
|
||||||
Шаги:
|
Шаги:
|
||||||
1. Тянем monthly макро-ряд (PR2). Пустой → coef=1.0, confidence='low'.
|
1. Тянем monthly макро-ряд (PR2). Пустой → coef=1.0, confidence='low'.
|
||||||
2. Считаем 4 BACKED sub-factors (rate / mortgage_rate / issuance / overdue);
|
2. Считаем 5 BACKED sub-factors (rate / mortgage_rate / issuance / overdue /
|
||||||
degraded каналы (gov/income/cpi/confidence) → None (нет ряда в MonthlyMacro).
|
inflation, #946); degraded каналы (gov/income/confidence) → None (нет ряда).
|
||||||
3. Применяем сегментную крутизну к rate-driven нуджам (rate + mortgage_rate).
|
3. Применяем сегментную крутизну к rate-driven нуджам (rate + mortgage_rate).
|
||||||
4. Ренормализуем веса по ДОСТУПНЫМ каналам (выпавшие не тянут coef к 1.0).
|
4. Ренормализуем веса по ДОСТУПНЫМ каналам (выпавшие не тянут coef к 1.0).
|
||||||
5. Собираем clamp(1.0 + Σ вкладов, MIN, MAX); confidence + confounded-флаг.
|
5. Собираем clamp(1.0 + Σ вкладов, MIN, MAX); confidence + confounded-флаг.
|
||||||
|
|
@ -737,7 +782,7 @@ def compute_macro_coefficient(
|
||||||
|
|
||||||
|
|
||||||
def _compute_backed_nudges(macro: list[MonthlyMacro]) -> dict[str, float | None]:
|
def _compute_backed_nudges(macro: list[MonthlyMacro]) -> dict[str, float | None]:
|
||||||
"""Посчитать 4 backed sub-factors из макро-ряда (каждый None при нехватке). PURE-ish.
|
"""Посчитать 5 backed sub-factors из макро-ряда (каждый None при нехватке). PURE-ish.
|
||||||
|
|
||||||
Не ходит в БД (получает уже вытянутый ряд) — тестируется напрямую.
|
Не ходит в БД (получает уже вытянутый ряд) — тестируется напрямую.
|
||||||
|
|
||||||
|
|
@ -745,6 +790,7 @@ def _compute_backed_nudges(macro: list[MonthlyMacro]) -> dict[str, float | None]
|
||||||
• mortgage_rate : тренд mortgage_rate_weighted за окно → f_mortgage_rate.
|
• mortgage_rate : тренд mortgage_rate_weighted за окно → f_mortgage_rate.
|
||||||
• issuance : относит. тренд count & volume выдач → f_issuance.
|
• issuance : относит. тренд count & volume выдач → f_issuance.
|
||||||
• overdue : последняя доля overdue/debt → f_overdue.
|
• overdue : последняя доля overdue/debt → f_overdue.
|
||||||
|
• inflation : последний уровень inflation_yoy относительно цели → f_inflation (#946).
|
||||||
"""
|
"""
|
||||||
key_rate = [m.key_rate for m in macro]
|
key_rate = [m.key_rate for m in macro]
|
||||||
mortg_rate = [m.mortgage_rate_weighted for m in macro]
|
mortg_rate = [m.mortgage_rate_weighted for m in macro]
|
||||||
|
|
@ -752,8 +798,14 @@ def _compute_backed_nudges(macro: list[MonthlyMacro]) -> dict[str, float | None]
|
||||||
issued_volume = [m.mortgage_issued_volume for m in macro]
|
issued_volume = [m.mortgage_issued_volume for m in macro]
|
||||||
overdue_series = [m.mortgage_overdue for m in macro]
|
overdue_series = [m.mortgage_overdue for m in macro]
|
||||||
debt_series = [m.mortgage_debt for m in macro]
|
debt_series = [m.mortgage_debt for m in macro]
|
||||||
|
inflation_series = [m.inflation_yoy for m in macro]
|
||||||
|
|
||||||
overdue_last, debt_last = _latest_ratio(overdue_series, debt_series)
|
overdue_last, debt_last = _latest_ratio(overdue_series, debt_series)
|
||||||
|
# Инфляция — УРОВЕНЬ: берём последнюю известную точку (как overdue-ratio).
|
||||||
|
inflation_last_idx = _last_known_idx(inflation_series)
|
||||||
|
inflation_last = (
|
||||||
|
inflation_series[inflation_last_idx] if inflation_last_idx is not None else None
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
_F_RATE: f_rate(_trend_over_window(key_rate)),
|
_F_RATE: f_rate(_trend_over_window(key_rate)),
|
||||||
|
|
@ -763,6 +815,7 @@ def _compute_backed_nudges(macro: list[MonthlyMacro]) -> dict[str, float | None]
|
||||||
_rel_change_over_window(issued_volume),
|
_rel_change_over_window(issued_volume),
|
||||||
),
|
),
|
||||||
_F_OVERDUE: f_overdue(overdue_last, debt_last),
|
_F_OVERDUE: f_overdue(overdue_last, debt_last),
|
||||||
|
_F_INFLATION: f_inflation(inflation_last),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,13 @@
|
||||||
читается через тонкий reader app.services.site_finder.macro (переиспользуем):
|
читается через тонкий reader app.services.site_finder.macro (переиспользуем):
|
||||||
• key_rate — region 'rf', ДНЕВНОЙ ряд, source 'cbr' (backfill PR B
|
• key_rate — region 'rf', ДНЕВНОЙ ряд, source 'cbr' (backfill PR B
|
||||||
#945, 2019-01-01 → сегодня). Ресэмплим daily → monthly.
|
#945, 2019-01-01 → сегодня). Ресэмплим daily → monthly.
|
||||||
|
• inflation_yoy — region 'rf', УЖЕ месячный (obs_date = 1-е число), source
|
||||||
|
'cbr' (ИПЦ YoY, #946 — залит cbr_macro_sync через CBR
|
||||||
|
UniDbQuery). Берём как есть. Потребитель — §9.5 канал
|
||||||
|
inflation (macro_coefficient), теперь BACKED, не degraded.
|
||||||
• mortgage_* — region 'sverdl', УЖЕ месячные (obs_date нормализован к
|
• mortgage_* — region 'sverdl', УЖЕ месячные (obs_date нормализован к
|
||||||
1-му числу через make_date(...,1)). Берём как есть.
|
1-му числу через make_date(...,1)). Берём как есть.
|
||||||
(income/cpi НЕ backfilled — на них не опираемся.)
|
(income НЕ backfilled — на него не опираемся.)
|
||||||
|
|
||||||
Соглашение по дате: каждый MonthlyMacro.month — ПЕРВОЕ число месяца (YYYY-MM-01).
|
Соглашение по дате: каждый MonthlyMacro.month — ПЕРВОЕ число месяца (YYYY-MM-01).
|
||||||
Это зеркалит нормализацию mortgage-рядов в backfill (make_date(year, month, 1)),
|
Это зеркалит нормализацию mortgage-рядов в backfill (make_date(year, month, 1)),
|
||||||
|
|
@ -100,11 +104,15 @@ class MonthlyMacro:
|
||||||
mortgage_issued_volume: float | None # объём выданных ИЖК, млн руб
|
mortgage_issued_volume: float | None # объём выданных ИЖК, млн руб
|
||||||
mortgage_debt: float | None # задолженность по ИЖК, млн руб
|
mortgage_debt: float | None # задолженность по ИЖК, млн руб
|
||||||
mortgage_overdue: float | None # просроченная задолженность по ИЖК, млн руб
|
mortgage_overdue: float | None # просроченная задолженность по ИЖК, млн руб
|
||||||
|
# #946: инфляция «% г/г» (ИПЦ YoY), region 'rf'. Поле с дефолтом None и в КОНЦЕ —
|
||||||
|
# чтобы существующие kwargs-конструкторы (тесты/демо), не знающие о поле, не падали.
|
||||||
|
inflation_yoy: float | None = None
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
def as_dict(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"month": self.month.isoformat(),
|
"month": self.month.isoformat(),
|
||||||
"key_rate": _round_or_none(self.key_rate, 2),
|
"key_rate": _round_or_none(self.key_rate, 2),
|
||||||
|
"inflation_yoy": _round_or_none(self.inflation_yoy, 2),
|
||||||
"mortgage_rate_weighted": _round_or_none(self.mortgage_rate_weighted, 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_count": _round_or_none(self.mortgage_issued_count, 0),
|
||||||
"mortgage_issued_volume": _round_or_none(self.mortgage_issued_volume, 1),
|
"mortgage_issued_volume": _round_or_none(self.mortgage_issued_volume, 1),
|
||||||
|
|
@ -312,24 +320,38 @@ def get_monthly_macro(
|
||||||
return []
|
return []
|
||||||
|
|
||||||
key_rate_by_month = _query_key_rate_monthly(db, since=start)
|
key_rate_by_month = _query_key_rate_monthly(db, since=start)
|
||||||
|
inflation_by_month = _query_inflation_monthly(db, since=start)
|
||||||
mortgage_by_field = _query_mortgage_monthly(db, since=start)
|
mortgage_by_field = _query_mortgage_monthly(db, since=start)
|
||||||
|
|
||||||
# key_rate: разворачиваем на сетку + carry-forward по сетке (LOCF).
|
# key_rate: разворачиваем на сетку + carry-forward по сетке (LOCF).
|
||||||
raw_rates: list[float | None] = [key_rate_by_month.get(m) for m in grid]
|
raw_rates: list[float | None] = [key_rate_by_month.get(m) for m in grid]
|
||||||
filled_rates = _carry_forward(raw_rates)
|
filled_rates = _carry_forward(raw_rates)
|
||||||
|
|
||||||
|
# inflation_yoy: тоже carry-forward (медленный месячный ряд; последняя известная
|
||||||
|
# точка = текущий режим, как и у ставки). До первой точки → None.
|
||||||
|
raw_infl: list[float | None] = [inflation_by_month.get(m) for m in grid]
|
||||||
|
filled_infl = _carry_forward(raw_infl)
|
||||||
|
|
||||||
rows: list[MonthlyMacro] = []
|
rows: list[MonthlyMacro] = []
|
||||||
for idx, month in enumerate(grid):
|
for idx, month in enumerate(grid):
|
||||||
kwargs: dict[str, float | None] = {
|
kwargs: dict[str, float | None] = {
|
||||||
field: mortgage_by_field.get(field, {}).get(month) for _itype, field in _MORTGAGE_FIELDS
|
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))
|
rows.append(
|
||||||
|
MonthlyMacro(
|
||||||
|
month=month,
|
||||||
|
key_rate=filled_rates[idx],
|
||||||
|
inflation_yoy=filled_infl[idx],
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"get_monthly_macro: months_back=%d grid=%d key_rate_pts=%d",
|
"get_monthly_macro: months_back=%d grid=%d key_rate_pts=%d inflation_pts=%d",
|
||||||
months_back,
|
months_back,
|
||||||
len(grid),
|
len(grid),
|
||||||
len(key_rate_by_month),
|
len(key_rate_by_month),
|
||||||
|
len(inflation_by_month),
|
||||||
)
|
)
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
@ -361,6 +383,21 @@ def _query_key_rate_monthly(db: Session, *, since: date) -> dict[date, float]:
|
||||||
return {r[0]: float(r[1]) for r in rows if r[0] is not None and r[1] is not None}
|
return {r[0]: float(r[1]) for r in rows if r[0] is not None and r[1] is not None}
|
||||||
|
|
||||||
|
|
||||||
|
def _query_inflation_monthly(db: Session, *, since: date) -> dict[date, float]:
|
||||||
|
"""Месячный inflation_yoy (region 'rf', ИПЦ YoY) → {month1st: value}. Graceful → {}.
|
||||||
|
|
||||||
|
Ряд УЖЕ месячный (obs_date = 1-е число, залит cbr_macro_sync) — берём как есть
|
||||||
|
через reuse get_macro_series (свой SQL не пишем). _month_start — страховка.
|
||||||
|
Сбой/пустой ряд → {} (НЕ crash), inflation_yoy тогда None по всей сетке.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
series = get_macro_series(db, "inflation_yoy", region="rf", since=since)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("get_monthly_macro: inflation_yoy query failed")
|
||||||
|
return {}
|
||||||
|
return {_month_start(d): float(v) for d, v in series}
|
||||||
|
|
||||||
|
|
||||||
def _query_mortgage_monthly(db: Session, *, since: date) -> dict[str, dict[date, float]]:
|
def _query_mortgage_monthly(db: Session, *, since: date) -> dict[str, dict[date, float]]:
|
||||||
"""Месячные mortgage-ряды (region 'sverdl') через reuse get_macro_series.
|
"""Месячные mortgage-ряды (region 'sverdl') через reuse get_macro_series.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,20 @@
|
||||||
"""CBR (Банк России) macro indicators scraper — key rate history.
|
"""CBR (Банк России) macro indicators scraper — key rate + inflation (ИПЦ YoY).
|
||||||
|
|
||||||
Fills the ``key_rate`` series of the ``macro_indicator`` table (region='rf')
|
Fills two ``macro_indicator`` series (both region='rf', source='cbr'), детерминированно,
|
||||||
from the live CBR SOAP service ``DailyInfoWebServ``. Deterministic, no LLM.
|
без LLM:
|
||||||
|
|
||||||
Endpoint / method (verified live 2026-06-02):
|
• ``key_rate`` — ключевая ставка ЦБ, ДНЕВНОЙ ряд, через SOAP DailyInfoWebServ.
|
||||||
|
• ``inflation_yoy`` — инфляция «% г/г» (year-over-year, на базе ИПЦ Росстата),
|
||||||
|
МЕСЯЧНЫЙ ряд, через CBR UniDbQuery DownloadExcel (#946).
|
||||||
|
|
||||||
|
#946 / ИПЦ-вход: путь fedstat ИПЦ заблокирован reCAPTCHA (dataGrid, см.
|
||||||
|
``rosstat_emiss``). ЦБ публикует инфляцию ОТКРЫТО (НЕ за WAF, работает с dev напрямую)
|
||||||
|
на ``https://www.cbr.ru/hd_base/infl/`` — таблица «Дата | Ключевая ставка |
|
||||||
|
Инфляция, % г/г | Цель по инфляции». Чистый машиночитаемый источник за этой
|
||||||
|
страницей — её Excel-экспорт UniDbQuery (HTML-таблица рендерит только последние ~6
|
||||||
|
месяцев и НЕ реагирует на GET-параметр диапазона, поэтому для бэкфилла берём Excel).
|
||||||
|
|
||||||
|
Endpoint / method (KEY RATE — verified live 2026-06-02):
|
||||||
POST https://www.cbr.ru/DailyInfoWebServ/DailyInfo.asmx
|
POST https://www.cbr.ru/DailyInfoWebServ/DailyInfo.asmx
|
||||||
SOAP 1.2 action ``KeyRateXML(fromDate, ToDate)``.
|
SOAP 1.2 action ``KeyRateXML(fromDate, ToDate)``.
|
||||||
|
|
||||||
|
|
@ -24,8 +35,22 @@ Response shape (``KeyRateXML`` — без diffgram-обёртки, в отлич
|
||||||
Строки идут в обратном хронологическом порядке; ``parse_key_rate_xml``
|
Строки идут в обратном хронологическом порядке; ``parse_key_rate_xml``
|
||||||
сортировку не навязывает — отдаёт в порядке появления.
|
сортировку не навязывает — отдаёт в порядке появления.
|
||||||
|
|
||||||
Парсинг вынесен в чистую ``parse_key_rate_xml`` (offline-тестируемую на
|
Endpoint / method (INFLATION — verified live 2026-06-07/08):
|
||||||
фикстуре); HTTP-обвязка ``fetch_key_rate`` тонкая (httpx, timeout, UA, retry).
|
GET https://www.cbr.ru/Queries/UniDbQuery/DownloadExcel/132934
|
||||||
|
?FromDate=MM/DD/YYYY&ToDate=MM/DD/YYYY&posted=False
|
||||||
|
Возвращает .xlsx (openpyxl-читаемый): колонки
|
||||||
|
«Дата (MM.YYYY) | Ключевая ставка, % годовых | Инфляция, % г/г | Цель …».
|
||||||
|
Берём ТОЛЬКО колонку «Инфляция, % г/г» (year-over-year %). Полная история
|
||||||
|
01.2013→текущий месяц; FromDate/ToDate точно ограничивают диапазон. Запятая —
|
||||||
|
десятичный разделитель в HTML, но openpyxl отдаёт нативный float. Ранние строки
|
||||||
|
(до 09.2013) несут пустую ставку, но инфляция есть всегда.
|
||||||
|
NB: id=132934 — стабильный id ряда «Инфляция и ключевая ставка» (взят из
|
||||||
|
ссылки DownloadExcel на самой странице /hd_base/infl/). Если ЦБ его сменит —
|
||||||
|
fetch вернёт не-200 / пустой парс → task залогирует и пробросит (видно в GlitchTip).
|
||||||
|
|
||||||
|
Парсинг вынесен в чистые ``parse_key_rate_xml`` / ``parse_inflation_xlsx``
|
||||||
|
(offline-тестируемые на фикстурах); HTTP-обвязки ``fetch_key_rate`` /
|
||||||
|
``fetch_inflation`` тонкие (httpx, timeout, UA, retry).
|
||||||
|
|
||||||
Standalone smoke (для ручной проверки против живого cbr.ru):
|
Standalone smoke (для ручной проверки против живого cbr.ru):
|
||||||
python -m app.services.scrapers.cbr_macro
|
python -m app.services.scrapers.cbr_macro
|
||||||
|
|
@ -39,9 +64,12 @@ from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
|
from io import BytesIO
|
||||||
|
from typing import Any
|
||||||
from xml.etree import ElementTree as ET
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -51,6 +79,14 @@ CBR_DAILYINFO_URL = "https://www.cbr.ru/DailyInfoWebServ/DailyInfo.asmx"
|
||||||
# SOAP method that returns a clean <KeyRate><KR>... structure (no ADO diffgram).
|
# SOAP method that returns a clean <KeyRate><KR>... structure (no ADO diffgram).
|
||||||
CBR_KEYRATE_METHOD = "KeyRateXML"
|
CBR_KEYRATE_METHOD = "KeyRateXML"
|
||||||
|
|
||||||
|
# Инфляция (ИПЦ YoY): Excel-экспорт UniDbQuery страницы /hd_base/infl/. id=132934 —
|
||||||
|
# стабильный id ряда «Инфляция и ключевая ставка» (см. ссылку DownloadExcel на странице).
|
||||||
|
CBR_INFLATION_EXCEL_URL = "https://www.cbr.ru/Queries/UniDbQuery/DownloadExcel/132934"
|
||||||
|
# Заголовок колонки инфляции в Excel-экспорте (подстрочный матч — устойчив к мелким
|
||||||
|
# правкам формулировки/единиц после «Инфляция»). НЕ путать с «Цель по инфляции».
|
||||||
|
CBR_INFLATION_COLUMN_MARKER = "инфляц"
|
||||||
|
CBR_INFLATION_TARGET_MARKER = "цель"
|
||||||
|
|
||||||
USER_AGENT = "GenDesign/1.0 (+https://gendsgn.ru) macro indicators scraper"
|
USER_AGENT = "GenDesign/1.0 (+https://gendsgn.ru) macro indicators scraper"
|
||||||
DEFAULT_TIMEOUT_S = 30.0
|
DEFAULT_TIMEOUT_S = 30.0
|
||||||
DEFAULT_RETRIES = 3
|
DEFAULT_RETRIES = 3
|
||||||
|
|
@ -59,6 +95,10 @@ DEFAULT_RETRIES = 3
|
||||||
# прогоны идемпотентными, так что диапазон можно держать широким.
|
# прогоны идемпотентными, так что диапазон можно держать широким.
|
||||||
DEFAULT_FROM_DATE = date(2019, 1, 1)
|
DEFAULT_FROM_DATE = date(2019, 1, 1)
|
||||||
|
|
||||||
|
# Инфляция ЦБ публикуется с 01.2013 (ИПЦ YoY). Широкий старт безопасен — ON CONFLICT
|
||||||
|
# в таске делает прогон идемпотентным.
|
||||||
|
DEFAULT_INFLATION_FROM_DATE = date(2013, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
class CBRScraperError(RuntimeError):
|
class CBRScraperError(RuntimeError):
|
||||||
"""Сетевая / протокольная ошибка обращения к DailyInfoWebServ."""
|
"""Сетевая / протокольная ошибка обращения к DailyInfoWebServ."""
|
||||||
|
|
@ -250,6 +290,219 @@ def fetch_key_rate(
|
||||||
raise CBRScraperError(f"CBR KeyRate fetch failed after {retries + 1} attempts: {last_exc}")
|
raise CBRScraperError(f"CBR KeyRate fetch failed after {retries + 1} attempts: {last_exc}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── inflation (ИПЦ YoY, region='rf', monthly) — pure parse ──────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_month_cell(raw: Any) -> date | None:
|
||||||
|
"""Распарсить ячейку «Дата» Excel-экспорта инфляции в 1-е число месяца.
|
||||||
|
|
||||||
|
Формат CBR — строка ``MM.YYYY`` (напр. '04.2026'). На всякий случай поддерживаем
|
||||||
|
и datetime/date (если ЦБ когда-нибудь отдаст типизированную ячейку — берём её
|
||||||
|
год/месяц, нормализуя к 1-му числу). Незнакомое → None (строка пропускается).
|
||||||
|
"""
|
||||||
|
if isinstance(raw, datetime):
|
||||||
|
return date(raw.year, raw.month, 1)
|
||||||
|
if isinstance(raw, date):
|
||||||
|
return date(raw.year, raw.month, 1)
|
||||||
|
s = str(raw or "").strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
# Основной формат MM.YYYY. Терпим и MM/YYYY (на случай локали).
|
||||||
|
s = s.replace("/", ".")
|
||||||
|
parts = s.split(".")
|
||||||
|
if len(parts) != 2:
|
||||||
|
logger.warning("CBR inflation: не удалось распарсить дату=%r", raw)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
month = int(parts[0])
|
||||||
|
year = int(parts[1])
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("CBR inflation: не удалось распарсить дату=%r", raw)
|
||||||
|
return None
|
||||||
|
if not (1 <= month <= 12 and year >= 1900):
|
||||||
|
logger.warning("CBR inflation: дата вне диапазона=%r", raw)
|
||||||
|
return None
|
||||||
|
return date(year, month, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_inflation_value(raw: Any) -> float | None:
|
||||||
|
"""Распарсить ячейку «Инфляция, % г/г» в float. None/пусто → None (строка скип).
|
||||||
|
|
||||||
|
openpyxl отдаёт нативный int/float; на случай строковой ячейки принимаем и
|
||||||
|
запятую как десятичный разделитель.
|
||||||
|
"""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
if isinstance(raw, (int, float)):
|
||||||
|
return float(raw)
|
||||||
|
s = str(raw).strip().replace(",", ".")
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(Decimal(s))
|
||||||
|
except (InvalidOperation, ValueError):
|
||||||
|
logger.warning("CBR inflation: не удалось распарсить значение=%r", raw)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_header_and_inflation_col(rows: list[tuple[Any, ...]]) -> tuple[int, int] | None:
|
||||||
|
"""Найти строку-заголовок и индекс колонки «Инфляция, % г/г» в Excel-экспорте.
|
||||||
|
|
||||||
|
Сканируем первые строки: заголовок — та, где есть ячейка, СОДЕРЖАЩАЯ 'инфляц' и
|
||||||
|
НЕ содержащая 'цель' (чтобы не схватить «Цель по инфляции»). Возвращает
|
||||||
|
``(header_row_idx, inflation_col_idx)`` или None, если колонка не найдена
|
||||||
|
(формат изменился) — тогда парсер вернёт [] и task залогирует пустой результат.
|
||||||
|
"""
|
||||||
|
for r_idx, row in enumerate(rows[:10]): # заголовок всегда в самом начале
|
||||||
|
for c_idx, cell in enumerate(row):
|
||||||
|
text = str(cell or "").strip().lower()
|
||||||
|
if CBR_INFLATION_COLUMN_MARKER in text and CBR_INFLATION_TARGET_MARKER not in text:
|
||||||
|
return r_idx, c_idx
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_inflation_xlsx(content: bytes) -> list[tuple[date, float]]:
|
||||||
|
"""Pure-парсер Excel-экспорта инфляции CBR → отсортированный список (date, %).
|
||||||
|
|
||||||
|
Берёт колонку «Инфляция, % г/г» (YoY %), дату «MM.YYYY» → 1-е число месяца.
|
||||||
|
Строки без даты ИЛИ без значения инфляции пропускаются (ранние месяцы несут
|
||||||
|
пустую ставку, но инфляция там есть; обратной ситуации в данных нет, но guard
|
||||||
|
симметричен). Дубли по дате схлопываются (последний выигрывает).
|
||||||
|
|
||||||
|
Graceful: пустой / битый xlsx / отсутствие колонки «Инфляция» → [] (НЕ бросает) —
|
||||||
|
единственное исключение логируется как warning. Сортировка по возрастанию даты.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: байты .xlsx (как вернул DownloadExcel).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Отсортированный по дате список ``(date, float)`` (значение инфляции в %).
|
||||||
|
"""
|
||||||
|
if not content:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
wb = load_workbook(BytesIO(content), read_only=True, data_only=True)
|
||||||
|
except Exception as e: # openpyxl бросает разные типы на битом файле
|
||||||
|
logger.warning("CBR inflation: не удалось открыть xlsx: %s", e)
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
sheet = wb.active
|
||||||
|
if sheet is None:
|
||||||
|
return []
|
||||||
|
rows = list(sheet.iter_rows(values_only=True))
|
||||||
|
finally:
|
||||||
|
wb.close()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
|
||||||
|
found = _find_header_and_inflation_col(rows)
|
||||||
|
if found is None:
|
||||||
|
logger.warning("CBR inflation: колонка «Инфляция, %% г/г» не найдена в xlsx")
|
||||||
|
return []
|
||||||
|
header_idx, infl_col = found
|
||||||
|
|
||||||
|
by_date: dict[date, float] = {}
|
||||||
|
for row in rows[header_idx + 1 :]:
|
||||||
|
if not row:
|
||||||
|
continue
|
||||||
|
d = _parse_month_cell(row[0] if len(row) > 0 else None)
|
||||||
|
if d is None:
|
||||||
|
continue
|
||||||
|
value = _parse_inflation_value(row[infl_col] if len(row) > infl_col else None)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
by_date[d] = value
|
||||||
|
|
||||||
|
return sorted(by_date.items())
|
||||||
|
|
||||||
|
|
||||||
|
# ── inflation (ИПЦ YoY) — HTTP fetch (thin) ─────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_inflation(
|
||||||
|
from_date: date | None = None,
|
||||||
|
to_date: date | None = None,
|
||||||
|
*,
|
||||||
|
timeout_s: float = DEFAULT_TIMEOUT_S,
|
||||||
|
retries: int = DEFAULT_RETRIES,
|
||||||
|
) -> list[tuple[date, Decimal]]:
|
||||||
|
"""Загрузить месячный ряд инфляции «% г/г» (ИПЦ YoY) ЦБ за период.
|
||||||
|
|
||||||
|
Источник — CBR UniDbQuery DownloadExcel (см. module docstring): открытый, НЕ за
|
||||||
|
WAF, работает с dev напрямую (в отличие от fedstat ИПЦ за reCAPTCHA). Тянем .xlsx
|
||||||
|
и парсим колонку «Инфляция, % г/г» через ``parse_inflation_xlsx``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
from_date: начало периода. По умолчанию ``DEFAULT_INFLATION_FROM_DATE``
|
||||||
|
(2013-01-01) — полный бэкфилл.
|
||||||
|
to_date: конец периода. По умолчанию сегодня.
|
||||||
|
timeout_s: httpx timeout на запрос.
|
||||||
|
retries: число повторов на сетевых / 5xx ошибках (экспоненциальный backoff).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Отсортированный по дате список ``(date, Decimal)`` (значение в %). Decimal —
|
||||||
|
чтобы отдать в numeric-колонку без потери точности.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
CBRScraperError: при сетевой ошибке / не-200 после всех повторов. Caller
|
||||||
|
(Celery task) логирует и пробрасывает (surfaces в GlitchTip). Пустой
|
||||||
|
(но валидный) Excel → [] без исключения.
|
||||||
|
"""
|
||||||
|
from_date = from_date or DEFAULT_INFLATION_FROM_DATE
|
||||||
|
to_date = to_date or datetime.now().date()
|
||||||
|
|
||||||
|
# CBR UniDbQuery ждёт даты в формате MM/DD/YYYY (US), URL-кодирование делает httpx.
|
||||||
|
params = {
|
||||||
|
"FromDate": from_date.strftime("%m/%d/%Y"),
|
||||||
|
"ToDate": to_date.strftime("%m/%d/%Y"),
|
||||||
|
"posted": "False",
|
||||||
|
}
|
||||||
|
headers = {"User-Agent": USER_AGENT}
|
||||||
|
|
||||||
|
last_exc: Exception | None = None
|
||||||
|
with httpx.Client(timeout=timeout_s, headers=headers, follow_redirects=True) as client:
|
||||||
|
for attempt in range(retries + 1):
|
||||||
|
try:
|
||||||
|
resp = client.get(CBR_INFLATION_EXCEL_URL, params=params)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
last_exc = e
|
||||||
|
logger.warning(
|
||||||
|
"CBR inflation fetch network error (attempt %d/%d): %s",
|
||||||
|
attempt + 1,
|
||||||
|
retries + 1,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if resp.status_code == 200:
|
||||||
|
rows = parse_inflation_xlsx(resp.content)
|
||||||
|
logger.info(
|
||||||
|
"CBR inflation fetched %d rows for [%s..%s]",
|
||||||
|
len(rows),
|
||||||
|
from_date.isoformat(),
|
||||||
|
to_date.isoformat(),
|
||||||
|
)
|
||||||
|
return [(d, Decimal(str(v))) for d, v in rows]
|
||||||
|
last_exc = CBRScraperError(
|
||||||
|
f"CBR inflation DownloadExcel HTTP {resp.status_code}: {resp.text[:300]}"
|
||||||
|
)
|
||||||
|
if resp.status_code < 500:
|
||||||
|
break
|
||||||
|
logger.warning(
|
||||||
|
"CBR inflation HTTP %d (attempt %d/%d)",
|
||||||
|
resp.status_code,
|
||||||
|
attempt + 1,
|
||||||
|
retries + 1,
|
||||||
|
)
|
||||||
|
if attempt < retries:
|
||||||
|
import time
|
||||||
|
|
||||||
|
time.sleep(min(2**attempt, 10))
|
||||||
|
|
||||||
|
raise CBRScraperError(f"CBR inflation fetch failed after {retries + 1} attempts: {last_exc}")
|
||||||
|
|
||||||
|
|
||||||
# ── national mortgage weighted rate (region='rf') ───────────────────────────────
|
# ── national mortgage weighted rate (region='rf') ───────────────────────────────
|
||||||
# TODO(#945): национальная средневзвешенная ставка по ипотеке (region='rf').
|
# TODO(#945): национальная средневзвешенная ставка по ипотеке (region='rf').
|
||||||
# Намеренно НЕ реализовано в этом PR. У ЦБ ряд есть, но стабильного
|
# Намеренно НЕ реализовано в этом PR. У ЦБ ряд есть, но стабильного
|
||||||
|
|
@ -273,9 +526,17 @@ def _main() -> None:
|
||||||
logger.info("KeyRate rows fetched: %d", len(rows))
|
logger.info("KeyRate rows fetched: %d", len(rows))
|
||||||
# Печатаем последние 5 (самые свежие) — sorted asc, поэтому хвост.
|
# Печатаем последние 5 (самые свежие) — sorted asc, поэтому хвост.
|
||||||
for d, v in rows[-5:]:
|
for d, v in rows[-5:]:
|
||||||
logger.info(" %s -> %s%%", d.isoformat(), v)
|
logger.info(" key_rate %s -> %s%%", d.isoformat(), v)
|
||||||
if not rows:
|
if not rows:
|
||||||
logger.warning("No rows returned — проверь endpoint/период.")
|
logger.warning("No key_rate rows returned — проверь endpoint/период.")
|
||||||
|
|
||||||
|
# Инфляция (ИПЦ YoY) — последние ~12 месяцев.
|
||||||
|
infl = fetch_inflation(from_date=date(today.year - 1, today.month, 1), to_date=today)
|
||||||
|
logger.info("Inflation rows fetched: %d", len(infl))
|
||||||
|
for d, v in infl[-6:]:
|
||||||
|
logger.info(" inflation_yoy %s -> %s%%", d.isoformat(), v)
|
||||||
|
if not infl:
|
||||||
|
logger.warning("No inflation rows returned — проверь endpoint/период.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,27 @@
|
||||||
"""Celery task: синхронизация макро-показателей ЦБ (ключевая ставка) в
|
"""Celery task: синхронизация макро-показателей ЦБ (ключевая ставка + инфляция) в
|
||||||
``macro_indicator`` (#945 PR B, GG-форсайт / Site Finder v2).
|
``macro_indicator`` (#945 PR B / #946, GG-форсайт / Site Finder v2).
|
||||||
|
|
||||||
Тянет историю ключевой ставки через ``fetch_key_rate`` (живой SOAP CBR
|
Тянет два ряда ЦБ (оба ``region='rf'``, ``source='cbr'``):
|
||||||
DailyInfoWebServ) и апсертит каждую (дату, ставку) в ``macro_indicator`` как
|
• ``key_rate`` — ключевая ставка через ``fetch_key_rate`` (живой SOAP CBR
|
||||||
ряд ``indicator_type='key_rate'``, ``region='rf'``, ``source='cbr'``.
|
DailyInfoWebServ), ДНЕВНОЙ, ``frequency='daily'``.
|
||||||
|
• ``inflation_yoy`` — инфляция «% г/г» (ИПЦ YoY) через ``fetch_inflation`` (CBR
|
||||||
|
UniDbQuery DownloadExcel, #946), МЕСЯЧНЫЙ,
|
||||||
|
``frequency='monthly'``, ``obs_date`` = 1-е число месяца.
|
||||||
|
|
||||||
|
#946 / ИПЦ: путь fedstat ИПЦ заблокирован reCAPTCHA — но ЦБ публикует инфляцию
|
||||||
|
открыто (см. cbr_macro docstring). Это закрывает инфляционный вход макро-слоя.
|
||||||
|
|
||||||
Детерминированно, без LLM. ON CONFLICT делает повторные прогоны идемпотентными
|
Детерминированно, без LLM. ON CONFLICT делает повторные прогоны идемпотентными
|
||||||
(re-run обновляет value + updated_at). Расписание — еженедельно (ключевая
|
(re-run обновляет value + updated_at). Расписание — еженедельно (и ставка, и
|
||||||
ставка меняется редко), регистрируется в beat_schedule.py.
|
инфляция меняются медленно), регистрируется в beat_schedule.py.
|
||||||
|
|
||||||
|
Per-series guard: сбой fetch'а инфляции НЕ мешает ставке (и наоборот) — каждый ряд
|
||||||
|
апсертится независимо, но первая возникшая ошибка ПРОБРАСЫВАЕТСЯ в конце (surfaces
|
||||||
|
в Celery/GlitchTip), не глотается. Зеркалит per-source-дисциплину rosstat_macro_sync.
|
||||||
|
|
||||||
Mirror conventions: ``SessionLocal()`` + try/finally close, ``logger`` (не
|
Mirror conventions: ``SessionLocal()`` + try/finally close, ``logger`` (не
|
||||||
print), SAVEPOINT per-row в цикле upsert (backend.md), ``CAST(:x AS type)`` —
|
print), SAVEPOINT per-row в цикле upsert (backend.md), ``CAST(:x AS type)`` —
|
||||||
никогда ``:x::type`` (psycopg v3). На fetch-фейле — логируем и пробрасываем
|
никогда ``:x::type`` (psycopg v3).
|
||||||
(surfaces в Celery/GlitchTip), не глотаем.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -26,7 +35,7 @@ from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.db import SessionLocal
|
from app.core.db import SessionLocal
|
||||||
from app.services.scrapers.cbr_macro import fetch_key_rate
|
from app.services.scrapers.cbr_macro import fetch_inflation, fetch_key_rate
|
||||||
from app.workers.celery_app import celery_app
|
from app.workers.celery_app import celery_app
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -50,6 +59,23 @@ UPSERT_KEY_RATE_SQL = text(
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Инфляция «% г/г» (ИПЦ YoY): indicator_type='inflation_yoy', monthly, region='rf'.
|
||||||
|
# obs_date уже нормализован к 1-му числу месяца парсером (parse_inflation_xlsx).
|
||||||
|
UPSERT_INFLATION_SQL = text(
|
||||||
|
"""
|
||||||
|
INSERT INTO macro_indicator (
|
||||||
|
indicator_type, region, obs_date, value,
|
||||||
|
source, frequency, unit, comment
|
||||||
|
) VALUES (
|
||||||
|
'inflation_yoy', 'rf', CAST(:d AS date), CAST(:v AS numeric),
|
||||||
|
'cbr', 'monthly', '%', 'CBR inflation YoY (ИПЦ, % г/г)'
|
||||||
|
)
|
||||||
|
ON CONFLICT (indicator_type, region, obs_date) DO UPDATE SET
|
||||||
|
value = EXCLUDED.value,
|
||||||
|
updated_at = now()
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _upsert_key_rate(db: Session, rows: list[tuple[date, Decimal]]) -> int:
|
def _upsert_key_rate(db: Session, rows: list[tuple[date, Decimal]]) -> int:
|
||||||
"""Апсертит (date, rate) в macro_indicator. SAVEPOINT per-row, чтобы один
|
"""Апсертит (date, rate) в macro_indicator. SAVEPOINT per-row, чтобы один
|
||||||
|
|
@ -66,6 +92,21 @@ def _upsert_key_rate(db: Session, rows: list[tuple[date, Decimal]]) -> int:
|
||||||
return upserted
|
return upserted
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_inflation(db: Session, rows: list[tuple[date, Decimal]]) -> int:
|
||||||
|
"""Апсертит (month1st, inflation%) в macro_indicator. SAVEPOINT per-row.
|
||||||
|
Возвращает число успешных upsert'ов."""
|
||||||
|
upserted = 0
|
||||||
|
for d, v in rows:
|
||||||
|
try:
|
||||||
|
with db.begin_nested():
|
||||||
|
db.execute(UPSERT_INFLATION_SQL, {"d": d, "v": v})
|
||||||
|
upserted += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("upsert inflation_yoy %s=%s failed: %s", d, v, e)
|
||||||
|
db.commit()
|
||||||
|
return upserted
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(
|
@celery_app.task(
|
||||||
bind=True,
|
bind=True,
|
||||||
name="tasks.cbr_macro_sync.cbr_macro_sync",
|
name="tasks.cbr_macro_sync.cbr_macro_sync",
|
||||||
|
|
@ -76,40 +117,69 @@ def cbr_macro_sync(
|
||||||
from_date: str | None = None,
|
from_date: str | None = None,
|
||||||
to_date: str | None = None,
|
to_date: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Загрузить историю ключевой ставки ЦБ и апсертить в macro_indicator.
|
"""Загрузить ряды ЦБ (ключевая ставка + инфляция YoY) и апсертить в macro_indicator.
|
||||||
|
|
||||||
|
Per-series guard (зеркало rosstat_macro_sync): сбой одного ряда не блокирует
|
||||||
|
другой — оба пытаются fetch+upsert независимо; первая возникшая ошибка
|
||||||
|
пробрасывается в конце (surfaces в Celery/GlitchTip), не глотается.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
from_date: ISO-дата начала периода ('YYYY-MM-DD'). None → дефолт
|
from_date: ISO-дата начала периода ('YYYY-MM-DD'). None → дефолт КАЖДОГО
|
||||||
scraper'а (2019-01-01, полный бэкфилл).
|
scraper'а (key_rate 2019-01-01, inflation 2013-01-01; полный бэкфилл).
|
||||||
to_date: ISO-дата конца периода. None → сегодня.
|
to_date: ISO-дата конца периода. None → сегодня.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Счётчики ``{"fetched": N, "upserted": M, "from_date": ..., "to_date": ...}``.
|
Счётчики. ``fetched``/``upserted`` на верхнем уровне — по ключевой ставке
|
||||||
|
(обратная совместимость); ``key_rate`` и ``inflation`` — пер-ряд под-счётчики.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
Пробрасывает любую ошибку fetch'а (CBRScraperError и пр.) — чтобы фейл
|
Пробрасывает первую ошибку fetch'а (CBRScraperError и пр.) после попытки
|
||||||
был виден в Celery / GlitchTip, а не молча проглочен.
|
обоих рядов — чтобы фейл был виден в Celery / GlitchTip, а не молча проглочен.
|
||||||
"""
|
"""
|
||||||
fd = date.fromisoformat(from_date) if from_date else None
|
fd = date.fromisoformat(from_date) if from_date else None
|
||||||
td = date.fromisoformat(to_date) if to_date else None
|
td = date.fromisoformat(to_date) if to_date else None
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
rows = fetch_key_rate(from_date=fd, to_date=td)
|
first_error: Exception | None = None
|
||||||
fetched = len(rows)
|
|
||||||
logger.info("cbr_macro_sync: fetched %d key_rate rows", fetched)
|
|
||||||
|
|
||||||
upserted = _upsert_key_rate(db, rows)
|
# ── key_rate (дневной, SOAP) ──────────────────────────────────────────
|
||||||
logger.info("cbr_macro_sync: upserted %d/%d key_rate rows", upserted, fetched)
|
kr_fetched = kr_upserted = 0
|
||||||
|
try:
|
||||||
|
kr_rows = fetch_key_rate(from_date=fd, to_date=td)
|
||||||
|
kr_fetched = len(kr_rows)
|
||||||
|
logger.info("cbr_macro_sync: fetched %d key_rate rows", kr_fetched)
|
||||||
|
kr_upserted = _upsert_key_rate(db, kr_rows)
|
||||||
|
logger.info("cbr_macro_sync: upserted %d/%d key_rate rows", kr_upserted, kr_fetched)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("cbr_macro_sync: key_rate sync failed: %s", e)
|
||||||
|
first_error = first_error or e
|
||||||
|
|
||||||
|
# ── inflation_yoy (месячный, Excel #946) ──────────────────────────────
|
||||||
|
infl_fetched = infl_upserted = 0
|
||||||
|
try:
|
||||||
|
infl_rows = fetch_inflation(from_date=fd, to_date=td)
|
||||||
|
infl_fetched = len(infl_rows)
|
||||||
|
logger.info("cbr_macro_sync: fetched %d inflation_yoy rows", infl_fetched)
|
||||||
|
infl_upserted = _upsert_inflation(db, infl_rows)
|
||||||
|
logger.info(
|
||||||
|
"cbr_macro_sync: upserted %d/%d inflation_yoy rows", infl_upserted, infl_fetched
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("cbr_macro_sync: inflation sync failed: %s", e)
|
||||||
|
first_error = first_error or e
|
||||||
|
|
||||||
|
if first_error is not None:
|
||||||
|
raise first_error
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"fetched": fetched,
|
# обратная совместимость: верхнеуровневые счётчики = ключевая ставка
|
||||||
"upserted": upserted,
|
"fetched": kr_fetched,
|
||||||
|
"upserted": kr_upserted,
|
||||||
|
"key_rate": {"fetched": kr_fetched, "upserted": kr_upserted},
|
||||||
|
"inflation": {"fetched": infl_fetched, "upserted": infl_upserted},
|
||||||
"from_date": fd.isoformat() if fd else None,
|
"from_date": fd.isoformat() if fd else None,
|
||||||
"to_date": td.isoformat() if td else None,
|
"to_date": td.isoformat() if td else None,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
|
||||||
logger.exception("cbr_macro_sync failed: %s", e)
|
|
||||||
raise
|
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,22 @@
|
||||||
"""Тесты pure-парсера CBR KeyRate (offline, без живой сети).
|
"""Тесты pure-парсеров CBR (offline, без живой сети).
|
||||||
|
|
||||||
Покрывают: парсинг (date, value), обработку tz-суффикса, сортировку,
|
Покрывают:
|
||||||
пустой / битый / без-KR XML → []. Фикстуры — из реального формата
|
• parse_key_rate_xml — парсинг (date, value), tz-суффикс, сортировка, пустой /
|
||||||
KeyRateXML-ответа DailyInfoWebServ (verified live 2026-06-02).
|
битый / без-KR XML → []. Фикстуры из реального KeyRateXML (verified 2026-06-02).
|
||||||
|
• parse_inflation_xlsx — парсинг колонки «Инфляция, % г/г» Excel-экспорта
|
||||||
|
UniDbQuery (#946): дата MM.YYYY → 1-е число, выбор правильной колонки (НЕ «Цель»),
|
||||||
|
пропуск строк без значения, сортировка, пустой / битый / без-колонки xlsx → [].
|
||||||
|
Фикстуры строятся openpyxl'ом, зеркаля реальный формат CBR (verified 2026-06-08).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
from app.services.scrapers.cbr_macro import parse_key_rate_xml
|
from openpyxl import Workbook
|
||||||
|
|
||||||
|
from app.services.scrapers.cbr_macro import parse_inflation_xlsx, parse_key_rate_xml
|
||||||
|
|
||||||
# Реальный shape KeyRateXML (без diffgram). 3 строки, descending по дате,
|
# Реальный shape KeyRateXML (без diffgram). 3 строки, descending по дате,
|
||||||
# tz-суффикс +03:00, точка как десятичный разделитель.
|
# tz-суффикс +03:00, точка как десятичный разделитель.
|
||||||
|
|
@ -129,3 +136,115 @@ def test_parse_dedups_by_date_last_wins() -> None:
|
||||||
"</KeyRate>"
|
"</KeyRate>"
|
||||||
)
|
)
|
||||||
assert parse_key_rate_xml(xml) == [(date(2023, 8, 15), 13.0)]
|
assert parse_key_rate_xml(xml) == [(date(2023, 8, 15), 13.0)]
|
||||||
|
|
||||||
|
|
||||||
|
# ── parse_inflation_xlsx (#946) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _make_inflation_xlsx(
|
||||||
|
rows: list[tuple[object, object, object, object]],
|
||||||
|
*,
|
||||||
|
header: tuple[str, str, str, str] = (
|
||||||
|
"Дата",
|
||||||
|
"Ключевая ставка, % годовых",
|
||||||
|
"Инфляция, % г/г",
|
||||||
|
"Цель по инфляции",
|
||||||
|
),
|
||||||
|
) -> bytes:
|
||||||
|
"""Собрать .xlsx, зеркаля реальный CBR-экспорт: header + строки (дата, ставка,
|
||||||
|
инфляция, цель). Возвращает байты (как вернул бы DownloadExcel)."""
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.append(list(header))
|
||||||
|
for r in rows:
|
||||||
|
ws.append(list(r))
|
||||||
|
buf = BytesIO()
|
||||||
|
wb.save(buf)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_inflation_basic() -> None:
|
||||||
|
"""Реальный shape: MM.YYYY-даты descending, нативные float → берём колонку
|
||||||
|
«Инфляция» (НЕ «Цель»), дата → 1-е число, сортировка по возрастанию."""
|
||||||
|
content = _make_inflation_xlsx(
|
||||||
|
[
|
||||||
|
("04.2026", 14.5, 5.58, 4.0),
|
||||||
|
("03.2026", 15.0, 5.86, 4.0),
|
||||||
|
("02.2026", 15.5, 5.91, 4.0),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
rows = parse_inflation_xlsx(content)
|
||||||
|
assert rows == [
|
||||||
|
(date(2026, 2, 1), 5.91),
|
||||||
|
(date(2026, 3, 1), 5.86),
|
||||||
|
(date(2026, 4, 1), 5.58),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_inflation_picks_inflation_not_target() -> None:
|
||||||
|
"""Колонка «Цель по инфляции» (=4.0) НЕ должна попасть вместо «Инфляция»."""
|
||||||
|
content = _make_inflation_xlsx([("01.2026", 16.0, 6.0, 4.0)])
|
||||||
|
rows = parse_inflation_xlsx(content)
|
||||||
|
assert rows == [(date(2026, 1, 1), 6.0)]
|
||||||
|
# значение именно инфляции, а не цели
|
||||||
|
assert rows[0][1] != 4.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_inflation_skips_rows_without_value() -> None:
|
||||||
|
"""Строки с пустой инфляцией пропускаются; валидные остаются."""
|
||||||
|
content = _make_inflation_xlsx(
|
||||||
|
[
|
||||||
|
("03.2013", None, 7.02, None), # ранний месяц: ставка пустая, инфляция есть
|
||||||
|
("02.2013", None, None, None), # нет инфляции → скип
|
||||||
|
]
|
||||||
|
)
|
||||||
|
rows = parse_inflation_xlsx(content)
|
||||||
|
assert rows == [(date(2013, 3, 1), 7.02)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_inflation_handles_comma_decimal() -> None:
|
||||||
|
"""Строковая ячейка с запятой-разделителем (на случай не-числовой ячейки)."""
|
||||||
|
content = _make_inflation_xlsx([("12.2025", "16,00", "5,59", "4,00")])
|
||||||
|
rows = parse_inflation_xlsx(content)
|
||||||
|
assert rows == [(date(2025, 12, 1), 5.59)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_inflation_dedups_by_month_last_wins() -> None:
|
||||||
|
content = _make_inflation_xlsx(
|
||||||
|
[
|
||||||
|
("05.2025", 20.0, 9.9, 4.0),
|
||||||
|
("05.2025", 20.0, 10.1, 4.0),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
rows = parse_inflation_xlsx(content)
|
||||||
|
assert rows == [(date(2025, 5, 1), 10.1)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_inflation_empty_bytes() -> None:
|
||||||
|
assert parse_inflation_xlsx(b"") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_inflation_malformed_bytes() -> None:
|
||||||
|
"""Не-xlsx байты → [] (не бросает)."""
|
||||||
|
assert parse_inflation_xlsx(b"not an excel file") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_inflation_no_inflation_column() -> None:
|
||||||
|
"""Excel без колонки «Инфляция» (формат изменился) → [] (graceful)."""
|
||||||
|
content = _make_inflation_xlsx(
|
||||||
|
[("04.2026", 14.5, 5.58, 4.0)],
|
||||||
|
header=("Дата", "Ставка", "Нечто", "Иное"),
|
||||||
|
)
|
||||||
|
assert parse_inflation_xlsx(content) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_inflation_skips_unparseable_date() -> None:
|
||||||
|
"""Строка с непарсящейся датой (мусор) пропускается, валидные остаются."""
|
||||||
|
content = _make_inflation_xlsx(
|
||||||
|
[
|
||||||
|
("04.2026", 14.5, 5.58, 4.0),
|
||||||
|
("мусор", 1.0, 9.9, 4.0),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
rows = parse_inflation_xlsx(content)
|
||||||
|
assert rows == [(date(2026, 4, 1), 5.58)]
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
|
||||||
|
|
||||||
from app.services.forecasting.macro_coefficient import (
|
from app.services.forecasting.macro_coefficient import (
|
||||||
_DEGRADED_FACTORS,
|
_DEGRADED_FACTORS,
|
||||||
|
_F_INFLATION,
|
||||||
_F_ISSUANCE,
|
_F_ISSUANCE,
|
||||||
_F_MORTG_RATE,
|
_F_MORTG_RATE,
|
||||||
_F_OVERDUE,
|
_F_OVERDUE,
|
||||||
|
|
@ -40,6 +41,7 @@ from app.services.forecasting.macro_coefficient import (
|
||||||
MacroCoefficient,
|
MacroCoefficient,
|
||||||
assemble_coefficient,
|
assemble_coefficient,
|
||||||
compute_macro_coefficient,
|
compute_macro_coefficient,
|
||||||
|
f_inflation,
|
||||||
f_issuance,
|
f_issuance,
|
||||||
f_mortgage_rate,
|
f_mortgage_rate,
|
||||||
f_overdue,
|
f_overdue,
|
||||||
|
|
@ -75,6 +77,7 @@ def _macro(
|
||||||
issued_volume: list[float | None] | None = None,
|
issued_volume: list[float | None] | None = None,
|
||||||
debt: list[float | None] | None = None,
|
debt: list[float | None] | None = None,
|
||||||
overdue: list[float | None] | None = None,
|
overdue: list[float | None] | None = None,
|
||||||
|
inflation: list[float | None] | None = None,
|
||||||
) -> list[MonthlyMacro]:
|
) -> list[MonthlyMacro]:
|
||||||
"""Список MonthlyMacro; невыставленные ряды → все None (degraded-вход)."""
|
"""Список MonthlyMacro; невыставленные ряды → все None (degraded-вход)."""
|
||||||
n = len(months)
|
n = len(months)
|
||||||
|
|
@ -85,6 +88,7 @@ def _macro(
|
||||||
iv = issued_volume if issued_volume is not None else none_n
|
iv = issued_volume if issued_volume is not None else none_n
|
||||||
db_ = debt if debt is not None else none_n
|
db_ = debt if debt is not None else none_n
|
||||||
od = overdue if overdue is not None else none_n
|
od = overdue if overdue is not None else none_n
|
||||||
|
infl = inflation if inflation is not None else none_n
|
||||||
out: list[MonthlyMacro] = []
|
out: list[MonthlyMacro] = []
|
||||||
for i, month in enumerate(months):
|
for i, month in enumerate(months):
|
||||||
out.append(
|
out.append(
|
||||||
|
|
@ -96,6 +100,7 @@ def _macro(
|
||||||
mortgage_issued_volume=iv[i],
|
mortgage_issued_volume=iv[i],
|
||||||
mortgage_debt=db_[i],
|
mortgage_debt=db_[i],
|
||||||
mortgage_overdue=od[i],
|
mortgage_overdue=od[i],
|
||||||
|
inflation_yoy=infl[i],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return out
|
return out
|
||||||
|
|
@ -198,6 +203,33 @@ class TestFOverdue:
|
||||||
assert f_overdue(10.0, 0.0) is None # нулевой портфель → нет базы
|
assert f_overdue(10.0, 0.0) is None # нулевой портфель → нет базы
|
||||||
|
|
||||||
|
|
||||||
|
# ── pure: f_inflation (#946) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestFInflation:
|
||||||
|
def test_above_target_is_negative(self) -> None:
|
||||||
|
# 8% > цель 4% → превышение 4 п.п. → негатив (макро-стресс).
|
||||||
|
v = f_inflation(8.0)
|
||||||
|
assert v is not None and v < 0
|
||||||
|
|
||||||
|
def test_at_target_is_neutral_zero(self) -> None:
|
||||||
|
# ровно цель 4% → канал доступен, но нудж 0.
|
||||||
|
assert f_inflation(4.0) == 0.0
|
||||||
|
|
||||||
|
def test_below_target_is_neutral_zero(self) -> None:
|
||||||
|
# ниже цели → НЕ положительный вклад (консервативно), нудж 0 (доступен).
|
||||||
|
assert f_inflation(2.0) == 0.0
|
||||||
|
|
||||||
|
def test_only_non_positive(self) -> None:
|
||||||
|
# Экстремальная инфляция (цель+8=12% при шкале 8 п.п.) → клэмп −1.
|
||||||
|
assert f_inflation(12.0) == -1.0
|
||||||
|
# Ещё выше — всё равно не ниже −1.
|
||||||
|
assert f_inflation(50.0) == -1.0
|
||||||
|
|
||||||
|
def test_none_is_unavailable(self) -> None:
|
||||||
|
assert f_inflation(None) is None
|
||||||
|
|
||||||
|
|
||||||
# ── pure: segment_steepness ───────────────────────────────────────────────────
|
# ── pure: segment_steepness ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -349,6 +381,37 @@ class TestComputeMacroCoefficient:
|
||||||
assert _F_MORTG_RATE in out.unavailable_inputs
|
assert _F_MORTG_RATE in out.unavailable_inputs
|
||||||
assert out.breakdown[_F_MORTG_RATE] is None
|
assert out.breakdown[_F_MORTG_RATE] is None
|
||||||
|
|
||||||
|
def test_inflation_channel_backed_when_present(self) -> None:
|
||||||
|
# #946: inflation_yoy задан → канал inflation BACKED (в available, breakdown != None).
|
||||||
|
n = 12
|
||||||
|
months = _months(n)
|
||||||
|
inflation = [9.0 for _ in range(n)] # 9% > цель 4% → давящий нудж
|
||||||
|
macro = _macro(months, inflation=inflation)
|
||||||
|
with patch(_MACRO, return_value=macro):
|
||||||
|
out = compute_macro_coefficient(MagicMock(), segment_profile={})
|
||||||
|
assert _F_INFLATION in out.available_inputs
|
||||||
|
assert out.breakdown[_F_INFLATION] is not None
|
||||||
|
assert out.breakdown[_F_INFLATION] < 0 # инфляция выше цели → отрицательный вклад
|
||||||
|
|
||||||
|
def test_inflation_channel_unavailable_when_absent(self) -> None:
|
||||||
|
# Нет inflation_yoy (None по ряду) → канал inflation в unavailable, breakdown None.
|
||||||
|
n = 12
|
||||||
|
months = _months(n)
|
||||||
|
macro = _macro(months, key_rate=[10.0] * n) # inflation не задан → None
|
||||||
|
with patch(_MACRO, return_value=macro):
|
||||||
|
out = compute_macro_coefficient(MagicMock(), segment_profile={})
|
||||||
|
assert _F_INFLATION in out.unavailable_inputs
|
||||||
|
assert out.breakdown[_F_INFLATION] is None
|
||||||
|
|
||||||
|
def test_high_inflation_pushes_coef_below_one(self) -> None:
|
||||||
|
# Только инфляционный канал, высоко над целью → coef < 1 (давящий режим).
|
||||||
|
n = 12
|
||||||
|
months = _months(n)
|
||||||
|
macro = _macro(months, inflation=[11.0 for _ in range(n)])
|
||||||
|
with patch(_MACRO, return_value=macro):
|
||||||
|
out = compute_macro_coefficient(MagicMock(), segment_profile={})
|
||||||
|
assert out.coefficient < _MACRO_COEF_NEUTRAL
|
||||||
|
|
||||||
def test_rate_down_favored_segment_coef_above_one(self) -> None:
|
def test_rate_down_favored_segment_coef_above_one(self) -> None:
|
||||||
# Ставка падает + выдачи растут → поддерживающий режим → coef > 1, и для
|
# Ставка падает + выдачи растут → поддерживающий режим → coef > 1, и для
|
||||||
# favored-сегмента (семейный/компакт) подъём КРУЧЕ (сегмент-модификатор).
|
# favored-сегмента (семейный/компакт) подъём КРУЧЕ (сегмент-модификатор).
|
||||||
|
|
@ -392,7 +455,7 @@ class TestComputeMacroCoefficient:
|
||||||
assert expensive.coefficient < neutral.coefficient
|
assert expensive.coefficient < neutral.coefficient
|
||||||
|
|
||||||
def test_all_backed_available_with_clean_window_high(self) -> None:
|
def test_all_backed_available_with_clean_window_high(self) -> None:
|
||||||
# Все 4 backed-канала есть + окно без шок-дат → confidence='high'.
|
# Все 5 backed-каналов есть (включая inflation, #946) + окно без шок-дат → 'high'.
|
||||||
n = 12
|
n = 12
|
||||||
months = _months(n, end=dt.date(2023, 12, 1)) # 2023 без шок-дат PR2
|
months = _months(n, end=dt.date(2023, 12, 1)) # 2023 без шок-дат PR2
|
||||||
key_rate = [9.0 + i * 0.2 for i in range(n)]
|
key_rate = [9.0 + i * 0.2 for i in range(n)]
|
||||||
|
|
@ -401,6 +464,7 @@ class TestComputeMacroCoefficient:
|
||||||
issued_volume = [45000.0 - i * 500 for i in range(n)]
|
issued_volume = [45000.0 - i * 500 for i in range(n)]
|
||||||
debt = [2_000_000.0 + i * 1000 for i in range(n)]
|
debt = [2_000_000.0 + i * 1000 for i in range(n)]
|
||||||
overdue = [25_000.0 + i * 200 for i in range(n)]
|
overdue = [25_000.0 + i * 200 for i in range(n)]
|
||||||
|
inflation = [7.0 + i * 0.1 for i in range(n)] # выше цели 4% → backed канал есть
|
||||||
macro = _macro(
|
macro = _macro(
|
||||||
months,
|
months,
|
||||||
key_rate=key_rate,
|
key_rate=key_rate,
|
||||||
|
|
@ -409,13 +473,14 @@ class TestComputeMacroCoefficient:
|
||||||
issued_volume=issued_volume,
|
issued_volume=issued_volume,
|
||||||
debt=debt,
|
debt=debt,
|
||||||
overdue=overdue,
|
overdue=overdue,
|
||||||
|
inflation=inflation,
|
||||||
)
|
)
|
||||||
with patch(_MACRO, return_value=macro):
|
with patch(_MACRO, return_value=macro):
|
||||||
out = compute_macro_coefficient(MagicMock(), segment_profile={})
|
out = compute_macro_coefficient(MagicMock(), segment_profile={})
|
||||||
assert out.confidence == "high"
|
assert out.confidence == "high"
|
||||||
assert out.confounded is False
|
assert out.confounded is False
|
||||||
# Все backed доступны; все degraded — нет.
|
# Все backed доступны (включая inflation); все degraded — нет.
|
||||||
for name in (_F_RATE, _F_MORTG_RATE, _F_ISSUANCE, _F_OVERDUE):
|
for name in (_F_RATE, _F_MORTG_RATE, _F_ISSUANCE, _F_OVERDUE, _F_INFLATION):
|
||||||
assert name in out.available_inputs
|
assert name in out.available_inputs
|
||||||
for name in _DEGRADED_FACTORS:
|
for name in _DEGRADED_FACTORS:
|
||||||
assert name in out.unavailable_inputs
|
assert name in out.unavailable_inputs
|
||||||
|
|
|
||||||
|
|
@ -261,10 +261,12 @@ class TestAsDict:
|
||||||
mortgage_issued_volume=98765.4321,
|
mortgage_issued_volume=98765.4321,
|
||||||
mortgage_debt=123456.789,
|
mortgage_debt=123456.789,
|
||||||
mortgage_overdue=234.5678,
|
mortgage_overdue=234.5678,
|
||||||
|
inflation_yoy=5.876543,
|
||||||
)
|
)
|
||||||
d = m.as_dict()
|
d = m.as_dict()
|
||||||
assert d["month"] == "2024-03-01"
|
assert d["month"] == "2024-03-01"
|
||||||
assert d["key_rate"] == 16.12
|
assert d["key_rate"] == 16.12
|
||||||
|
assert d["inflation_yoy"] == 5.88 # round to 2 digits
|
||||||
assert d["mortgage_rate_weighted"] == 7.99
|
assert d["mortgage_rate_weighted"] == 7.99
|
||||||
assert d["mortgage_issued_count"] == 1235 # round to 0 digits
|
assert d["mortgage_issued_count"] == 1235 # round to 0 digits
|
||||||
assert d["mortgage_issued_volume"] == 98765.4
|
assert d["mortgage_issued_volume"] == 98765.4
|
||||||
|
|
@ -276,6 +278,7 @@ class TestAsDict:
|
||||||
d = m.as_dict()
|
d = m.as_dict()
|
||||||
assert d["month"] == "2024-03-01"
|
assert d["month"] == "2024-03-01"
|
||||||
assert d["key_rate"] is None
|
assert d["key_rate"] is None
|
||||||
|
assert d["inflation_yoy"] is None
|
||||||
assert d["mortgage_rate_weighted"] is None
|
assert d["mortgage_rate_weighted"] is None
|
||||||
assert d["mortgage_debt"] is None
|
assert d["mortgage_debt"] is None
|
||||||
|
|
||||||
|
|
@ -330,13 +333,20 @@ class TestGetMonthlyMacroShape:
|
||||||
db.execute.return_value = _key_rate_result([])
|
db.execute.return_value = _key_rate_result([])
|
||||||
with patch(_GET_SERIES, return_value=[]) as gs:
|
with patch(_GET_SERIES, return_value=[]) as gs:
|
||||||
get_monthly_macro(db, months_back=3)
|
get_monthly_macro(db, months_back=3)
|
||||||
# Все mortgage-ряды читаются регионом 'sverdl'.
|
# MORTGAGE-ряды читаются регионом 'sverdl' (inflation_yoy — отдельно 'rf', ниже).
|
||||||
assert gs.called
|
assert gs.called
|
||||||
for call in gs.call_args_list:
|
region_by_type = {call.args[1]: call.kwargs["region"] for call in gs.call_args_list}
|
||||||
assert call.kwargs["region"] == "sverdl"
|
assert region_by_type.get("mortgage_rate_weighted") == "sverdl"
|
||||||
read_types = {call.args[1] for call in gs.call_args_list}
|
assert region_by_type.get("mortgage_issued_count") == "sverdl"
|
||||||
assert "mortgage_rate_weighted" in read_types
|
|
||||||
assert "mortgage_issued_count" in read_types
|
def test_inflation_series_read_from_rf(self) -> None:
|
||||||
|
# #946: inflation_yoy — национальный ряд (region 'rf'), читается через get_macro_series.
|
||||||
|
db = MagicMock()
|
||||||
|
db.execute.return_value = _key_rate_result([])
|
||||||
|
with patch(_GET_SERIES, return_value=[]) as gs:
|
||||||
|
get_monthly_macro(db, months_back=3)
|
||||||
|
region_by_type = {call.args[1]: call.kwargs["region"] for call in gs.call_args_list}
|
||||||
|
assert region_by_type.get("inflation_yoy") == "rf"
|
||||||
|
|
||||||
|
|
||||||
class TestGetMonthlyMacroLogic:
|
class TestGetMonthlyMacroLogic:
|
||||||
|
|
@ -392,6 +402,44 @@ class TestGetMonthlyMacroLogic:
|
||||||
other = next(m for m in out if m.month != target)
|
other = next(m for m in out if m.month != target)
|
||||||
assert other.mortgage_rate_weighted is None
|
assert other.mortgage_rate_weighted is None
|
||||||
|
|
||||||
|
def test_inflation_carries_forward_on_grid(self) -> None:
|
||||||
|
# #946: inflation_yoy известен в первом месяце сетки → carry-forward вперёд
|
||||||
|
# (медленный месячный ряд, последняя известная точка = текущий режим).
|
||||||
|
today = dt.date.today()
|
||||||
|
first = _shift_months(today, -3)
|
||||||
|
|
||||||
|
def fake_series(_db: object, indicator_type: str, **_kw: object) -> list:
|
||||||
|
if indicator_type == "inflation_yoy":
|
||||||
|
return [(first, 6.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)
|
||||||
|
by_month = {m.month: m for m in out}
|
||||||
|
assert by_month[first].inflation_yoy == 6.0
|
||||||
|
# LOCF: последующий месяц наследует 6.0 (нет своей точки).
|
||||||
|
mid = _shift_months(today, -2)
|
||||||
|
assert by_month[mid].inflation_yoy == 6.0
|
||||||
|
|
||||||
|
def test_leading_months_without_inflation_are_none(self) -> None:
|
||||||
|
# inflation появляется только в последнем месяце → ранние = None (carry невозможен).
|
||||||
|
today = dt.date.today()
|
||||||
|
last = _shift_months(today, -1)
|
||||||
|
|
||||||
|
def fake_series(_db: object, indicator_type: str, **_kw: object) -> list:
|
||||||
|
if indicator_type == "inflation_yoy":
|
||||||
|
return [(last, 5.5)]
|
||||||
|
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)
|
||||||
|
assert out[0].inflation_yoy is None
|
||||||
|
assert out[-2].inflation_yoy == 5.5
|
||||||
|
|
||||||
|
|
||||||
class TestGetMonthlyMacroGraceful:
|
class TestGetMonthlyMacroGraceful:
|
||||||
def test_empty_table_returns_rows_with_none(self) -> None:
|
def test_empty_table_returns_rows_with_none(self) -> None:
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"""Тесты Celery-таски cbr_macro_sync (mock session + mock fetch, без живой сети).
|
"""Тесты Celery-таски cbr_macro_sync (mock session + mock fetch, без живой сети).
|
||||||
|
|
||||||
Покрывают: контракт upsert-SQL (indicator_type='key_rate', region='rf',
|
Покрывают: контракт upsert-SQL обоих рядов (key_rate + inflation_yoy, region='rf',
|
||||||
source='cbr', ON CONFLICT, CAST not ::), счётчики fetched/upserted, проброс
|
source='cbr', ON CONFLICT, CAST not ::), счётчики fetched/upserted (пер-ряд),
|
||||||
ошибки fetch'а.
|
per-series guard (сбой одного ряда не мешает другому, но пробрасывается).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -71,12 +71,16 @@ def test_task_upserts_each_row_with_correct_params() -> None:
|
||||||
with (
|
with (
|
||||||
patch.object(task_mod, "SessionLocal", return_value=db),
|
patch.object(task_mod, "SessionLocal", return_value=db),
|
||||||
patch.object(task_mod, "fetch_key_rate", return_value=fake_rows) as mock_fetch,
|
patch.object(task_mod, "fetch_key_rate", return_value=fake_rows) as mock_fetch,
|
||||||
|
patch.object(task_mod, "fetch_inflation", return_value=[]) as mock_infl,
|
||||||
):
|
):
|
||||||
result = task_mod.cbr_macro_sync.run()
|
result = task_mod.cbr_macro_sync.run()
|
||||||
|
|
||||||
mock_fetch.assert_called_once()
|
mock_fetch.assert_called_once()
|
||||||
|
mock_infl.assert_called_once()
|
||||||
assert result["fetched"] == 2
|
assert result["fetched"] == 2
|
||||||
assert result["upserted"] == 2
|
assert result["upserted"] == 2
|
||||||
|
assert result["key_rate"] == {"fetched": 2, "upserted": 2}
|
||||||
|
assert result["inflation"] == {"fetched": 0, "upserted": 0}
|
||||||
# параметры каждого upsert
|
# параметры каждого upsert
|
||||||
assert captured == [
|
assert captured == [
|
||||||
{"d": date(2023, 8, 14), "v": Decimal("8.5")},
|
{"d": date(2023, 8, 14), "v": Decimal("8.5")},
|
||||||
|
|
@ -95,10 +99,13 @@ def test_task_passes_date_range_to_fetch() -> None:
|
||||||
with (
|
with (
|
||||||
patch.object(task_mod, "SessionLocal", return_value=db),
|
patch.object(task_mod, "SessionLocal", return_value=db),
|
||||||
patch.object(task_mod, "fetch_key_rate", return_value=[]) as mock_fetch,
|
patch.object(task_mod, "fetch_key_rate", return_value=[]) as mock_fetch,
|
||||||
|
patch.object(task_mod, "fetch_inflation", return_value=[]) as mock_infl,
|
||||||
):
|
):
|
||||||
result = task_mod.cbr_macro_sync.run(from_date="2020-01-01", to_date="2020-12-31")
|
result = task_mod.cbr_macro_sync.run(from_date="2020-01-01", to_date="2020-12-31")
|
||||||
|
|
||||||
mock_fetch.assert_called_once_with(from_date=date(2020, 1, 1), to_date=date(2020, 12, 31))
|
mock_fetch.assert_called_once_with(from_date=date(2020, 1, 1), to_date=date(2020, 12, 31))
|
||||||
|
# период прокидывается и в inflation fetch
|
||||||
|
mock_infl.assert_called_once_with(from_date=date(2020, 1, 1), to_date=date(2020, 12, 31))
|
||||||
assert result["from_date"] == "2020-01-01"
|
assert result["from_date"] == "2020-01-01"
|
||||||
assert result["to_date"] == "2020-12-31"
|
assert result["to_date"] == "2020-12-31"
|
||||||
assert result["fetched"] == 0
|
assert result["fetched"] == 0
|
||||||
|
|
@ -106,7 +113,7 @@ def test_task_passes_date_range_to_fetch() -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_task_reraises_fetch_failure() -> None:
|
def test_task_reraises_fetch_failure() -> None:
|
||||||
"""Ошибка fetch'а пробрасывается (surfaces в Celery/GlitchTip), не глотается.
|
"""Ошибка fetch'а ставки пробрасывается (surfaces в Celery/GlitchTip), не глотается.
|
||||||
DB-сессия при этом закрывается."""
|
DB-сессия при этом закрывается."""
|
||||||
from app.workers.tasks import cbr_macro_sync as task_mod
|
from app.workers.tasks import cbr_macro_sync as task_mod
|
||||||
|
|
||||||
|
|
@ -115,6 +122,7 @@ def test_task_reraises_fetch_failure() -> None:
|
||||||
with (
|
with (
|
||||||
patch.object(task_mod, "SessionLocal", return_value=db),
|
patch.object(task_mod, "SessionLocal", return_value=db),
|
||||||
patch.object(task_mod, "fetch_key_rate", side_effect=RuntimeError("CBR down")),
|
patch.object(task_mod, "fetch_key_rate", side_effect=RuntimeError("CBR down")),
|
||||||
|
patch.object(task_mod, "fetch_inflation", return_value=[]),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
task_mod.cbr_macro_sync.run()
|
task_mod.cbr_macro_sync.run()
|
||||||
|
|
@ -125,6 +133,74 @@ def test_task_reraises_fetch_failure() -> None:
|
||||||
db.close.assert_called_once()
|
db.close.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_inflation_failure_does_not_block_key_rate() -> None:
|
||||||
|
"""Per-series guard: сбой инфляции НЕ мешает ставке апсертнуться, но пробрасывается."""
|
||||||
|
from app.workers.tasks import cbr_macro_sync as task_mod
|
||||||
|
|
||||||
|
db, captured = _make_mock_db()
|
||||||
|
kr_rows = [(date(2024, 1, 1), Decimal("16.0"))]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(task_mod, "SessionLocal", return_value=db),
|
||||||
|
patch.object(task_mod, "fetch_key_rate", return_value=kr_rows),
|
||||||
|
patch.object(task_mod, "fetch_inflation", side_effect=RuntimeError("infl down")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
task_mod.cbr_macro_sync.run()
|
||||||
|
raise AssertionError("expected RuntimeError to propagate")
|
||||||
|
except RuntimeError as e:
|
||||||
|
assert "infl down" in str(e)
|
||||||
|
|
||||||
|
# key_rate всё равно апсертнулся ДО проброса inflation-ошибки
|
||||||
|
assert captured == [{"d": date(2024, 1, 1), "v": Decimal("16.0")}]
|
||||||
|
db.close.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_upserts_inflation_rows_with_correct_params() -> None:
|
||||||
|
"""Каждая (month1st, %) инфляции → execute UPSERT_INFLATION_SQL с :d и :v."""
|
||||||
|
from app.workers.tasks import cbr_macro_sync as task_mod
|
||||||
|
|
||||||
|
db, captured = _make_mock_db()
|
||||||
|
infl_rows = [
|
||||||
|
(date(2026, 3, 1), Decimal("5.86")),
|
||||||
|
(date(2026, 4, 1), Decimal("5.58")),
|
||||||
|
]
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(task_mod, "SessionLocal", return_value=db),
|
||||||
|
patch.object(task_mod, "fetch_key_rate", return_value=[]),
|
||||||
|
patch.object(task_mod, "fetch_inflation", return_value=infl_rows),
|
||||||
|
):
|
||||||
|
result = task_mod.cbr_macro_sync.run()
|
||||||
|
|
||||||
|
assert result["inflation"] == {"fetched": 2, "upserted": 2}
|
||||||
|
assert captured == [
|
||||||
|
{"d": date(2026, 3, 1), "v": Decimal("5.86")},
|
||||||
|
{"d": date(2026, 4, 1), "v": Decimal("5.58")},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_inflation_sql_contract() -> None:
|
||||||
|
"""UPSERT_INFLATION_SQL: indicator_type='inflation_yoy', monthly, CAST not ::."""
|
||||||
|
from app.workers.tasks.cbr_macro_sync import UPSERT_INFLATION_SQL
|
||||||
|
|
||||||
|
sql = str(UPSERT_INFLATION_SQL)
|
||||||
|
upper = sql.upper()
|
||||||
|
|
||||||
|
assert "INTO MACRO_INDICATOR" in upper
|
||||||
|
assert "'inflation_yoy'" in sql
|
||||||
|
assert "'rf'" in sql
|
||||||
|
assert "'cbr'" in sql
|
||||||
|
assert "'monthly'" in sql
|
||||||
|
assert "ON CONFLICT (INDICATOR_TYPE, REGION, OBS_DATE) DO UPDATE" in upper
|
||||||
|
assert "VALUE = EXCLUDED.VALUE" in upper
|
||||||
|
assert "UPDATED_AT = NOW()" in upper
|
||||||
|
# psycopg v3: CAST(:x AS type), НИКОГДА :x::type
|
||||||
|
assert "CAST(:D AS DATE)" in upper
|
||||||
|
assert "CAST(:V AS NUMERIC)" in upper
|
||||||
|
assert "::" not in sql
|
||||||
|
|
||||||
|
|
||||||
def test_task_registered_in_beat_weekly() -> None:
|
def test_task_registered_in_beat_weekly() -> None:
|
||||||
"""Задача зарегистрирована в beat как еженедельная (понедельник)."""
|
"""Задача зарегистрирована в beat как еженедельная (понедельник)."""
|
||||||
from app.workers.beat_schedule import build_beat_schedule
|
from app.workers.beat_schedule import build_beat_schedule
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue