diff --git a/backend/app/services/forecasting/macro_coefficient.py b/backend/app/services/forecasting/macro_coefficient.py index 04017933..4fe60ccd 100644 --- a/backend/app/services/forecasting/macro_coefficient.py +++ b/backend/app/services/forecasting/macro_coefficient.py @@ -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), } diff --git a/backend/app/services/forecasting/macro_series.py b/backend/app/services/forecasting/macro_series.py index 2008d1ea..fd8e3874 100644 --- a/backend/app/services/forecasting/macro_series.py +++ b/backend/app/services/forecasting/macro_series.py @@ -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. diff --git a/backend/app/services/scrapers/cbr_macro.py b/backend/app/services/scrapers/cbr_macro.py index f4de52e8..6a0bd042 100644 --- a/backend/app/services/scrapers/cbr_macro.py +++ b/backend/app/services/scrapers/cbr_macro.py @@ -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 ... 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__": diff --git a/backend/app/workers/tasks/cbr_macro_sync.py b/backend/app/workers/tasks/cbr_macro_sync.py index 87f2ad25..441612af 100644 --- a/backend/app/workers/tasks/cbr_macro_sync.py +++ b/backend/app/workers/tasks/cbr_macro_sync.py @@ -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() diff --git a/backend/tests/scrapers/test_cbr_macro.py b/backend/tests/scrapers/test_cbr_macro.py index 5405c713..5b03f0c3 100644 --- a/backend/tests/scrapers/test_cbr_macro.py +++ b/backend/tests/scrapers/test_cbr_macro.py @@ -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: "" ) 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)] diff --git a/backend/tests/services/forecasting/test_macro_coefficient.py b/backend/tests/services/forecasting/test_macro_coefficient.py index 446fc377..4ce63164 100644 --- a/backend/tests/services/forecasting/test_macro_coefficient.py +++ b/backend/tests/services/forecasting/test_macro_coefficient.py @@ -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 diff --git a/backend/tests/services/forecasting/test_macro_series.py b/backend/tests/services/forecasting/test_macro_series.py index f22736b1..8ecc6093 100644 --- a/backend/tests/services/forecasting/test_macro_series.py +++ b/backend/tests/services/forecasting/test_macro_series.py @@ -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: diff --git a/backend/tests/workers/test_cbr_macro_sync.py b/backend/tests/workers/test_cbr_macro_sync.py index 41741bf9..2b09c51f 100644 --- a/backend/tests/workers/test_cbr_macro_sync.py +++ b/backend/tests/workers/test_cbr_macro_sync.py @@ -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