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_ISSUANCE · f_issuance(Δ count & Δ volume ИЖК) # выдачи↓ → <0
+ W_OVERDUE · f_overdue(overdue / debt) # доля↑ → <0
+ W_INFLATION · f_inflation(inflation_yoy цель) # выше цели → <0 (#946)
+ W_GOVSUPPORT · None # DEGRADED — нет поля в MonthlyMacro → не участвует
+ W_INCOME · None # DEGRADED
+ W_INFLATION · None # DEGRADED
+ W_CONFIDENCE · None, # DEGRADED
_MACRO_COEF_MIN, _MACRO_COEF_MAX)
@ -41,13 +41,14 @@ f_mortgage_rate — оба про стоимость заёмных денег)
comfort/comfort+ / быстрой нарезки КРУЧЕ-позитивно на rate (+ господдержка).
Реализовано простым набором правил с именованными константами-крутизнами (НЕ фит).
BACKED vs DEGRADED входы (сверено с полями MonthlyMacro PR2):
BACKED vs DEGRADED входы (сверено с полями MonthlyMacro):
BACKED : f_rate (key_rate), f_mortgage_rate (mortgage_rate_weighted),
f_issuance (mortgage_issued_count + _volume), f_overdue
(mortgage_overdue / mortgage_debt).
DEGRADED : gov-support / income / cpi / confidence этих рядов в MonthlyMacro
НЕТ (income/cpi не backfilled, см. macro_series docstring; gov-support
и consumer-confidence вообще не собираются). Они зарезервированы в
(mortgage_overdue / mortgage_debt), f_inflation (inflation_yoy, #946 —
ИПЦ YoY ЦБ, залит cbr_macro_sync; УРОВЕНЬ относительно цели 4%).
DEGRADED : gov-support / income / confidence этих рядов в MonthlyMacro НЕТ
(income не backfilled, см. macro_series docstring; gov-support и
consumer-confidence вообще не собираются). Они зарезервированы в
формуле/весах для будущего, но СЕЙЧАС всегда contribute None
выпадают из renorm. Помечаем их явно unavailable, не молчаливый 0.
@ -100,15 +101,15 @@ _TREND_WINDOW_MONTHS: int = 6
# Заданы в ИСХОДНОМ (полном) наборе из 8 каналов; renorm делит на сумму ДОСТУПНЫХ.
# Backed-каналы (rate/mortgage_rate/issuance/overdue) несут основную массу: ставка и
# стоимость/доступность ипотеки — доминирующий драйвер первичного спроса в РФ.
# Degraded-каналы (gov/income/cpi/confidence) имеют НЕнулевые веса в схеме (резерв
# Degraded-каналы (gov/income/confidence) имеют НЕнулевые веса в схеме (резерв
# под будущие ряды), но СЕЙЧАС всегда None → в renorm не попадают.
_W_RATE: float = 0.18 # ключевая ставка — главный канал (стоимость денег)
_W_MORTG_RATE: float = 0.12 # ИЖК-ставка — прямой канал доступности ипотеки
_W_ISSUANCE: float = 0.10 # объём/число выдач ИЖК — фактический спрос-прокси
_W_OVERDUE: float = 0.05 # просрочка/долг — стресс заёмщиков (медленный канал)
_W_INFLATION: float = 0.08 # BACKED (#946) — ИПЦ YoY, уровень относительно цели
_W_GOVSUPPORT: float = 0.20 # DEGRADED (нет ряда) — крупный вес-резерв (льготы)
_W_INCOME: float = 0.12 # DEGRADED — реальные доходы
_W_INFLATION: float = 0.08 # DEGRADED — ИПЦ
_W_CONFIDENCE: float = 0.15 # DEGRADED — потребит. уверенность
# Имена sub-factors (ключи breakdown / available / weights). Держим как константы,
@ -137,9 +138,9 @@ _WEIGHTS: dict[str, float] = {
# Каналы, для которых СЕЙЧАС нет ряда в MonthlyMacro → всегда contribute None.
# Зарезервированы в _WEIGHTS под будущие данные; помечаем unavailable явно.
_DEGRADED_FACTORS: frozenset[str] = frozenset(
{_F_GOVSUPPORT, _F_INCOME, _F_INFLATION, _F_CONFIDENCE}
)
# inflation БОЛЬШЕ НЕ здесь (#946): ряд inflation_yoy залит cbr_macro_sync и
# присутствует в MonthlyMacro → канал стал BACKED (f_inflation).
_DEGRADED_FACTORS: frozenset[str] = frozenset({_F_GOVSUPPORT, _F_INCOME, _F_CONFIDENCE})
# Rate-driven каналы — к ним применяется сегментный модификатор крутизны (оба про
# стоимость заёмных денег: ставка ЦБ + ИЖК-ставка).
@ -170,6 +171,18 @@ _OVERDUE_RATIO_FULL: float = 0.05
# «Нейтральная» доля просрочки: ниже неё канал не давит (здоровый портфель). 1%.
_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 нуджей) ────────
# Базовая крутизна 1.0 (нейтральный сегмент). Чувствительные форматы получают
# >1.0 (круче реагируют на канал стоимости денег — в ОБЕ стороны: сильнее падают
@ -194,10 +207,11 @@ _STEEP_MAX: float = 1.8
# ── Named-константы: confidence ───────────────────────────────────────────────
# Confidence падает, когда мало backed-входов доступно ИЛИ окно конфаундено.
# 'high' — доступны ВСЕ 4 backed-канала и окно не шок.
# 'high' — доступны ВСЕ 5 backed-каналов (rate/mortgage_rate/issuance/overdue/
# inflation, #946) и окно не шок.
# 'medium' — доступно ≥ _CONF_MEDIUM_MIN_BACKED backed-каналов (частичный сигнал).
# '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-каналов
@ -332,6 +346,37 @@ def f_overdue(overdue: float | None, debt: float | None) -> float | None:
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(
values: list[float | None], *, window: int = _TREND_WINDOW_MONTHS
) -> float | None:
@ -667,8 +712,8 @@ def compute_macro_coefficient(
Шаги:
1. Тянем monthly макро-ряд (PR2). Пустой coef=1.0, confidence='low'.
2. Считаем 4 BACKED sub-factors (rate / mortgage_rate / issuance / overdue);
degraded каналы (gov/income/cpi/confidence) None (нет ряда в MonthlyMacro).
2. Считаем 5 BACKED sub-factors (rate / mortgage_rate / issuance / overdue /
inflation, #946); degraded каналы (gov/income/confidence) → None (нет ряда).
3. Применяем сегментную крутизну к rate-driven нуджам (rate + mortgage_rate).
4. Ренормализуем веса по ДОСТУПНЫМ каналам (выпавшие не тянут coef к 1.0).
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]:
"""Посчитать 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.
issuance : относит. тренд count & volume выдач f_issuance.
overdue : последняя доля overdue/debt f_overdue.
inflation : последний уровень inflation_yoy относительно цели f_inflation (#946).
"""
key_rate = [m.key_rate 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]
overdue_series = [m.mortgage_overdue 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-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 {
_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),
),
_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 (переиспользуем):
key_rate region 'rf', ДНЕВНОЙ ряд, source 'cbr' (backfill PR B
#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 нормализован к
1-му числу через make_date(...,1)). Берём как есть.
(income/cpi НЕ backfilled на них не опираемся.)
(income НЕ backfilled на него не опираемся.)
Соглашение по дате: каждый MonthlyMacro.month ПЕРВОЕ число месяца (YYYY-MM-01).
Это зеркалит нормализацию mortgage-рядов в backfill (make_date(year, month, 1)),
@ -100,11 +104,15 @@ class MonthlyMacro:
mortgage_issued_volume: float | None # объём выданных ИЖК, млн руб
mortgage_debt: float | None # задолженность по ИЖК, млн руб
mortgage_overdue: float | None # просроченная задолженность по ИЖК, млн руб
# #946: инфляция «% г/г» (ИПЦ YoY), region 'rf'. Поле с дефолтом None и в КОНЦЕ —
# чтобы существующие kwargs-конструкторы (тесты/демо), не знающие о поле, не падали.
inflation_yoy: float | None = None
def as_dict(self) -> dict[str, Any]:
return {
"month": self.month.isoformat(),
"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_issued_count": _round_or_none(self.mortgage_issued_count, 0),
"mortgage_issued_volume": _round_or_none(self.mortgage_issued_volume, 1),
@ -312,24 +320,38 @@ def get_monthly_macro(
return []
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)
# key_rate: разворачиваем на сетку + carry-forward по сетке (LOCF).
raw_rates: list[float | None] = [key_rate_by_month.get(m) for m in grid]
filled_rates = _carry_forward(raw_rates)
# 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] = []
for idx, month in enumerate(grid):
kwargs: dict[str, float | None] = {
field: mortgage_by_field.get(field, {}).get(month) for _itype, field in _MORTGAGE_FIELDS
}
rows.append(MonthlyMacro(month=month, key_rate=filled_rates[idx], **kwargs))
rows.append(
MonthlyMacro(
month=month,
key_rate=filled_rates[idx],
inflation_yoy=filled_infl[idx],
**kwargs,
)
)
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,
len(grid),
len(key_rate_by_month),
len(inflation_by_month),
)
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}
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]]:
"""Месячные 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')
from the live CBR SOAP service ``DailyInfoWebServ``. Deterministic, no LLM.
Fills two ``macro_indicator`` series (both region='rf', source='cbr'), детерминированно,
без 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
SOAP 1.2 action ``KeyRateXML(fromDate, ToDate)``.
@ -24,8 +35,22 @@ Response shape (``KeyRateXML`` — без diffgram-обёртки, в отлич
Строки идут в обратном хронологическом порядке; ``parse_key_rate_xml``
сортировку не навязывает отдаёт в порядке появления.
Парсинг вынесен в чистую ``parse_key_rate_xml`` (offline-тестируемую на
фикстуре); HTTP-обвязка ``fetch_key_rate`` тонкая (httpx, timeout, UA, retry).
Endpoint / method (INFLATION verified live 2026-06-07/08):
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):
python -m app.services.scrapers.cbr_macro
@ -39,9 +64,12 @@ from __future__ import annotations
import logging
from datetime import date, datetime, timedelta
from decimal import Decimal, InvalidOperation
from io import BytesIO
from typing import Any
from xml.etree import ElementTree as ET
import httpx
from openpyxl import load_workbook
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).
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"
DEFAULT_TIMEOUT_S = 30.0
DEFAULT_RETRIES = 3
@ -59,6 +95,10 @@ DEFAULT_RETRIES = 3
# прогоны идемпотентными, так что диапазон можно держать широким.
DEFAULT_FROM_DATE = date(2019, 1, 1)
# Инфляция ЦБ публикуется с 01.2013 (ИПЦ YoY). Широкий старт безопасен — ON CONFLICT
# в таске делает прогон идемпотентным.
DEFAULT_INFLATION_FROM_DATE = date(2013, 1, 1)
class CBRScraperError(RuntimeError):
"""Сетевая / протокольная ошибка обращения к DailyInfoWebServ."""
@ -250,6 +290,219 @@ def fetch_key_rate(
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') ───────────────────────────────
# TODO(#945): национальная средневзвешенная ставка по ипотеке (region='rf').
# Намеренно НЕ реализовано в этом PR. У ЦБ ряд есть, но стабильного
@ -273,9 +526,17 @@ def _main() -> None:
logger.info("KeyRate rows fetched: %d", len(rows))
# Печатаем последние 5 (самые свежие) — sorted asc, поэтому хвост.
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:
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__":

View file

@ -1,18 +1,27 @@
"""Celery task: синхронизация макро-показателей ЦБ (ключевая ставка) в
``macro_indicator`` (#945 PR B, GG-форсайт / Site Finder v2).
"""Celery task: синхронизация макро-показателей ЦБ (ключевая ставка + инфляция) в
``macro_indicator`` (#945 PR B / #946, GG-форсайт / Site Finder v2).
Тянет историю ключевой ставки через ``fetch_key_rate`` (живой SOAP CBR
DailyInfoWebServ) и апсертит каждую (дату, ставку) в ``macro_indicator`` как
ряд ``indicator_type='key_rate'``, ``region='rf'``, ``source='cbr'``.
Тянет два ряда ЦБ (оба ``region='rf'``, ``source='cbr'``):
``key_rate`` ключевая ставка через ``fetch_key_rate`` (живой SOAP 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 делает повторные прогоны идемпотентными
(re-run обновляет value + updated_at). Расписание еженедельно (ключевая
ставка меняется редко), регистрируется в beat_schedule.py.
(re-run обновляет value + updated_at). Расписание еженедельно (и ставка, и
инфляция меняются медленно), регистрируется в beat_schedule.py.
Per-series guard: сбой fetch'а инфляции НЕ мешает ставке (и наоборот) — каждый ряд
апсертится независимо, но первая возникшая ошибка ПРОБРАСЫВАЕТСЯ в конце (surfaces
в Celery/GlitchTip), не глотается. Зеркалит per-source-дисциплину rosstat_macro_sync.
Mirror conventions: ``SessionLocal()`` + try/finally close, ``logger`` (не
print), SAVEPOINT per-row в цикле upsert (backend.md), ``CAST(:x AS type)``
никогда ``:x::type`` (psycopg v3). На fetch-фейле логируем и пробрасываем
(surfaces в Celery/GlitchTip), не глотаем.
никогда ``:x::type`` (psycopg v3).
"""
from __future__ import annotations
@ -26,7 +35,7 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
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
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:
"""Апсертит (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
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(
bind=True,
name="tasks.cbr_macro_sync.cbr_macro_sync",
@ -76,40 +117,69 @@ def cbr_macro_sync(
from_date: str | None = None,
to_date: str | None = None,
) -> dict[str, Any]:
"""Загрузить историю ключевой ставки ЦБ и апсертить в macro_indicator.
"""Загрузить ряды ЦБ (ключевая ставка + инфляция YoY) и апсертить в macro_indicator.
Per-series guard (зеркало rosstat_macro_sync): сбой одного ряда не блокирует
другой оба пытаются fetch+upsert независимо; первая возникшая ошибка
пробрасывается в конце (surfaces в Celery/GlitchTip), не глотается.
Args:
from_date: ISO-дата начала периода ('YYYY-MM-DD'). None дефолт
scraper'а (2019-01-01, полный бэкфилл).
from_date: ISO-дата начала периода ('YYYY-MM-DD'). None дефолт КАЖДОГО
scraper'а (key_rate 2019-01-01, inflation 2013-01-01; полный бэкфилл).
to_date: ISO-дата конца периода. None сегодня.
Returns:
Счётчики ``{"fetched": N, "upserted": M, "from_date": ..., "to_date": ...}``.
Счётчики. ``fetched``/``upserted`` на верхнем уровне по ключевой ставке
(обратная совместимость); ``key_rate`` и ``inflation`` пер-ряд под-счётчики.
Raises:
Пробрасывает любую ошибку fetch'а (CBRScraperError и пр.) — чтобы фейл
был виден в Celery / GlitchTip, а не молча проглочен.
Пробрасывает первую ошибку fetch'а (CBRScraperError и пр.) после попытки
обоих рядов чтобы фейл был виден в Celery / GlitchTip, а не молча проглочен.
"""
fd = date.fromisoformat(from_date) if from_date else None
td = date.fromisoformat(to_date) if to_date else None
db = SessionLocal()
try:
rows = fetch_key_rate(from_date=fd, to_date=td)
fetched = len(rows)
logger.info("cbr_macro_sync: fetched %d key_rate rows", fetched)
first_error: Exception | None = None
upserted = _upsert_key_rate(db, rows)
logger.info("cbr_macro_sync: upserted %d/%d key_rate rows", upserted, fetched)
# ── key_rate (дневной, SOAP) ──────────────────────────────────────────
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 {
"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,
"to_date": td.isoformat() if td else None,
}
except Exception as e:
logger.exception("cbr_macro_sync failed: %s", e)
raise
finally:
db.close()

View file

@ -1,15 +1,22 @@
"""Тесты pure-парсера CBR KeyRate (offline, без живой сети).
"""Тесты pure-парсеров CBR (offline, без живой сети).
Покрывают: парсинг (date, value), обработку tz-суффикса, сортировку,
пустой / битый / без-KR XML []. Фикстуры из реального формата
KeyRateXML-ответа DailyInfoWebServ (verified live 2026-06-02).
Покрывают:
parse_key_rate_xml парсинг (date, value), tz-суффикс, сортировка, пустой /
битый / без-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 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 по дате,
# tz-суффикс +03:00, точка как десятичный разделитель.
@ -129,3 +136,115 @@ def test_parse_dedups_by_date_last_wins() -> None:
"</KeyRate>"
)
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 (
_DEGRADED_FACTORS,
_F_INFLATION,
_F_ISSUANCE,
_F_MORTG_RATE,
_F_OVERDUE,
@ -40,6 +41,7 @@ from app.services.forecasting.macro_coefficient import (
MacroCoefficient,
assemble_coefficient,
compute_macro_coefficient,
f_inflation,
f_issuance,
f_mortgage_rate,
f_overdue,
@ -75,6 +77,7 @@ def _macro(
issued_volume: list[float | None] | None = None,
debt: list[float | None] | None = None,
overdue: list[float | None] | None = None,
inflation: list[float | None] | None = None,
) -> list[MonthlyMacro]:
"""Список MonthlyMacro; невыставленные ряды → все None (degraded-вход)."""
n = len(months)
@ -85,6 +88,7 @@ def _macro(
iv = issued_volume if issued_volume 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
infl = inflation if inflation is not None else none_n
out: list[MonthlyMacro] = []
for i, month in enumerate(months):
out.append(
@ -96,6 +100,7 @@ def _macro(
mortgage_issued_volume=iv[i],
mortgage_debt=db_[i],
mortgage_overdue=od[i],
inflation_yoy=infl[i],
)
)
return out
@ -198,6 +203,33 @@ class TestFOverdue:
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 ───────────────────────────────────────────────────
@ -349,6 +381,37 @@ class TestComputeMacroCoefficient:
assert _F_MORTG_RATE in out.unavailable_inputs
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:
# Ставка падает + выдачи растут → поддерживающий режим → coef > 1, и для
# favored-сегмента (семейный/компакт) подъём КРУЧЕ (сегмент-модификатор).
@ -392,7 +455,7 @@ class TestComputeMacroCoefficient:
assert expensive.coefficient < neutral.coefficient
def test_all_backed_available_with_clean_window_high(self) -> None:
# Все 4 backed-канала есть + окно без шок-дат → confidence='high'.
# Все 5 backed-каналов есть (включая inflation, #946) + окно без шок-дат → 'high'.
n = 12
months = _months(n, end=dt.date(2023, 12, 1)) # 2023 без шок-дат PR2
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)]
debt = [2_000_000.0 + i * 1000 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(
months,
key_rate=key_rate,
@ -409,13 +473,14 @@ class TestComputeMacroCoefficient:
issued_volume=issued_volume,
debt=debt,
overdue=overdue,
inflation=inflation,
)
with patch(_MACRO, return_value=macro):
out = compute_macro_coefficient(MagicMock(), segment_profile={})
assert out.confidence == "high"
assert out.confounded is False
# Все backed доступны; все degraded — нет.
for name in (_F_RATE, _F_MORTG_RATE, _F_ISSUANCE, _F_OVERDUE):
# Все backed доступны (включая inflation); все degraded — нет.
for name in (_F_RATE, _F_MORTG_RATE, _F_ISSUANCE, _F_OVERDUE, _F_INFLATION):
assert name in out.available_inputs
for name in _DEGRADED_FACTORS:
assert name in out.unavailable_inputs

View file

@ -261,10 +261,12 @@ class TestAsDict:
mortgage_issued_volume=98765.4321,
mortgage_debt=123456.789,
mortgage_overdue=234.5678,
inflation_yoy=5.876543,
)
d = m.as_dict()
assert d["month"] == "2024-03-01"
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_issued_count"] == 1235 # round to 0 digits
assert d["mortgage_issued_volume"] == 98765.4
@ -276,6 +278,7 @@ class TestAsDict:
d = m.as_dict()
assert d["month"] == "2024-03-01"
assert d["key_rate"] is None
assert d["inflation_yoy"] is None
assert d["mortgage_rate_weighted"] is None
assert d["mortgage_debt"] is None
@ -330,13 +333,20 @@ class TestGetMonthlyMacroShape:
db.execute.return_value = _key_rate_result([])
with patch(_GET_SERIES, return_value=[]) as gs:
get_monthly_macro(db, months_back=3)
# Все mortgage-ряды читаются регионом 'sverdl'.
# MORTGAGE-ряды читаются регионом 'sverdl' (inflation_yoy — отдельно 'rf', ниже).
assert gs.called
for call in gs.call_args_list:
assert call.kwargs["region"] == "sverdl"
read_types = {call.args[1] for call in gs.call_args_list}
assert "mortgage_rate_weighted" in read_types
assert "mortgage_issued_count" in read_types
region_by_type = {call.args[1]: call.kwargs["region"] for call in gs.call_args_list}
assert region_by_type.get("mortgage_rate_weighted") == "sverdl"
assert region_by_type.get("mortgage_issued_count") == "sverdl"
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:
@ -392,6 +402,44 @@ class TestGetMonthlyMacroLogic:
other = next(m for m in out if m.month != target)
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:
def test_empty_table_returns_rows_with_none(self) -> None:

View file

@ -1,8 +1,8 @@
"""Тесты Celery-таски cbr_macro_sync (mock session + mock fetch, без живой сети).
Покрывают: контракт upsert-SQL (indicator_type='key_rate', region='rf',
source='cbr', ON CONFLICT, CAST not ::), счётчики fetched/upserted, проброс
ошибки fetch'а.
Покрывают: контракт upsert-SQL обоих рядов (key_rate + inflation_yoy, region='rf',
source='cbr', ON CONFLICT, CAST not ::), счётчики fetched/upserted (пер-ряд),
per-series guard (сбой одного ряда не мешает другому, но пробрасывается).
"""
from __future__ import annotations
@ -71,12 +71,16 @@ def test_task_upserts_each_row_with_correct_params() -> None:
with (
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_inflation", return_value=[]) as mock_infl,
):
result = task_mod.cbr_macro_sync.run()
mock_fetch.assert_called_once()
mock_infl.assert_called_once()
assert result["fetched"] == 2
assert result["upserted"] == 2
assert result["key_rate"] == {"fetched": 2, "upserted": 2}
assert result["inflation"] == {"fetched": 0, "upserted": 0}
# параметры каждого upsert
assert captured == [
{"d": date(2023, 8, 14), "v": Decimal("8.5")},
@ -95,10 +99,13 @@ def test_task_passes_date_range_to_fetch() -> None:
with (
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_inflation", return_value=[]) as mock_infl,
):
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))
# период прокидывается и в 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["to_date"] == "2020-12-31"
assert result["fetched"] == 0
@ -106,7 +113,7 @@ def test_task_passes_date_range_to_fetch() -> None:
def test_task_reraises_fetch_failure() -> None:
"""Ошибка fetch'а пробрасывается (surfaces в Celery/GlitchTip), не глотается.
"""Ошибка fetch'а ставки пробрасывается (surfaces в Celery/GlitchTip), не глотается.
DB-сессия при этом закрывается."""
from app.workers.tasks import cbr_macro_sync as task_mod
@ -115,6 +122,7 @@ def test_task_reraises_fetch_failure() -> None:
with (
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_inflation", return_value=[]),
):
try:
task_mod.cbr_macro_sync.run()
@ -125,6 +133,74 @@ def test_task_reraises_fetch_failure() -> None:
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:
"""Задача зарегистрирована в beat как еженедельная (понедельник)."""
from app.workers.beat_schedule import build_beat_schedule