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.
250 lines
9.9 KiB
Python
250 lines
9.9 KiB
Python
"""Тесты 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)]
|