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
+420
View File
@@ -0,0 +1,420 @@
"""The universal event-CSV parser, checked against the real Snowball export.
The fixture is the only file in the project that covers every brokerage account at once,
so these tests double as its documentation: the exact number of rows of each type, which
of them deliberately never become ledger events, and what the format's ambiguities were
decided to mean. A change in any of those numbers is a change in the import contract, not
a test that needs relaxing.
"""
from __future__ import annotations
import collections
import dataclasses
from decimal import Decimal
from pathlib import Path
import pytest
from fintracker.models.ledger import EventKind
from fintracker.sources.reports.base import BrokerEvent, ParsedReport, fingerprint_key
from fintracker.sources.reports.csv_universal import UniversalCsvParser
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "reports"
SNOWBALL = FIXTURES / "snowball"
HEADER = (
"Event,Date,Symbol,Price,Quantity,Currency,FeeTax,Exchange,NKD,FeeCurrency,DoNotAdjustCash,Note"
)
#: What the fixture must yield, per kind. Sums to 535 of its 545 rows: 5 CUSTOM_HOLDING_PRICE
#: and 1 CUSTOM_HOLDING_SETTINGS are not events at all, and 4 cash rows are Snowball's own
#: balance corrections.
EXPECTED_COUNTS = {
EventKind.buy: 270,
EventKind.sell: 60,
EventKind.dividend: 80,
EventKind.deposit: 96,
EventKind.withdrawal: 5,
EventKind.amortization: 9,
EventKind.commission: 7,
EventKind.tax: 4,
EventKind.tax_refund: 2,
EventKind.stock_split: 2,
}
@pytest.fixture(scope="module")
def parser() -> UniversalCsvParser:
return UniversalCsvParser()
@pytest.fixture(scope="module")
def fixture_path() -> Path:
files = sorted(SNOWBALL.glob("*.csv"))
assert files, f"нет фикстуры в {SNOWBALL}"
return files[0]
@pytest.fixture(scope="module")
def report(parser: UniversalCsvParser, fixture_path: Path) -> ParsedReport:
return parser.parse(fixture_path.read_bytes(), fixture_path.name)
def _csv(*rows: str) -> bytes:
return ("" + "\n".join((HEADER, *rows)) + "\n").encode("utf-8")
def _key(report: ParsedReport, event: BrokerEvent) -> str:
"""The dedupe key `ledger/ingest.py` will build for this event (plan §1.6 A)."""
return fingerprint_key(
report.broker,
report.account_external_id,
event.kind,
event.instrument.key() if event.instrument else "",
event.trade_date,
event.quantity,
event.price,
event.currency,
amount=event.amount,
seq=event.seq,
)
def _floats(value: object, path: str) -> list[str]:
"""Every float reachable from `value`, by path — money must be Decimal end to end."""
if isinstance(value, bool | str | bytes) or value is None:
return []
if isinstance(value, float):
return [f"{path} = {value!r}"]
if dataclasses.is_dataclass(value) and not isinstance(value, type):
return [
bad
for f in dataclasses.fields(value)
for bad in _floats(getattr(value, f.name), f"{path}.{f.name}")
]
if isinstance(value, dict):
return [bad for k, v in value.items() for bad in _floats(v, f"{path}[{k!r}]")]
if isinstance(value, list | tuple | set):
return [bad for i, v in enumerate(value) for bad in _floats(v, f"{path}[{i}]")]
return []
# --- 1. sniff -------------------------------------------------------------------------
def test_sniff_recognises_the_fixture(parser: UniversalCsvParser, fixture_path: Path) -> None:
assert parser.sniff(fixture_path.read_bytes(), fixture_path.name) is True
def test_sniff_ignores_the_filename(parser: UniversalCsvParser) -> None:
"""Recognition is by the header, so a renamed export is still recognised..."""
data = _csv('"CASH_IN","2025-02-26 09:22:54","RUB","1","5000","RUB","0","","","","False",""')
assert parser.sniff(data, "какой-то-файл.csv") is True
assert parser.sniff(data, "") is True
def test_sniff_rejects_other_report_formats(parser: UniversalCsvParser) -> None:
"""...and a file that merely has an extension we read is not ours."""
others = sorted((FIXTURES / "sber").glob("*.html")) + sorted((FIXTURES / "vtb").glob("*.xlsx"))
assert others, "нет чужих фикстур для отрицательной проверки"
for path in others:
assert parser.sniff(path.read_bytes(), path.name) is False, path.name
def test_sniff_rejects_a_csv_with_foreign_columns(parser: UniversalCsvParser) -> None:
assert parser.sniff(b"a,b,c\n1,2,3\n", "x.csv") is False
assert parser.sniff(b"", "x.csv") is False
assert parser.sniff(b"\x00\x01\x02", "x.csv") is False
def test_parse_rejects_a_file_it_cannot_read(parser: UniversalCsvParser) -> None:
from fintracker.sources.reports.base import ParseError
with pytest.raises(ParseError):
parser.parse(b"a,b,c\n1,2,3\n", "x.csv")
with pytest.raises(ParseError):
parser.parse(_csv(), "empty.csv")
# --- 2. what the fixture yields --------------------------------------------------------
def test_report_header(report: ParsedReport) -> None:
import datetime
assert report.broker == "csv"
assert report.parser_version == "1"
# the file names a portfolio, never an account — see the warning below
assert report.account_external_id == "Мой капитал"
assert report.period_from == datetime.date(2025, 2, 26)
assert report.period_to == datetime.date(2026, 9, 17)
def test_event_counts_by_kind(report: ParsedReport) -> None:
counts = collections.Counter(e.kind for e in report.events)
assert dict(counts) == EXPECTED_COUNTS
assert len(report.events) == 535
assert report.meta["row_count"] == 545
def test_instruments_are_deduplicated(report: ParsedReport) -> None:
keys = [ref.key() for ref in report.instruments]
assert len(keys) == len(set(keys)) == 49
# bonds arrive by ISIN, everything else by ticker; cash rows name no instrument
assert "ISIN:RU000A10B7T7" in keys
assert "TICKER:SBER" in keys
assert "TICKER:SU26212RMFS9" in keys, "секунда ОФЗ — не ISIN, а тикер MOEX"
assert not any(k.startswith("TICKER:RUB") for k in keys)
# --- 3. decimals ----------------------------------------------------------------------
def test_decimal_comma_is_parsed(parser: UniversalCsvParser) -> None:
data = _csv(
'"BUY","2025-03-01 10:00:00","SBER","4,48","219","RUB","6,57","MCX","0","RUB","False",""'
)
(event,) = parser.parse(data, "renamed.csv").events
assert event.price == Decimal("4.48")
assert event.quantity == Decimal("219")
assert event.fee == Decimal("6.57")
assert event.amount == Decimal("-987.69") # 4.48 * 219 + 6.57, fee capitalised
def test_no_floats_anywhere_in_the_output(report: ParsedReport) -> None:
bad = _floats(report, "report")
assert bad == []
def test_empty_numeric_cell_is_none_not_zero(parser: UniversalCsvParser) -> None:
"""An absent НКД is not a zero НКД: one is «not a bond», the other is «no interest»."""
data = _csv('"BUY","2025-03-01 10:00:00","SBER","100","1","RUB","0","MCX","","","False",""')
(event,) = parser.parse(data, "x.csv").events
assert event.accrued_interest is None
assert event.fee == Decimal(0)
def test_bond_trade_adds_accrued_interest_to_the_money(report: ParsedReport) -> None:
"""НКД is paid on top of the price by the buyer — it must be inside `amount`."""
buy = next(
e
for e in report.events
if e.kind is EventKind.buy
and e.instrument is not None
and e.instrument.isin == "RU000A107AM4"
and e.accrued_interest == Decimal("5.08")
)
assert buy.quantity == Decimal(4)
assert buy.price == Decimal("929.25")
assert buy.amount == -(Decimal(4) * Decimal("929.25") + Decimal("5.08") + Decimal("11.15"))
# --- 4. dividends and coupons ----------------------------------------------------------
def test_dividend_money_lands_in_amount_not_quantity(report: ParsedReport) -> None:
dividends = [e for e in report.events if e.kind is EventKind.dividend]
assert len(dividends) == 80
assert all(e.quantity is None for e in dividends), "Quantity у DIVIDEND — это деньги"
assert all(e.price is None for e in dividends)
assert all(e.amount > 0 for e in dividends)
assert sum(e.amount for e in dividends) == Decimal("12949.07")
def test_bond_dividends_carry_the_coupon_hint(report: ParsedReport) -> None:
"""The parser cannot know an asset class, so it hands ingest a suspicion, not a kind."""
dividends = [e for e in report.events if e.kind is EventKind.dividend]
hinted = [e for e in dividends if e.meta.get("payout_hint") == "coupon"]
assert len(hinted) == 44
assert all(
e.instrument is not None
and (
(e.instrument.isin or "").startswith("RU000A")
or (e.instrument.ticker or "").startswith("SU")
)
for e in hinted
)
# a share payout must never be hinted
assert all(
e.meta.get("payout_hint") is None
for e in dividends
if e.instrument is not None and e.instrument.ticker in {"T", "ROSN", "SBERP", "TATNP"}
)
# --- 5. splits -------------------------------------------------------------------------
def test_split_ratio_and_collapsing_dedupe_key(report: ParsedReport) -> None:
"""Two identical SPLIT rows for T are one corporate action seen on two accounts.
The format has no account column, so both rows land on the single account the user
points the import at. They therefore share a `fingerprint_key` on purpose: replaying the
ratio twice would multiply the position by 100 instead of by 10. This test pins that
behaviour — if a future format gains an account column, it is expected to break.
"""
splits = [e for e in report.events if e.kind is EventKind.stock_split]
assert len(splits) == 2
assert {e.meta["split_ratio"] for e in splits} == {Decimal(10)}
assert {e.meta["ratio"] for e in splits} == {Decimal(10)}
assert all(e.amount == Decimal(0) and e.quantity is None for e in splits)
assert all(e.instrument is not None and e.instrument.ticker == "T" for e in splits)
assert [e.seq for e in splits] == [0, 0]
assert _key(report, splits[0]) == _key(report, splits[1])
def test_every_other_event_keeps_a_distinct_dedupe_key(report: ParsedReport) -> None:
"""The split pair is the ONLY intentional collapse in the file.
In particular the pairs of equal same-day top-ups (two 1 100 ₽ one second apart, two
4 000 ₽ that differ only by their note) survive as distinct events: they are real money
into two accounts, and netting them would understate the XIRR denominator.
"""
keys = [_key(report, e) for e in report.events]
collapsed = [k for k, n in collections.Counter(keys).items() if n > 1]
assert len(collapsed) == 1
assert len(set(keys)) == 534
same_second = [e for e in report.events if e.kind is EventKind.deposit and e.seq > 0]
assert same_second, "в файле есть одинаковые пополнения одного дня — они должны выживать"
def test_seq_is_stable_across_reparsing(parser: UniversalCsvParser, fixture_path: Path) -> None:
"""A re-import of the same bytes must reproduce every key, or nothing ever upserts."""
data = fixture_path.read_bytes()
first = parser.parse(data, fixture_path.name)
second = parser.parse(data, "совсем другое имя.csv")
assert [_key(first, e) for e in first.events] == [_key(first, e) for e in second.events]
# --- 6. amortisation -------------------------------------------------------------------
def test_amortisation_is_money_on_a_bond(report: ParsedReport) -> None:
amortisations = [e for e in report.events if e.kind is EventKind.amortization]
assert len(amortisations) == 9
assert all(e.amount > 0 for e in amortisations)
assert all(e.quantity is None and e.price is None for e in amortisations)
assert {e.instrument.isin for e in amortisations if e.instrument} == {"RU000A10B7T7"}
assert all(
e.instrument is not None and e.instrument.asset_class_hint == "bond" for e in amortisations
)
assert sum(e.amount for e in amortisations) == Decimal("4530.07")
# --- 7. manual prices ------------------------------------------------------------------
def test_custom_holding_prices_go_to_meta_not_to_events(report: ParsedReport) -> None:
"""CUSTOM_HOLDING_PRICE is a `price_manual` row, not a ledger event."""
prices = report.meta["manual_prices"]
assert len(prices) == 5
assert {p["instrument_key"] for p in prices} == {"TICKER:SIBN6P4"}
assert {p["currency"] for p in prices} == {"RUB"}
assert all(isinstance(p["price"], Decimal) for p in prices)
assert max(p["price"] for p in prices) == Decimal("12320.94504")
assert all(e.meta.get("csv_event") != "CUSTOM_HOLDING_PRICE" for e in report.events)
# the instrument they price must be resolvable, so it is also listed
assert "TICKER:SIBN6P4" in {ref.key() for ref in report.instruments}
def test_the_export_has_no_price_for_the_other_unquoted_paper(report: ParsedReport) -> None:
"""NDM_TBNK-PP-FIXPRCNT-08.25 is simply absent — this file cannot price it."""
named = {(ref.ticker or "") + (ref.isin or "") for ref in report.instruments}
assert not any("NDM" in name for name in named)
assert not any("NDM" in p["instrument_key"] for p in report.meta["manual_prices"])
def test_custom_holding_settings_describe_an_instrument(report: ParsedReport) -> None:
"""Its Note is JSON with `"` rewritten to `@*@` and Cyrillic as \\uXXXX escapes."""
ref = next(r for r in report.instruments if r.ticker == "SIBN6P4")
assert ref.name == "Газпром Нефть 006Р-04"
assert ref.currency == "RUB"
assert ref.meta["custom_holding"] is True
assert ref.meta["sector"] == "Other"
assert ref.meta["settings"]["CustomHoldingType"] == 0
assert all(e.meta.get("csv_event") != "CUSTOM_HOLDING_SETTINGS" for e in report.events)
def test_broken_settings_json_is_a_warning_not_a_crash(parser: UniversalCsvParser) -> None:
data = _csv(
'"CUSTOM_HOLDING_SETTINGS","2026-09-17 00:00:00","XYZ","0","0","RUB","0",'
'"CUSTOM_HOLDING","0","","","{@*@Holding@*@:'
)
result = parser.parse(data, "x.csv")
assert result.events == []
assert any("не удалось разобрать JSON" in w for w in result.warnings)
assert [ref.key() for ref in result.instruments] == ["TICKER:XYZ"]
# --- 8. balance adjustments ------------------------------------------------------------
def test_balance_adjustments_never_become_flows(report: ParsedReport) -> None:
"""Snowball's own corrections would feed XIRR an external flow that never happened."""
adjustments = report.meta["balance_adjustments"]
assert len(adjustments) == 4
assert collections.Counter(a["kind"] for a in adjustments) == {"CASH_OUT": 3, "CASH_IN": 1}
assert all(isinstance(a["amount"], Decimal) for a in adjustments)
# detection is by the note, not by the amount: one correction is 1017,84 ₽ while a
# genuine withdrawal in the same file is 32 ₽
assert min(abs(a["amount"]) for a in adjustments) == Decimal("0.0024")
assert max(abs(a["amount"]) for a in adjustments) == Decimal("1017.83999543")
excluded = {(a["kind"], a["d"], abs(a["amount"])) for a in adjustments}
for event in report.events:
kind = "CASH_IN" if event.kind is EventKind.deposit else "CASH_OUT"
assert (kind, event.trade_date, abs(event.amount)) not in excluded
assert any("правка остатка" in w for w in report.warnings)
def test_real_cash_flows_survive(report: ParsedReport) -> None:
deposits = [e for e in report.events if e.kind is EventKind.deposit]
withdrawals = [e for e in report.events if e.kind is EventKind.withdrawal]
assert len(deposits) == 96 and len(withdrawals) == 5
assert all(e.amount > 0 and e.instrument is None for e in deposits)
assert all(e.amount < 0 and e.instrument is None for e in withdrawals)
assert sum(e.amount for e in withdrawals) == -Decimal("51294.41")
assert sum(e.amount for e in deposits) == Decimal("454058.45")
# --- 9. fees, taxes, refunds -----------------------------------------------------------
def test_fee_tax_and_refund_signs(report: ParsedReport) -> None:
fees = [e for e in report.events if e.kind is EventKind.commission]
taxes = [e for e in report.events if e.kind is EventKind.tax]
refunds = [e for e in report.events if e.kind is EventKind.tax_refund]
assert (len(fees), len(taxes), len(refunds)) == (7, 4, 2)
assert all(e.instrument is None for e in fees + taxes + refunds)
assert all(e.quantity is None and e.price is None for e in fees + taxes + refunds)
assert all(e.amount == Decimal(-45) and e.fee == Decimal(45) for e in fees)
assert [e.amount for e in taxes] == [Decimal(-234), Decimal(-3), Decimal(-22), Decimal(-32)]
assert [e.amount for e in refunds] == [Decimal(3), Decimal(32)]
assert all(e.tax == -e.amount for e in taxes)
assert all(e.tax == e.amount for e in refunds)
# --- unknown types and reconciliation warnings -----------------------------------------
def test_unknown_event_type_is_skipped_with_a_warning(parser: UniversalCsvParser) -> None:
"""Never `EventKind.other`: a silent bucket hides a format that grew a new row type."""
data = _csv(
'"MARGIN_CALL","2025-03-01 10:00:00","SBER","1","1","RUB","0","MCX","0","","False",""',
'"BUY","2025-03-01 10:00:00","SBER","100","1","RUB","0","MCX","0","","False",""',
)
result = parser.parse(data, "x.csv")
assert [e.kind for e in result.events] == [EventKind.buy]
assert result.meta["unknown_events"] == {"MARGIN_CALL": 1}
assert any("MARGIN_CALL" in w for w in result.warnings)
assert not any(e.kind is EventKind.other for e in result.events)
def test_closing_balances_are_absent_and_said_to_be(report: ParsedReport) -> None:
assert report.positions_end == []
assert report.cash_end == []
assert any("positions_end" in w for w in report.warnings)
def test_warns_that_the_target_account_is_the_user_s_to_choose(report: ParsedReport) -> None:
assert any("shadow" in w and "все брокерские счета" in w for w in report.warnings)
@@ -0,0 +1,56 @@
"""Реестр парсеров отчётов: какой файл достаётся какому парсеру (plan §2).
Проверка выглядит тривиальной, но ловит ровно ту ошибку, которую больше не поймает никто:
универсальный CSV узнаёт файл по набору колонок, то есть по определению шире любого
брокерского формата, и достаточно поставить его в списке первым, чтобы отчёт Сбера тихо
уехал в чужой парсер. Порядок в `PARSERS` — это поведение, а не оформление.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from fintracker.sources.reports import registry
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "reports"
CASES = [
(FIXTURES / "sber" / "S930W42_11022026_17092026.html", "report_sber"),
(FIXTURES / "sber" / "S930W42_01082026_31082026.html", "report_sber"),
(FIXTURES / "vtb" / "reportd0235f1e-55ac-fdc8-5c7b-279e4fe26e2b.xlsx", "report_vtb"),
(FIXTURES / "vtb" / "report8ef8347a-2deb-e880-959a-d9a8ca12f735.xlsx", "report_vtb"),
(next((FIXTURES / "snowball").glob("*.csv")), "csv"),
]
@pytest.mark.parametrize("path,expected", CASES, ids=lambda v: getattr(v, "name", v))
def test_pick_routes_each_fixture_to_its_parser(path: Path, expected: str) -> None:
chosen = registry.pick(path.read_bytes(), path.name)
assert chosen is not None, f"{path.name}: ни один парсер не узнал файл"
assert chosen.name == expected
def test_every_registered_parser_is_reachable_by_name() -> None:
assert set(registry.names()) == {"report_sber", "report_vtb", "csv"}
for name in registry.names():
assert registry.get(name).name == name
def test_unknown_format_is_a_none_not_an_exception() -> None:
"""`pick` возвращает None, и роутер превращает это в 415 — не в 500."""
assert registry.pick(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n", "statement.pdf") is None
assert registry.pick(b"", "empty.txt") is None
def test_parser_metadata_is_filled_in() -> None:
"""`parser_name` и `parser_version` уходят в `raw_report_file` — пустых быть не должно."""
for parser in registry.PARSERS:
assert parser.name and parser.broker and parser.version
assert parser.formats
def test_csv_is_last_so_it_cannot_shadow_a_broker_format() -> None:
names = registry.names()
assert names.index("csv") == len(names) - 1
+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")
+410
View File
@@ -0,0 +1,410 @@
"""VTB xlsx report parser against the two anonymised fixtures.
The fixtures are the same account rendered twice: the whole life of the IIS (12.0217.09) and
the single month of August, which is a strict subset of it. That overlap is the point — it is
exactly the case the dedupe key exists for, so the deal both files contain must produce one
key, and the monthly file must not invent a trade the full one does not have.
Every number asserted here is taken from «Сводная информация по субсчёту Клиента», the
broker's own totals block: the parser is trusted only insofar as what it extracted adds up to
what VTB printed.
"""
from __future__ import annotations
import io
import zipfile
from collections import Counter
from dataclasses import fields, is_dataclass
from decimal import Decimal
from pathlib import Path
import pytest
from openpyxl import load_workbook
from fintracker.models.ledger import EventKind
from fintracker.sources.reports.base import ParsedReport
from fintracker.sources.reports.vtb import common
from fintracker.sources.reports.vtb.xlsx import VtbXlsxParser
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "reports"
FULL = FIXTURES / "vtb" / "reportd0235f1e-55ac-fdc8-5c7b-279e4fe26e2b.xlsx"
AUGUST = FIXTURES / "vtb" / "report8ef8347a-2deb-e880-959a-d9a8ca12f735.xlsx"
#: The August deal, as printed in both files: «№ сделки» B17476399721, 33 EQMX at 122.9.
AUGUST_TRADE_NO = "B17476399721"
@pytest.fixture(scope="module")
def parser() -> VtbXlsxParser:
return VtbXlsxParser()
@pytest.fixture(scope="module")
def full(parser: VtbXlsxParser) -> ParsedReport:
return parser.parse(FULL.read_bytes(), FULL.name)
@pytest.fixture(scope="module")
def august(parser: VtbXlsxParser) -> ParsedReport:
return parser.parse(AUGUST.read_bytes(), AUGUST.name)
def trades(report: ParsedReport) -> list:
return [event for event in report.events if event.kind in {EventKind.buy, EventKind.sell}]
def count_security_movement_rows(path: Path) -> int:
"""Rows of «Движение ценных бумаг» counted straight off the sheet.
Deliberately independent of the parser: the section is the settlement side of the same
deals, so its row count is an outside check that no trade was dropped or doubled.
"""
workbook = load_workbook(io.BytesIO(path.read_bytes()), read_only=True, data_only=True)
sheet = workbook["brokerage_report"]
sheet.reset_dimensions()
inside = False
rows = 0
for row in sheet.iter_rows(values_only=True):
label = common.normalize_text(row[1] if len(row) > 1 else None)
if label == "движение ценных бумаг":
inside = True
continue
if not inside:
continue
if label in {"заключенные в отчетном периоде сделки с ценными бумагами"}:
break
if "расчеты по заключенным сделкам" in common.normalize_text(
row[16] if len(row) > 16 else None
):
rows += 1
workbook.close()
return rows
# -- 1. sniff ----------------------------------------------------------------------------
def test_sniff_accepts_both_vtb_files(parser: VtbXlsxParser) -> None:
for path in (FULL, AUGUST):
assert parser.sniff(path.read_bytes(), path.name) is True
def test_sniff_rejects_other_brokers(parser: VtbXlsxParser) -> None:
others = list((FIXTURES / "sber").glob("*.html")) + list((FIXTURES / "snowball").glob("*.csv"))
assert others, "фикстуры других брокеров не найдены — проверка бессмысленна"
for path in others:
assert parser.sniff(path.read_bytes(), path.name) is False
def test_sniff_never_raises_on_garbage(parser: VtbXlsxParser) -> None:
"""A registry walks every parser over every upload, so a False must never be an
exception — including for a zip that merely looks like a workbook."""
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as archive:
archive.writestr("xl/workbook.xml", "<workbook/>")
for payload in (b"", b"not a zip", b"PK\x03\x04broken", buffer.getvalue()):
assert parser.sniff(payload, "whatever.xlsx") is False
def test_sniff_ignores_the_file_name(parser: VtbXlsxParser) -> None:
"""The name is a GUID with no format in it; content decides."""
assert parser.sniff(FULL.read_bytes(), "totally-unrelated.bin") is True
# -- 2. the full report ------------------------------------------------------------------
def test_full_report_header(full: ParsedReport) -> None:
assert full.broker == "vtb"
assert full.account_external_id == "77R593"
assert (full.period_from.isoformat(), full.period_to.isoformat()) == (
"2026-02-12",
"2026-09-17",
)
assert full.meta["iis_opened_at"] == "2026-02-12"
def test_full_report_trades_come_only_from_concluded_section(full: ParsedReport) -> None:
"""«Завершенные» repeats «Заключенные» with the same deal numbers, and «Движение ценных
бумаг» settles those same deals — so ten deals must yield ten events, not twenty or
thirty."""
numbers = [event.trade_no for event in trades(full)]
assert len(numbers) == len(set(numbers)) == count_security_movement_rows(FULL) == 10
def test_full_report_parses_without_warnings(full: ParsedReport) -> None:
assert full.warnings == []
# -- 3. money ----------------------------------------------------------------------------
def test_full_report_deposits_and_fees(full: ParsedReport) -> None:
deposits = [event for event in full.events if event.kind is EventKind.deposit]
assert sum(event.amount for event in deposits) == Decimal("26100")
assert all(event.amount > 0 for event in deposits), "пополнение увеличивает счёт"
# The per-deal commissions add up to «Вознаграждение брокера» of the summary block to the
# kopeck, which is why they are capitalised into the deals instead of being emitted
# separately: `ledger/lots.py` already puts `fee` into the cost of the lot.
assert sum((event.fee or Decimal(0)) for event in full.events) == Decimal("3.64")
assert Decimal(full.meta["summary"]["вознаграждение брокера"]) == Decimal("-3.64")
assert not [event for event in full.events if event.kind is EventKind.commission]
def test_full_report_signs(full: ParsedReport) -> None:
for event in trades(full):
assert event.quantity is not None
if event.kind is EventKind.buy:
assert event.quantity > 0 and event.amount < 0
else:
assert event.quantity < 0 and event.amount > 0
def test_settlement_saldo_is_not_emitted_twice(full: ParsedReport) -> None:
"""«Сальдо расчетов по сделкам с ценными бумагами» is the money leg of the trades; the
cash section must contribute deposits only."""
cash_kinds = Counter(event.kind for event in full.events if event.meta.get("section") == "cash")
assert cash_kinds == Counter({EventKind.deposit: 8})
def test_closing_cash_follows_from_the_events(full: ParsedReport) -> None:
"""The whole-file check: opening + everything emitted = closing, as VTB states it."""
summary = full.meta["summary"]
opening = Decimal(summary["входящий остаток денежных средств"])
closing = Decimal(summary["исходящий остаток денежных средств"])
assert opening + sum(event.amount for event in full.events) == closing == Decimal("120.99")
# -- 4. positions and cash balances -------------------------------------------------------
def test_full_report_positions_end(full: ParsedReport) -> None:
positions = {position.instrument.isin: position for position in full.positions_end}
assert set(positions) == {"RU000A101EJ5", "RU000A1014L8"}
assert positions["RU000A101EJ5"].qty == Decimal("197")
assert positions["RU000A1014L8"].qty == Decimal("0")
eqmx = positions["RU000A101EJ5"].instrument
assert (eqmx.ticker, eqmx.asset_class_hint) == ("EQMX", "etf")
assert eqmx.name == "EQMX ETF"
assert eqmx.meta["reg_number"] == "3965"
assert positions["RU000A101EJ5"].price == Decimal("124.653")
def test_currency_is_normalised_to_iso(full: ParsedReport) -> None:
"""VTB prints the pre-ISO `RUR`; the ledger stores three-letter ISO codes only."""
assert [(cash.currency, cash.balance) for cash in full.cash_end] == [("RUB", Decimal("120.99"))]
codes = {cash.currency for cash in full.cash_end}
codes |= {position.currency for position in full.positions_end}
codes |= {event.currency for event in full.events}
codes |= {event.price_currency for event in trades(full)}
assert codes == {"RUB"}
def test_instrument_hint_reaches_trade_events(full: ParsedReport) -> None:
"""Only the positions table names the asset class («ПАЙ»); the trades table does not, so
the hint is carried over by ISIN."""
assert {ref.asset_class_hint for ref in full.instruments} == {"etf"}
assert all(
event.instrument is not None and event.instrument.asset_class_hint == "etf"
for event in trades(full)
)
# -- 5. the monthly report and the cross-file dedupe key ----------------------------------
def test_august_report(august: ParsedReport) -> None:
assert (august.period_from.isoformat(), august.period_to.isoformat()) == (
"2026-08-01",
"2026-08-31",
)
assert august.account_external_id == "77R593"
assert august.warnings == []
deals = trades(august)
assert len(deals) == 1
deal = deals[0]
assert deal.kind is EventKind.buy
assert deal.instrument is not None and deal.instrument.isin == "RU000A101EJ5"
assert deal.quantity == Decimal("33")
assert deal.price == Decimal("122.9")
assert deal.trade_no == AUGUST_TRADE_NO
# 33 * 122.9 = 4055.70 plus the 0.81 commission capitalised into the deal.
assert deal.amount == Decimal("-4056.51")
assert deal.fee == Decimal("0.81")
def test_same_deal_has_one_dedupe_key_in_both_reports(
full: ParsedReport, august: ParsedReport
) -> None:
"""The overlapping-period case from plan §1.6 A: re-importing August after the full
report must upsert, not duplicate."""
keys = {}
for report in (full, august):
for event in trades(report):
if event.trade_no == AUGUST_TRADE_NO:
keys[report.period_to] = common.dedupe_key(report.account_external_id, event)
assert len(keys) == 2
assert len(set(keys.values())) == 1
def test_dedupe_key_uses_the_brokers_own_deal_number(full: ParsedReport) -> None:
"""«№ сделки» (Z), not «№ сделки у организатора торгов» (AC).
The key is `sha1(broker | account | trade_no)` and is therefore already unique within the
broker; what it needs is the number VTB prints in every rendering of its own report. The
organiser's number is a different string (`17476399721` against `B17476399721`) and is
absent for over-the-counter deals in some formats.
"""
deal = next(event for event in trades(full) if event.trade_no == AUGUST_TRADE_NO)
assert deal.meta["exchange_trade_no"] == "17476399721"
assert common.dedupe_key("77R593", deal) != common.dedupe_key(
"77R593",
type(deal)(
**{
**{field.name: getattr(deal, field.name) for field in fields(deal)},
"trade_no": deal.meta["exchange_trade_no"],
}
),
)
def test_cash_rows_get_distinct_fingerprint_keys(full: ParsedReport) -> None:
"""Deposits carry no deal number and fall back to a fingerprint; two 4 000 ₽ top-ups in
different months must still differ."""
keys = [
common.dedupe_key(full.account_external_id, event)
for event in full.events
if event.kind is EventKind.deposit
]
assert len(set(keys)) == len(keys) == 8
# -- 6. concluded vs completed ------------------------------------------------------------
def test_concluded_and_completed_deal_numbers_agree(full: ParsedReport) -> None:
"""Asserted through the parser's own warning channel: a divergence is reported, and for a
fully settled period there is none."""
assert not [warning for warning in full.warnings if "Завершенных" in warning]
parsed = VtbXlsxParser().parse(FULL.read_bytes(), FULL.name)
concluded = {event.trade_no for event in trades(parsed)}
assert concluded == set(_completed_deal_numbers(FULL))
def _completed_deal_numbers(path: Path) -> list[str]:
"""«№ сделки» of «Завершенные…» read straight off the sheet, bypassing the parser."""
workbook = load_workbook(io.BytesIO(path.read_bytes()), read_only=True, data_only=True)
sheet = workbook["brokerage_report"]
sheet.reset_dimensions()
inside = False
numbers: list[str] = []
for row in sheet.iter_rows(values_only=True):
label = common.normalize_text(row[1] if len(row) > 1 else None)
if label.startswith("завершенные в отчетном периоде сделки с ценными бумагами"):
inside = True
continue
if not inside:
continue
value = row[25] if len(row) > 25 else None # column Z
if value and str(value).strip() and common.normalize_text(value) != "№ сделки":
numbers.append(str(value).strip())
workbook.close()
return numbers
# -- 7. no floats anywhere ----------------------------------------------------------------
def _floats(value: object, path: str = "") -> list[str]:
if isinstance(value, bool) or value is None:
return []
if isinstance(value, float):
return [path]
if is_dataclass(value) and not isinstance(value, type):
found: list[str] = []
for field in fields(value):
found += _floats(getattr(value, field.name), f"{path}.{field.name}")
return found
if isinstance(value, dict):
return [p for key, item in value.items() for p in _floats(item, f"{path}[{key}]")]
if isinstance(value, (list, tuple, set)):
return [p for index, item in enumerate(value) for p in _floats(item, f"{path}[{index}]")]
return []
@pytest.mark.parametrize("name", ["full", "august"])
def test_no_floats_in_output(name: str, request: pytest.FixtureRequest) -> None:
"""openpyxl hands back floats for every numeric cell; none of them may survive into the
result, or a kopeck disappears into binary rounding on the way to `NUMERIC(24,10)`."""
report: ParsedReport = request.getfixturevalue(name)
assert _floats(report, name) == []
# -- 8. totals against the summary block --------------------------------------------------
@pytest.mark.parametrize(("name", "expected"), [("full", "-25975.37"), ("august", "-4055.7")])
def test_trade_sums_match_the_settlement_saldo(
name: str, expected: str, request: pytest.FixtureRequest
) -> None:
"""Σ «Сумма сделки» (column M) of the purchases minus the sales equals «Сальдо расчетов по
сделкам с ценными бумагами» in the summary.
The commission has to be taken back out of `amount` to make the comparison: VTB's saldo is
the money of the deals themselves, while `event.amount` carries the fee inside it by the
ledger's convention. The two agree exactly once that is done — the fee lives in the same
file as its own «Вознаграждение брокера» line, and it is that line, not the saldo, that
accounts for it.
"""
report: ParsedReport = request.getfixturevalue(name)
gross = Decimal(0)
for event in trades(report):
fee = event.fee or Decimal(0)
gross += event.amount + fee if event.kind is EventKind.buy else event.amount - fee
assert gross == Decimal(expected)
assert gross == Decimal(report.meta["summary"]["сальдо расчетов по сделкам с ценными бумагами"])
def test_position_quantities_match_the_settled_movements(full: ParsedReport) -> None:
"""Closing position = what the trades did, since the account opened empty."""
by_isin: dict[str, Decimal] = {}
for event in trades(full):
assert event.instrument is not None and event.instrument.isin
assert event.quantity is not None
by_isin[event.instrument.isin] = (
by_isin.get(event.instrument.isin, Decimal(0)) + event.quantity
)
for position in full.positions_end:
assert position.instrument.isin is not None
assert by_isin[position.instrument.isin] == position.qty
# -- parse errors --------------------------------------------------------------------------
def test_unreadable_file_raises_parse_error(parser: VtbXlsxParser) -> None:
from fintracker.sources.reports.base import ParseError
with pytest.raises(ParseError):
parser.parse(b"PK\x03\x04 definitely not a workbook", "report.xlsx")
def test_workbook_without_a_period_raises_parse_error(parser: VtbXlsxParser) -> None:
"""A file we recognise but cannot place on a timeline must fail loudly rather than import
as an empty report."""
from openpyxl import Workbook
from fintracker.sources.reports.base import ParseError
workbook = Workbook()
sheet = workbook.worksheets[0]
sheet.title = "brokerage_report"
sheet["D1"] = "Отчет Банка ВТБ (ПАО) без периода"
buffer = io.BytesIO()
workbook.save(buffer)
with pytest.raises(ParseError):
parser.parse(buffer.getvalue(), "report.xlsx")