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.
543 lines
27 KiB
Python
543 lines
27 KiB
Python
"""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()
|