Site Finder v2 / GG-форсайт. Deterministic, no LLM. Fills the key_rate series (empty after PR A) into macro_indicator from the live CBR source. - services/scrapers/cbr_macro.py: fetch_key_rate via CBR SOAP DailyInfoWebServ KeyRateXML(fromDate, ToDate) (POST soap+xml; ToDate capital-T + dateTime suffix required — flat GET returns empty). Pure parse_key_rate_xml (TZ-safe date, empty/malformed → []). Live-verified: 85 rows, current rate 14.5%. - workers/tasks/cbr_macro_sync.py: upsert (key_rate, rf, daily, %) into macro_indicator ON CONFLICT DO UPDATE; SAVEPOINT per row; fetch-fail → raise. - beat: cbr-macro-sync-weekly (Mon 05:30 МСК, Europe/Moscow tz). - 15 tests (pure parse + mocked upsert). No migration (table from #963 PR A).
282 lines
14 KiB
Python
282 lines
14 KiB
Python
"""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``):
|
||
<KeyRateXMLResult>
|
||
<KeyRate>
|
||
<KR><DT>2023-08-15T00:00:00+03:00</DT><Rate>12.00</Rate></KR>
|
||
...
|
||
</KeyRate>
|
||
</KeyRateXMLResult>
|
||
Строки идут в обратном хронологическом порядке; ``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 <KeyRate><KR>... 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 (
|
||
'<?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}")
|
||
|
||
|
||
# ── 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()
|