Files
fin-tracker/backend/tests/sources/test_report_vtb.py
T
Dmitry 2e742a093b 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/, когда оно
на месте.
2026-09-19 10:39:37 +03:00

411 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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")