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:
@@ -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.02–17.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")
|
||||
Reference in New Issue
Block a user