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
@@ -0,0 +1,32 @@
"""Broker report parsers (plan §2 «Интерфейсы плагинов», фаза 3).
A parser turns the bytes of one uploaded file into a `ParsedReport`: a period, a broker
account id, a flat list of `BrokerEvent`, the closing positions and cash the report itself
states, and the instruments it mentions. Nothing here touches the database — the whole
package is importable and testable from a fixture alone, which is the point: report formats
change without warning, and a format regression must be reproducible from one file.
"""
from fintracker.sources.reports.base import (
BrokerEvent,
CashEnd,
InstrumentRef,
ParsedReport,
ParseError,
PositionEnd,
ReportParser,
fingerprint_key,
trade_dedupe_key,
)
__all__ = [
"BrokerEvent",
"CashEnd",
"InstrumentRef",
"ParseError",
"ParsedReport",
"PositionEnd",
"ReportParser",
"fingerprint_key",
"trade_dedupe_key",
]
@@ -0,0 +1,233 @@
"""Turning a real broker report into a committable fixture (AGENTS.md: только обезличенные).
A broker report is a personal document: it prints the investor's full name, their taxpayer
number, the agreement and sub-account numbers, and the settlement account. None of that may
reach git, while everything a parser is tested on — dates, amounts, quantities, tickers,
ISINs, deal numbers, section layout — must survive untouched, or the fixture stops testing
the parser.
Two rules make this reproducible rather than a one-off scrub:
* **Nothing secret is written down here.** The module *finds* the personal data in the file
by the labels the broker itself prints («Инвестор:», «Клиент:», «ИНН:», «№ субсчета:»),
so this source file is safe to commit and the same raw file always yields the same
fixture.
* **Replacements preserve shape.** An account number keeps its length and its
letter/digit layout, because the parser reads it as an identifier and a fixture with a
differently-shaped id would not exercise the same code path. The substitute is derived
from a hash of the original, so it is stable across runs but tells you nothing about it.
`tests/test_fixtures_anonymized.py` is the enforcement side: it re-derives the secrets from
`raw/` (when present) and fails if any of them appears in a committed fixture.
"""
from __future__ import annotations
import hashlib
import io
import re
import zipfile
from collections.abc import Iterable
from dataclasses import dataclass, field
DIGITS = "0123456789"
UPPER = "АБВГДЕЖЗИКЛМНОПРСТУФХЦЧШЩЭЮЯ"
LATIN = "ABCDEFGHIJKLMNPQRSTUVWXYZ"
#: A fixture may legitimately contain these — they are the substitutes this module produces.
FAKE_SURNAME = "Иванов"
FAKE_NAME = "Иван"
FAKE_PATRONYMIC = "Иванович"
# --- what counts as personal data -------------------------------------------------------
#: Individual taxpayer number: 12 digits. A legal entity's is 10 and is public information
#: (the clearing house's INN is printed in every VTB report), so only 12 is scrubbed.
#: The lookarounds exclude a decimal point on either side — an xlsx stores a timestamp as
#: `46247.480069444515`, and the fractional part is twelve digits that mean nothing.
RE_INN = re.compile(r"(?<![\d.])\d{12}(?![\d.])")
#: Settlement / depo account: 20 digits.
RE_ACCOUNT_20 = re.compile(r"(?<![\d.])\d{20}(?![\d.])")
#: Substitutes for the two numeric identifiers are sentinels, not hashes: a hash of a
#: 12-digit INN is another 12-digit number, and the fixture check could not then tell a
#: leak from a replacement. Six leading zeros never occur in a real INN or account number,
#: so `RE_PLACEHOLDER_NUMBER` is a safe allow-list for the test.
RE_PLACEHOLDER_NUMBER = re.compile(r"^0{6,}\d*$")
#: «Петров И. С.» — surname plus initials, in any spacing. VTB prints the client this way,
#: and its own back-office employee too, so both are caught.
RE_SHORT_NAME = re.compile(r"\b([А-ЯЁ][а-яё]{2,})\s+([А-ЯЁ])\.\s?([А-ЯЁ])\.")
#: «ПЕТРОВ ИВАН СЕРГЕЕВИЧ» — the all-caps form Sber prints after «Инвестор:».
RE_CAPS_FIO = re.compile(r"\b[А-ЯЁ]{3,}\s+[А-ЯЁ]{3,}\s+[А-ЯЁ]{3,}\b")
#: Labels after which a broker prints the agreement / sub-account id.
ACCOUNT_ANCHORS = (
re.compile(
r"(?:индивидуального инвестиционного счета|Договор[^\n<]{0,60}?)\s+([0-9A-Z]{5,12})\s+от"
),
re.compile(r"№ и дата Соглашения[^0-9A-Z]{0,40}([0-9A-Z]{5,12})\b"),
re.compile(r"\s*субсчета[^0-9A-Z]{0,40}([0-9A-Z]{5,12})\b"),
re.compile(r"Субпозиция\s*№\s*([0-9A-Z]{5,12})\b"),
re.compile(r"с договора\s+([0-9A-Z]{5,12})\b"),
re.compile(r"на договор\s+([0-9A-Z]{5,12})\b"),
)
#: Tokens that match an anchor's shape but are not identifiers of this client.
ACCOUNT_STOPWORDS = frozenset({"UCAF", "RUB", "USD", "EUR"})
@dataclass
class Secrets:
"""What was found in one report, and what each item is replaced with."""
replacements: dict[str, str] = field(default_factory=dict)
def add(self, original: str, substitute: str) -> None:
if original and original not in self.replacements:
self.replacements[original] = substitute
def apply(self, text: str) -> str:
# Longest first: an agreement id may be a substring of a longer one.
for original in sorted(self.replacements, key=len, reverse=True):
text = text.replace(original, self.replacements[original])
return text
@property
def originals(self) -> list[str]:
return list(self.replacements)
def _shaped_substitute(value: str) -> str:
"""A stable stand-in with the same length and letter/digit layout as `value`."""
digest = hashlib.sha256(value.encode("utf-8")).digest()
out: list[str] = []
for i, ch in enumerate(value):
byte = digest[i % len(digest)]
if ch.isdigit():
out.append(DIGITS[byte % 10])
elif ch.isalpha() and ch.isupper():
alphabet = UPPER if ch in UPPER or ord(ch) > 127 else LATIN
out.append(alphabet[byte % len(alphabet)])
elif ch.isalpha():
out.append(LATIN[byte % len(LATIN)].lower())
else:
out.append(ch)
return "".join(out)
def find_secrets(text: str) -> Secrets:
"""Everything personal in one report's text, with its replacement decided."""
secrets = Secrets()
for match in RE_CAPS_FIO.finditer(text):
fio = match.group(0)
secrets.add(fio, f"{FAKE_SURNAME.upper()} {FAKE_NAME.upper()} {FAKE_PATRONYMIC.upper()}")
for match in RE_SHORT_NAME.finditer(text):
secrets.add(match.group(0), f"{FAKE_SURNAME} {FAKE_NAME[0]}. {FAKE_PATRONYMIC[0]}.")
for pattern in (RE_INN, RE_ACCOUNT_20):
for match in pattern.finditer(text):
original = match.group(0)
if RE_PLACEHOLDER_NUMBER.match(original):
continue # already a substitute — re-running must be idempotent
index = sum(1 for v in secrets.replacements.values() if len(v) == len(original))
secrets.add(original, "0" * (len(original) - 2) + f"{index:02d}")
for pattern in ACCOUNT_ANCHORS:
for match in pattern.finditer(text):
token = match.group(1)
if token in ACCOUNT_STOPWORDS or token.isalpha():
continue
secrets.add(token, _shaped_substitute(token))
return secrets
# --- per-format rewriting ----------------------------------------------------------------
def anonymize_text(
data: bytes, encoding: str = "utf-8", secrets: Secrets | None = None
) -> tuple[bytes, Secrets]:
"""HTML and CSV: one decode, one pass, one encode."""
text = data.decode(encoding)
secrets = secrets if secrets is not None else find_secrets(text)
return secrets.apply(text).encode(encoding), secrets
#: Parts of an xlsx that can hold a visible string. Styles and calc chains cannot.
XLSX_TEXT_PARTS = re.compile(r"^xl/(sharedStrings\.xml|worksheets/.*\.xml|comments\d*\.xml)$")
def anonymize_xlsx(data: bytes, secrets: Secrets | None = None) -> tuple[bytes, Secrets]:
"""Rewrite the strings inside a workbook, leaving every other part byte-identical.
Editing the zip directly rather than round-tripping through openpyxl is deliberate: a
load/save cycle rewrites formatting, drops the parts openpyxl does not model, and would
make the fixture differ from a real export in ways a parser might depend on.
"""
source = zipfile.ZipFile(io.BytesIO(data))
texts = {
name: source.read(name).decode("utf-8")
for name in source.namelist()
if XLSX_TEXT_PARTS.match(name)
}
secrets = secrets if secrets is not None else find_secrets("\n".join(texts.values()))
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as out:
for item in source.infolist():
if item.filename in texts:
out.writestr(item, secrets.apply(texts[item.filename]).encode("utf-8"))
else:
out.writestr(item, source.read(item.filename))
return buffer.getvalue(), secrets
def readable_text(data: bytes, filename: str) -> str:
"""The visible text of a report, whatever its container — the input to `find_secrets`."""
if filename.lower().endswith((".xlsx", ".xlsm")):
archive = zipfile.ZipFile(io.BytesIO(data))
return "\n".join(
archive.read(name).decode("utf-8", "replace")
for name in archive.namelist()
if XLSX_TEXT_PARTS.match(name)
)
return data.decode("utf-8", "replace")
def collect_secrets(files: Iterable[tuple[bytes, str]]) -> Secrets:
"""One secret set for a whole batch of reports — the only safe way to scrub them.
Personal data crosses file boundaries: the Snowball CSV quotes the Sber agreement number
inside a transfer's note, and nothing in the CSV itself marks it as an identifier. Each
file scrubbed in isolation therefore leaks what another file named. Deriving the set once
from every original and applying it to all of them closes that gap, and keeps one
identifier mapped to one substitute across the whole fixture corpus.
"""
merged = Secrets()
for data, filename in files:
for original, substitute in find_secrets(
readable_text(data, filename)
).replacements.items():
merged.add(original, substitute)
return merged
def anonymize(data: bytes, filename: str, secrets: Secrets | None = None) -> tuple[bytes, Secrets]:
"""Dispatch on extension. Returns the clean bytes and what was removed."""
lowered = filename.lower()
if lowered.endswith((".xlsx", ".xlsm")):
return anonymize_xlsx(data, secrets)
if lowered.endswith(".csv"):
return anonymize_text(data, "utf-8-sig", secrets)
return anonymize_text(data, "utf-8", secrets)
def anonymize_filename(filename: str, secrets: Secrets) -> str:
"""Sber names its exports after the account number — the name leaks too."""
return secrets.apply(filename)
@@ -0,0 +1,231 @@
"""The `ReportParser` contract and the value objects every parser produces (plan §2).
Why these types and not the ORM rows directly: a report is *evidence*, not truth. It names
an instrument the way its own back office names it, states a period that may overlap another
file, and carries closing balances that exist precisely so they can be compared against what
the ledger derives. Handing `ingest.py` a plain dataclass keeps the parser free of the
database — a format regression is then reproducible from one fixture and nothing else.
Sign conventions match `models/ledger.Event` exactly, so ingest never has to re-interpret:
* `quantity` is signed by its effect on the POSITION: + into it, - out of it.
* `amount` is signed by its effect on the ACCOUNT's cash: + received, - paid.
* `fee` and `tax` are positive and already reflected inside `amount`.
**Dedup keys.** Two report files routinely describe the same trade — overlapping periods, a
re-export, the xlsx and the pdf of one month. A key must therefore be derived from the trade
itself, never from the file. `trade_dedupe_key` is the good case: the broker's own deal
number is stable across every rendering of it. `fingerprint_key` is the fallback for rows
that have no number (a cash movement, a fee line): it hashes what actually identifies the
row economically. The fallback is genuinely weaker — two identical purchases of the same
paper at the same price on one day collapse into one key — so a parser that has a deal
number must pass it, and a parser that has to fall back should add a discriminator
(`seq`) that is stable for the same report content, not the line number of a particular
rendering.
"""
from __future__ import annotations
import hashlib
from dataclasses import dataclass, field
from datetime import date
from decimal import Decimal
from typing import Any, Protocol, runtime_checkable
from fintracker.models.ledger import EventKind
class ParseError(ValueError):
"""The file is this parser's format but its content cannot be read.
Distinct from `sniff` returning False (wrong format, try another parser): raising this
means the right parser gave up, and the import must fail loudly instead of silently
producing an empty report.
"""
@dataclass(frozen=True, slots=True)
class InstrumentRef:
"""How a report names a security, before anything resolves it to an `instrument` row.
Every field is optional because reports differ in what they print: Sber gives ISIN and a
ticker, VTB gives a name plus ISIN inside one string, a CSV may give a ticker only.
Resolution order is the plan's (§1.3): ISIN → FIGI → tinvest_uid → (ticker, board) →
alias. `source_key` is what an `instrument_alias` row would store when nothing else
matches, and it is what a `pending_instrument` is keyed by — so it must be stable for
the same paper across files.
"""
isin: str | None = None
ticker: str | None = None
board: str | None = None
name: str | None = None
currency: str | None = None
asset_class_hint: str | None = None
"""share | bond | etf | fund | currency — a hint from the report's own wording, never
authoritative: the user confirms the class when resolving a pending instrument."""
source_key: str | None = None
"""Stable key for `instrument_alias(source, source_key)`, e.g. 'ISIN:RU0009029540'."""
meta: dict[str, Any] = field(default_factory=dict)
def key(self) -> str:
"""The identity used for pending-instrument grouping and alias lookups."""
if self.source_key:
return self.source_key
if self.isin:
return f"ISIN:{self.isin}"
if self.ticker and self.board:
return f"TICKER:{self.ticker}/{self.board}"
if self.ticker:
return f"TICKER:{self.ticker}"
return f"NAME:{self.name or ''}"
@dataclass(frozen=True, slots=True)
class BrokerEvent:
"""One ledger-bound row of a report, already in `event`'s sign conventions."""
kind: EventKind
trade_date: date
amount: Decimal
"""Signed cash effect on the account: + received, - paid. Zero for a pure position move."""
currency: str
settle_date: date | None = None
instrument: InstrumentRef | None = None
quantity: Decimal | None = None
price: Decimal | None = None
price_currency: str | None = None
fee: Decimal | None = None
fee_currency: str | None = None
tax: Decimal | None = None
tax_currency: str | None = None
accrued_interest: Decimal | None = None
trade_no: str | None = None
"""The broker's own deal number — the only stable identity across re-exports."""
description: str | None = None
raw_line_no: int = 0
"""Where the row sat in the parsed file; stored on `raw_report_line` for traceability.
Never part of a dedupe key: the same trade sits on different lines in different files."""
seq: int = 0
"""Discriminator for fingerprint keys when a report repeats an identical row legitimately
(two equal purchases in one second). Must be derived from the row's position *within its
own section*, so the same content yields the same value in any rendering of the report."""
meta: dict[str, Any] = field(default_factory=dict)
"""Parser-specific extras carried to `event.meta`: split ratio, deal comment, section."""
@dataclass(frozen=True, slots=True)
class PositionEnd:
"""A closing position as the report states it — reconciliation input, never a ledger row."""
instrument: InstrumentRef
qty: Decimal
price: Decimal | None = None
market_value: Decimal | None = None
currency: str | None = None
accrued_interest: Decimal | None = None
@dataclass(frozen=True, slots=True)
class CashEnd:
"""A closing cash balance per currency, as the report states it."""
currency: str
balance: Decimal
blocked: Decimal | None = None
@dataclass
class ParsedReport:
"""Everything one file yields. `warnings` is the parser's own data-quality channel:
a section it did not recognise, a total that did not add up, a row it skipped."""
broker: str
"""sber | vtb | csv — matches `Broker` and the `report_*` source names."""
account_external_id: str
"""The broker's account/agreement number as printed, e.g. 'S930W42', '77R593'."""
period_from: date
period_to: date
parser_version: str
events: list[BrokerEvent] = field(default_factory=list)
positions_end: list[PositionEnd] = field(default_factory=list)
cash_end: list[CashEnd] = field(default_factory=list)
instruments: list[InstrumentRef] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
meta: dict[str, Any] = field(default_factory=dict)
@runtime_checkable
class ReportParser(Protocol):
"""Pure, database-free: bytes in, `ParsedReport` out."""
broker: str
name: str
"""Registry key and `event.source`, e.g. 'report_sber'."""
formats: tuple[str, ...]
"""Lowercase extensions this parser reads, e.g. ('html', 'htm')."""
version: str
"""Bumped whenever output changes for the same input; stored on `raw_report_file`."""
def sniff(self, data: bytes, filename: str) -> bool:
"""True when this parser recognises the file. Cheap, and never raises."""
...
def parse(self, data: bytes, filename: str) -> ParsedReport:
"""Parse, or raise `ParseError` when the content is unreadable."""
...
def _sha1(parts: list[str]) -> str:
return hashlib.sha1("|".join(parts).encode("utf-8")).hexdigest()
def trade_dedupe_key(broker: str, account_external_id: str, trade_no: str) -> str:
"""sha1(broker|account|trade_no) — the plan's §1.6 A key for a numbered deal."""
return _sha1([broker, account_external_id, trade_no])
def fingerprint_key(
broker: str,
account_external_id: str,
kind: EventKind | str,
instrument_key: str,
trade_date: date,
quantity: Decimal | None,
price: Decimal | None,
currency: str,
*,
amount: Decimal | None = None,
seq: int = 0,
) -> str:
"""Fallback key for a row with no deal number (plan §1.6 A).
`amount` joins the plan's list because a report's cash section is full of rows that share
everything else — two 4 000 ₽ top-ups in one month differ by nothing but the amount and
the date, and a fee line has neither quantity nor price. `seq` breaks the remaining ties.
"""
kind_value = kind.value if isinstance(kind, EventKind) else str(kind)
return _sha1(
[
broker,
account_external_id,
kind_value,
instrument_key,
trade_date.isoformat(),
_num(quantity),
_num(price),
currency,
_num(amount),
str(seq),
]
)
def _num(value: Decimal | None) -> str:
"""Normalised so 1.50 and 1.5 hash alike — a re-export may print either."""
if value is None:
return ""
normalized = value.normalize()
if normalized == 0:
return "0"
return format(normalized, "f")
@@ -0,0 +1,12 @@
"""Universal CSV report parser (plan §2, фаза 3).
The format is Snowball Income's own export — the one file that covers *every* brokerage
account at once, which makes it the only independent cross-check of the whole ledger we
can get without re-reading each broker's own statements. It is therefore parsed as a
generic "event CSV" rather than as a Snowball-branded format: recognition is by the header
columns, so any future export with the same column set is read by the same code.
"""
from fintracker.sources.reports.csv_universal.parser import UniversalCsvParser
__all__ = ["UniversalCsvParser"]
@@ -0,0 +1,791 @@
"""A generic event CSV (Snowball Income's export) → `ParsedReport`.
The column set is fixed; what a column *means* is not. `Quantity` holds a number of shares
on a BUY and a number of rubles on a DIVIDEND, `Price` holds a price on a trade and a split
ratio on a SPLIT, and a FEE row carries its money in `FeeTax` with every other numeric
column zero. So the parser is organised as one handler per event type rather than as a
single row→event mapping: the shape of the row *is* the event type, and pretending
otherwise is how a dividend of 131,44 ₽ becomes a position of 131,44 bonds.
Contracts with the rest of the import pipeline, all decided here and documented in
`docs/ai/csv-import.md`:
* **`CUSTOM_HOLDING_PRICE` is a price, not an event.** It is a user-entered quote for a
paper the exchange does not quote (in the fixture: SIBN6P4, a bond held off-market), and
its home is `price_manual`. `base.py` has nowhere to put a price, and `base.py` is not
ours to change, so the rows travel in `meta["manual_prices"]` as
`{"instrument_key", "d", "price", "currency"}` and the instruments they name are also
listed in `instruments`, so `ingest.py` resolves them through the ordinary pending-
instrument flow before writing the prices.
* **`CUSTOM_HOLDING_SETTINGS` describes an instrument, not an event.** Its `Note` is JSON
with every `"` rewritten to `@*@` (so the CSV never has to escape quotes). Undo that,
read `Holding.Description`/`Holding.Currency`/`Holding.Sector`, and emit an
`InstrumentRef` — this is the only place a custom holding gets a human name. Malformed
JSON is a `warning`, never an exception: one broken settings row must not cost the user
500 good trades.
* **A `CASH_IN`/`CASH_OUT` whose `Note` says it balances the account is not a flow.**
Snowball inserts these itself when the broker's stated cash disagrees with the cash its
own replay produces. Emitting them as deposit/withdrawal would feed XIRR an external flow
that never crossed the portfolio boundary. They go to `warnings` and to
`meta["balance_adjustments"]`. Detection is by the note's wording, never by the size of
the amount: the fixture has one such correction of 1017,84 ₽ next to genuine withdrawals
of 32 ₽, so a threshold would misclassify both.
* **A `DIVIDEND` on a bond is a coupon, and the parser cannot prove it.** Asset class lives
in the database and the parser has none. So the kind stays `dividend` and the suspicion
travels as `meta["payout_hint"] = "coupon"` whenever the symbol looks like a bond (a
valid ISIN, or an OFZ secid `SU…RMFS…`). `ledger/ingest.py` reclassifies to
`EventKind.coupon` once the instrument resolves and its real `asset_class` is known; the
hint is a shortcut for the resolver, not an answer.
* **The file names a portfolio, not an account.** It covers every broker at once — 49
symbols from three brokers in one 545-row fixture — so `account_external_id` cannot be
derived from it and is filled with the portfolio name out of the filename. A warning says
so, because per plan §1.6 B an account has one `primary_event_source`: whatever account
the user points this import at, most of these events will land as `status = shadow` and
serve as reconciliation evidence rather than as the ledger itself.
* **An unknown event type is skipped with a warning, never mapped to `other`.** `other` is
a silent bucket; a format that grew a new row type must be visible as a data-quality
finding, not averaged into a metric.
**Dedup.** The format has no deal numbers, so every row gets `fingerprint_key`, and the
whole weight of telling two real trades apart from one trade exported twice falls on `seq`.
`seq` is the rank of the row's (time-of-day, note) among the distinct (time-of-day, note)
pairs of all rows that share its fingerprint content. Consequences, both intended:
* two genuine 1 100 ₽ top-ups one second apart keep distinct keys, and a re-import of the
same file reproduces those keys exactly, because the rank depends on content and clock
time only — never on line numbers;
* two rows identical down to the second *and* the note collapse into one key. That is the
right default for this format: it has no account column, so the repeated row is almost
always one event seen on two accounts (the fixture's two `SPLIT` rows for T at
2026-04-16 03:00:00), and replaying it twice against the single target account would
apply the split ratio twice.
The cost is the honest one: an export overlapping this one but missing some of the
same-content rows shifts the surviving rows' ranks, so the overlap re-imports as new rows
instead of upserting. A format with deal numbers would not have this problem; this one has
no numbers to use.
"""
from __future__ import annotations
import csv
import io
import json
import re
from collections import defaultdict
from dataclasses import dataclass, field, replace
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from typing import Any
from fintracker.models.ledger import EventKind
from fintracker.sources.reports.base import (
BrokerEvent,
InstrumentRef,
ParsedReport,
ParseError,
)
ZERO = Decimal(0)
#: Columns without which the file is not this format. Everything else is optional, so an
#: export that drops `NKD` or `FeeCurrency` is still read.
REQUIRED_COLUMNS = frozenset({"Event", "Date", "Symbol", "Price", "Quantity", "Currency"})
OPTIONAL_COLUMNS = frozenset(
{"FeeTax", "Exchange", "NKD", "FeeCurrency", "DoNotAdjustCash", "Note"}
)
#: Event types that become ledger events, and the kind each becomes.
EVENT_KINDS: dict[str, EventKind] = {
"BUY": EventKind.buy,
"SELL": EventKind.sell,
"CASH_IN": EventKind.deposit,
"CASH_OUT": EventKind.withdrawal,
"DIVIDEND": EventKind.dividend,
"AMORTISATION": EventKind.amortization,
"FEE": EventKind.commission,
"TAX": EventKind.tax,
"TAX_RETURN": EventKind.tax_refund,
"SPLIT": EventKind.stock_split,
}
#: Recognised event types that are deliberately NOT ledger events (see the module docstring).
NON_LEDGER_EVENTS = frozenset({"CUSTOM_HOLDING_PRICE", "CUSTOM_HOLDING_SETTINGS"})
#: Substrings that mark a cash row Snowball inserted to reconcile its own replay with the
#: broker's stated balance. Two markers, because the wording of the sentence varies with
#: the currency and the app version but these two fragments have not.
BALANCE_ADJUSTMENT_MARKERS = ("корректировки баланса", "не соответствует балансу")
#: Quotes inside the JSON blob of a CUSTOM_HOLDING_SETTINGS note.
JSON_QUOTE_ESCAPE = "@*@"
#: `Exchange` value marking a paper the exchange does not quote.
CUSTOM_HOLDING = "CUSTOM_HOLDING"
_TIMESTAMP_FORMATS = ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d")
#: 12 characters, two letters, a check digit — validated properly in `_looks_like_isin`.
_ISIN_SHAPE = re.compile(r"^[A-Z]{2}[A-Z0-9]{9}[0-9]$")
#: OFZ are addressed by a MOEX secid that happens to satisfy the ISIN checksum
#: (SU26212RMFS9). Calling it an ISIN would send the resolver looking for a security that
#: does not exist under that number, so the shape is excluded explicitly.
_OFZ_SECID = re.compile(r"^SU\d{5}RMFS\d$")
#: `Snowball_Export_<portfolio>_<dd.mm.yyyy>.csv` — the only place the portfolio is named.
_FILENAME = re.compile(r"^.*?Export[_\s]+(?P<name>.+?)[_\s]*\d{2}\.\d{2}\.\d{4}$")
#: Decorations around a portfolio name in a filename (Snowball allows an emoji prefix).
_NAME_EDGE = re.compile(r"^[^\w]+|[^\w]+$", re.UNICODE)
#: Whitespace that shows up inside exported numbers: plain, non-breaking, narrow.
_SPACES = str.maketrans({" ": "", "\xa0": "", "": ""})
@dataclass
class _Row:
"""One CSV data row, decoded but not yet interpreted."""
line_no: int
event: str
ts: datetime
symbol: str
price: Decimal | None
quantity: Decimal | None
currency: str
fee_tax: Decimal | None
exchange: str
nkd: Decimal | None
fee_currency: str
note: str
@property
def trade_date(self) -> date:
return self.ts.date()
@dataclass
class _Draft:
"""A `BrokerEvent` with `seq` still unknown — it needs the whole file to be assigned."""
event: BrokerEvent
time_key: tuple[str, str]
"""(time of day, note) — what tells two same-day, same-content rows apart."""
@dataclass
class _Accumulator:
"""Everything the row handlers write into, so they stay free of parser plumbing."""
drafts: list[_Draft] = field(default_factory=list)
instruments: dict[str, InstrumentRef] = field(default_factory=dict)
manual_prices: list[dict[str, Any]] = field(default_factory=list)
balance_adjustments: list[dict[str, Any]] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
unknown_events: dict[str, int] = field(default_factory=lambda: defaultdict(int))
def add_instrument(self, ref: InstrumentRef | None) -> InstrumentRef | None:
"""Keep one ref per identity, preferring the richest description seen.
The same paper appears on 30 trade rows with a bare ticker and once on a settings
row with its name, currency and sector; the ledger wants the latter.
"""
if ref is None:
return None
key = ref.key()
known = self.instruments.get(key)
if known is None:
self.instruments[key] = ref
return ref
merged = replace(
known,
isin=known.isin or ref.isin,
ticker=known.ticker or ref.ticker,
name=known.name or ref.name,
currency=known.currency or ref.currency,
asset_class_hint=known.asset_class_hint or ref.asset_class_hint,
meta={**ref.meta, **known.meta},
)
self.instruments[key] = merged
return merged
class UniversalCsvParser:
"""`ReportParser` for the generic event CSV. Pure: bytes in, `ParsedReport` out."""
broker = "csv"
name = "csv"
formats: tuple[str, ...] = ("csv",)
version = "1"
def sniff(self, data: bytes, filename: str) -> bool:
"""Recognise the format by its header columns alone.
Not by the filename: the user renames exports, and a second tool may write the same
columns. Not by content either — the header is the only thing every export of this
format shares. Never raises, per the protocol.
"""
del filename # deliberately unused — that is the point of this method
try:
header = _header(data)
except Exception:
return False
return header is not None and header >= REQUIRED_COLUMNS
def parse(self, data: bytes, filename: str) -> ParsedReport:
"""Read the whole file, or raise `ParseError` if it is not readable as this format."""
text = _decode(data)
reader = csv.DictReader(io.StringIO(text))
fieldnames = {(name or "").strip() for name in (reader.fieldnames or ())}
if not fieldnames >= REQUIRED_COLUMNS:
missing = ", ".join(sorted(REQUIRED_COLUMNS - fieldnames))
raise ParseError(f"CSV без обязательных колонок: {missing}")
acc = _Accumulator()
rows = _read_rows(reader, acc)
if not rows:
raise ParseError("CSV не содержит ни одной строки событий")
for row in rows:
_dispatch(row, acc)
events = _assign_seq(acc.drafts)
portfolio = _portfolio_name(filename)
report = ParsedReport(
broker=self.broker,
account_external_id=portfolio,
period_from=min(r.trade_date for r in rows),
period_to=max(r.trade_date for r in rows),
parser_version=self.version,
events=events,
positions_end=[],
cash_end=[],
instruments=list(acc.instruments.values()),
warnings=acc.warnings,
meta={
"portfolio_name": portfolio,
"manual_prices": acc.manual_prices,
"balance_adjustments": acc.balance_adjustments,
"row_count": len(rows),
},
)
_finalize_warnings(report, acc)
return report
# --- reading --------------------------------------------------------------------------
def _decode(data: bytes) -> str:
"""UTF-8 with an optional BOM, falling back to the cp1251 a Russian export may carry."""
for encoding in ("utf-8-sig", "cp1251"):
try:
return data.decode(encoding)
except UnicodeDecodeError:
continue
raise ParseError("не удалось определить кодировку CSV")
def _header(data: bytes) -> frozenset[str] | None:
"""The header's column names, read from the first line only (sniffing must be cheap)."""
head = data[:4096].decode("utf-8-sig", errors="replace")
line = head.splitlines()[0] if head.splitlines() else ""
if not line:
return None
names = next(csv.reader([line]), [])
return frozenset(name.strip() for name in names if name.strip())
def _read_rows(reader: csv.DictReader[str], acc: _Accumulator) -> list[_Row]:
"""Decode every data row; a row with an unreadable date is dropped with a warning.
The date is the one field with no sane default — it decides the period, the sort order
and every dedupe key — so a row without it cannot be salvaged.
"""
rows: list[_Row] = []
for raw in reader:
line_no = reader.line_num
event = _text(raw.get("Event")).upper()
if not event:
continue
ts = _timestamp(_text(raw.get("Date")))
if ts is None:
acc.warnings.append(
f"строка {line_no}: непонятная дата {_text(raw.get('Date'))!r}, строка пропущена"
)
continue
try:
rows.append(
_Row(
line_no=line_no,
event=event,
ts=ts,
symbol=_text(raw.get("Symbol")),
price=_decimal(raw.get("Price")),
quantity=_decimal(raw.get("Quantity")),
currency=_text(raw.get("Currency")).upper() or "RUB",
fee_tax=_decimal(raw.get("FeeTax")),
exchange=_text(raw.get("Exchange")).upper(),
nkd=_decimal(raw.get("NKD")),
fee_currency=_text(raw.get("FeeCurrency")).upper(),
note=_text(raw.get("Note")),
)
)
except InvalidOperation:
acc.warnings.append(f"строка {line_no}: нечисловое значение в числовой колонке")
return rows
def _text(value: str | None) -> str:
return (value or "").strip()
def _decimal(value: str | None) -> Decimal | None:
"""Decimal or None — never 0.
An empty cell and a zero are different facts: `FeeTax` of 0 on a trade means the broker
charged nothing, an empty `NKD` means the row is about a share and accrued interest is
not a concept there. Collapsing them would put a 0 ₽ НКД on every equity trade.
"""
raw = _text(value).translate(_SPACES).replace(",", ".")
if not raw:
return None
return Decimal(raw)
def _timestamp(value: str) -> datetime | None:
for fmt in _TIMESTAMP_FORMATS:
try:
return datetime.strptime(value, fmt)
except ValueError:
continue
return None
# --- row handlers ---------------------------------------------------------------------
def _dispatch(row: _Row, acc: _Accumulator) -> None:
if row.event in ("BUY", "SELL"):
_trade(row, acc)
elif row.event in ("CASH_IN", "CASH_OUT"):
_cash(row, acc)
elif row.event in ("DIVIDEND", "AMORTISATION"):
_payout(row, acc)
elif row.event in ("FEE", "TAX", "TAX_RETURN"):
_charge(row, acc)
elif row.event == "SPLIT":
_split(row, acc)
elif row.event == "CUSTOM_HOLDING_PRICE":
_manual_price(row, acc)
elif row.event == "CUSTOM_HOLDING_SETTINGS":
_custom_holding(row, acc)
else:
# neither a ledger event nor a recognised non-ledger one: make it visible
assert row.event not in EVENT_KINDS and row.event not in NON_LEDGER_EVENTS
acc.unknown_events[row.event] += 1
def _trade(row: _Row, acc: _Accumulator) -> None:
"""BUY/SELL: `Quantity` is units, `Price` is money per unit, `NKD` is the whole НКД.
Accrued interest is paid on top of the price by the buyer and received by the seller,
and the fee always leaves the account — hence the asymmetric signs below. Both are
already inside `amount`, which is what `base.py` promises ingest.
"""
qty = row.quantity or ZERO
price = row.price or ZERO
nkd = row.nkd or ZERO
fee = row.fee_tax
gross = qty * price + nkd
is_buy = row.event == "BUY"
amount = -(gross + (fee or ZERO)) if is_buy else gross - (fee or ZERO)
instrument = acc.add_instrument(_instrument(row))
_emit(
acc,
row,
kind=EventKind.buy if is_buy else EventKind.sell,
amount=amount,
instrument=instrument,
quantity=qty if is_buy else -qty,
price=price,
price_currency=row.currency,
fee=fee,
fee_currency=row.fee_currency or row.currency,
accrued_interest=nkd if row.nkd is not None else None,
)
def _cash(row: _Row, acc: _Accumulator) -> None:
"""CASH_IN/CASH_OUT: `Symbol` is a currency code, `Quantity` is the amount of money.
Snowball's own balance corrections wear the same event type and are filtered out here —
they are bookkeeping about the export, not money the user moved.
"""
amount = row.quantity or ZERO
currency = (row.symbol or row.currency).upper()
signed = amount if row.event == "CASH_IN" else -amount
if _is_balance_adjustment(row.note):
acc.balance_adjustments.append(
{
"kind": row.event,
"d": row.trade_date,
"amount": signed,
"currency": currency,
"note": row.note,
"line_no": row.line_no,
}
)
acc.warnings.append(
f"строка {row.line_no}: {row.event} на {signed} {currency} — это правка остатка "
"Snowball, а не движение денег; в леджер не попадает"
)
return
_emit(
acc,
row,
kind=EventKind.deposit if row.event == "CASH_IN" else EventKind.withdrawal,
amount=signed,
currency=currency,
)
def _payout(row: _Row, acc: _Accumulator) -> None:
"""DIVIDEND/AMORTISATION: `Quantity` is MONEY, not units, and `Price` is a filler zero.
Reading `Quantity` as a position here is the single most damaging mistake this format
invites: a 131,44 ₽ coupon would become 131,44 bonds in the lot engine.
"""
amount = row.quantity or ZERO
instrument = acc.add_instrument(_instrument(row))
meta: dict[str, Any] = {}
if row.event == "DIVIDEND" and _looks_like_bond(row.symbol):
meta["payout_hint"] = "coupon"
_emit(
acc,
row,
kind=EVENT_KINDS[row.event],
amount=amount,
instrument=instrument,
tax=row.fee_tax if row.fee_tax else None,
tax_currency=row.currency if row.fee_tax else None,
meta=meta,
)
def _charge(row: _Row, acc: _Accumulator) -> None:
"""FEE/TAX/TAX_RETURN: the money is in `FeeTax`; every other numeric column is zero.
These rows have no instrument even when the charge logically belongs to one — the format
does not say which — so they stay account-level, which is also how the ledger treats
them (`commission`/`tax` move cash and never touch a position).
"""
value = row.fee_tax or ZERO
is_refund = row.event == "TAX_RETURN"
_emit(
acc,
row,
kind=EVENT_KINDS[row.event],
amount=value if is_refund else -value,
fee=value if row.event == "FEE" else None,
fee_currency=row.fee_currency or row.currency if row.event == "FEE" else None,
tax=value if row.event != "FEE" else None,
tax_currency=row.fee_currency or row.currency if row.event != "FEE" else None,
)
def _split(row: _Row, acc: _Accumulator) -> None:
"""SPLIT: `Price` is the ratio, and no money or quantity moves.
The ratio goes to `meta` under both keys `corporate_actions._stated_ratio` looks for, so
the existing derivation picks it up as a *stated* ratio and never has to guess one from
a transfer pair.
"""
ratio = row.price
if ratio is None or ratio <= ZERO:
acc.warnings.append(f"строка {row.line_no}: SPLIT без коэффициента, строка пропущена")
return
instrument = acc.add_instrument(_instrument(row))
_emit(
acc,
row,
kind=EventKind.stock_split,
amount=ZERO,
instrument=instrument,
meta={"split_ratio": ratio, "ratio": ratio},
)
def _manual_price(row: _Row, acc: _Accumulator) -> None:
"""CUSTOM_HOLDING_PRICE: a quote for `price_manual`, not an event (module docstring)."""
if row.price is None:
acc.warnings.append(f"строка {row.line_no}: CUSTOM_HOLDING_PRICE без цены, пропущена")
return
instrument = acc.add_instrument(_instrument(row))
if instrument is None:
acc.warnings.append(f"строка {row.line_no}: CUSTOM_HOLDING_PRICE без инструмента")
return
acc.manual_prices.append(
{
"instrument_key": instrument.key(),
"d": row.trade_date,
"price": row.price,
"currency": row.currency,
}
)
def _custom_holding(row: _Row, acc: _Accumulator) -> None:
"""CUSTOM_HOLDING_SETTINGS: the instrument card of a user-defined holding.
`Note` is JSON whose quotes were rewritten to `@*@` so the CSV needs no escaping, and
whose non-ASCII text is `\\uXXXX`-escaped — `json.loads` undoes both. A row we cannot
read costs only the paper's human name, so it is a warning and the bare ticker survives.
"""
settings = _settings_json(row.note)
if settings is None:
acc.warnings.append(
f"строка {row.line_no}: не удалось разобрать JSON в CUSTOM_HOLDING_SETTINGS "
f"для {row.symbol or '?'}; инструмент остаётся без названия"
)
acc.add_instrument(_instrument(row))
return
holding = settings.get("Holding") or {}
ref = _instrument(row)
if ref is None:
acc.warnings.append(f"строка {row.line_no}: CUSTOM_HOLDING_SETTINGS без символа")
return
acc.add_instrument(
replace(
ref,
name=_text(holding.get("Description")) or ref.name,
currency=_text(holding.get("Currency")).upper() or ref.currency,
meta={
**ref.meta,
"custom_holding": True,
"sector": _text(holding.get("Sector")) or None,
"settings": settings.get("Settings") or {},
},
)
)
def _settings_json(note: str) -> dict[str, Any] | None:
if not note:
return None
try:
parsed = json.loads(note.replace(JSON_QUOTE_ESCAPE, '"'))
except (ValueError, TypeError):
return None
return parsed if isinstance(parsed, dict) else None
# --- helpers --------------------------------------------------------------------------
def _emit(
acc: _Accumulator,
row: _Row,
*,
kind: EventKind,
amount: Decimal,
currency: str | None = None,
instrument: InstrumentRef | None = None,
quantity: Decimal | None = None,
price: Decimal | None = None,
price_currency: str | None = None,
fee: Decimal | None = None,
fee_currency: str | None = None,
tax: Decimal | None = None,
tax_currency: str | None = None,
accrued_interest: Decimal | None = None,
meta: dict[str, Any] | None = None,
) -> None:
extra = dict(meta or {})
extra["csv_event"] = row.event
if row.exchange:
extra["exchange"] = row.exchange
acc.drafts.append(
_Draft(
event=BrokerEvent(
kind=kind,
trade_date=row.trade_date,
amount=amount,
currency=currency or row.currency,
instrument=instrument,
quantity=quantity,
price=price,
price_currency=price_currency,
fee=fee,
fee_currency=fee_currency,
tax=tax,
tax_currency=tax_currency,
accrued_interest=accrued_interest,
description=row.note or None,
raw_line_no=row.line_no,
meta=extra,
),
time_key=(row.ts.strftime("%H:%M:%S"), row.note),
)
)
def _assign_seq(drafts: list[_Draft]) -> list[BrokerEvent]:
"""Give each event the `seq` its fingerprint key needs (module docstring, «Dedup»).
Two passes on purpose: `seq` is a property of the *file*, not of the row, so it cannot
be known while the row is being read.
"""
ranks: dict[tuple[str, ...], list[tuple[str, str]]] = defaultdict(list)
for draft in drafts:
keys = ranks[_content_key(draft.event)]
if draft.time_key not in keys:
keys.append(draft.time_key)
for keys in ranks.values():
keys.sort()
return [
replace(
draft.event,
seq=ranks[_content_key(draft.event)].index(draft.time_key),
)
for draft in drafts
]
def _content_key(event: BrokerEvent) -> tuple[str, ...]:
"""Exactly what `fingerprint_key` hashes, minus `seq` — the thing `seq` has to break."""
return (
event.kind.value,
event.instrument.key() if event.instrument else "",
event.trade_date.isoformat(),
_norm(event.quantity),
_norm(event.price),
event.currency,
_norm(event.amount),
)
def _norm(value: Decimal | None) -> str:
if value is None:
return ""
normalized = value.normalize()
return "0" if normalized == 0 else format(normalized, "f")
def _instrument(row: _Row) -> InstrumentRef | None:
"""An `InstrumentRef` from a `Symbol` that may be an ISIN, a ticker or nothing at all."""
symbol = row.symbol
if not symbol:
return None
meta: dict[str, Any] = {}
if row.exchange:
meta["exchange"] = row.exchange
is_bond = _looks_like_bond(symbol)
if _looks_like_isin(symbol):
return InstrumentRef(
isin=symbol,
currency=row.currency,
asset_class_hint="bond" if is_bond else None,
meta=meta,
)
return InstrumentRef(
ticker=symbol,
currency=row.currency,
asset_class_hint="bond" if is_bond else None,
meta=meta,
)
def _looks_like_isin(symbol: str) -> bool:
"""A 12-character code with a valid ISIN check digit, excluding OFZ secids.
The checksum matters: `SIBN6P4` and `SBER` fail the shape, but a shape test alone would
happily call `SU26212RMFS9` an ISIN — and that one even passes the checksum, which is
why the OFZ secid pattern is excluded by name rather than by arithmetic.
"""
if not _ISIN_SHAPE.match(symbol) or _OFZ_SECID.match(symbol):
return False
digits = "".join(str(int(ch, 36)) for ch in symbol)
total = 0
for i, ch in enumerate(reversed(digits)):
n = int(ch)
if i % 2 == 1:
n *= 2
if n > 9:
n -= 9
total += n
return total % 10 == 0
def _looks_like_bond(symbol: str) -> bool:
"""Bond-shaped symbol: a real ISIN, or the OFZ secid MOEX and brokers both print.
Deliberately narrow. It only feeds a *hint*: a false positive would tell ingest to look
for a coupon where a dividend was paid, and ingest would believe it until the instrument
resolves. Papers with neither shape (a custom holding like `SIBN6P4`, which really is a
bond) simply get no hint and are classified from the resolved instrument instead.
"""
return bool(_OFZ_SECID.match(symbol)) or _looks_like_isin(symbol)
def _is_balance_adjustment(note: str) -> bool:
lowered = note.lower()
return any(marker in lowered for marker in BALANCE_ADJUSTMENT_MARKERS)
def _portfolio_name(filename: str) -> str:
"""The portfolio the export belongs to, dug out of `Snowball_Export_<name>_<date>.csv`.
It is not an account number and must never be mistaken for one — see the warning in
`_finalize_warnings`. When the filename says nothing, its stem is still a better label
than an empty string, because the user sees it in the import preview.
"""
stem = filename.rsplit("/", 1)[-1]
if "." in stem:
stem = stem.rsplit(".", 1)[0]
match = _FILENAME.match(stem)
raw = match.group("name") if match else stem
return _NAME_EDGE.sub("", raw.strip()) or stem
def _finalize_warnings(report: ParsedReport, acc: _Accumulator) -> None:
"""Everything the importer must be told before it commits this file."""
for event_type, count in sorted(acc.unknown_events.items()):
report.warnings.append(f"неизвестный тип события {event_type!r}: пропущено строк — {count}")
report.meta["unknown_events"] = dict(acc.unknown_events)
report.warnings.append(
"выгрузка не содержит номера счёта: события покрывают все брокерские счета сразу, "
f"целевой счёт обязан указать пользователь (портфель из имени файла — "
f"{report.account_external_id!r}); у счёта один primary_event_source, поэтому "
"почти все события лягут со status = shadow и годятся как сверка, а не как леджер"
)
report.warnings.append(
"формат не содержит закрывающих позиций и остатков денег — сверка positions_end / "
"cash_end по этой выгрузке невозможна"
)
if acc.balance_adjustments:
report.warnings.append(
f"правок остатка, исключённых из леджера: {len(acc.balance_adjustments)} "
"(см. meta['balance_adjustments'])"
)
if acc.manual_prices:
report.warnings.append(
f"ручных цен для price_manual: {len(acc.manual_prices)} (см. meta['manual_prices'])"
)
__all__ = [
"BALANCE_ADJUSTMENT_MARKERS",
"EVENT_KINDS",
"NON_LEDGER_EVENTS",
"REQUIRED_COLUMNS",
"UniversalCsvParser",
]
@@ -0,0 +1,48 @@
"""Which parsers exist and which one claims a given upload (plan §2: «список в registry.py»).
Order matters: `pick` returns the first parser whose `sniff` accepts the bytes, so a narrow
format (a broker's own HTML) must precede a broad one (any CSV). Entry points would buy
nothing here — the set of parsers is known at build time.
"""
from __future__ import annotations
from fintracker.sources.reports.base import ReportParser
from fintracker.sources.reports.csv_universal import UniversalCsvParser
from fintracker.sources.reports.sber import SberHtmlParser
from fintracker.sources.reports.vtb import VtbXlsxParser
#: Narrow formats first, broad ones last: the universal CSV recognises any file with its
#: column set, so it must not get a chance to claim a broker's own export.
PARSERS: list[ReportParser] = [
SberHtmlParser(),
VtbXlsxParser(),
UniversalCsvParser(),
]
def register(parser: ReportParser) -> ReportParser:
PARSERS.append(parser)
return parser
def pick(data: bytes, filename: str) -> ReportParser | None:
"""The first parser that recognises the file, or None when no format matches."""
for parser in PARSERS:
try:
if parser.sniff(data, filename):
return parser
except Exception:
continue
return None
def get(name: str) -> ReportParser:
for parser in PARSERS:
if parser.name == name:
return parser
raise KeyError(name)
def names() -> list[str]:
return [p.name for p in PARSERS]
@@ -0,0 +1,11 @@
"""Sber broker report parsers.
Sber hands out the same report as HTML and as xlsx; the HTML one is what the user actually
downloads, so it is what exists here. A future xlsx parser sits next to it and shares
nothing but the `ReportParser` protocol — the two layouts differ enough that a common base
would be a fiction.
"""
from fintracker.sources.reports.sber.html import SberHtmlParser, event_dedupe_key
__all__ = ["SberHtmlParser", "event_dedupe_key"]
@@ -0,0 +1,812 @@
"""Sber's «Отчет брокера» in HTML → `ParsedReport` (plan §2, фаза 3).
The file is a printout, not an export: nine `<table>` elements, each introduced by a bold
`<p>`, with two-level headers, footnote markers glued into header cells, and «Итого» rows
mixed in with the data. So the parser keys off the section *title* and off the *cell count*
of a row, never off a column index taken from the header — the header of the portfolio
section alone spans three rows and eighteen columns.
Four decisions are worth stating, because each of them is a choice between two readings of
the same numbers and the wrong one silently doubles money in the ledger.
**1. Commission is capitalised into the trade, never emitted on its own.** Sber prints the
same fee twice: as «Комиссия Брокера»/«Комиссия Биржи» columns on the trade row, and again
as a dated «Комиссия Брокера от DD.MM.YYYY» line in the cash movement section (aggregated
per settlement day — 22.04's 0.60 and 1.27 arrive as one 1.87 line). Both renderings are
verified to agree on the fixtures: the trade columns total 138.03 / 19.54, and the cash
lines total exactly the same. Only the trade columns are kept, as `BrokerEvent.fee`, which
`ledger/lots.py` capitalises into the lot cost; emitting the cash lines as well would put
that same money into the ledger a second time as a separate `commission` outflow. The
parser re-runs this arithmetic on every file and warns when the two sides disagree, because
a disagreement means one of the two carries a fee the other does not, and the choice would
then have to be revisited.
**2. «Сделка от DD.MM.YYYY» lines are dropped.** They are the T+1 cash settlement of a
trade already recorded from the trades section, netted per day (the 23.04 line nets a sale
and a purchase). Emitting them would double every trade's cash effect.
**3. «Перевод д/с с договора X» becomes a `deposit` carrying
`meta["internal_transfer_from"]`.** Economically it *is* a deposit for this account, and
dropping it would break the cash reconciliation — Sber's own «Пополнение счета» summary
counts it. But the money came from another agreement of the same broker, which the user may
later add as its own account; at that point the same rubles would be a withdrawal there and
a deposit here, and XIRR would see one external flow where there is none. The counterparty
number is therefore preserved on the event so `ledger/matching.py` can pair the two legs
instead of guessing from amounts and dates.
**4. «Информация о зачислениях денежных средств на ИИС» is read but never emitted.** That
table is cumulative *for the calendar year*, not for the report period: the August file
lists top-ups back to February. Feeding it to the ledger would inflate every account whose
history is imported from more than one file.
Two further notes on identity. Trades carry Sber's own deal number, stable across
re-exports, so they dedupe on `trade_dedupe_key`. Cash rows have no number, so their
`seq` is their index among the rows of the same `(kind, trade_date, amount)` *within the
report* — not their line number, which differs between a full-history file and a monthly
one describing the same operation.
What the format does not contain at all, on either fixture: dividends, taxes, and any
coupon section (only footnote ⁸ mentions coupon income). A «Купонный доход» table is
handled generically if one ever appears, and its absence is recorded in `warnings` rather
than assumed away.
"""
from __future__ import annotations
import re
from collections import Counter
from dataclasses import dataclass, field, replace
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from typing import Any
import lxml.html
from lxml.html import HtmlElement
from fintracker.models.ledger import EventKind
from fintracker.sources.reports.base import (
BrokerEvent,
CashEnd,
InstrumentRef,
ParsedReport,
ParseError,
PositionEnd,
fingerprint_key,
trade_dedupe_key,
)
BROKER = "sber"
SOURCE = "report_sber"
#: Whitespace Sber uses as a thousands separator, plus the ordinary space.
_SPACES = dict.fromkeys(map(ord, "  "), None)
_PERIOD_RE = re.compile(r"за период с\s+(\d{2}\.\d{2}\.\d{4})\s+по\s+(\d{2}\.\d{2}\.\d{4})")
#: «Договор на ведение индивидуального инвестиционного счета S930W42 от 11.02.2026» — the
#: number is simply the token before «от DD.MM.YYYY», which also fits «Договор … № 12345 от».
_AGREEMENT_RE = re.compile(r"Договор[^\n]{0,200}?(\S+)\s+от\s+(\d{2}\.\d{2}\.\d{4})")
_TRADING_CODE_RE = re.compile(r"Торговый код:\s*(\S+)")
_TRANSFER_FROM_RE = re.compile(r"Перевод д/с с договора\s+([^\s,]+)")
_TRANSFER_TO_RE = re.compile(r"Перевод д/с[^,]{0,80}на договор\s+([^\s,]+)")
#: Section titles, longest-first so «Движение денежных средств за период» is not mistaken
#: for «Денежные средства».
_SECTIONS: tuple[tuple[str, str], ...] = (
("Оценка активов", "assets"),
("Сводная информация по движению денежных средств", "summary"),
("Информация о зачислениях денежных средств на ИИС", "iis"),
("Портфель Ценных Бумаг", "portfolio"),
("Движение денежных средств за период", "cash_flow"),
("Денежные средства", "cash"),
("Сделки купли/продажи ценных бумаг", "trades"),
("Справочник Ценных Бумаг", "securities"),
)
#: «Вид, Категория, Тип» of the securities directory → `InstrumentRef.asset_class_hint`.
#: Checked as substrings in this order: «Биржевой фонд» must win over «фонд» inside a name.
_ASSET_CLASS_HINTS: tuple[tuple[str, str], ...] = (
("биржевой фонд", "etf"),
("инвестиционный пай", "fund"),
("пай", "fund"),
("облигаци", "bond"),
("акци", "share"),
("депозитарная расписка", "share"),
)
def _text(el: HtmlElement) -> str:
return " ".join(el.text_content().split())
def _decimal(raw: str) -> Decimal | None:
"""A Sber number, or None for an empty cell. Never float — not even transiently."""
cleaned = raw.translate(_SPACES).replace(",", ".").lstrip("+")
if not cleaned or cleaned == "-":
return None
try:
return Decimal(cleaned)
except InvalidOperation:
return None
def _date(raw: str) -> date | None:
try:
return datetime.strptime(raw.strip(), "%d.%m.%Y").date()
except ValueError:
return None
def _is_numbering(cells: list[str]) -> bool:
"""The «1 2 3 …» row Sber prints under every header to number the columns."""
return len(cells) > 1 and all(c.isdigit() for c in cells)
def _is_total(cells: list[str]) -> bool:
return bool(cells) and cells[0].startswith("Итого")
@dataclass
class _Directory:
"""The securities reference, indexed for enrichment of trades and positions."""
refs: list[InstrumentRef] = field(default_factory=list)
by_ticker: dict[str, InstrumentRef] = field(default_factory=dict)
by_isin: dict[str, InstrumentRef] = field(default_factory=dict)
by_name: dict[str, InstrumentRef] = field(default_factory=dict)
def add(self, ref: InstrumentRef) -> None:
self.refs.append(ref)
if ref.ticker:
self.by_ticker.setdefault(ref.ticker, ref)
if ref.isin:
self.by_isin.setdefault(ref.isin, ref)
if ref.name:
self.by_name.setdefault(ref.name, ref)
class SberHtmlParser:
"""Reads one «Отчет брокера» HTML file. Pure: bytes in, `ParsedReport` out."""
broker = BROKER
name = SOURCE
formats: tuple[str, ...] = ("html", "htm")
version = "1"
def sniff(self, data: bytes, filename: str) -> bool:
"""True for a Sber broker report. Content-based — the filename is a hash for VTB
and a template name for Sber, so neither is evidence of anything."""
try:
head = data[:1_000_000].decode("utf-8", errors="ignore")
except Exception: # pragma: no cover - decode with errors="ignore" does not raise
return False
if "<html" not in head.lower():
return False
if "Отчет брокера" not in head:
return False
return any(
marker in head
for marker in ("Сделки купли/продажи ценных бумаг", "Портфель Ценных Бумаг")
)
def parse(self, data: bytes, filename: str) -> ParsedReport:
return _Parse(data, filename).run()
parser = SberHtmlParser()
def event_dedupe_key(report: ParsedReport, event: BrokerEvent) -> str:
"""The plan's §1.6 A key for one parsed row.
Lives next to the parser because only the parser knows which of its rows carry a deal
number: a trade keys on Sber's number and nothing else, while a cash row has to fall
back on its economic fingerprint plus the `seq` the parser assigned.
"""
if event.trade_no:
return trade_dedupe_key(report.broker, report.account_external_id, event.trade_no)
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,
)
class _Parse:
"""One file's parse. A short-lived object so the row counter and the warning list do
not have to be threaded through a dozen functions."""
def __init__(self, data: bytes, filename: str) -> None:
self.data = data
self.filename = filename
self.warnings: list[str] = []
self.meta: dict[str, Any] = {"filename": filename}
self.line_no = 0
self.directory = _Directory()
self.events: list[BrokerEvent] = []
self.positions: list[PositionEnd] = []
self.cash: list[CashEnd] = []
self.summary: dict[str, Decimal] = {}
#: Fee lines of the cash section, kept only to check them against the trade columns.
self.cash_fees = Decimal(0)
self.trade_fees = Decimal(0)
# --- driver -----------------------------------------------------------------
def run(self) -> ParsedReport:
root = self._root()
body = root.body if root.body is not None else root
text = _text(body)
period_from, period_to = self._period(text)
account = self._account(text)
sections = self._sections(body)
# The securities directory is the last section but the first one needed: trades name
# a paper by ticker only, positions by ISIN only, and both want the asset class.
for title, kind, table in sections:
if kind == "securities":
self._parse_securities(title, table)
for title, kind, table in sections:
handler = {
"summary": self._parse_summary,
"iis": self._parse_iis,
"portfolio": self._parse_portfolio,
"cash": self._parse_cash,
"cash_flow": self._parse_cash_flow,
"trades": self._parse_trades,
"coupons": self._parse_coupons,
# "securities" is deliberately absent: it was already read above, and
# reading it twice would duplicate every instrument reference.
}.get(kind)
if handler is not None:
handler(title, table)
self._check_fees()
self._check_deposits()
if not any(kind == "coupons" for _, kind, _ in sections):
self.warnings.append(
"секции «Купонный доход» в отчёте нет — купонные выплаты могли прийти только "
"строкой движения денежных средств"
)
report = ParsedReport(
broker=BROKER,
account_external_id=account,
period_from=period_from,
period_to=period_to,
parser_version=SberHtmlParser.version,
events=self.events,
positions_end=self.positions,
cash_end=self.cash,
instruments=list(self.directory.refs),
warnings=self.warnings,
meta=self.meta,
)
return report
def _root(self) -> HtmlElement:
if not self.data.strip():
raise ParseError(f"{self.filename}: пустой файл")
try:
root = lxml.html.fromstring(self.data)
except Exception as exc: # lxml raises several unrelated types on garbage input
raise ParseError(f"{self.filename}: не разбирается как HTML: {exc}") from exc
if root is None:
raise ParseError(f"{self.filename}: не разбирается как HTML")
return root
def _period(self, text: str) -> tuple[date, date]:
found = _PERIOD_RE.search(text)
if not found:
raise ParseError(f"{self.filename}: не найден период «за период с … по …»")
start, end = _date(found.group(1)), _date(found.group(2))
if start is None or end is None:
raise ParseError(f"{self.filename}: период не разбирается: {found.group(0)}")
return start, end
def _account(self, text: str) -> str:
found = _AGREEMENT_RE.search(text)
if found:
self.meta["agreement_opened_at"] = found.group(2)
opened = _date(found.group(2))
if opened is not None:
self.meta["opened_at"] = opened.isoformat()
return found.group(1).lstrip("").strip()
code = _TRADING_CODE_RE.search(text)
if code:
self.warnings.append("номер договора не найден, взят «Торговый код»")
return code.group(1)
raise ParseError(f"{self.filename}: не найден номер договора")
def _sections(self, body: HtmlElement) -> list[tuple[str, str, HtmlElement]]:
"""Pair each table with the bold `<p>` that introduces it.
A title is consumed by the first table after it, so the unlabelled signature table
at the end does not get parsed as a second securities directory.
"""
found: list[tuple[str, str, HtmlElement]] = []
pending: str | None = None
seen: list[HtmlElement] = []
for el in body.iter("p", "table"):
if el.tag == "p":
title = _text(el)
if title:
pending = title
continue
if any(el in table.iterdescendants("table") for table in seen):
continue # a nested table is part of its parent's rows
seen.append(el)
title, pending = pending, None
if title is None:
if "Подпись" not in _text(el):
self.warnings.append("таблица без заголовка пропущена")
continue
kind = self._section_kind(title)
if kind is None:
self.warnings.append(f"неизвестная секция: {title[:120]}")
continue
found.append((title, kind, el))
return found
@staticmethod
def _section_kind(title: str) -> str | None:
if "Купонный доход" in title:
return "coupons"
for prefix, kind in _SECTIONS:
if title.startswith(prefix):
return kind
return None
# --- row plumbing -----------------------------------------------------------
def _rows(self, table: HtmlElement, width: int, section: str) -> list[list[str]]:
"""Data rows of `width` cells, with headers, numbering, «Площадка:» and «Итого»
filtered out. Anything else of an unexpected shape becomes a warning, never a
silent skip — a column added by Sber must be visible, not absorbed."""
rows: list[list[str]] = []
for tr in table.iter("tr"):
cells = [_text(td) for td in tr.iterchildren("td", "th")]
if not cells or not any(cells):
continue
if _is_numbering(cells) or _is_total(cells):
continue
if len(cells) == 1:
continue # «Площадка: Фондовый рынок» and other spanning captions
if len(cells) != width and not any(any(ch.isdigit() for ch in c) for c in cells):
continue # an upper header row of a two-level header: «Начало периода» …
if len(cells) != width:
self.warnings.append(
f"{section}: строка из {len(cells)} ячеек вместо {width}: {cells[:3]}"
)
continue
rows.append(cells)
return rows
@staticmethod
def _drop_header(rows: list[list[str]], first_cell: str) -> list[list[str]]:
return [r for r in rows if r[0] != first_cell]
def _next_line(self) -> int:
self.line_no += 1
return self.line_no
def _total_row(self, table: HtmlElement) -> list[str] | None:
for tr in table.iter("tr"):
cells = [_text(td) for td in tr.iterchildren("td", "th")]
if _is_total(cells):
return cells
return None
# --- sections ---------------------------------------------------------------
def _parse_summary(self, title: str, table: HtmlElement) -> None:
"""«Сводная информация …»: Описание | Сумма | Валюта — self-check material only."""
for cells in self._drop_header(self._rows(table, 3, title), "Описание"):
value = _decimal(cells[1])
if value is not None:
self.summary[cells[0]] = value
self.meta["summary"] = {k: str(v) for k, v in self.summary.items()}
def _parse_iis(self, title: str, table: HtmlElement) -> None:
"""Deliberately not emitted: the table is cumulative for the calendar year, so the
August file repeats February's top-ups and importing both files would double them."""
rows = self._drop_header(self._rows(table, 6, title), "Год")
self.meta["iis_contributions_ignored"] = len(rows)
def _parse_portfolio(self, title: str, table: HtmlElement) -> None:
"""«Портфель Ценных Бумаг» → `positions_end`.
18 columns: 0 name, 1 ISIN, 2 currency, 3-7 opening, 8-12 closing, 13-14 change,
15-17 planned. Closing is what reconciliation compares against, so only 8-12 are read.
"""
for cells in self._drop_header(self._rows(table, 18, title), "Наименование"):
qty = _decimal(cells[8])
if qty is None:
self.warnings.append(f"{title}: позиция без количества: {cells[0]}")
continue
self._next_line()
isin = cells[1] or None
known = self.directory.by_isin.get(isin or "") or self.directory.by_name.get(cells[0])
self.positions.append(
PositionEnd(
instrument=InstrumentRef(
isin=isin,
ticker=known.ticker if known else None,
name=cells[0] or None,
currency=cells[2] or None,
asset_class_hint=known.asset_class_hint if known else None,
source_key=f"ISIN:{isin}" if isin else f"NAME:{cells[0]}",
),
qty=qty,
price=_decimal(cells[10]),
market_value=_decimal(cells[11]),
currency=cells[2] or None,
accrued_interest=_decimal(cells[12]),
)
)
def _parse_cash(self, title: str, table: HtmlElement) -> None:
"""«Денежные средства» → `cash_end`: column 5 is the closing balance per currency."""
for cells in self._drop_header(self._rows(table, 9, title), "Торговая площадка"):
balance = _decimal(cells[5])
if balance is None:
self.warnings.append(f"{title}: остаток не разобран: {cells[:3]}")
continue
self._next_line()
self.cash.append(CashEnd(currency=cells[1], balance=balance))
def _parse_cash_flow(self, title: str, table: HtmlElement) -> None:
"""«Движение денежных средств за период» → deposits, withdrawals and payouts.
Trade settlements and fee lines are dropped here (see the module docstring); what
remains is money crossing the account boundary, which is what XIRR is computed on.
"""
rows = self._drop_header(self._rows(table, 6, title), "Дата")
credits_total = Decimal(0)
debits_total = Decimal(0)
staged: list[BrokerEvent] = []
for cells in rows:
when = _date(cells[0])
if when is None:
self.warnings.append(f"{title}: дата не разобрана: {cells[:3]}")
continue
self._next_line()
description = cells[2]
currency = cells[3] or "RUB"
credit = _decimal(cells[4]) or Decimal(0)
debit = _decimal(cells[5]) or Decimal(0)
credits_total += credit
debits_total += debit
amount = credit - debit
if description.startswith("Сделка от"):
continue # settlement of a trade already taken from the trades section
if description.startswith(("Комиссия Брокера", "Комиссия Биржи")):
self.cash_fees += debit - credit
continue # capitalised into the trade's `fee`
kind, meta = self._classify_cash(description, amount)
if kind is None:
self.warnings.append(f"{title}: неизвестная операция «{description}»")
kind = EventKind.other
meta["section"] = title
staged.append(
BrokerEvent(
kind=kind,
trade_date=when,
amount=amount,
currency=currency,
description=description,
raw_line_no=self.line_no,
meta=meta,
)
)
self._check_cash_flow_total(title, table, credits_total, debits_total)
self.events.extend(self._numbered(staged))
@staticmethod
def _classify_cash(
description: str, amount: Decimal
) -> tuple[EventKind | None, dict[str, Any]]:
"""Description → kind. Sber writes free text, so this matches on the stable head of
each phrase and refuses to guess: an unrecognised row becomes `other` plus a warning
rather than a deposit inferred from its sign."""
meta: dict[str, Any] = {}
lowered = description.lower()
transfer_from = _TRANSFER_FROM_RE.search(description)
if transfer_from:
# Another agreement of the same broker. Kept as a deposit because it really does
# fund this account, but tagged so the other leg can be recognised later and the
# pair not counted as two external flows.
meta["internal_transfer_from"] = transfer_from.group(1)
return (EventKind.deposit if amount >= 0 else EventKind.withdrawal), meta
transfer_to = _TRANSFER_TO_RE.search(description)
if transfer_to:
meta["internal_transfer_to"] = transfer_to.group(1)
return (EventKind.withdrawal if amount <= 0 else EventKind.deposit), meta
if "купон" in lowered:
return EventKind.coupon, meta
if "дивиденд" in lowered:
return EventKind.dividend, meta
if "налог" in lowered:
return (EventKind.tax if amount <= 0 else EventKind.tax_refund), meta
if lowered.startswith("зачисление д/с"):
return EventKind.deposit, meta
if lowered.startswith(("вывод д/с", "списание д/с", "снятие д/с")):
return EventKind.withdrawal, meta
return None, meta
def _parse_trades(self, title: str, table: HtmlElement) -> None:
"""«Сделки купли/продажи ценных бумаг» → buy/sell events.
16 columns: 0 trade date, 1 settle date, 2 time, 3 name, 4 ticker, 5 currency,
6 side, 7 qty, 8 price, 9 amount, 10 accrued interest, 11 broker fee,
12 exchange fee, 13 deal number, 14 comment, 15 status.
"""
gross = Decimal(0)
accrued_total = Decimal(0)
broker_total = Decimal(0)
exchange_total = Decimal(0)
for cells in self._drop_header(self._rows(table, 16, title), "Дата заключения"):
trade_date = _date(cells[0])
qty = _decimal(cells[7])
price = _decimal(cells[8])
amount = _decimal(cells[9])
if trade_date is None or qty is None or amount is None:
self.warnings.append(f"{title}: сделка не разобрана: {cells[:5]}")
continue
accrued = _decimal(cells[10]) or Decimal(0)
broker_fee = _decimal(cells[11]) or Decimal(0)
exchange_fee = _decimal(cells[12]) or Decimal(0)
side = cells[6]
if side.startswith("Покупка"):
kind = EventKind.buy
quantity = qty
cash = -(amount + accrued + broker_fee + exchange_fee)
elif side.startswith("Продажа"):
kind = EventKind.sell
quantity = -qty
cash = amount + accrued - broker_fee - exchange_fee
else:
self.warnings.append(f"{title}: неизвестный вид сделки «{side}»")
continue
gross += amount
accrued_total += accrued
broker_total += broker_fee
exchange_total += exchange_fee
self._next_line()
currency = cells[5] or "RUB"
known = self.directory.by_ticker.get(cells[4]) or self.directory.by_name.get(cells[3])
instrument = InstrumentRef(
isin=known.isin if known else None,
ticker=cells[4] or (known.ticker if known else None),
name=cells[3] or None,
currency=currency,
asset_class_hint=known.asset_class_hint if known else None,
source_key=(f"ISIN:{known.isin}" if known and known.isin else f"TICKER:{cells[4]}"),
)
meta: dict[str, Any] = {"section": title, "status": cells[15] or None}
if cells[2]:
meta["trade_time"] = cells[2]
if cells[14]:
meta["comment"] = cells[14]
self.events.append(
BrokerEvent(
kind=kind,
trade_date=trade_date,
settle_date=_date(cells[1]),
amount=cash,
currency=currency,
instrument=instrument,
quantity=quantity,
price=price,
price_currency=currency,
fee=broker_fee + exchange_fee,
fee_currency=currency,
accrued_interest=accrued or None,
trade_no=cells[13] or None,
description=f"{side} {cells[3]}".strip(),
raw_line_no=self.line_no,
meta=meta,
)
)
self.trade_fees = broker_total + exchange_total
self._check_trades_total(title, table, gross, accrued_total, broker_total, exchange_total)
def _parse_securities(self, title: str, table: HtmlElement) -> None:
"""«Справочник Ценных Бумаг» → `instruments`: 0 name, 1 ticker, 2 ISIN, 3 issuer,
4 kind/category, 5 issue."""
for cells in self._drop_header(self._rows(table, 6, title), "Наименование"):
isin = cells[2] or None
ticker = cells[1] or None
if not isin and not ticker:
self.warnings.append(f"{title}: бумага без ISIN и тикера: {cells[0]}")
continue
self._next_line()
self.directory.add(
InstrumentRef(
isin=isin,
ticker=ticker,
name=cells[0] or None,
asset_class_hint=_asset_class_hint(cells[4]),
source_key=f"ISIN:{isin}" if isin else f"TICKER:{ticker}",
meta={"issuer": cells[3] or None, "issue": cells[5] or None, "kind": cells[4]},
)
)
def _parse_coupons(self, title: str, table: HtmlElement) -> None:
"""A «Купонный доход» table, should Sber ever print one.
Neither fixture contains this section — only footnote ⁸ refers to coupon income —
so the layout is unknown and the parser reads it by header name rather than by
column position, and says so in `warnings` instead of pretending it is verified.
"""
rows = [
[_text(td) for td in tr.iterchildren("td", "th")]
for tr in table.iter("tr")
if any(_text(td) for td in tr.iterchildren("td", "th"))
]
header = next((r for r in rows if any("ата" in c for c in r)), None)
if header is None:
self.warnings.append(f"{title}: не найден заголовок таблицы, секция пропущена")
return
index = {
key: i
for i, cell in enumerate(header)
for key in ("date", "amount", "isin", "name", "qty", "tax")
if _matches_column(cell, key)
}
if "date" not in index or "amount" not in index:
self.warnings.append(f"{title}: нет колонок даты и суммы, секция пропущена")
return
staged: list[BrokerEvent] = []
for cells in rows:
if cells is header or _is_numbering(cells) or _is_total(cells):
continue
if len(cells) != len(header):
continue
when = _date(cells[index["date"]])
amount = _decimal(cells[index["amount"]])
if when is None or amount is None:
continue
self._next_line()
isin = cells[index["isin"]] if "isin" in index else None
known = self.directory.by_isin.get(isin or "")
staged.append(
BrokerEvent(
kind=EventKind.coupon,
trade_date=when,
amount=amount,
currency="RUB",
instrument=known
or (InstrumentRef(isin=isin, source_key=f"ISIN:{isin}") if isin else None),
quantity=_decimal(cells[index["qty"]]) if "qty" in index else None,
tax=_decimal(cells[index["tax"]]) if "tax" in index else None,
description=cells[index["name"]] if "name" in index else None,
raw_line_no=self.line_no,
meta={"section": title},
)
)
self.warnings.append(
f"{title}: секция разобрана эвристикой по названиям колонок, фикстуры для неё нет"
)
self.events.extend(self._numbered(staged))
# --- self-checks ------------------------------------------------------------
@staticmethod
def _numbered(events: list[BrokerEvent]) -> list[BrokerEvent]:
"""Assign `seq` within `(kind, trade_date, amount)` groups.
The point is the overlapping-period case: the same 10 551.18 ₽ transfer of 13.08
is row 24 of the full-history file and row 1 of the August file. Numbering within
the group makes it 0 in both, so the fingerprint key matches and the second import
upserts instead of duplicating.
"""
counter: Counter[tuple[str, date, Decimal]] = Counter()
out: list[BrokerEvent] = []
for event in events:
group = (event.kind.value, event.trade_date, event.amount)
seq = counter[group]
counter[group] += 1
out.append(replace(event, seq=seq))
return out
def _check_trades_total(
self,
title: str,
table: HtmlElement,
gross: Decimal,
accrued: Decimal,
broker_fee: Decimal,
exchange_fee: Decimal,
) -> None:
"""«Итого, RUB | сумма | НКД | комиссия брокера | комиссия биржи»."""
total = self._total_row(table)
if total is None or len(total) < 5:
self.warnings.append(f"{title}: итоговая строка не найдена")
return
for label, stated, computed in (
("сумма сделок", _decimal(total[1]), gross),
("НКД", _decimal(total[2]), accrued),
("комиссия брокера", _decimal(total[3]), broker_fee),
("комиссия биржи", _decimal(total[4]), exchange_fee),
):
if stated is not None and stated != computed:
self.warnings.append(
f"{title}: {label} не сходится с итогом: {computed} против {stated}"
)
def _check_cash_flow_total(
self, title: str, table: HtmlElement, credits: Decimal, debits: Decimal
) -> None:
total = self._total_row(table)
if total is None or len(total) < 3:
self.warnings.append(f"{title}: итоговая строка не найдена")
return
stated_credit, stated_debit = _decimal(total[1]), _decimal(total[2])
if stated_credit is not None and stated_credit != credits:
self.warnings.append(
f"{title}: зачисления не сходятся с итогом: {credits} против {stated_credit}"
)
if stated_debit is not None and stated_debit != debits:
self.warnings.append(
f"{title}: списания не сходятся с итогом: {debits} против {stated_debit}"
)
def _check_fees(self) -> None:
"""The whole basis for capitalising fees: both renderings must state the same money.
If they ever diverge, one of them carries a fee the other does not and dropping the
cash lines would lose it — so the divergence has to surface, not be assumed away.
"""
if self.cash_fees != self.trade_fees:
self.warnings.append(
"комиссии расходятся: в сделках "
f"{self.trade_fees}, отдельными строками движения денег {self.cash_fees}; "
"в события попали только комиссии из сделок"
)
def _check_deposits(self) -> None:
"""«Пополнение счета» of the summary counts transfers from the broker's other
agreements alongside ordinary top-ups, so both kinds are summed here."""
stated = self.summary.get("Пополнение счета")
if stated is None:
return
parsed = sum(
(e.amount for e in self.events if e.kind == EventKind.deposit),
start=Decimal(0),
)
if parsed != stated:
self.warnings.append(f"пополнения не сходятся со сводкой: {parsed} против {stated}")
def _asset_class_hint(kind: str) -> str | None:
lowered = kind.lower()
for marker, hint in _ASSET_CLASS_HINTS:
if marker in lowered:
return hint
return None
def _matches_column(cell: str, key: str) -> bool:
lowered = cell.lower()
return {
"date": "дата" in lowered,
"amount": "сумма" in lowered and "налог" not in lowered,
"isin": "isin" in lowered,
"name": "наименование" in lowered,
"qty": "количество" in lowered,
"tax": "налог" in lowered,
}[key]
@@ -0,0 +1,10 @@
"""VTB brokerage report parsers.
`common.py` holds the format-independent half (what a row means, how a key is built),
`xlsx.py` the container-specific half. A pdf rendering of the same report would be a third
module reusing `common.py` — the contract it has to honour is written down there.
"""
from fintracker.sources.reports.vtb.xlsx import VtbXlsxParser
__all__ = ["VtbXlsxParser"]
@@ -0,0 +1,584 @@
"""Everything in a VTB report that does not depend on the container it arrived in.
VTB renders one and the same back-office report as xlsx and as pdf. The wording of every
section, the vocabulary of its columns and the sign conventions are identical in both; only
the way one gets at a value differs — a cell address versus a position on a page. So the
split here is deliberate: `xlsx.py` is allowed to know about worksheets and merged cells and
nothing else, while this module owns the meaning — how «EQMX ETF, 3965, RU000A101EJ5» turns
into an `InstrumentRef`, what «Вид сделки: Продажа» does to the sign of a quantity, and how a
row becomes a `BrokerEvent` in `event`'s conventions.
**The contract a future `vtb/pdf.py` must satisfy.** Two files describing the same month
must produce the same `dedupe_key` for the same deal, otherwise a re-import doubles the
ledger. The key of a trade is `sha1(broker | account_external_id | trade_no)`, so a pdf
parser has to extract exactly three things identically:
* `broker` — the constant `BROKER` here, never derived from the file;
* `account_external_id` — the **agreement number** («№ и дата Соглашения»), the same string
in both renderings, and not the personal account number («Лицевой счет»), which is
personal data we never carry;
* `trade_no` — column «№ сделки» (`Z` in xlsx, e.g. `B17476399721`), **not** «№ сделки у
организатора торгов» (`AC`, `17476399721`). The key is already scoped by broker and
account, so exchange-wide uniqueness buys nothing; what matters is that VTB prints the
same «№ сделки» in every rendering of its own report, while the organiser's number is
absent for over-the-counter deals in some formats and is the exchange's identifier, not
VTB's.
Cash rows carry no deal number and fall back to `fingerprint_key`, which needs the date, the
signed amount, the currency and a stable `seq` — for a pdf parser `seq` must be the row's
index **inside its own section**, counted the same way as here, not a page line number.
Everything else a pdf parser extracts (the venue, the order number, the counterparty) lands
in `meta` and never touches a key, so the two formats may legitimately differ there.
"""
from __future__ import annotations
import re
from datetime import date, datetime, timedelta
from decimal import Decimal, InvalidOperation
from typing import Any
from fintracker.models.ledger import EventKind
from fintracker.sources.reports.base import (
BrokerEvent,
InstrumentRef,
fingerprint_key,
trade_dedupe_key,
)
BROKER = "vtb"
"""`Broker` value and the first component of every dedupe key this parser builds."""
PARSER_NAME = "report_vtb"
#: VTB prints the pre-ISO Soviet code for the rouble everywhere — in balances, in the price
#: currency of a position and in the settlement currency of a deal. The project stores
#: three-letter ISO 4217 codes (`String(3)` columns, `fx_rate_daily.ccy`), so the alias is
#: applied at the parser boundary and `RUR` never reaches the ledger.
CURRENCY_ALIASES = {"RUR": "RUB"}
#: «Вид сделки» / «Тип операции» of a securities row -> ledger kind. The report states the
#: direction in words and keeps the quantity positive; the sign is ours to apply.
TRADE_SIDES: dict[str, EventKind] = {
"покупка": EventKind.buy,
"продажа": EventKind.sell,
}
#: «Тип операции» in «Движение денежных средств» -> ledger kind, or None for a row that must
#: NOT become an event. «Сальдо расчетов по сделкам с ценными бумагами» is exactly that: it
#: is the money leg of deals already emitted from the trades table, netted per settlement
#: day, and emitting it would count every purchase twice — once as `buy.amount`, once as a
#: cash movement. «Вознаграждение брокера» is also None here, but for a different reason:
#: the fee is capitalised into the deal that caused it (see `broker_fee_residual`).
CASH_OPERATIONS: dict[str, EventKind | None] = {
"зачисление денежных средств": EventKind.deposit,
"списание денежных средств": EventKind.withdrawal,
"вывод денежных средств": EventKind.withdrawal,
"перевод денежных средств": EventKind.transfer_in,
"налог на доходы физических лиц": EventKind.tax,
"дивиденды": EventKind.dividend,
"купонный доход": EventKind.coupon,
"сальдо расчетов по сделкам с ценными бумагами": None,
"вознаграждение брокера": None,
"вознаграждение депозитария": None,
}
#: Group headers inside «Отчёт об остатках ценных бумаг» -> `InstrumentRef.asset_class_hint`.
#: A hint only: the user confirms the class when a pending instrument is resolved.
ASSET_CLASS_HINTS: dict[str, str] = {
"пай": "etf",
"акция": "share",
"адр": "share",
"облигация": "bond",
"еврооблигация": "bond",
"депозитарная расписка": "share",
}
#: Sub-headers and market names that sit in the first column of a data section and are not
#: rows: skipping them silently is correct, warning about them would be noise.
SECTION_SUBHEADERS = frozenset(
{
"основной рынок",
"срочный рынок",
"внебирж. рынок",
"внебиржевой рынок",
"итого",
"итого:",
}
)
_PERIOD_RE = re.compile(
r"за период с\s+(\d{2}\.\d{2}\.\d{4})\s+по\s+(\d{2}\.\d{2}\.\d{4})", re.IGNORECASE
)
_ISIN_RE = re.compile(r"\b([A-Z]{2}[A-Z0-9]{9}\d)\b")
_TICKER_RE = re.compile(r"^[A-Z][A-Z0-9]{1,11}$")
_DATE_RE = re.compile(r"^(\d{2})\.(\d{2})\.(\d{4})")
#: Excel's day zero on Windows: serial 1 is 1900-01-01 and serial 60 is the mythical
#: 1900-02-29, so counting from 1899-12-30 is right for everything after that.
_EXCEL_EPOCH = date(1899, 12, 30)
def normalize_text(value: Any) -> str:
"""Lowercased, whitespace-collapsed, ё-folded — the form every label is matched in.
VTB wraps header captions with newlines to fit a column and is inconsistent about «ё»
(«субсчёту» in one section title, «отчет» in another), so matching raw strings would
make the parser fail on cosmetic edits to the template.
"""
if value is None:
return ""
text = str(value).replace("\xa0", " ").replace("ё", "е").replace("Ё", "Е")
return re.sub(r"\s+", " ", text).strip().lower()
def normalize_currency(value: Any) -> str | None:
"""`RUR` -> `RUB`; anything else uppercased as printed. None for an empty cell."""
text = str(value or "").strip().upper()
if not text:
return None
return CURRENCY_ALIASES.get(text, text)
def to_decimal(value: Any) -> Decimal | None:
"""Money and quantities as `Decimal`, never through `Decimal(float)`.
openpyxl hands back a `float` for every numeric cell, and `Decimal(1.24)` would store
1.2399999999999999911182158029987476766109466552734375. Going through `str()` keeps the
number the report actually printed. Strings arrive too (VTB stores the position price as
text) and may use a comma or a thin space, which the pdf rendering will also do.
"""
if value is None or isinstance(value, bool):
return None
if isinstance(value, Decimal):
return value
if isinstance(value, int):
return Decimal(value)
if isinstance(value, float):
return Decimal(str(value))
text = str(value).replace("\xa0", "").replace(" ", "").replace(",", ".").strip()
if not text or text in {"-", ""}:
return None
try:
return Decimal(text)
except InvalidOperation:
return None
def to_date(value: Any) -> date | None:
"""A cell that should hold a date, whatever the writer chose to put there.
openpyxl usually applies the number format and returns `datetime`, but a cell VTB left
unformatted comes back as the bare serial (`46247.0`, or `46247.664…` when a time is
attached), and a pdf will hand over `17.09.2026` as text. All three are accepted.
"""
if value is None or isinstance(value, bool):
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
if isinstance(value, (int, float)):
serial = float(value)
if serial <= 0:
return None
return _EXCEL_EPOCH + timedelta(days=int(serial))
text = str(value).strip()
match = _DATE_RE.match(text)
if match:
day, month, year = (int(part) for part in match.groups())
try:
return date(year, month, day)
except ValueError:
return None
return None
def to_datetime(value: Any) -> datetime | None:
"""Same as `to_date`, but keeps the time of a deal when the cell carries one."""
if isinstance(value, datetime):
return value
if isinstance(value, (int, float)) and not isinstance(value, bool):
serial = float(value)
if serial <= 0:
return None
return datetime(_EXCEL_EPOCH.year, _EXCEL_EPOCH.month, _EXCEL_EPOCH.day) + timedelta(
days=serial
)
if isinstance(value, str):
text = value.strip()
for fmt in ("%d.%m.%Y %H:%M:%S", "%d.%m.%Y %H:%M", "%d.%m.%Y"):
try:
return datetime.strptime(text, fmt)
except ValueError:
continue
day = to_date(value)
return datetime(day.year, day.month, day.day) if day else None
def parse_period(text: str) -> tuple[date, date] | None:
"""The reporting period out of the title line.
The period lives in the title and nowhere else: the file name is a GUID
(`reportd0235f1e-…​.xlsx`), and the section rows only show the dates on which something
happened, which for a quiet month is not the period at all.
"""
match = _PERIOD_RE.search(str(text or ""))
if not match:
return None
first, second = (to_date(part) for part in match.groups())
if first is None or second is None:
return None
return first, second
def parse_security(
title: Any,
*,
asset_class_hint: str | None = None,
venue: str | None = None,
currency: str | None = None,
extra: dict[str, Any] | None = None,
) -> InstrumentRef:
"""«EQMX ETF, 3965, RU000A101EJ5» -> an `InstrumentRef`.
VTB glues the name, the state registration number and the ISIN into one cell with commas
— and the name itself may contain commas («ПАО "Сбербанк России", ао»), so the fields are
taken from the end: the last comma-separated part that looks like an ISIN is the ISIN,
and a purely numeric or `dash`-shaped part before it is the registration number. What is
left is the name.
The ticker is not printed as a field, but VTB starts an ETF's name with it («EQMX ETF»),
so the first word is taken as a ticker candidate only when it looks like one. It is never
the sole identity: resolution goes by ISIN first, and `source_key` is the ISIN whenever
there is one, so a wrong guess costs nothing.
"""
text = str(title or "").replace("\n", " ").strip()
parts = [part.strip() for part in text.split(",") if part.strip()]
isin: str | None = None
reg_number: str | None = None
for index in range(len(parts) - 1, -1, -1):
match = _ISIN_RE.fullmatch(parts[index].upper())
if match:
isin = match.group(1)
if index > 0 and re.fullmatch(r"[\w\-/]{1,20}", parts[index - 1]):
reg_number = parts[index - 1]
parts = parts[: index - 1]
else:
parts = parts[:index]
break
name = ", ".join(parts).strip() or text or None
ticker: str | None = None
if name:
first_word = name.split()[0].upper()
if _TICKER_RE.fullmatch(first_word):
ticker = first_word
meta: dict[str, Any] = {"title": text}
if reg_number:
meta["reg_number"] = reg_number
if venue:
meta["venue"] = venue
if extra:
meta.update({key: value for key, value in extra.items() if value is not None})
return InstrumentRef(
isin=isin,
ticker=ticker,
name=name,
currency=normalize_currency(currency),
asset_class_hint=asset_class_hint,
source_key=f"ISIN:{isin}" if isin else f"VTB:{text}",
meta=meta,
)
def trade_side_kind(value: Any) -> EventKind | None:
"""«Покупка»/«Продажа» -> `buy`/`sell`; None for a wording we do not know."""
return TRADE_SIDES.get(normalize_text(value))
def cash_operation_kind(value: Any) -> tuple[EventKind | None, bool]:
"""`(kind, known)` for a «Тип операции» of the cash section.
Two different Nones have to be told apart: a row we deliberately drop (`known=True`,
kind None) and a wording nobody has mapped yet (`known=False`), which must reach
`warnings` rather than disappear.
"""
key = normalize_text(value)
if key in CASH_OPERATIONS:
return CASH_OPERATIONS[key], True
return None, False
def build_trade_event(
*,
security: Any,
side: Any,
trade_dt: Any,
settle_date: Any,
quantity: Any,
price: Any,
price_currency: Any,
settlement_currency: Any,
amount_gross: Any,
accrued_interest: Any = None,
fees: tuple[Any, ...] = (),
trade_no: Any = None,
asset_class_hint: str | None = None,
venue: Any = None,
order_no: Any = None,
exchange_trade_no: Any = None,
counterparty: Any = None,
raw_line_no: int = 0,
seq: int = 0,
) -> BrokerEvent:
"""One row of «Заключенные в отчетном периоде сделки» as a ledger event.
Two decisions are baked in here.
**The fee is capitalised into the deal.** VTB prints its commission twice: per deal in
«Комиссия Банка за расчёт/за заключение сделки», and again as a «Вознаграждение брокера»
line in the cash section (the two agree to the kopeck — the parser checks it and warns if
they ever stop agreeing). Only one of them may become an event: `ledger/lots.py`
capitalises `fee` into the cost of the lot, so emitting the cash line as a separate
`commission` event on top would charge the commission twice — once inside the lot's cost
basis, once as a standalone expense. The deal-level figure is the one kept, because it is
the one that knows which lot it belongs to.
**`amount` includes the fee, `fee` stays positive.** That is `event`'s convention:
a buy pays `-(sum + fee)`, a sale receives `+(sum - fee)`, and `fee` is stated positive
so that summing fees never double-counts against the cash effect.
Raises `ValueError` when the row lacks something a trade cannot exist without — the
caller turns that into a warning naming the line, never into a silent skip.
"""
kind = trade_side_kind(side)
if kind is None:
raise ValueError(f"неизвестный вид сделки «{side}»")
day = to_datetime(trade_dt)
if day is None:
raise ValueError("нет даты заключения сделки")
qty = to_decimal(quantity)
if qty is None:
raise ValueError("нет количества")
qty = abs(qty)
gross = to_decimal(amount_gross)
if gross is None:
raise ValueError("нет суммы сделки")
gross = abs(gross)
fee_total = sum((to_decimal(part) or Decimal(0) for part in fees), Decimal(0))
currency = normalize_currency(settlement_currency) or normalize_currency(price_currency)
if currency is None:
raise ValueError("нет валюты расчётов")
if kind is EventKind.buy:
quantity_signed = qty
amount = -(gross + fee_total)
else:
quantity_signed = -qty
amount = gross - fee_total
meta: dict[str, Any] = {"section": "trades", "ts": day.isoformat()}
for key, value in (
("venue", venue),
("order_no", order_no),
("exchange_trade_no", exchange_trade_no),
("counterparty", counterparty),
):
text = str(value).strip() if value is not None else ""
if text:
meta[key] = text
return BrokerEvent(
kind=kind,
trade_date=day.date(),
amount=amount,
currency=currency,
settle_date=to_date(settle_date),
instrument=parse_security(
security,
asset_class_hint=asset_class_hint,
venue=str(venue).strip() if venue else None,
currency=price_currency,
),
quantity=quantity_signed,
price=to_decimal(price),
price_currency=normalize_currency(price_currency),
fee=fee_total if fee_total else None,
fee_currency=currency if fee_total else None,
accrued_interest=to_decimal(accrued_interest),
trade_no=str(trade_no).strip() if trade_no else None,
description=f"{str(side).strip()} {qty} шт.",
raw_line_no=raw_line_no,
seq=seq,
meta=meta,
)
def build_cash_event(
*,
kind: EventKind,
operation: Any,
trade_date: Any,
amount: Any,
currency: Any,
comment: Any = None,
market: str | None = None,
raw_line_no: int = 0,
seq: int = 0,
) -> BrokerEvent:
"""One row of «Движение денежных средств» as a ledger event.
The amount is taken with the sign VTB printed: the column is already signed by its effect
on the account (+26 100 credited, 3.64 charged), which is `event.amount`'s convention.
"""
day = to_date(trade_date)
if day is None:
raise ValueError("нет даты операции")
value = to_decimal(amount)
if value is None:
raise ValueError("нет суммы операции")
code = normalize_currency(currency)
if code is None:
raise ValueError("нет валюты операции")
meta: dict[str, Any] = {"section": "cash", "operation": str(operation).strip()}
if market:
meta["market"] = market
text = str(comment).strip() if comment else ""
return BrokerEvent(
kind=kind,
trade_date=day,
amount=value,
currency=code,
fee=abs(value) if kind is EventKind.commission else None,
fee_currency=code if kind is EventKind.commission else None,
description=text or str(operation).strip() or None,
raw_line_no=raw_line_no,
seq=seq,
meta=meta,
)
def dedupe_key(account_external_id: str, event: BrokerEvent) -> str:
"""The key `ledger/ingest.py` will upsert on — see this module's docstring for the
cross-format contract it encodes."""
if event.trade_no:
return trade_dedupe_key(BROKER, account_external_id, event.trade_no)
instrument_key = event.instrument.key() if event.instrument else ""
return fingerprint_key(
BROKER,
account_external_id,
event.kind,
instrument_key,
event.trade_date,
event.quantity,
event.price,
event.currency,
amount=event.amount,
seq=event.seq,
)
def broker_fee_residual(
cash_fee_total: Decimal, trade_fee_total: Decimal, *, tolerance: Decimal = Decimal("0.005")
) -> Decimal:
"""How much of the brokerage fee the trades table does not account for.
Zero in every report seen so far — VTB's «Вознаграждение брокера» is the sum of the
per-deal commissions to the kopeck. If a report ever charges a fee that belongs to no
deal (a monthly service charge, a depository fee billed under the same wording), the
difference is real money that would otherwise vanish from the ledger, so the caller emits
it as a standalone `commission` event and warns.
"""
residual = cash_fee_total - trade_fee_total
return residual if abs(residual) > tolerance else Decimal(0)
def merge_instruments(refs: list[InstrumentRef]) -> list[InstrumentRef]:
"""Unique instruments in first-seen order, the asset-class hint carried over.
A paper is named in three sections and only the positions table states its class, so the
hint found there is applied to the same ISIN wherever else it appeared.
"""
hints = {ref.key(): ref.asset_class_hint for ref in refs if ref.asset_class_hint}
merged: dict[str, InstrumentRef] = {}
for ref in refs:
key = ref.key()
hint = ref.asset_class_hint or hints.get(key)
current = merged.get(key)
if current is None:
merged[key] = ref if ref.asset_class_hint == hint else _with_hint(ref, hint)
elif current.asset_class_hint is None and hint:
merged[key] = _with_hint(current, hint)
return list(merged.values())
def _with_hint(ref: InstrumentRef, hint: str | None) -> InstrumentRef:
if hint is None or ref.asset_class_hint == hint:
return ref
return InstrumentRef(
isin=ref.isin,
ticker=ref.ticker,
board=ref.board,
name=ref.name,
currency=ref.currency,
asset_class_hint=hint,
source_key=ref.source_key,
meta=dict(ref.meta),
)
def apply_instrument_hints(
events: list[BrokerEvent], refs: list[InstrumentRef]
) -> list[BrokerEvent]:
"""Re-stamp events with the asset-class hint learned from the positions table.
The trades table does not say whether a paper is a share, a unit or a bond; the positions
table does, in its group headers. Both describe the same ISINs, so the hint is filled in
after the whole file is read.
"""
hints = {ref.key(): ref.asset_class_hint for ref in refs if ref.asset_class_hint}
result: list[BrokerEvent] = []
for event in events:
instrument = event.instrument
if instrument is None or instrument.asset_class_hint is not None:
result.append(event)
continue
hint = hints.get(instrument.key())
if hint is None:
result.append(event)
continue
result.append(
BrokerEvent(
kind=event.kind,
trade_date=event.trade_date,
amount=event.amount,
currency=event.currency,
settle_date=event.settle_date,
instrument=_with_hint(instrument, hint),
quantity=event.quantity,
price=event.price,
price_currency=event.price_currency,
fee=event.fee,
fee_currency=event.fee_currency,
tax=event.tax,
tax_currency=event.tax_currency,
accrued_interest=event.accrued_interest,
trade_no=event.trade_no,
description=event.description,
raw_line_no=event.raw_line_no,
seq=event.seq,
meta=dict(event.meta),
)
)
return result
@@ -0,0 +1,704 @@
"""The xlsx rendering of a VTB brokerage report: worksheet in, `ParsedReport` out.
The file is a printed page saved as a spreadsheet, not a data export. Everything lives on a
single sheet called `brokerage_report`, sections follow one another vertically and are
identified only by their Russian title sitting in column B, and every field is a merged
range — which is why values land in columns as scattered as C, F, I, L, O, R, AB, AF. There
is no dimension record either, so `reset_dimensions()` is required before iterating or
openpyxl reports a 1x1 sheet.
Consequently the parser never addresses a grid by offset. It walks rows, switches on the
section title in column B, and inside a section asks a fixed question per column letter —
the letters being the only stable thing in the layout. A row that does not answer the
question (no date where a date must be, no quantity where a quantity must be) ends the
section instead of being force-fitted, and anything unrecognised goes to `warnings`.
What is deliberately NOT emitted as an event, because VTB states the same fact three times:
* «Движение ценных бумаг» is the settlement leg of the deals in the trades table — same
papers, same quantities, one day later. It is used to cross-check quantities and nothing
else.
* «Завершенные в отчетном периоде сделки» repeats «Заключенные» verbatim, deal numbers
included; for a fully settled period the two tables are identical. Emitting it would
double every trade — and because the deal number is the same, the duplicate would not even
be caught by the dedupe key, it would simply be the same event parsed twice. It is used to
assert that the two sets of deal numbers agree.
* «Сальдо расчетов по сделкам с ценными бумагами» in the cash section is the money leg of
those same deals, netted per settlement day.
All of the meaning — signs, kinds, dedupe keys, the instrument string — lives in
`common.py`, so that a future pdf parser reuses it instead of re-deriving it.
"""
from __future__ import annotations
import io
import re
import warnings as warnings_module
import zipfile
from decimal import Decimal
from typing import Any
from openpyxl import load_workbook
from openpyxl.utils import column_index_from_string
from fintracker.models.ledger import EventKind
from fintracker.sources.reports.base import (
BrokerEvent,
CashEnd,
InstrumentRef,
ParsedReport,
ParseError,
PositionEnd,
)
from fintracker.sources.reports.vtb import common
from fintracker.sources.reports.vtb.common import (
BROKER,
normalize_currency,
normalize_text,
to_date,
to_decimal,
)
SHEET_NAME = "brokerage_report"
#: Sections this parser reads, keyed by their normalised title in column B.
SECTIONS: dict[str, str] = {
"сводная информация по субсчету клиента": "summary",
"отчет об остатках денежных средств": "cash_balances",
"движение денежных средств": "cash_flow",
"отчет об остатках ценных бумаг": "positions",
"движение ценных бумаг": "security_moves",
"заключенные в отчетном периоде сделки с ценными бумагами": "trades",
"завершенные в отчетном периоде сделки с ценными бумагами (обязательства прекращены)": (
"trades_completed"
),
}
#: Sections VTB itself lists as possible (the boilerplate paragraph at the end of the file
#: enumerates them) but which this account has never produced. Meeting one is not an error —
#: it is a report with derivatives or currency trades in it — but it must be visible, so it
#: becomes a warning rather than a silent skip.
KNOWN_UNPARSED_SECTIONS = frozenset(
{
"задолженность клиента",
"обязательства/требования по передаче дохода (заблокировано)",
"блокировки / аресты",
"открытые позиции по производным финансовым инструментам",
"обязательства и требования по незавершенным сделкам",
"заключенные в отчетном периоде сделки с иностранной валютой",
"завершенные в отчетном периоде сделки с иностранной валютой (обязательства прекращены)",
"незавершенные в отчетном периоде сделки с ценными бумагами (обязательства не исполнены)",
"незавершенные в отчетном периоде сделки с иностранной валютой "
"(обязательства не исполнены)",
"сделки с производными финансовыми инструментами в отчетном периоде",
"завершенные в отчетном периоде сделки по переносу открытой позиции клиента",
}
)
#: Header labels in column B whose value sits in the first non-empty cell to the right.
_HEADER_FIELDS = {
"№ и дата соглашения": "agreement",
"№ субсчета:": "subaccount",
"на ведение иис": "iis_opened_at",
"дата формирования отчета": "generated_at",
}
_SUMMARY_OPENING = "входящий остаток денежных средств"
_SUMMARY_CLOSING = "исходящий остаток денежных средств"
_SUMMARY_SETTLEMENT = "сальдо расчетов по сделкам с ценными бумагами"
_SUMMARY_BROKER_FEE = "вознаграждение брокера"
_CURRENCY_CODE_RE = re.compile(r"^[a-z]{3}$")
_FOOTNOTE_RE = re.compile(r"^\s*\d+\s*[-–—]\s")
_MONEY_TOLERANCE = Decimal("0.01")
def _cell(row: tuple[Any, ...], column: str) -> Any:
index = column_index_from_string(column) - 1
return row[index] if index < len(row) else None
def _text(row: tuple[Any, ...], column: str) -> str:
value = _cell(row, column)
return "" if value is None else str(value).strip()
def _value_right_of(row: tuple[Any, ...], column: str) -> Any:
"""The first non-empty cell after `column` — the value of a label in a merged layout.
The header fields are printed as «label in B, value somewhere to the right»; in today's
template the value is in I, but the template moves it whenever a label gets longer, and
scanning is cheap.
"""
start = column_index_from_string(column)
for value in row[start:]:
if value is not None and str(value).strip():
return value
return None
def _is_boilerplate(text: str) -> bool:
"""Legal prose and footnotes that trail the report — never a section, never data."""
return bool(_FOOTNOTE_RE.match(text)) or "\n" in text or len(text) > 200
class VtbXlsxParser:
"""`ReportParser` for VTB's xlsx brokerage report."""
broker = BROKER
name = common.PARSER_NAME
formats: tuple[str, ...] = ("xlsx",)
version = "1"
def sniff(self, data: bytes, filename: str) -> bool:
"""Recognise the file by its content only.
The name carries nothing: VTB calls every download `report<guid>.xlsx`. What is
reliable is the workbook's single sheet name (`brokerage_report`, written into
`xl/workbook.xml`) together with the bank's own title string in the shared strings
table. Both are read straight out of the zip, so sniffing costs no parse — and never
raises: an unreadable or foreign file is simply not ours.
"""
del filename # deliberately unused: it is a GUID, it says nothing about the format
try:
if not data.startswith(b"PK\x03\x04"):
return False
with zipfile.ZipFile(io.BytesIO(data)) as archive:
names = set(archive.namelist())
if "xl/workbook.xml" not in names:
return False
workbook = archive.read("xl/workbook.xml").decode("utf-8", "replace")
if SHEET_NAME not in workbook:
return False
for entry in ("xl/sharedStrings.xml", "xl/worksheets/sheet1.xml"):
if entry not in names:
continue
content = archive.read(entry).decode("utf-8", "replace")
if "Банка ВТБ" in content and "за период с" in content:
return True
return False
except Exception:
return False
def parse(self, data: bytes, filename: str) -> ParsedReport:
"""Parse the sheet. `filename` is kept only as traceability in `meta.source_file`:
nothing in the report is derived from it."""
rows = _load_rows(data)
return _Parse(rows, source_file=filename).run()
def _load_rows(data: bytes) -> list[tuple[Any, ...]]:
try:
with warnings_module.catch_warnings():
# The file ships without a default style; openpyxl warns and copes, and the
# warning would otherwise surface on every import the user makes.
warnings_module.simplefilter("ignore")
workbook = load_workbook(io.BytesIO(data), read_only=True, data_only=True)
try:
sheet = (
workbook[SHEET_NAME]
if SHEET_NAME in workbook.sheetnames
else workbook.worksheets[0]
)
# No dimension record in the file: without this the sheet reports 1x1.
sheet.reset_dimensions()
return [tuple(row) for row in sheet.iter_rows(values_only=True)]
finally:
workbook.close()
except ParseError:
raise
except Exception as exc: # любой сбой openpyxl/zip значит «файл нечитаем»
raise ParseError(f"не удалось прочитать xlsx ВТБ: {exc}") from exc
class _Parse:
"""One pass over the sheet plus the cross-checks that follow it.
Kept as a class only because a dozen accumulators would otherwise be threaded through
every handler by hand; there is no state that outlives `run()`.
"""
def __init__(self, rows: list[tuple[Any, ...]], *, source_file: str = "") -> None:
self.rows = rows
self.source_file = source_file
self.warnings: list[str] = []
self.events: list[BrokerEvent] = []
self.positions: list[PositionEnd] = []
self.cash_end: list[CashEnd] = []
self.instruments: list[InstrumentRef] = []
self.summary: dict[str, Decimal] = {}
self.summary_currency: str | None = None
self.section: str | None = None
self.expect_caption = False
self.started = False
self.asset_class_hint: str | None = None
self.market: str | None = None
self.section_seq = 0
self.trade_numbers: list[str] = []
self.completed_numbers: list[str] = []
self.trade_gross_signed = Decimal(0)
self.trade_fee_total = Decimal(0)
self.trade_qty: dict[str, Decimal] = {}
self.move_qty: dict[str, Decimal] = {}
self.move_rows = 0
self.cash_fee_total = Decimal(0)
self.cash_settlement_total = Decimal(0)
self.positions_total_rub: Decimal | None = None
self.last_fee_row: tuple[Any, ...] | None = None
# -- entry point ---------------------------------------------------------------------
def run(self) -> ParsedReport:
period, header = self._read_header()
for line_no, row in enumerate(self.rows, start=1):
self._row(line_no, row)
self.instruments = common.merge_instruments(self.instruments)
self.events = common.apply_instrument_hints(self.events, self.instruments)
self._reconcile()
meta: dict[str, Any] = {
"subaccount": header.get("subaccount"),
"iis_opened_at": header.get("iis_opened_at"),
"generated_at": header.get("generated_at"),
"summary": {key: str(value) for key, value in self.summary.items()},
"source_file": self.source_file or None,
}
return ParsedReport(
broker=BROKER,
account_external_id=header["agreement"],
period_from=period[0],
period_to=period[1],
parser_version=VtbXlsxParser.version,
events=self.events,
positions_end=self.positions,
cash_end=self.cash_end,
instruments=self.instruments,
warnings=self.warnings,
meta={key: value for key, value in meta.items() if value is not None},
)
# -- header --------------------------------------------------------------------------
def _read_header(self) -> tuple[tuple[Any, Any], dict[str, Any]]:
"""The period from the title line and the agreement number from the label block.
Both are mandatory: without a period the report cannot be placed on a timeline, and
without the agreement number no event can be keyed, so either missing is a
`ParseError` rather than a warning.
"""
period = None
fields: dict[str, Any] = {}
for row in self.rows:
first = normalize_text(_cell(row, "B"))
if first in SECTIONS:
break
for value in row:
if period is None and isinstance(value, str):
period = common.parse_period(value)
if first in _HEADER_FIELDS:
fields[_HEADER_FIELDS[first]] = _value_right_of(row, "B")
if period is None:
raise ParseError("в отчёте ВТБ не найден период («за период с … по …»)")
agreement = str(fields.get("agreement") or "").strip()
if not agreement:
raise ParseError("в отчёте ВТБ не найден номер соглашения")
header: dict[str, Any] = {"agreement": agreement}
subaccount = str(fields.get("subaccount") or "").strip()
if subaccount:
header["subaccount"] = subaccount
for key in ("iis_opened_at", "generated_at"):
day = to_date(fields.get(key))
if day is not None:
header[key] = day.isoformat()
return period, header
# -- row dispatch --------------------------------------------------------------------
def _row(self, line_no: int, row: tuple[Any, ...]) -> None:
first = _cell(row, "B")
title = normalize_text(first)
if title in SECTIONS:
self.section = SECTIONS[title]
self.expect_caption = True
self.asset_class_hint = None
self.market = None
self.section_seq = 0
self.started = True
return
if not self.started:
return
if title in KNOWN_UNPARSED_SECTIONS:
self.section = None
self.warnings.append(f"строка {line_no}: секция «{first}» не разбирается парсером")
return
if not title:
# A blank line, or the second line of a two-storey column caption.
return
if self.expect_caption:
self.expect_caption = False
return
handler = getattr(self, f"_row_{self.section}", None) if self.section else None
if handler is not None and handler(line_no, row):
return
# The row does not fit the section it follows: the section is over.
self.section = None
text = str(first).strip()
if not _is_boilerplate(text):
self.warnings.append(f"строка {line_no}: нераспознанная строка/секция «{text[:80]}»")
# -- sections ------------------------------------------------------------------------
def _row_summary(self, line_no: int, row: tuple[Any, ...]) -> bool:
amount = to_decimal(_cell(row, "L"))
if amount is None:
return False
currency = normalize_currency(_cell(row, "O"))
if currency is None:
# Every summary line is денежная; a missing currency means the layout moved and
# the totals below are being compared across currencies without knowing it.
self.warnings.append(
f"строка {line_no}: строка сводки «{_text(row, 'B')[:60]}» без валюты"
)
self.summary[normalize_text(_cell(row, "B"))] = amount
self.summary_currency = self.summary_currency or currency
return True
def _row_cash_balances(self, line_no: int, row: tuple[Any, ...]) -> bool:
label = normalize_text(_cell(row, "B"))
if label.startswith("сумма денежных средств"):
# The all-currency total in roubles: a derived line, not a balance of its own.
return True
if not _CURRENCY_CODE_RE.match(label):
return False
# AF is «Итого» of the closing balance across markets; R alone is the main market.
balance = to_decimal(_cell(row, "AF"))
if balance is None:
balance = to_decimal(_cell(row, "R"))
if balance is None:
self.warnings.append(f"строка {line_no}: остаток денежных средств без суммы")
return True
currency = normalize_currency(_cell(row, "B"))
assert currency is not None
self.cash_end.append(CashEnd(currency=currency, balance=balance))
return True
def _row_cash_flow(self, line_no: int, row: tuple[Any, ...]) -> bool:
label = normalize_text(_cell(row, "B"))
if label in common.SECTION_SUBHEADERS:
self.market = str(_cell(row, "B")).strip()
return True
day = to_date(_cell(row, "B"))
if day is None:
return False
self.section_seq += 1
operation = _cell(row, "J")
amount = to_decimal(_cell(row, "C"))
if amount is None:
self.warnings.append(f"строка {line_no}: движение денег без суммы, строка пропущена")
return True
kind, known = common.cash_operation_kind(operation)
operation_key = normalize_text(operation)
if operation_key == _SUMMARY_BROKER_FEE:
self.cash_fee_total += amount
self.last_fee_row = row
return True
if operation_key == _SUMMARY_SETTLEMENT:
self.cash_settlement_total += amount
return True
if not known:
self.warnings.append(
f"строка {line_no}: неизвестный тип денежной операции «{operation}», "
"записан как other"
)
kind = EventKind.other
if kind is None:
return True
try:
self.events.append(
common.build_cash_event(
kind=kind,
operation=operation,
trade_date=_cell(row, "B"),
amount=_cell(row, "C"),
currency=_cell(row, "G"),
comment=_cell(row, "P"),
market=self.market,
raw_line_no=line_no,
seq=self.section_seq,
)
)
except ValueError as exc:
self.warnings.append(f"строка {line_no}: {exc}, строка пропущена")
return True
def _row_positions(self, line_no: int, row: tuple[Any, ...]) -> bool:
label = normalize_text(_cell(row, "B"))
if label in common.ASSET_CLASS_HINTS:
self.asset_class_hint = common.ASSET_CLASS_HINTS[label]
return True
if label in common.SECTION_SUBHEADERS:
self.positions_total_rub = to_decimal(_cell(row, "AF"))
return True
qty = to_decimal(_cell(row, "L"))
if qty is None:
return False
currency = normalize_currency(_cell(row, "O"))
instrument = common.parse_security(
_cell(row, "B"),
asset_class_hint=self.asset_class_hint,
venue=_text(row, "F") or None,
currency=currency,
extra={
"nominal": _str_decimal(_cell(row, "R")),
"coupon_rate": _str_decimal(_cell(row, "X")),
"coupon_or_maturity_date": _iso(_cell(row, "U")),
},
)
if instrument.isin is None:
self.warnings.append(
f"строка {line_no}: в остатках не разобран ISIN бумаги «{_text(row, 'B')[:60]}»"
)
self.instruments.append(instrument)
self.positions.append(
PositionEnd(
instrument=instrument,
qty=qty,
price=to_decimal(_cell(row, "P")),
market_value=to_decimal(_cell(row, "AF")) or to_decimal(_cell(row, "AB")),
currency=currency,
accrued_interest=to_decimal(_cell(row, "S")),
)
)
return True
def _row_security_moves(self, line_no: int, row: tuple[Any, ...]) -> bool:
"""Settlement of the deals already emitted — counted, never emitted (see module doc)."""
day = to_date(_cell(row, "I"))
if day is None:
return False
qty = to_decimal(_cell(row, "L"))
if qty is None:
self.warnings.append(f"строка {line_no}: движение бумаг без количества")
return True
kind = common.trade_side_kind(_cell(row, "O"))
if kind is None:
self.warnings.append(
f"строка {line_no}: неизвестный тип операции с бумагами «{_text(row, 'O')}»"
)
signed = -abs(qty) if kind is EventKind.sell else abs(qty)
key = common.parse_security(_cell(row, "B")).key()
self.move_qty[key] = self.move_qty.get(key, Decimal(0)) + signed
self.move_rows += 1
return True
def _row_trades(self, line_no: int, row: tuple[Any, ...]) -> bool:
if common.to_datetime(_cell(row, "C")) is None:
return False
self.section_seq += 1
try:
event = common.build_trade_event(
security=_cell(row, "B"),
side=_cell(row, "F"),
trade_dt=_cell(row, "C"),
settle_date=_cell(row, "T"),
quantity=_cell(row, "H"),
price=_cell(row, "J"),
price_currency=_cell(row, "I"),
settlement_currency=_cell(row, "L"),
amount_gross=_cell(row, "M"),
accrued_interest=_cell(row, "O"),
fees=(_cell(row, "P"), _cell(row, "R")),
trade_no=_cell(row, "Z"),
venue=_cell(row, "AK"),
order_no=_cell(row, "W"),
exchange_trade_no=_cell(row, "AC"),
counterparty=_cell(row, "AG"),
raw_line_no=line_no,
seq=self.section_seq,
)
except ValueError as exc:
self.warnings.append(f"строка {line_no}: сделка пропущена — {exc}")
return True
if not event.trade_no:
self.warnings.append(
f"строка {line_no}: сделка без «№ сделки», ключ дедупликации по отпечатку"
)
else:
self.trade_numbers.append(event.trade_no)
gross = abs(to_decimal(_cell(row, "M")) or Decimal(0))
self.trade_gross_signed += gross if event.kind is EventKind.sell else -gross
self.trade_fee_total += event.fee or Decimal(0)
if event.instrument is not None and event.quantity is not None:
key = event.instrument.key()
self.trade_qty[key] = self.trade_qty.get(key, Decimal(0)) + event.quantity
self.instruments.append(event.instrument)
self.events.append(event)
return True
def _row_trades_completed(self, line_no: int, row: tuple[Any, ...]) -> bool:
"""The same deals again (see module doc) — only their numbers are taken."""
if common.to_datetime(_cell(row, "C")) is None:
return False
number = _text(row, "Z")
if not number:
self.warnings.append(
f"строка {line_no}: завершённая сделка без «№ сделки» — сверка с "
"«Заключенными» по ней невозможна"
)
return True
self.completed_numbers.append(number)
return True
# -- cross-checks --------------------------------------------------------------------
def _reconcile(self) -> None:
"""Everything the report states twice, checked against itself.
A report is evidence: the value of parsing it is only as good as the assurance that
what was extracted adds up to the totals the broker printed. Each mismatch below is a
real bug class — a missed section, a sign flipped, a fee counted twice — so none of
them may pass silently.
"""
concluded = set(self.trade_numbers)
completed = set(self.completed_numbers)
if len(concluded) != len(self.trade_numbers):
self.warnings.append("в «Заключенных сделках» повторяются номера сделок")
if completed and completed != concluded:
only_completed = sorted(completed - concluded)
only_concluded = sorted(concluded - completed)
self.warnings.append(
"номера сделок «Завершенных» и «Заключенных» расходятся: "
f"только в завершённых {only_completed}, только в заключённых {only_concluded}"
)
if self.move_rows and self.move_rows != len(self.trade_numbers):
self.warnings.append(
f"строк «Движения ценных бумаг» {self.move_rows}, сделок "
f"{len(self.trade_numbers)} — расчёты не совпадают со сделками"
)
for key, qty in self.move_qty.items():
traded = self.trade_qty.get(key, Decimal(0))
if qty != traded:
self.warnings.append(
f"{key}: движение бумаг {qty} шт., сделки {traded} шт. — расхождение"
)
settlement = self.summary.get(_SUMMARY_SETTLEMENT)
if settlement is not None and abs(settlement - self.trade_gross_signed) > _MONEY_TOLERANCE:
self.warnings.append(
f"сальдо расчётов по сделкам в сводке {settlement}, по таблице сделок "
f"{self.trade_gross_signed}"
)
if (
self.cash_settlement_total
and settlement is not None
and abs(settlement - self.cash_settlement_total) > _MONEY_TOLERANCE
):
self.warnings.append(
f"сальдо расчётов в сводке {settlement}, в движении денег "
f"{self.cash_settlement_total}"
)
self._reconcile_fees()
self._reconcile_cash()
def _reconcile_fees(self) -> None:
"""Deal commissions against «Вознаграждение брокера» in the cash section.
They agree to the kopeck in every report seen, which is what justifies capitalising
the fee into the deal and dropping the cash line (`common.build_trade_event`). The
check is what keeps that justification honest: if a fee ever belongs to no deal, the
difference is emitted as its own `commission` event so the money does not vanish, and
the warning says how much was moved.
"""
charged = -self.cash_fee_total # the cash column prints a charge as negative
residual = common.broker_fee_residual(charged, self.trade_fee_total)
summary_fee = self.summary.get(_SUMMARY_BROKER_FEE)
if summary_fee is not None and abs(-summary_fee - charged) > _MONEY_TOLERANCE:
self.warnings.append(
f"вознаграждение брокера в сводке {summary_fee}, в движении денег "
f"{self.cash_fee_total}"
)
if not residual:
return
self.warnings.append(
f"комиссия в движении денег {charged} не сходится с комиссиями по сделкам "
f"{self.trade_fee_total}; разница {residual} записана отдельным событием commission"
)
row = self.last_fee_row
day = to_date(_cell(row, "B")) if row is not None else None
currency = (normalize_currency(_cell(row, "G")) if row is not None else None) or (
self.cash_end[0].currency if self.cash_end else "RUB"
)
if day is None:
self.warnings.append("разницу по комиссии не к чему привязать по дате — пропущена")
return
self.events.append(
BrokerEvent(
kind=EventKind.commission,
trade_date=day,
amount=-residual,
currency=currency,
fee=residual,
fee_currency=currency,
description="Вознаграждение брокера сверх комиссий по сделкам",
meta={"section": "cash", "operation": "Вознаграждение брокера", "residual": True},
)
)
def _reconcile_cash(self) -> None:
"""Opening balance plus every emitted event must equal the closing balance.
This is the one check that covers the whole file at once: it fails if a deposit was
missed, if a trade's sign is wrong, or if a fee got counted twice. Note that it only
holds because the deal commissions are inside `amount` — the same reason the cash fee
lines are not events.
"""
opening = self.summary.get(_SUMMARY_OPENING)
closing = self.summary.get(_SUMMARY_CLOSING)
if opening is None or closing is None:
return
derived = opening + sum((event.amount for event in self.events), Decimal(0))
if abs(derived - closing) > _MONEY_TOLERANCE:
self.warnings.append(
f"исходящий остаток по сводке {closing}, по разобранным событиям {derived}"
)
for balance in self.cash_end:
if balance.currency == (self.summary_currency or "RUB") and (
abs(balance.balance - closing) > _MONEY_TOLERANCE
):
self.warnings.append(
f"остаток {balance.currency} {balance.balance} не совпадает со сводкой "
f"{closing}"
)
def _str_decimal(value: Any) -> str | None:
number = to_decimal(value)
return None if number is None else str(number)
def _iso(value: Any) -> str | None:
day = to_date(value)
return None if day is None else day.isoformat()
parser = VtbXlsxParser()