gendesign/backend/tests/scrapers/test_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

250 lines
9.9 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.

"""Тесты pure-парсеров CBR (offline, без живой сети).
Покрывают:
• 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 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, точка как десятичный разделитель.
FIXTURE_KEYRATE_XML = (
'<?xml version="1.0" encoding="utf-8"?>'
'<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">'
"<soap:Body>"
'<KeyRateXMLResponse xmlns="http://web.cbr.ru/">'
"<KeyRateXMLResult>"
'<KeyRate xmlns="">'
"<KR><DT>2023-09-15T00:00:00+03:00</DT><Rate>12.00</Rate></KR>"
"<KR><DT>2023-08-15T00:00:00+03:00</DT><Rate>12.00</Rate></KR>"
"<KR><DT>2023-08-14T00:00:00+03:00</DT><Rate>8.50</Rate></KR>"
"</KeyRate>"
"</KeyRateXMLResult>"
"</KeyRateXMLResponse>"
"</soap:Body>"
"</soap:Envelope>"
)
# Diffgram-вариант (метод KeyRate) — парсер должен его тоже понимать (находит KR
# где угодно), несмотря на schema-блок и namespace на KR.
FIXTURE_KEYRATE_DIFFGRAM = (
'<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">'
"<soap:Body><KeyRateResponse xmlns=\"http://web.cbr.ru/\"><KeyRateResult>"
'<diffgr:diffgram xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"'
' xmlns:diffgr="urn:schemas-microsoft-com:xml-diffgram-v1">'
'<KeyRate xmlns="">'
'<KR diffgr:id="KR1" msdata:rowOrder="0">'
"<DT>2023-09-29T00:00:00+03:00</DT><Rate>13.00</Rate></KR>"
'<KR diffgr:id="KR2" msdata:rowOrder="1">'
"<DT>2023-09-15T00:00:00+03:00</DT><Rate>12.00</Rate></KR>"
"</KeyRate></diffgr:diffgram></KeyRateResult></KeyRateResponse>"
"</soap:Body></soap:Envelope>"
)
def test_parse_basic_rows() -> None:
"""3 KR-строки → 3 кортежа (date, float), отсортированы по возрастанию даты."""
rows = parse_key_rate_xml(FIXTURE_KEYRATE_XML)
assert rows == [
(date(2023, 8, 14), 8.5),
(date(2023, 8, 15), 12.0),
(date(2023, 9, 15), 12.0),
]
def test_parse_returns_sorted_ascending() -> None:
"""Вход descending — выход обязан быть ascending по дате."""
rows = parse_key_rate_xml(FIXTURE_KEYRATE_XML)
dates = [d for d, _ in rows]
assert dates == sorted(dates)
def test_parse_strips_timezone_suffix() -> None:
"""DT с tz-суффиксом '+03:00' парсится в чистую дату (без сдвига)."""
rows = parse_key_rate_xml(FIXTURE_KEYRATE_XML)
assert (date(2023, 8, 15), 12.0) in rows
# значение float, не строка
assert all(isinstance(v, float) for _, v in rows)
def test_parse_value_types() -> None:
"""Дробная ставка 8.50 → 8.5 (float)."""
rows = dict(parse_key_rate_xml(FIXTURE_KEYRATE_XML))
assert rows[date(2023, 8, 14)] == 8.5
def test_parse_diffgram_variant() -> None:
"""Diffgram-обёртка (метод KeyRate) тоже парсится — KR находится в любом
namespace / вложенности."""
rows = parse_key_rate_xml(FIXTURE_KEYRATE_DIFFGRAM)
assert rows == [
(date(2023, 9, 15), 12.0),
(date(2023, 9, 29), 13.0),
]
def test_parse_empty_string() -> None:
assert parse_key_rate_xml("") == []
assert parse_key_rate_xml(" ") == []
def test_parse_empty_response_no_kr() -> None:
"""Валидный, но пустой KeyRateXMLResponse (период без данных) → []."""
xml = (
'<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">'
'<soap:Body><KeyRateXMLResponse xmlns="http://web.cbr.ru/" />'
"</soap:Body></soap:Envelope>"
)
assert parse_key_rate_xml(xml) == []
def test_parse_malformed_xml() -> None:
"""Битый XML → [] (не бросает)."""
assert parse_key_rate_xml("<KeyRate><KR><DT>2023") == []
def test_parse_skips_kr_with_missing_fields() -> None:
"""KR без DT или без Rate пропускается; валидные остаются."""
xml = (
"<KeyRate>"
"<KR><DT>2023-08-15T00:00:00+03:00</DT><Rate>12.00</Rate></KR>"
"<KR><DT>2023-08-16T00:00:00+03:00</DT></KR>" # нет Rate
"<KR><Rate>11.00</Rate></KR>" # нет DT
"</KeyRate>"
)
assert parse_key_rate_xml(xml) == [(date(2023, 8, 15), 12.0)]
def test_parse_dedups_by_date_last_wins() -> None:
"""Дубли по дате схлопываются — последний выигрывает."""
xml = (
"<KeyRate>"
"<KR><DT>2023-08-15T00:00:00+03:00</DT><Rate>12.00</Rate></KR>"
"<KR><DT>2023-08-15T00:00:00+03:00</DT><Rate>13.00</Rate></KR>"
"</KeyRate>"
)
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)]