feat(reports): протокол и парсеры отчётов Сбера, ВТБ и Snowball CSV

sources/reports/base.py — контракт ReportParser (плагины: sniff/parse, чистые
функции без БД). registry.py выбирает парсер по содержимому файла, CSV
последним: он узнаёт файл по набору колонок и иначе перехватил бы чужой
формат.

Комиссия капитализируется в сделку, отдельным событием не эмитится: и Сбер,
и ВТБ печатают её дважды — колонками в сделках и строками в движении денег,
суммы совпадают, второе прочтение задвоило бы её. Расчётные строки («Сделка
от …», «Сальдо расчетов по сделкам») не эмитятся — это денежные ноги уже
учтённых сделок. У Сбера таблица «Информация о зачислениях на ИИС»
кумулятивна за календарный год и в леджер не идёт, иначе два пересекающихся
отчёта задвоили бы пополнения. CSV Snowball сводный по всем брокерам —
годится как сверка потоков и сделок на уровне портфеля, но не позиций по
счетам; CUSTOM_HOLDING_PRICE не событие, а цена — уходит в meta для
price_manual.

Обезличивание — anonymize.py + scripts/anonymize_reports.py, секреты
собираются по всему корпусу отчётов разом (Snowball цитирует номер договора
Сбера в примечании к переводу). test_fixtures_anonymized.py падает, если
в tests/fixtures/reports/ вне raw/ найдётся ИНН, ФИО или номер счёта — и по
форме (работает в CI без raw/), и по фактическому содержимому raw/, когда оно
на месте.
This commit is contained in:
Dmitry
2026-09-19 10:39:37 +03:00
parent 6b32c79e79
commit 2e742a093b
25 changed files with 7284 additions and 0 deletions
+362
View File
@@ -0,0 +1,362 @@
"""Парсер отчётов Сбера на обезличенных фикстурах (фаза 3).
Фикстуры — два отчёта по одному и тому же счёту: полный (11.02–17.09.2026) и августовский
(01.0831.08.2026). Пара выбрана не случайно: именно перекрывающиеся периоды ломают импорт,
если ключ дедупликации выводится из файла, а не из самой операции.
Главная проверка здесь — арифметическая. Отчёт печатает свои итоги («Итого, RUB» по
сделкам, «Пополнение счета» и «Исходящий остаток» в сводке), и распарсенные события обязаны
их воспроизвести: пополнения минус покупки плюс продажи = остаток на конец периода. Если
парсер потеряет строку, задвоит комиссию или примет расчётную строку за сделку, это
равенство разойдётся — а тест на «количество событий» этого не заметит.
"""
from __future__ import annotations
from collections import Counter
from decimal import Decimal
from pathlib import Path
import pytest
from fintracker.models.ledger import EventKind
from fintracker.sources.reports.base import BrokerEvent, ParsedReport
from fintracker.sources.reports.sber import SberHtmlParser, event_dedupe_key
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "reports"
FULL = FIXTURES / "sber" / "S930W42_11022026_17092026.html"
AUGUST = FIXTURES / "sber" / "S930W42_01082026_31082026.html"
ACCOUNT = "S930W42"
#: Второй договор того же брокера — источник переводов д/с, не внешний поток.
OTHER_AGREEMENT = "8184V30"
ZERO = Decimal(0)
@pytest.fixture(scope="module")
def parser() -> SberHtmlParser:
return SberHtmlParser()
@pytest.fixture(scope="module")
def full(parser: SberHtmlParser) -> ParsedReport:
return parser.parse(FULL.read_bytes(), FULL.name)
@pytest.fixture(scope="module")
def august(parser: SberHtmlParser) -> ParsedReport:
return parser.parse(AUGUST.read_bytes(), AUGUST.name)
def total(events: list[BrokerEvent], kind: EventKind) -> Decimal:
return sum((e.amount for e in events if e.kind == kind), ZERO)
def of_kind(report: ParsedReport, kind: EventKind) -> list[BrokerEvent]:
return [e for e in report.events if e.kind == kind]
# --- 1. распознавание формата ------------------------------------------------------------
def test_sniff_accepts_both_sber_reports(parser: SberHtmlParser) -> None:
assert parser.sniff(FULL.read_bytes(), FULL.name)
assert parser.sniff(AUGUST.read_bytes(), AUGUST.name)
@pytest.mark.parametrize(
"path",
sorted((FIXTURES / "vtb").glob("*.xlsx")) + sorted((FIXTURES / "snowball").glob("*.csv")),
ids=lambda p: p.suffix,
)
def test_sniff_rejects_other_formats(parser: SberHtmlParser, path: Path) -> None:
assert not parser.sniff(path.read_bytes(), path.name)
def test_sniff_never_raises_on_garbage(parser: SberHtmlParser) -> None:
"""`registry.pick` опрашивает парсеры подряд — падение на чужом файле остановило бы перебор."""
assert not parser.sniff(b"", "empty.html")
assert not parser.sniff(b"\x00\x01\x02not html at all", "junk.html")
# --- 2. шапка и состав ---------------------------------------------------------------------
def test_full_report_header(full: ParsedReport) -> None:
assert full.broker == "sber"
assert full.account_external_id == ACCOUNT
assert (full.period_from.isoformat(), full.period_to.isoformat()) == (
"2026-02-11",
"2026-09-17",
)
assert full.meta["opened_at"] == "2026-02-11"
def test_august_report_header(august: ParsedReport) -> None:
assert august.account_external_id == ACCOUNT
assert (august.period_from.isoformat(), august.period_to.isoformat()) == (
"2026-08-01",
"2026-08-31",
)
def test_full_report_event_counts(full: ParsedReport) -> None:
assert Counter(e.kind for e in full.events) == {
EventKind.buy: 24,
EventKind.deposit: 9,
EventKind.sell: 2,
}
def test_august_report_event_counts(august: ParsedReport) -> None:
assert Counter(e.kind for e in august.events) == {
EventKind.buy: 2,
EventKind.deposit: 1,
}
# --- 3. арифметика: события воспроизводят итоги самого отчёта -------------------------------
def test_trade_totals_match_the_reports_own_total_row(full: ParsedReport) -> None:
"""«Итого, RUB» таблицы сделок: 89 205.58 оборота, 138.03 брокеру, 19.54 бирже."""
trades = of_kind(full, EventKind.buy) + of_kind(full, EventKind.sell)
assert len(trades) == 26
# Оборот — это «Сумма» сделки без комиссии, а `amount` её уже включает: у покупки
# прибавляет, у продажи вычитает. Отсюда знаки при обратном пересчёте.
turnover = sum(
(-e.amount - (e.fee or ZERO) if e.kind == EventKind.buy else e.amount + (e.fee or ZERO))
for e in trades
)
assert turnover == Decimal("89205.58")
assert sum((e.fee or ZERO for e in full.events), ZERO) == Decimal("138.03") + Decimal("19.54")
def test_deposits_match_the_summary_line(full: ParsedReport) -> None:
"""«Пополнение счета» сводки считает и переводы с другого договора — как и парсер."""
assert total(full.events, EventKind.deposit) == Decimal("49120.87")
assert Decimal(full.meta["summary"]["Пополнение счета"]) == Decimal("49120.87")
def test_cash_closes_against_the_reported_balance(full: ParsedReport) -> None:
"""Сквозная проверка: сумма денежных эффектов всех событий = исходящий остаток.
Это единственная проверка, которая ловит и потерянную строку, и лишнюю: пропущенная
сделка занижает отток, а расчётная строка «Сделка от …», принятая за сделку, задваивает
его. Входящий остаток нулевой, поэтому сумма событий и есть конечный остаток.
"""
assert Decimal(full.meta["summary"]["Входящий остаток"]) == ZERO
assert sum((e.amount for e in full.events), ZERO) == Decimal("3171.34")
rub = next(c for c in full.cash_end if c.currency == "RUB")
assert rub.balance == Decimal("3171.34")
def test_august_cash_closes_from_its_own_opening_balance(august: ParsedReport) -> None:
opening = Decimal(august.meta["summary"]["Входящий остаток"])
assert opening == Decimal("357.15")
rub = next(c for c in august.cash_end if c.currency == "RUB")
assert opening + sum((e.amount for e in august.events), ZERO) == rub.balance
# --- 4. комиссии учтены ровно один раз -----------------------------------------------------
def test_commission_is_capitalised_and_never_emitted_separately(full: ParsedReport) -> None:
"""Комиссия живёт в `fee` сделки; отдельных `commission`-событий быть не должно.
`ledger/lots.py` капитализирует `fee` в стоимость лота, поэтому вторая, «денежная»
ипостась той же комиссии (строки «Комиссия Брокера от …» в движении денег) ушла бы в
леджер повторно — как отток, которого не было.
"""
assert not of_kind(full, EventKind.commission)
assert all(e.fee is None for e in full.events if e.kind == EventKind.deposit)
fees = sum((e.fee or ZERO for e in full.events), ZERO)
assert fees == Decimal("157.57")
assert Decimal(full.meta["summary"]["Комиссия брокера"]) == Decimal("-138.03")
assert Decimal(full.meta["summary"]["Комиссия биржи"]) == Decimal("-19.54")
def test_parser_reports_no_fee_divergence(full: ParsedReport, august: ParsedReport) -> None:
"""Парсер сам сверяет две ипостаси комиссии и предупреждает при расхождении."""
for report in (full, august):
assert not [w for w in report.warnings if "комиссии расходятся" in w]
# --- 5. дедупликация перекрывающихся периодов ----------------------------------------------
def keys(report: ParsedReport) -> dict[str, BrokerEvent]:
return {event_dedupe_key(report, e): e for e in report.events}
def test_august_events_keep_their_keys_in_the_full_report(
full: ParsedReport, august: ParsedReport
) -> None:
"""Каждое августовское событие должно прийти с тем же ключом из полного отчёта.
Иначе импорт двух пересекающихся отчётов задвоит август — ровно тот случай, который
план §1.6 A называет «перекрывающиеся периоды апсертят в ту же строку».
"""
full_keys, august_keys = keys(full), keys(august)
assert set(august_keys) <= set(full_keys), (
"у августовских событий появились ключи, которых нет в полном отчёте: "
f"{sorted(set(august_keys) - set(full_keys))}"
)
for key, event in august_keys.items():
twin = full_keys[key]
assert (event.kind, event.trade_date, event.quantity, event.price, event.amount) == (
twin.kind,
twin.trade_date,
twin.quantity,
twin.price,
twin.amount,
), f"ключ {key} указывает на разные операции"
def test_overlap_covers_every_august_operation(august: ParsedReport) -> None:
"""Перекрытие не должно оказаться пустым — иначе предыдущий тест проходит вхолостую."""
assert len(keys(august)) == len(august.events) == 3
def test_trades_key_on_the_brokers_deal_number(full: ParsedReport) -> None:
trades = of_kind(full, EventKind.buy) + of_kind(full, EventKind.sell)
assert all(e.trade_no for e in trades)
assert len({e.trade_no for e in trades}) == len(trades)
def test_cash_rows_have_stable_keys_without_a_deal_number(full: ParsedReport) -> None:
"""У денежных строк номера нет, и ключ обязан оставаться уникальным внутри отчёта."""
deposits = of_kind(full, EventKind.deposit)
assert all(e.trade_no is None for e in deposits)
assert len({event_dedupe_key(full, e) for e in deposits}) == len(deposits)
def test_reparsing_the_same_bytes_is_deterministic(parser: SberHtmlParser) -> None:
once = parser.parse(FULL.read_bytes(), FULL.name)
twice = parser.parse(FULL.read_bytes(), FULL.name)
assert [event_dedupe_key(once, e) for e in once.events] == [
event_dedupe_key(twice, e) for e in twice.events
]
# --- 6. закрывающие позиции и остатки ------------------------------------------------------
def test_closing_positions(full: ParsedReport) -> None:
assert len(full.positions_end) == 10
assert sum((p.market_value or ZERO for p in full.positions_end), ZERO) == Decimal("46663.30")
aeroflot = next(p for p in full.positions_end if p.instrument.isin == "RU0009062285")
assert aeroflot.qty == Decimal("130")
assert aeroflot.currency == "RUB"
def test_closing_cash_keeps_every_currency(full: ParsedReport) -> None:
balances = {c.currency: c.balance for c in full.cash_end}
assert balances == {"RUB": Decimal("3171.34"), "EUR": ZERO, "USD": ZERO}
def test_august_closing_snapshot(august: ParsedReport) -> None:
assert len(august.positions_end) == 5
assert sum((p.market_value or ZERO for p in august.positions_end), ZERO) == Decimal("38739.43")
assert next(c for c in august.cash_end if c.currency == "RUB").balance == Decimal("1407.88")
# --- 7. справочник инструментов ------------------------------------------------------------
def test_securities_directory_feeds_instruments(full: ParsedReport) -> None:
assert len(full.instruments) >= 9
assert all(i.isin and i.ticker for i in full.instruments)
assert all(i.asset_class_hint for i in full.instruments)
sber = next(i for i in full.instruments if i.ticker == "SBER")
assert (sber.isin, sber.asset_class_hint) == ("RU0009029540", "share")
fund = next(i for i in full.instruments if i.ticker == "STME")
assert fund.asset_class_hint in {"etf", "fund"}
def test_trades_reference_instruments_by_isin(full: ParsedReport) -> None:
"""Сделка печатает только тикер — ISIN подставляется из справочника того же файла."""
trades = of_kind(full, EventKind.buy) + of_kind(full, EventKind.sell)
assert all(e.instrument is not None and e.instrument.isin for e in trades)
# --- 8. ловушки формата --------------------------------------------------------------------
def test_iis_contributions_table_is_read_but_not_emitted(
full: ParsedReport, august: ParsedReport
) -> None:
"""Таблица зачислений на ИИС кумулятивна за ГОД, а не за период отчёта.
Августовский файл перечисляет в ней восемь пополнений начиная с февраля — всё, что
случилось до даты его формирования (01.09.2026); в полном отчёте их девять, добавилось
сентябрьское. То есть таблица растёт от отчёта к отчёту независимо от периода, и если
бы парсер эмитил её строки, импорт августовского файла поверх полного задвоил бы
пополнения за полгода. Поэтому её содержимое только пересчитывается, в события не идёт:
августовский отчёт даёт ровно одно денежное поступление — то, что реально было в августе.
"""
assert full.meta["iis_contributions_ignored"] == 9
assert august.meta["iis_contributions_ignored"] == 8
assert len(of_kind(august, EventKind.deposit)) == 1
def test_settlement_lines_are_not_mistaken_for_trades(full: ParsedReport) -> None:
"""«Сделка от DD.MM.YYYY» в движении денег — расчёт по уже учтённой сделке."""
assert not [e for e in full.events if (e.description or "").startswith("Сделка от")]
def test_transfer_from_another_agreement_keeps_its_counterparty(full: ParsedReport) -> None:
"""Перевод с другого договора Сбера — deposit, но с сохранённым источником.
Без `internal_transfer_from` эти деньги навсегда останутся внешним потоком: заведи
пользователь второй договор счётом, XIRR увидел бы пополнение здесь и вывод там как два
независимых события.
"""
transfers = [
e
for e in of_kind(full, EventKind.deposit)
if e.meta.get("internal_transfer_from") == OTHER_AGREEMENT
]
assert len(transfers) == 2
assert sum((e.amount for e in transfers), ZERO) == Decimal("3458.88") + Decimal("10551.18")
def test_missing_coupon_section_is_reported_not_assumed(full: ParsedReport) -> None:
"""Раздела «Купонный доход» в этих отчётах нет — это факт для data quality, не молчание."""
assert any("Купонный доход" in w for w in full.warnings)
# --- 9. деньги --------------------------------------------------------------------------------
def numbers(report: ParsedReport) -> list[object]:
values: list[object] = []
for e in report.events:
values += [e.amount, e.quantity, e.price, e.fee, e.tax, e.accrued_interest]
for p in report.positions_end:
values += [p.qty, p.price, p.market_value, p.accrued_interest]
values += [c.balance for c in report.cash_end]
return values
@pytest.mark.parametrize("name", ["full", "august"])
def test_no_floats_anywhere(name: str, request: pytest.FixtureRequest) -> None:
"""Деньги — только Decimal (AGENTS.md). Один float здесь означает потерю копеек ниже."""
report: ParsedReport = request.getfixturevalue(name)
assert not [v for v in numbers(report) if isinstance(v, float)]
assert all(isinstance(v, Decimal) for v in numbers(report) if v is not None)
def test_every_event_carries_currency_and_traceability(full: ParsedReport) -> None:
for event in full.events:
assert event.currency == "RUB"
assert event.raw_line_no > 0
assert event.meta.get("section")