feat(macro): CBR inflation (ИПЦ YoY) -> macro_indicator + activate §9.5 channel (#946)
All checks were successful
CI / frontend-tests (pull_request) Has been skipped
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m45s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m42s
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
Deploy / deploy (push) Successful in 1m15s
CI / backend-tests (push) Successful in 6m30s
CI / backend-tests (pull_request) Successful in 6m32s
All checks were successful
CI / frontend-tests (pull_request) Has been skipped
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m45s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m42s
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
Deploy / deploy (push) Successful in 1m15s
CI / backend-tests (push) Successful in 6m30s
CI / backend-tests (pull_request) Successful in 6m32s
fedstat ИПЦ is reCAPTCHA-blocked; CBR publishes inflation openly. Add fetch_inflation + parse_inflation_xlsx (CBR UniDbQuery DownloadExcel/132934, monthly % г/г, region=rf, source=cbr) to cbr_macro.py; upsert indicator_type=inflation_yoy via the existing cbr_macro_sync task (per-series guard, SAVEPOINT-per-row, CAST not ::, ON CONFLICT on the PK). Surface inflation_yoy in MonthlyMacro (frozen, carry-forward) and ACTIVATE the reserved §9.5 inflation channel (macro_coefficient f_inflation: level-vs-4%-target nudge, non-positive to avoid double-counting f_rate, excluded from _RATE_DRIVEN_FACTORS). Channel was DEGRADED (no data) -> now BACKED + consumed; _CONF_HIGH_MIN_BACKED 4->5. Deterministic (§16/§26); renorm claims the reserved 0.08 slice as designed. Live-verified (2026-04 5.58%); 194 macro + 902 forecasting tests green. No migration, no new deps. Refs #946.
This commit is contained in:
parent
b0fe292a63
commit
25e21c2bff
8 changed files with 800 additions and 71 deletions
|
|
@ -22,9 +22,9 @@ PR. Цифры пригодны для explainability/прототипа, но
|
|||
+ W_MORTG_RATE · f_mortgage_rate(Δ mortgage_rate_weighted) # ↑ → <0
|
||||
+ W_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),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue