From 2e742a093b556d83476a2907a6ebe46fa260b644 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sat, 19 Sep 2026 10:39:37 +0300 Subject: [PATCH] =?UTF-8?q?feat(reports):=20=D0=BF=D1=80=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=BA=D0=BE=D0=BB=20=D0=B8=20=D0=BF=D0=B0=D1=80=D1=81=D0=B5?= =?UTF-8?q?=D1=80=D1=8B=20=D0=BE=D1=82=D1=87=D1=91=D1=82=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=A1=D0=B1=D0=B5=D1=80=D0=B0,=20=D0=92=D0=A2=D0=91=20=D0=B8?= =?UTF-8?q?=20Snowball=20CSV?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/, когда оно на месте. --- backend/pyproject.toml | 2 + backend/scripts/anonymize_reports.py | 80 ++ .../fintracker/sources/reports/__init__.py | 32 + .../fintracker/sources/reports/anonymize.py | 233 +++++ .../src/fintracker/sources/reports/base.py | 231 +++++ .../sources/reports/csv_universal/__init__.py | 12 + .../sources/reports/csv_universal/parser.py | 791 +++++++++++++++++ .../fintracker/sources/reports/registry.py | 48 ++ .../sources/reports/sber/__init__.py | 11 + .../fintracker/sources/reports/sber/html.py | 812 +++++++++++++++++ .../sources/reports/vtb/__init__.py | 10 + .../fintracker/sources/reports/vtb/common.py | 584 +++++++++++++ .../fintracker/sources/reports/vtb/xlsx.py | 704 +++++++++++++++ .../sber/S930W42_01082026_31082026.html | 624 ++++++++++++++ .../sber/S930W42_11022026_17092026.html | 813 ++++++++++++++++++ ...ball_Export_💰 Мой капитал _17.09.2026.csv | 546 ++++++++++++ ...t8ef8347a-2deb-e880-959a-d9a8ca12f735.xlsx | Bin 0 -> 24529 bytes ...td0235f1e-55ac-fdc8-5c7b-279e4fe26e2b.xlsx | Bin 0 -> 33408 bytes backend/tests/sources/test_report_csv.py | 420 +++++++++ backend/tests/sources/test_report_registry.py | 56 ++ backend/tests/sources/test_report_sber.py | 362 ++++++++ backend/tests/sources/test_report_vtb.py | 410 +++++++++ backend/tests/test_fixtures_anonymized.py | 168 ++++ backend/uv.lock | 135 +++ docs/ai/csv-import.md | 200 +++++ 25 files changed, 7284 insertions(+) create mode 100644 backend/scripts/anonymize_reports.py create mode 100644 backend/src/fintracker/sources/reports/__init__.py create mode 100644 backend/src/fintracker/sources/reports/anonymize.py create mode 100644 backend/src/fintracker/sources/reports/base.py create mode 100644 backend/src/fintracker/sources/reports/csv_universal/__init__.py create mode 100644 backend/src/fintracker/sources/reports/csv_universal/parser.py create mode 100644 backend/src/fintracker/sources/reports/registry.py create mode 100644 backend/src/fintracker/sources/reports/sber/__init__.py create mode 100644 backend/src/fintracker/sources/reports/sber/html.py create mode 100644 backend/src/fintracker/sources/reports/vtb/__init__.py create mode 100644 backend/src/fintracker/sources/reports/vtb/common.py create mode 100644 backend/src/fintracker/sources/reports/vtb/xlsx.py create mode 100644 backend/tests/fixtures/reports/sber/S930W42_01082026_31082026.html create mode 100644 backend/tests/fixtures/reports/sber/S930W42_11022026_17092026.html create mode 100644 backend/tests/fixtures/reports/snowball/Snowball_Export_💰 Мой капитал _17.09.2026.csv create mode 100644 backend/tests/fixtures/reports/vtb/report8ef8347a-2deb-e880-959a-d9a8ca12f735.xlsx create mode 100644 backend/tests/fixtures/reports/vtb/reportd0235f1e-55ac-fdc8-5c7b-279e4fe26e2b.xlsx create mode 100644 backend/tests/sources/test_report_csv.py create mode 100644 backend/tests/sources/test_report_registry.py create mode 100644 backend/tests/sources/test_report_sber.py create mode 100644 backend/tests/sources/test_report_vtb.py create mode 100644 backend/tests/test_fixtures_anonymized.py create mode 100644 docs/ai/csv-import.md diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 0414f30..9b27ef9 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -20,6 +20,8 @@ dependencies = [ "python-multipart>=0.0.12", "t-tech-investments>=1.51.0", "pyxirr>=0.10.8", + "openpyxl>=3.1", + "lxml>=5.3", ] [project.scripts] diff --git a/backend/scripts/anonymize_reports.py b/backend/scripts/anonymize_reports.py new file mode 100644 index 0000000..07655b2 --- /dev/null +++ b/backend/scripts/anonymize_reports.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python +"""Regenerate the committed report fixtures from the real reports in `raw/`. + + uv run python scripts/anonymize_reports.py # rewrite fixtures + uv run python scripts/anonymize_reports.py --check # fail if they are out of date + uv run python scripts/anonymize_reports.py --report # show what would be removed + +`tests/fixtures/reports/raw/` is gitignored and holds the originals; everything one level +up is committed and must be clean. The transformation is deterministic (see +`fintracker.sources.reports.anonymize`), so running this twice is a no-op and a reviewer can +reproduce any fixture from the original. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) + +from fintracker.sources.reports.anonymize import ( + anonymize, + anonymize_filename, + collect_secrets, +) + +FIXTURES = Path(__file__).resolve().parent.parent / "tests" / "fixtures" / "reports" +RAW = FIXTURES / "raw" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true", help="exit 1 if a fixture is stale") + parser.add_argument("--report", action="store_true", help="print what was found") + args = parser.parse_args() + + if not RAW.exists(): + print(f"нет {RAW} — положите туда реальные отчёты", file=sys.stderr) + return 1 + + sources = [p for p in sorted(RAW.rglob("*")) if p.is_file() and not p.name.startswith(".")] + # One secret set for the whole corpus: a Snowball note quotes the Sber agreement number, + # so a per-file scrub would leak what another file names. + secrets = collect_secrets((p.read_bytes(), p.name) for p in sources) + + if args.report: + for original, substitute in secrets.replacements.items(): + print(f"{original!r} -> {substitute!r}") + + stale: list[str] = [] + written = 0 + for source in sources: + data = source.read_bytes() + clean, _ = anonymize(data, source.name, secrets) + relative = source.relative_to(RAW) + target = FIXTURES / relative.parent / anonymize_filename(source.name, secrets) + + if args.report: + continue # --report only inspects; it must never touch a fixture + if target.exists() and target.read_bytes() == clean: + continue + if args.check: + stale.append(str(target.relative_to(FIXTURES))) + continue + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(clean) + written += 1 + print(f"{relative} -> {target.relative_to(FIXTURES)}") + + if stale: + print("фикстуры устарели, перегенерируйте: " + ", ".join(stale), file=sys.stderr) + return 1 + if not args.check and not args.report: + print(f"обновлено файлов: {written}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/src/fintracker/sources/reports/__init__.py b/backend/src/fintracker/sources/reports/__init__.py new file mode 100644 index 0000000..30306cb --- /dev/null +++ b/backend/src/fintracker/sources/reports/__init__.py @@ -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", +] diff --git a/backend/src/fintracker/sources/reports/anonymize.py b/backend/src/fintracker/sources/reports/anonymize.py new file mode 100644 index 0000000..cb2015a --- /dev/null +++ b/backend/src/fintracker/sources/reports/anonymize.py @@ -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"(? 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) diff --git a/backend/src/fintracker/sources/reports/base.py b/backend/src/fintracker/sources/reports/base.py new file mode 100644 index 0000000..841eaad --- /dev/null +++ b/backend/src/fintracker/sources/reports/base.py @@ -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") diff --git a/backend/src/fintracker/sources/reports/csv_universal/__init__.py b/backend/src/fintracker/sources/reports/csv_universal/__init__.py new file mode 100644 index 0000000..86166f7 --- /dev/null +++ b/backend/src/fintracker/sources/reports/csv_universal/__init__.py @@ -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"] diff --git a/backend/src/fintracker/sources/reports/csv_universal/parser.py b/backend/src/fintracker/sources/reports/csv_universal/parser.py new file mode 100644 index 0000000..7c69c23 --- /dev/null +++ b/backend/src/fintracker/sources/reports/csv_universal/parser.py @@ -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__.csv` — the only place the portfolio is named. +_FILENAME = re.compile(r"^.*?Export[_\s]+(?P.+?)[_\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__.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", +] diff --git a/backend/src/fintracker/sources/reports/registry.py b/backend/src/fintracker/sources/reports/registry.py new file mode 100644 index 0000000..1ad7e58 --- /dev/null +++ b/backend/src/fintracker/sources/reports/registry.py @@ -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] diff --git a/backend/src/fintracker/sources/reports/sber/__init__.py b/backend/src/fintracker/sources/reports/sber/__init__.py new file mode 100644 index 0000000..cc27c72 --- /dev/null +++ b/backend/src/fintracker/sources/reports/sber/__init__.py @@ -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"] diff --git a/backend/src/fintracker/sources/reports/sber/html.py b/backend/src/fintracker/sources/reports/sber/html.py new file mode 100644 index 0000000..f461e65 --- /dev/null +++ b/backend/src/fintracker/sources/reports/sber/html.py @@ -0,0 +1,812 @@ +"""Sber's «Отчет брокера» in HTML → `ParsedReport` (plan §2, фаза 3). + +The file is a printout, not an export: nine `` elements, each introduced by a bold +`

`, 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 " 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 `

` 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] diff --git a/backend/src/fintracker/sources/reports/vtb/__init__.py b/backend/src/fintracker/sources/reports/vtb/__init__.py new file mode 100644 index 0000000..0e2853c --- /dev/null +++ b/backend/src/fintracker/sources/reports/vtb/__init__.py @@ -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"] diff --git a/backend/src/fintracker/sources/reports/vtb/common.py b/backend/src/fintracker/sources/reports/vtb/common.py new file mode 100644 index 0000000..dd4e919 --- /dev/null +++ b/backend/src/fintracker/sources/reports/vtb/common.py @@ -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 diff --git a/backend/src/fintracker/sources/reports/vtb/xlsx.py b/backend/src/fintracker/sources/reports/vtb/xlsx.py new file mode 100644 index 0000000..30a9a7d --- /dev/null +++ b/backend/src/fintracker/sources/reports/vtb/xlsx.py @@ -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.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() diff --git a/backend/tests/fixtures/reports/sber/S930W42_01082026_31082026.html b/backend/tests/fixtures/reports/sber/S930W42_01082026_31082026.html new file mode 100644 index 0000000..684c22d --- /dev/null +++ b/backend/tests/fixtures/reports/sber/S930W42_01082026_31082026.html @@ -0,0 +1,624 @@ + + + + + Отчет брокера + S930W42 + + + + +

+
+ Отчет брокера +
+ за период с 01.08.2026 по 31.08.2026, дата создания 01.09.2026 +
+

+

+ Инвестор: ИВАНОВ ИВАН ИВАНОВИЧ +
Договор на ведение индивидуального инвестиционного счета S930W42 от 11.02.2026
+

+

+
+ Оценка активов, руб. +
+

+
+ + + + + + + + + + + + + + + +
Торговая площадкаНачало периодаКонец периодаИзменение за период
Ценные бумагиДенежные средстваВсегоЦенные бумагиДенежные средстваВсегоЦенные бумагиДенежные средстваВсего
12345678910
Основной рынок28 819.76357.1529 176.9138 739.431 407.8840 147.31+9 919.67+1 050.73+10 970.40
Итого29 176.9140 147.31+10 970.40
+ +

+
Сводная информация по движению денежных средств за период (основной рынок)
+

+ + + + + + + + + + + + + + + + + + + + + + + + + +
ОписаниеСуммаВалюта
123
Входящий остаток357.15RUB
Пополнение счета10 551.18RUB
Сальдо расчетов по сделкам-9 469.20RUB
Комиссия брокера-28.41RUB
Комиссия биржи-2.84RUB
Исходящий остаток1 407.88RUB
+

+
Информация о зачислениях денежных средств на ИИС
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ГодЛимит, руб.Дата операцииСумма, руб.Основание операцииОстаток лимита (сумма к внесению), руб.
123456
ИИС3
2026Ограничений нет24.02.20262 000.00Зачисление д/с на ИИСОграничений нет
17.04.20261 110.00Зачисление д/с на ИИС
20.04.20263 458.88Перевод д/с с договора 8184V30, ТП UCAF на договор S930W42, ТП UCAF
28.04.20264 000.81Зачисление д/с на ИИС
28.05.20264 000.00Зачисление д/с на ИИС
28.06.20264 000.00Зачисление д/с на ИИС
23.07.202610 000.00Зачисление д/с на ИИС
13.08.202610 551.18Перевод д/с с договора 8184V30, ТП UCAF на договор S930W42, ТП UCAF
+

+
Портфель Ценных Бумаг
+
Торговый код: S930W42
+
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Основной рынокНачало периодаКонец периодаИзменение за периодПлановые показатели
НаименованиеISIN ценной бумагиВалюта рыночной ценыКоличество, штНоминал¹Рыночная цена²Рыночная стоимость, без НКД³НКДКоличество, штНоминал¹Рыночная цена²Рыночная стоимость, без НКД³НКДКоличество, штРыночная стоимостьПлановые зачисления по сделкам, штПлановые списания по сделкам, штПлановый исходящий остаток, шт
123456789101112131415161718
Площадка: Фондовый рынок
ЛУКОЙЛRU0009024277RUB0010.0254 7114 711.00014 711.00001
Московская БиржаRU000A0JR4A1RUB301157.714 731.300301153.264 597.8000-133.500030
Сбербанк РоссииRU0009029540RUB203276.525 530.400203275.855 517.0000-13.400020
ТатнефтьRU0009033591RUB0091580.15 220.90095 220.90009
Фонд Корпоративные облигацииRU000A100P44RUB98318.87918 558.06098319.01618 692.7300134.6700983
Итого по площадке Фондовый рынок, RUB28 819.760.0038 739.430.009 919.67
 
Итого по Основному рынку, RUB28 819.760.0038 739.430.009 919.67
+

+
Денежные средства
+

+ + + + + + + + + + + + + + + + + + + + + + +
Торговая площадкаВалютаКурс на конец периодаНачало периодаИзменение за периодКонец периодаПлановые зачисления по операциямПлановые списания по операциямПлановый исходящий остаток
123456789
Торговый счет, Основной рынокRUB357.151 050.731 407.880.000.001 407.88
Торговый счет, Основной рынокEUR0.000.000.000.000.000.00
Торговый счет, Основной рынокUSD0.000.000.000.000.000.00
Итого по площадке Основной рынок357.151 050.731 407.880.000.001 407.88
Итого в рублевой оценке по курсам Банка России357.151 050.731 407.880.000.001 407.88
+

+
Движение денежных средств за период
+

+ + + + + + + + + + + + + + + + + + + + + + +
ДатаТорговая площадкаОписание операцииВалютаСумма зачисленияСумма списания
123456
13.08.2026Основной рынокПеревод д/с с договора 8184V30, ТП Основной рынок на договор S930W42, ТП Основной рынокRUB10 551.180.00
14.08.2026Основной рынокСделка от 13.08.2026RUB0.009 469.20
14.08.2026Основной рынокКомиссия Биржи от 13.08.2026RUB0.002.84
14.08.2026Основной рынокКомиссия Брокера от 13.08.2026RUB0.0028.41
Итого, RUB10 551.189 500.45
+

+
Сделки купли/продажи ценных бумаг
+

+ + + + + + + + + + + + + + + + + + + +
Дата заключенияДата расчетовВремя заключенияНаименование ЦБКод ЦБВалютаВидКоличество, шт.Цена²СуммаНКДКомиссия БрокераКомиссия БиржиНомер сделкиКомментарийСтатус сделки
12345678910111213141516
Площадка: Фондовый рынок
13.08.202614.08.202616:45:46ЛУКОЙЛLKOHRUBПокупка14 534.54 534.500.0013.601.3617476864892ЗИ
13.08.202614.08.202616:46:25ТатнефтьTATNRUBПокупка9548.34 934.700.0014.811.4817476869116ЗИ
Итого, RUB9 469.200.0028.412.84
+

+
Справочник Ценных Бумаг
+

+ + + + + + + + + + + + + + + + + + + + + + +
НаименованиеКодISIN ценной бумагиЭмитентВид, Категория, Тип, иная информацияВыпуск, Транш, Серия
123456
ЛУКОЙЛLKOHRU0009024277"Нефтяная компания "ЛУКОЙЛ", ПАООбыкновенная акция1-01-00077-A
Московская БиржаMOEXRU000A0JR4A1"Московская Биржа ММВБ-РТС", ПАООбыкновенная акция1-05-08443-H
Сбербанк РоссииSBERRU0009029540"Сбербанк России", ПАООбыкновенная акция10301481B
ТатнефтьTATNRU0009033591"Татнефть" имени В.Д.Шашина, ПАООбыкновенная акция1-03-00161-A
Фонд Корпоративные облигацииSBRBRU000A100P44"Управляющая компания ПЕРВАЯ", АОБиржевой фонд3785
+
+ + + + +
Подпись Руководителя (уполномоченного лица)Иванов И. И.
+
+

+ ¹ Номинал указывается для облигаций с учетом амортизации. +
+ ² Цена указывается для акций, депозитарных расписок, паев – в валюте инструмента в ТС, для облигаций – в % от номинала, для срочных контрактов – в соответствии со спецификацией срочного контракта. Сведения о ценах и +
+   характеристиках ценных бумаг носят информационный характер и не предназначены для целей ведения бухгалтерского учета. По некоторым видам ценных бумаг (структурированные ноты, необращающиеся ценные бумаги, ценные бумаги +
+   со специальными (ограниченными) условиями обращения) при отсутствии цены указывается стоимость приобретения ценных бумаг, которая включает затраты на приобретение. +
+ ³ По некоторым видам ценных бумаг (структурированные ноты, необращающиеся ценные бумаги, ценные бумаги со специальными (ограниченными) условиями обращения) указывается стоимость приобретения ценных бумаг (включая НКД). +
+ Информация о накопленном купонном доходе (НКД) носит информационный характер и соответствует информации торговых систем (в исключительных случаях может рассчитываться брокером самостоятельно). +
+ З - сделка заключена в течение периода; +
+   О - на конец периода ни одно обязательство по сделке не исполнено; +
+   И - на конец периода сделка исполнена в полном объеме; +
+   Д - донорская сделка, заключенная НКО НКЦ(АО); +
+   ИЦБ (для внебиржевых сделок купли-продажи) - на конец периода исполнены обязательства/требования по ценным бумагам, если дата поставки ЦБ наступает ранее даты оплаты; +
+   ИДС (для внебиржевых сделок купли-продажи) - на конец периода исполнены обязательства/требования по денежным средствам, если дата оплаты наступает ранее даты поставки ЦБ; +
+   И1 - на конец периода исполнены обязательства по 1-й части; +
+   П - на конец периода сделка подлежит урегулированию; +
+   Н – обязательства по ценным бумагам рассчитаны зачетом встречных требований; +
+   У - на конец периода сделка урегулирована. +
+ Применяемая налоговая ставка обусловлена включением в налогооблагаемую базу всех видов доходов (доходы по брокерскому обслуживанию, по депозитарной деятельности), полученных физическим лицом +
+   (налоговым резидентом РФ) от ПАО Сбербанк в налоговом периоде (год). Напоминаем, что в случае, если совокупный доход превышает 2,4 млн руб. в год, применяется повышенная ставка 15% к сумме превышения. +
+ Купонный доход, полученный от эмитента. +
+ Заявление на возврат излишне удержанного налога может быть подано удаленно через мобильное приложение "СберБанк Онлайн" по ссылке https://www.sberbank.com/sms/createStatement. +
+ ¹⁰ Инструкция по разблокировке бумаг, заблокированных по 138 указу, находится по ссылке https://www.sberbank.ru/ru/person/investments/broker_service/bs_transfer. +
+
+ В секции «Блокировано» для справочной оценки портфеля инвестора в том числе учитываются ценные бумаги на разделах Счета(ов) депо Инвестора, предназначенных для обособленного учета ценных бумаг, включая ценные бумаги, обособление которых производится до проведения Депозитарием проверочных мероприятий, в соответствии с Указом Президента РФ от 03.03.2023 № 138 "О дополнительных временных мерах экономического характера, связанных с обращением ценных бумаг", открытых в Депозитарии Банка. Данные ценные бумаги не доступны для проведения торговых операций. +
+
+
+ + diff --git a/backend/tests/fixtures/reports/sber/S930W42_11022026_17092026.html b/backend/tests/fixtures/reports/sber/S930W42_11022026_17092026.html new file mode 100644 index 0000000..f2f1196 --- /dev/null +++ b/backend/tests/fixtures/reports/sber/S930W42_11022026_17092026.html @@ -0,0 +1,813 @@ + + + + + Отчет брокера + S930W42 + + + + +

+
+ Отчет брокера +
+ за период с 11.02.2026 по 17.09.2026, дата создания 18.09.2026 +
+

+

+ Инвестор: ИВАНОВ ИВАН ИВАНОВИЧ +
Договор на ведение индивидуального инвестиционного счета S930W42 от 11.02.2026
+

+

+
+ Оценка активов, руб. +
+

+ + + + + + + + + + + + + + + + +
Торговая площадкаНачало периодаКонец периодаИзменение за период
Ценные бумагиДенежные средстваВсегоЦенные бумагиДенежные средстваВсегоЦенные бумагиДенежные средстваВсего
12345678910
Основной рынок0.000.000.0046 663.303 171.3449 834.64+46 663.30+3 171.34+49 834.64
Итого0.0049 834.64+49 834.64
+ +

+
Сводная информация по движению денежных средств за период (основной рынок)
+

+ + + + + + + + + + + + + + + + + + + + + + + + + +
ОписаниеСуммаВалюта
123
Входящий остаток0.00RUB
Пополнение счета49 120.87RUB
Сальдо расчетов по сделкам-45 791.96RUB
Комиссия брокера-138.03RUB
Комиссия биржи-19.54RUB
Исходящий остаток3 171.34RUB
+

+
Информация о зачислениях денежных средств на ИИС
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ГодЛимит, руб.Дата операцииСумма, руб.Основание операцииОстаток лимита (сумма к внесению), руб.
123456
ИИС3
2026Ограничений нет24.02.20262 000.00Зачисление д/с на ИИСОграничений нет
17.04.20261 110.00Зачисление д/с на ИИС
20.04.20263 458.88Перевод д/с с договора 8184V30, ТП UCAF на договор S930W42, ТП UCAF
28.04.20264 000.81Зачисление д/с на ИИС
28.05.20264 000.00Зачисление д/с на ИИС
28.06.20264 000.00Зачисление д/с на ИИС
23.07.202610 000.00Зачисление д/с на ИИС
13.08.202610 551.18Перевод д/с с договора 8184V30, ТП UCAF на договор S930W42, ТП UCAF
16.09.202610 000.00Зачисление д/с на ИИС
+

+
Портфель Ценных Бумаг
+
Торговый код: S930W42
+
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Основной рынокНачало периодаКонец периодаИзменение за периодПлановые показатели
НаименованиеISIN ценной бумагиВалюта рыночной ценыКоличество, штНоминал¹Рыночная цена²Рыночная стоимость, без НКД³НКДКоличество, штНоминал¹Рыночная цена²Рыночная стоимость, без НКД³НКДКоличество, штРыночная стоимостьПлановые зачисления по сделкам, штПлановые списания по сделкам, штПлановый исходящий остаток, шт
123456789101112131415161718
Площадка: Фондовый рынок
АэрофлотRU0009062285RUB00130132.034 163.9001304 163.9000130
ЛУКОЙЛRU0009024277RUB0010.0255 206.55 206.50015 206.50001
МТСRU0007775219RUB00170.1176.42 998.800172 998.800017
Московская БиржаRU000A0JR4A1RUB00301146.74 401.000304 401.000030
МосэнергоRU0008958863RUB003 00011.36654 099.5003 0004 099.50003 000
Сбербанк РоссииRU0009029540RUB00203275.895 517.800205 517.800020
СеверстальRU0009046510RUB0070.01626.84 387.60074 387.60007
ТатнефтьRU0009033591RUB0091619.85 578.20095 578.20009
Фонд Корпоративные облигацииRU000A100P44RUB00019.051000.00000
ФосАгроRU000A0JRKT8RUB0022.55 15510 310.000210 310.00002
Итого по площадке Фондовый рынок, RUB0.000.0046 663.300.0046 663.30
 
Итого по Основному рынку, RUB0.000.0046 663.300.0046 663.30
+

+
Денежные средства
+

+ + + + + + + + + + + + + + + + + + + + + + +
Торговая площадкаВалютаКурс на конец периодаНачало периодаИзменение за периодКонец периодаПлановые зачисления по операциямПлановые списания по операциямПлановый исходящий остаток
123456789
Торговый счет, Основной рынокRUB0.003 171.343 171.340.000.003 171.34
Торговый счет, Основной рынокEUR0.000.000.000.000.000.00
Торговый счет, Основной рынокUSD0.000.000.000.000.000.00
Итого по площадке Основной рынок0.003 171.343 171.340.000.003 171.34
Итого в рублевой оценке по курсам Банка России0.003 171.343 171.340.000.003 171.34
+

+
Движение денежных средств за период
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ДатаТорговая площадкаОписание операцииВалютаСумма зачисленияСумма списания
123456
24.02.2026Основной рынокЗачисление д/сRUB2 000.000.00
25.02.2026Основной рынокСделка от 24.02.2026RUB0.001 966.72
25.02.2026Основной рынокКомиссия Биржи от 25.02.2026RUB0.000.39
17.04.2026Основной рынокЗачисление д/сRUB1 110.000.00
20.04.2026Основной рынокСделка от 17.04.2026RUB0.001 122.67
20.04.2026Основной рынокКомиссия Биржи от 17.04.2026RUB0.000.22
20.04.2026Основной рынокПеревод д/с с договора 8184V30, ТП Основной рынок на договор S930W42, ТП Основной рынокRUB3 458.880.00
23.04.2026Основной рынокСделка от 22.04.2026RUB2 975.756 365.65
23.04.2026Основной рынокКомиссия Биржи от 22.04.2026RUB0.001.87
28.04.2026Основной рынокЗачисление д/сRUB4 000.810.00
29.04.2026Основной рынокСделка от 28.04.2026RUB0.004 036.18
29.04.2026Основной рынокКомиссия Биржи от 28.04.2026RUB0.000.80
28.05.2026Основной рынокЗачисление д/сRUB4 000.000.00
29.05.2026Основной рынокСделка от 28.05.2026RUB0.004 004.89
29.05.2026Основной рынокКомиссия Биржи от 28.05.2026RUB0.000.80
28.06.2026Основной рынокЗачисление д/сRUB4 000.000.00
30.06.2026Основной рынокСделка от 28.06.2026RUB0.003 996.31
30.06.2026Основной рынокКомиссия Биржи от 28.06.2026RUB0.000.80
23.07.2026Основной рынокЗачисление д/сRUB10 000.000.00
24.07.2026Основной рынокСделка от 23.07.2026RUB0.005 273.60
24.07.2026Основной рынокКомиссия Брокера от 23.07.2026RUB0.0015.82
27.07.2026Основной рынокСделка от 24.07.2026RUB0.004 388.40
27.07.2026Основной рынокКомиссия Брокера от 24.07.2026RUB0.0013.17
13.08.2026Основной рынокПеревод д/с с договора 8184V30, ТП Основной рынок на договор S930W42, ТП Основной рынокRUB10 551.180.00
14.08.2026Основной рынокСделка от 13.08.2026RUB0.009 469.20
14.08.2026Основной рынокКомиссия Биржи от 13.08.2026RUB0.002.84
14.08.2026Основной рынокКомиссия Брокера от 13.08.2026RUB0.0028.41
16.09.2026Основной рынокЗачисление д/сRUB10 000.000.00
17.09.2026Основной рынокСделка от 16.09.2026RUB18 731.0626 875.15
17.09.2026Основной рынокКомиссия Брокера от 16.09.2026RUB0.0080.63
17.09.2026Основной рынокКомиссия Биржи от 16.09.2026RUB0.0011.82
Итого, RUB70 827.6867 656.34
+

+
Сделки купли/продажи ценных бумаг
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Дата заключенияДата расчетовВремя заключенияНаименование ЦБКод ЦБВалютаВидКоличество, шт.Цена²СуммаНКДКомиссия БрокераКомиссия БиржиНомер сделкиКомментарийСтатус сделки
12345678910111213141516
Площадка: Фондовый рынок
24.02.202625.02.202611:23:11Первая-ВечныйПортф БПИФSTMERUBПокупка4394.481 966.720.000.000.3915678045077ЗИ
17.04.202620.04.202615:04:40Первая-ВечныйПортф БПИФSTMERUBПокупка2624.2851 122.670.000.000.2216200841452ЗИ
22.04.202623.04.202615:29:28Первая-ВечныйПортф БПИФSTMERUBПродажа7014.2452 975.750.000.000.6016236032587ЗИ
22.04.202623.04.202615:30:13Первая-ФондКорпОбл БПИФSBRBRUBПокупка34218.6136 365.650.000.001.2716236035304ЗИ
28.04.202629.04.202611:12:15Фонд Корпоративные облигацииSBRBRUBПокупка21618.6864 036.180.000.000.8016333277169ЗИ
28.05.202629.05.202611:08:57Фонд Корпоративные облигацииSBRBRUBПокупка21218.8914 004.890.000.000.8016579796740ЗИ
28.06.202630.06.202612:04:55Фонд Корпоративные облигацииSBRBRUBПокупка21318.7623 996.310.000.000.8016923118209ЗИ
23.07.202624.07.202617:04:07Сбербанк РоссииSBERRUBПокупка3263.68791.040.002.370.0017246055391ЗИ
23.07.202624.07.202617:04:12Сбербанк РоссииSBERRUBПокупка17263.684 482.560.0013.450.0017246055744ЗИ
24.07.202627.07.202608:11:17Московская БиржаMOEXRUBПокупка10146.281 462.800.004.390.0017267798505ЗИ
24.07.202627.07.202608:11:38Московская БиржаMOEXRUBПокупка20146.282 925.600.008.780.0017267799171ЗИ
13.08.202614.08.202616:45:46ЛУКОЙЛLKOHRUBПокупка14 534.54 534.500.0013.601.3617476864892ЗИ
13.08.202614.08.202616:46:25ТатнефтьTATNRUBПокупка9548.34 934.700.0014.811.4817476869116ЗИ
16.09.202617.09.202610:51:25МТСMTSSRUBПокупка2189.8379.600.001.140.1217727970049ЗИ
16.09.202617.09.202610:51:51МосэнергоMSNGRUBПокупка1 0001.4391 439.000.004.320.4317727973101ЗИ
16.09.202617.09.202610:52:16СеверстальCHMFRUBПокупка2638.41 276.800.003.830.3817727975786ЗИ
16.09.202617.09.202610:52:37АэрофлотAFLTRUBПокупка3032.65979.500.002.930.2917727977785ЗИ
16.09.202617.09.202610:53:34МТСMTSSRUBПокупка10189.11 891.000.005.680.5717727983802ЗИ
16.09.202617.09.202611:00:04Фонд Корпоративные облигацииSBRBRUBПродажа98319.05518 731.060.000.003.7517728034732ЗИ
16.09.202617.09.202611:05:14ФосАгроPHORRUBПокупка15 3095 309.000.0015.921.6017728078869ЗИ
16.09.202617.09.202611:05:14ФосАгроPHORRUBПокупка15 3095 309.000.0015.931.6017728078870ЗИ
16.09.202617.09.202611:05:28СеверстальCHMFRUBПокупка5639.43 197.000.009.590.9617728080350ЗИ
16.09.202617.09.202611:05:44МосэнергоMSNGRUBПокупка1 0001.43851 438.500.004.310.4317728082210ЗИ
16.09.202617.09.202611:05:44МосэнергоMSNGRUBПокупка1 0001.43851 438.500.004.320.4317728082211ЗИ
16.09.202617.09.202611:06:11МТСMTSSRUBПокупка5190.25951.250.002.860.2817728085299ЗИ
16.09.202617.09.202611:06:32АэрофлотAFLTRUBПокупка10032.663 266.000.009.800.9817728087354ЗИ
Итого, RUB89 205.580.00138.0319.54
+

+
Справочник Ценных Бумаг
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
НаименованиеКодISIN ценной бумагиЭмитентВид, Категория, Тип, иная информацияВыпуск, Транш, Серия
123456
АэрофлотAFLTRU0009062285"Аэрофлот-российские авиалинии", ПАООбыкновенная акция1-01-00010-A
ЛУКОЙЛLKOHRU0009024277"Нефтяная компания "ЛУКОЙЛ", ПАООбыкновенная акция1-01-00077-A
Московская БиржаMOEXRU000A0JR4A1"Московская Биржа ММВБ-РТС", ПАООбыкновенная акция1-05-08443-H
МосэнергоMSNGRU0008958863ПАО энергетики и электрификации "Мосэнерго" (ТГК-3)Обыкновенная акция1-01-00085-A
МТСMTSSRU0007775219"Мобильные ТелеСистемы", ПАООбыкновенная акция1-01-04715-A
Первая-ВечныйПортф БПИФSTMERU000A1035S8"Управляющая компания ПЕРВАЯ", АОБиржевой фонд4429
Сбербанк РоссииSBERRU0009029540"Сбербанк России", ПАООбыкновенная акция10301481B
СеверстальCHMFRU0009046510"Северсталь", ПАООбыкновенная акция1-02-00143-A
ТатнефтьTATNRU0009033591"Татнефть" имени В.Д.Шашина, ПАООбыкновенная акция1-03-00161-A
Фонд Корпоративные облигацииSBRBRU000A100P44"Управляющая компания ПЕРВАЯ", АОБиржевой фонд3785
ФосАгроPHORRU000A0JRKT8"ФосАгро", ПАООбыкновенная акция1-02-06556-A
+
+ + + + +
Подпись Руководителя (уполномоченного лица)Иванов И. И.
+
+

+ ¹ Номинал указывается для облигаций с учетом амортизации. +
+ ² Цена указывается для акций, депозитарных расписок, паев – в валюте инструмента в ТС, для облигаций – в % от номинала, для срочных контрактов – в соответствии со спецификацией срочного контракта. Сведения о ценах и +
+   характеристиках ценных бумаг носят информационный характер и не предназначены для целей ведения бухгалтерского учета. По некоторым видам ценных бумаг (структурированные ноты, необращающиеся ценные бумаги, ценные бумаги +
+   со специальными (ограниченными) условиями обращения) при отсутствии цены указывается стоимость приобретения ценных бумаг, которая включает затраты на приобретение. +
+ ³ По некоторым видам ценных бумаг (структурированные ноты, необращающиеся ценные бумаги, ценные бумаги со специальными (ограниченными) условиями обращения) указывается стоимость приобретения ценных бумаг (включая НКД). +
+ Информация о накопленном купонном доходе (НКД) носит информационный характер и соответствует информации торговых систем (в исключительных случаях может рассчитываться брокером самостоятельно). +
+ З - сделка заключена в течение периода; +
+   О - на конец периода ни одно обязательство по сделке не исполнено; +
+   И - на конец периода сделка исполнена в полном объеме; +
+   Д - донорская сделка, заключенная НКО НКЦ(АО); +
+   ИЦБ (для внебиржевых сделок купли-продажи) - на конец периода исполнены обязательства/требования по ценным бумагам, если дата поставки ЦБ наступает ранее даты оплаты; +
+   ИДС (для внебиржевых сделок купли-продажи) - на конец периода исполнены обязательства/требования по денежным средствам, если дата оплаты наступает ранее даты поставки ЦБ; +
+   И1 - на конец периода исполнены обязательства по 1-й части; +
+   П - на конец периода сделка подлежит урегулированию; +
+   Н – обязательства по ценным бумагам рассчитаны зачетом встречных требований; +
+   У - на конец периода сделка урегулирована. +
+ Применяемая налоговая ставка обусловлена включением в налогооблагаемую базу всех видов доходов (доходы по брокерскому обслуживанию, по депозитарной деятельности), полученных физическим лицом +
+   (налоговым резидентом РФ) от ПАО Сбербанк в налоговом периоде (год). Напоминаем, что в случае, если совокупный доход превышает 2,4 млн руб. в год, применяется повышенная ставка 15% к сумме превышения. +
+ Купонный доход, полученный от эмитента. +
+ Заявление на возврат излишне удержанного налога может быть подано удаленно через мобильное приложение "СберБанк Онлайн" по ссылке https://www.sberbank.com/sms/createStatement. +
+ ¹⁰ Инструкция по разблокировке бумаг, заблокированных по 138 указу, находится по ссылке https://www.sberbank.ru/ru/person/investments/broker_service/bs_transfer. +
+
+ В секции «Блокировано» для справочной оценки портфеля инвестора в том числе учитываются ценные бумаги на разделах Счета(ов) депо Инвестора, предназначенных для обособленного учета ценных бумаг, включая ценные бумаги, обособление которых производится до проведения Депозитарием проверочных мероприятий, в соответствии с Указом Президента РФ от 03.03.2023 № 138 "О дополнительных временных мерах экономического характера, связанных с обращением ценных бумаг", открытых в Депозитарии Банка. Данные ценные бумаги не доступны для проведения торговых операций. +
+
+
+ + diff --git a/backend/tests/fixtures/reports/snowball/Snowball_Export_💰 Мой капитал _17.09.2026.csv b/backend/tests/fixtures/reports/snowball/Snowball_Export_💰 Мой капитал _17.09.2026.csv new file mode 100644 index 0000000..b6db380 --- /dev/null +++ b/backend/tests/fixtures/reports/snowball/Snowball_Export_💰 Мой капитал _17.09.2026.csv @@ -0,0 +1,546 @@ +Event,Date,Symbol,Price,Quantity,Currency,FeeTax,Exchange,NKD,FeeCurrency,DoNotAdjustCash,Note +"CASH_IN","2025-02-26 09:22:54","RUB","1","5000","RUB","0","","","","False","Пополнение счета" +"CASH_OUT","2025-02-26 09:22:54","RUB","1","0,002832","RUB","0","","","","False","Эта сделка добавлена с целью корректировки баланса по этой валюте, т.к. баланс по данным брокера (459,38) не соответствует балансу рассчитанному по сделкам (459,38)." +"BUY","2025-02-26 09:23:29","DELI","219","10","RUB","6,57","MCX","","","False","" +"BUY","2025-02-26 09:24:09","ROSN","581,25","2","RUB","3,49","MCX","","","False","" +"BUY","2025-02-26 09:25:23","DELI","219","7","RUB","4,6","MCX","","","False","" +"CASH_IN","2025-02-27 19:28:34","RUB","1","3400","RUB","0","","","","False","Пополнение счета" +"BUY","2025-02-27 19:28:47","SBERP","307,39","10","RUB","9,22","MCX","","","False","" +"BUY","2025-02-28 08:49:02","DELI","211,45","1","RUB","0,63","MCX","","","False","" +"CASH_IN","2025-03-12 16:12:53","RUB","1","10000","RUB","0","","","","False","Пополнение счета" +"BUY","2025-03-12 16:13:39","DELI","196,3","12","RUB","7,07","MCX","","","False","" +"BUY","2025-03-12 16:13:49","DELI","196,3","10","RUB","5,89","MCX","","","False","" +"BUY","2025-03-12 16:14:20","DELI","196,4","10","RUB","5,89","MCX","","","False","" +"BUY","2025-03-12 16:18:28","RU000A107AM4","929,25","4","RUB","11,15","MCX","5,08","","False","" +"CASH_IN","2025-03-12 16:19:39","RUB","1","197,7","RUB","0","","","","False","Пополнение счета" +"BUY","2025-03-12 16:19:40","DELI","196,5","1","RUB","0,59","MCX","","","False","" +"BUY","2025-03-24 16:28:49","ASTR","427,45","2","RUB","2,56","MCX","","","False","" +"CASH_IN","2025-03-24 16:28:49","RUB","1","860,08","RUB","0","","","","False","Пополнение счета" +"CASH_IN","2025-03-25 18:00:22","RUB","1","5007,5","RUB","0","","","","False","Пополнение счета" +"CASH_IN","2025-03-25 18:02:09","RUB","1","5508,25","RUB","0","","","","False","Пополнение счета" +"BUY","2025-03-25 18:17:42","WUSH","217,552","5","RUB","3,26","MCX","","","False","" +"BUY","2025-03-25 18:21:55","TATNP","647,5","2","RUB","3,89","MCX","","","False","" +"BUY","2025-03-25 18:24:32","SFIN","1538,8","1","RUB","4,62","MCX","","","False","" +"BUY","2025-03-25 18:24:41","LENT","1384","1","RUB","4,15","MCX","","","False","" +"BUY","2025-03-25 18:25:23","IVAT","187,5","1","RUB","0,56","MCX","","","False","" +"BUY","2025-03-26 17:27:00","TPAY","96,5","1","RUB","0","MCX","","","False","" +"BUY","2025-04-02 11:22:50","RU000A10B7T7","1000","5","RUB","7,5","MCX","0","","False","" +"DIVIDEND","2025-04-09 18:40:43","TPAY","0","1,94","RUB","0","MCX","","","False","" +"CASH_IN","2025-04-11 18:24:33","RUB","1","8745,17","RUB","0","","","","False","Пополнение счета" +"CASH_OUT","2025-04-11 18:24:33","RUB","1","0,010799","RUB","0","","","","False","Эта сделка добавлена с целью корректировки баланса по этой валюте, т.к. баланс по данным брокера (364,86) не соответствует балансу рассчитанному по сделкам (364,87)." +"BUY","2025-04-11 18:25:10","WUSH","181,58","10","RUB","5,45","MCX","","","False","" +"BUY","2025-04-11 18:25:27","IVAT","152,99","10","RUB","4,59","MCX","","","False","" +"BUY","2025-04-11 18:25:58","ROSN","450,566667","3","RUB","4,06","MCX","","","False","" +"BUY","2025-04-11 18:26:54","PIKK","427,3","3","RUB","3,85","MCX","","","False","" +"BUY","2025-04-11 18:28:10","CNRU","539,8","3","RUB","4,86","MCX","","","False","" +"BUY","2025-04-18 09:52:48","ROSN","475,05","2","RUB","2,85","MCX","","","False","" +"CASH_IN","2025-04-25 16:11:11","RUB","1","1626,6","RUB","0","","","","False","Пополнение счета" +"CASH_IN","2025-04-25 16:11:25","RUB","1","1626,6","RUB","0","","","","False","Пополнение счета" +"BUY","2025-04-28 10:34:11","TPAY","96,76","17","RUB","0","MCX","","","False","" +"BUY","2025-04-28 10:35:25","NVTK","1259,3","1","RUB","3,78","MCX","","","False","" +"CASH_IN","2025-04-29 19:39:31","RUB","1","2610","RUB","0","","","","False","Пополнение счета" +"CASH_IN","2025-04-29 19:39:41","RUB","1","2610","RUB","0","","","","False","Пополнение счета" +"BUY","2025-04-30 10:50:36","TPAY","95,47","2","RUB","0","MCX","","","False","" +"BUY","2025-04-30 10:50:48","RU000A107AM4","935,8","1","RUB","2,81","MCX","23,23","","False","" +"BUY","2025-04-30 10:51:05","DELI","169,4","8","RUB","4,07","MCX","","","False","" +"BUY","2025-04-30 10:51:20","CNRU","549","2","RUB","3,29","MCX","","","False","" +"BUY","2025-04-30 10:51:32","PIKK","470,8","2","RUB","2,82","MCX","","","False","" +"BUY","2025-04-30 10:52:36","APTK","10,412","100","RUB","3,12","MCX","","","False","" +"CASH_IN","2025-05-12 19:51:08","RUB","1","5000","RUB","0","","","","False","Пополнение счета" +"CASH_IN","2025-05-12 19:51:19","RUB","1","5000","RUB","0","","","","False","Пополнение счета" +"BUY","2025-05-14 10:43:57","EUTR","125,25","10","RUB","3,76","MCX","","","False","" +"BUY","2025-05-14 10:45:03","DELI","163,6","1","RUB","0,49","MCX","","","False","" +"BUY","2025-05-14 10:45:16","ROSN","451,2","8","RUB","10,83","MCX","","","False","" +"BUY","2025-05-14 10:45:36","PIKK","490,58","5","RUB","7,36","MCX","","","False","" +"BUY","2025-05-14 10:46:01","NVTK","1192,55","2","RUB","7,16","MCX","","","False","" +"DIVIDEND","2025-05-15 17:05:35","TPAY","0","25,72","RUB","0","MCX","","","False","" +"CASH_IN","2025-05-23 16:12:44","RUB","1","1200","RUB","0","","","","False","Пополнение счета" +"CASH_IN","2025-05-23 16:12:54","RUB","1","1200","RUB","0","","","","False","Пополнение счета" +"BUY","2025-05-27 08:55:30","DELI","157,65","8","RUB","3,78","MCX","","","False","" +"BUY","2025-05-27 08:55:47","DELI","157,65","8","RUB","3,78","MCX","","","False","" +"DIVIDEND","2025-05-30 09:12:44","RU000A107AM4","0","165,15","RUB","0","MCX","","","False","" +"CASH_IN","2025-06-05 12:46:14","RUB","1","2600","RUB","0","","","","False","Пополнение счета" +"CASH_IN","2025-06-05 12:46:24","RUB","1","2600","RUB","0","","","","False","Пополнение счета" +"BUY","2025-06-05 16:21:32","RU000A10B7T7","1038","2","RUB","6,23","MCX","44,52","","False","" +"BUY","2025-06-05 16:21:43","DELI","169,85","2","RUB","1,02","MCX","","","False","" +"BUY","2025-06-05 16:21:53","NVTK","1110,55","2","RUB","6,66","MCX","","","False","" +"BUY","2025-06-05 16:22:12","IVAT","128,35","5","RUB","1,93","MCX","","","False","" +"DIVIDEND","2025-06-09 15:56:44","TPAY","0","26,82","RUB","0","MCX","","","False","" +"DIVIDEND","2025-06-11 12:29:50","SFIN","0","83,5","RUB","9","MCX","","","False","" +"CASH_IN","2025-06-12 10:19:15","RUB","1","6000","RUB","0","","","","False","Пополнение счета" +"CASH_IN","2025-06-12 10:19:29","RUB","1","6000","RUB","0","","","","False","Пополнение счета" +"DIVIDEND","2025-06-19 15:21:06","TATNP","0","86,22","RUB","11","MCX","","","False","" +"BUY","2025-06-19 16:22:31","TMOS","6,21","398","RUB","0","MCX","","","False","" +"BUY","2025-06-19 16:23:05","TMOS","6,21","398","RUB","0","MCX","","","False","" +"BUY","2025-06-19 16:24:05","SBRB","15,845","77","RUB","3,66","MCX","","","False","" +"BUY","2025-06-19 16:24:16","SBRB","15,845","77","RUB","3,66","MCX","","","False","" +"BUY","2025-06-19 16:25:10","TGLD","10,52","58","RUB","0","MCX","","","False","" +"BUY","2025-06-19 16:25:27","TGLD","10,52","58","RUB","0","MCX","","","False","" +"BUY","2025-06-19 16:26:09","LQDT","1,7196","356","RUB","1,84","MCX","","","False","" +"BUY","2025-06-19 16:26:26","LQDT","1,7196","356","RUB","1,84","MCX","","","False","" +"BUY","2025-06-19 16:27:03","TPAY","98,23","12","RUB","0","MCX","","","False","" +"BUY","2025-06-19 16:27:09","TPAY","98,23","12","RUB","0","MCX","","","False","" +"CASH_IN","2025-06-25 19:07:20","RUB","1","1200","RUB","0","","","","False","Пополнение счета" +"CASH_IN","2025-06-25 19:07:31","RUB","1","1200","RUB","0","","","","False","Пополнение счета" +"AMORTISATION","2025-06-26 14:00:41","RU000A10B7T7","0","897,82","RUB","0","MCX","","","False","" +"DIVIDEND","2025-06-26 14:10:53","RU000A10B7T7","0","407,54","RUB","0","MCX","","","False","" +"BUY","2025-06-26 14:22:14","RU000A10B7T7","909,835","2","RUB","5,46","MCX","0,6","","False","" +"BUY","2025-06-26 14:22:34","RU000A10B7T7","909,92","1","RUB","2,73","MCX","0,6","","False","" +"CASH_IN","2025-07-04 15:45:31","RUB","1","2900","RUB","0","","","","False","Пополнение счета" +"CASH_IN","2025-07-04 15:45:52","RUB","1","2900","RUB","0","","","","False","Пополнение счета" +"CASH_IN","2025-07-04 23:49:07","RUB","1","2610","RUB","0","","","","False","Пополнение счета" +"CASH_IN","2025-07-04 23:49:17","RUB","1","2610","RUB","0","","","","False","Пополнение счета" +"DIVIDEND","2025-07-08 12:33:19","TPAY","0","56,4","RUB","0","MCX","","","False","" +"DIVIDEND","2025-07-08 13:58:58","TPAY","0","20,15","RUB","0","MCX","","","False","" +"BUY","2025-07-08 14:24:10","TMOS","6,2","403","RUB","0","MCX","","","False","" +"BUY","2025-07-08 14:24:21","TMOS","6,2","403","RUB","0","MCX","","","False","" +"BUY","2025-07-08 14:24:50","TBRU","6,96","286","RUB","0","MCX","","","False","" +"BUY","2025-07-08 14:24:58","TBRU","6,96","286","RUB","0","MCX","","","False","" +"BUY","2025-07-08 14:25:57","TGLD","10,37","96","RUB","0","MCX","","","False","" +"BUY","2025-07-08 14:26:05","TGLD","10,37","96","RUB","0","MCX","","","False","" +"BUY","2025-07-08 14:26:34","TPAY","98,56","8","RUB","0","MCX","","","False","" +"BUY","2025-07-08 14:26:40","TPAY","98,56","4","RUB","0","MCX","","","False","" +"CASH_IN","2025-07-11 21:28:53","RUB","1","5300","RUB","0","","","","False","Пополнение счета" +"CASH_IN","2025-07-11 21:29:03","RUB","1","5300","RUB","0","","","","False","Пополнение счета" +"BUY","2025-07-11 21:31:35","VTBR","72,84","13","RUB","2,84","MCX","","","False","" +"BUY","2025-07-11 21:31:44","VTBR","72,84","13","RUB","2,84","MCX","","","False","" +"BUY","2025-07-11 21:32:50","T","3129,4","1","RUB","0","MCX","","","False","" +"BUY","2025-07-11 21:32:58","T","3129,3","1","RUB","0","MCX","","","False","" +"DIVIDEND","2025-07-15 14:09:51","ASTR","0","6,29","RUB","1","MCX","","","False","" +"CASH_IN","2025-07-15 17:22:31","RUB","1","1000","RUB","0","","","","False","Пополнение счета" +"CASH_IN","2025-07-15 17:23:34","RUB","1","1000","RUB","0","","","","False","Пополнение счета" +"DIVIDEND","2025-07-21 07:36:14","T","0","33","RUB","4","MCX","","","False","" +"DIVIDEND","2025-07-21 08:13:36","T","0","33","RUB","4","MCX","","","False","" +"DIVIDEND","2025-07-28 20:55:02","RU000A10B7T7","0","161,19","RUB","0","MCX","","","False","" +"DIVIDEND","2025-07-28 20:59:19","RU000A10B7T7","0","17,91","RUB","0","MCX","","","False","" +"AMORTISATION","2025-07-28 21:02:33","RU000A10B7T7","0","592,11","RUB","0","MCX","","","False","" +"AMORTISATION","2025-07-28 21:06:43","RU000A10B7T7","0","65,79","RUB","0","MCX","","","False","" +"DIVIDEND","2025-08-01 11:52:28","EUTR","0","141,9","RUB","19","MCX","","","False","" +"DIVIDEND","2025-08-01 13:35:14","EUTR","0","30","RUB","4","MCX","","","False","" +"DIVIDEND","2025-08-04 14:42:34","SBERP","0","348,4","RUB","45","MCX","","","False","" +"DIVIDEND","2025-08-04 19:55:40","ROSN","0","73,4","RUB","10","MCX","","","False","" +"DIVIDEND","2025-08-04 20:07:25","ROSN","0","146,8","RUB","19","MCX","","","False","" +"DIVIDEND","2025-08-08 15:01:23","TPAY","0","57,07","RUB","0","MCX","","","False","" +"DIVIDEND","2025-08-08 15:45:28","TPAY","0","23,43","RUB","0","MCX","","","False","" +"SELL","2025-08-08 17:15:27","PIKK","638,7","10","RUB","19,16","MCX","","","False","" +"SELL","2025-08-08 17:16:06","IVAT","168,3","6","RUB","3,03","MCX","","","False","" +"SELL","2025-08-08 17:16:16","IVAT","168,25","2","RUB","1,01","MCX","","","False","" +"CASH_OUT","2025-08-08 17:18:02","RUB","1","9900","RUB","0","","","","False","Вывод со счета" +"TAX","2025-08-12 06:03:36","","0","0","RUB","234","","","","False","Корректировка налога" +"SELL","2025-08-20 21:40:50","VTBR","77,91","13","RUB","3,04","MCX","","","False","" +"SELL","2025-08-20 21:41:08","EUTR","126,4","10","RUB","3,79","MCX","","","False","" +"SELL","2025-08-20 21:41:19","LENT","1785,5","1","RUB","5,36","MCX","","","False","" +"SELL","2025-08-20 21:41:56","IVAT","169,4","6","RUB","3,05","MCX","","","False","" +"SELL","2025-08-20 21:43:13","LQDT","1,7755","356","RUB","1,9","MCX","","","False","" +"SELL","2025-08-20 21:51:17","WUSH","140,58","5","RUB","2,11","MCX","","","False","" +"BUY","2025-08-21 10:50:16","TGLD","10,69","93","RUB","0","MCX","","","False","" +"BUY","2025-08-21 10:50:16","TMOS","6,76","369","RUB","0","MCX","","","False","" +"SELL","2025-08-21 11:17:38","TPAY","101,62","40","RUB","0","MCX","","","False","" +"BUY","2025-08-21 11:22:38","TMOS","6,75","381","RUB","0","MCX","","","False","" +"BUY","2025-08-21 11:22:39","TGLD","10,69","96","RUB","0","MCX","","","False","" +"BUY","2025-08-21 12:15:01","TMOS","6,75","251","RUB","0","MCX","","","False","" +"BUY","2025-08-21 12:15:01","TPAY","101,61","16","RUB","0","MCX","","","False","" +"BUY","2025-08-21 12:15:02","TGLD","10,69","63","RUB","0","MCX","","","False","" +"BUY","2025-08-21 12:26:45","SU26212RMFS9","887,83","1","RUB","2,66","MCX","5,79","","False","" +"BUY","2025-08-21 12:26:45","TPAY","101,6","7","RUB","0","MCX","","","False","" +"BUY","2025-08-21 12:26:46","TGLD","10,69","27","RUB","0","MCX","","","False","" +"BUY","2025-08-21 12:26:46","TMOS","6,76","107","RUB","0","MCX","","","False","" +"SELL","2025-08-21 12:43:14","ASTR","416,75","2","RUB","2,5","MCX","","","False","" +"SELL","2025-08-21 12:46:04","DELI","148,3","68","RUB","30,25","MCX","","","False","" +"SELL","2025-08-21 12:46:47","DELI","148,255","10","RUB","4,44","MCX","","","False","" +"BUY","2025-08-21 12:55:10","SU26212RMFS9","887,35","4","RUB","10,65","MCX","5,79","","False","" +"BUY","2025-08-21 12:55:11","TMOS","6,77","394","RUB","0","MCX","","","False","" +"BUY","2025-08-21 12:55:11","TPAY","101,58","26","RUB","0","MCX","","","False","" +"BUY","2025-08-21 12:55:12","TGLD","10,71","99","RUB","0","MCX","","","False","" +"BUY","2025-08-22 13:40:55","RNFT","124,5","50","RUB","18,68","MCX","","","False","" +"SELL","2025-08-22 13:45:06","TATN","675,1","10","RUB","20,25","MCX","","","False","" +"FEE","2025-08-23 00:30:53","","0","0","RUB","45","","","","False","" +"FEE","2025-08-24 00:32:22","","0","0","RUB","45","","","","False","" +"FEE","2025-08-25 00:31:52","","0","0","RUB","45","","","","False","" +"FEE","2025-08-26 00:32:25","","0","0","RUB","45","","","","False","" +"AMORTISATION","2025-08-26 13:06:06","RU000A10B7T7","0","664,47","RUB","0","MCX","","","False","" +"AMORTISATION","2025-08-26 13:07:38","RU000A10B7T7","0","73,83","RUB","0","MCX","","","False","" +"DIVIDEND","2025-08-26 13:14:31","RU000A10B7T7","0","153,99","RUB","0","MCX","","","False","" +"DIVIDEND","2025-08-26 13:14:58","RU000A10B7T7","0","17,11","RUB","0","MCX","","","False","" +"FEE","2025-08-27 00:36:17","","0","0","RUB","45","","","","False","" +"FEE","2025-08-28 00:34:11","","0","0","RUB","45","","","","False","" +"FEE","2025-08-29 00:33:46","","0","0","RUB","45","","","","False","" +"DIVIDEND","2025-08-29 13:54:42","RU000A107AM4","0","165,15","RUB","0","MCX","","","False","" +"BUY","2025-08-29 17:24:07","TATN","650,5","10","RUB","19,52","MCX","","","False","" +"SELL","2025-08-29 17:24:25","RNFT","121,2","50","RUB","18,18","MCX","","","False","" +"SELL","2025-09-01 17:59:16","VTBR","74,54","13","RUB","2,91","MCX","","","False","" +"SELL","2025-09-01 17:59:29","NVTK","1239,6","5","RUB","18,59","MCX","","","False","" +"SELL","2025-09-01 17:59:39","ROSN","461,32","5","RUB","6,92","MCX","","","False","" +"SELL","2025-09-01 18:02:17","IVAT","159,7","2","RUB","0,96","MCX","","","False","" +"SELL","2025-09-01 18:02:30","CNRU","607,4","5","RUB","9,11","MCX","","","False","" +"SELL","2025-09-01 18:02:39","T","3335,8","1","RUB","0","MCX","","","False","" +"SELL","2025-09-01 18:04:27","APTK","9,532","100","RUB","2,86","MCX","","","False","" +"SELL","2025-09-01 18:04:35","WUSH","125,45","10","RUB","3,77","MCX","","","False","" +"SELL","2025-09-01 18:04:53","RU000A10B7T7","769,384908","1","RUB","2,31","MCX","3,505092","","False","" +"TAX","2025-09-01 18:05:10","","0","0","RUB","3","","","","False","Удержание налога" +"CASH_OUT","2025-09-01 18:05:10","RUB","1","20358,92","RUB","0","","","","False","Вывод со счета" +"TAX_RETURN","2025-09-03 07:21:59","","0","0","RUB","3","","","","False","Корректировка налога" +"CASH_IN","2025-09-05 11:23:58","RUB","1","5000","RUB","0","","","","False","Пополнение счета" +"DIVIDEND","2025-09-05 17:42:16","TPAY","0","60,89","RUB","0","MCX","","","False","" +"DIVIDEND","2025-09-05 18:09:43","TPAY","0","19,82","RUB","0","MCX","","","False","" +"BUY","2025-09-08 21:21:46","SU26212RMFS9","884,4","2","RUB","5,31","MCX","9,27","","False","" +"BUY","2025-09-08 21:21:47","TMOS","6,76","179","RUB","0","MCX","","","False","" +"BUY","2025-09-08 21:21:47","TPAY","100,84","12","RUB","0","MCX","","","False","" +"BUY","2025-09-08 21:22:16","SU26212RMFS9","884,4","1","RUB","2,65","MCX","9,27","","False","" +"BUY","2025-09-08 21:22:17","TMOS","6,76","107","RUB","0","MCX","","","False","" +"BUY","2025-09-08 21:22:17","TPAY","100,84","7","RUB","0","MCX","","","False","" +"AMORTISATION","2025-09-26 16:32:06","RU000A10B7T7","0","631,89","RUB","0","MCX","","","False","" +"DIVIDEND","2025-09-26 16:33:22","RU000A10B7T7","0","139,95","RUB","0","MCX","","","False","" +"CASH_IN","2025-10-05 13:15:58","RUB","1","5000","RUB","0","","","","False","Пополнение счета" +"BUY","2025-10-06 20:20:29","SU26212RMFS9","869,77","2","RUB","5,22","MCX","14,68","","False","" +"BUY","2025-10-06 20:20:29","TPAY","100,02","12","RUB","0","MCX","","","False","" +"BUY","2025-10-06 20:20:30","TMOS","6,17","196","RUB","0","MCX","","","False","" +"SELL","2025-10-07 13:23:38","LQDT","1,8159","356","RUB","1,94","MCX","","","False","" +"SELL","2025-10-07 13:25:10","TGLD","12,88","154","RUB","0","MCX","","","False","" +"SELL","2025-10-07 13:25:31","TMOS","6,21","801","RUB","0","MCX","","","False","" +"SELL","2025-10-07 13:25:58","TPAY","100,1","16","RUB","0","MCX","","","False","" +"SELL","2025-10-07 13:26:18","SBRB","16,599","77","RUB","3,83","MCX","","","False","" +"SELL","2025-10-07 13:26:39","TBRU","7,39","286","RUB","0","MCX","","","False","" +"CASH_OUT","2025-10-07 13:27:12","RUB","1","12614,5","RUB","0","","","","False","Вывод со счета" +"DIVIDEND","2025-10-08 10:27:11","T","0","35","RUB","4","MCX","","","False","" +"DIVIDEND","2025-10-08 16:58:14","TPAY","0","93,27","RUB","0","MCX","","","False","" +"DIVIDEND","2025-10-08 17:28:20","TPAY","0","22,24","RUB","0","MCX","","","False","" +"TAX","2025-10-09 04:18:26","","0","0","RUB","22","","","","False","Корректировка налога" +"AMORTISATION","2025-10-27 18:30:50","RU000A10B7T7","0","570,42","RUB","0","MCX","","","False","" +"DIVIDEND","2025-10-27 19:16:05","RU000A10B7T7","0","122,4","RUB","0","MCX","","","False","" +"DIVIDEND","2025-10-29 14:52:01","TATNP","0","28,7","RUB","4","MCX","","","False","" +"CASH_IN","2025-11-05 11:58:33","RUB","1","5000","RUB","0","","","","False","Пополнение счета" +"BUY","2025-11-06 12:12:16","SU26212RMFS9","879,766667","3","RUB","7,92","MCX","20,66999966666667","","False","" +"BUY","2025-11-06 12:12:17","TMOS","5,94","327","RUB","0","MCX","","","False","" +"BUY","2025-11-06 12:12:17","TPAY","100,49","19","RUB","0","MCX","","","False","" +"BUY","2025-11-06 12:12:18","TGLD","13","59","RUB","0","MCX","","","False","" +"DIVIDEND","2025-11-10 19:06:16","TPAY","0","95,8","RUB","0","MCX","","","False","" +"AMORTISATION","2025-11-26 18:13:31","RU000A10B7T7","0","558,09","RUB","0","MCX","","","False","" +"DIVIDEND","2025-11-26 18:21:54","RU000A10B7T7","0","114,39","RUB","0","MCX","","","False","" +"CASH_IN","2025-11-27 11:05:13","RUB","1","5818,41","RUB","0","","","","False","Пополнение счета" +"BUY","2025-11-27 11:05:14","ASTR","289,1","20","RUB","17,35","MCX","","","False","" +"CASH_IN","2025-11-27 11:05:59","RUB","1","3431,27","RUB","0","","","","False","Пополнение счета" +"BUY","2025-11-27 11:06:00","IVAT","170,25","20","RUB","10,22","MCX","","","False","" +"DIVIDEND","2025-11-28 10:29:07","RU000A107AM4","0","165,15","RUB","0","MCX","","","False","" +"CASH_IN","2025-12-05 14:15:14","RUB","1","5000","RUB","0","","","","False","Пополнение счета" +"DIVIDEND","2025-12-08 19:27:48","TPAY","0","92,14","RUB","0","MCX","","","False","" +"DIVIDEND","2025-12-26 17:14:16","RU000A10B7T7","0","99,18","RUB","0","MCX","","","False","" +"AMORTISATION","2025-12-26 17:17:03","RU000A10B7T7","0","475,65","RUB","0","MCX","","","False","" +"DIVIDEND","2025-12-29 12:07:40","SFIN","0","902","RUB","117","MCX","","","False","" +"CASH_IN","2026-01-05 13:59:37","RUB","1","5000","RUB","0","","","","False","Пополнение счета" +"BUY","2026-01-06 10:22:03","TPAY","100,27","34","RUB","0","MCX","","","False","" +"BUY","2026-01-06 10:22:04","TMOS","6,45","346","RUB","0","MCX","","","False","" +"SELL","2026-01-08 11:41:58","RU000A10B7T7","502,484763","9","RUB","13,57","MCX","4,639681444444444","","False","" +"SELL","2026-01-08 11:41:59","RU000A107AM4","991,7","5","RUB","14,88","MCX","15,61","","False","" +"SELL","2026-01-08 11:41:59","SFIN","829,2","1","RUB","2,49","MCX","","","False","" +"SELL","2026-01-08 11:41:59","T","3255","1","RUB","0","MCX","","","False","" +"SELL","2026-01-08 11:42:00","SBERP","297,89","10","RUB","8,94","MCX","","","False","" +"SELL","2026-01-08 11:42:00","SU26212RMFS9","890,520769","13","RUB","34,73","MCX","32,83000023076923","","False","" +"SELL","2026-01-08 11:42:00","TATNP","535,4","2","RUB","3,21","MCX","","","False","" +"SELL","2026-01-08 11:42:01","ROSN","400,45","10","RUB","12,01","MCX","","","False","" +"BUY","2026-01-08 11:42:01","TMOS","6,37","2114","RUB","0","MCX","","","False","" +"BUY","2026-01-08 11:42:02","SBRB","17,683","173","RUB","9,18","MCX","","","False","" +"BUY","2026-01-08 11:42:02","TBRU","7,81","1986","RUB","0","MCX","","","False","" +"BUY","2026-01-08 11:42:02","TPAY","100,39","43","RUB","0","MCX","","","False","" +"BUY","2026-01-12 18:40:57","TDIV","10,269799","696","RUB","0","MCX","","","False","" +"SELL","2026-01-12 18:40:57","TMOS","6,380144","695","RUB","0","MCX","","","False","" +"SELL","2026-01-12 18:46:05","TDIV","10,240573","262","RUB","0","MCX","","","False","" +"DIVIDEND","2026-01-14 11:09:42","T","0","36","RUB","5","MCX","","","False","" +"DIVIDEND","2026-01-20 15:55:25","TPAY","0","136,92","RUB","0","MCX","","","False","" +"CASH_IN","2026-02-02 11:12:04","RUB","1","5000","RUB","0","","","","False","Пополнение счета" +"CASH_IN","2026-02-02 11:32:35","RUB","1","5566","RUB","0","","","","False","Пополнение счета" +"BUY","2026-02-02 11:39:30","TBRU","7,8","302","RUB","0","MCX","","","False","" +"BUY","2026-02-02 11:39:30","TOFZ","13,57","767","RUB","0","MCX","","","False","" +"BUY","2026-02-02 11:39:30","TPAY","100,08","24","RUB","0","MCX","","","False","" +"SELL","2026-02-02 11:40:19","TOFZ","13,56","397","RUB","0","MCX","","","False","" +"CASH_IN","2026-02-05 14:25:59","RUB","1","5000","RUB","0","","","","False","Пополнение счета" +"BUY","2026-02-09 11:24:18","TDIV","10,42","28","RUB","0","MCX","","","False","" +"BUY","2026-02-09 11:24:18","TMOS","6,43","279","RUB","0","MCX","","","False","" +"BUY","2026-02-09 11:24:18","TOFZ","13,53","22","RUB","0","MCX","","","False","" +"BUY","2026-02-09 11:24:19","SBRB","17,781","16","RUB","0,11","MCX","","","False","" +"BUY","2026-02-09 11:24:19","TBRU","7,8","153","RUB","0","MCX","","","False","" +"BUY","2026-02-09 11:24:19","TGLD","15,47","38","RUB","0","MCX","","","False","" +"BUY","2026-02-09 11:24:19","TPAY","100,19","11","RUB","0","MCX","","","False","" +"DIVIDEND","2026-02-09 16:36:01","TPAY","0","201,78","RUB","0","MCX","","","False","" +"BUY","2026-02-16 11:03:47","TDIV","10,69","1","RUB","0","MCX","","","False","" +"BUY","2026-02-16 11:03:47","TMOS","6,6","12","RUB","0","MCX","","","False","" +"BUY","2026-02-16 11:03:48","TBRU","7,9","6","RUB","0","MCX","","","False","" +"BUY","2026-02-16 11:03:48","TGLD","15,25","1","RUB","0","MCX","","","False","" +"SELL","2026-02-18 11:05:26","SBRB","17,917226","266","RUB","1,91","MCX","","","False","" +"BUY","2026-02-18 11:05:26","TMOS","6,56","6","RUB","0","MCX","","","False","" +"BUY","2026-02-18 11:05:26","TOFZ","13,82","380","RUB","0","MCX","","","False","" +"BUY","2026-02-18 11:05:27","TBRU","7,91","3","RUB","0","MCX","","","False","" +"SELL","2026-02-21 10:24:30","IVAT","159,55","20","RUB","1,28","MCX","","","False","" +"SELL","2026-02-21 10:24:40","ASTR","259,9","20","RUB","2,08","MCX","","","False","" +"TAX","2026-02-21 10:24:58","","0","0","RUB","32","","","","False","Удержание налога по ставке 13%" +"CASH_OUT","2026-02-21 10:24:58","RUB","1","8388,99","RUB","0","","","","False","Вывод со счета" +"CASH_IN","2026-02-24 00:00:00","RUB","1","0,0024","RUB","0","","","","False","Эта сделка добавлена с целью корректировки баланса по этой валюте, т.к. баланс по данным брокера (16,14) не соответствует балансу рассчитанному по сделкам (16,14)." +"CASH_IN","2026-02-24 00:00:00","RUB","1","2000","RUB","0","","","","False","" +"BUY","2026-02-24 11:31:18","LQDT","1,9291","1036","RUB","0","MCX","","","False","" +"TAX_RETURN","2026-02-26 02:13:39","","0","0","RUB","32","","","","False","Корректировка налога по ставке 13%" +"CASH_OUT","2026-03-01 18:17:42","RUB","1","32","RUB","0","","","","False","Вывод со счета" +"SELL","2026-03-02 11:31:12","TBRU","7,94","2051","RUB","0","MCX","","","False","" +"SELL","2026-03-02 11:31:12","TDIV","10,869698","463","RUB","0","MCX","","","False","" +"SELL","2026-03-02 11:31:12","TPAY","100,13","178","RUB","0","MCX","","","False","" +"SELL","2026-03-02 11:31:13","TGLD","16,69","303","RUB","0","MCX","","","False","" +"BUY","2026-03-02 11:31:13","TMOS","6,73","6144","RUB","0","MCX","","","False","" +"BUY","2026-03-02 11:31:14","TMOS","6,73","290","RUB","0","MCX","","","False","" +"BUY","2026-03-02 11:31:14","TOFZ","13,8","410","RUB","0","MCX","","","False","" +"BUY","2026-03-02 11:31:15","TBRU","7,95","17","RUB","0","MCX","","","False","" +"CASH_IN","2026-03-05 14:48:54","RUB","1","5000","RUB","0","","","","False","Пополнение счета" +"BUY","2026-03-09 11:01:40","TMOS","6,85","283","RUB","0","MCX","","","False","" +"BUY","2026-03-09 11:01:40","TOFZ","13,87","29","RUB","0","MCX","","","False","" +"BUY","2026-03-09 11:01:41","TBRU","7,97","17","RUB","0","MCX","","","False","" +"BUY","2026-03-09 11:01:41","TGLD","15,9","8","RUB","0","MCX","","","False","" +"DIVIDEND","2026-03-10 16:55:28","TPAY","0","199,98","RUB","0","MCX","","","False","" +"BUY","2026-03-16 11:35:58","TMOS","6,81","32","RUB","0","MCX","","","False","" +"BUY","2026-03-16 11:35:59","TBRU","8,07","1","RUB","0","MCX","","","False","" +"BUY","2026-03-16 11:35:59","TOFZ","14,06","3","RUB","0","MCX","","","False","" +"BUY","2026-03-23 11:11:19","TMOS","6,76","7","RUB","0","MCX","","","False","" +"BUY","2026-03-30 11:53:07","TMOS","6,56","7","RUB","0","MCX","","","False","" +"CASH_IN","2026-04-01 14:43:30","RUB","1","1100","RUB","0","","","","False","Пополнение счета" +"BUY","2026-04-01 14:47:55","RU000A10BF48","1008","1","RUB","3,02","MCX","7,46","","False","" +"CASH_IN","2026-04-01 20:05:46","RUB","1","100000","RUB","0","","","","False","Пополнение счета" +"BUY","2026-04-01 20:08:22","SU26207RMFS9","964,82","8","RUB","23,16","MCX","12,73","","False","" +"BUY","2026-04-01 20:08:40","SU26207RMFS9","964,81","8","RUB","23,16","MCX","12,73","","False","" +"BUY","2026-04-01 20:09:02","SU26207RMFS9","964,84","14","RUB","40,52","MCX","12,73","","False","" +"BUY","2026-04-01 20:09:33","SU29016RMFS1","1003,88","10","RUB","30,11","MCX","3,24","","False","" +"BUY","2026-04-01 20:09:47","SU29007RMFS0","1020","10","RUB","30,6","MCX","13,89","","False","" +"BUY","2026-04-01 20:18:20","RU000A10AT35","1029,1","5","RUB","15,44","MCX","6,62","","False","" +"BUY","2026-04-01 20:18:33","RU000A10BF48","1008","4","RUB","12,1","MCX","7,46","","False","" +"BUY","2026-04-01 20:21:25","RU000A10CQ85","999,2","5","RUB","14,99","MCX","8,44","","False","" +"BUY","2026-04-01 20:22:09","RU000A10B0U0","1070,5","5","RUB","16,06","MCX","1,26","","False","" +"BUY","2026-04-01 20:23:59","RU000A10B214","1042,4","5","RUB","15,64","MCX","13,36","","False","" +"CUSTOM_HOLDING_PRICE","2026-04-03 00:00:00","SIBN6P4","11353,38392","0","RUB","0","CUSTOM_HOLDING","0","","","" +"BUY","2026-04-03 16:38:22","SIBN6P4","11353,38392","2","RUB","68,12","CUSTOM_HOLDING","25,35108","","False","" +"CASH_IN","2026-04-05 14:36:58","RUB","1","5000","RUB","0","","","","False","Пополнение счета" +"BUY","2026-04-06 11:24:08","TMOS","6,49","558","RUB","0","MCX","","","False","" +"BUY","2026-04-06 11:24:09","TMOS","6,49","543","RUB","0","MCX","","","False","" +"BUY","2026-04-06 11:24:10","TPAY","100,92","1","RUB","0","MCX","","","False","" +"SELL","2026-04-06 11:51:39","TMOS","6,48","539","RUB","0","MCX","","","False","" +"DIVIDEND","2026-04-07 15:55:02","RU000A10B214","0","77,05","RUB","0","MCX","","","False","" +"CASH_IN","2026-04-08 00:00:00","RUB","1","3000","RUB","0","","","","False","" +"BUY","2026-04-08 15:24:35","LQDT","1,9629","1529","RUB","0","MCX","","","False","" +"DIVIDEND","2026-04-09 13:13:04","TPAY","0","33,01","RUB","0","MCX","","","False","" +"CASH_IN","2026-04-11 21:18:45","RUB","1","5000","RUB","0","","","","False","Пополнение счета" +"BUY","2026-04-12 10:34:35","SU26207RMFS9","966,13","5","RUB","14,49","MCX","15,41","","False","" +"CASH_IN","2026-04-12 10:46:14","RUB","1","10000","RUB","0","","","","False","Пополнение счета" +"BUY","2026-04-12 10:46:24","SU29007RMFS0","1020,5","5","RUB","15,31","MCX","19,63","","False","" +"BUY","2026-04-12 10:46:32","SU29016RMFS1","1001,35","5","RUB","15,02","MCX","8,13","","False","" +"BUY","2026-04-13 12:54:35","TBRU","8,16","7","RUB","0","MCX","","","False","" +"BUY","2026-04-13 12:54:35","TGLD","14,24","4","RUB","0","MCX","","","False","" +"BUY","2026-04-13 12:54:35","TMOS","6,45","136","RUB","0","MCX","","","False","" +"BUY","2026-04-13 12:54:35","TOFZ","14,09","13","RUB","0","MCX","","","False","" +"DIVIDEND","2026-04-13 13:44:05","RU000A10CQ85","0","57,55","RUB","0","MCX","","","False","" +"CASH_IN","2026-04-15 22:54:40","RUB","1","1200","RUB","0","","","","False","Пополнение счета" +"SPLIT","2026-04-16 03:00:00","T","10","0","RUB","0","MCX","0","","","" +"SPLIT","2026-04-16 03:00:00","T","10","0","RUB","0","MCX","0","","","" +"CASH_IN","2026-04-16 14:08:35","RUB","1","1100","RUB","0","","","","False","Пополнение счета" +"CASH_IN","2026-04-16 14:08:36","RUB","1","1100","RUB","0","","","","False","Пополнение счета" +"CASH_IN","2026-04-17 00:00:00","RUB","1","1100","RUB","0","","","","False","" +"CASH_IN","2026-04-17 00:00:00","RUB","1","1110","RUB","0","","","","False","ЗАЧИСЛЕНИЕ Д/С" +"CASH_OUT","2026-04-17 00:00:00","RUB","1","1017,83999543","RUB","0","","","","False","Эта сделка добавлена с целью корректировки баланса по этой валюте, т.к. баланс по данным брокера (357,15) не соответствует балансу рассчитанному по сделкам (1374,99)." +"BUY","2026-04-17 00:00:00","STME","4,285","262","RUB","0,22","MCX","","","False","" +"DIVIDEND","2026-04-17 13:25:18","RU000A10BF48","0","69,3","RUB","0","MCX","","","False","" +"BUY","2026-04-17 15:04:10","LQDT","1,9714","558","RUB","0","MCX","","","False","" +"CASH_IN","2026-04-20 00:00:00","RUB","1","3458,88","RUB","0","","","","False","ПЕРЕВОД Д/С С ДОГОВОРА 8184V30, ТП ОСНОВНОЙ РЫНОК НА ДОГОВОР S930W42, ТП ОСНОВНОЙ РЫНОК" +"BUY","2026-04-20 12:23:33","TMOS","6,45629","124","RUB","0","MCX","","","False","" +"BUY","2026-04-20 12:23:34","TBRU","8,21","6","RUB","0","MCX","","","False","" +"BUY","2026-04-20 12:23:34","TGLD","14,35","4","RUB","0","MCX","","","False","" +"BUY","2026-04-20 12:23:34","TOFZ","14,24","12","RUB","0","MCX","","","False","" +"BUY","2026-04-20 12:33:50","RU000A10BF48","1008,2","1","RUB","3,02","MCX","2,29","","False","" +"BUY","2026-04-20 12:34:00","RU000A10B214","1046,6","1","RUB","3,14","MCX","7,71","","False","" +"BUY","2026-04-20 12:34:08","RU000A10CQ85","999,9","1","RUB","3","MCX","4,22","","False","" +"BUY","2026-04-20 12:34:16","RU000A10AT35","1026,6","1","RUB","3,08","MCX","1,42","","False","" +"DIVIDEND","2026-04-20 17:33:41","RU000A10AT35","0","70,9","RUB","0","MCX","","","False","" +"BUY","2026-04-22 00:00:00","SBRB","18,6130117","342","RUB","1,27","MCX","","","False","" +"SELL","2026-04-22 00:00:00","STME","4,24500713","701","RUB","0,6","MCX","","","False","" +"SELL","2026-04-22 15:04:08","TPAY","101,28","34","RUB","0","MCX","","","False","" +"BUY","2026-04-22 15:04:09","TBRU","8,24","2115","RUB","0","MCX","","","False","" +"SELL","2026-04-22 15:04:09","TOFZ","14,28","1239","RUB","0","MCX","","","False","" +"BUY","2026-04-22 15:04:10","TGLD","14,17","320","RUB","0","MCX","","","False","" +"BUY","2026-04-22 15:04:10","TMOS","6,51","3","RUB","0","MCX","","","False","" +"SELL","2026-04-22 15:22:42","LQDT","1,9735","3123","RUB","0","MCX","","","False","" +"BUY","2026-04-22 15:23:14","EQMX","143,2","43","RUB","1,24","MCX","","","False","" +"CASH_IN","2026-04-28 00:00:00","RUB","1","4000","RUB","0","","","","False","" +"CASH_IN","2026-04-28 00:00:00","RUB","1","4000,81","RUB","0","","","","False","ЗАЧИСЛЕНИЕ Д/С" +"BUY","2026-04-28 00:00:00","SBRB","18,68601852","216","RUB","0,8","MCX","","","False","" +"BUY","2026-04-28 11:10:13","EQMX","141,6","28","RUB","0,8","MCX","","","False","" +"DIVIDEND","2026-04-28 16:28:09","SIBN6P4","0","131,44","RUB","0","CUSTOM_HOLDING","","","False","" +"CASH_IN","2026-05-01 00:16:15","RUB","1","760","RUB","0","","","","False","Пополнение счета" +"DIVIDEND","2026-05-04 13:13:02","RU000A10B0U0","0","94,5","RUB","0","MCX","","","False","" +"CASH_IN","2026-05-05 13:48:20","RUB","1","5000","RUB","0","","","","False","Пополнение счета" +"DIVIDEND","2026-05-07 11:34:21","RU000A10B214","0","92,46","RUB","0","MCX","","","False","" +"CASH_IN","2026-05-10 10:27:31","RUB","1","2000","RUB","0","","","","False","Пополнение счета" +"BUY","2026-05-11 13:45:38","TMOS","6,36","485","RUB","0","MCX","","","False","" +"BUY","2026-05-11 13:45:39","TGLD","13,6","24","RUB","0","MCX","","","False","" +"BUY","2026-05-11 13:45:39","TMOS","6,36","464","RUB","0","MCX","","","False","" +"CASH_IN","2026-05-12 16:23:54","RUB","1","9000","RUB","0","","","","False","Пополнение счета" +"BUY","2026-05-12 16:26:57","RU000A10B0U0","1066,7","5","RUB","16","MCX","8,19","","False","" +"BUY","2026-05-12 16:27:09","RU000A10AT35","1031,5","4","RUB","12,38","MCX","11,82","","False","" +"BUY","2026-05-12 16:27:44","RU000A10BF48","1008","2","RUB","6,05","MCX","12,19","","False","" +"DIVIDEND","2026-05-13 12:22:10","RU000A10CQ85","0","69,06","RUB","0","MCX","","","False","" +"SELL","2026-05-18 14:13:35","TMOS","6,3","488","RUB","0","MCX","","","False","" +"DIVIDEND","2026-05-18 17:46:19","RU000A10AT35","0","141,8","RUB","0","MCX","","","False","" +"DIVIDEND","2026-05-19 12:28:16","RU000A10BF48","0","108,16","RUB","0","MCX","","","False","" +"BUY","2026-05-25 14:33:45","TMOS","6,28","98","RUB","0","MCX","","","False","" +"BUY","2026-05-25 14:33:46","TBRU","8,32","21","RUB","0","MCX","","","False","" +"BUY","2026-05-25 14:33:46","TGLD","12,81","5","RUB","0","MCX","","","False","" +"DIVIDEND","2026-05-26 18:44:26","SIBN6P4","0","124,94","RUB","0","CUSTOM_HOLDING","","","False","" +"CASH_IN","2026-05-28 00:00:00","RUB","1","4000","RUB","0","","","","False","" +"CASH_IN","2026-05-28 00:00:00","RUB","1","4000","RUB","0","","","","False","ЗАЧИСЛЕНИЕ Д/С" +"BUY","2026-05-28 00:00:00","SBRB","18,89099057","212","RUB","0,8","MCX","","","False","" +"BUY","2026-05-28 11:06:55","EQMX","136,3","29","RUB","0,79","MCX","","","False","" +"BUY","2026-06-01 16:08:51","TBRU","8,33","1","RUB","0","MCX","","","False","" +"BUY","2026-06-01 16:08:51","TMOS","6,15","5","RUB","0","MCX","","","False","" +"DIVIDEND","2026-06-02 12:30:21","RU000A10B0U0","0","189","RUB","0","MCX","","","False","" +"CASH_IN","2026-06-05 12:10:51","RUB","1","5000","RUB","0","","","","False","Пополнение счета" +"DIVIDEND","2026-06-08 12:41:38","RU000A10B214","0","92,46","RUB","0","MCX","","","False","" +"BUY","2026-06-08 14:44:52","TMOS","6,1","588","RUB","0","MCX","","","False","" +"BUY","2026-06-08 14:44:52","TMOS","6,1","576","RUB","0","MCX","","","False","" +"SELL","2026-06-08 14:47:22","TMOS","6,09","569","RUB","0","MCX","","","False","" +"BUY","2026-06-08 15:13:02","TMOS","6,09","151","RUB","0","MCX","","","False","" +"BUY","2026-06-08 15:13:03","TBRU","8,36","31","RUB","0","MCX","","","False","" +"BUY","2026-06-08 15:13:03","TGLD","12,48","8","RUB","0","MCX","","","False","" +"DIVIDEND","2026-06-10 13:22:42","RU000A10CQ85","0","69,06","RUB","0","MCX","","","False","" +"BUY","2026-06-15 16:28:53","TMOS","6,06","3","RUB","0","MCX","","","False","" +"CASH_IN","2026-06-15 19:47:02","RUB","1","10000","RUB","0","","","","False","Пополнение счета" +"BUY","2026-06-15 20:22:47","RU000A10B214","1039,9","4","RUB","12,48","MCX","5,65","","False","" +"BUY","2026-06-15 20:22:56","RU000A10BF48","1008","2","RUB","6,05","MCX","0,44","","False","" +"BUY","2026-06-15 20:23:06","RU000A10CQ85","1004,2","4","RUB","12,05","MCX","2,68","","False","" +"DIVIDEND","2026-06-16 15:47:20","RU000A10BF48","0","106,56","RUB","0","MCX","","","False","" +"DIVIDEND","2026-06-18 10:10:16","RU000A10AT35","0","141,8","RUB","0","MCX","","","False","" +"BUY","2026-06-22 16:08:34","TBRU","8,28","3","RUB","0","MCX","","","False","" +"BUY","2026-06-22 16:08:34","TMOS","5,63","17","RUB","0","MCX","","","False","" +"DIVIDEND","2026-06-24 16:34:47","SU29016RMFS1","0","538,05","RUB","0","MCX","","","False","" +"DIVIDEND","2026-06-25 19:11:14","SIBN6P4","0","131,06","RUB","0","CUSTOM_HOLDING","","","False","" +"BUY","2026-06-26 23:28:57","TMOS","5,51","417","RUB","0","MCX","","","False","" +"CASH_IN","2026-06-28 00:00:00","RUB","1","4000","RUB","0","","","","False","" +"CASH_IN","2026-06-28 00:00:00","RUB","1","4000","RUB","0","","","","False","ЗАЧИСЛЕНИЕ Д/С" +"BUY","2026-06-28 00:00:00","SBRB","18,76201878","213","RUB","0,8","MCX","","","False","" +"BUY","2026-06-28 14:13:46","EQMX","121,6","33","RUB","0","MCX","","","False","" +"DIVIDEND","2026-06-30 12:00:37","RU000A10B0U0","0","189","RUB","0","MCX","","","False","" +"CASH_IN","2026-07-01 12:25:32","RUB","1","1000","RUB","0","","","","False","Пополнение счета" +"CASH_IN","2026-07-01 12:25:56","RUB","1","1000","RUB","0","","","","False","Пополнение счета" +"BUY","2026-07-01 12:39:46","TGLD","12,17","68","RUB","0","MCX","","","False","" +"DIVIDEND","2026-07-07 13:30:58","RU000A10B214","0","154,1","RUB","0","MCX","","","False","" +"DIVIDEND","2026-07-10 10:36:35","RU000A10CQ85","0","115,1","RUB","0","MCX","","","False","" +"BUY","2026-07-10 10:43:57","RU000A10CQ85","997,1","1","RUB","0,4","MCX","1,53","","False","" +"BUY","2026-07-10 10:44:04","RU000A10CQ85","997,2","2","RUB","0,8","MCX","1,53","","False","" +"CASH_IN","2026-07-13 11:03:09","RUB","1","10000","RUB","0","","","","False","Пополнение счета" +"CASH_IN","2026-07-13 11:03:15","RUB","1","10000","RUB","0","","","","False","Пополнение счета" +"CASH_IN","2026-07-13 11:37:10","RUB","1","5000","RUB","0","","","","False","Пополнение счета" +"BUY","2026-07-13 12:32:18","TBRU","8,21","217","RUB","0","MCX","","","False","" +"BUY","2026-07-13 12:32:18","TMOS","5,29","1523","RUB","0","MCX","","","False","" +"BUY","2026-07-13 12:32:19","TGLD","12,23","58","RUB","0","MCX","","","False","" +"BUY","2026-07-13 12:33:16","RU000A10B0U0","1044,2","3","RUB","1,25","MCX","9,45","","False","" +"BUY","2026-07-14 07:18:33","RU000A10B0U0","1045","2","RUB","0,84","MCX","10,08","","False","" +"BUY","2026-07-14 07:18:48","RU000A10CQ85","998,3","2","RUB","0,8","MCX","2,3","","False","" +"BUY","2026-07-14 07:18:57","RU000A10AT35","1026,2","5","RUB","2,05","MCX","13,23","","False","" +"BUY","2026-07-14 07:19:14","SU29016RMFS1","1000,96","2","RUB","0,8","MCX","8,13","","False","" +"DIVIDEND","2026-07-16 12:32:09","RU000A10BF48","0","131,8","RUB","0","MCX","","","False","" +"DIVIDEND","2026-07-17 17:42:53","RU000A10AT35","0","212,7","RUB","0","MCX","","","False","" +"BUY","2026-07-19 15:39:27","TMOS","4,77","130","RUB","0","MCX","","","False","" +"SELL","2026-07-20 13:22:33","TBRU","8,06","290","RUB","0","MCX","","","False","" +"CASH_IN","2026-07-23 00:00:00","RUB","1","10000","RUB","0","","","","False","ЗАЧИСЛЕНИЕ Д/С" +"BUY","2026-07-23 00:00:00","SBER","263,68","17","RUB","13,45","MCX","","","False","" +"BUY","2026-07-23 00:00:00","SBER","263,68","3","RUB","2,37","MCX","","","False","" +"BUY","2026-07-24 00:00:00","MOEX","146,28","10","RUB","4,39","MCX","","","False","" +"BUY","2026-07-24 00:00:00","MOEX","146,28","20","RUB","8,78","MCX","","","False","" +"DIVIDEND","2026-07-28 09:53:12","SIBN6P4","0","138,06","RUB","0","CUSTOM_HOLDING","","","False","" +"DIVIDEND","2026-07-30 11:50:58","RU000A10B0U0","0","283,5","RUB","0","MCX","","","False","" +"BUY","2026-07-30 12:13:11","SU26207RMFS9","978,99","1","RUB","2,94","MCX","39,52","","False","" +"DIVIDEND","2026-08-05 13:31:58","RU000A10B214","0","154,1","RUB","0","MCX","","","False","" +"DIVIDEND","2026-08-05 18:29:11","SU26207RMFS9","0","1463,04","RUB","0","MCX","","","False","" +"BUY","2026-08-05 22:37:13","SU26207RMFS9","978,88","1","RUB","2,94","MCX","0,22","","False","" +"DIVIDEND","2026-08-11 13:40:31","RU000A10CQ85","0","172,65","RUB","0","MCX","","","False","" +"BUY","2026-08-13 00:00:00","LKOH","4534,5","1","RUB","14,96","MCX","","","False","" +"CASH_IN","2026-08-13 00:00:00","RUB","1","4000","RUB","0","","","","False","" +"CASH_IN","2026-08-13 00:00:00","RUB","1","10551,18","RUB","0","","","","False","ПЕРЕВОД Д/С С ДОГОВОРА 8184V30, ТП ОСНОВНОЙ РЫНОК НА ДОГОВОР S930W42, ТП ОСНОВНОЙ РЫНОК" +"BUY","2026-08-13 00:00:00","TATN","548,3","9","RUB","16,29","MCX","","","False","" +"CASH_IN","2026-08-13 11:05:50","RUB","1","14000","RUB","0","","","","False","Пополнение счета" +"CASH_IN","2026-08-13 13:06:52","RUB","1","10000","RUB","0","","","","False","Пополнение счета" +"BUY","2026-08-13 15:56:15","EQMX","122,9","33","RUB","0,81","MCX","","","False","" +"BUY","2026-08-14 14:13:20","SU29016RMFS1","1000,8","5","RUB","15,01","MCX","20,92","","False","" +"BUY","2026-08-14 14:13:42","SU26207RMFS9","979,08","6","RUB","17,62","MCX","2,68","","False","" +"DIVIDEND","2026-08-17 13:13:29","RU000A10BF48","0","130,1","RUB","0","MCX","","","False","" +"DIVIDEND","2026-08-17 16:37:48","RU000A10AT35","0","212,7","RUB","0","MCX","","","False","" +"BUY","2026-08-18 14:52:52","TBRU","8,41","427","RUB","0","MCX","","","False","" +"BUY","2026-08-18 14:52:52","TMOS","5,34","1843","RUB","0","MCX","","","False","" +"DIVIDEND","2026-08-26 09:18:46","SIBN6P4","0","147,02","RUB","0","CUSTOM_HOLDING","","","False","" +"CASH_IN","2026-08-27 11:04:19","RUB","1","450","RUB","0","","","","False","Пополнение счета" +"BUY","2026-08-27 11:04:52","TMOS","5,16","121","RUB","0","MCX","","","False","" +"CASH_IN","2026-08-28 11:45:37","RUB","1","450","RUB","0","","","","False","Пополнение счета" +"CASH_IN","2026-08-29 12:12:09","RUB","1","450","RUB","0","","","","False","Пополнение счета" +"CASH_IN","2026-08-30 12:07:12","RUB","1","450","RUB","0","","","","False","Пополнение счета" +"BUY","2026-08-31 11:00:16","TBRU","8,44","40","RUB","0","MCX","","","False","" +"BUY","2026-08-31 11:00:16","TMOS","5,33","173","RUB","0","MCX","","","False","" +"DIVIDEND","2026-08-31 12:29:47","RU000A10B0U0","0","283,5","RUB","0","MCX","","","False","" +"CASH_IN","2026-08-31 13:44:09","RUB","1","450","RUB","0","","","","False","Пополнение счета" +"BUY","2026-09-01 11:00:11","TBRU","8,46","25","RUB","0","MCX","","","False","" +"BUY","2026-09-01 11:00:11","TMOS","5,43","38","RUB","0","MCX","","","False","" +"CASH_IN","2026-09-01 12:49:47","RUB","1","450","RUB","0","","","","False","Пополнение счета" +"BUY","2026-09-02 11:00:17","TBRU","8,45","18","RUB","0","MCX","","","False","" +"BUY","2026-09-02 11:00:17","TMOS","5,4","56","RUB","0","MCX","","","False","" +"CASH_IN","2026-09-02 14:10:10","RUB","1","450","RUB","0","","","","False","Пополнение счета" +"DIVIDEND","2026-09-03 10:57:34","SU29007RMFS0","0","1307,4","RUB","0","MCX","","","False","" +"BUY","2026-09-03 11:00:18","TBRU","8,45","113","RUB","0","MCX","","","False","" +"BUY","2026-09-03 11:00:18","TMOS","5,45","425","RUB","0","MCX","","","False","" +"BUY","2026-09-03 11:20:02","SU26207RMFS9","980,48","2","RUB","5,88","MCX","6,7","","False","" +"CASH_IN","2026-09-03 13:52:30","RUB","1","450","RUB","0","","","","False","Пополнение счета" +"BUY","2026-09-04 11:00:21","TMOS","5,58","9","RUB","0","MCX","","","False","" +"BUY","2026-09-04 11:00:22","TBRU","8,45","50","RUB","0","MCX","","","False","" +"CASH_IN","2026-09-04 11:31:06","RUB","1","450","RUB","0","","","","False","Пополнение счета" +"DIVIDEND","2026-09-04 12:06:55","RU000A10B214","0","154,1","RUB","0","MCX","","","False","" +"CASH_IN","2026-09-05 13:46:10","RUB","1","450","RUB","0","","","","False","Пополнение счета" +"CASH_IN","2026-09-06 14:12:11","RUB","1","450","RUB","0","","","","False","Пополнение счета" +"BUY","2026-09-07 11:00:18","TBRU","8,46","66","RUB","0","MCX","","","False","" +"BUY","2026-09-07 11:00:18","TMOS","5,58","140","RUB","0","MCX","","","False","" +"BUY","2026-09-08 11:00:20","TBRU","8,46","2","RUB","0","MCX","","","False","" +"DIVIDEND","2026-09-08 13:02:22","RU000A10CQ85","0","172,65","RUB","0","MCX","","","False","" +"CASH_IN","2026-09-11 16:30:24","RUB","1","2300","RUB","0","","","","False","Пополнение счета" +"BUY","2026-09-11 18:32:59","TBRU","8,47","97","RUB","0","MCX","","","False","" +"BUY","2026-09-11 18:32:59","TGLD","14,32","41","RUB","0","MCX","","","False","" +"BUY","2026-09-11 18:32:59","TMOS","5,69","151","RUB","0","MCX","","","False","" +"CUSTOM_HOLDING_PRICE","2026-09-12 00:00:00","SIBN6P4","12228,06098","0","RUB","0","CUSTOM_HOLDING","0","","","" +"CASH_IN","2026-09-12 11:31:49","RUB","1","450","RUB","0","","","","False","Пополнение счета" +"CUSTOM_HOLDING_PRICE","2026-09-13 00:00:00","SIBN6P4","12228,06098","0","RUB","0","CUSTOM_HOLDING","0","","","" +"CASH_IN","2026-09-13 13:32:57","RUB","1","450","RUB","0","","","","False","Пополнение счета" +"CASH_IN","2026-09-13 14:37:16","RUB","1","10000","RUB","0","","","","False","Пополнение счета" +"CUSTOM_HOLDING_PRICE","2026-09-14 00:00:00","SIBN6P4","12320,94504","0","RUB","0","CUSTOM_HOLDING","0","","","" +"BUY","2026-09-14 11:00:21","TBRU","8,48","60","RUB","0","MCX","","","False","" +"BUY","2026-09-14 11:00:21","TMOS","5,78","10","RUB","0","MCX","","","False","" +"BUY","2026-09-14 11:00:22","TGLD","14,15","25","RUB","0","MCX","","","False","" +"CASH_IN","2026-09-14 14:16:05","RUB","1","450","RUB","0","","","","False","Пополнение счета" +"CUSTOM_HOLDING_PRICE","2026-09-15 00:00:00","SIBN6P4","12284,594","0","RUB","0","CUSTOM_HOLDING","0","","","" +"BUY","2026-09-15 11:00:18","TBRU","8,49","39","RUB","0","MCX","","","False","" +"BUY","2026-09-15 11:00:18","TGLD","14,2","8","RUB","0","MCX","","","False","" +"CASH_IN","2026-09-15 14:34:11","RUB","1","450","RUB","0","","","","False","Пополнение счета" +"DIVIDEND","2026-09-15 15:56:39","RU000A10BF48","0","129","RUB","0","MCX","","","False","" +"DIVIDEND","2026-09-15 16:51:49","RU000A10AT35","0","212,7","RUB","0","MCX","","","False","" +"BUY","2026-09-16 00:00:00","AFLT","32,65","30","RUB","3,22","MCX","0","RUB","False","" +"BUY","2026-09-16 00:00:00","AFLT","32,66","100","RUB","10,78","MCX","0","RUB","False","" +"BUY","2026-09-16 00:00:00","CHMF","638,4","2","RUB","4,22","MCX","0","RUB","False","" +"BUY","2026-09-16 00:00:00","CHMF","639,4","5","RUB","10,55","MCX","0","RUB","False","" +"BUY","2026-09-16 00:00:00","MSNG","1,44","1000","RUB","4,75","MCX","0","RUB","False","" +"BUY","2026-09-16 00:00:00","MSNG","1,44","2000","RUB","9,49","MCX","0","RUB","False","" +"BUY","2026-09-16 00:00:00","MTSS","189,8","2","RUB","1,26","MCX","0","RUB","False","" +"BUY","2026-09-16 00:00:00","MTSS","189,1","10","RUB","6,25","MCX","0","RUB","False","" +"BUY","2026-09-16 00:00:00","MTSS","190,25","5","RUB","3,14","MCX","0","RUB","False","" +"BUY","2026-09-16 00:00:00","PHOR","5309","2","RUB","35,05","MCX","0","RUB","False","" +"CASH_IN","2026-09-16 00:00:00","RUB","1","10000","RUB","0","","","","False","" +"SELL","2026-09-16 00:00:00","SBRB","19,06","983","RUB","3,75","MCX","0","RUB","False","" +"BUY","2026-09-16 11:00:22","TMOS","5,8","80","RUB","0","MCX","","","False","" +"CASH_IN","2026-09-16 11:31:47","RUB","1","450","RUB","0","","","","False","Пополнение счета" +"CUSTOM_HOLDING_SETTINGS","2026-09-17 00:00:00","SIBN6P4","0","0","RUB","0","CUSTOM_HOLDING","0","","","{@*@Holding@*@:{@*@Note@*@:null,@*@Currency@*@:@*@RUB@*@,@*@Description@*@:@*@\u0413\u0430\u0437\u043F\u0440\u043E\u043C \u041D\u0435\u0444\u0442\u044C 006\u0420-04@*@,@*@DividendTax@*@:null,@*@Sector@*@:@*@Other@*@},@*@Settings@*@:{@*@CustomHoldingType@*@:0,@*@IncomeType@*@:1,@*@FirstIncomeDate@*@:null,@*@GenerateIncome@*@:false,@*@IncomeAmount@*@:null,@*@IncomeReinvestmentType@*@:0,@*@IsIncomeReinvested@*@:false,@*@MaturityDate@*@:null,@*@Period@*@:null,@*@PeriodType@*@:0,@*@NextIncomeDate@*@:null}}" +"BUY","2026-09-17 11:00:21","TMOS","5,61","80","RUB","0","MCX","","","False","" +"CASH_IN","2026-09-17 11:29:23","RUB","1","450","RUB","0","","","","False","Пополнение счета" +"BUY","2026-09-17 16:23:00","RU000A10BK09","1014,4","2","RUB","6,09","MCX","8,33","","False","" +"BUY","2026-09-17 16:23:11","RU000A10BK09","1014,4","3","RUB","9,13","MCX","8,33","","False","" +"BUY","2026-09-17 16:23:18","RU000A10BK09","1014,5","1","RUB","3,04","MCX","8,33","","False","" +"BUY","2026-09-17 16:23:50","RU000A10BF48","1006","4","RUB","12,07","MCX","2,15","","False","" +"SELL","2026-09-17 16:24:21","RU000A10CQ85","1000,2","9","RUB","27","MCX","4,22","","False","" +"SELL","2026-09-17 16:24:26","RU000A10CQ85","1000,1","1","RUB","3","MCX","4,22","","False","" +"SELL","2026-09-17 16:24:30","RU000A10CQ85","1000","5","RUB","15","MCX","4,22","","False","" +"BUY","2026-09-17 16:24:46","RU000A10BK09","1014,4","7","RUB","21,3","MCX","8,33","","False","" +"BUY","2026-09-17 16:24:54","RU000A10BF48","1006","8","RUB","24,14","MCX","2,15","","False","" diff --git a/backend/tests/fixtures/reports/vtb/report8ef8347a-2deb-e880-959a-d9a8ca12f735.xlsx b/backend/tests/fixtures/reports/vtb/report8ef8347a-2deb-e880-959a-d9a8ca12f735.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..77bbc213b74d8e4fd073e6668bf66a31a416fea9 GIT binary patch literal 24529 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 literal 0 HcmV?d00001 diff --git a/backend/tests/sources/test_report_csv.py b/backend/tests/sources/test_report_csv.py new file mode 100644 index 0000000..7ead30b --- /dev/null +++ b/backend/tests/sources/test_report_csv.py @@ -0,0 +1,420 @@ +"""The universal event-CSV parser, checked against the real Snowball export. + +The fixture is the only file in the project that covers every brokerage account at once, +so these tests double as its documentation: the exact number of rows of each type, which +of them deliberately never become ledger events, and what the format's ambiguities were +decided to mean. A change in any of those numbers is a change in the import contract, not +a test that needs relaxing. +""" + +from __future__ import annotations + +import collections +import dataclasses +from decimal import Decimal +from pathlib import Path + +import pytest + +from fintracker.models.ledger import EventKind +from fintracker.sources.reports.base import BrokerEvent, ParsedReport, fingerprint_key +from fintracker.sources.reports.csv_universal import UniversalCsvParser + +FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "reports" +SNOWBALL = FIXTURES / "snowball" + +HEADER = ( + "Event,Date,Symbol,Price,Quantity,Currency,FeeTax,Exchange,NKD,FeeCurrency,DoNotAdjustCash,Note" +) + +#: What the fixture must yield, per kind. Sums to 535 of its 545 rows: 5 CUSTOM_HOLDING_PRICE +#: and 1 CUSTOM_HOLDING_SETTINGS are not events at all, and 4 cash rows are Snowball's own +#: balance corrections. +EXPECTED_COUNTS = { + EventKind.buy: 270, + EventKind.sell: 60, + EventKind.dividend: 80, + EventKind.deposit: 96, + EventKind.withdrawal: 5, + EventKind.amortization: 9, + EventKind.commission: 7, + EventKind.tax: 4, + EventKind.tax_refund: 2, + EventKind.stock_split: 2, +} + + +@pytest.fixture(scope="module") +def parser() -> UniversalCsvParser: + return UniversalCsvParser() + + +@pytest.fixture(scope="module") +def fixture_path() -> Path: + files = sorted(SNOWBALL.glob("*.csv")) + assert files, f"нет фикстуры в {SNOWBALL}" + return files[0] + + +@pytest.fixture(scope="module") +def report(parser: UniversalCsvParser, fixture_path: Path) -> ParsedReport: + return parser.parse(fixture_path.read_bytes(), fixture_path.name) + + +def _csv(*rows: str) -> bytes: + return ("" + "\n".join((HEADER, *rows)) + "\n").encode("utf-8") + + +def _key(report: ParsedReport, event: BrokerEvent) -> str: + """The dedupe key `ledger/ingest.py` will build for this event (plan §1.6 A).""" + return fingerprint_key( + report.broker, + report.account_external_id, + event.kind, + event.instrument.key() if event.instrument else "", + event.trade_date, + event.quantity, + event.price, + event.currency, + amount=event.amount, + seq=event.seq, + ) + + +def _floats(value: object, path: str) -> list[str]: + """Every float reachable from `value`, by path — money must be Decimal end to end.""" + if isinstance(value, bool | str | bytes) or value is None: + return [] + if isinstance(value, float): + return [f"{path} = {value!r}"] + if dataclasses.is_dataclass(value) and not isinstance(value, type): + return [ + bad + for f in dataclasses.fields(value) + for bad in _floats(getattr(value, f.name), f"{path}.{f.name}") + ] + if isinstance(value, dict): + return [bad for k, v in value.items() for bad in _floats(v, f"{path}[{k!r}]")] + if isinstance(value, list | tuple | set): + return [bad for i, v in enumerate(value) for bad in _floats(v, f"{path}[{i}]")] + return [] + + +# --- 1. sniff ------------------------------------------------------------------------- + + +def test_sniff_recognises_the_fixture(parser: UniversalCsvParser, fixture_path: Path) -> None: + assert parser.sniff(fixture_path.read_bytes(), fixture_path.name) is True + + +def test_sniff_ignores_the_filename(parser: UniversalCsvParser) -> None: + """Recognition is by the header, so a renamed export is still recognised...""" + data = _csv('"CASH_IN","2025-02-26 09:22:54","RUB","1","5000","RUB","0","","","","False",""') + assert parser.sniff(data, "какой-то-файл.csv") is True + assert parser.sniff(data, "") is True + + +def test_sniff_rejects_other_report_formats(parser: UniversalCsvParser) -> None: + """...and a file that merely has an extension we read is not ours.""" + others = sorted((FIXTURES / "sber").glob("*.html")) + sorted((FIXTURES / "vtb").glob("*.xlsx")) + assert others, "нет чужих фикстур для отрицательной проверки" + for path in others: + assert parser.sniff(path.read_bytes(), path.name) is False, path.name + + +def test_sniff_rejects_a_csv_with_foreign_columns(parser: UniversalCsvParser) -> None: + assert parser.sniff(b"a,b,c\n1,2,3\n", "x.csv") is False + assert parser.sniff(b"", "x.csv") is False + assert parser.sniff(b"\x00\x01\x02", "x.csv") is False + + +def test_parse_rejects_a_file_it_cannot_read(parser: UniversalCsvParser) -> None: + from fintracker.sources.reports.base import ParseError + + with pytest.raises(ParseError): + parser.parse(b"a,b,c\n1,2,3\n", "x.csv") + with pytest.raises(ParseError): + parser.parse(_csv(), "empty.csv") + + +# --- 2. what the fixture yields -------------------------------------------------------- + + +def test_report_header(report: ParsedReport) -> None: + import datetime + + assert report.broker == "csv" + assert report.parser_version == "1" + # the file names a portfolio, never an account — see the warning below + assert report.account_external_id == "Мой капитал" + assert report.period_from == datetime.date(2025, 2, 26) + assert report.period_to == datetime.date(2026, 9, 17) + + +def test_event_counts_by_kind(report: ParsedReport) -> None: + counts = collections.Counter(e.kind for e in report.events) + assert dict(counts) == EXPECTED_COUNTS + assert len(report.events) == 535 + assert report.meta["row_count"] == 545 + + +def test_instruments_are_deduplicated(report: ParsedReport) -> None: + keys = [ref.key() for ref in report.instruments] + assert len(keys) == len(set(keys)) == 49 + # bonds arrive by ISIN, everything else by ticker; cash rows name no instrument + assert "ISIN:RU000A10B7T7" in keys + assert "TICKER:SBER" in keys + assert "TICKER:SU26212RMFS9" in keys, "секунда ОФЗ — не ISIN, а тикер MOEX" + assert not any(k.startswith("TICKER:RUB") for k in keys) + + +# --- 3. decimals ---------------------------------------------------------------------- + + +def test_decimal_comma_is_parsed(parser: UniversalCsvParser) -> None: + data = _csv( + '"BUY","2025-03-01 10:00:00","SBER","4,48","219","RUB","6,57","MCX","0","RUB","False",""' + ) + (event,) = parser.parse(data, "renamed.csv").events + assert event.price == Decimal("4.48") + assert event.quantity == Decimal("219") + assert event.fee == Decimal("6.57") + assert event.amount == Decimal("-987.69") # 4.48 * 219 + 6.57, fee capitalised + + +def test_no_floats_anywhere_in_the_output(report: ParsedReport) -> None: + bad = _floats(report, "report") + assert bad == [] + + +def test_empty_numeric_cell_is_none_not_zero(parser: UniversalCsvParser) -> None: + """An absent НКД is not a zero НКД: one is «not a bond», the other is «no interest».""" + data = _csv('"BUY","2025-03-01 10:00:00","SBER","100","1","RUB","0","MCX","","","False",""') + (event,) = parser.parse(data, "x.csv").events + assert event.accrued_interest is None + assert event.fee == Decimal(0) + + +def test_bond_trade_adds_accrued_interest_to_the_money(report: ParsedReport) -> None: + """НКД is paid on top of the price by the buyer — it must be inside `amount`.""" + buy = next( + e + for e in report.events + if e.kind is EventKind.buy + and e.instrument is not None + and e.instrument.isin == "RU000A107AM4" + and e.accrued_interest == Decimal("5.08") + ) + assert buy.quantity == Decimal(4) + assert buy.price == Decimal("929.25") + assert buy.amount == -(Decimal(4) * Decimal("929.25") + Decimal("5.08") + Decimal("11.15")) + + +# --- 4. dividends and coupons ---------------------------------------------------------- + + +def test_dividend_money_lands_in_amount_not_quantity(report: ParsedReport) -> None: + dividends = [e for e in report.events if e.kind is EventKind.dividend] + assert len(dividends) == 80 + assert all(e.quantity is None for e in dividends), "Quantity у DIVIDEND — это деньги" + assert all(e.price is None for e in dividends) + assert all(e.amount > 0 for e in dividends) + assert sum(e.amount for e in dividends) == Decimal("12949.07") + + +def test_bond_dividends_carry_the_coupon_hint(report: ParsedReport) -> None: + """The parser cannot know an asset class, so it hands ingest a suspicion, not a kind.""" + dividends = [e for e in report.events if e.kind is EventKind.dividend] + hinted = [e for e in dividends if e.meta.get("payout_hint") == "coupon"] + assert len(hinted) == 44 + assert all( + e.instrument is not None + and ( + (e.instrument.isin or "").startswith("RU000A") + or (e.instrument.ticker or "").startswith("SU") + ) + for e in hinted + ) + # a share payout must never be hinted + assert all( + e.meta.get("payout_hint") is None + for e in dividends + if e.instrument is not None and e.instrument.ticker in {"T", "ROSN", "SBERP", "TATNP"} + ) + + +# --- 5. splits ------------------------------------------------------------------------- + + +def test_split_ratio_and_collapsing_dedupe_key(report: ParsedReport) -> None: + """Two identical SPLIT rows for T are one corporate action seen on two accounts. + + The format has no account column, so both rows land on the single account the user + points the import at. They therefore share a `fingerprint_key` on purpose: replaying the + ratio twice would multiply the position by 100 instead of by 10. This test pins that + behaviour — if a future format gains an account column, it is expected to break. + """ + splits = [e for e in report.events if e.kind is EventKind.stock_split] + assert len(splits) == 2 + assert {e.meta["split_ratio"] for e in splits} == {Decimal(10)} + assert {e.meta["ratio"] for e in splits} == {Decimal(10)} + assert all(e.amount == Decimal(0) and e.quantity is None for e in splits) + assert all(e.instrument is not None and e.instrument.ticker == "T" for e in splits) + assert [e.seq for e in splits] == [0, 0] + assert _key(report, splits[0]) == _key(report, splits[1]) + + +def test_every_other_event_keeps_a_distinct_dedupe_key(report: ParsedReport) -> None: + """The split pair is the ONLY intentional collapse in the file. + + In particular the pairs of equal same-day top-ups (two 1 100 ₽ one second apart, two + 4 000 ₽ that differ only by their note) survive as distinct events: they are real money + into two accounts, and netting them would understate the XIRR denominator. + """ + keys = [_key(report, e) for e in report.events] + collapsed = [k for k, n in collections.Counter(keys).items() if n > 1] + assert len(collapsed) == 1 + assert len(set(keys)) == 534 + same_second = [e for e in report.events if e.kind is EventKind.deposit and e.seq > 0] + assert same_second, "в файле есть одинаковые пополнения одного дня — они должны выживать" + + +def test_seq_is_stable_across_reparsing(parser: UniversalCsvParser, fixture_path: Path) -> None: + """A re-import of the same bytes must reproduce every key, or nothing ever upserts.""" + data = fixture_path.read_bytes() + first = parser.parse(data, fixture_path.name) + second = parser.parse(data, "совсем другое имя.csv") + assert [_key(first, e) for e in first.events] == [_key(first, e) for e in second.events] + + +# --- 6. amortisation ------------------------------------------------------------------- + + +def test_amortisation_is_money_on_a_bond(report: ParsedReport) -> None: + amortisations = [e for e in report.events if e.kind is EventKind.amortization] + assert len(amortisations) == 9 + assert all(e.amount > 0 for e in amortisations) + assert all(e.quantity is None and e.price is None for e in amortisations) + assert {e.instrument.isin for e in amortisations if e.instrument} == {"RU000A10B7T7"} + assert all( + e.instrument is not None and e.instrument.asset_class_hint == "bond" for e in amortisations + ) + assert sum(e.amount for e in amortisations) == Decimal("4530.07") + + +# --- 7. manual prices ------------------------------------------------------------------ + + +def test_custom_holding_prices_go_to_meta_not_to_events(report: ParsedReport) -> None: + """CUSTOM_HOLDING_PRICE is a `price_manual` row, not a ledger event.""" + prices = report.meta["manual_prices"] + assert len(prices) == 5 + assert {p["instrument_key"] for p in prices} == {"TICKER:SIBN6P4"} + assert {p["currency"] for p in prices} == {"RUB"} + assert all(isinstance(p["price"], Decimal) for p in prices) + assert max(p["price"] for p in prices) == Decimal("12320.94504") + assert all(e.meta.get("csv_event") != "CUSTOM_HOLDING_PRICE" for e in report.events) + # the instrument they price must be resolvable, so it is also listed + assert "TICKER:SIBN6P4" in {ref.key() for ref in report.instruments} + + +def test_the_export_has_no_price_for_the_other_unquoted_paper(report: ParsedReport) -> None: + """NDM_TBNK-PP-FIXPRCNT-08.25 is simply absent — this file cannot price it.""" + named = {(ref.ticker or "") + (ref.isin or "") for ref in report.instruments} + assert not any("NDM" in name for name in named) + assert not any("NDM" in p["instrument_key"] for p in report.meta["manual_prices"]) + + +def test_custom_holding_settings_describe_an_instrument(report: ParsedReport) -> None: + """Its Note is JSON with `"` rewritten to `@*@` and Cyrillic as \\uXXXX escapes.""" + ref = next(r for r in report.instruments if r.ticker == "SIBN6P4") + assert ref.name == "Газпром Нефть 006Р-04" + assert ref.currency == "RUB" + assert ref.meta["custom_holding"] is True + assert ref.meta["sector"] == "Other" + assert ref.meta["settings"]["CustomHoldingType"] == 0 + assert all(e.meta.get("csv_event") != "CUSTOM_HOLDING_SETTINGS" for e in report.events) + + +def test_broken_settings_json_is_a_warning_not_a_crash(parser: UniversalCsvParser) -> None: + data = _csv( + '"CUSTOM_HOLDING_SETTINGS","2026-09-17 00:00:00","XYZ","0","0","RUB","0",' + '"CUSTOM_HOLDING","0","","","{@*@Holding@*@:' + ) + result = parser.parse(data, "x.csv") + assert result.events == [] + assert any("не удалось разобрать JSON" in w for w in result.warnings) + assert [ref.key() for ref in result.instruments] == ["TICKER:XYZ"] + + +# --- 8. balance adjustments ------------------------------------------------------------ + + +def test_balance_adjustments_never_become_flows(report: ParsedReport) -> None: + """Snowball's own corrections would feed XIRR an external flow that never happened.""" + adjustments = report.meta["balance_adjustments"] + assert len(adjustments) == 4 + assert collections.Counter(a["kind"] for a in adjustments) == {"CASH_OUT": 3, "CASH_IN": 1} + assert all(isinstance(a["amount"], Decimal) for a in adjustments) + # detection is by the note, not by the amount: one correction is 1017,84 ₽ while a + # genuine withdrawal in the same file is 32 ₽ + assert min(abs(a["amount"]) for a in adjustments) == Decimal("0.0024") + assert max(abs(a["amount"]) for a in adjustments) == Decimal("1017.83999543") + excluded = {(a["kind"], a["d"], abs(a["amount"])) for a in adjustments} + for event in report.events: + kind = "CASH_IN" if event.kind is EventKind.deposit else "CASH_OUT" + assert (kind, event.trade_date, abs(event.amount)) not in excluded + assert any("правка остатка" in w for w in report.warnings) + + +def test_real_cash_flows_survive(report: ParsedReport) -> None: + deposits = [e for e in report.events if e.kind is EventKind.deposit] + withdrawals = [e for e in report.events if e.kind is EventKind.withdrawal] + assert len(deposits) == 96 and len(withdrawals) == 5 + assert all(e.amount > 0 and e.instrument is None for e in deposits) + assert all(e.amount < 0 and e.instrument is None for e in withdrawals) + assert sum(e.amount for e in withdrawals) == -Decimal("51294.41") + assert sum(e.amount for e in deposits) == Decimal("454058.45") + + +# --- 9. fees, taxes, refunds ----------------------------------------------------------- + + +def test_fee_tax_and_refund_signs(report: ParsedReport) -> None: + fees = [e for e in report.events if e.kind is EventKind.commission] + taxes = [e for e in report.events if e.kind is EventKind.tax] + refunds = [e for e in report.events if e.kind is EventKind.tax_refund] + assert (len(fees), len(taxes), len(refunds)) == (7, 4, 2) + assert all(e.instrument is None for e in fees + taxes + refunds) + assert all(e.quantity is None and e.price is None for e in fees + taxes + refunds) + assert all(e.amount == Decimal(-45) and e.fee == Decimal(45) for e in fees) + assert [e.amount for e in taxes] == [Decimal(-234), Decimal(-3), Decimal(-22), Decimal(-32)] + assert [e.amount for e in refunds] == [Decimal(3), Decimal(32)] + assert all(e.tax == -e.amount for e in taxes) + assert all(e.tax == e.amount for e in refunds) + + +# --- unknown types and reconciliation warnings ----------------------------------------- + + +def test_unknown_event_type_is_skipped_with_a_warning(parser: UniversalCsvParser) -> None: + """Never `EventKind.other`: a silent bucket hides a format that grew a new row type.""" + data = _csv( + '"MARGIN_CALL","2025-03-01 10:00:00","SBER","1","1","RUB","0","MCX","0","","False",""', + '"BUY","2025-03-01 10:00:00","SBER","100","1","RUB","0","MCX","0","","False",""', + ) + result = parser.parse(data, "x.csv") + assert [e.kind for e in result.events] == [EventKind.buy] + assert result.meta["unknown_events"] == {"MARGIN_CALL": 1} + assert any("MARGIN_CALL" in w for w in result.warnings) + assert not any(e.kind is EventKind.other for e in result.events) + + +def test_closing_balances_are_absent_and_said_to_be(report: ParsedReport) -> None: + assert report.positions_end == [] + assert report.cash_end == [] + assert any("positions_end" in w for w in report.warnings) + + +def test_warns_that_the_target_account_is_the_user_s_to_choose(report: ParsedReport) -> None: + assert any("shadow" in w and "все брокерские счета" in w for w in report.warnings) diff --git a/backend/tests/sources/test_report_registry.py b/backend/tests/sources/test_report_registry.py new file mode 100644 index 0000000..a1dc133 --- /dev/null +++ b/backend/tests/sources/test_report_registry.py @@ -0,0 +1,56 @@ +"""Реестр парсеров отчётов: какой файл достаётся какому парсеру (plan §2). + +Проверка выглядит тривиальной, но ловит ровно ту ошибку, которую больше не поймает никто: +универсальный CSV узнаёт файл по набору колонок, то есть по определению шире любого +брокерского формата, и достаточно поставить его в списке первым, чтобы отчёт Сбера тихо +уехал в чужой парсер. Порядок в `PARSERS` — это поведение, а не оформление. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from fintracker.sources.reports import registry + +FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "reports" + +CASES = [ + (FIXTURES / "sber" / "S930W42_11022026_17092026.html", "report_sber"), + (FIXTURES / "sber" / "S930W42_01082026_31082026.html", "report_sber"), + (FIXTURES / "vtb" / "reportd0235f1e-55ac-fdc8-5c7b-279e4fe26e2b.xlsx", "report_vtb"), + (FIXTURES / "vtb" / "report8ef8347a-2deb-e880-959a-d9a8ca12f735.xlsx", "report_vtb"), + (next((FIXTURES / "snowball").glob("*.csv")), "csv"), +] + + +@pytest.mark.parametrize("path,expected", CASES, ids=lambda v: getattr(v, "name", v)) +def test_pick_routes_each_fixture_to_its_parser(path: Path, expected: str) -> None: + chosen = registry.pick(path.read_bytes(), path.name) + assert chosen is not None, f"{path.name}: ни один парсер не узнал файл" + assert chosen.name == expected + + +def test_every_registered_parser_is_reachable_by_name() -> None: + assert set(registry.names()) == {"report_sber", "report_vtb", "csv"} + for name in registry.names(): + assert registry.get(name).name == name + + +def test_unknown_format_is_a_none_not_an_exception() -> None: + """`pick` возвращает None, и роутер превращает это в 415 — не в 500.""" + assert registry.pick(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n", "statement.pdf") is None + assert registry.pick(b"", "empty.txt") is None + + +def test_parser_metadata_is_filled_in() -> None: + """`parser_name` и `parser_version` уходят в `raw_report_file` — пустых быть не должно.""" + for parser in registry.PARSERS: + assert parser.name and parser.broker and parser.version + assert parser.formats + + +def test_csv_is_last_so_it_cannot_shadow_a_broker_format() -> None: + names = registry.names() + assert names.index("csv") == len(names) - 1 diff --git a/backend/tests/sources/test_report_sber.py b/backend/tests/sources/test_report_sber.py new file mode 100644 index 0000000..e4bb74b --- /dev/null +++ b/backend/tests/sources/test_report_sber.py @@ -0,0 +1,362 @@ +"""Парсер отчётов Сбера на обезличенных фикстурах (фаза 3). + +Фикстуры — два отчёта по одному и тому же счёту: полный (11.02–17.09.2026) и августовский +(01.08–31.08.2026). Пара выбрана не случайно: именно перекрывающиеся периоды ломают импорт, +если ключ дедупликации выводится из файла, а не из самой операции. + +Главная проверка здесь — арифметическая. Отчёт печатает свои итоги («Итого, RUB» по +сделкам, «Пополнение счета» и «Исходящий остаток» в сводке), и распарсенные события обязаны +их воспроизвести: пополнения минус покупки плюс продажи = остаток на конец периода. Если +парсер потеряет строку, задвоит комиссию или примет расчётную строку за сделку, это +равенство разойдётся — а тест на «количество событий» этого не заметит. +""" + +from __future__ import annotations + +from collections import Counter +from decimal import Decimal +from pathlib import Path + +import pytest + +from fintracker.models.ledger import EventKind +from fintracker.sources.reports.base import BrokerEvent, ParsedReport +from fintracker.sources.reports.sber import SberHtmlParser, event_dedupe_key + +FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "reports" +FULL = FIXTURES / "sber" / "S930W42_11022026_17092026.html" +AUGUST = FIXTURES / "sber" / "S930W42_01082026_31082026.html" + +ACCOUNT = "S930W42" +#: Второй договор того же брокера — источник переводов д/с, не внешний поток. +OTHER_AGREEMENT = "8184V30" + +ZERO = Decimal(0) + + +@pytest.fixture(scope="module") +def parser() -> SberHtmlParser: + return SberHtmlParser() + + +@pytest.fixture(scope="module") +def full(parser: SberHtmlParser) -> ParsedReport: + return parser.parse(FULL.read_bytes(), FULL.name) + + +@pytest.fixture(scope="module") +def august(parser: SberHtmlParser) -> ParsedReport: + return parser.parse(AUGUST.read_bytes(), AUGUST.name) + + +def total(events: list[BrokerEvent], kind: EventKind) -> Decimal: + return sum((e.amount for e in events if e.kind == kind), ZERO) + + +def of_kind(report: ParsedReport, kind: EventKind) -> list[BrokerEvent]: + return [e for e in report.events if e.kind == kind] + + +# --- 1. распознавание формата ------------------------------------------------------------ + + +def test_sniff_accepts_both_sber_reports(parser: SberHtmlParser) -> None: + assert parser.sniff(FULL.read_bytes(), FULL.name) + assert parser.sniff(AUGUST.read_bytes(), AUGUST.name) + + +@pytest.mark.parametrize( + "path", + sorted((FIXTURES / "vtb").glob("*.xlsx")) + sorted((FIXTURES / "snowball").glob("*.csv")), + ids=lambda p: p.suffix, +) +def test_sniff_rejects_other_formats(parser: SberHtmlParser, path: Path) -> None: + assert not parser.sniff(path.read_bytes(), path.name) + + +def test_sniff_never_raises_on_garbage(parser: SberHtmlParser) -> None: + """`registry.pick` опрашивает парсеры подряд — падение на чужом файле остановило бы перебор.""" + assert not parser.sniff(b"", "empty.html") + assert not parser.sniff(b"\x00\x01\x02not html at all", "junk.html") + + +# --- 2. шапка и состав --------------------------------------------------------------------- + + +def test_full_report_header(full: ParsedReport) -> None: + assert full.broker == "sber" + assert full.account_external_id == ACCOUNT + assert (full.period_from.isoformat(), full.period_to.isoformat()) == ( + "2026-02-11", + "2026-09-17", + ) + assert full.meta["opened_at"] == "2026-02-11" + + +def test_august_report_header(august: ParsedReport) -> None: + assert august.account_external_id == ACCOUNT + assert (august.period_from.isoformat(), august.period_to.isoformat()) == ( + "2026-08-01", + "2026-08-31", + ) + + +def test_full_report_event_counts(full: ParsedReport) -> None: + assert Counter(e.kind for e in full.events) == { + EventKind.buy: 24, + EventKind.deposit: 9, + EventKind.sell: 2, + } + + +def test_august_report_event_counts(august: ParsedReport) -> None: + assert Counter(e.kind for e in august.events) == { + EventKind.buy: 2, + EventKind.deposit: 1, + } + + +# --- 3. арифметика: события воспроизводят итоги самого отчёта ------------------------------- + + +def test_trade_totals_match_the_reports_own_total_row(full: ParsedReport) -> None: + """«Итого, RUB» таблицы сделок: 89 205.58 оборота, 138.03 брокеру, 19.54 бирже.""" + trades = of_kind(full, EventKind.buy) + of_kind(full, EventKind.sell) + assert len(trades) == 26 + + # Оборот — это «Сумма» сделки без комиссии, а `amount` её уже включает: у покупки + # прибавляет, у продажи вычитает. Отсюда знаки при обратном пересчёте. + turnover = sum( + (-e.amount - (e.fee or ZERO) if e.kind == EventKind.buy else e.amount + (e.fee or ZERO)) + for e in trades + ) + assert turnover == Decimal("89205.58") + assert sum((e.fee or ZERO for e in full.events), ZERO) == Decimal("138.03") + Decimal("19.54") + + +def test_deposits_match_the_summary_line(full: ParsedReport) -> None: + """«Пополнение счета» сводки считает и переводы с другого договора — как и парсер.""" + assert total(full.events, EventKind.deposit) == Decimal("49120.87") + assert Decimal(full.meta["summary"]["Пополнение счета"]) == Decimal("49120.87") + + +def test_cash_closes_against_the_reported_balance(full: ParsedReport) -> None: + """Сквозная проверка: сумма денежных эффектов всех событий = исходящий остаток. + + Это единственная проверка, которая ловит и потерянную строку, и лишнюю: пропущенная + сделка занижает отток, а расчётная строка «Сделка от …», принятая за сделку, задваивает + его. Входящий остаток нулевой, поэтому сумма событий и есть конечный остаток. + """ + assert Decimal(full.meta["summary"]["Входящий остаток"]) == ZERO + assert sum((e.amount for e in full.events), ZERO) == Decimal("3171.34") + + rub = next(c for c in full.cash_end if c.currency == "RUB") + assert rub.balance == Decimal("3171.34") + + +def test_august_cash_closes_from_its_own_opening_balance(august: ParsedReport) -> None: + opening = Decimal(august.meta["summary"]["Входящий остаток"]) + assert opening == Decimal("357.15") + rub = next(c for c in august.cash_end if c.currency == "RUB") + assert opening + sum((e.amount for e in august.events), ZERO) == rub.balance + + +# --- 4. комиссии учтены ровно один раз ----------------------------------------------------- + + +def test_commission_is_capitalised_and_never_emitted_separately(full: ParsedReport) -> None: + """Комиссия живёт в `fee` сделки; отдельных `commission`-событий быть не должно. + + `ledger/lots.py` капитализирует `fee` в стоимость лота, поэтому вторая, «денежная» + ипостась той же комиссии (строки «Комиссия Брокера от …» в движении денег) ушла бы в + леджер повторно — как отток, которого не было. + """ + assert not of_kind(full, EventKind.commission) + assert all(e.fee is None for e in full.events if e.kind == EventKind.deposit) + + fees = sum((e.fee or ZERO for e in full.events), ZERO) + assert fees == Decimal("157.57") + assert Decimal(full.meta["summary"]["Комиссия брокера"]) == Decimal("-138.03") + assert Decimal(full.meta["summary"]["Комиссия биржи"]) == Decimal("-19.54") + + +def test_parser_reports_no_fee_divergence(full: ParsedReport, august: ParsedReport) -> None: + """Парсер сам сверяет две ипостаси комиссии и предупреждает при расхождении.""" + for report in (full, august): + assert not [w for w in report.warnings if "комиссии расходятся" in w] + + +# --- 5. дедупликация перекрывающихся периодов ---------------------------------------------- + + +def keys(report: ParsedReport) -> dict[str, BrokerEvent]: + return {event_dedupe_key(report, e): e for e in report.events} + + +def test_august_events_keep_their_keys_in_the_full_report( + full: ParsedReport, august: ParsedReport +) -> None: + """Каждое августовское событие должно прийти с тем же ключом из полного отчёта. + + Иначе импорт двух пересекающихся отчётов задвоит август — ровно тот случай, который + план §1.6 A называет «перекрывающиеся периоды апсертят в ту же строку». + """ + full_keys, august_keys = keys(full), keys(august) + assert set(august_keys) <= set(full_keys), ( + "у августовских событий появились ключи, которых нет в полном отчёте: " + f"{sorted(set(august_keys) - set(full_keys))}" + ) + + for key, event in august_keys.items(): + twin = full_keys[key] + assert (event.kind, event.trade_date, event.quantity, event.price, event.amount) == ( + twin.kind, + twin.trade_date, + twin.quantity, + twin.price, + twin.amount, + ), f"ключ {key} указывает на разные операции" + + +def test_overlap_covers_every_august_operation(august: ParsedReport) -> None: + """Перекрытие не должно оказаться пустым — иначе предыдущий тест проходит вхолостую.""" + assert len(keys(august)) == len(august.events) == 3 + + +def test_trades_key_on_the_brokers_deal_number(full: ParsedReport) -> None: + trades = of_kind(full, EventKind.buy) + of_kind(full, EventKind.sell) + assert all(e.trade_no for e in trades) + assert len({e.trade_no for e in trades}) == len(trades) + + +def test_cash_rows_have_stable_keys_without_a_deal_number(full: ParsedReport) -> None: + """У денежных строк номера нет, и ключ обязан оставаться уникальным внутри отчёта.""" + deposits = of_kind(full, EventKind.deposit) + assert all(e.trade_no is None for e in deposits) + assert len({event_dedupe_key(full, e) for e in deposits}) == len(deposits) + + +def test_reparsing_the_same_bytes_is_deterministic(parser: SberHtmlParser) -> None: + once = parser.parse(FULL.read_bytes(), FULL.name) + twice = parser.parse(FULL.read_bytes(), FULL.name) + assert [event_dedupe_key(once, e) for e in once.events] == [ + event_dedupe_key(twice, e) for e in twice.events + ] + + +# --- 6. закрывающие позиции и остатки ------------------------------------------------------ + + +def test_closing_positions(full: ParsedReport) -> None: + assert len(full.positions_end) == 10 + assert sum((p.market_value or ZERO for p in full.positions_end), ZERO) == Decimal("46663.30") + + aeroflot = next(p for p in full.positions_end if p.instrument.isin == "RU0009062285") + assert aeroflot.qty == Decimal("130") + assert aeroflot.currency == "RUB" + + +def test_closing_cash_keeps_every_currency(full: ParsedReport) -> None: + balances = {c.currency: c.balance for c in full.cash_end} + assert balances == {"RUB": Decimal("3171.34"), "EUR": ZERO, "USD": ZERO} + + +def test_august_closing_snapshot(august: ParsedReport) -> None: + assert len(august.positions_end) == 5 + assert sum((p.market_value or ZERO for p in august.positions_end), ZERO) == Decimal("38739.43") + assert next(c for c in august.cash_end if c.currency == "RUB").balance == Decimal("1407.88") + + +# --- 7. справочник инструментов ------------------------------------------------------------ + + +def test_securities_directory_feeds_instruments(full: ParsedReport) -> None: + assert len(full.instruments) >= 9 + assert all(i.isin and i.ticker for i in full.instruments) + assert all(i.asset_class_hint for i in full.instruments) + + sber = next(i for i in full.instruments if i.ticker == "SBER") + assert (sber.isin, sber.asset_class_hint) == ("RU0009029540", "share") + fund = next(i for i in full.instruments if i.ticker == "STME") + assert fund.asset_class_hint in {"etf", "fund"} + + +def test_trades_reference_instruments_by_isin(full: ParsedReport) -> None: + """Сделка печатает только тикер — ISIN подставляется из справочника того же файла.""" + trades = of_kind(full, EventKind.buy) + of_kind(full, EventKind.sell) + assert all(e.instrument is not None and e.instrument.isin for e in trades) + + +# --- 8. ловушки формата -------------------------------------------------------------------- + + +def test_iis_contributions_table_is_read_but_not_emitted( + full: ParsedReport, august: ParsedReport +) -> None: + """Таблица зачислений на ИИС кумулятивна за ГОД, а не за период отчёта. + + Августовский файл перечисляет в ней восемь пополнений начиная с февраля — всё, что + случилось до даты его формирования (01.09.2026); в полном отчёте их девять, добавилось + сентябрьское. То есть таблица растёт от отчёта к отчёту независимо от периода, и если + бы парсер эмитил её строки, импорт августовского файла поверх полного задвоил бы + пополнения за полгода. Поэтому её содержимое только пересчитывается, в события не идёт: + августовский отчёт даёт ровно одно денежное поступление — то, что реально было в августе. + """ + assert full.meta["iis_contributions_ignored"] == 9 + assert august.meta["iis_contributions_ignored"] == 8 + + assert len(of_kind(august, EventKind.deposit)) == 1 + + +def test_settlement_lines_are_not_mistaken_for_trades(full: ParsedReport) -> None: + """«Сделка от DD.MM.YYYY» в движении денег — расчёт по уже учтённой сделке.""" + assert not [e for e in full.events if (e.description or "").startswith("Сделка от")] + + +def test_transfer_from_another_agreement_keeps_its_counterparty(full: ParsedReport) -> None: + """Перевод с другого договора Сбера — deposit, но с сохранённым источником. + + Без `internal_transfer_from` эти деньги навсегда останутся внешним потоком: заведи + пользователь второй договор счётом, XIRR увидел бы пополнение здесь и вывод там как два + независимых события. + """ + transfers = [ + e + for e in of_kind(full, EventKind.deposit) + if e.meta.get("internal_transfer_from") == OTHER_AGREEMENT + ] + assert len(transfers) == 2 + assert sum((e.amount for e in transfers), ZERO) == Decimal("3458.88") + Decimal("10551.18") + + +def test_missing_coupon_section_is_reported_not_assumed(full: ParsedReport) -> None: + """Раздела «Купонный доход» в этих отчётах нет — это факт для data quality, не молчание.""" + assert any("Купонный доход" in w for w in full.warnings) + + +# --- 9. деньги -------------------------------------------------------------------------------- + + +def numbers(report: ParsedReport) -> list[object]: + values: list[object] = [] + for e in report.events: + values += [e.amount, e.quantity, e.price, e.fee, e.tax, e.accrued_interest] + for p in report.positions_end: + values += [p.qty, p.price, p.market_value, p.accrued_interest] + values += [c.balance for c in report.cash_end] + return values + + +@pytest.mark.parametrize("name", ["full", "august"]) +def test_no_floats_anywhere(name: str, request: pytest.FixtureRequest) -> None: + """Деньги — только Decimal (AGENTS.md). Один float здесь означает потерю копеек ниже.""" + report: ParsedReport = request.getfixturevalue(name) + assert not [v for v in numbers(report) if isinstance(v, float)] + assert all(isinstance(v, Decimal) for v in numbers(report) if v is not None) + + +def test_every_event_carries_currency_and_traceability(full: ParsedReport) -> None: + for event in full.events: + assert event.currency == "RUB" + assert event.raw_line_no > 0 + assert event.meta.get("section") diff --git a/backend/tests/sources/test_report_vtb.py b/backend/tests/sources/test_report_vtb.py new file mode 100644 index 0000000..75f1e6a --- /dev/null +++ b/backend/tests/sources/test_report_vtb.py @@ -0,0 +1,410 @@ +"""VTB xlsx report parser against the two anonymised fixtures. + +The fixtures are the same account rendered twice: the whole life of the IIS (12.02–17.09) and +the single month of August, which is a strict subset of it. That overlap is the point — it is +exactly the case the dedupe key exists for, so the deal both files contain must produce one +key, and the monthly file must not invent a trade the full one does not have. + +Every number asserted here is taken from «Сводная информация по субсчёту Клиента», the +broker's own totals block: the parser is trusted only insofar as what it extracted adds up to +what VTB printed. +""" + +from __future__ import annotations + +import io +import zipfile +from collections import Counter +from dataclasses import fields, is_dataclass +from decimal import Decimal +from pathlib import Path + +import pytest +from openpyxl import load_workbook + +from fintracker.models.ledger import EventKind +from fintracker.sources.reports.base import ParsedReport +from fintracker.sources.reports.vtb import common +from fintracker.sources.reports.vtb.xlsx import VtbXlsxParser + +FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "reports" +FULL = FIXTURES / "vtb" / "reportd0235f1e-55ac-fdc8-5c7b-279e4fe26e2b.xlsx" +AUGUST = FIXTURES / "vtb" / "report8ef8347a-2deb-e880-959a-d9a8ca12f735.xlsx" + +#: The August deal, as printed in both files: «№ сделки» B17476399721, 33 EQMX at 122.9. +AUGUST_TRADE_NO = "B17476399721" + + +@pytest.fixture(scope="module") +def parser() -> VtbXlsxParser: + return VtbXlsxParser() + + +@pytest.fixture(scope="module") +def full(parser: VtbXlsxParser) -> ParsedReport: + return parser.parse(FULL.read_bytes(), FULL.name) + + +@pytest.fixture(scope="module") +def august(parser: VtbXlsxParser) -> ParsedReport: + return parser.parse(AUGUST.read_bytes(), AUGUST.name) + + +def trades(report: ParsedReport) -> list: + return [event for event in report.events if event.kind in {EventKind.buy, EventKind.sell}] + + +def count_security_movement_rows(path: Path) -> int: + """Rows of «Движение ценных бумаг» counted straight off the sheet. + + Deliberately independent of the parser: the section is the settlement side of the same + deals, so its row count is an outside check that no trade was dropped or doubled. + """ + workbook = load_workbook(io.BytesIO(path.read_bytes()), read_only=True, data_only=True) + sheet = workbook["brokerage_report"] + sheet.reset_dimensions() + inside = False + rows = 0 + for row in sheet.iter_rows(values_only=True): + label = common.normalize_text(row[1] if len(row) > 1 else None) + if label == "движение ценных бумаг": + inside = True + continue + if not inside: + continue + if label in {"заключенные в отчетном периоде сделки с ценными бумагами"}: + break + if "расчеты по заключенным сделкам" in common.normalize_text( + row[16] if len(row) > 16 else None + ): + rows += 1 + workbook.close() + return rows + + +# -- 1. sniff ---------------------------------------------------------------------------- + + +def test_sniff_accepts_both_vtb_files(parser: VtbXlsxParser) -> None: + for path in (FULL, AUGUST): + assert parser.sniff(path.read_bytes(), path.name) is True + + +def test_sniff_rejects_other_brokers(parser: VtbXlsxParser) -> None: + others = list((FIXTURES / "sber").glob("*.html")) + list((FIXTURES / "snowball").glob("*.csv")) + assert others, "фикстуры других брокеров не найдены — проверка бессмысленна" + for path in others: + assert parser.sniff(path.read_bytes(), path.name) is False + + +def test_sniff_never_raises_on_garbage(parser: VtbXlsxParser) -> None: + """A registry walks every parser over every upload, so a False must never be an + exception — including for a zip that merely looks like a workbook.""" + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr("xl/workbook.xml", "") + for payload in (b"", b"not a zip", b"PK\x03\x04broken", buffer.getvalue()): + assert parser.sniff(payload, "whatever.xlsx") is False + + +def test_sniff_ignores_the_file_name(parser: VtbXlsxParser) -> None: + """The name is a GUID with no format in it; content decides.""" + assert parser.sniff(FULL.read_bytes(), "totally-unrelated.bin") is True + + +# -- 2. the full report ------------------------------------------------------------------ + + +def test_full_report_header(full: ParsedReport) -> None: + assert full.broker == "vtb" + assert full.account_external_id == "77R593" + assert (full.period_from.isoformat(), full.period_to.isoformat()) == ( + "2026-02-12", + "2026-09-17", + ) + assert full.meta["iis_opened_at"] == "2026-02-12" + + +def test_full_report_trades_come_only_from_concluded_section(full: ParsedReport) -> None: + """«Завершенные» repeats «Заключенные» with the same deal numbers, and «Движение ценных + бумаг» settles those same deals — so ten deals must yield ten events, not twenty or + thirty.""" + numbers = [event.trade_no for event in trades(full)] + assert len(numbers) == len(set(numbers)) == count_security_movement_rows(FULL) == 10 + + +def test_full_report_parses_without_warnings(full: ParsedReport) -> None: + assert full.warnings == [] + + +# -- 3. money ---------------------------------------------------------------------------- + + +def test_full_report_deposits_and_fees(full: ParsedReport) -> None: + deposits = [event for event in full.events if event.kind is EventKind.deposit] + assert sum(event.amount for event in deposits) == Decimal("26100") + assert all(event.amount > 0 for event in deposits), "пополнение увеличивает счёт" + + # The per-deal commissions add up to «Вознаграждение брокера» of the summary block to the + # kopeck, which is why they are capitalised into the deals instead of being emitted + # separately: `ledger/lots.py` already puts `fee` into the cost of the lot. + assert sum((event.fee or Decimal(0)) for event in full.events) == Decimal("3.64") + assert Decimal(full.meta["summary"]["вознаграждение брокера"]) == Decimal("-3.64") + assert not [event for event in full.events if event.kind is EventKind.commission] + + +def test_full_report_signs(full: ParsedReport) -> None: + for event in trades(full): + assert event.quantity is not None + if event.kind is EventKind.buy: + assert event.quantity > 0 and event.amount < 0 + else: + assert event.quantity < 0 and event.amount > 0 + + +def test_settlement_saldo_is_not_emitted_twice(full: ParsedReport) -> None: + """«Сальдо расчетов по сделкам с ценными бумагами» is the money leg of the trades; the + cash section must contribute deposits only.""" + cash_kinds = Counter(event.kind for event in full.events if event.meta.get("section") == "cash") + assert cash_kinds == Counter({EventKind.deposit: 8}) + + +def test_closing_cash_follows_from_the_events(full: ParsedReport) -> None: + """The whole-file check: opening + everything emitted = closing, as VTB states it.""" + summary = full.meta["summary"] + opening = Decimal(summary["входящий остаток денежных средств"]) + closing = Decimal(summary["исходящий остаток денежных средств"]) + assert opening + sum(event.amount for event in full.events) == closing == Decimal("120.99") + + +# -- 4. positions and cash balances ------------------------------------------------------- + + +def test_full_report_positions_end(full: ParsedReport) -> None: + positions = {position.instrument.isin: position for position in full.positions_end} + assert set(positions) == {"RU000A101EJ5", "RU000A1014L8"} + assert positions["RU000A101EJ5"].qty == Decimal("197") + assert positions["RU000A1014L8"].qty == Decimal("0") + eqmx = positions["RU000A101EJ5"].instrument + assert (eqmx.ticker, eqmx.asset_class_hint) == ("EQMX", "etf") + assert eqmx.name == "EQMX ETF" + assert eqmx.meta["reg_number"] == "3965" + assert positions["RU000A101EJ5"].price == Decimal("124.653") + + +def test_currency_is_normalised_to_iso(full: ParsedReport) -> None: + """VTB prints the pre-ISO `RUR`; the ledger stores three-letter ISO codes only.""" + assert [(cash.currency, cash.balance) for cash in full.cash_end] == [("RUB", Decimal("120.99"))] + codes = {cash.currency for cash in full.cash_end} + codes |= {position.currency for position in full.positions_end} + codes |= {event.currency for event in full.events} + codes |= {event.price_currency for event in trades(full)} + assert codes == {"RUB"} + + +def test_instrument_hint_reaches_trade_events(full: ParsedReport) -> None: + """Only the positions table names the asset class («ПАЙ»); the trades table does not, so + the hint is carried over by ISIN.""" + assert {ref.asset_class_hint for ref in full.instruments} == {"etf"} + assert all( + event.instrument is not None and event.instrument.asset_class_hint == "etf" + for event in trades(full) + ) + + +# -- 5. the monthly report and the cross-file dedupe key ---------------------------------- + + +def test_august_report(august: ParsedReport) -> None: + assert (august.period_from.isoformat(), august.period_to.isoformat()) == ( + "2026-08-01", + "2026-08-31", + ) + assert august.account_external_id == "77R593" + assert august.warnings == [] + + deals = trades(august) + assert len(deals) == 1 + deal = deals[0] + assert deal.kind is EventKind.buy + assert deal.instrument is not None and deal.instrument.isin == "RU000A101EJ5" + assert deal.quantity == Decimal("33") + assert deal.price == Decimal("122.9") + assert deal.trade_no == AUGUST_TRADE_NO + # 33 * 122.9 = 4055.70 plus the 0.81 commission capitalised into the deal. + assert deal.amount == Decimal("-4056.51") + assert deal.fee == Decimal("0.81") + + +def test_same_deal_has_one_dedupe_key_in_both_reports( + full: ParsedReport, august: ParsedReport +) -> None: + """The overlapping-period case from plan §1.6 A: re-importing August after the full + report must upsert, not duplicate.""" + keys = {} + for report in (full, august): + for event in trades(report): + if event.trade_no == AUGUST_TRADE_NO: + keys[report.period_to] = common.dedupe_key(report.account_external_id, event) + assert len(keys) == 2 + assert len(set(keys.values())) == 1 + + +def test_dedupe_key_uses_the_brokers_own_deal_number(full: ParsedReport) -> None: + """«№ сделки» (Z), not «№ сделки у организатора торгов» (AC). + + The key is `sha1(broker | account | trade_no)` and is therefore already unique within the + broker; what it needs is the number VTB prints in every rendering of its own report. The + organiser's number is a different string (`17476399721` against `B17476399721`) and is + absent for over-the-counter deals in some formats. + """ + deal = next(event for event in trades(full) if event.trade_no == AUGUST_TRADE_NO) + assert deal.meta["exchange_trade_no"] == "17476399721" + assert common.dedupe_key("77R593", deal) != common.dedupe_key( + "77R593", + type(deal)( + **{ + **{field.name: getattr(deal, field.name) for field in fields(deal)}, + "trade_no": deal.meta["exchange_trade_no"], + } + ), + ) + + +def test_cash_rows_get_distinct_fingerprint_keys(full: ParsedReport) -> None: + """Deposits carry no deal number and fall back to a fingerprint; two 4 000 ₽ top-ups in + different months must still differ.""" + keys = [ + common.dedupe_key(full.account_external_id, event) + for event in full.events + if event.kind is EventKind.deposit + ] + assert len(set(keys)) == len(keys) == 8 + + +# -- 6. concluded vs completed ------------------------------------------------------------ + + +def test_concluded_and_completed_deal_numbers_agree(full: ParsedReport) -> None: + """Asserted through the parser's own warning channel: a divergence is reported, and for a + fully settled period there is none.""" + assert not [warning for warning in full.warnings if "Завершенных" in warning] + parsed = VtbXlsxParser().parse(FULL.read_bytes(), FULL.name) + concluded = {event.trade_no for event in trades(parsed)} + assert concluded == set(_completed_deal_numbers(FULL)) + + +def _completed_deal_numbers(path: Path) -> list[str]: + """«№ сделки» of «Завершенные…» read straight off the sheet, bypassing the parser.""" + workbook = load_workbook(io.BytesIO(path.read_bytes()), read_only=True, data_only=True) + sheet = workbook["brokerage_report"] + sheet.reset_dimensions() + inside = False + numbers: list[str] = [] + for row in sheet.iter_rows(values_only=True): + label = common.normalize_text(row[1] if len(row) > 1 else None) + if label.startswith("завершенные в отчетном периоде сделки с ценными бумагами"): + inside = True + continue + if not inside: + continue + value = row[25] if len(row) > 25 else None # column Z + if value and str(value).strip() and common.normalize_text(value) != "№ сделки": + numbers.append(str(value).strip()) + workbook.close() + return numbers + + +# -- 7. no floats anywhere ---------------------------------------------------------------- + + +def _floats(value: object, path: str = "") -> list[str]: + if isinstance(value, bool) or value is None: + return [] + if isinstance(value, float): + return [path] + if is_dataclass(value) and not isinstance(value, type): + found: list[str] = [] + for field in fields(value): + found += _floats(getattr(value, field.name), f"{path}.{field.name}") + return found + if isinstance(value, dict): + return [p for key, item in value.items() for p in _floats(item, f"{path}[{key}]")] + if isinstance(value, (list, tuple, set)): + return [p for index, item in enumerate(value) for p in _floats(item, f"{path}[{index}]")] + return [] + + +@pytest.mark.parametrize("name", ["full", "august"]) +def test_no_floats_in_output(name: str, request: pytest.FixtureRequest) -> None: + """openpyxl hands back floats for every numeric cell; none of them may survive into the + result, or a kopeck disappears into binary rounding on the way to `NUMERIC(24,10)`.""" + report: ParsedReport = request.getfixturevalue(name) + assert _floats(report, name) == [] + + +# -- 8. totals against the summary block -------------------------------------------------- + + +@pytest.mark.parametrize(("name", "expected"), [("full", "-25975.37"), ("august", "-4055.7")]) +def test_trade_sums_match_the_settlement_saldo( + name: str, expected: str, request: pytest.FixtureRequest +) -> None: + """Σ «Сумма сделки» (column M) of the purchases minus the sales equals «Сальдо расчетов по + сделкам с ценными бумагами» in the summary. + + The commission has to be taken back out of `amount` to make the comparison: VTB's saldo is + the money of the deals themselves, while `event.amount` carries the fee inside it by the + ledger's convention. The two agree exactly once that is done — the fee lives in the same + file as its own «Вознаграждение брокера» line, and it is that line, not the saldo, that + accounts for it. + """ + report: ParsedReport = request.getfixturevalue(name) + gross = Decimal(0) + for event in trades(report): + fee = event.fee or Decimal(0) + gross += event.amount + fee if event.kind is EventKind.buy else event.amount - fee + assert gross == Decimal(expected) + assert gross == Decimal(report.meta["summary"]["сальдо расчетов по сделкам с ценными бумагами"]) + + +def test_position_quantities_match_the_settled_movements(full: ParsedReport) -> None: + """Closing position = what the trades did, since the account opened empty.""" + by_isin: dict[str, Decimal] = {} + for event in trades(full): + assert event.instrument is not None and event.instrument.isin + assert event.quantity is not None + by_isin[event.instrument.isin] = ( + by_isin.get(event.instrument.isin, Decimal(0)) + event.quantity + ) + for position in full.positions_end: + assert position.instrument.isin is not None + assert by_isin[position.instrument.isin] == position.qty + + +# -- parse errors -------------------------------------------------------------------------- + + +def test_unreadable_file_raises_parse_error(parser: VtbXlsxParser) -> None: + from fintracker.sources.reports.base import ParseError + + with pytest.raises(ParseError): + parser.parse(b"PK\x03\x04 definitely not a workbook", "report.xlsx") + + +def test_workbook_without_a_period_raises_parse_error(parser: VtbXlsxParser) -> None: + """A file we recognise but cannot place on a timeline must fail loudly rather than import + as an empty report.""" + from openpyxl import Workbook + + from fintracker.sources.reports.base import ParseError + + workbook = Workbook() + sheet = workbook.worksheets[0] + sheet.title = "brokerage_report" + sheet["D1"] = "Отчет Банка ВТБ (ПАО) без периода" + buffer = io.BytesIO() + workbook.save(buffer) + + with pytest.raises(ParseError): + parser.parse(buffer.getvalue(), "report.xlsx") diff --git a/backend/tests/test_fixtures_anonymized.py b/backend/tests/test_fixtures_anonymized.py new file mode 100644 index 0000000..dbc7998 --- /dev/null +++ b/backend/tests/test_fixtures_anonymized.py @@ -0,0 +1,168 @@ +"""Персданные не должны попадать в git (AGENTS.md). + +Отчёты Сбера и ВТБ содержат ФИО, ИНН, номер соглашения и номер лицевого счёта. В +репозиторий идут только обезличенные копии, и этот тест — то, что делает правило +исполнимым, а не пожеланием: он падает, если в `tests/fixtures/reports/` (вне `raw/`) +встречается что-то персональное. + +Две проверки, и вторая важнее первой: + +1. **По форме.** 12-значный ИНН, 20-значный счёт, ФИО кириллицей — ловятся регулярками и + без доступа к оригиналу, то есть работают и в CI, где `raw/` нет. +2. **По содержанию.** Если `raw/` на месте, из оригиналов извлекаются те же секреты, что + вырезает `anonymize.py`, и каждый проверяется на отсутствие в каждой фикстуре и в её + имени. Это ловит случай, который первая проверка пропустит: фикстуру, собранную руками + мимо скрипта. +""" + +from __future__ import annotations + +import io +import re +import zipfile +from pathlib import Path + +import pytest + +from fintracker.sources.reports.anonymize import ( + FAKE_NAME, + FAKE_PATRONYMIC, + FAKE_SURNAME, + RE_ACCOUNT_20, + RE_CAPS_FIO, + RE_INN, + RE_PLACEHOLDER_NUMBER, + RE_SHORT_NAME, + Secrets, + collect_secrets, +) + +FIXTURES = Path(__file__).resolve().parent / "fixtures" / "reports" +RAW = FIXTURES / "raw" + +ALLOWED_NAMES = { + f"{FAKE_SURNAME} {FAKE_NAME[0]}. {FAKE_PATRONYMIC[0]}.", + f"{FAKE_SURNAME.upper()} {FAKE_NAME.upper()} {FAKE_PATRONYMIC.upper()}", +} + + +def committed_fixtures() -> list[Path]: + """Every fixture that git tracks: everything under reports/ except raw/.""" + if not FIXTURES.exists(): + return [] + return sorted( + p + for p in FIXTURES.rglob("*") + if p.is_file() and RAW not in p.parents and p != RAW and not p.name.startswith(".") + ) + + +def raw_reports() -> list[Path]: + if not RAW.exists(): + return [] + return sorted(p for p in RAW.rglob("*") if p.is_file() and not p.name.startswith(".")) + + +def raw_secrets() -> Secrets: + """The same corpus-wide secret set the script uses — never a per-file one.""" + return collect_secrets((p.read_bytes(), p.name) for p in raw_reports()) + + +def readable_text(path: Path) -> str: + """The visible text of a fixture, whatever its container.""" + data = path.read_bytes() + if path.suffix.lower() in {".xlsx", ".xlsm"}: + archive = zipfile.ZipFile(io.BytesIO(data)) + parts = [ + archive.read(name).decode("utf-8", "replace") + for name in archive.namelist() + if name.startswith("xl/") and name.endswith(".xml") + ] + return "\n".join(parts) + return data.decode("utf-8", "replace") + + +@pytest.mark.parametrize("path", committed_fixtures(), ids=lambda p: str(p.name)) +def test_fixture_has_no_personal_shapes(path: Path) -> None: + text = readable_text(path) + + inns = [v for v in RE_INN.findall(text) if not RE_PLACEHOLDER_NUMBER.match(v)] + assert not inns, f"{path.name}: похоже на ИНН физлица: {inns[:3]}" + + accounts = [v for v in RE_ACCOUNT_20.findall(text) if not RE_PLACEHOLDER_NUMBER.match(v)] + assert not accounts, f"{path.name}: похоже на номер счёта: {accounts[:3]}" + + names = {m.group(0) for m in RE_SHORT_NAME.finditer(text)} - ALLOWED_NAMES + assert not names, f"{path.name}: ФИО с инициалами: {sorted(names)[:3]}" + + caps = {m.group(0) for m in RE_CAPS_FIO.finditer(text)} - ALLOWED_NAMES + assert not caps, f"{path.name}: ФИО заглавными: {sorted(caps)[:3]}" + + +@pytest.mark.skipif(not RAW.exists(), reason="реальных отчётов нет (CI) — проверка по форме") +def test_no_secret_from_raw_leaks_into_fixtures() -> None: + """Ни один секрет, извлечённый из оригиналов, не встречается в коммитимых фикстурах.""" + secrets = set(raw_secrets().originals) + if not secrets: + pytest.skip("в оригиналах не нашлось персданных — нечего проверять") + + leaks: list[str] = [] + for fixture in committed_fixtures(): + text = readable_text(fixture) + for secret in secrets: + if secret in text: + leaks.append(f"{fixture.name}: содержимое содержит {secret!r}") + if secret in fixture.name: + leaks.append(f"{fixture.name}: имя файла содержит {secret!r}") + assert not leaks, "утечка персданных в git:\n" + "\n".join(leaks) + + +@pytest.mark.skipif(not RAW.exists(), reason="реальных отчётов нет") +def test_fixtures_are_reproducible_from_the_script() -> None: + """Фикстуры совпадают с тем, что сейчас выдаёт скрипт: обезличивание воспроизводимо.""" + from fintracker.sources.reports.anonymize import anonymize, anonymize_filename + + secrets = raw_secrets() + mismatched: list[str] = [] + for source in raw_reports(): + clean, _ = anonymize(source.read_bytes(), source.name, secrets) + relative = source.relative_to(RAW) + target = FIXTURES / relative.parent / anonymize_filename(source.name, secrets) + if not target.exists(): + mismatched.append(f"нет фикстуры {target.name} для {relative}") + elif target.read_bytes() != clean: + mismatched.append(f"{target.name} не совпадает с выводом скрипта") + assert not mismatched, ( + "перегенерируйте: uv run python scripts/anonymize_reports.py\n" + "\n".join(mismatched) + ) + + +def test_raw_reports_are_not_tracked_by_git() -> None: + """`raw/` в .gitignore — иначе всё остальное бессмысленно.""" + import subprocess + + root = Path(__file__).resolve().parents[2] + out = subprocess.run( + ["git", "ls-files", "backend/tests/fixtures/reports/raw"], + cwd=root, + capture_output=True, + text=True, + check=False, + ) + tracked = [ + line + for line in out.stdout.splitlines() + if line.strip() and not line.rsplit("/", 1)[-1].startswith(".") + ] + assert not tracked, f"реальные отчёты под контролем версий: {tracked}" + + +RE_ANY_CYRILLIC_FIO = re.compile(r"[А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+(?:ович|евич|овна|евна)\b") + + +@pytest.mark.parametrize("path", committed_fixtures(), ids=lambda p: str(p.name)) +def test_fixture_has_no_full_patronymic(path: Path) -> None: + """«Иванов Пётр Сергеевич» в обычном регистре — отдельный случай, регистр не спасает.""" + found = {m.group(0) for m in RE_ANY_CYRILLIC_FIO.finditer(readable_text(path))} + found -= {f"{FAKE_NAME} {FAKE_PATRONYMIC}", f"{FAKE_SURNAME} {FAKE_PATRONYMIC}"} + assert not found, f"{path.name}: ФИО с отчеством: {sorted(found)[:3]}" diff --git a/backend/uv.lock b/backend/uv.lock index caae33a..eabbcad 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -462,6 +462,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, ] +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + [[package]] name = "fastapi" version = "0.141.1" @@ -488,6 +497,8 @@ dependencies = [ { name = "asyncpg" }, { name = "fastapi" }, { name = "httpx", extra = ["socks"] }, + { name = "lxml" }, + { name = "openpyxl" }, { name = "pwdlib", extra = ["argon2"] }, { name = "pydantic", extra = ["email"] }, { name = "pydantic-settings" }, @@ -518,6 +529,8 @@ requires-dist = [ { name = "asyncpg", specifier = ">=0.30" }, { name = "fastapi", specifier = ">=0.115" }, { name = "httpx", extras = ["socks"], specifier = ">=0.27" }, + { name = "lxml", specifier = ">=5.3" }, + { name = "openpyxl", specifier = ">=3.1" }, { name = "pwdlib", extras = ["argon2"], specifier = ">=0.2" }, { name = "pydantic", extras = ["email"], specifier = ">=2.9" }, { name = "pydantic-settings", specifier = ">=2.5" }, @@ -814,6 +827,116 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/89/db/896be7b15725e3ca63dbe6c8b254ec5ca6bad21b0f918749c70b16c47522/iprotopy-0.3.0-py3-none-any.whl", hash = "sha256:bd7baa564e40ad583e10ab439f6eae631a3fa6f45390d1a1a6f4bce957d0cdd7", size = 17392, upload-time = "2024-10-10T19:42:08.463Z" }, ] +[[package]] +name = "lxml" +version = "6.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/ad/28ecd7cb894d172f3c9c80a075eeeb2017ac62e3632cee05a5f9493547eb/lxml-6.1.3.tar.gz", hash = "sha256:45222d94ddd511536f3b2f7d9deae3b2339b4ce0f075f1ca25703b07cad9dd21", size = 4211198, upload-time = "2026-09-02T14:48:02.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/1f/a180b57d9eeabaab77f9d5aa30356898ea749c4795596a8f66d1eb6bef2e/lxml-6.1.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c0710ac085a157b593c38fbcacd950f15c4afa8e2057527185875ab302752bc", size = 8602094, upload-time = "2026-09-02T14:47:26.054Z" }, + { url = "https://files.pythonhosted.org/packages/a8/25/070c92013a1c029a602b03560d68772313d918268667fa993da7961759c9/lxml-6.1.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:623c8799c17128753c65699f1c3aa32402657393a9ad6db09ed8b98ddf76611d", size = 4638308, upload-time = "2026-09-02T14:47:29.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/1c/722e88883173097a1a375153e3c2447eba3060d0231522cf6596e99f4195/lxml-6.1.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f683dc6300317700025e41d89a43e0276692ded16113a3c43eab704d605c58e5", size = 4939696, upload-time = "2026-09-02T14:47:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/db/36/aa413bc214dc4f785ad2b2ddd8cc99aae7062d49ab155e91e6011af00daf/lxml-6.1.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:379f8a75cf6eb7eef0af074b55f49ab73b868388a98de14646abcdfa4564bb11", size = 5105247, upload-time = "2026-09-02T14:47:36.734Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a0/a1f7f1313795bfec67b77f01ef3b1128d49f2d7f66a8413fa55d47f4e25f/lxml-6.1.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b37772102d44bb6628186accca3a121b1fa3a6b3d97518a8c29a5229ca4c0d0a", size = 5011915, upload-time = "2026-09-02T14:47:39.846Z" }, + { url = "https://files.pythonhosted.org/packages/b9/78/840e7e3f1d0cc7a5cfac5d8505b97e25b6427fd774ac4bae672aaebfb4b5/lxml-6.1.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddcf547bea2aee967d6a77779376a45e77e610e8465147a1f3d7e20d539d6e32", size = 5638175, upload-time = "2026-09-02T14:47:43.644Z" }, + { url = "https://files.pythonhosted.org/packages/0a/20/e022dbc6b4753a9bc9fc5fb28a27163430c1731b9913997f6544c1b2518c/lxml-6.1.3-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:909f4e927bb051f7740d6367285fc60cdcfdaf0258c2dba4ff5ba7eadadc250c", size = 5244675, upload-time = "2026-09-02T14:47:47.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/83/82cde81d2b5eb38d1539fdfdf318abdd014a7e604f4df01c9cd3deb18f2a/lxml-6.1.3-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:a5c18810318303ce9afb3f95e2ddb54834f96fa699a8600433fd5a93dcf44c56", size = 5358205, upload-time = "2026-09-02T14:47:50.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/a1/f3b057371c8cb29f2a9c9c44ea320592446e40b74a4b0af68c3d8e65bc73/lxml-6.1.3-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:3e42265103fb385d8642a78672edf376c6f7e1d3598a7a4f9cb1278f2f6b5f6f", size = 4704495, upload-time = "2026-09-02T14:47:53.251Z" }, + { url = "https://files.pythonhosted.org/packages/1a/a4/230eb28be5d412152ffc3c679b51fe1aeede5a53f3a8eb6e9748f2f4754f/lxml-6.1.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:21402998e4b78e7cce237d2788841aaa21ac9a4d1574d04dc2d12ee41ae807b5", size = 5255117, upload-time = "2026-09-02T14:47:55.963Z" }, + { url = "https://files.pythonhosted.org/packages/a3/18/1969f56763af24ce42ea156007b0b2d73fddea552e283b2010416394f0f4/lxml-6.1.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:38fc4e4e4e084e0bd491949482527d406788045c546d4f8789e93fc527b91385", size = 5054424, upload-time = "2026-09-02T14:47:58.131Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/2a90acc1f6fabaa3a8db9340437822bd8d041b205d626a4b3e8621aaa390/lxml-6.1.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5609efdb0d3c95499c00046bc53648b3482ec2175b5503d6e611b3f0555dc71d", size = 4785572, upload-time = "2026-09-02T14:48:01.029Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1e/b90e845b1dcd0f2f3f26b98283d857f25909223aacd265eee032c34ab8b1/lxml-6.1.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:97ce49699d87ebf8aad631b55d65b33219a4f1bfefbbf5bff19dc9af160aeaf9", size = 5656516, upload-time = "2026-09-02T14:48:03.419Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ab/0a1b802c57f3fba5c4efd77d5c6b78adaa8f7b681f0c90456b140fe8bf6c/lxml-6.1.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:48542c9acba9ff9450bd18d871d2c2c8787fdb283572b623d206f1b927cd7d9e", size = 5245982, upload-time = "2026-09-02T14:48:06.109Z" }, + { url = "https://files.pythonhosted.org/packages/da/ee/2c016fbceb3778137459292538d9dfa7e3ad9070fe409c15254ddd90d2cc/lxml-6.1.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c55e71a9b1db1f107efb60da49c093689b74c5c31a708e5379e2fd9439d4fbb5", size = 5267340, upload-time = "2026-09-02T14:48:08.374Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b1/736d18fd6f0835761923b7bac1f0c27d60c1200384e9093f05d8c5100525/lxml-6.1.3-cp312-cp312-win32.whl", hash = "sha256:b3ff39654f0ce6ebd4db154211136dbe7e8157bcc3bed2344c87f32c7c6ecb6c", size = 3602606, upload-time = "2026-09-02T14:48:10.384Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5b/6ed903e4e6278a020c8a6f0dbbe78030d041840a6b4a64ea441a1e414077/lxml-6.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:3e9a00d1c2c30936f7add097c41afc5da6556c580909104aafd382cac92a855c", size = 4005999, upload-time = "2026-09-02T14:48:12.51Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1b/7bcebb7b6332cb3ae85e9c13b139adb6f23f75c71d84041c56a5005d9a29/lxml-6.1.3-cp312-cp312-win_arm64.whl", hash = "sha256:1aeca87830c4fe649dcf93fe2b059525b71c72587f21be4ae4af7103082a79fa", size = 3666631, upload-time = "2026-09-02T14:48:14.567Z" }, + { url = "https://files.pythonhosted.org/packages/52/05/3ef45db776baea068044c799bbba68f3ca00a440c0e930a17c572f3d9639/lxml-6.1.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3a48093cdb058a93af842ede9703520e810b05dcd0fc6d7190a06376c3bfb6bd", size = 8590357, upload-time = "2026-09-02T14:48:17.413Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a5/eee2fc77eee5ea68e4a4334b1def1781a3beaeefd3d98e81b4a38dc447b7/lxml-6.1.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:887c021d9a977cff89cb273047c1352997b772a8908a25c21836861f69b92be1", size = 4632616, upload-time = "2026-09-02T14:48:20.745Z" }, + { url = "https://files.pythonhosted.org/packages/35/42/df27b56848acd29d8a720acc28977911aab36f2a09df4208d5502e887415/lxml-6.1.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:611a51e61c92f62345a50b0035df6fc0d678f9299f33728826d831598862f59d", size = 4936186, upload-time = "2026-09-02T14:48:22.94Z" }, + { url = "https://files.pythonhosted.org/packages/ab/8d/8a7b91df0b54d09d25f5f44885d6b3e0a6d6643a8c070191580318d20c42/lxml-6.1.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b477912f42c5c33405a10c759d22f80cf5af043ae02d95b9d8e5e5bc555739ed", size = 5093324, upload-time = "2026-09-02T14:48:25.132Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/8f340ddcd43790332fb0de8a26628d571a492da3300cd191821698407c96/lxml-6.1.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cffe18571ccc51d742cd08cbb3f8b756de9311d18c7ea98f5d92f37b8fb60c2", size = 4998850, upload-time = "2026-09-02T14:48:27.394Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c1/9c5bb572f1f09ec9e4322bd4a4e9f4ad48347fc56ef94cf4df58a5279dc8/lxml-6.1.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75cc6569e86be5785b6188ef1642670c6adbc984e81ec35e224842ecd9eefcc8", size = 5626813, upload-time = "2026-09-02T14:48:29.61Z" }, + { url = "https://files.pythonhosted.org/packages/ac/7d/8bf1fd8bae8247743968bb76d027a1ac5bd2c4b44495fba6a71b30d10706/lxml-6.1.3-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d85dfab42dd672f87a7f76e9de7172962aee69fa12044f0d6e1a23cbd53fb80e", size = 5232385, upload-time = "2026-09-02T14:48:31.969Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2e/6cef69ed81cb7df0d03b0dd09d08e6e2cf5061a743ff6f42f0b741548e9b/lxml-6.1.3-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:42632b4024ab24a6b488f559ac851312509888b6b80ae2aa11cf29a646a0d245", size = 5347088, upload-time = "2026-09-02T14:48:34.13Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e1/8e5fd8ddc8c7d685badb0f2db149e3c9da84eefc2827c01c658df2c4e3cb/lxml-6.1.3-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:febd35ef45f603c2d74b74655efdbf45e14f55fc0aef4ac82b663ca829b283e0", size = 4707227, upload-time = "2026-09-02T14:48:36.62Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7e/00041382a11be40a88bf405ebff11c8efabd3de79f2691e1638b1c47a8a0/lxml-6.1.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a43b3bdf11e477dc7770609d3477316f974354dfc8425d596f64f471cc8daf6e", size = 5240208, upload-time = "2026-09-02T14:48:38.893Z" }, + { url = "https://files.pythonhosted.org/packages/fd/fe/316538b5cff0936fa63d45d421c655730fcbb5a28dcac728c175083002bc/lxml-6.1.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d582042c69857c364e8153de6e18e0da9b7b515a6a8113caf69a6ec8e0520f2", size = 5050271, upload-time = "2026-09-02T14:48:41.213Z" }, + { url = "https://files.pythonhosted.org/packages/c9/91/455bcccb3ac725373007344d351151810cd19762d1673b64b811f4359a42/lxml-6.1.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8e49a646acfab83c68974f4aa1d0a2acca9e88d7d627ae0fc13201b14b76d310", size = 4780433, upload-time = "2026-09-02T14:48:43.779Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f6/580440e2f52cf00bba5c5e1080bfa88cdfcde73be71a11d95170ddbb663f/lxml-6.1.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0dee106e9aa97fb00541b1ed7827070564d0549c3d3fba8920e6b20fd980f748", size = 5645928, upload-time = "2026-09-02T14:48:46.187Z" }, + { url = "https://files.pythonhosted.org/packages/f6/dc/d123c1f244306543d545f62443f794959e4f1ea709fe100f8740d514e74a/lxml-6.1.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dd5e90f34cffcfed97f36cf066325773d2b6021c60c29942e53a18b028501b1d", size = 5231184, upload-time = "2026-09-02T14:48:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/c3/3c/fe55b2bd5c6113c906511cd88f6a470195c5fbff1124f19970ab706c3477/lxml-6.1.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d9b3e7d71bf6acff341233417abbdface29c647e3113892d9aaedc02eb4aa2bc", size = 5255814, upload-time = "2026-09-02T14:48:50.948Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a7/485df55acf55dc35e4ca89d2f48f03889e5a3241826b18b85102b32ce9d8/lxml-6.1.3-cp313-cp313-win32.whl", hash = "sha256:160fcf381f76c3aeac28a756bec44f48942a8f7245a87aa28e3a523b4d90cd87", size = 3602214, upload-time = "2026-09-02T14:48:53.236Z" }, + { url = "https://files.pythonhosted.org/packages/c0/28/e46a7702bd95e9043291f7c3539b6184cba66f96cea9936f20939b284eeb/lxml-6.1.3-cp313-cp313-win_amd64.whl", hash = "sha256:e477aca0bc0d19f3b4ae9e4f2a1cfd687c31bf772d78734910658186b40b2477", size = 4004091, upload-time = "2026-09-02T14:48:55.699Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1d/154c78e20479a43916e63f19cb720d83f44f024b03228be44c92d9a97b24/lxml-6.1.3-cp313-cp313-win_arm64.whl", hash = "sha256:b1cc980905221a5d8b3c476330730b3adb40ff80add71ffbdb6215ba055656f1", size = 3665468, upload-time = "2026-09-02T14:48:57.703Z" }, + { url = "https://files.pythonhosted.org/packages/0c/15/fc75a70b0af6021d0ea16811f1fc71cc42cd06ce90fe10f007a69b2eed84/lxml-6.1.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2bec13085dc8ef48a3fe62f7dfcacfeda2c785cdf19cc8eeda2bb9ed081da165", size = 8609725, upload-time = "2026-09-02T14:49:00.156Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/398fcf9018f881ec9aeaafae1ddd6586dfb13314a35d35e899de373dcae0/lxml-6.1.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4f4db7c7e954d289d71878938348b3d91b904a3e8210a11939359fb758a58e7d", size = 4639629, upload-time = "2026-09-02T14:49:02.81Z" }, + { url = "https://files.pythonhosted.org/packages/a7/2d/49b6a6ad7ce8f64b07b9fe852ff0c6d3fcbb26db61bee4f63d4120180a1c/lxml-6.1.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2cae5d5c90a62d9139c512a0cb1aad1d182b022b5740daea2617eb5bf7fc658e", size = 4965074, upload-time = "2026-09-02T14:49:05.133Z" }, + { url = "https://files.pythonhosted.org/packages/66/bc/6230cf80e4331c33383b0b6b73dc31a393dd76edd4cb73d761de5123034d/lxml-6.1.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c6c0c13128a32eb04a51357e56a094e13aa8e6d3d1884de2e9ae923f6915e1a8", size = 5099355, upload-time = "2026-09-02T14:49:07.343Z" }, + { url = "https://files.pythonhosted.org/packages/ac/cf/d1143d9b7717e07a82f158a1fc9ce6e581fdad1226734950af869e3ffde4/lxml-6.1.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2221e88679d1351e9a40aaee54bc65679b9795bbd0160bc3d5e36b163344eb75", size = 5036795, upload-time = "2026-09-02T14:49:09.65Z" }, + { url = "https://files.pythonhosted.org/packages/31/6f/194bb00ffb89712c30f5a7e1b8e685590e140fad6c8261fec172c09a3dc0/lxml-6.1.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cfb398886a7eb4c719161c3efcff2a1248febc53a4d8e5072d2d8a87fed84ac9", size = 5658740, upload-time = "2026-09-02T14:49:11.9Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/27e3cee3dcdb3b7bc09727b642bdbfcd098490ea77df04611db9060d7722/lxml-6.1.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7eb78ba28b187e1e9203a55c60fcf70df2d22cb205fe6d51b9383d6097419f0", size = 5245991, upload-time = "2026-09-02T14:49:14.154Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e9/8312560579fc980bbd2233a8a673cc46f7d613d3633f2bf08a21e8f4ad13/lxml-6.1.3-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:ea6b1e9105b4b24a34c722432d9fb578f9ed83af21fa1abda639011e0f22bbb6", size = 5354136, upload-time = "2026-09-02T14:49:16.459Z" }, + { url = "https://files.pythonhosted.org/packages/74/d8/eda60f4f73a9c780b5d6e1175484f66e6c81a2c93346e2906a1fec9c7a02/lxml-6.1.3-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:e8b17e23df3e827a69d25af70990ca2420e92668aaffaeeb3cd2351d7916a023", size = 4704379, upload-time = "2026-09-02T14:49:19.032Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c8/c9cc60057be78ac34bd2b842e45e6e88edbfe5e532e82c3b82381b7aab49/lxml-6.1.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b7c37339d7e75cab9a123a04248e243cefefb302ad6db566ea0c77cbcde421e", size = 5258676, upload-time = "2026-09-02T14:49:21.306Z" }, + { url = "https://files.pythonhosted.org/packages/41/7b/66894008fee8d1785b8db129747ae963fd427b68f456918df7f2f24a8b98/lxml-6.1.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:83e3a51e7933db700a0da0db31849db3a24022d9970da9bb73001e1d0326fd92", size = 5090069, upload-time = "2026-09-02T14:49:23.562Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/c1b60404859f4c3cd1f41f29c65a24e25cea78fde822d9574a21f66810be/lxml-6.1.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9bde9ae026a55b9a192078dfa6e27dd0ca4a050171ab6272e92f97b757dfdf48", size = 4741958, upload-time = "2026-09-02T14:49:26.037Z" }, + { url = "https://files.pythonhosted.org/packages/23/b8/6285f0cf546f14da2554cabdeaf7c2c2ff3190c74807f0de2e8810a786f9/lxml-6.1.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1a635e837b50a1819bebfedaac5916498ea024120969da8790500148fb0a894d", size = 5683245, upload-time = "2026-09-02T14:49:28.438Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f6/2168cab44336dcb15fed0f0b78577225b83297cdf0dee349c95420c3dcb0/lxml-6.1.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d0c5c362bc94f1929dc7e96e715bbe7bd17037f802e6d8f0d1545df9133c0559", size = 5246087, upload-time = "2026-09-02T14:49:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/f5/89/32f5de69a0a31f30e6164981851f87b37ecb2c4ee838e504b88d49d4818e/lxml-6.1.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c59e4265608da6a041f54646ecc0c9ecdbb19aaf14c4c684bb6c2114998cc415", size = 5269352, upload-time = "2026-09-02T14:49:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a1/741d952ed3a7ef7a50055c6415aec3f067015e97f72f4389ce77b09657ba/lxml-6.1.3-cp314-cp314-win32.whl", hash = "sha256:2e62c569ec7531b679b184cbfe335c501c1d13c4b363560013019962eb630e6d", size = 3662783, upload-time = "2026-09-02T14:50:23.751Z" }, + { url = "https://files.pythonhosted.org/packages/0f/bc/5811cc73cac05e324e05ba9b0924e1a163a317a167ede8a9c748b11db30a/lxml-6.1.3-cp314-cp314-win_amd64.whl", hash = "sha256:66299564c046bc7e0cc5de5106601eae907e9fa5904cd68a323380a8502f7861", size = 4073951, upload-time = "2026-09-02T14:50:26.348Z" }, + { url = "https://files.pythonhosted.org/packages/92/18/3768c8b01ac3a9bed1914715e6011711b00e2a11628ffa6f7fa37f8e0269/lxml-6.1.3-cp314-cp314-win_arm64.whl", hash = "sha256:ebd054ad1737a68fb7c5c073d405cef2b88bb824e294de3b4a4e995b47f0e376", size = 3749279, upload-time = "2026-09-02T14:50:28.749Z" }, + { url = "https://files.pythonhosted.org/packages/72/38/84684784738d9451db2b330de2483f496690c3a5c642071df24135739b37/lxml-6.1.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5a143e6207579de8baeded4eaac9134413200359f1969d636f0bfb98ee8c3c8f", size = 8860296, upload-time = "2026-09-02T14:49:36.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/b7/fc4c50bb1b38e864010ea396046cabe85129bf9e65b11edcfbc37d356241/lxml-6.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a1cec0f99b9b914d39176347a93b7610dc09324491aee1cbc57cd291a41a1d55", size = 4755190, upload-time = "2026-09-02T14:49:39.872Z" }, + { url = "https://files.pythonhosted.org/packages/94/e2/ee9aa6ed2b666b2db1f6f7fd48964ff9da39ebe827ef5eac0ab881f639d9/lxml-6.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f6b9d2aad499c769ee8287609ab0e6de99d8bcea99c6e6c2e64945259fd52fb2", size = 4979517, upload-time = "2026-09-02T14:49:42.153Z" }, + { url = "https://files.pythonhosted.org/packages/29/e3/e7763d1661b283ddd4fa36f91b9a497db6b8d2aff55028b16c7f642e0755/lxml-6.1.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a23fefdb345b2d4d0ff2860571b5ff9a89a28b6a120f720e8fb0324d346626", size = 5115270, upload-time = "2026-09-02T14:49:44.493Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cd/22205d5b4d177e3f4156f780412426ee7c7f8107809f119f0dcc40fa51e3/lxml-6.1.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:545ccc14fb05485f48b4439ec35beb16d5b5280eb6c81c658bd4707a2a119414", size = 5032449, upload-time = "2026-09-02T14:49:46.841Z" }, + { url = "https://files.pythonhosted.org/packages/da/43/06a4626c3bb79ef8c501b674afab8100d64e798665bb2a97d1c960636a49/lxml-6.1.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:93476b6514b373fc6ca67d26c442784f7807c86f00635bfe79f935c3eab2af17", size = 5603325, upload-time = "2026-09-02T14:49:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/733682a0c2de9f5779ba207bbb3f3f6be8c6bda863fc01739b186b38783a/lxml-6.1.3-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8db38ff3fb7aee7d6a82ae4da2eef1178656fe1216841fbd24870062a9d60473", size = 5229023, upload-time = "2026-09-02T14:49:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8a/e69cdaca3fd33a647942925664f01b20908d41a6968c182305be9c38fb11/lxml-6.1.3-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:25f4118c438f96bb466e83108506d03d5c31b1bd2387e83e5b070bda6ded9c37", size = 5317811, upload-time = "2026-09-02T14:49:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b2/0c397588174403c2ab68fc464abf97e03e7324f9c6cb6a99023104707195/lxml-6.1.3-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:1beb0f9909b26cee938df9ba56b15252a84429b1fc30ce6fca161390b9789a70", size = 4646516, upload-time = "2026-09-02T14:49:57.761Z" }, + { url = "https://files.pythonhosted.org/packages/56/7e/cfea25afafbe49db8b225764f7f74bb37c2a7f5e717d917d3d4a5e098ed4/lxml-6.1.3-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3a27ac6c780c8b8a1cd231b58407634cafc1c4cc28cd6c7141362df0f36351e7", size = 5240626, upload-time = "2026-09-02T14:50:00.279Z" }, + { url = "https://files.pythonhosted.org/packages/a1/75/7a587771bb52ebb0e2c57b6dbe9fd96a70fbb54d72ddd97d54c5f8ec18d5/lxml-6.1.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a1932d7ce78a561367512c594fe66eac2b2ec9b9264cfd9b5f950622f4a116e2", size = 5086619, upload-time = "2026-09-02T14:50:03.245Z" }, + { url = "https://files.pythonhosted.org/packages/1e/01/94c0ebe6d831861542d251e038052e52bf6d33f1d18f1cfffdc82851065a/lxml-6.1.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:7d0f5976aa2701996f759b30172925829867547bb073af0ae67d1307a0f0262c", size = 4758828, upload-time = "2026-09-02T14:50:05.873Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/938d67bd0e5b1fdfa52be28aefdffbad57e1f6b8e921c2aab88542c75f40/lxml-6.1.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e7ce578aa8a80910a72a8ca0bbea3baae10100827249001999726a788456d8", size = 5627083, upload-time = "2026-09-02T14:50:08.555Z" }, + { url = "https://files.pythonhosted.org/packages/d8/65/4e51522f6c214650db0abb7b16ccd11b1238b8a05a8d59aa4ebed59c9f67/lxml-6.1.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d97c5227621af74b111882a290b10f371780a38eef9d9e730408fba2259b52fb", size = 5235170, upload-time = "2026-09-02T14:50:11.255Z" }, + { url = "https://files.pythonhosted.org/packages/92/c2/e73d19365665f6b16ef84df21199befc3b06e4c539046ad2d9595f6fb9ea/lxml-6.1.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:da707f14ea3c35ee463d50acd596d6488e4b2b4ae7cf77a5bf93f55c023d63e8", size = 5252273, upload-time = "2026-09-02T14:50:13.782Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/7f386c84c9fe2854e1ca6e231c285e1c8f392971ac353c6865e6ec49faff/lxml-6.1.3-cp314-cp314t-win32.whl", hash = "sha256:9efe56a68179f3adc4de41861c9358931db03837c48dd5e1c78077b84dd07f3a", size = 3902712, upload-time = "2026-09-02T14:50:16.171Z" }, + { url = "https://files.pythonhosted.org/packages/82/a6/8a3eb793f7900ef01c7f99e6f5fcbcfbdff35251cfaef66b32a4c16352d6/lxml-6.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c9389b3784b56c58d933b5e0aecdf28f901b073ff385358d8a7d40907f6e14b2", size = 4400979, upload-time = "2026-09-02T14:50:18.621Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c4/3807bea283b4fe9e9d9f5dde46a73df91178472b335d2778e10b2a37aa22/lxml-6.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32a409be3190b088f960ac92bfedfbef2f86c49ff940765e1548177592d20026", size = 3823401, upload-time = "2026-09-02T14:50:21.119Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8e/4614fcd65496054cfb7172662f3576a59200278739506433b8c241ea422a/lxml-6.1.3-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:6ea2f13dce778ca072ccee598bca46a092ce192e8fd907b6c1f0e52c800529a0", size = 8609378, upload-time = "2026-09-02T14:50:31.772Z" }, + { url = "https://files.pythonhosted.org/packages/f2/51/2cdce3c65fa99a6195dd8fbd512d33407c1000ad99f63e0a285b63d7a8eb/lxml-6.1.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:c581b1d68b3845fb86c6b2983e755b29bf001461c59fa411d2c26a911b6559a9", size = 4640022, upload-time = "2026-09-02T14:50:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/52/09/0b30084e9eb1c546a4be3d9c56df70058d116b1a320400a59b0f7da87bf0/lxml-6.1.3-cp315-cp315-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e01125896585139453cab8cb235893644d8815d7509520da95ae3ee8d1c1f79", size = 5037928, upload-time = "2026-09-02T14:50:37.007Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0e/5c37275a3e361f6138dc06db748ea565c1fe8a5f4ee5e2ddd80047c81a89/lxml-6.1.3-cp315-cp315-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:290f66b97ede0e552e1cb44a0fd8a74f9753ee635b50830a0b122fb72788d015", size = 5661932, upload-time = "2026-09-02T14:50:39.777Z" }, + { url = "https://files.pythonhosted.org/packages/70/c5/b71ffb289b15e2642e2a3cf6d468c44da39ea119061a99e5b05e3d10f217/lxml-6.1.3-cp315-cp315-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73fc05988ed20809450474ba760a87c8ad4e455fc09783c02195e56ec634b41a", size = 5249209, upload-time = "2026-09-02T14:50:42.141Z" }, + { url = "https://files.pythonhosted.org/packages/81/ea/9910da149a23932f9301652e57661cd9e42b0df18f12be21159b7255f92b/lxml-6.1.3-cp315-cp315-manylinux_2_31_armv7l.whl", hash = "sha256:dc3a44689eea43eab836e5c98a8ab015dc2419987d1ea6eafc7c590cdff86bed", size = 4704543, upload-time = "2026-09-02T14:50:44.634Z" }, + { url = "https://files.pythonhosted.org/packages/76/07/9290329cd188c62e22021f79df04ee0cc33d9a93b0d38bd65ccd452ad9d0/lxml-6.1.3-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:209c3ccbfe35a04ac6d24f0611f9d1cbf8025d49991b14acd935236234d6c156", size = 5261298, upload-time = "2026-09-02T14:50:47.301Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0c/aba78bd3401cd99b73a0aed8e2b9b43e14be94fab3603d4bbc8a62365f2a/lxml-6.1.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2f5b2a2b9811b853b39bfa41367c6d78747b8e3e80e07fc5a24aae295c1a4d7d", size = 5090453, upload-time = "2026-09-02T14:50:49.952Z" }, + { url = "https://files.pythonhosted.org/packages/8d/dc/fa4426c3355aa0216cbeb3911495b5f65a26e0df85859a89928fe28f0396/lxml-6.1.3-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6a406d0b3cb207b0fa460ed4dc93e866f44f105da0169361cb18ff998a44c7f0", size = 4744709, upload-time = "2026-09-02T14:50:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/be/2b/224fe7918658ab7c532ac2412f3c1eb28f71e6364fb07566262d0cc6a7b6/lxml-6.1.3-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:53258656846f5c48996b882fb4b135885e088a3ad3d96b4bc0530f95124d1f69", size = 5685802, upload-time = "2026-09-02T14:50:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/21/44/7d480819b9adcae5f84dd8ac529132c6b7a578544398225cd20321adcd91/lxml-6.1.3-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:aa633613ff907ea91b9b0489a1f0da1b8725d8c6ccec6b77e8a1c9c235044bb0", size = 5249019, upload-time = "2026-09-02T14:50:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/72/83/385a267ea1b6b283f2249dd827ef360a295e9db14e13ef4665a120c60d64/lxml-6.1.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:90f709b9accab6b2e4d14f5c8718203877a0486bcb3afd74d8b539ecd1e961d4", size = 5271886, upload-time = "2026-09-02T14:51:01.667Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0d/f967b0eb172ae876855a402d6d9b11fa86e3e0c89ca9bbfeadf7ffbfa719/lxml-6.1.3-cp315-cp315-win32.whl", hash = "sha256:b4fc6b03b9d9d90557274f571ab30e7fbbfc527955536935d96f98b6817a86e4", size = 3662894, upload-time = "2026-09-02T14:51:45.173Z" }, + { url = "https://files.pythonhosted.org/packages/f4/48/d8a8c4160a29e663109ad520bac2deb37fcd014756d024561e8bc3e611ec/lxml-6.1.3-cp315-cp315-win_amd64.whl", hash = "sha256:33cadd956b667997e4de1635fce9541f2e8ede2038fcde8cf55aa14d571d1bad", size = 4074626, upload-time = "2026-09-02T14:51:47.77Z" }, + { url = "https://files.pythonhosted.org/packages/25/20/3e1395d34d19f9254625d0b567b81cf70d37d3417be074f4d63b94a2be3c/lxml-6.1.3-cp315-cp315-win_arm64.whl", hash = "sha256:8a330c0ee5fa318c7b5cbbaad882baeca3f570357e7eb25ab34bf31008150758", size = 3749495, upload-time = "2026-09-02T14:51:50.663Z" }, + { url = "https://files.pythonhosted.org/packages/8f/c6/7465ffd9c43883526a382df6fa4846c9d8d419214f7effbf65270e795471/lxml-6.1.3-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:0bf5a3e397df2ec4258eb5eea4c1ac6cf013ca1abd04a176903bff20a70021fe", size = 8857677, upload-time = "2026-09-02T14:51:05.109Z" }, + { url = "https://files.pythonhosted.org/packages/ed/eb/1f3a917e299df43c8162c3e6f64fc2cea3bcf277910f35bff5b8e5d39901/lxml-6.1.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:13d22c0d57355366b393936acf6b98a5e0edeadddd3fccbc6a846c50a76b8741", size = 4754522, upload-time = "2026-09-02T14:51:08.137Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f9/f81b4bdb6efb7a596be29603d8758154d00a5f545db9f3cef9d9041c8f64/lxml-6.1.3-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad7617727a96d189bd6f979d0fadf765198c7934e85f4edaba9bf3ad919a300", size = 5033744, upload-time = "2026-09-02T14:51:10.633Z" }, + { url = "https://files.pythonhosted.org/packages/c8/0f/26d9bfaacb319c86e0eca8a1a0bf1130d36a7afbd318883e23caea63763d/lxml-6.1.3-cp315-cp315t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cae82b5ca24b0c2beedb269f6e2a96f466acd926879ab00ae19f1a65cbf9ffb0", size = 5615269, upload-time = "2026-09-02T14:51:13.357Z" }, + { url = "https://files.pythonhosted.org/packages/5d/90/73675f3f4141350ed65d6fec533b107d4e802c5caa340cf111771edd86e0/lxml-6.1.3-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:69cafd61aea04ebb3502c93c2aaa568b12931ca0802231e0b5de76bf8b6e74bd", size = 5236280, upload-time = "2026-09-02T14:51:16.051Z" }, + { url = "https://files.pythonhosted.org/packages/fd/be/ed260767e7977de463a0f91f3f4fffcab85c0a2a024a21ffe1fa442c2c79/lxml-6.1.3-cp315-cp315t-manylinux_2_31_armv7l.whl", hash = "sha256:dc205732d593118cf701d986f40e9de7801bb2e371cb189ddbda9b7348f4d97e", size = 4650718, upload-time = "2026-09-02T14:51:19.102Z" }, + { url = "https://files.pythonhosted.org/packages/d0/fd/e9839d03b1e767f2725cf7d7d81b80d5f3f9fdc10ad8827e2479311b046e/lxml-6.1.3-cp315-cp315t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88e719b9437f148f7e1465df845c758dd1598618cbea3a2fd1e61a715542f2b2", size = 5243376, upload-time = "2026-09-02T14:51:21.606Z" }, + { url = "https://files.pythonhosted.org/packages/34/a5/4606e347e2788c301f677004aa83e28d24da9fe663a24380122af57be6fc/lxml-6.1.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:40983eabefd13da003e68170928c7acc011f0d095eefce5871a3c71c9385fb9a", size = 5092340, upload-time = "2026-09-02T14:51:24.21Z" }, + { url = "https://files.pythonhosted.org/packages/ea/99/3314a8661cdf30f493c55a87db283961dfaae08451976a2ca418958e1804/lxml-6.1.3-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:fad67b12ffe0f71e02b4932b04883cbc76a9072bbd30731409d3523cf058b011", size = 4758768, upload-time = "2026-09-02T14:51:26.813Z" }, + { url = "https://files.pythonhosted.org/packages/30/58/3bdc577f78ea8b7d72d39a84506f7001d5b28728f43e5b84891e3b7d9a4a/lxml-6.1.3-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6cd11e7550d89e551a87dcec30f04b1fca32e86b68708aa01a4daa455d8605e5", size = 5649546, upload-time = "2026-09-02T14:51:29.453Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e4/652633de1a2395949ebb7a8fc7d089aba12a2b45f0fefbc9d29e3e3ab3cf/lxml-6.1.3-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ca0ec532ad2f5ba1e5ec120ac157769c57f01855b3d8bf37213f5d88abd9ba0a", size = 5234874, upload-time = "2026-09-02T14:51:32.262Z" }, + { url = "https://files.pythonhosted.org/packages/65/a6/c4581d171de30449304b4859bbd3607e9b40da13c0f88b68e6097c8d785e/lxml-6.1.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e99e09ab7741f1281e2677f4c0058c7f5267d182530b09c87e4f6aa26adf3887", size = 5260043, upload-time = "2026-09-02T14:51:34.841Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/ed6ee6186a89e69ca4ea9658b2a278f46a5efe8b5d4db56c7197f18653fe/lxml-6.1.3-cp315-cp315t-win32.whl", hash = "sha256:ace1d2c83b2bd24db5940600541140e87a325e119cb32d5fa9ad720d7e76648e", size = 3901093, upload-time = "2026-09-02T14:51:37.234Z" }, + { url = "https://files.pythonhosted.org/packages/67/9d/11d10257a4a048d04195d638bb61f0246ce2448eb05f682bcbab25a257a8/lxml-6.1.3-cp315-cp315t-win_amd64.whl", hash = "sha256:b49638355ea3bebba70da783ccbc630fd72afa16bc46c54474bfa1f9a915bbc6", size = 4395446, upload-time = "2026-09-02T14:51:39.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b7/44edd7de434181c582892e68d1ffe6775ca403ce14aea07cb5a218a936cf/lxml-6.1.3-cp315-cp315t-win_arm64.whl", hash = "sha256:5a721a98c649855963811b59b55755b30566e7f7fc40bdc9803d66dee9f811cf", size = 3822836, upload-time = "2026-09-02T14:51:42.471Z" }, +] + [[package]] name = "mako" version = "1.4.1" @@ -944,6 +1067,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + [[package]] name = "packaging" version = "26.3" diff --git a/docs/ai/csv-import.md b/docs/ai/csv-import.md new file mode 100644 index 0000000..ae535fc --- /dev/null +++ b/docs/ai/csv-import.md @@ -0,0 +1,200 @@ +# Универсальный CSV импорта событий + +Фаза 3. Формат — экспорт Snowball Income, но парсер +(`sources/reports/csv_universal/parser.py`, `broker = "csv"`, `name = "csv"`) обращается с +ним как с **универсальным CSV событий**: файл узнаётся по набору колонок заголовка, а не +по имени. Любая будущая выгрузка с тем же заголовком читается тем же кодом. + +Общие правила проекта действуют: деньги и количества — `Decimal` (ни одного float ни в +событиях, ни в `meta`), даты — ISO, знаки — как в `models/ledger.Event` (`quantity`: + в +позицию, − из позиции; `amount`: + получено, − уплачено; `fee`/`tax` положительные и уже +внутри `amount`). + +## Файл + +- UTF-8, допускается BOM; запасная кодировка — cp1251. +- Разделитель — запятая, все значения в кавычках (заголовок — без). +- **Десятичный разделитель — запятая** (`4,48`, `11353,38392`), но целые пишутся без неё + (`219`). Пробелы (обычный, NBSP, узкий NBSP) внутри числа вырезаются. +- Пустая ячейка — это `None`, а **не** ноль. Пустой `NKD` значит «это не облигация», + `NKD = 0` — «облигация, но НКД нулевой»; схлопывать их нельзя. +- Дата: `2025-02-26 09:22:54` (принимаются также `2025-02-26T…` и `2025-02-26`). + В леджер идёт только дата; время используется для `seq` (см. «Дедупликация»). + +### Колонки + +Обязательные (без них `sniff` → `False`, `parse` → `ParseError`): +`Event`, `Date`, `Symbol`, `Price`, `Quantity`, `Currency`. + +Необязательные: `FeeTax`, `Exchange`, `NKD`, `FeeCurrency`, `DoNotAdjustCash`, `Note`. +Выгрузка без них читается; `DoNotAdjustCash` парсер игнорирует — это внутренний флаг +Snowball о том, правил ли он остаток, а не факт о деньгах. + +## Смысл колонок зависит от типа события + +Это главная особенность формата: `Quantity` — это штуки на `BUY` и **рубли** на +`DIVIDEND`, `Price` — цена на сделке и **коэффициент** на `SPLIT`. + +| `Event` | `Symbol` | `Price` | `Quantity` | `FeeTax` | Остальное | +|---|---|---|---|---|---| +| `BUY` / `SELL` | тикер или ISIN | цена за штуку (для облигаций — рубли за бумагу, не % номинала) | штук | комиссия сделки | `NKD` — весь НКД сделки, `Exchange` = `MCX` или `CUSTOM_HOLDING`, `FeeCurrency` — валюта комиссии | +| `DIVIDEND` | тикер/ISIN | `0` | **сумма денег** | `0` | на облигации это купон, см. «Контракт с ingest» | +| `AMORTISATION` | ISIN облигации | `0` | **сумма денег** | `0` | | +| `CASH_IN` / `CASH_OUT` | код валюты (`RUB`) | `1` | сумма | `0` | `Note` отличает реальный поток от правки остатка | +| `FEE` / `TAX` / `TAX_RETURN` | пусто | `0` | `0` | **сумма** | инструмента нет даже если сбор относится к бумаге — формат не говорит, к какой | +| `SPLIT` | тикер | **коэффициент** (`10`) | `0` | `0` | деньги и позиция не двигаются | +| `CUSTOM_HOLDING_PRICE` | тикер | **ручная цена** | `0` | `0` | `Exchange` = `CUSTOM_HOLDING` | +| `CUSTOM_HOLDING_SETTINGS` | тикер | `0` | `0` | `0` | `Note` — JSON, где `"` заменены на `@*@` | + +Деньги считаются так: + +- `BUY`: `amount = −(Quantity·Price + NKD + FeeTax)`, `quantity = +Quantity`; +- `SELL`: `amount = +(Quantity·Price + NKD − FeeTax)`, `quantity = −Quantity`; +- `DIVIDEND` / `AMORTISATION`: `amount = +Quantity`, `quantity = None`; +- `CASH_IN` / `CASH_OUT`: `amount = ±Quantity`, валюта — из `Symbol`, инструмента нет; +- `FEE` / `TAX`: `amount = −FeeTax`; `TAX_RETURN`: `amount = +FeeTax`; +- `SPLIT`: `amount = 0`, `quantity = None`, коэффициент — в `meta`. + +## Маппинг в `EventKind` + +| `Event` | `EventKind` | +|---|---| +| `BUY` | `buy` | +| `SELL` | `sell` | +| `CASH_IN` | `deposit` | +| `CASH_OUT` | `withdrawal` | +| `DIVIDEND` | `dividend` (+ `meta["payout_hint"] = "coupon"` для облигаций) | +| `AMORTISATION` | `amortization` | +| `FEE` | `commission` | +| `TAX` | `tax` | +| `TAX_RETURN` | `tax_refund` | +| `SPLIT` | `split` (`EventKind.stock_split`) | + +Событиями леджера **не становятся** `CUSTOM_HOLDING_PRICE` и `CUSTOM_HOLDING_SETTINGS` +(см. ниже) и строки-правки остатка. + +### Неизвестный тип события + +Строка **пропускается**, в `warnings` добавляется `неизвестный тип события 'X': пропущено +строк — N`, счётчик кладётся в `meta["unknown_events"]`. В `EventKind.other` такие строки +не маппятся сознательно: `other` — молчаливая корзина, а нам нужен сигнал в data quality о +том, что формат отрастил новый тип строки. + +## Инструменты + +`Symbol` — либо ISIN, либо тикер. ISIN распознаётся по форме (12 символов, две буквы, +контрольная цифра) **с проверкой контрольной суммы** и с явным исключением секьюрити-id +ОФЗ вида `SU26212RMFS9`: он проходит ту же контрольную сумму, но ISIN-ом не является, и +резолвер искал бы по нему несуществующую бумагу. ОФЗ уходят тикером. + +`asset_class_hint = "bond"` ставится, когда символ — валидный ISIN или секьюрити-id ОФЗ. +Кастомный холдинг вроде `SIBN6P4` облигацией по символу не выглядит и подсказки не +получает — класс определится при резолве инструмента. + +`Exchange` (`MCX` / `CUSTOM_HOLDING`) кладётся в `InstrumentRef.meta["exchange"]`; доской +(`board`) он не является, поэтому поле `board` остаётся пустым. + +### `CUSTOM_HOLDING_SETTINGS` — карточка пользовательского инструмента + +`Note` — это JSON, в котором все `"` заменены на `@*@` (чтобы CSV не пришлось экранировать), +а не-ASCII текст записан `\uXXXX`-escape'ами. Парсер делает обратную замену и `json.loads`, +после чего берёт: + +- `Holding.Description` → `InstrumentRef.name` (в фикстуре — «Газпром Нефть 006Р-04»); +- `Holding.Currency` → `InstrumentRef.currency`; +- `Holding.Sector` → `meta["sector"]`, плюс `meta["custom_holding"] = True` и весь блок + `Settings` в `meta["settings"]`. + +Если JSON не разобрался — это `warning`, а не исключение: одна битая строка настроек не +должна стоить пользователю пятисот хороших сделок. Инструмент тогда остаётся голым тикером. + +### `CUSTOM_HOLDING_PRICE` — цена, а не событие + +Это цена, введённая пользователем для бумаги, которую биржа не котирует. Её место — +`price_manual`. В `BrokerEvent` положить цену некуда, поэтому строки едут в +`ParsedReport.meta["manual_prices"]`: + +```python +{"instrument_key": "TICKER:SIBN6P4", "d": date(2026, 9, 14), + "price": Decimal("12320.94504"), "currency": "RUB"} +``` + +Сами инструменты дублируются в `ParsedReport.instruments`, чтобы `ingest` сначала +отрезолвил бумагу обычным путём (pending instrument), а потом записал цены. + +## Правки остатка — не поток и не сделка + +Snowball сам вставляет строки `CASH_IN`/`CASH_OUT` с `Note` вида + +> Эта сделка добавлена с целью корректировки баланса по этой валюте, т.к. баланс по данным +> брокера (…) не соответствует балансу рассчитанному по сделкам (…). + +Это правка расхождения его собственного пересчёта с остатком брокера, а не деньги, которые +пересекли границу портфеля. Эмитить их как `deposit`/`withdrawal` нельзя: XIRR получит +фиктивный внешний поток. + +Такие строки в `events` **не попадают**, а уходят в `ParsedReport.meta["balance_adjustments"]` +(`{"kind", "d", "amount", "currency", "note", "line_no"}`) и каждая — в `warnings`. + +Определяются **по тексту `Note`** (маркеры `корректировки баланса` и +`не соответствует балансу`), а не по величине суммы: в реальной выгрузке такая правка +бывает и на 0,0024 ₽, и на 1017,84 ₽, тогда как настоящий вывод — на 32 ₽. Любой порог по +сумме ошибся бы на обоих. + +## Дедупликация + +Номеров сделок в формате нет, поэтому `dedupe_key` всегда `fingerprint_key(...)`, и всю +работу по различению «две реальные сделки» и «одна сделка, выгруженная дважды» делает +`seq`. + +`seq` — это ранг пары `(время суток, Note)` строки среди различных таких пар у всех строк +с тем же содержимым отпечатка (`kind`, инструмент, дата, `quantity`, `price`, валюта, +`amount`). Отсюда два следствия, оба намеренные: + +- два настоящих пополнения по 1100 ₽ с разницей в секунду получают разные ключи, а + повторный импорт того же файла воспроизводит те же ключи — ранг зависит только от + содержимого и времени, никогда от номеров строк; +- две строки, совпадающие вплоть до секунды и до `Note`, дают **один** ключ и схлопываются. + Для этого формата это правильное поведение по умолчанию: колонки счёта в нём нет, поэтому + повторённая строка почти всегда — одно событие, увиденное на двух счетах (в фикстуре — две + строки `SPLIT` по `T` на `2026-04-16 03:00:00`), а применить коэффициент сплита дважды к + единственному целевому счёту означало бы умножить позицию на 100 вместо 10. + +Цена компромисса честная: перекрывающаяся выгрузка, в которой части одинаковых строк нет, +сдвинет ранги оставшихся, и перекрытие импортируется как новые строки вместо upsert. +Формат с номерами сделок этой проблемы не имел бы — здесь номеров нет. + +## Область действия выгрузки + +Файл покрывает **все брокерские счета сразу**: в фикстуре 49 инструментов трёх брокеров +(T-Invest, Сбер, ВТБ) вперемешку за 2025-02-26…2026-09-17. Номера счёта в формате нет и +вывести его неоткуда. + +Поэтому `account_external_id` заполняется **именем портфеля из имени файла** +(`Snowball_Export_<портфель>_<дд.мм.гггг>.csv` → `Мой капитал`; то же значение — в +`meta["portfolio_name"]`), и парсер добавляет `warning`: целевой счёт обязан указать +пользователь, а по plan §1.6 B у счёта один `primary_event_source` — значит почти все +события лягут со `status = shadow` и работают как сверка, а не как леджер. + +`positions_end` и `cash_end` формат не даёт вообще: закрывающих позиций и остатков денег в +нём нет. Об этом тоже говорит `warning` — сверка остатков по этой выгрузке невозможна. + +## Контракт с `ledger/ingest.py` + +1. **Дивиденд на облигации — это купон.** Парсер не ходит в БД и не знает класс актива, + поэтому `kind` остаётся `dividend`, а подозрение едет в `meta["payout_hint"] = "coupon"`. + Окончательную переклассификацию в `EventKind.coupon` делает `ingest` после резолва + инструмента, по его настоящему `asset_class`. Подсказка — ускорение для резолвера, а не + ответ: кастомный холдинг-облигация подсказки не получит, и решать всё равно `ingest`. +2. **`meta["manual_prices"]`** пишутся в `price_manual` после резолва инструментов из + `instruments` (ключ — `InstrumentRef.key()`). +3. **`meta["balance_adjustments"]`** в леджер не пишутся никогда; их место — data quality + (расхождение derived vs брокер, которое Snowball уже зафиксировал). +4. **`meta["split_ratio"]` / `meta["ratio"]`** на событии `split` — это заявленный + коэффициент. `ledger/corporate_actions.py:_stated_ratio` читает обе эти ключевые метки, + поэтому сплит из CSV попадает в `corporate_action` как **заявленный**, и выводить + коэффициент из пары переводов не нужно. +5. **`meta["csv_event"]`** хранит исходный тип строки — по нему в `raw_report_line` видно, + из чего получилось событие. +6. Ожидаемый статус событий — `shadow` для всех счетов, у которых `primary_event_source` + не `csv`.