"""CBR (Банк России) macro indicators scraper — key rate history.
Fills the ``key_rate`` series of the ``macro_indicator`` table (region='rf')
from the live CBR SOAP service ``DailyInfoWebServ``. Deterministic, no LLM.
Endpoint / method (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``):
2023-08-15T00:00:00+03:0012.00
...
Строки идут в обратном хронологическом порядке; ``parse_key_rate_xml``
сортировку не навязывает — отдаёт в порядке появления.
Парсинг вынесен в чистую ``parse_key_rate_xml`` (offline-тестируемую на
фикстуре); HTTP-обвязка ``fetch_key_rate`` тонкая (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 xml.etree import ElementTree as ET
import httpx
logger = logging.getLogger(__name__)
# ── constants ────────────────────────────────────────────────────────────────
CBR_DAILYINFO_URL = "https://www.cbr.ru/DailyInfoWebServ/DailyInfo.asmx"
# SOAP method that returns a clean ... structure (no ADO diffgram).
CBR_KEYRATE_METHOD = "KeyRateXML"
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)
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 (
''
''
""
f'<{CBR_KEYRATE_METHOD} xmlns="http://web.cbr.ru/">'
f"{from_date.isoformat()}T00:00:00"
f"{to_date.isoformat()}T00:00:00"
f"{CBR_KEYRATE_METHOD}>"
""
""
)
# ── pure parse (unit-testable offline) ──────────────────────────────────────────
def _parse_dt(raw: str) -> date | None:
"""Распарсить ```` (``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:
"""Распарсить ```` в 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).
Находит все элементы ```` где угодно в дереве (устойчиво и к чистому
``KeyRateXML``, и к diffgram-варианту ``KeyRate``, и к разным namespace'ам).
Из каждого берёт дочерние ```` и ````.
Возвращает список ``(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}")
# ── 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(" %s -> %s%%", d.isoformat(), v)
if not rows:
logger.warning("No rows returned — проверь endpoint/период.")
if __name__ == "__main__":
_main()