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

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:
Light1YT 2026-06-08 11:41:14 +05:00
parent b0fe292a63
commit 25e21c2bff
8 changed files with 800 additions and 71 deletions

View file

@ -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),
} }

View file

@ -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.

View file

@ -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__":

View file

@ -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()

View file

@ -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)]

View file

@ -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

View file

@ -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:

View file

@ -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