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/, когда оно на месте.
363 lines
18 KiB
Python
363 lines
18 KiB
Python
"""Парсер отчётов Сбера на обезличенных фикстурах (фаза 3).
|
||
|
||
Фикстуры — два отчёта по одному и тому же счёту: полный (11.02–17.09.2026) и августовский
|
||
(01.08–31.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")
|