gendesign/backend/app/services/scrapers/cbr_macro.py
Light1YT 25e21c2bff
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
feat(macro): CBR inflation (ИПЦ YoY) -> macro_indicator + activate §9.5 channel (#946)
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.
2026-06-08 11:41:14 +05:00

543 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""CBR (Банк России) macro indicators scraper — key rate + inflation (ИПЦ YoY).
Fills two ``macro_indicator`` series (both region='rf', source='cbr'), детерминированно,
без LLM:
• ``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)``.
⚠ Параметры называются ``fromDate`` и ``ToDate`` (вторая — с ЗАГЛАВНОЙ T,
так в WSDL). Оба типа ``xsd:dateTime`` — нужен суффикс времени
``T00:00:00``; голый ``YYYY-MM-DD`` сервис молча игнорит и возвращает
пустой ``KeyRateXMLResponse``. (Поэтому плоский GET-form
``/KeyRate?fromDate=..&toDate=..`` тоже не работает — у DailyInfoWebServ нет
HTTP-GET binding для этого метода, только SOAP.)
Response shape (``KeyRateXML`` — без diffgram-обёртки, в отличие от ``KeyRate``):
<KeyRateXMLResult>
<KeyRate>
<KR><DT>2023-08-15T00:00:00+03:00</DT><Rate>12.00</Rate></KR>
...
</KeyRate>
</KeyRateXMLResult>
Строки идут в обратном хронологическом порядке; ``parse_key_rate_xml``
сортировку не навязывает — отдаёт в порядке появления.
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
Mirror conventions: httpx.Client + явный User-Agent (как objective.py /
ekburg_permits.py), logger вместо print, типизированные сигнатуры.
"""
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__)
# ── constants ────────────────────────────────────────────────────────────────
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
# Полный бэкфилл истории ключевой ставки. ON CONFLICT в таске делает повторные
# прогоны идемпотентными, так что диапазон можно держать широким.
DEFAULT_FROM_DATE = date(2019, 1, 1)
# Инфляция ЦБ публикуется с 01.2013 (ИПЦ YoY). Широкий старт безопасен — ON CONFLICT
# в таске делает прогон идемпотентным.
DEFAULT_INFLATION_FROM_DATE = date(2013, 1, 1)
class CBRScraperError(RuntimeError):
"""Сетевая / протокольная ошибка обращения к DailyInfoWebServ."""
# ── SOAP envelope ──────────────────────────────────────────────────────────────
def _soap_envelope(from_date: date, to_date: date) -> str:
"""Собрать SOAP 1.2 envelope для ``KeyRateXML(fromDate, ToDate)``.
Даты сериализуются как ``YYYY-MM-DDT00:00:00`` — сервис принимает только
``xsd:dateTime`` (см. module docstring). ``ToDate`` — с заглавной T.
"""
return (
'<?xml version="1.0" encoding="utf-8"?>'
'<soap12:Envelope xmlns:soap12="http://www.w3.org/2003/05/soap-envelope">'
"<soap12:Body>"
f'<{CBR_KEYRATE_METHOD} xmlns="http://web.cbr.ru/">'
f"<fromDate>{from_date.isoformat()}T00:00:00</fromDate>"
f"<ToDate>{to_date.isoformat()}T00:00:00</ToDate>"
f"</{CBR_KEYRATE_METHOD}>"
"</soap12:Body>"
"</soap12:Envelope>"
)
# ── pure parse (unit-testable offline) ──────────────────────────────────────────
def _parse_dt(raw: str) -> date | None:
"""Распарсить ``<DT>`` (``2023-08-15T00:00:00+03:00``) в ``date``.
Берём только календарную дату; tz-суффикс игнорируем (ключевая ставка —
дневной показатель, время всегда полночь МСК). Поддерживаем и формы без
суффикса / с 'Z'.
"""
raw = (raw or "").strip()
if not raw:
return None
# Дата всегда в первых 10 символах ISO-8601 (YYYY-MM-DD...). Это устойчиво к
# любому tz-суффиксу (+03:00 / Z / отсутствию) без зависимости от Python <3.11
# fromisoformat ограничений.
head = raw[:10]
try:
return datetime.strptime(head, "%Y-%m-%d").date()
except ValueError:
logger.warning("CBR KeyRate: не удалось распарсить DT=%r", raw)
return None
def _parse_rate(raw: str) -> float | None:
"""Распарсить ``<Rate>`` в float. Принимаем и запятую как десятичный
разделитель (на всякий случай — фактически CBR отдаёт точку)."""
raw = (raw or "").strip().replace(",", ".")
if not raw:
return None
try:
# Через Decimal — чтобы не словить артефакты float-парсинга строки.
return float(Decimal(raw))
except (InvalidOperation, ValueError):
logger.warning("CBR KeyRate: не удалось распарсить Rate=%r", raw)
return None
def _localname(tag: str) -> str:
"""ElementTree-тег без namespace-префикса: ``{ns}KR`` → ``KR``."""
return tag.rsplit("}", 1)[-1] if "}" in tag else tag
def parse_key_rate_xml(xml: str) -> list[tuple[date, float]]:
"""Pure-парсер SOAP-ответа CBR KeyRate → отсортированный список (date, rate).
Находит все элементы ``<KR>`` где угодно в дереве (устойчиво и к чистому
``KeyRateXML``, и к diffgram-варианту ``KeyRate``, и к разным namespace'ам).
Из каждого берёт дочерние ``<DT>`` и ``<Rate>``.
Возвращает список ``(date, rate)``, отсортированный по возрастанию даты.
Пустой / битый / без KR XML → ``[]`` (не бросает) — единственное исключение
логируется как warning. Дубли по дате схлопываются (последний выигрывает).
"""
if not xml or not xml.strip():
return []
try:
root = ET.fromstring(xml)
except ET.ParseError as e:
logger.warning("CBR KeyRate: XML parse error: %s", e)
return []
by_date: dict[date, float] = {}
for kr in root.iter():
if _localname(kr.tag) != "KR":
continue
dt_val: date | None = None
rate_val: float | None = None
for child in kr:
name = _localname(child.tag)
if name == "DT":
dt_val = _parse_dt(child.text or "")
elif name == "Rate":
rate_val = _parse_rate(child.text or "")
if dt_val is not None and rate_val is not None:
by_date[dt_val] = rate_val
return sorted(by_date.items())
# ── HTTP fetch (thin) ───────────────────────────────────────────────────────────
def fetch_key_rate(
from_date: date | None = None,
to_date: date | None = None,
*,
timeout_s: float = DEFAULT_TIMEOUT_S,
retries: int = DEFAULT_RETRIES,
) -> list[tuple[date, Decimal]]:
"""Загрузить историю ключевой ставки ЦБ за период [from_date, to_date].
Args:
from_date: начало периода. По умолчанию ``DEFAULT_FROM_DATE`` (2019-01-01)
— полный бэкфилл истории.
to_date: конец периода. По умолчанию сегодня (UTC-date достаточно;
ставка дневная).
timeout_s: httpx timeout на запрос.
retries: число повторов на сетевых / 5xx ошибках (экспоненциальный backoff).
Returns:
Отсортированный по дате список ``(date, Decimal)``. Decimal — чтобы
отдать в numeric-колонку без потери точности.
Raises:
CBRScraperError: при сетевой ошибке / не-200 / SOAP fault после всех
повторов. Caller (Celery task) логирует и пробрасывает — surfaces
в GlitchTip.
"""
from_date = from_date or DEFAULT_FROM_DATE
to_date = to_date or datetime.now().date()
body = _soap_envelope(from_date, to_date)
headers = {
"Content-Type": "application/soap+xml; charset=utf-8",
"User-Agent": USER_AGENT,
}
last_exc: Exception | None = None
with httpx.Client(timeout=timeout_s, headers=headers) as client:
for attempt in range(retries + 1):
try:
resp = client.post(CBR_DAILYINFO_URL, content=body.encode("utf-8"))
except httpx.HTTPError as e:
last_exc = e
logger.warning(
"CBR KeyRate fetch network error (attempt %d/%d): %s",
attempt + 1,
retries + 1,
e,
)
else:
if resp.status_code == 200:
rows = parse_key_rate_xml(resp.text)
logger.info(
"CBR KeyRate fetched %d rows for [%s..%s]",
len(rows),
from_date.isoformat(),
to_date.isoformat(),
)
# Decimal(str(float)) — нормализуем через строку, чтобы
# numeric получил аккуратное значение (12.0 → '12.0').
return [(d, Decimal(str(v))) for d, v in rows]
# 5xx — повторяем; 4xx — нет смысла.
last_exc = CBRScraperError(
f"CBR DailyInfoWebServ HTTP {resp.status_code}: {resp.text[:300]}"
)
if resp.status_code < 500:
break
logger.warning(
"CBR KeyRate HTTP %d (attempt %d/%d)",
resp.status_code,
attempt + 1,
retries + 1,
)
# backoff перед следующей попыткой (не на последней итерации)
if attempt < retries:
import time
time.sleep(min(2**attempt, 10))
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. У ЦБ ряд есть, но стабильного
# машиночитаемого endpoint без авторизации / Excel-парсинга не нашлось
# (формы 0409316 публикуются как .xlsx с плавающей структурой листов —
# рискованно для детерминированного scraper). key_rate — must-have, закрыт.
# Ипотека deferrable: можно добавить отдельным fetch_*-методом по образцу
# ekburg_permits.parse_xlsx, когда определимся со стабильным источником.
# ── standalone smoke ────────────────────────────────────────────────────────────
def _main() -> None:
"""``python -m app.services.scrapers.cbr_macro`` — печатает несколько свежих
строк ключевой ставки для ручной верификации против живого cbr.ru."""
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
# Узкий диапазон (последние ~120 дней) — быстрый smoke без полного бэкфилла.
today = datetime.now().date()
rows = fetch_key_rate(from_date=today - timedelta(days=120), to_date=today)
logger.info("KeyRate rows fetched: %d", len(rows))
# Печатаем последние 5 (самые свежие) — sorted asc, поэтому хвост.
for d, v in rows[-5:]:
logger.info(" key_rate %s -> %s%%", d.isoformat(), v)
if not rows:
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__":
_main()