feat(reports): протокол и парсеры отчётов Сбера, ВТБ и Snowball CSV

sources/reports/base.py — контракт ReportParser (плагины: sniff/parse, чистые
функции без БД). registry.py выбирает парсер по содержимому файла, CSV
последним: он узнаёт файл по набору колонок и иначе перехватил бы чужой
формат.

Комиссия капитализируется в сделку, отдельным событием не эмитится: и Сбер,
и ВТБ печатают её дважды — колонками в сделках и строками в движении денег,
суммы совпадают, второе прочтение задвоило бы её. Расчётные строки («Сделка
от …», «Сальдо расчетов по сделкам») не эмитятся — это денежные ноги уже
учтённых сделок. У Сбера таблица «Информация о зачислениях на ИИС»
кумулятивна за календарный год и в леджер не идёт, иначе два пересекающихся
отчёта задвоили бы пополнения. CSV Snowball сводный по всем брокерам —
годится как сверка потоков и сделок на уровне портфеля, но не позиций по
счетам; CUSTOM_HOLDING_PRICE не событие, а цена — уходит в meta для
price_manual.

Обезличивание — anonymize.py + scripts/anonymize_reports.py, секреты
собираются по всему корпусу отчётов разом (Snowball цитирует номер договора
Сбера в примечании к переводу). test_fixtures_anonymized.py падает, если
в tests/fixtures/reports/ вне raw/ найдётся ИНН, ФИО или номер счёта — и по
форме (работает в CI без raw/), и по фактическому содержимому raw/, когда оно
на месте.
This commit is contained in:
Dmitry
2026-09-19 10:39:37 +03:00
parent 6b32c79e79
commit 2e742a093b
25 changed files with 7284 additions and 0 deletions
+2
View File
@@ -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]
+80
View File
@@ -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())
@@ -0,0 +1,32 @@
"""Broker report parsers (plan §2 «Интерфейсы плагинов», фаза 3).
A parser turns the bytes of one uploaded file into a `ParsedReport`: a period, a broker
account id, a flat list of `BrokerEvent`, the closing positions and cash the report itself
states, and the instruments it mentions. Nothing here touches the database — the whole
package is importable and testable from a fixture alone, which is the point: report formats
change without warning, and a format regression must be reproducible from one file.
"""
from fintracker.sources.reports.base import (
BrokerEvent,
CashEnd,
InstrumentRef,
ParsedReport,
ParseError,
PositionEnd,
ReportParser,
fingerprint_key,
trade_dedupe_key,
)
__all__ = [
"BrokerEvent",
"CashEnd",
"InstrumentRef",
"ParseError",
"ParsedReport",
"PositionEnd",
"ReportParser",
"fingerprint_key",
"trade_dedupe_key",
]
@@ -0,0 +1,233 @@
"""Turning a real broker report into a committable fixture (AGENTS.md: только обезличенные).
A broker report is a personal document: it prints the investor's full name, their taxpayer
number, the agreement and sub-account numbers, and the settlement account. None of that may
reach git, while everything a parser is tested on — dates, amounts, quantities, tickers,
ISINs, deal numbers, section layout — must survive untouched, or the fixture stops testing
the parser.
Two rules make this reproducible rather than a one-off scrub:
* **Nothing secret is written down here.** The module *finds* the personal data in the file
by the labels the broker itself prints («Инвестор:», «Клиент:», «ИНН:», «№ субсчета:»),
so this source file is safe to commit and the same raw file always yields the same
fixture.
* **Replacements preserve shape.** An account number keeps its length and its
letter/digit layout, because the parser reads it as an identifier and a fixture with a
differently-shaped id would not exercise the same code path. The substitute is derived
from a hash of the original, so it is stable across runs but tells you nothing about it.
`tests/test_fixtures_anonymized.py` is the enforcement side: it re-derives the secrets from
`raw/` (when present) and fails if any of them appears in a committed fixture.
"""
from __future__ import annotations
import hashlib
import io
import re
import zipfile
from collections.abc import Iterable
from dataclasses import dataclass, field
DIGITS = "0123456789"
UPPER = "АБВГДЕЖЗИКЛМНОПРСТУФХЦЧШЩЭЮЯ"
LATIN = "ABCDEFGHIJKLMNPQRSTUVWXYZ"
#: A fixture may legitimately contain these — they are the substitutes this module produces.
FAKE_SURNAME = "Иванов"
FAKE_NAME = "Иван"
FAKE_PATRONYMIC = "Иванович"
# --- what counts as personal data -------------------------------------------------------
#: Individual taxpayer number: 12 digits. A legal entity's is 10 and is public information
#: (the clearing house's INN is printed in every VTB report), so only 12 is scrubbed.
#: The lookarounds exclude a decimal point on either side — an xlsx stores a timestamp as
#: `46247.480069444515`, and the fractional part is twelve digits that mean nothing.
RE_INN = re.compile(r"(?<![\d.])\d{12}(?![\d.])")
#: Settlement / depo account: 20 digits.
RE_ACCOUNT_20 = re.compile(r"(?<![\d.])\d{20}(?![\d.])")
#: Substitutes for the two numeric identifiers are sentinels, not hashes: a hash of a
#: 12-digit INN is another 12-digit number, and the fixture check could not then tell a
#: leak from a replacement. Six leading zeros never occur in a real INN or account number,
#: so `RE_PLACEHOLDER_NUMBER` is a safe allow-list for the test.
RE_PLACEHOLDER_NUMBER = re.compile(r"^0{6,}\d*$")
#: «Петров И. С.» — surname plus initials, in any spacing. VTB prints the client this way,
#: and its own back-office employee too, so both are caught.
RE_SHORT_NAME = re.compile(r"\b([А-ЯЁ][а-яё]{2,})\s+([А-ЯЁ])\.\s?([А-ЯЁ])\.")
#: «ПЕТРОВ ИВАН СЕРГЕЕВИЧ» — the all-caps form Sber prints after «Инвестор:».
RE_CAPS_FIO = re.compile(r"\b[А-ЯЁ]{3,}\s+[А-ЯЁ]{3,}\s+[А-ЯЁ]{3,}\b")
#: Labels after which a broker prints the agreement / sub-account id.
ACCOUNT_ANCHORS = (
re.compile(
r"(?:индивидуального инвестиционного счета|Договор[^\n<]{0,60}?)\s+([0-9A-Z]{5,12})\s+от"
),
re.compile(r"№ и дата Соглашения[^0-9A-Z]{0,40}([0-9A-Z]{5,12})\b"),
re.compile(r"\s*субсчета[^0-9A-Z]{0,40}([0-9A-Z]{5,12})\b"),
re.compile(r"Субпозиция\s*№\s*([0-9A-Z]{5,12})\b"),
re.compile(r"с договора\s+([0-9A-Z]{5,12})\b"),
re.compile(r"на договор\s+([0-9A-Z]{5,12})\b"),
)
#: Tokens that match an anchor's shape but are not identifiers of this client.
ACCOUNT_STOPWORDS = frozenset({"UCAF", "RUB", "USD", "EUR"})
@dataclass
class Secrets:
"""What was found in one report, and what each item is replaced with."""
replacements: dict[str, str] = field(default_factory=dict)
def add(self, original: str, substitute: str) -> None:
if original and original not in self.replacements:
self.replacements[original] = substitute
def apply(self, text: str) -> str:
# Longest first: an agreement id may be a substring of a longer one.
for original in sorted(self.replacements, key=len, reverse=True):
text = text.replace(original, self.replacements[original])
return text
@property
def originals(self) -> list[str]:
return list(self.replacements)
def _shaped_substitute(value: str) -> str:
"""A stable stand-in with the same length and letter/digit layout as `value`."""
digest = hashlib.sha256(value.encode("utf-8")).digest()
out: list[str] = []
for i, ch in enumerate(value):
byte = digest[i % len(digest)]
if ch.isdigit():
out.append(DIGITS[byte % 10])
elif ch.isalpha() and ch.isupper():
alphabet = UPPER if ch in UPPER or ord(ch) > 127 else LATIN
out.append(alphabet[byte % len(alphabet)])
elif ch.isalpha():
out.append(LATIN[byte % len(LATIN)].lower())
else:
out.append(ch)
return "".join(out)
def find_secrets(text: str) -> Secrets:
"""Everything personal in one report's text, with its replacement decided."""
secrets = Secrets()
for match in RE_CAPS_FIO.finditer(text):
fio = match.group(0)
secrets.add(fio, f"{FAKE_SURNAME.upper()} {FAKE_NAME.upper()} {FAKE_PATRONYMIC.upper()}")
for match in RE_SHORT_NAME.finditer(text):
secrets.add(match.group(0), f"{FAKE_SURNAME} {FAKE_NAME[0]}. {FAKE_PATRONYMIC[0]}.")
for pattern in (RE_INN, RE_ACCOUNT_20):
for match in pattern.finditer(text):
original = match.group(0)
if RE_PLACEHOLDER_NUMBER.match(original):
continue # already a substitute — re-running must be idempotent
index = sum(1 for v in secrets.replacements.values() if len(v) == len(original))
secrets.add(original, "0" * (len(original) - 2) + f"{index:02d}")
for pattern in ACCOUNT_ANCHORS:
for match in pattern.finditer(text):
token = match.group(1)
if token in ACCOUNT_STOPWORDS or token.isalpha():
continue
secrets.add(token, _shaped_substitute(token))
return secrets
# --- per-format rewriting ----------------------------------------------------------------
def anonymize_text(
data: bytes, encoding: str = "utf-8", secrets: Secrets | None = None
) -> tuple[bytes, Secrets]:
"""HTML and CSV: one decode, one pass, one encode."""
text = data.decode(encoding)
secrets = secrets if secrets is not None else find_secrets(text)
return secrets.apply(text).encode(encoding), secrets
#: Parts of an xlsx that can hold a visible string. Styles and calc chains cannot.
XLSX_TEXT_PARTS = re.compile(r"^xl/(sharedStrings\.xml|worksheets/.*\.xml|comments\d*\.xml)$")
def anonymize_xlsx(data: bytes, secrets: Secrets | None = None) -> tuple[bytes, Secrets]:
"""Rewrite the strings inside a workbook, leaving every other part byte-identical.
Editing the zip directly rather than round-tripping through openpyxl is deliberate: a
load/save cycle rewrites formatting, drops the parts openpyxl does not model, and would
make the fixture differ from a real export in ways a parser might depend on.
"""
source = zipfile.ZipFile(io.BytesIO(data))
texts = {
name: source.read(name).decode("utf-8")
for name in source.namelist()
if XLSX_TEXT_PARTS.match(name)
}
secrets = secrets if secrets is not None else find_secrets("\n".join(texts.values()))
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as out:
for item in source.infolist():
if item.filename in texts:
out.writestr(item, secrets.apply(texts[item.filename]).encode("utf-8"))
else:
out.writestr(item, source.read(item.filename))
return buffer.getvalue(), secrets
def readable_text(data: bytes, filename: str) -> str:
"""The visible text of a report, whatever its container — the input to `find_secrets`."""
if filename.lower().endswith((".xlsx", ".xlsm")):
archive = zipfile.ZipFile(io.BytesIO(data))
return "\n".join(
archive.read(name).decode("utf-8", "replace")
for name in archive.namelist()
if XLSX_TEXT_PARTS.match(name)
)
return data.decode("utf-8", "replace")
def collect_secrets(files: Iterable[tuple[bytes, str]]) -> Secrets:
"""One secret set for a whole batch of reports — the only safe way to scrub them.
Personal data crosses file boundaries: the Snowball CSV quotes the Sber agreement number
inside a transfer's note, and nothing in the CSV itself marks it as an identifier. Each
file scrubbed in isolation therefore leaks what another file named. Deriving the set once
from every original and applying it to all of them closes that gap, and keeps one
identifier mapped to one substitute across the whole fixture corpus.
"""
merged = Secrets()
for data, filename in files:
for original, substitute in find_secrets(
readable_text(data, filename)
).replacements.items():
merged.add(original, substitute)
return merged
def anonymize(data: bytes, filename: str, secrets: Secrets | None = None) -> tuple[bytes, Secrets]:
"""Dispatch on extension. Returns the clean bytes and what was removed."""
lowered = filename.lower()
if lowered.endswith((".xlsx", ".xlsm")):
return anonymize_xlsx(data, secrets)
if lowered.endswith(".csv"):
return anonymize_text(data, "utf-8-sig", secrets)
return anonymize_text(data, "utf-8", secrets)
def anonymize_filename(filename: str, secrets: Secrets) -> str:
"""Sber names its exports after the account number — the name leaks too."""
return secrets.apply(filename)
@@ -0,0 +1,231 @@
"""The `ReportParser` contract and the value objects every parser produces (plan §2).
Why these types and not the ORM rows directly: a report is *evidence*, not truth. It names
an instrument the way its own back office names it, states a period that may overlap another
file, and carries closing balances that exist precisely so they can be compared against what
the ledger derives. Handing `ingest.py` a plain dataclass keeps the parser free of the
database — a format regression is then reproducible from one fixture and nothing else.
Sign conventions match `models/ledger.Event` exactly, so ingest never has to re-interpret:
* `quantity` is signed by its effect on the POSITION: + into it, - out of it.
* `amount` is signed by its effect on the ACCOUNT's cash: + received, - paid.
* `fee` and `tax` are positive and already reflected inside `amount`.
**Dedup keys.** Two report files routinely describe the same trade — overlapping periods, a
re-export, the xlsx and the pdf of one month. A key must therefore be derived from the trade
itself, never from the file. `trade_dedupe_key` is the good case: the broker's own deal
number is stable across every rendering of it. `fingerprint_key` is the fallback for rows
that have no number (a cash movement, a fee line): it hashes what actually identifies the
row economically. The fallback is genuinely weaker — two identical purchases of the same
paper at the same price on one day collapse into one key — so a parser that has a deal
number must pass it, and a parser that has to fall back should add a discriminator
(`seq`) that is stable for the same report content, not the line number of a particular
rendering.
"""
from __future__ import annotations
import hashlib
from dataclasses import dataclass, field
from datetime import date
from decimal import Decimal
from typing import Any, Protocol, runtime_checkable
from fintracker.models.ledger import EventKind
class ParseError(ValueError):
"""The file is this parser's format but its content cannot be read.
Distinct from `sniff` returning False (wrong format, try another parser): raising this
means the right parser gave up, and the import must fail loudly instead of silently
producing an empty report.
"""
@dataclass(frozen=True, slots=True)
class InstrumentRef:
"""How a report names a security, before anything resolves it to an `instrument` row.
Every field is optional because reports differ in what they print: Sber gives ISIN and a
ticker, VTB gives a name plus ISIN inside one string, a CSV may give a ticker only.
Resolution order is the plan's (§1.3): ISIN → FIGI → tinvest_uid → (ticker, board) →
alias. `source_key` is what an `instrument_alias` row would store when nothing else
matches, and it is what a `pending_instrument` is keyed by — so it must be stable for
the same paper across files.
"""
isin: str | None = None
ticker: str | None = None
board: str | None = None
name: str | None = None
currency: str | None = None
asset_class_hint: str | None = None
"""share | bond | etf | fund | currency — a hint from the report's own wording, never
authoritative: the user confirms the class when resolving a pending instrument."""
source_key: str | None = None
"""Stable key for `instrument_alias(source, source_key)`, e.g. 'ISIN:RU0009029540'."""
meta: dict[str, Any] = field(default_factory=dict)
def key(self) -> str:
"""The identity used for pending-instrument grouping and alias lookups."""
if self.source_key:
return self.source_key
if self.isin:
return f"ISIN:{self.isin}"
if self.ticker and self.board:
return f"TICKER:{self.ticker}/{self.board}"
if self.ticker:
return f"TICKER:{self.ticker}"
return f"NAME:{self.name or ''}"
@dataclass(frozen=True, slots=True)
class BrokerEvent:
"""One ledger-bound row of a report, already in `event`'s sign conventions."""
kind: EventKind
trade_date: date
amount: Decimal
"""Signed cash effect on the account: + received, - paid. Zero for a pure position move."""
currency: str
settle_date: date | None = None
instrument: InstrumentRef | None = None
quantity: Decimal | None = None
price: Decimal | None = None
price_currency: str | None = None
fee: Decimal | None = None
fee_currency: str | None = None
tax: Decimal | None = None
tax_currency: str | None = None
accrued_interest: Decimal | None = None
trade_no: str | None = None
"""The broker's own deal number — the only stable identity across re-exports."""
description: str | None = None
raw_line_no: int = 0
"""Where the row sat in the parsed file; stored on `raw_report_line` for traceability.
Never part of a dedupe key: the same trade sits on different lines in different files."""
seq: int = 0
"""Discriminator for fingerprint keys when a report repeats an identical row legitimately
(two equal purchases in one second). Must be derived from the row's position *within its
own section*, so the same content yields the same value in any rendering of the report."""
meta: dict[str, Any] = field(default_factory=dict)
"""Parser-specific extras carried to `event.meta`: split ratio, deal comment, section."""
@dataclass(frozen=True, slots=True)
class PositionEnd:
"""A closing position as the report states it — reconciliation input, never a ledger row."""
instrument: InstrumentRef
qty: Decimal
price: Decimal | None = None
market_value: Decimal | None = None
currency: str | None = None
accrued_interest: Decimal | None = None
@dataclass(frozen=True, slots=True)
class CashEnd:
"""A closing cash balance per currency, as the report states it."""
currency: str
balance: Decimal
blocked: Decimal | None = None
@dataclass
class ParsedReport:
"""Everything one file yields. `warnings` is the parser's own data-quality channel:
a section it did not recognise, a total that did not add up, a row it skipped."""
broker: str
"""sber | vtb | csv — matches `Broker` and the `report_*` source names."""
account_external_id: str
"""The broker's account/agreement number as printed, e.g. 'S930W42', '77R593'."""
period_from: date
period_to: date
parser_version: str
events: list[BrokerEvent] = field(default_factory=list)
positions_end: list[PositionEnd] = field(default_factory=list)
cash_end: list[CashEnd] = field(default_factory=list)
instruments: list[InstrumentRef] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
meta: dict[str, Any] = field(default_factory=dict)
@runtime_checkable
class ReportParser(Protocol):
"""Pure, database-free: bytes in, `ParsedReport` out."""
broker: str
name: str
"""Registry key and `event.source`, e.g. 'report_sber'."""
formats: tuple[str, ...]
"""Lowercase extensions this parser reads, e.g. ('html', 'htm')."""
version: str
"""Bumped whenever output changes for the same input; stored on `raw_report_file`."""
def sniff(self, data: bytes, filename: str) -> bool:
"""True when this parser recognises the file. Cheap, and never raises."""
...
def parse(self, data: bytes, filename: str) -> ParsedReport:
"""Parse, or raise `ParseError` when the content is unreadable."""
...
def _sha1(parts: list[str]) -> str:
return hashlib.sha1("|".join(parts).encode("utf-8")).hexdigest()
def trade_dedupe_key(broker: str, account_external_id: str, trade_no: str) -> str:
"""sha1(broker|account|trade_no) — the plan's §1.6 A key for a numbered deal."""
return _sha1([broker, account_external_id, trade_no])
def fingerprint_key(
broker: str,
account_external_id: str,
kind: EventKind | str,
instrument_key: str,
trade_date: date,
quantity: Decimal | None,
price: Decimal | None,
currency: str,
*,
amount: Decimal | None = None,
seq: int = 0,
) -> str:
"""Fallback key for a row with no deal number (plan §1.6 A).
`amount` joins the plan's list because a report's cash section is full of rows that share
everything else — two 4 000 ₽ top-ups in one month differ by nothing but the amount and
the date, and a fee line has neither quantity nor price. `seq` breaks the remaining ties.
"""
kind_value = kind.value if isinstance(kind, EventKind) else str(kind)
return _sha1(
[
broker,
account_external_id,
kind_value,
instrument_key,
trade_date.isoformat(),
_num(quantity),
_num(price),
currency,
_num(amount),
str(seq),
]
)
def _num(value: Decimal | None) -> str:
"""Normalised so 1.50 and 1.5 hash alike — a re-export may print either."""
if value is None:
return ""
normalized = value.normalize()
if normalized == 0:
return "0"
return format(normalized, "f")
@@ -0,0 +1,12 @@
"""Universal CSV report parser (plan §2, фаза 3).
The format is Snowball Income's own export — the one file that covers *every* brokerage
account at once, which makes it the only independent cross-check of the whole ledger we
can get without re-reading each broker's own statements. It is therefore parsed as a
generic "event CSV" rather than as a Snowball-branded format: recognition is by the header
columns, so any future export with the same column set is read by the same code.
"""
from fintracker.sources.reports.csv_universal.parser import UniversalCsvParser
__all__ = ["UniversalCsvParser"]
@@ -0,0 +1,791 @@
"""A generic event CSV (Snowball Income's export) → `ParsedReport`.
The column set is fixed; what a column *means* is not. `Quantity` holds a number of shares
on a BUY and a number of rubles on a DIVIDEND, `Price` holds a price on a trade and a split
ratio on a SPLIT, and a FEE row carries its money in `FeeTax` with every other numeric
column zero. So the parser is organised as one handler per event type rather than as a
single row→event mapping: the shape of the row *is* the event type, and pretending
otherwise is how a dividend of 131,44 ₽ becomes a position of 131,44 bonds.
Contracts with the rest of the import pipeline, all decided here and documented in
`docs/ai/csv-import.md`:
* **`CUSTOM_HOLDING_PRICE` is a price, not an event.** It is a user-entered quote for a
paper the exchange does not quote (in the fixture: SIBN6P4, a bond held off-market), and
its home is `price_manual`. `base.py` has nowhere to put a price, and `base.py` is not
ours to change, so the rows travel in `meta["manual_prices"]` as
`{"instrument_key", "d", "price", "currency"}` and the instruments they name are also
listed in `instruments`, so `ingest.py` resolves them through the ordinary pending-
instrument flow before writing the prices.
* **`CUSTOM_HOLDING_SETTINGS` describes an instrument, not an event.** Its `Note` is JSON
with every `"` rewritten to `@*@` (so the CSV never has to escape quotes). Undo that,
read `Holding.Description`/`Holding.Currency`/`Holding.Sector`, and emit an
`InstrumentRef` — this is the only place a custom holding gets a human name. Malformed
JSON is a `warning`, never an exception: one broken settings row must not cost the user
500 good trades.
* **A `CASH_IN`/`CASH_OUT` whose `Note` says it balances the account is not a flow.**
Snowball inserts these itself when the broker's stated cash disagrees with the cash its
own replay produces. Emitting them as deposit/withdrawal would feed XIRR an external flow
that never crossed the portfolio boundary. They go to `warnings` and to
`meta["balance_adjustments"]`. Detection is by the note's wording, never by the size of
the amount: the fixture has one such correction of 1017,84 ₽ next to genuine withdrawals
of 32 ₽, so a threshold would misclassify both.
* **A `DIVIDEND` on a bond is a coupon, and the parser cannot prove it.** Asset class lives
in the database and the parser has none. So the kind stays `dividend` and the suspicion
travels as `meta["payout_hint"] = "coupon"` whenever the symbol looks like a bond (a
valid ISIN, or an OFZ secid `SU…RMFS…`). `ledger/ingest.py` reclassifies to
`EventKind.coupon` once the instrument resolves and its real `asset_class` is known; the
hint is a shortcut for the resolver, not an answer.
* **The file names a portfolio, not an account.** It covers every broker at once — 49
symbols from three brokers in one 545-row fixture — so `account_external_id` cannot be
derived from it and is filled with the portfolio name out of the filename. A warning says
so, because per plan §1.6 B an account has one `primary_event_source`: whatever account
the user points this import at, most of these events will land as `status = shadow` and
serve as reconciliation evidence rather than as the ledger itself.
* **An unknown event type is skipped with a warning, never mapped to `other`.** `other` is
a silent bucket; a format that grew a new row type must be visible as a data-quality
finding, not averaged into a metric.
**Dedup.** The format has no deal numbers, so every row gets `fingerprint_key`, and the
whole weight of telling two real trades apart from one trade exported twice falls on `seq`.
`seq` is the rank of the row's (time-of-day, note) among the distinct (time-of-day, note)
pairs of all rows that share its fingerprint content. Consequences, both intended:
* two genuine 1 100 ₽ top-ups one second apart keep distinct keys, and a re-import of the
same file reproduces those keys exactly, because the rank depends on content and clock
time only — never on line numbers;
* two rows identical down to the second *and* the note collapse into one key. That is the
right default for this format: it has no account column, so the repeated row is almost
always one event seen on two accounts (the fixture's two `SPLIT` rows for T at
2026-04-16 03:00:00), and replaying it twice against the single target account would
apply the split ratio twice.
The cost is the honest one: an export overlapping this one but missing some of the
same-content rows shifts the surviving rows' ranks, so the overlap re-imports as new rows
instead of upserting. A format with deal numbers would not have this problem; this one has
no numbers to use.
"""
from __future__ import annotations
import csv
import io
import json
import re
from collections import defaultdict
from dataclasses import dataclass, field, replace
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from typing import Any
from fintracker.models.ledger import EventKind
from fintracker.sources.reports.base import (
BrokerEvent,
InstrumentRef,
ParsedReport,
ParseError,
)
ZERO = Decimal(0)
#: Columns without which the file is not this format. Everything else is optional, so an
#: export that drops `NKD` or `FeeCurrency` is still read.
REQUIRED_COLUMNS = frozenset({"Event", "Date", "Symbol", "Price", "Quantity", "Currency"})
OPTIONAL_COLUMNS = frozenset(
{"FeeTax", "Exchange", "NKD", "FeeCurrency", "DoNotAdjustCash", "Note"}
)
#: Event types that become ledger events, and the kind each becomes.
EVENT_KINDS: dict[str, EventKind] = {
"BUY": EventKind.buy,
"SELL": EventKind.sell,
"CASH_IN": EventKind.deposit,
"CASH_OUT": EventKind.withdrawal,
"DIVIDEND": EventKind.dividend,
"AMORTISATION": EventKind.amortization,
"FEE": EventKind.commission,
"TAX": EventKind.tax,
"TAX_RETURN": EventKind.tax_refund,
"SPLIT": EventKind.stock_split,
}
#: Recognised event types that are deliberately NOT ledger events (see the module docstring).
NON_LEDGER_EVENTS = frozenset({"CUSTOM_HOLDING_PRICE", "CUSTOM_HOLDING_SETTINGS"})
#: Substrings that mark a cash row Snowball inserted to reconcile its own replay with the
#: broker's stated balance. Two markers, because the wording of the sentence varies with
#: the currency and the app version but these two fragments have not.
BALANCE_ADJUSTMENT_MARKERS = ("корректировки баланса", "не соответствует балансу")
#: Quotes inside the JSON blob of a CUSTOM_HOLDING_SETTINGS note.
JSON_QUOTE_ESCAPE = "@*@"
#: `Exchange` value marking a paper the exchange does not quote.
CUSTOM_HOLDING = "CUSTOM_HOLDING"
_TIMESTAMP_FORMATS = ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d")
#: 12 characters, two letters, a check digit — validated properly in `_looks_like_isin`.
_ISIN_SHAPE = re.compile(r"^[A-Z]{2}[A-Z0-9]{9}[0-9]$")
#: OFZ are addressed by a MOEX secid that happens to satisfy the ISIN checksum
#: (SU26212RMFS9). Calling it an ISIN would send the resolver looking for a security that
#: does not exist under that number, so the shape is excluded explicitly.
_OFZ_SECID = re.compile(r"^SU\d{5}RMFS\d$")
#: `Snowball_Export_<portfolio>_<dd.mm.yyyy>.csv` — the only place the portfolio is named.
_FILENAME = re.compile(r"^.*?Export[_\s]+(?P<name>.+?)[_\s]*\d{2}\.\d{2}\.\d{4}$")
#: Decorations around a portfolio name in a filename (Snowball allows an emoji prefix).
_NAME_EDGE = re.compile(r"^[^\w]+|[^\w]+$", re.UNICODE)
#: Whitespace that shows up inside exported numbers: plain, non-breaking, narrow.
_SPACES = str.maketrans({" ": "", "\xa0": "", "": ""})
@dataclass
class _Row:
"""One CSV data row, decoded but not yet interpreted."""
line_no: int
event: str
ts: datetime
symbol: str
price: Decimal | None
quantity: Decimal | None
currency: str
fee_tax: Decimal | None
exchange: str
nkd: Decimal | None
fee_currency: str
note: str
@property
def trade_date(self) -> date:
return self.ts.date()
@dataclass
class _Draft:
"""A `BrokerEvent` with `seq` still unknown — it needs the whole file to be assigned."""
event: BrokerEvent
time_key: tuple[str, str]
"""(time of day, note) — what tells two same-day, same-content rows apart."""
@dataclass
class _Accumulator:
"""Everything the row handlers write into, so they stay free of parser plumbing."""
drafts: list[_Draft] = field(default_factory=list)
instruments: dict[str, InstrumentRef] = field(default_factory=dict)
manual_prices: list[dict[str, Any]] = field(default_factory=list)
balance_adjustments: list[dict[str, Any]] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
unknown_events: dict[str, int] = field(default_factory=lambda: defaultdict(int))
def add_instrument(self, ref: InstrumentRef | None) -> InstrumentRef | None:
"""Keep one ref per identity, preferring the richest description seen.
The same paper appears on 30 trade rows with a bare ticker and once on a settings
row with its name, currency and sector; the ledger wants the latter.
"""
if ref is None:
return None
key = ref.key()
known = self.instruments.get(key)
if known is None:
self.instruments[key] = ref
return ref
merged = replace(
known,
isin=known.isin or ref.isin,
ticker=known.ticker or ref.ticker,
name=known.name or ref.name,
currency=known.currency or ref.currency,
asset_class_hint=known.asset_class_hint or ref.asset_class_hint,
meta={**ref.meta, **known.meta},
)
self.instruments[key] = merged
return merged
class UniversalCsvParser:
"""`ReportParser` for the generic event CSV. Pure: bytes in, `ParsedReport` out."""
broker = "csv"
name = "csv"
formats: tuple[str, ...] = ("csv",)
version = "1"
def sniff(self, data: bytes, filename: str) -> bool:
"""Recognise the format by its header columns alone.
Not by the filename: the user renames exports, and a second tool may write the same
columns. Not by content either — the header is the only thing every export of this
format shares. Never raises, per the protocol.
"""
del filename # deliberately unused — that is the point of this method
try:
header = _header(data)
except Exception:
return False
return header is not None and header >= REQUIRED_COLUMNS
def parse(self, data: bytes, filename: str) -> ParsedReport:
"""Read the whole file, or raise `ParseError` if it is not readable as this format."""
text = _decode(data)
reader = csv.DictReader(io.StringIO(text))
fieldnames = {(name or "").strip() for name in (reader.fieldnames or ())}
if not fieldnames >= REQUIRED_COLUMNS:
missing = ", ".join(sorted(REQUIRED_COLUMNS - fieldnames))
raise ParseError(f"CSV без обязательных колонок: {missing}")
acc = _Accumulator()
rows = _read_rows(reader, acc)
if not rows:
raise ParseError("CSV не содержит ни одной строки событий")
for row in rows:
_dispatch(row, acc)
events = _assign_seq(acc.drafts)
portfolio = _portfolio_name(filename)
report = ParsedReport(
broker=self.broker,
account_external_id=portfolio,
period_from=min(r.trade_date for r in rows),
period_to=max(r.trade_date for r in rows),
parser_version=self.version,
events=events,
positions_end=[],
cash_end=[],
instruments=list(acc.instruments.values()),
warnings=acc.warnings,
meta={
"portfolio_name": portfolio,
"manual_prices": acc.manual_prices,
"balance_adjustments": acc.balance_adjustments,
"row_count": len(rows),
},
)
_finalize_warnings(report, acc)
return report
# --- reading --------------------------------------------------------------------------
def _decode(data: bytes) -> str:
"""UTF-8 with an optional BOM, falling back to the cp1251 a Russian export may carry."""
for encoding in ("utf-8-sig", "cp1251"):
try:
return data.decode(encoding)
except UnicodeDecodeError:
continue
raise ParseError("не удалось определить кодировку CSV")
def _header(data: bytes) -> frozenset[str] | None:
"""The header's column names, read from the first line only (sniffing must be cheap)."""
head = data[:4096].decode("utf-8-sig", errors="replace")
line = head.splitlines()[0] if head.splitlines() else ""
if not line:
return None
names = next(csv.reader([line]), [])
return frozenset(name.strip() for name in names if name.strip())
def _read_rows(reader: csv.DictReader[str], acc: _Accumulator) -> list[_Row]:
"""Decode every data row; a row with an unreadable date is dropped with a warning.
The date is the one field with no sane default — it decides the period, the sort order
and every dedupe key — so a row without it cannot be salvaged.
"""
rows: list[_Row] = []
for raw in reader:
line_no = reader.line_num
event = _text(raw.get("Event")).upper()
if not event:
continue
ts = _timestamp(_text(raw.get("Date")))
if ts is None:
acc.warnings.append(
f"строка {line_no}: непонятная дата {_text(raw.get('Date'))!r}, строка пропущена"
)
continue
try:
rows.append(
_Row(
line_no=line_no,
event=event,
ts=ts,
symbol=_text(raw.get("Symbol")),
price=_decimal(raw.get("Price")),
quantity=_decimal(raw.get("Quantity")),
currency=_text(raw.get("Currency")).upper() or "RUB",
fee_tax=_decimal(raw.get("FeeTax")),
exchange=_text(raw.get("Exchange")).upper(),
nkd=_decimal(raw.get("NKD")),
fee_currency=_text(raw.get("FeeCurrency")).upper(),
note=_text(raw.get("Note")),
)
)
except InvalidOperation:
acc.warnings.append(f"строка {line_no}: нечисловое значение в числовой колонке")
return rows
def _text(value: str | None) -> str:
return (value or "").strip()
def _decimal(value: str | None) -> Decimal | None:
"""Decimal or None — never 0.
An empty cell and a zero are different facts: `FeeTax` of 0 on a trade means the broker
charged nothing, an empty `NKD` means the row is about a share and accrued interest is
not a concept there. Collapsing them would put a 0 ₽ НКД on every equity trade.
"""
raw = _text(value).translate(_SPACES).replace(",", ".")
if not raw:
return None
return Decimal(raw)
def _timestamp(value: str) -> datetime | None:
for fmt in _TIMESTAMP_FORMATS:
try:
return datetime.strptime(value, fmt)
except ValueError:
continue
return None
# --- row handlers ---------------------------------------------------------------------
def _dispatch(row: _Row, acc: _Accumulator) -> None:
if row.event in ("BUY", "SELL"):
_trade(row, acc)
elif row.event in ("CASH_IN", "CASH_OUT"):
_cash(row, acc)
elif row.event in ("DIVIDEND", "AMORTISATION"):
_payout(row, acc)
elif row.event in ("FEE", "TAX", "TAX_RETURN"):
_charge(row, acc)
elif row.event == "SPLIT":
_split(row, acc)
elif row.event == "CUSTOM_HOLDING_PRICE":
_manual_price(row, acc)
elif row.event == "CUSTOM_HOLDING_SETTINGS":
_custom_holding(row, acc)
else:
# neither a ledger event nor a recognised non-ledger one: make it visible
assert row.event not in EVENT_KINDS and row.event not in NON_LEDGER_EVENTS
acc.unknown_events[row.event] += 1
def _trade(row: _Row, acc: _Accumulator) -> None:
"""BUY/SELL: `Quantity` is units, `Price` is money per unit, `NKD` is the whole НКД.
Accrued interest is paid on top of the price by the buyer and received by the seller,
and the fee always leaves the account — hence the asymmetric signs below. Both are
already inside `amount`, which is what `base.py` promises ingest.
"""
qty = row.quantity or ZERO
price = row.price or ZERO
nkd = row.nkd or ZERO
fee = row.fee_tax
gross = qty * price + nkd
is_buy = row.event == "BUY"
amount = -(gross + (fee or ZERO)) if is_buy else gross - (fee or ZERO)
instrument = acc.add_instrument(_instrument(row))
_emit(
acc,
row,
kind=EventKind.buy if is_buy else EventKind.sell,
amount=amount,
instrument=instrument,
quantity=qty if is_buy else -qty,
price=price,
price_currency=row.currency,
fee=fee,
fee_currency=row.fee_currency or row.currency,
accrued_interest=nkd if row.nkd is not None else None,
)
def _cash(row: _Row, acc: _Accumulator) -> None:
"""CASH_IN/CASH_OUT: `Symbol` is a currency code, `Quantity` is the amount of money.
Snowball's own balance corrections wear the same event type and are filtered out here —
they are bookkeeping about the export, not money the user moved.
"""
amount = row.quantity or ZERO
currency = (row.symbol or row.currency).upper()
signed = amount if row.event == "CASH_IN" else -amount
if _is_balance_adjustment(row.note):
acc.balance_adjustments.append(
{
"kind": row.event,
"d": row.trade_date,
"amount": signed,
"currency": currency,
"note": row.note,
"line_no": row.line_no,
}
)
acc.warnings.append(
f"строка {row.line_no}: {row.event} на {signed} {currency} — это правка остатка "
"Snowball, а не движение денег; в леджер не попадает"
)
return
_emit(
acc,
row,
kind=EventKind.deposit if row.event == "CASH_IN" else EventKind.withdrawal,
amount=signed,
currency=currency,
)
def _payout(row: _Row, acc: _Accumulator) -> None:
"""DIVIDEND/AMORTISATION: `Quantity` is MONEY, not units, and `Price` is a filler zero.
Reading `Quantity` as a position here is the single most damaging mistake this format
invites: a 131,44 ₽ coupon would become 131,44 bonds in the lot engine.
"""
amount = row.quantity or ZERO
instrument = acc.add_instrument(_instrument(row))
meta: dict[str, Any] = {}
if row.event == "DIVIDEND" and _looks_like_bond(row.symbol):
meta["payout_hint"] = "coupon"
_emit(
acc,
row,
kind=EVENT_KINDS[row.event],
amount=amount,
instrument=instrument,
tax=row.fee_tax if row.fee_tax else None,
tax_currency=row.currency if row.fee_tax else None,
meta=meta,
)
def _charge(row: _Row, acc: _Accumulator) -> None:
"""FEE/TAX/TAX_RETURN: the money is in `FeeTax`; every other numeric column is zero.
These rows have no instrument even when the charge logically belongs to one — the format
does not say which — so they stay account-level, which is also how the ledger treats
them (`commission`/`tax` move cash and never touch a position).
"""
value = row.fee_tax or ZERO
is_refund = row.event == "TAX_RETURN"
_emit(
acc,
row,
kind=EVENT_KINDS[row.event],
amount=value if is_refund else -value,
fee=value if row.event == "FEE" else None,
fee_currency=row.fee_currency or row.currency if row.event == "FEE" else None,
tax=value if row.event != "FEE" else None,
tax_currency=row.fee_currency or row.currency if row.event != "FEE" else None,
)
def _split(row: _Row, acc: _Accumulator) -> None:
"""SPLIT: `Price` is the ratio, and no money or quantity moves.
The ratio goes to `meta` under both keys `corporate_actions._stated_ratio` looks for, so
the existing derivation picks it up as a *stated* ratio and never has to guess one from
a transfer pair.
"""
ratio = row.price
if ratio is None or ratio <= ZERO:
acc.warnings.append(f"строка {row.line_no}: SPLIT без коэффициента, строка пропущена")
return
instrument = acc.add_instrument(_instrument(row))
_emit(
acc,
row,
kind=EventKind.stock_split,
amount=ZERO,
instrument=instrument,
meta={"split_ratio": ratio, "ratio": ratio},
)
def _manual_price(row: _Row, acc: _Accumulator) -> None:
"""CUSTOM_HOLDING_PRICE: a quote for `price_manual`, not an event (module docstring)."""
if row.price is None:
acc.warnings.append(f"строка {row.line_no}: CUSTOM_HOLDING_PRICE без цены, пропущена")
return
instrument = acc.add_instrument(_instrument(row))
if instrument is None:
acc.warnings.append(f"строка {row.line_no}: CUSTOM_HOLDING_PRICE без инструмента")
return
acc.manual_prices.append(
{
"instrument_key": instrument.key(),
"d": row.trade_date,
"price": row.price,
"currency": row.currency,
}
)
def _custom_holding(row: _Row, acc: _Accumulator) -> None:
"""CUSTOM_HOLDING_SETTINGS: the instrument card of a user-defined holding.
`Note` is JSON whose quotes were rewritten to `@*@` so the CSV needs no escaping, and
whose non-ASCII text is `\\uXXXX`-escaped — `json.loads` undoes both. A row we cannot
read costs only the paper's human name, so it is a warning and the bare ticker survives.
"""
settings = _settings_json(row.note)
if settings is None:
acc.warnings.append(
f"строка {row.line_no}: не удалось разобрать JSON в CUSTOM_HOLDING_SETTINGS "
f"для {row.symbol or '?'}; инструмент остаётся без названия"
)
acc.add_instrument(_instrument(row))
return
holding = settings.get("Holding") or {}
ref = _instrument(row)
if ref is None:
acc.warnings.append(f"строка {row.line_no}: CUSTOM_HOLDING_SETTINGS без символа")
return
acc.add_instrument(
replace(
ref,
name=_text(holding.get("Description")) or ref.name,
currency=_text(holding.get("Currency")).upper() or ref.currency,
meta={
**ref.meta,
"custom_holding": True,
"sector": _text(holding.get("Sector")) or None,
"settings": settings.get("Settings") or {},
},
)
)
def _settings_json(note: str) -> dict[str, Any] | None:
if not note:
return None
try:
parsed = json.loads(note.replace(JSON_QUOTE_ESCAPE, '"'))
except (ValueError, TypeError):
return None
return parsed if isinstance(parsed, dict) else None
# --- helpers --------------------------------------------------------------------------
def _emit(
acc: _Accumulator,
row: _Row,
*,
kind: EventKind,
amount: Decimal,
currency: str | None = None,
instrument: InstrumentRef | None = None,
quantity: Decimal | None = None,
price: Decimal | None = None,
price_currency: str | None = None,
fee: Decimal | None = None,
fee_currency: str | None = None,
tax: Decimal | None = None,
tax_currency: str | None = None,
accrued_interest: Decimal | None = None,
meta: dict[str, Any] | None = None,
) -> None:
extra = dict(meta or {})
extra["csv_event"] = row.event
if row.exchange:
extra["exchange"] = row.exchange
acc.drafts.append(
_Draft(
event=BrokerEvent(
kind=kind,
trade_date=row.trade_date,
amount=amount,
currency=currency or row.currency,
instrument=instrument,
quantity=quantity,
price=price,
price_currency=price_currency,
fee=fee,
fee_currency=fee_currency,
tax=tax,
tax_currency=tax_currency,
accrued_interest=accrued_interest,
description=row.note or None,
raw_line_no=row.line_no,
meta=extra,
),
time_key=(row.ts.strftime("%H:%M:%S"), row.note),
)
)
def _assign_seq(drafts: list[_Draft]) -> list[BrokerEvent]:
"""Give each event the `seq` its fingerprint key needs (module docstring, «Dedup»).
Two passes on purpose: `seq` is a property of the *file*, not of the row, so it cannot
be known while the row is being read.
"""
ranks: dict[tuple[str, ...], list[tuple[str, str]]] = defaultdict(list)
for draft in drafts:
keys = ranks[_content_key(draft.event)]
if draft.time_key not in keys:
keys.append(draft.time_key)
for keys in ranks.values():
keys.sort()
return [
replace(
draft.event,
seq=ranks[_content_key(draft.event)].index(draft.time_key),
)
for draft in drafts
]
def _content_key(event: BrokerEvent) -> tuple[str, ...]:
"""Exactly what `fingerprint_key` hashes, minus `seq` — the thing `seq` has to break."""
return (
event.kind.value,
event.instrument.key() if event.instrument else "",
event.trade_date.isoformat(),
_norm(event.quantity),
_norm(event.price),
event.currency,
_norm(event.amount),
)
def _norm(value: Decimal | None) -> str:
if value is None:
return ""
normalized = value.normalize()
return "0" if normalized == 0 else format(normalized, "f")
def _instrument(row: _Row) -> InstrumentRef | None:
"""An `InstrumentRef` from a `Symbol` that may be an ISIN, a ticker or nothing at all."""
symbol = row.symbol
if not symbol:
return None
meta: dict[str, Any] = {}
if row.exchange:
meta["exchange"] = row.exchange
is_bond = _looks_like_bond(symbol)
if _looks_like_isin(symbol):
return InstrumentRef(
isin=symbol,
currency=row.currency,
asset_class_hint="bond" if is_bond else None,
meta=meta,
)
return InstrumentRef(
ticker=symbol,
currency=row.currency,
asset_class_hint="bond" if is_bond else None,
meta=meta,
)
def _looks_like_isin(symbol: str) -> bool:
"""A 12-character code with a valid ISIN check digit, excluding OFZ secids.
The checksum matters: `SIBN6P4` and `SBER` fail the shape, but a shape test alone would
happily call `SU26212RMFS9` an ISIN — and that one even passes the checksum, which is
why the OFZ secid pattern is excluded by name rather than by arithmetic.
"""
if not _ISIN_SHAPE.match(symbol) or _OFZ_SECID.match(symbol):
return False
digits = "".join(str(int(ch, 36)) for ch in symbol)
total = 0
for i, ch in enumerate(reversed(digits)):
n = int(ch)
if i % 2 == 1:
n *= 2
if n > 9:
n -= 9
total += n
return total % 10 == 0
def _looks_like_bond(symbol: str) -> bool:
"""Bond-shaped symbol: a real ISIN, or the OFZ secid MOEX and brokers both print.
Deliberately narrow. It only feeds a *hint*: a false positive would tell ingest to look
for a coupon where a dividend was paid, and ingest would believe it until the instrument
resolves. Papers with neither shape (a custom holding like `SIBN6P4`, which really is a
bond) simply get no hint and are classified from the resolved instrument instead.
"""
return bool(_OFZ_SECID.match(symbol)) or _looks_like_isin(symbol)
def _is_balance_adjustment(note: str) -> bool:
lowered = note.lower()
return any(marker in lowered for marker in BALANCE_ADJUSTMENT_MARKERS)
def _portfolio_name(filename: str) -> str:
"""The portfolio the export belongs to, dug out of `Snowball_Export_<name>_<date>.csv`.
It is not an account number and must never be mistaken for one — see the warning in
`_finalize_warnings`. When the filename says nothing, its stem is still a better label
than an empty string, because the user sees it in the import preview.
"""
stem = filename.rsplit("/", 1)[-1]
if "." in stem:
stem = stem.rsplit(".", 1)[0]
match = _FILENAME.match(stem)
raw = match.group("name") if match else stem
return _NAME_EDGE.sub("", raw.strip()) or stem
def _finalize_warnings(report: ParsedReport, acc: _Accumulator) -> None:
"""Everything the importer must be told before it commits this file."""
for event_type, count in sorted(acc.unknown_events.items()):
report.warnings.append(f"неизвестный тип события {event_type!r}: пропущено строк — {count}")
report.meta["unknown_events"] = dict(acc.unknown_events)
report.warnings.append(
"выгрузка не содержит номера счёта: события покрывают все брокерские счета сразу, "
f"целевой счёт обязан указать пользователь (портфель из имени файла — "
f"{report.account_external_id!r}); у счёта один primary_event_source, поэтому "
"почти все события лягут со status = shadow и годятся как сверка, а не как леджер"
)
report.warnings.append(
"формат не содержит закрывающих позиций и остатков денег — сверка positions_end / "
"cash_end по этой выгрузке невозможна"
)
if acc.balance_adjustments:
report.warnings.append(
f"правок остатка, исключённых из леджера: {len(acc.balance_adjustments)} "
"(см. meta['balance_adjustments'])"
)
if acc.manual_prices:
report.warnings.append(
f"ручных цен для price_manual: {len(acc.manual_prices)} (см. meta['manual_prices'])"
)
__all__ = [
"BALANCE_ADJUSTMENT_MARKERS",
"EVENT_KINDS",
"NON_LEDGER_EVENTS",
"REQUIRED_COLUMNS",
"UniversalCsvParser",
]
@@ -0,0 +1,48 @@
"""Which parsers exist and which one claims a given upload (plan §2: «список в registry.py»).
Order matters: `pick` returns the first parser whose `sniff` accepts the bytes, so a narrow
format (a broker's own HTML) must precede a broad one (any CSV). Entry points would buy
nothing here — the set of parsers is known at build time.
"""
from __future__ import annotations
from fintracker.sources.reports.base import ReportParser
from fintracker.sources.reports.csv_universal import UniversalCsvParser
from fintracker.sources.reports.sber import SberHtmlParser
from fintracker.sources.reports.vtb import VtbXlsxParser
#: Narrow formats first, broad ones last: the universal CSV recognises any file with its
#: column set, so it must not get a chance to claim a broker's own export.
PARSERS: list[ReportParser] = [
SberHtmlParser(),
VtbXlsxParser(),
UniversalCsvParser(),
]
def register(parser: ReportParser) -> ReportParser:
PARSERS.append(parser)
return parser
def pick(data: bytes, filename: str) -> ReportParser | None:
"""The first parser that recognises the file, or None when no format matches."""
for parser in PARSERS:
try:
if parser.sniff(data, filename):
return parser
except Exception:
continue
return None
def get(name: str) -> ReportParser:
for parser in PARSERS:
if parser.name == name:
return parser
raise KeyError(name)
def names() -> list[str]:
return [p.name for p in PARSERS]
@@ -0,0 +1,11 @@
"""Sber broker report parsers.
Sber hands out the same report as HTML and as xlsx; the HTML one is what the user actually
downloads, so it is what exists here. A future xlsx parser sits next to it and shares
nothing but the `ReportParser` protocol — the two layouts differ enough that a common base
would be a fiction.
"""
from fintracker.sources.reports.sber.html import SberHtmlParser, event_dedupe_key
__all__ = ["SberHtmlParser", "event_dedupe_key"]
@@ -0,0 +1,812 @@
"""Sber's «Отчет брокера» in HTML → `ParsedReport` (plan §2, фаза 3).
The file is a printout, not an export: nine `<table>` elements, each introduced by a bold
`<p>`, with two-level headers, footnote markers glued into header cells, and «Итого» rows
mixed in with the data. So the parser keys off the section *title* and off the *cell count*
of a row, never off a column index taken from the header — the header of the portfolio
section alone spans three rows and eighteen columns.
Four decisions are worth stating, because each of them is a choice between two readings of
the same numbers and the wrong one silently doubles money in the ledger.
**1. Commission is capitalised into the trade, never emitted on its own.** Sber prints the
same fee twice: as «Комиссия Брокера»/«Комиссия Биржи» columns on the trade row, and again
as a dated «Комиссия Брокера от DD.MM.YYYY» line in the cash movement section (aggregated
per settlement day — 22.04's 0.60 and 1.27 arrive as one 1.87 line). Both renderings are
verified to agree on the fixtures: the trade columns total 138.03 / 19.54, and the cash
lines total exactly the same. Only the trade columns are kept, as `BrokerEvent.fee`, which
`ledger/lots.py` capitalises into the lot cost; emitting the cash lines as well would put
that same money into the ledger a second time as a separate `commission` outflow. The
parser re-runs this arithmetic on every file and warns when the two sides disagree, because
a disagreement means one of the two carries a fee the other does not, and the choice would
then have to be revisited.
**2. «Сделка от DD.MM.YYYY» lines are dropped.** They are the T+1 cash settlement of a
trade already recorded from the trades section, netted per day (the 23.04 line nets a sale
and a purchase). Emitting them would double every trade's cash effect.
**3. «Перевод д/с с договора X» becomes a `deposit` carrying
`meta["internal_transfer_from"]`.** Economically it *is* a deposit for this account, and
dropping it would break the cash reconciliation — Sber's own «Пополнение счета» summary
counts it. But the money came from another agreement of the same broker, which the user may
later add as its own account; at that point the same rubles would be a withdrawal there and
a deposit here, and XIRR would see one external flow where there is none. The counterparty
number is therefore preserved on the event so `ledger/matching.py` can pair the two legs
instead of guessing from amounts and dates.
**4. «Информация о зачислениях денежных средств на ИИС» is read but never emitted.** That
table is cumulative *for the calendar year*, not for the report period: the August file
lists top-ups back to February. Feeding it to the ledger would inflate every account whose
history is imported from more than one file.
Two further notes on identity. Trades carry Sber's own deal number, stable across
re-exports, so they dedupe on `trade_dedupe_key`. Cash rows have no number, so their
`seq` is their index among the rows of the same `(kind, trade_date, amount)` *within the
report* — not their line number, which differs between a full-history file and a monthly
one describing the same operation.
What the format does not contain at all, on either fixture: dividends, taxes, and any
coupon section (only footnote ⁸ mentions coupon income). A «Купонный доход» table is
handled generically if one ever appears, and its absence is recorded in `warnings` rather
than assumed away.
"""
from __future__ import annotations
import re
from collections import Counter
from dataclasses import dataclass, field, replace
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from typing import Any
import lxml.html
from lxml.html import HtmlElement
from fintracker.models.ledger import EventKind
from fintracker.sources.reports.base import (
BrokerEvent,
CashEnd,
InstrumentRef,
ParsedReport,
ParseError,
PositionEnd,
fingerprint_key,
trade_dedupe_key,
)
BROKER = "sber"
SOURCE = "report_sber"
#: Whitespace Sber uses as a thousands separator, plus the ordinary space.
_SPACES = dict.fromkeys(map(ord, "  "), None)
_PERIOD_RE = re.compile(r"за период с\s+(\d{2}\.\d{2}\.\d{4})\s+по\s+(\d{2}\.\d{2}\.\d{4})")
#: «Договор на ведение индивидуального инвестиционного счета S930W42 от 11.02.2026» — the
#: number is simply the token before «от DD.MM.YYYY», which also fits «Договор … № 12345 от».
_AGREEMENT_RE = re.compile(r"Договор[^\n]{0,200}?(\S+)\s+от\s+(\d{2}\.\d{2}\.\d{4})")
_TRADING_CODE_RE = re.compile(r"Торговый код:\s*(\S+)")
_TRANSFER_FROM_RE = re.compile(r"Перевод д/с с договора\s+([^\s,]+)")
_TRANSFER_TO_RE = re.compile(r"Перевод д/с[^,]{0,80}на договор\s+([^\s,]+)")
#: Section titles, longest-first so «Движение денежных средств за период» is not mistaken
#: for «Денежные средства».
_SECTIONS: tuple[tuple[str, str], ...] = (
("Оценка активов", "assets"),
("Сводная информация по движению денежных средств", "summary"),
("Информация о зачислениях денежных средств на ИИС", "iis"),
("Портфель Ценных Бумаг", "portfolio"),
("Движение денежных средств за период", "cash_flow"),
("Денежные средства", "cash"),
("Сделки купли/продажи ценных бумаг", "trades"),
("Справочник Ценных Бумаг", "securities"),
)
#: «Вид, Категория, Тип» of the securities directory → `InstrumentRef.asset_class_hint`.
#: Checked as substrings in this order: «Биржевой фонд» must win over «фонд» inside a name.
_ASSET_CLASS_HINTS: tuple[tuple[str, str], ...] = (
("биржевой фонд", "etf"),
("инвестиционный пай", "fund"),
("пай", "fund"),
("облигаци", "bond"),
("акци", "share"),
("депозитарная расписка", "share"),
)
def _text(el: HtmlElement) -> str:
return " ".join(el.text_content().split())
def _decimal(raw: str) -> Decimal | None:
"""A Sber number, or None for an empty cell. Never float — not even transiently."""
cleaned = raw.translate(_SPACES).replace(",", ".").lstrip("+")
if not cleaned or cleaned == "-":
return None
try:
return Decimal(cleaned)
except InvalidOperation:
return None
def _date(raw: str) -> date | None:
try:
return datetime.strptime(raw.strip(), "%d.%m.%Y").date()
except ValueError:
return None
def _is_numbering(cells: list[str]) -> bool:
"""The «1 2 3 …» row Sber prints under every header to number the columns."""
return len(cells) > 1 and all(c.isdigit() for c in cells)
def _is_total(cells: list[str]) -> bool:
return bool(cells) and cells[0].startswith("Итого")
@dataclass
class _Directory:
"""The securities reference, indexed for enrichment of trades and positions."""
refs: list[InstrumentRef] = field(default_factory=list)
by_ticker: dict[str, InstrumentRef] = field(default_factory=dict)
by_isin: dict[str, InstrumentRef] = field(default_factory=dict)
by_name: dict[str, InstrumentRef] = field(default_factory=dict)
def add(self, ref: InstrumentRef) -> None:
self.refs.append(ref)
if ref.ticker:
self.by_ticker.setdefault(ref.ticker, ref)
if ref.isin:
self.by_isin.setdefault(ref.isin, ref)
if ref.name:
self.by_name.setdefault(ref.name, ref)
class SberHtmlParser:
"""Reads one «Отчет брокера» HTML file. Pure: bytes in, `ParsedReport` out."""
broker = BROKER
name = SOURCE
formats: tuple[str, ...] = ("html", "htm")
version = "1"
def sniff(self, data: bytes, filename: str) -> bool:
"""True for a Sber broker report. Content-based — the filename is a hash for VTB
and a template name for Sber, so neither is evidence of anything."""
try:
head = data[:1_000_000].decode("utf-8", errors="ignore")
except Exception: # pragma: no cover - decode with errors="ignore" does not raise
return False
if "<html" not in head.lower():
return False
if "Отчет брокера" not in head:
return False
return any(
marker in head
for marker in ("Сделки купли/продажи ценных бумаг", "Портфель Ценных Бумаг")
)
def parse(self, data: bytes, filename: str) -> ParsedReport:
return _Parse(data, filename).run()
parser = SberHtmlParser()
def event_dedupe_key(report: ParsedReport, event: BrokerEvent) -> str:
"""The plan's §1.6 A key for one parsed row.
Lives next to the parser because only the parser knows which of its rows carry a deal
number: a trade keys on Sber's number and nothing else, while a cash row has to fall
back on its economic fingerprint plus the `seq` the parser assigned.
"""
if event.trade_no:
return trade_dedupe_key(report.broker, report.account_external_id, event.trade_no)
return fingerprint_key(
report.broker,
report.account_external_id,
event.kind,
event.instrument.key() if event.instrument else "",
event.trade_date,
event.quantity,
event.price,
event.currency,
amount=event.amount,
seq=event.seq,
)
class _Parse:
"""One file's parse. A short-lived object so the row counter and the warning list do
not have to be threaded through a dozen functions."""
def __init__(self, data: bytes, filename: str) -> None:
self.data = data
self.filename = filename
self.warnings: list[str] = []
self.meta: dict[str, Any] = {"filename": filename}
self.line_no = 0
self.directory = _Directory()
self.events: list[BrokerEvent] = []
self.positions: list[PositionEnd] = []
self.cash: list[CashEnd] = []
self.summary: dict[str, Decimal] = {}
#: Fee lines of the cash section, kept only to check them against the trade columns.
self.cash_fees = Decimal(0)
self.trade_fees = Decimal(0)
# --- driver -----------------------------------------------------------------
def run(self) -> ParsedReport:
root = self._root()
body = root.body if root.body is not None else root
text = _text(body)
period_from, period_to = self._period(text)
account = self._account(text)
sections = self._sections(body)
# The securities directory is the last section but the first one needed: trades name
# a paper by ticker only, positions by ISIN only, and both want the asset class.
for title, kind, table in sections:
if kind == "securities":
self._parse_securities(title, table)
for title, kind, table in sections:
handler = {
"summary": self._parse_summary,
"iis": self._parse_iis,
"portfolio": self._parse_portfolio,
"cash": self._parse_cash,
"cash_flow": self._parse_cash_flow,
"trades": self._parse_trades,
"coupons": self._parse_coupons,
# "securities" is deliberately absent: it was already read above, and
# reading it twice would duplicate every instrument reference.
}.get(kind)
if handler is not None:
handler(title, table)
self._check_fees()
self._check_deposits()
if not any(kind == "coupons" for _, kind, _ in sections):
self.warnings.append(
"секции «Купонный доход» в отчёте нет — купонные выплаты могли прийти только "
"строкой движения денежных средств"
)
report = ParsedReport(
broker=BROKER,
account_external_id=account,
period_from=period_from,
period_to=period_to,
parser_version=SberHtmlParser.version,
events=self.events,
positions_end=self.positions,
cash_end=self.cash,
instruments=list(self.directory.refs),
warnings=self.warnings,
meta=self.meta,
)
return report
def _root(self) -> HtmlElement:
if not self.data.strip():
raise ParseError(f"{self.filename}: пустой файл")
try:
root = lxml.html.fromstring(self.data)
except Exception as exc: # lxml raises several unrelated types on garbage input
raise ParseError(f"{self.filename}: не разбирается как HTML: {exc}") from exc
if root is None:
raise ParseError(f"{self.filename}: не разбирается как HTML")
return root
def _period(self, text: str) -> tuple[date, date]:
found = _PERIOD_RE.search(text)
if not found:
raise ParseError(f"{self.filename}: не найден период «за период с … по …»")
start, end = _date(found.group(1)), _date(found.group(2))
if start is None or end is None:
raise ParseError(f"{self.filename}: период не разбирается: {found.group(0)}")
return start, end
def _account(self, text: str) -> str:
found = _AGREEMENT_RE.search(text)
if found:
self.meta["agreement_opened_at"] = found.group(2)
opened = _date(found.group(2))
if opened is not None:
self.meta["opened_at"] = opened.isoformat()
return found.group(1).lstrip("").strip()
code = _TRADING_CODE_RE.search(text)
if code:
self.warnings.append("номер договора не найден, взят «Торговый код»")
return code.group(1)
raise ParseError(f"{self.filename}: не найден номер договора")
def _sections(self, body: HtmlElement) -> list[tuple[str, str, HtmlElement]]:
"""Pair each table with the bold `<p>` that introduces it.
A title is consumed by the first table after it, so the unlabelled signature table
at the end does not get parsed as a second securities directory.
"""
found: list[tuple[str, str, HtmlElement]] = []
pending: str | None = None
seen: list[HtmlElement] = []
for el in body.iter("p", "table"):
if el.tag == "p":
title = _text(el)
if title:
pending = title
continue
if any(el in table.iterdescendants("table") for table in seen):
continue # a nested table is part of its parent's rows
seen.append(el)
title, pending = pending, None
if title is None:
if "Подпись" not in _text(el):
self.warnings.append("таблица без заголовка пропущена")
continue
kind = self._section_kind(title)
if kind is None:
self.warnings.append(f"неизвестная секция: {title[:120]}")
continue
found.append((title, kind, el))
return found
@staticmethod
def _section_kind(title: str) -> str | None:
if "Купонный доход" in title:
return "coupons"
for prefix, kind in _SECTIONS:
if title.startswith(prefix):
return kind
return None
# --- row plumbing -----------------------------------------------------------
def _rows(self, table: HtmlElement, width: int, section: str) -> list[list[str]]:
"""Data rows of `width` cells, with headers, numbering, «Площадка:» and «Итого»
filtered out. Anything else of an unexpected shape becomes a warning, never a
silent skip — a column added by Sber must be visible, not absorbed."""
rows: list[list[str]] = []
for tr in table.iter("tr"):
cells = [_text(td) for td in tr.iterchildren("td", "th")]
if not cells or not any(cells):
continue
if _is_numbering(cells) or _is_total(cells):
continue
if len(cells) == 1:
continue # «Площадка: Фондовый рынок» and other spanning captions
if len(cells) != width and not any(any(ch.isdigit() for ch in c) for c in cells):
continue # an upper header row of a two-level header: «Начало периода» …
if len(cells) != width:
self.warnings.append(
f"{section}: строка из {len(cells)} ячеек вместо {width}: {cells[:3]}"
)
continue
rows.append(cells)
return rows
@staticmethod
def _drop_header(rows: list[list[str]], first_cell: str) -> list[list[str]]:
return [r for r in rows if r[0] != first_cell]
def _next_line(self) -> int:
self.line_no += 1
return self.line_no
def _total_row(self, table: HtmlElement) -> list[str] | None:
for tr in table.iter("tr"):
cells = [_text(td) for td in tr.iterchildren("td", "th")]
if _is_total(cells):
return cells
return None
# --- sections ---------------------------------------------------------------
def _parse_summary(self, title: str, table: HtmlElement) -> None:
"""«Сводная информация …»: Описание | Сумма | Валюта — self-check material only."""
for cells in self._drop_header(self._rows(table, 3, title), "Описание"):
value = _decimal(cells[1])
if value is not None:
self.summary[cells[0]] = value
self.meta["summary"] = {k: str(v) for k, v in self.summary.items()}
def _parse_iis(self, title: str, table: HtmlElement) -> None:
"""Deliberately not emitted: the table is cumulative for the calendar year, so the
August file repeats February's top-ups and importing both files would double them."""
rows = self._drop_header(self._rows(table, 6, title), "Год")
self.meta["iis_contributions_ignored"] = len(rows)
def _parse_portfolio(self, title: str, table: HtmlElement) -> None:
"""«Портфель Ценных Бумаг» → `positions_end`.
18 columns: 0 name, 1 ISIN, 2 currency, 3-7 opening, 8-12 closing, 13-14 change,
15-17 planned. Closing is what reconciliation compares against, so only 8-12 are read.
"""
for cells in self._drop_header(self._rows(table, 18, title), "Наименование"):
qty = _decimal(cells[8])
if qty is None:
self.warnings.append(f"{title}: позиция без количества: {cells[0]}")
continue
self._next_line()
isin = cells[1] or None
known = self.directory.by_isin.get(isin or "") or self.directory.by_name.get(cells[0])
self.positions.append(
PositionEnd(
instrument=InstrumentRef(
isin=isin,
ticker=known.ticker if known else None,
name=cells[0] or None,
currency=cells[2] or None,
asset_class_hint=known.asset_class_hint if known else None,
source_key=f"ISIN:{isin}" if isin else f"NAME:{cells[0]}",
),
qty=qty,
price=_decimal(cells[10]),
market_value=_decimal(cells[11]),
currency=cells[2] or None,
accrued_interest=_decimal(cells[12]),
)
)
def _parse_cash(self, title: str, table: HtmlElement) -> None:
"""«Денежные средства» → `cash_end`: column 5 is the closing balance per currency."""
for cells in self._drop_header(self._rows(table, 9, title), "Торговая площадка"):
balance = _decimal(cells[5])
if balance is None:
self.warnings.append(f"{title}: остаток не разобран: {cells[:3]}")
continue
self._next_line()
self.cash.append(CashEnd(currency=cells[1], balance=balance))
def _parse_cash_flow(self, title: str, table: HtmlElement) -> None:
"""«Движение денежных средств за период» → deposits, withdrawals and payouts.
Trade settlements and fee lines are dropped here (see the module docstring); what
remains is money crossing the account boundary, which is what XIRR is computed on.
"""
rows = self._drop_header(self._rows(table, 6, title), "Дата")
credits_total = Decimal(0)
debits_total = Decimal(0)
staged: list[BrokerEvent] = []
for cells in rows:
when = _date(cells[0])
if when is None:
self.warnings.append(f"{title}: дата не разобрана: {cells[:3]}")
continue
self._next_line()
description = cells[2]
currency = cells[3] or "RUB"
credit = _decimal(cells[4]) or Decimal(0)
debit = _decimal(cells[5]) or Decimal(0)
credits_total += credit
debits_total += debit
amount = credit - debit
if description.startswith("Сделка от"):
continue # settlement of a trade already taken from the trades section
if description.startswith(("Комиссия Брокера", "Комиссия Биржи")):
self.cash_fees += debit - credit
continue # capitalised into the trade's `fee`
kind, meta = self._classify_cash(description, amount)
if kind is None:
self.warnings.append(f"{title}: неизвестная операция «{description}»")
kind = EventKind.other
meta["section"] = title
staged.append(
BrokerEvent(
kind=kind,
trade_date=when,
amount=amount,
currency=currency,
description=description,
raw_line_no=self.line_no,
meta=meta,
)
)
self._check_cash_flow_total(title, table, credits_total, debits_total)
self.events.extend(self._numbered(staged))
@staticmethod
def _classify_cash(
description: str, amount: Decimal
) -> tuple[EventKind | None, dict[str, Any]]:
"""Description → kind. Sber writes free text, so this matches on the stable head of
each phrase and refuses to guess: an unrecognised row becomes `other` plus a warning
rather than a deposit inferred from its sign."""
meta: dict[str, Any] = {}
lowered = description.lower()
transfer_from = _TRANSFER_FROM_RE.search(description)
if transfer_from:
# Another agreement of the same broker. Kept as a deposit because it really does
# fund this account, but tagged so the other leg can be recognised later and the
# pair not counted as two external flows.
meta["internal_transfer_from"] = transfer_from.group(1)
return (EventKind.deposit if amount >= 0 else EventKind.withdrawal), meta
transfer_to = _TRANSFER_TO_RE.search(description)
if transfer_to:
meta["internal_transfer_to"] = transfer_to.group(1)
return (EventKind.withdrawal if amount <= 0 else EventKind.deposit), meta
if "купон" in lowered:
return EventKind.coupon, meta
if "дивиденд" in lowered:
return EventKind.dividend, meta
if "налог" in lowered:
return (EventKind.tax if amount <= 0 else EventKind.tax_refund), meta
if lowered.startswith("зачисление д/с"):
return EventKind.deposit, meta
if lowered.startswith(("вывод д/с", "списание д/с", "снятие д/с")):
return EventKind.withdrawal, meta
return None, meta
def _parse_trades(self, title: str, table: HtmlElement) -> None:
"""«Сделки купли/продажи ценных бумаг» → buy/sell events.
16 columns: 0 trade date, 1 settle date, 2 time, 3 name, 4 ticker, 5 currency,
6 side, 7 qty, 8 price, 9 amount, 10 accrued interest, 11 broker fee,
12 exchange fee, 13 deal number, 14 comment, 15 status.
"""
gross = Decimal(0)
accrued_total = Decimal(0)
broker_total = Decimal(0)
exchange_total = Decimal(0)
for cells in self._drop_header(self._rows(table, 16, title), "Дата заключения"):
trade_date = _date(cells[0])
qty = _decimal(cells[7])
price = _decimal(cells[8])
amount = _decimal(cells[9])
if trade_date is None or qty is None or amount is None:
self.warnings.append(f"{title}: сделка не разобрана: {cells[:5]}")
continue
accrued = _decimal(cells[10]) or Decimal(0)
broker_fee = _decimal(cells[11]) or Decimal(0)
exchange_fee = _decimal(cells[12]) or Decimal(0)
side = cells[6]
if side.startswith("Покупка"):
kind = EventKind.buy
quantity = qty
cash = -(amount + accrued + broker_fee + exchange_fee)
elif side.startswith("Продажа"):
kind = EventKind.sell
quantity = -qty
cash = amount + accrued - broker_fee - exchange_fee
else:
self.warnings.append(f"{title}: неизвестный вид сделки «{side}»")
continue
gross += amount
accrued_total += accrued
broker_total += broker_fee
exchange_total += exchange_fee
self._next_line()
currency = cells[5] or "RUB"
known = self.directory.by_ticker.get(cells[4]) or self.directory.by_name.get(cells[3])
instrument = InstrumentRef(
isin=known.isin if known else None,
ticker=cells[4] or (known.ticker if known else None),
name=cells[3] or None,
currency=currency,
asset_class_hint=known.asset_class_hint if known else None,
source_key=(f"ISIN:{known.isin}" if known and known.isin else f"TICKER:{cells[4]}"),
)
meta: dict[str, Any] = {"section": title, "status": cells[15] or None}
if cells[2]:
meta["trade_time"] = cells[2]
if cells[14]:
meta["comment"] = cells[14]
self.events.append(
BrokerEvent(
kind=kind,
trade_date=trade_date,
settle_date=_date(cells[1]),
amount=cash,
currency=currency,
instrument=instrument,
quantity=quantity,
price=price,
price_currency=currency,
fee=broker_fee + exchange_fee,
fee_currency=currency,
accrued_interest=accrued or None,
trade_no=cells[13] or None,
description=f"{side} {cells[3]}".strip(),
raw_line_no=self.line_no,
meta=meta,
)
)
self.trade_fees = broker_total + exchange_total
self._check_trades_total(title, table, gross, accrued_total, broker_total, exchange_total)
def _parse_securities(self, title: str, table: HtmlElement) -> None:
"""«Справочник Ценных Бумаг» → `instruments`: 0 name, 1 ticker, 2 ISIN, 3 issuer,
4 kind/category, 5 issue."""
for cells in self._drop_header(self._rows(table, 6, title), "Наименование"):
isin = cells[2] or None
ticker = cells[1] or None
if not isin and not ticker:
self.warnings.append(f"{title}: бумага без ISIN и тикера: {cells[0]}")
continue
self._next_line()
self.directory.add(
InstrumentRef(
isin=isin,
ticker=ticker,
name=cells[0] or None,
asset_class_hint=_asset_class_hint(cells[4]),
source_key=f"ISIN:{isin}" if isin else f"TICKER:{ticker}",
meta={"issuer": cells[3] or None, "issue": cells[5] or None, "kind": cells[4]},
)
)
def _parse_coupons(self, title: str, table: HtmlElement) -> None:
"""A «Купонный доход» table, should Sber ever print one.
Neither fixture contains this section — only footnote ⁸ refers to coupon income —
so the layout is unknown and the parser reads it by header name rather than by
column position, and says so in `warnings` instead of pretending it is verified.
"""
rows = [
[_text(td) for td in tr.iterchildren("td", "th")]
for tr in table.iter("tr")
if any(_text(td) for td in tr.iterchildren("td", "th"))
]
header = next((r for r in rows if any("ата" in c for c in r)), None)
if header is None:
self.warnings.append(f"{title}: не найден заголовок таблицы, секция пропущена")
return
index = {
key: i
for i, cell in enumerate(header)
for key in ("date", "amount", "isin", "name", "qty", "tax")
if _matches_column(cell, key)
}
if "date" not in index or "amount" not in index:
self.warnings.append(f"{title}: нет колонок даты и суммы, секция пропущена")
return
staged: list[BrokerEvent] = []
for cells in rows:
if cells is header or _is_numbering(cells) or _is_total(cells):
continue
if len(cells) != len(header):
continue
when = _date(cells[index["date"]])
amount = _decimal(cells[index["amount"]])
if when is None or amount is None:
continue
self._next_line()
isin = cells[index["isin"]] if "isin" in index else None
known = self.directory.by_isin.get(isin or "")
staged.append(
BrokerEvent(
kind=EventKind.coupon,
trade_date=when,
amount=amount,
currency="RUB",
instrument=known
or (InstrumentRef(isin=isin, source_key=f"ISIN:{isin}") if isin else None),
quantity=_decimal(cells[index["qty"]]) if "qty" in index else None,
tax=_decimal(cells[index["tax"]]) if "tax" in index else None,
description=cells[index["name"]] if "name" in index else None,
raw_line_no=self.line_no,
meta={"section": title},
)
)
self.warnings.append(
f"{title}: секция разобрана эвристикой по названиям колонок, фикстуры для неё нет"
)
self.events.extend(self._numbered(staged))
# --- self-checks ------------------------------------------------------------
@staticmethod
def _numbered(events: list[BrokerEvent]) -> list[BrokerEvent]:
"""Assign `seq` within `(kind, trade_date, amount)` groups.
The point is the overlapping-period case: the same 10 551.18 ₽ transfer of 13.08
is row 24 of the full-history file and row 1 of the August file. Numbering within
the group makes it 0 in both, so the fingerprint key matches and the second import
upserts instead of duplicating.
"""
counter: Counter[tuple[str, date, Decimal]] = Counter()
out: list[BrokerEvent] = []
for event in events:
group = (event.kind.value, event.trade_date, event.amount)
seq = counter[group]
counter[group] += 1
out.append(replace(event, seq=seq))
return out
def _check_trades_total(
self,
title: str,
table: HtmlElement,
gross: Decimal,
accrued: Decimal,
broker_fee: Decimal,
exchange_fee: Decimal,
) -> None:
"""«Итого, RUB | сумма | НКД | комиссия брокера | комиссия биржи»."""
total = self._total_row(table)
if total is None or len(total) < 5:
self.warnings.append(f"{title}: итоговая строка не найдена")
return
for label, stated, computed in (
("сумма сделок", _decimal(total[1]), gross),
("НКД", _decimal(total[2]), accrued),
("комиссия брокера", _decimal(total[3]), broker_fee),
("комиссия биржи", _decimal(total[4]), exchange_fee),
):
if stated is not None and stated != computed:
self.warnings.append(
f"{title}: {label} не сходится с итогом: {computed} против {stated}"
)
def _check_cash_flow_total(
self, title: str, table: HtmlElement, credits: Decimal, debits: Decimal
) -> None:
total = self._total_row(table)
if total is None or len(total) < 3:
self.warnings.append(f"{title}: итоговая строка не найдена")
return
stated_credit, stated_debit = _decimal(total[1]), _decimal(total[2])
if stated_credit is not None and stated_credit != credits:
self.warnings.append(
f"{title}: зачисления не сходятся с итогом: {credits} против {stated_credit}"
)
if stated_debit is not None and stated_debit != debits:
self.warnings.append(
f"{title}: списания не сходятся с итогом: {debits} против {stated_debit}"
)
def _check_fees(self) -> None:
"""The whole basis for capitalising fees: both renderings must state the same money.
If they ever diverge, one of them carries a fee the other does not and dropping the
cash lines would lose it — so the divergence has to surface, not be assumed away.
"""
if self.cash_fees != self.trade_fees:
self.warnings.append(
"комиссии расходятся: в сделках "
f"{self.trade_fees}, отдельными строками движения денег {self.cash_fees}; "
"в события попали только комиссии из сделок"
)
def _check_deposits(self) -> None:
"""«Пополнение счета» of the summary counts transfers from the broker's other
agreements alongside ordinary top-ups, so both kinds are summed here."""
stated = self.summary.get("Пополнение счета")
if stated is None:
return
parsed = sum(
(e.amount for e in self.events if e.kind == EventKind.deposit),
start=Decimal(0),
)
if parsed != stated:
self.warnings.append(f"пополнения не сходятся со сводкой: {parsed} против {stated}")
def _asset_class_hint(kind: str) -> str | None:
lowered = kind.lower()
for marker, hint in _ASSET_CLASS_HINTS:
if marker in lowered:
return hint
return None
def _matches_column(cell: str, key: str) -> bool:
lowered = cell.lower()
return {
"date": "дата" in lowered,
"amount": "сумма" in lowered and "налог" not in lowered,
"isin": "isin" in lowered,
"name": "наименование" in lowered,
"qty": "количество" in lowered,
"tax": "налог" in lowered,
}[key]
@@ -0,0 +1,10 @@
"""VTB brokerage report parsers.
`common.py` holds the format-independent half (what a row means, how a key is built),
`xlsx.py` the container-specific half. A pdf rendering of the same report would be a third
module reusing `common.py` — the contract it has to honour is written down there.
"""
from fintracker.sources.reports.vtb.xlsx import VtbXlsxParser
__all__ = ["VtbXlsxParser"]
@@ -0,0 +1,584 @@
"""Everything in a VTB report that does not depend on the container it arrived in.
VTB renders one and the same back-office report as xlsx and as pdf. The wording of every
section, the vocabulary of its columns and the sign conventions are identical in both; only
the way one gets at a value differs — a cell address versus a position on a page. So the
split here is deliberate: `xlsx.py` is allowed to know about worksheets and merged cells and
nothing else, while this module owns the meaning — how «EQMX ETF, 3965, RU000A101EJ5» turns
into an `InstrumentRef`, what «Вид сделки: Продажа» does to the sign of a quantity, and how a
row becomes a `BrokerEvent` in `event`'s conventions.
**The contract a future `vtb/pdf.py` must satisfy.** Two files describing the same month
must produce the same `dedupe_key` for the same deal, otherwise a re-import doubles the
ledger. The key of a trade is `sha1(broker | account_external_id | trade_no)`, so a pdf
parser has to extract exactly three things identically:
* `broker` — the constant `BROKER` here, never derived from the file;
* `account_external_id` — the **agreement number** («№ и дата Соглашения»), the same string
in both renderings, and not the personal account number («Лицевой счет»), which is
personal data we never carry;
* `trade_no` — column «№ сделки» (`Z` in xlsx, e.g. `B17476399721`), **not** «№ сделки у
организатора торгов» (`AC`, `17476399721`). The key is already scoped by broker and
account, so exchange-wide uniqueness buys nothing; what matters is that VTB prints the
same «№ сделки» in every rendering of its own report, while the organiser's number is
absent for over-the-counter deals in some formats and is the exchange's identifier, not
VTB's.
Cash rows carry no deal number and fall back to `fingerprint_key`, which needs the date, the
signed amount, the currency and a stable `seq` — for a pdf parser `seq` must be the row's
index **inside its own section**, counted the same way as here, not a page line number.
Everything else a pdf parser extracts (the venue, the order number, the counterparty) lands
in `meta` and never touches a key, so the two formats may legitimately differ there.
"""
from __future__ import annotations
import re
from datetime import date, datetime, timedelta
from decimal import Decimal, InvalidOperation
from typing import Any
from fintracker.models.ledger import EventKind
from fintracker.sources.reports.base import (
BrokerEvent,
InstrumentRef,
fingerprint_key,
trade_dedupe_key,
)
BROKER = "vtb"
"""`Broker` value and the first component of every dedupe key this parser builds."""
PARSER_NAME = "report_vtb"
#: VTB prints the pre-ISO Soviet code for the rouble everywhere — in balances, in the price
#: currency of a position and in the settlement currency of a deal. The project stores
#: three-letter ISO 4217 codes (`String(3)` columns, `fx_rate_daily.ccy`), so the alias is
#: applied at the parser boundary and `RUR` never reaches the ledger.
CURRENCY_ALIASES = {"RUR": "RUB"}
#: «Вид сделки» / «Тип операции» of a securities row -> ledger kind. The report states the
#: direction in words and keeps the quantity positive; the sign is ours to apply.
TRADE_SIDES: dict[str, EventKind] = {
"покупка": EventKind.buy,
"продажа": EventKind.sell,
}
#: «Тип операции» in «Движение денежных средств» -> ledger kind, or None for a row that must
#: NOT become an event. «Сальдо расчетов по сделкам с ценными бумагами» is exactly that: it
#: is the money leg of deals already emitted from the trades table, netted per settlement
#: day, and emitting it would count every purchase twice — once as `buy.amount`, once as a
#: cash movement. «Вознаграждение брокера» is also None here, but for a different reason:
#: the fee is capitalised into the deal that caused it (see `broker_fee_residual`).
CASH_OPERATIONS: dict[str, EventKind | None] = {
"зачисление денежных средств": EventKind.deposit,
"списание денежных средств": EventKind.withdrawal,
"вывод денежных средств": EventKind.withdrawal,
"перевод денежных средств": EventKind.transfer_in,
"налог на доходы физических лиц": EventKind.tax,
"дивиденды": EventKind.dividend,
"купонный доход": EventKind.coupon,
"сальдо расчетов по сделкам с ценными бумагами": None,
"вознаграждение брокера": None,
"вознаграждение депозитария": None,
}
#: Group headers inside «Отчёт об остатках ценных бумаг» -> `InstrumentRef.asset_class_hint`.
#: A hint only: the user confirms the class when a pending instrument is resolved.
ASSET_CLASS_HINTS: dict[str, str] = {
"пай": "etf",
"акция": "share",
"адр": "share",
"облигация": "bond",
"еврооблигация": "bond",
"депозитарная расписка": "share",
}
#: Sub-headers and market names that sit in the first column of a data section and are not
#: rows: skipping them silently is correct, warning about them would be noise.
SECTION_SUBHEADERS = frozenset(
{
"основной рынок",
"срочный рынок",
"внебирж. рынок",
"внебиржевой рынок",
"итого",
"итого:",
}
)
_PERIOD_RE = re.compile(
r"за период с\s+(\d{2}\.\d{2}\.\d{4})\s+по\s+(\d{2}\.\d{2}\.\d{4})", re.IGNORECASE
)
_ISIN_RE = re.compile(r"\b([A-Z]{2}[A-Z0-9]{9}\d)\b")
_TICKER_RE = re.compile(r"^[A-Z][A-Z0-9]{1,11}$")
_DATE_RE = re.compile(r"^(\d{2})\.(\d{2})\.(\d{4})")
#: Excel's day zero on Windows: serial 1 is 1900-01-01 and serial 60 is the mythical
#: 1900-02-29, so counting from 1899-12-30 is right for everything after that.
_EXCEL_EPOCH = date(1899, 12, 30)
def normalize_text(value: Any) -> str:
"""Lowercased, whitespace-collapsed, ё-folded — the form every label is matched in.
VTB wraps header captions with newlines to fit a column and is inconsistent about «ё»
(«субсчёту» in one section title, «отчет» in another), so matching raw strings would
make the parser fail on cosmetic edits to the template.
"""
if value is None:
return ""
text = str(value).replace("\xa0", " ").replace("ё", "е").replace("Ё", "Е")
return re.sub(r"\s+", " ", text).strip().lower()
def normalize_currency(value: Any) -> str | None:
"""`RUR` -> `RUB`; anything else uppercased as printed. None for an empty cell."""
text = str(value or "").strip().upper()
if not text:
return None
return CURRENCY_ALIASES.get(text, text)
def to_decimal(value: Any) -> Decimal | None:
"""Money and quantities as `Decimal`, never through `Decimal(float)`.
openpyxl hands back a `float` for every numeric cell, and `Decimal(1.24)` would store
1.2399999999999999911182158029987476766109466552734375. Going through `str()` keeps the
number the report actually printed. Strings arrive too (VTB stores the position price as
text) and may use a comma or a thin space, which the pdf rendering will also do.
"""
if value is None or isinstance(value, bool):
return None
if isinstance(value, Decimal):
return value
if isinstance(value, int):
return Decimal(value)
if isinstance(value, float):
return Decimal(str(value))
text = str(value).replace("\xa0", "").replace(" ", "").replace(",", ".").strip()
if not text or text in {"-", ""}:
return None
try:
return Decimal(text)
except InvalidOperation:
return None
def to_date(value: Any) -> date | None:
"""A cell that should hold a date, whatever the writer chose to put there.
openpyxl usually applies the number format and returns `datetime`, but a cell VTB left
unformatted comes back as the bare serial (`46247.0`, or `46247.664…` when a time is
attached), and a pdf will hand over `17.09.2026` as text. All three are accepted.
"""
if value is None or isinstance(value, bool):
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
if isinstance(value, (int, float)):
serial = float(value)
if serial <= 0:
return None
return _EXCEL_EPOCH + timedelta(days=int(serial))
text = str(value).strip()
match = _DATE_RE.match(text)
if match:
day, month, year = (int(part) for part in match.groups())
try:
return date(year, month, day)
except ValueError:
return None
return None
def to_datetime(value: Any) -> datetime | None:
"""Same as `to_date`, but keeps the time of a deal when the cell carries one."""
if isinstance(value, datetime):
return value
if isinstance(value, (int, float)) and not isinstance(value, bool):
serial = float(value)
if serial <= 0:
return None
return datetime(_EXCEL_EPOCH.year, _EXCEL_EPOCH.month, _EXCEL_EPOCH.day) + timedelta(
days=serial
)
if isinstance(value, str):
text = value.strip()
for fmt in ("%d.%m.%Y %H:%M:%S", "%d.%m.%Y %H:%M", "%d.%m.%Y"):
try:
return datetime.strptime(text, fmt)
except ValueError:
continue
day = to_date(value)
return datetime(day.year, day.month, day.day) if day else None
def parse_period(text: str) -> tuple[date, date] | None:
"""The reporting period out of the title line.
The period lives in the title and nowhere else: the file name is a GUID
(`reportd0235f1e-…​.xlsx`), and the section rows only show the dates on which something
happened, which for a quiet month is not the period at all.
"""
match = _PERIOD_RE.search(str(text or ""))
if not match:
return None
first, second = (to_date(part) for part in match.groups())
if first is None or second is None:
return None
return first, second
def parse_security(
title: Any,
*,
asset_class_hint: str | None = None,
venue: str | None = None,
currency: str | None = None,
extra: dict[str, Any] | None = None,
) -> InstrumentRef:
"""«EQMX ETF, 3965, RU000A101EJ5» -> an `InstrumentRef`.
VTB glues the name, the state registration number and the ISIN into one cell with commas
— and the name itself may contain commas («ПАО "Сбербанк России", ао»), so the fields are
taken from the end: the last comma-separated part that looks like an ISIN is the ISIN,
and a purely numeric or `dash`-shaped part before it is the registration number. What is
left is the name.
The ticker is not printed as a field, but VTB starts an ETF's name with it («EQMX ETF»),
so the first word is taken as a ticker candidate only when it looks like one. It is never
the sole identity: resolution goes by ISIN first, and `source_key` is the ISIN whenever
there is one, so a wrong guess costs nothing.
"""
text = str(title or "").replace("\n", " ").strip()
parts = [part.strip() for part in text.split(",") if part.strip()]
isin: str | None = None
reg_number: str | None = None
for index in range(len(parts) - 1, -1, -1):
match = _ISIN_RE.fullmatch(parts[index].upper())
if match:
isin = match.group(1)
if index > 0 and re.fullmatch(r"[\w\-/]{1,20}", parts[index - 1]):
reg_number = parts[index - 1]
parts = parts[: index - 1]
else:
parts = parts[:index]
break
name = ", ".join(parts).strip() or text or None
ticker: str | None = None
if name:
first_word = name.split()[0].upper()
if _TICKER_RE.fullmatch(first_word):
ticker = first_word
meta: dict[str, Any] = {"title": text}
if reg_number:
meta["reg_number"] = reg_number
if venue:
meta["venue"] = venue
if extra:
meta.update({key: value for key, value in extra.items() if value is not None})
return InstrumentRef(
isin=isin,
ticker=ticker,
name=name,
currency=normalize_currency(currency),
asset_class_hint=asset_class_hint,
source_key=f"ISIN:{isin}" if isin else f"VTB:{text}",
meta=meta,
)
def trade_side_kind(value: Any) -> EventKind | None:
"""«Покупка»/«Продажа» -> `buy`/`sell`; None for a wording we do not know."""
return TRADE_SIDES.get(normalize_text(value))
def cash_operation_kind(value: Any) -> tuple[EventKind | None, bool]:
"""`(kind, known)` for a «Тип операции» of the cash section.
Two different Nones have to be told apart: a row we deliberately drop (`known=True`,
kind None) and a wording nobody has mapped yet (`known=False`), which must reach
`warnings` rather than disappear.
"""
key = normalize_text(value)
if key in CASH_OPERATIONS:
return CASH_OPERATIONS[key], True
return None, False
def build_trade_event(
*,
security: Any,
side: Any,
trade_dt: Any,
settle_date: Any,
quantity: Any,
price: Any,
price_currency: Any,
settlement_currency: Any,
amount_gross: Any,
accrued_interest: Any = None,
fees: tuple[Any, ...] = (),
trade_no: Any = None,
asset_class_hint: str | None = None,
venue: Any = None,
order_no: Any = None,
exchange_trade_no: Any = None,
counterparty: Any = None,
raw_line_no: int = 0,
seq: int = 0,
) -> BrokerEvent:
"""One row of «Заключенные в отчетном периоде сделки» as a ledger event.
Two decisions are baked in here.
**The fee is capitalised into the deal.** VTB prints its commission twice: per deal in
«Комиссия Банка за расчёт/за заключение сделки», and again as a «Вознаграждение брокера»
line in the cash section (the two agree to the kopeck — the parser checks it and warns if
they ever stop agreeing). Only one of them may become an event: `ledger/lots.py`
capitalises `fee` into the cost of the lot, so emitting the cash line as a separate
`commission` event on top would charge the commission twice — once inside the lot's cost
basis, once as a standalone expense. The deal-level figure is the one kept, because it is
the one that knows which lot it belongs to.
**`amount` includes the fee, `fee` stays positive.** That is `event`'s convention:
a buy pays `-(sum + fee)`, a sale receives `+(sum - fee)`, and `fee` is stated positive
so that summing fees never double-counts against the cash effect.
Raises `ValueError` when the row lacks something a trade cannot exist without — the
caller turns that into a warning naming the line, never into a silent skip.
"""
kind = trade_side_kind(side)
if kind is None:
raise ValueError(f"неизвестный вид сделки «{side}»")
day = to_datetime(trade_dt)
if day is None:
raise ValueError("нет даты заключения сделки")
qty = to_decimal(quantity)
if qty is None:
raise ValueError("нет количества")
qty = abs(qty)
gross = to_decimal(amount_gross)
if gross is None:
raise ValueError("нет суммы сделки")
gross = abs(gross)
fee_total = sum((to_decimal(part) or Decimal(0) for part in fees), Decimal(0))
currency = normalize_currency(settlement_currency) or normalize_currency(price_currency)
if currency is None:
raise ValueError("нет валюты расчётов")
if kind is EventKind.buy:
quantity_signed = qty
amount = -(gross + fee_total)
else:
quantity_signed = -qty
amount = gross - fee_total
meta: dict[str, Any] = {"section": "trades", "ts": day.isoformat()}
for key, value in (
("venue", venue),
("order_no", order_no),
("exchange_trade_no", exchange_trade_no),
("counterparty", counterparty),
):
text = str(value).strip() if value is not None else ""
if text:
meta[key] = text
return BrokerEvent(
kind=kind,
trade_date=day.date(),
amount=amount,
currency=currency,
settle_date=to_date(settle_date),
instrument=parse_security(
security,
asset_class_hint=asset_class_hint,
venue=str(venue).strip() if venue else None,
currency=price_currency,
),
quantity=quantity_signed,
price=to_decimal(price),
price_currency=normalize_currency(price_currency),
fee=fee_total if fee_total else None,
fee_currency=currency if fee_total else None,
accrued_interest=to_decimal(accrued_interest),
trade_no=str(trade_no).strip() if trade_no else None,
description=f"{str(side).strip()} {qty} шт.",
raw_line_no=raw_line_no,
seq=seq,
meta=meta,
)
def build_cash_event(
*,
kind: EventKind,
operation: Any,
trade_date: Any,
amount: Any,
currency: Any,
comment: Any = None,
market: str | None = None,
raw_line_no: int = 0,
seq: int = 0,
) -> BrokerEvent:
"""One row of «Движение денежных средств» as a ledger event.
The amount is taken with the sign VTB printed: the column is already signed by its effect
on the account (+26 100 credited, 3.64 charged), which is `event.amount`'s convention.
"""
day = to_date(trade_date)
if day is None:
raise ValueError("нет даты операции")
value = to_decimal(amount)
if value is None:
raise ValueError("нет суммы операции")
code = normalize_currency(currency)
if code is None:
raise ValueError("нет валюты операции")
meta: dict[str, Any] = {"section": "cash", "operation": str(operation).strip()}
if market:
meta["market"] = market
text = str(comment).strip() if comment else ""
return BrokerEvent(
kind=kind,
trade_date=day,
amount=value,
currency=code,
fee=abs(value) if kind is EventKind.commission else None,
fee_currency=code if kind is EventKind.commission else None,
description=text or str(operation).strip() or None,
raw_line_no=raw_line_no,
seq=seq,
meta=meta,
)
def dedupe_key(account_external_id: str, event: BrokerEvent) -> str:
"""The key `ledger/ingest.py` will upsert on — see this module's docstring for the
cross-format contract it encodes."""
if event.trade_no:
return trade_dedupe_key(BROKER, account_external_id, event.trade_no)
instrument_key = event.instrument.key() if event.instrument else ""
return fingerprint_key(
BROKER,
account_external_id,
event.kind,
instrument_key,
event.trade_date,
event.quantity,
event.price,
event.currency,
amount=event.amount,
seq=event.seq,
)
def broker_fee_residual(
cash_fee_total: Decimal, trade_fee_total: Decimal, *, tolerance: Decimal = Decimal("0.005")
) -> Decimal:
"""How much of the brokerage fee the trades table does not account for.
Zero in every report seen so far — VTB's «Вознаграждение брокера» is the sum of the
per-deal commissions to the kopeck. If a report ever charges a fee that belongs to no
deal (a monthly service charge, a depository fee billed under the same wording), the
difference is real money that would otherwise vanish from the ledger, so the caller emits
it as a standalone `commission` event and warns.
"""
residual = cash_fee_total - trade_fee_total
return residual if abs(residual) > tolerance else Decimal(0)
def merge_instruments(refs: list[InstrumentRef]) -> list[InstrumentRef]:
"""Unique instruments in first-seen order, the asset-class hint carried over.
A paper is named in three sections and only the positions table states its class, so the
hint found there is applied to the same ISIN wherever else it appeared.
"""
hints = {ref.key(): ref.asset_class_hint for ref in refs if ref.asset_class_hint}
merged: dict[str, InstrumentRef] = {}
for ref in refs:
key = ref.key()
hint = ref.asset_class_hint or hints.get(key)
current = merged.get(key)
if current is None:
merged[key] = ref if ref.asset_class_hint == hint else _with_hint(ref, hint)
elif current.asset_class_hint is None and hint:
merged[key] = _with_hint(current, hint)
return list(merged.values())
def _with_hint(ref: InstrumentRef, hint: str | None) -> InstrumentRef:
if hint is None or ref.asset_class_hint == hint:
return ref
return InstrumentRef(
isin=ref.isin,
ticker=ref.ticker,
board=ref.board,
name=ref.name,
currency=ref.currency,
asset_class_hint=hint,
source_key=ref.source_key,
meta=dict(ref.meta),
)
def apply_instrument_hints(
events: list[BrokerEvent], refs: list[InstrumentRef]
) -> list[BrokerEvent]:
"""Re-stamp events with the asset-class hint learned from the positions table.
The trades table does not say whether a paper is a share, a unit or a bond; the positions
table does, in its group headers. Both describe the same ISINs, so the hint is filled in
after the whole file is read.
"""
hints = {ref.key(): ref.asset_class_hint for ref in refs if ref.asset_class_hint}
result: list[BrokerEvent] = []
for event in events:
instrument = event.instrument
if instrument is None or instrument.asset_class_hint is not None:
result.append(event)
continue
hint = hints.get(instrument.key())
if hint is None:
result.append(event)
continue
result.append(
BrokerEvent(
kind=event.kind,
trade_date=event.trade_date,
amount=event.amount,
currency=event.currency,
settle_date=event.settle_date,
instrument=_with_hint(instrument, hint),
quantity=event.quantity,
price=event.price,
price_currency=event.price_currency,
fee=event.fee,
fee_currency=event.fee_currency,
tax=event.tax,
tax_currency=event.tax_currency,
accrued_interest=event.accrued_interest,
trade_no=event.trade_no,
description=event.description,
raw_line_no=event.raw_line_no,
seq=event.seq,
meta=dict(event.meta),
)
)
return result
@@ -0,0 +1,704 @@
"""The xlsx rendering of a VTB brokerage report: worksheet in, `ParsedReport` out.
The file is a printed page saved as a spreadsheet, not a data export. Everything lives on a
single sheet called `brokerage_report`, sections follow one another vertically and are
identified only by their Russian title sitting in column B, and every field is a merged
range — which is why values land in columns as scattered as C, F, I, L, O, R, AB, AF. There
is no dimension record either, so `reset_dimensions()` is required before iterating or
openpyxl reports a 1x1 sheet.
Consequently the parser never addresses a grid by offset. It walks rows, switches on the
section title in column B, and inside a section asks a fixed question per column letter —
the letters being the only stable thing in the layout. A row that does not answer the
question (no date where a date must be, no quantity where a quantity must be) ends the
section instead of being force-fitted, and anything unrecognised goes to `warnings`.
What is deliberately NOT emitted as an event, because VTB states the same fact three times:
* «Движение ценных бумаг» is the settlement leg of the deals in the trades table — same
papers, same quantities, one day later. It is used to cross-check quantities and nothing
else.
* «Завершенные в отчетном периоде сделки» repeats «Заключенные» verbatim, deal numbers
included; for a fully settled period the two tables are identical. Emitting it would
double every trade — and because the deal number is the same, the duplicate would not even
be caught by the dedupe key, it would simply be the same event parsed twice. It is used to
assert that the two sets of deal numbers agree.
* «Сальдо расчетов по сделкам с ценными бумагами» in the cash section is the money leg of
those same deals, netted per settlement day.
All of the meaning — signs, kinds, dedupe keys, the instrument string — lives in
`common.py`, so that a future pdf parser reuses it instead of re-deriving it.
"""
from __future__ import annotations
import io
import re
import warnings as warnings_module
import zipfile
from decimal import Decimal
from typing import Any
from openpyxl import load_workbook
from openpyxl.utils import column_index_from_string
from fintracker.models.ledger import EventKind
from fintracker.sources.reports.base import (
BrokerEvent,
CashEnd,
InstrumentRef,
ParsedReport,
ParseError,
PositionEnd,
)
from fintracker.sources.reports.vtb import common
from fintracker.sources.reports.vtb.common import (
BROKER,
normalize_currency,
normalize_text,
to_date,
to_decimal,
)
SHEET_NAME = "brokerage_report"
#: Sections this parser reads, keyed by their normalised title in column B.
SECTIONS: dict[str, str] = {
"сводная информация по субсчету клиента": "summary",
"отчет об остатках денежных средств": "cash_balances",
"движение денежных средств": "cash_flow",
"отчет об остатках ценных бумаг": "positions",
"движение ценных бумаг": "security_moves",
"заключенные в отчетном периоде сделки с ценными бумагами": "trades",
"завершенные в отчетном периоде сделки с ценными бумагами (обязательства прекращены)": (
"trades_completed"
),
}
#: Sections VTB itself lists as possible (the boilerplate paragraph at the end of the file
#: enumerates them) but which this account has never produced. Meeting one is not an error —
#: it is a report with derivatives or currency trades in it — but it must be visible, so it
#: becomes a warning rather than a silent skip.
KNOWN_UNPARSED_SECTIONS = frozenset(
{
"задолженность клиента",
"обязательства/требования по передаче дохода (заблокировано)",
"блокировки / аресты",
"открытые позиции по производным финансовым инструментам",
"обязательства и требования по незавершенным сделкам",
"заключенные в отчетном периоде сделки с иностранной валютой",
"завершенные в отчетном периоде сделки с иностранной валютой (обязательства прекращены)",
"незавершенные в отчетном периоде сделки с ценными бумагами (обязательства не исполнены)",
"незавершенные в отчетном периоде сделки с иностранной валютой "
"(обязательства не исполнены)",
"сделки с производными финансовыми инструментами в отчетном периоде",
"завершенные в отчетном периоде сделки по переносу открытой позиции клиента",
}
)
#: Header labels in column B whose value sits in the first non-empty cell to the right.
_HEADER_FIELDS = {
"№ и дата соглашения": "agreement",
"№ субсчета:": "subaccount",
"на ведение иис": "iis_opened_at",
"дата формирования отчета": "generated_at",
}
_SUMMARY_OPENING = "входящий остаток денежных средств"
_SUMMARY_CLOSING = "исходящий остаток денежных средств"
_SUMMARY_SETTLEMENT = "сальдо расчетов по сделкам с ценными бумагами"
_SUMMARY_BROKER_FEE = "вознаграждение брокера"
_CURRENCY_CODE_RE = re.compile(r"^[a-z]{3}$")
_FOOTNOTE_RE = re.compile(r"^\s*\d+\s*[-–—]\s")
_MONEY_TOLERANCE = Decimal("0.01")
def _cell(row: tuple[Any, ...], column: str) -> Any:
index = column_index_from_string(column) - 1
return row[index] if index < len(row) else None
def _text(row: tuple[Any, ...], column: str) -> str:
value = _cell(row, column)
return "" if value is None else str(value).strip()
def _value_right_of(row: tuple[Any, ...], column: str) -> Any:
"""The first non-empty cell after `column` — the value of a label in a merged layout.
The header fields are printed as «label in B, value somewhere to the right»; in today's
template the value is in I, but the template moves it whenever a label gets longer, and
scanning is cheap.
"""
start = column_index_from_string(column)
for value in row[start:]:
if value is not None and str(value).strip():
return value
return None
def _is_boilerplate(text: str) -> bool:
"""Legal prose and footnotes that trail the report — never a section, never data."""
return bool(_FOOTNOTE_RE.match(text)) or "\n" in text or len(text) > 200
class VtbXlsxParser:
"""`ReportParser` for VTB's xlsx brokerage report."""
broker = BROKER
name = common.PARSER_NAME
formats: tuple[str, ...] = ("xlsx",)
version = "1"
def sniff(self, data: bytes, filename: str) -> bool:
"""Recognise the file by its content only.
The name carries nothing: VTB calls every download `report<guid>.xlsx`. What is
reliable is the workbook's single sheet name (`brokerage_report`, written into
`xl/workbook.xml`) together with the bank's own title string in the shared strings
table. Both are read straight out of the zip, so sniffing costs no parse — and never
raises: an unreadable or foreign file is simply not ours.
"""
del filename # deliberately unused: it is a GUID, it says nothing about the format
try:
if not data.startswith(b"PK\x03\x04"):
return False
with zipfile.ZipFile(io.BytesIO(data)) as archive:
names = set(archive.namelist())
if "xl/workbook.xml" not in names:
return False
workbook = archive.read("xl/workbook.xml").decode("utf-8", "replace")
if SHEET_NAME not in workbook:
return False
for entry in ("xl/sharedStrings.xml", "xl/worksheets/sheet1.xml"):
if entry not in names:
continue
content = archive.read(entry).decode("utf-8", "replace")
if "Банка ВТБ" in content and "за период с" in content:
return True
return False
except Exception:
return False
def parse(self, data: bytes, filename: str) -> ParsedReport:
"""Parse the sheet. `filename` is kept only as traceability in `meta.source_file`:
nothing in the report is derived from it."""
rows = _load_rows(data)
return _Parse(rows, source_file=filename).run()
def _load_rows(data: bytes) -> list[tuple[Any, ...]]:
try:
with warnings_module.catch_warnings():
# The file ships without a default style; openpyxl warns and copes, and the
# warning would otherwise surface on every import the user makes.
warnings_module.simplefilter("ignore")
workbook = load_workbook(io.BytesIO(data), read_only=True, data_only=True)
try:
sheet = (
workbook[SHEET_NAME]
if SHEET_NAME in workbook.sheetnames
else workbook.worksheets[0]
)
# No dimension record in the file: without this the sheet reports 1x1.
sheet.reset_dimensions()
return [tuple(row) for row in sheet.iter_rows(values_only=True)]
finally:
workbook.close()
except ParseError:
raise
except Exception as exc: # любой сбой openpyxl/zip значит «файл нечитаем»
raise ParseError(f"не удалось прочитать xlsx ВТБ: {exc}") from exc
class _Parse:
"""One pass over the sheet plus the cross-checks that follow it.
Kept as a class only because a dozen accumulators would otherwise be threaded through
every handler by hand; there is no state that outlives `run()`.
"""
def __init__(self, rows: list[tuple[Any, ...]], *, source_file: str = "") -> None:
self.rows = rows
self.source_file = source_file
self.warnings: list[str] = []
self.events: list[BrokerEvent] = []
self.positions: list[PositionEnd] = []
self.cash_end: list[CashEnd] = []
self.instruments: list[InstrumentRef] = []
self.summary: dict[str, Decimal] = {}
self.summary_currency: str | None = None
self.section: str | None = None
self.expect_caption = False
self.started = False
self.asset_class_hint: str | None = None
self.market: str | None = None
self.section_seq = 0
self.trade_numbers: list[str] = []
self.completed_numbers: list[str] = []
self.trade_gross_signed = Decimal(0)
self.trade_fee_total = Decimal(0)
self.trade_qty: dict[str, Decimal] = {}
self.move_qty: dict[str, Decimal] = {}
self.move_rows = 0
self.cash_fee_total = Decimal(0)
self.cash_settlement_total = Decimal(0)
self.positions_total_rub: Decimal | None = None
self.last_fee_row: tuple[Any, ...] | None = None
# -- entry point ---------------------------------------------------------------------
def run(self) -> ParsedReport:
period, header = self._read_header()
for line_no, row in enumerate(self.rows, start=1):
self._row(line_no, row)
self.instruments = common.merge_instruments(self.instruments)
self.events = common.apply_instrument_hints(self.events, self.instruments)
self._reconcile()
meta: dict[str, Any] = {
"subaccount": header.get("subaccount"),
"iis_opened_at": header.get("iis_opened_at"),
"generated_at": header.get("generated_at"),
"summary": {key: str(value) for key, value in self.summary.items()},
"source_file": self.source_file or None,
}
return ParsedReport(
broker=BROKER,
account_external_id=header["agreement"],
period_from=period[0],
period_to=period[1],
parser_version=VtbXlsxParser.version,
events=self.events,
positions_end=self.positions,
cash_end=self.cash_end,
instruments=self.instruments,
warnings=self.warnings,
meta={key: value for key, value in meta.items() if value is not None},
)
# -- header --------------------------------------------------------------------------
def _read_header(self) -> tuple[tuple[Any, Any], dict[str, Any]]:
"""The period from the title line and the agreement number from the label block.
Both are mandatory: without a period the report cannot be placed on a timeline, and
without the agreement number no event can be keyed, so either missing is a
`ParseError` rather than a warning.
"""
period = None
fields: dict[str, Any] = {}
for row in self.rows:
first = normalize_text(_cell(row, "B"))
if first in SECTIONS:
break
for value in row:
if period is None and isinstance(value, str):
period = common.parse_period(value)
if first in _HEADER_FIELDS:
fields[_HEADER_FIELDS[first]] = _value_right_of(row, "B")
if period is None:
raise ParseError("в отчёте ВТБ не найден период («за период с … по …»)")
agreement = str(fields.get("agreement") or "").strip()
if not agreement:
raise ParseError("в отчёте ВТБ не найден номер соглашения")
header: dict[str, Any] = {"agreement": agreement}
subaccount = str(fields.get("subaccount") or "").strip()
if subaccount:
header["subaccount"] = subaccount
for key in ("iis_opened_at", "generated_at"):
day = to_date(fields.get(key))
if day is not None:
header[key] = day.isoformat()
return period, header
# -- row dispatch --------------------------------------------------------------------
def _row(self, line_no: int, row: tuple[Any, ...]) -> None:
first = _cell(row, "B")
title = normalize_text(first)
if title in SECTIONS:
self.section = SECTIONS[title]
self.expect_caption = True
self.asset_class_hint = None
self.market = None
self.section_seq = 0
self.started = True
return
if not self.started:
return
if title in KNOWN_UNPARSED_SECTIONS:
self.section = None
self.warnings.append(f"строка {line_no}: секция «{first}» не разбирается парсером")
return
if not title:
# A blank line, or the second line of a two-storey column caption.
return
if self.expect_caption:
self.expect_caption = False
return
handler = getattr(self, f"_row_{self.section}", None) if self.section else None
if handler is not None and handler(line_no, row):
return
# The row does not fit the section it follows: the section is over.
self.section = None
text = str(first).strip()
if not _is_boilerplate(text):
self.warnings.append(f"строка {line_no}: нераспознанная строка/секция «{text[:80]}»")
# -- sections ------------------------------------------------------------------------
def _row_summary(self, line_no: int, row: tuple[Any, ...]) -> bool:
amount = to_decimal(_cell(row, "L"))
if amount is None:
return False
currency = normalize_currency(_cell(row, "O"))
if currency is None:
# Every summary line is денежная; a missing currency means the layout moved and
# the totals below are being compared across currencies without knowing it.
self.warnings.append(
f"строка {line_no}: строка сводки «{_text(row, 'B')[:60]}» без валюты"
)
self.summary[normalize_text(_cell(row, "B"))] = amount
self.summary_currency = self.summary_currency or currency
return True
def _row_cash_balances(self, line_no: int, row: tuple[Any, ...]) -> bool:
label = normalize_text(_cell(row, "B"))
if label.startswith("сумма денежных средств"):
# The all-currency total in roubles: a derived line, not a balance of its own.
return True
if not _CURRENCY_CODE_RE.match(label):
return False
# AF is «Итого» of the closing balance across markets; R alone is the main market.
balance = to_decimal(_cell(row, "AF"))
if balance is None:
balance = to_decimal(_cell(row, "R"))
if balance is None:
self.warnings.append(f"строка {line_no}: остаток денежных средств без суммы")
return True
currency = normalize_currency(_cell(row, "B"))
assert currency is not None
self.cash_end.append(CashEnd(currency=currency, balance=balance))
return True
def _row_cash_flow(self, line_no: int, row: tuple[Any, ...]) -> bool:
label = normalize_text(_cell(row, "B"))
if label in common.SECTION_SUBHEADERS:
self.market = str(_cell(row, "B")).strip()
return True
day = to_date(_cell(row, "B"))
if day is None:
return False
self.section_seq += 1
operation = _cell(row, "J")
amount = to_decimal(_cell(row, "C"))
if amount is None:
self.warnings.append(f"строка {line_no}: движение денег без суммы, строка пропущена")
return True
kind, known = common.cash_operation_kind(operation)
operation_key = normalize_text(operation)
if operation_key == _SUMMARY_BROKER_FEE:
self.cash_fee_total += amount
self.last_fee_row = row
return True
if operation_key == _SUMMARY_SETTLEMENT:
self.cash_settlement_total += amount
return True
if not known:
self.warnings.append(
f"строка {line_no}: неизвестный тип денежной операции «{operation}», "
"записан как other"
)
kind = EventKind.other
if kind is None:
return True
try:
self.events.append(
common.build_cash_event(
kind=kind,
operation=operation,
trade_date=_cell(row, "B"),
amount=_cell(row, "C"),
currency=_cell(row, "G"),
comment=_cell(row, "P"),
market=self.market,
raw_line_no=line_no,
seq=self.section_seq,
)
)
except ValueError as exc:
self.warnings.append(f"строка {line_no}: {exc}, строка пропущена")
return True
def _row_positions(self, line_no: int, row: tuple[Any, ...]) -> bool:
label = normalize_text(_cell(row, "B"))
if label in common.ASSET_CLASS_HINTS:
self.asset_class_hint = common.ASSET_CLASS_HINTS[label]
return True
if label in common.SECTION_SUBHEADERS:
self.positions_total_rub = to_decimal(_cell(row, "AF"))
return True
qty = to_decimal(_cell(row, "L"))
if qty is None:
return False
currency = normalize_currency(_cell(row, "O"))
instrument = common.parse_security(
_cell(row, "B"),
asset_class_hint=self.asset_class_hint,
venue=_text(row, "F") or None,
currency=currency,
extra={
"nominal": _str_decimal(_cell(row, "R")),
"coupon_rate": _str_decimal(_cell(row, "X")),
"coupon_or_maturity_date": _iso(_cell(row, "U")),
},
)
if instrument.isin is None:
self.warnings.append(
f"строка {line_no}: в остатках не разобран ISIN бумаги «{_text(row, 'B')[:60]}»"
)
self.instruments.append(instrument)
self.positions.append(
PositionEnd(
instrument=instrument,
qty=qty,
price=to_decimal(_cell(row, "P")),
market_value=to_decimal(_cell(row, "AF")) or to_decimal(_cell(row, "AB")),
currency=currency,
accrued_interest=to_decimal(_cell(row, "S")),
)
)
return True
def _row_security_moves(self, line_no: int, row: tuple[Any, ...]) -> bool:
"""Settlement of the deals already emitted — counted, never emitted (see module doc)."""
day = to_date(_cell(row, "I"))
if day is None:
return False
qty = to_decimal(_cell(row, "L"))
if qty is None:
self.warnings.append(f"строка {line_no}: движение бумаг без количества")
return True
kind = common.trade_side_kind(_cell(row, "O"))
if kind is None:
self.warnings.append(
f"строка {line_no}: неизвестный тип операции с бумагами «{_text(row, 'O')}»"
)
signed = -abs(qty) if kind is EventKind.sell else abs(qty)
key = common.parse_security(_cell(row, "B")).key()
self.move_qty[key] = self.move_qty.get(key, Decimal(0)) + signed
self.move_rows += 1
return True
def _row_trades(self, line_no: int, row: tuple[Any, ...]) -> bool:
if common.to_datetime(_cell(row, "C")) is None:
return False
self.section_seq += 1
try:
event = common.build_trade_event(
security=_cell(row, "B"),
side=_cell(row, "F"),
trade_dt=_cell(row, "C"),
settle_date=_cell(row, "T"),
quantity=_cell(row, "H"),
price=_cell(row, "J"),
price_currency=_cell(row, "I"),
settlement_currency=_cell(row, "L"),
amount_gross=_cell(row, "M"),
accrued_interest=_cell(row, "O"),
fees=(_cell(row, "P"), _cell(row, "R")),
trade_no=_cell(row, "Z"),
venue=_cell(row, "AK"),
order_no=_cell(row, "W"),
exchange_trade_no=_cell(row, "AC"),
counterparty=_cell(row, "AG"),
raw_line_no=line_no,
seq=self.section_seq,
)
except ValueError as exc:
self.warnings.append(f"строка {line_no}: сделка пропущена — {exc}")
return True
if not event.trade_no:
self.warnings.append(
f"строка {line_no}: сделка без «№ сделки», ключ дедупликации по отпечатку"
)
else:
self.trade_numbers.append(event.trade_no)
gross = abs(to_decimal(_cell(row, "M")) or Decimal(0))
self.trade_gross_signed += gross if event.kind is EventKind.sell else -gross
self.trade_fee_total += event.fee or Decimal(0)
if event.instrument is not None and event.quantity is not None:
key = event.instrument.key()
self.trade_qty[key] = self.trade_qty.get(key, Decimal(0)) + event.quantity
self.instruments.append(event.instrument)
self.events.append(event)
return True
def _row_trades_completed(self, line_no: int, row: tuple[Any, ...]) -> bool:
"""The same deals again (see module doc) — only their numbers are taken."""
if common.to_datetime(_cell(row, "C")) is None:
return False
number = _text(row, "Z")
if not number:
self.warnings.append(
f"строка {line_no}: завершённая сделка без «№ сделки» — сверка с "
"«Заключенными» по ней невозможна"
)
return True
self.completed_numbers.append(number)
return True
# -- cross-checks --------------------------------------------------------------------
def _reconcile(self) -> None:
"""Everything the report states twice, checked against itself.
A report is evidence: the value of parsing it is only as good as the assurance that
what was extracted adds up to the totals the broker printed. Each mismatch below is a
real bug class — a missed section, a sign flipped, a fee counted twice — so none of
them may pass silently.
"""
concluded = set(self.trade_numbers)
completed = set(self.completed_numbers)
if len(concluded) != len(self.trade_numbers):
self.warnings.append("в «Заключенных сделках» повторяются номера сделок")
if completed and completed != concluded:
only_completed = sorted(completed - concluded)
only_concluded = sorted(concluded - completed)
self.warnings.append(
"номера сделок «Завершенных» и «Заключенных» расходятся: "
f"только в завершённых {only_completed}, только в заключённых {only_concluded}"
)
if self.move_rows and self.move_rows != len(self.trade_numbers):
self.warnings.append(
f"строк «Движения ценных бумаг» {self.move_rows}, сделок "
f"{len(self.trade_numbers)} — расчёты не совпадают со сделками"
)
for key, qty in self.move_qty.items():
traded = self.trade_qty.get(key, Decimal(0))
if qty != traded:
self.warnings.append(
f"{key}: движение бумаг {qty} шт., сделки {traded} шт. — расхождение"
)
settlement = self.summary.get(_SUMMARY_SETTLEMENT)
if settlement is not None and abs(settlement - self.trade_gross_signed) > _MONEY_TOLERANCE:
self.warnings.append(
f"сальдо расчётов по сделкам в сводке {settlement}, по таблице сделок "
f"{self.trade_gross_signed}"
)
if (
self.cash_settlement_total
and settlement is not None
and abs(settlement - self.cash_settlement_total) > _MONEY_TOLERANCE
):
self.warnings.append(
f"сальдо расчётов в сводке {settlement}, в движении денег "
f"{self.cash_settlement_total}"
)
self._reconcile_fees()
self._reconcile_cash()
def _reconcile_fees(self) -> None:
"""Deal commissions against «Вознаграждение брокера» in the cash section.
They agree to the kopeck in every report seen, which is what justifies capitalising
the fee into the deal and dropping the cash line (`common.build_trade_event`). The
check is what keeps that justification honest: if a fee ever belongs to no deal, the
difference is emitted as its own `commission` event so the money does not vanish, and
the warning says how much was moved.
"""
charged = -self.cash_fee_total # the cash column prints a charge as negative
residual = common.broker_fee_residual(charged, self.trade_fee_total)
summary_fee = self.summary.get(_SUMMARY_BROKER_FEE)
if summary_fee is not None and abs(-summary_fee - charged) > _MONEY_TOLERANCE:
self.warnings.append(
f"вознаграждение брокера в сводке {summary_fee}, в движении денег "
f"{self.cash_fee_total}"
)
if not residual:
return
self.warnings.append(
f"комиссия в движении денег {charged} не сходится с комиссиями по сделкам "
f"{self.trade_fee_total}; разница {residual} записана отдельным событием commission"
)
row = self.last_fee_row
day = to_date(_cell(row, "B")) if row is not None else None
currency = (normalize_currency(_cell(row, "G")) if row is not None else None) or (
self.cash_end[0].currency if self.cash_end else "RUB"
)
if day is None:
self.warnings.append("разницу по комиссии не к чему привязать по дате — пропущена")
return
self.events.append(
BrokerEvent(
kind=EventKind.commission,
trade_date=day,
amount=-residual,
currency=currency,
fee=residual,
fee_currency=currency,
description="Вознаграждение брокера сверх комиссий по сделкам",
meta={"section": "cash", "operation": "Вознаграждение брокера", "residual": True},
)
)
def _reconcile_cash(self) -> None:
"""Opening balance plus every emitted event must equal the closing balance.
This is the one check that covers the whole file at once: it fails if a deposit was
missed, if a trade's sign is wrong, or if a fee got counted twice. Note that it only
holds because the deal commissions are inside `amount` — the same reason the cash fee
lines are not events.
"""
opening = self.summary.get(_SUMMARY_OPENING)
closing = self.summary.get(_SUMMARY_CLOSING)
if opening is None or closing is None:
return
derived = opening + sum((event.amount for event in self.events), Decimal(0))
if abs(derived - closing) > _MONEY_TOLERANCE:
self.warnings.append(
f"исходящий остаток по сводке {closing}, по разобранным событиям {derived}"
)
for balance in self.cash_end:
if balance.currency == (self.summary_currency or "RUB") and (
abs(balance.balance - closing) > _MONEY_TOLERANCE
):
self.warnings.append(
f"остаток {balance.currency} {balance.balance} не совпадает со сводкой "
f"{closing}"
)
def _str_decimal(value: Any) -> str | None:
number = to_decimal(value)
return None if number is None else str(number)
def _iso(value: Any) -> str | None:
day = to_date(value)
return None if day is None else day.isoformat()
parser = VtbXlsxParser()
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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",""
1 Event Date Symbol Price Quantity Currency FeeTax Exchange NKD FeeCurrency DoNotAdjustCash Note
2 CASH_IN 2025-02-26 09:22:54 RUB 1 5000 RUB 0 False Пополнение счета
3 CASH_OUT 2025-02-26 09:22:54 RUB 1 0,002832 RUB 0 False Эта сделка добавлена с целью корректировки баланса по этой валюте, т.к. баланс по данным брокера (459,38) не соответствует балансу рассчитанному по сделкам (459,38).
4 BUY 2025-02-26 09:23:29 DELI 219 10 RUB 6,57 MCX False
5 BUY 2025-02-26 09:24:09 ROSN 581,25 2 RUB 3,49 MCX False
6 BUY 2025-02-26 09:25:23 DELI 219 7 RUB 4,6 MCX False
7 CASH_IN 2025-02-27 19:28:34 RUB 1 3400 RUB 0 False Пополнение счета
8 BUY 2025-02-27 19:28:47 SBERP 307,39 10 RUB 9,22 MCX False
9 BUY 2025-02-28 08:49:02 DELI 211,45 1 RUB 0,63 MCX False
10 CASH_IN 2025-03-12 16:12:53 RUB 1 10000 RUB 0 False Пополнение счета
11 BUY 2025-03-12 16:13:39 DELI 196,3 12 RUB 7,07 MCX False
12 BUY 2025-03-12 16:13:49 DELI 196,3 10 RUB 5,89 MCX False
13 BUY 2025-03-12 16:14:20 DELI 196,4 10 RUB 5,89 MCX False
14 BUY 2025-03-12 16:18:28 RU000A107AM4 929,25 4 RUB 11,15 MCX 5,08 False
15 CASH_IN 2025-03-12 16:19:39 RUB 1 197,7 RUB 0 False Пополнение счета
16 BUY 2025-03-12 16:19:40 DELI 196,5 1 RUB 0,59 MCX False
17 BUY 2025-03-24 16:28:49 ASTR 427,45 2 RUB 2,56 MCX False
18 CASH_IN 2025-03-24 16:28:49 RUB 1 860,08 RUB 0 False Пополнение счета
19 CASH_IN 2025-03-25 18:00:22 RUB 1 5007,5 RUB 0 False Пополнение счета
20 CASH_IN 2025-03-25 18:02:09 RUB 1 5508,25 RUB 0 False Пополнение счета
21 BUY 2025-03-25 18:17:42 WUSH 217,552 5 RUB 3,26 MCX False
22 BUY 2025-03-25 18:21:55 TATNP 647,5 2 RUB 3,89 MCX False
23 BUY 2025-03-25 18:24:32 SFIN 1538,8 1 RUB 4,62 MCX False
24 BUY 2025-03-25 18:24:41 LENT 1384 1 RUB 4,15 MCX False
25 BUY 2025-03-25 18:25:23 IVAT 187,5 1 RUB 0,56 MCX False
26 BUY 2025-03-26 17:27:00 TPAY 96,5 1 RUB 0 MCX False
27 BUY 2025-04-02 11:22:50 RU000A10B7T7 1000 5 RUB 7,5 MCX 0 False
28 DIVIDEND 2025-04-09 18:40:43 TPAY 0 1,94 RUB 0 MCX False
29 CASH_IN 2025-04-11 18:24:33 RUB 1 8745,17 RUB 0 False Пополнение счета
30 CASH_OUT 2025-04-11 18:24:33 RUB 1 0,010799 RUB 0 False Эта сделка добавлена с целью корректировки баланса по этой валюте, т.к. баланс по данным брокера (364,86) не соответствует балансу рассчитанному по сделкам (364,87).
31 BUY 2025-04-11 18:25:10 WUSH 181,58 10 RUB 5,45 MCX False
32 BUY 2025-04-11 18:25:27 IVAT 152,99 10 RUB 4,59 MCX False
33 BUY 2025-04-11 18:25:58 ROSN 450,566667 3 RUB 4,06 MCX False
34 BUY 2025-04-11 18:26:54 PIKK 427,3 3 RUB 3,85 MCX False
35 BUY 2025-04-11 18:28:10 CNRU 539,8 3 RUB 4,86 MCX False
36 BUY 2025-04-18 09:52:48 ROSN 475,05 2 RUB 2,85 MCX False
37 CASH_IN 2025-04-25 16:11:11 RUB 1 1626,6 RUB 0 False Пополнение счета
38 CASH_IN 2025-04-25 16:11:25 RUB 1 1626,6 RUB 0 False Пополнение счета
39 BUY 2025-04-28 10:34:11 TPAY 96,76 17 RUB 0 MCX False
40 BUY 2025-04-28 10:35:25 NVTK 1259,3 1 RUB 3,78 MCX False
41 CASH_IN 2025-04-29 19:39:31 RUB 1 2610 RUB 0 False Пополнение счета
42 CASH_IN 2025-04-29 19:39:41 RUB 1 2610 RUB 0 False Пополнение счета
43 BUY 2025-04-30 10:50:36 TPAY 95,47 2 RUB 0 MCX False
44 BUY 2025-04-30 10:50:48 RU000A107AM4 935,8 1 RUB 2,81 MCX 23,23 False
45 BUY 2025-04-30 10:51:05 DELI 169,4 8 RUB 4,07 MCX False
46 BUY 2025-04-30 10:51:20 CNRU 549 2 RUB 3,29 MCX False
47 BUY 2025-04-30 10:51:32 PIKK 470,8 2 RUB 2,82 MCX False
48 BUY 2025-04-30 10:52:36 APTK 10,412 100 RUB 3,12 MCX False
49 CASH_IN 2025-05-12 19:51:08 RUB 1 5000 RUB 0 False Пополнение счета
50 CASH_IN 2025-05-12 19:51:19 RUB 1 5000 RUB 0 False Пополнение счета
51 BUY 2025-05-14 10:43:57 EUTR 125,25 10 RUB 3,76 MCX False
52 BUY 2025-05-14 10:45:03 DELI 163,6 1 RUB 0,49 MCX False
53 BUY 2025-05-14 10:45:16 ROSN 451,2 8 RUB 10,83 MCX False
54 BUY 2025-05-14 10:45:36 PIKK 490,58 5 RUB 7,36 MCX False
55 BUY 2025-05-14 10:46:01 NVTK 1192,55 2 RUB 7,16 MCX False
56 DIVIDEND 2025-05-15 17:05:35 TPAY 0 25,72 RUB 0 MCX False
57 CASH_IN 2025-05-23 16:12:44 RUB 1 1200 RUB 0 False Пополнение счета
58 CASH_IN 2025-05-23 16:12:54 RUB 1 1200 RUB 0 False Пополнение счета
59 BUY 2025-05-27 08:55:30 DELI 157,65 8 RUB 3,78 MCX False
60 BUY 2025-05-27 08:55:47 DELI 157,65 8 RUB 3,78 MCX False
61 DIVIDEND 2025-05-30 09:12:44 RU000A107AM4 0 165,15 RUB 0 MCX False
62 CASH_IN 2025-06-05 12:46:14 RUB 1 2600 RUB 0 False Пополнение счета
63 CASH_IN 2025-06-05 12:46:24 RUB 1 2600 RUB 0 False Пополнение счета
64 BUY 2025-06-05 16:21:32 RU000A10B7T7 1038 2 RUB 6,23 MCX 44,52 False
65 BUY 2025-06-05 16:21:43 DELI 169,85 2 RUB 1,02 MCX False
66 BUY 2025-06-05 16:21:53 NVTK 1110,55 2 RUB 6,66 MCX False
67 BUY 2025-06-05 16:22:12 IVAT 128,35 5 RUB 1,93 MCX False
68 DIVIDEND 2025-06-09 15:56:44 TPAY 0 26,82 RUB 0 MCX False
69 DIVIDEND 2025-06-11 12:29:50 SFIN 0 83,5 RUB 9 MCX False
70 CASH_IN 2025-06-12 10:19:15 RUB 1 6000 RUB 0 False Пополнение счета
71 CASH_IN 2025-06-12 10:19:29 RUB 1 6000 RUB 0 False Пополнение счета
72 DIVIDEND 2025-06-19 15:21:06 TATNP 0 86,22 RUB 11 MCX False
73 BUY 2025-06-19 16:22:31 TMOS 6,21 398 RUB 0 MCX False
74 BUY 2025-06-19 16:23:05 TMOS 6,21 398 RUB 0 MCX False
75 BUY 2025-06-19 16:24:05 SBRB 15,845 77 RUB 3,66 MCX False
76 BUY 2025-06-19 16:24:16 SBRB 15,845 77 RUB 3,66 MCX False
77 BUY 2025-06-19 16:25:10 TGLD 10,52 58 RUB 0 MCX False
78 BUY 2025-06-19 16:25:27 TGLD 10,52 58 RUB 0 MCX False
79 BUY 2025-06-19 16:26:09 LQDT 1,7196 356 RUB 1,84 MCX False
80 BUY 2025-06-19 16:26:26 LQDT 1,7196 356 RUB 1,84 MCX False
81 BUY 2025-06-19 16:27:03 TPAY 98,23 12 RUB 0 MCX False
82 BUY 2025-06-19 16:27:09 TPAY 98,23 12 RUB 0 MCX False
83 CASH_IN 2025-06-25 19:07:20 RUB 1 1200 RUB 0 False Пополнение счета
84 CASH_IN 2025-06-25 19:07:31 RUB 1 1200 RUB 0 False Пополнение счета
85 AMORTISATION 2025-06-26 14:00:41 RU000A10B7T7 0 897,82 RUB 0 MCX False
86 DIVIDEND 2025-06-26 14:10:53 RU000A10B7T7 0 407,54 RUB 0 MCX False
87 BUY 2025-06-26 14:22:14 RU000A10B7T7 909,835 2 RUB 5,46 MCX 0,6 False
88 BUY 2025-06-26 14:22:34 RU000A10B7T7 909,92 1 RUB 2,73 MCX 0,6 False
89 CASH_IN 2025-07-04 15:45:31 RUB 1 2900 RUB 0 False Пополнение счета
90 CASH_IN 2025-07-04 15:45:52 RUB 1 2900 RUB 0 False Пополнение счета
91 CASH_IN 2025-07-04 23:49:07 RUB 1 2610 RUB 0 False Пополнение счета
92 CASH_IN 2025-07-04 23:49:17 RUB 1 2610 RUB 0 False Пополнение счета
93 DIVIDEND 2025-07-08 12:33:19 TPAY 0 56,4 RUB 0 MCX False
94 DIVIDEND 2025-07-08 13:58:58 TPAY 0 20,15 RUB 0 MCX False
95 BUY 2025-07-08 14:24:10 TMOS 6,2 403 RUB 0 MCX False
96 BUY 2025-07-08 14:24:21 TMOS 6,2 403 RUB 0 MCX False
97 BUY 2025-07-08 14:24:50 TBRU 6,96 286 RUB 0 MCX False
98 BUY 2025-07-08 14:24:58 TBRU 6,96 286 RUB 0 MCX False
99 BUY 2025-07-08 14:25:57 TGLD 10,37 96 RUB 0 MCX False
100 BUY 2025-07-08 14:26:05 TGLD 10,37 96 RUB 0 MCX False
101 BUY 2025-07-08 14:26:34 TPAY 98,56 8 RUB 0 MCX False
102 BUY 2025-07-08 14:26:40 TPAY 98,56 4 RUB 0 MCX False
103 CASH_IN 2025-07-11 21:28:53 RUB 1 5300 RUB 0 False Пополнение счета
104 CASH_IN 2025-07-11 21:29:03 RUB 1 5300 RUB 0 False Пополнение счета
105 BUY 2025-07-11 21:31:35 VTBR 72,84 13 RUB 2,84 MCX False
106 BUY 2025-07-11 21:31:44 VTBR 72,84 13 RUB 2,84 MCX False
107 BUY 2025-07-11 21:32:50 T 3129,4 1 RUB 0 MCX False
108 BUY 2025-07-11 21:32:58 T 3129,3 1 RUB 0 MCX False
109 DIVIDEND 2025-07-15 14:09:51 ASTR 0 6,29 RUB 1 MCX False
110 CASH_IN 2025-07-15 17:22:31 RUB 1 1000 RUB 0 False Пополнение счета
111 CASH_IN 2025-07-15 17:23:34 RUB 1 1000 RUB 0 False Пополнение счета
112 DIVIDEND 2025-07-21 07:36:14 T 0 33 RUB 4 MCX False
113 DIVIDEND 2025-07-21 08:13:36 T 0 33 RUB 4 MCX False
114 DIVIDEND 2025-07-28 20:55:02 RU000A10B7T7 0 161,19 RUB 0 MCX False
115 DIVIDEND 2025-07-28 20:59:19 RU000A10B7T7 0 17,91 RUB 0 MCX False
116 AMORTISATION 2025-07-28 21:02:33 RU000A10B7T7 0 592,11 RUB 0 MCX False
117 AMORTISATION 2025-07-28 21:06:43 RU000A10B7T7 0 65,79 RUB 0 MCX False
118 DIVIDEND 2025-08-01 11:52:28 EUTR 0 141,9 RUB 19 MCX False
119 DIVIDEND 2025-08-01 13:35:14 EUTR 0 30 RUB 4 MCX False
120 DIVIDEND 2025-08-04 14:42:34 SBERP 0 348,4 RUB 45 MCX False
121 DIVIDEND 2025-08-04 19:55:40 ROSN 0 73,4 RUB 10 MCX False
122 DIVIDEND 2025-08-04 20:07:25 ROSN 0 146,8 RUB 19 MCX False
123 DIVIDEND 2025-08-08 15:01:23 TPAY 0 57,07 RUB 0 MCX False
124 DIVIDEND 2025-08-08 15:45:28 TPAY 0 23,43 RUB 0 MCX False
125 SELL 2025-08-08 17:15:27 PIKK 638,7 10 RUB 19,16 MCX False
126 SELL 2025-08-08 17:16:06 IVAT 168,3 6 RUB 3,03 MCX False
127 SELL 2025-08-08 17:16:16 IVAT 168,25 2 RUB 1,01 MCX False
128 CASH_OUT 2025-08-08 17:18:02 RUB 1 9900 RUB 0 False Вывод со счета
129 TAX 2025-08-12 06:03:36 0 0 RUB 234 False Корректировка налога
130 SELL 2025-08-20 21:40:50 VTBR 77,91 13 RUB 3,04 MCX False
131 SELL 2025-08-20 21:41:08 EUTR 126,4 10 RUB 3,79 MCX False
132 SELL 2025-08-20 21:41:19 LENT 1785,5 1 RUB 5,36 MCX False
133 SELL 2025-08-20 21:41:56 IVAT 169,4 6 RUB 3,05 MCX False
134 SELL 2025-08-20 21:43:13 LQDT 1,7755 356 RUB 1,9 MCX False
135 SELL 2025-08-20 21:51:17 WUSH 140,58 5 RUB 2,11 MCX False
136 BUY 2025-08-21 10:50:16 TGLD 10,69 93 RUB 0 MCX False
137 BUY 2025-08-21 10:50:16 TMOS 6,76 369 RUB 0 MCX False
138 SELL 2025-08-21 11:17:38 TPAY 101,62 40 RUB 0 MCX False
139 BUY 2025-08-21 11:22:38 TMOS 6,75 381 RUB 0 MCX False
140 BUY 2025-08-21 11:22:39 TGLD 10,69 96 RUB 0 MCX False
141 BUY 2025-08-21 12:15:01 TMOS 6,75 251 RUB 0 MCX False
142 BUY 2025-08-21 12:15:01 TPAY 101,61 16 RUB 0 MCX False
143 BUY 2025-08-21 12:15:02 TGLD 10,69 63 RUB 0 MCX False
144 BUY 2025-08-21 12:26:45 SU26212RMFS9 887,83 1 RUB 2,66 MCX 5,79 False
145 BUY 2025-08-21 12:26:45 TPAY 101,6 7 RUB 0 MCX False
146 BUY 2025-08-21 12:26:46 TGLD 10,69 27 RUB 0 MCX False
147 BUY 2025-08-21 12:26:46 TMOS 6,76 107 RUB 0 MCX False
148 SELL 2025-08-21 12:43:14 ASTR 416,75 2 RUB 2,5 MCX False
149 SELL 2025-08-21 12:46:04 DELI 148,3 68 RUB 30,25 MCX False
150 SELL 2025-08-21 12:46:47 DELI 148,255 10 RUB 4,44 MCX False
151 BUY 2025-08-21 12:55:10 SU26212RMFS9 887,35 4 RUB 10,65 MCX 5,79 False
152 BUY 2025-08-21 12:55:11 TMOS 6,77 394 RUB 0 MCX False
153 BUY 2025-08-21 12:55:11 TPAY 101,58 26 RUB 0 MCX False
154 BUY 2025-08-21 12:55:12 TGLD 10,71 99 RUB 0 MCX False
155 BUY 2025-08-22 13:40:55 RNFT 124,5 50 RUB 18,68 MCX False
156 SELL 2025-08-22 13:45:06 TATN 675,1 10 RUB 20,25 MCX False
157 FEE 2025-08-23 00:30:53 0 0 RUB 45 False
158 FEE 2025-08-24 00:32:22 0 0 RUB 45 False
159 FEE 2025-08-25 00:31:52 0 0 RUB 45 False
160 FEE 2025-08-26 00:32:25 0 0 RUB 45 False
161 AMORTISATION 2025-08-26 13:06:06 RU000A10B7T7 0 664,47 RUB 0 MCX False
162 AMORTISATION 2025-08-26 13:07:38 RU000A10B7T7 0 73,83 RUB 0 MCX False
163 DIVIDEND 2025-08-26 13:14:31 RU000A10B7T7 0 153,99 RUB 0 MCX False
164 DIVIDEND 2025-08-26 13:14:58 RU000A10B7T7 0 17,11 RUB 0 MCX False
165 FEE 2025-08-27 00:36:17 0 0 RUB 45 False
166 FEE 2025-08-28 00:34:11 0 0 RUB 45 False
167 FEE 2025-08-29 00:33:46 0 0 RUB 45 False
168 DIVIDEND 2025-08-29 13:54:42 RU000A107AM4 0 165,15 RUB 0 MCX False
169 BUY 2025-08-29 17:24:07 TATN 650,5 10 RUB 19,52 MCX False
170 SELL 2025-08-29 17:24:25 RNFT 121,2 50 RUB 18,18 MCX False
171 SELL 2025-09-01 17:59:16 VTBR 74,54 13 RUB 2,91 MCX False
172 SELL 2025-09-01 17:59:29 NVTK 1239,6 5 RUB 18,59 MCX False
173 SELL 2025-09-01 17:59:39 ROSN 461,32 5 RUB 6,92 MCX False
174 SELL 2025-09-01 18:02:17 IVAT 159,7 2 RUB 0,96 MCX False
175 SELL 2025-09-01 18:02:30 CNRU 607,4 5 RUB 9,11 MCX False
176 SELL 2025-09-01 18:02:39 T 3335,8 1 RUB 0 MCX False
177 SELL 2025-09-01 18:04:27 APTK 9,532 100 RUB 2,86 MCX False
178 SELL 2025-09-01 18:04:35 WUSH 125,45 10 RUB 3,77 MCX False
179 SELL 2025-09-01 18:04:53 RU000A10B7T7 769,384908 1 RUB 2,31 MCX 3,505092 False
180 TAX 2025-09-01 18:05:10 0 0 RUB 3 False Удержание налога
181 CASH_OUT 2025-09-01 18:05:10 RUB 1 20358,92 RUB 0 False Вывод со счета
182 TAX_RETURN 2025-09-03 07:21:59 0 0 RUB 3 False Корректировка налога
183 CASH_IN 2025-09-05 11:23:58 RUB 1 5000 RUB 0 False Пополнение счета
184 DIVIDEND 2025-09-05 17:42:16 TPAY 0 60,89 RUB 0 MCX False
185 DIVIDEND 2025-09-05 18:09:43 TPAY 0 19,82 RUB 0 MCX False
186 BUY 2025-09-08 21:21:46 SU26212RMFS9 884,4 2 RUB 5,31 MCX 9,27 False
187 BUY 2025-09-08 21:21:47 TMOS 6,76 179 RUB 0 MCX False
188 BUY 2025-09-08 21:21:47 TPAY 100,84 12 RUB 0 MCX False
189 BUY 2025-09-08 21:22:16 SU26212RMFS9 884,4 1 RUB 2,65 MCX 9,27 False
190 BUY 2025-09-08 21:22:17 TMOS 6,76 107 RUB 0 MCX False
191 BUY 2025-09-08 21:22:17 TPAY 100,84 7 RUB 0 MCX False
192 AMORTISATION 2025-09-26 16:32:06 RU000A10B7T7 0 631,89 RUB 0 MCX False
193 DIVIDEND 2025-09-26 16:33:22 RU000A10B7T7 0 139,95 RUB 0 MCX False
194 CASH_IN 2025-10-05 13:15:58 RUB 1 5000 RUB 0 False Пополнение счета
195 BUY 2025-10-06 20:20:29 SU26212RMFS9 869,77 2 RUB 5,22 MCX 14,68 False
196 BUY 2025-10-06 20:20:29 TPAY 100,02 12 RUB 0 MCX False
197 BUY 2025-10-06 20:20:30 TMOS 6,17 196 RUB 0 MCX False
198 SELL 2025-10-07 13:23:38 LQDT 1,8159 356 RUB 1,94 MCX False
199 SELL 2025-10-07 13:25:10 TGLD 12,88 154 RUB 0 MCX False
200 SELL 2025-10-07 13:25:31 TMOS 6,21 801 RUB 0 MCX False
201 SELL 2025-10-07 13:25:58 TPAY 100,1 16 RUB 0 MCX False
202 SELL 2025-10-07 13:26:18 SBRB 16,599 77 RUB 3,83 MCX False
203 SELL 2025-10-07 13:26:39 TBRU 7,39 286 RUB 0 MCX False
204 CASH_OUT 2025-10-07 13:27:12 RUB 1 12614,5 RUB 0 False Вывод со счета
205 DIVIDEND 2025-10-08 10:27:11 T 0 35 RUB 4 MCX False
206 DIVIDEND 2025-10-08 16:58:14 TPAY 0 93,27 RUB 0 MCX False
207 DIVIDEND 2025-10-08 17:28:20 TPAY 0 22,24 RUB 0 MCX False
208 TAX 2025-10-09 04:18:26 0 0 RUB 22 False Корректировка налога
209 AMORTISATION 2025-10-27 18:30:50 RU000A10B7T7 0 570,42 RUB 0 MCX False
210 DIVIDEND 2025-10-27 19:16:05 RU000A10B7T7 0 122,4 RUB 0 MCX False
211 DIVIDEND 2025-10-29 14:52:01 TATNP 0 28,7 RUB 4 MCX False
212 CASH_IN 2025-11-05 11:58:33 RUB 1 5000 RUB 0 False Пополнение счета
213 BUY 2025-11-06 12:12:16 SU26212RMFS9 879,766667 3 RUB 7,92 MCX 20,66999966666667 False
214 BUY 2025-11-06 12:12:17 TMOS 5,94 327 RUB 0 MCX False
215 BUY 2025-11-06 12:12:17 TPAY 100,49 19 RUB 0 MCX False
216 BUY 2025-11-06 12:12:18 TGLD 13 59 RUB 0 MCX False
217 DIVIDEND 2025-11-10 19:06:16 TPAY 0 95,8 RUB 0 MCX False
218 AMORTISATION 2025-11-26 18:13:31 RU000A10B7T7 0 558,09 RUB 0 MCX False
219 DIVIDEND 2025-11-26 18:21:54 RU000A10B7T7 0 114,39 RUB 0 MCX False
220 CASH_IN 2025-11-27 11:05:13 RUB 1 5818,41 RUB 0 False Пополнение счета
221 BUY 2025-11-27 11:05:14 ASTR 289,1 20 RUB 17,35 MCX False
222 CASH_IN 2025-11-27 11:05:59 RUB 1 3431,27 RUB 0 False Пополнение счета
223 BUY 2025-11-27 11:06:00 IVAT 170,25 20 RUB 10,22 MCX False
224 DIVIDEND 2025-11-28 10:29:07 RU000A107AM4 0 165,15 RUB 0 MCX False
225 CASH_IN 2025-12-05 14:15:14 RUB 1 5000 RUB 0 False Пополнение счета
226 DIVIDEND 2025-12-08 19:27:48 TPAY 0 92,14 RUB 0 MCX False
227 DIVIDEND 2025-12-26 17:14:16 RU000A10B7T7 0 99,18 RUB 0 MCX False
228 AMORTISATION 2025-12-26 17:17:03 RU000A10B7T7 0 475,65 RUB 0 MCX False
229 DIVIDEND 2025-12-29 12:07:40 SFIN 0 902 RUB 117 MCX False
230 CASH_IN 2026-01-05 13:59:37 RUB 1 5000 RUB 0 False Пополнение счета
231 BUY 2026-01-06 10:22:03 TPAY 100,27 34 RUB 0 MCX False
232 BUY 2026-01-06 10:22:04 TMOS 6,45 346 RUB 0 MCX False
233 SELL 2026-01-08 11:41:58 RU000A10B7T7 502,484763 9 RUB 13,57 MCX 4,639681444444444 False
234 SELL 2026-01-08 11:41:59 RU000A107AM4 991,7 5 RUB 14,88 MCX 15,61 False
235 SELL 2026-01-08 11:41:59 SFIN 829,2 1 RUB 2,49 MCX False
236 SELL 2026-01-08 11:41:59 T 3255 1 RUB 0 MCX False
237 SELL 2026-01-08 11:42:00 SBERP 297,89 10 RUB 8,94 MCX False
238 SELL 2026-01-08 11:42:00 SU26212RMFS9 890,520769 13 RUB 34,73 MCX 32,83000023076923 False
239 SELL 2026-01-08 11:42:00 TATNP 535,4 2 RUB 3,21 MCX False
240 SELL 2026-01-08 11:42:01 ROSN 400,45 10 RUB 12,01 MCX False
241 BUY 2026-01-08 11:42:01 TMOS 6,37 2114 RUB 0 MCX False
242 BUY 2026-01-08 11:42:02 SBRB 17,683 173 RUB 9,18 MCX False
243 BUY 2026-01-08 11:42:02 TBRU 7,81 1986 RUB 0 MCX False
244 BUY 2026-01-08 11:42:02 TPAY 100,39 43 RUB 0 MCX False
245 BUY 2026-01-12 18:40:57 TDIV 10,269799 696 RUB 0 MCX False
246 SELL 2026-01-12 18:40:57 TMOS 6,380144 695 RUB 0 MCX False
247 SELL 2026-01-12 18:46:05 TDIV 10,240573 262 RUB 0 MCX False
248 DIVIDEND 2026-01-14 11:09:42 T 0 36 RUB 5 MCX False
249 DIVIDEND 2026-01-20 15:55:25 TPAY 0 136,92 RUB 0 MCX False
250 CASH_IN 2026-02-02 11:12:04 RUB 1 5000 RUB 0 False Пополнение счета
251 CASH_IN 2026-02-02 11:32:35 RUB 1 5566 RUB 0 False Пополнение счета
252 BUY 2026-02-02 11:39:30 TBRU 7,8 302 RUB 0 MCX False
253 BUY 2026-02-02 11:39:30 TOFZ 13,57 767 RUB 0 MCX False
254 BUY 2026-02-02 11:39:30 TPAY 100,08 24 RUB 0 MCX False
255 SELL 2026-02-02 11:40:19 TOFZ 13,56 397 RUB 0 MCX False
256 CASH_IN 2026-02-05 14:25:59 RUB 1 5000 RUB 0 False Пополнение счета
257 BUY 2026-02-09 11:24:18 TDIV 10,42 28 RUB 0 MCX False
258 BUY 2026-02-09 11:24:18 TMOS 6,43 279 RUB 0 MCX False
259 BUY 2026-02-09 11:24:18 TOFZ 13,53 22 RUB 0 MCX False
260 BUY 2026-02-09 11:24:19 SBRB 17,781 16 RUB 0,11 MCX False
261 BUY 2026-02-09 11:24:19 TBRU 7,8 153 RUB 0 MCX False
262 BUY 2026-02-09 11:24:19 TGLD 15,47 38 RUB 0 MCX False
263 BUY 2026-02-09 11:24:19 TPAY 100,19 11 RUB 0 MCX False
264 DIVIDEND 2026-02-09 16:36:01 TPAY 0 201,78 RUB 0 MCX False
265 BUY 2026-02-16 11:03:47 TDIV 10,69 1 RUB 0 MCX False
266 BUY 2026-02-16 11:03:47 TMOS 6,6 12 RUB 0 MCX False
267 BUY 2026-02-16 11:03:48 TBRU 7,9 6 RUB 0 MCX False
268 BUY 2026-02-16 11:03:48 TGLD 15,25 1 RUB 0 MCX False
269 SELL 2026-02-18 11:05:26 SBRB 17,917226 266 RUB 1,91 MCX False
270 BUY 2026-02-18 11:05:26 TMOS 6,56 6 RUB 0 MCX False
271 BUY 2026-02-18 11:05:26 TOFZ 13,82 380 RUB 0 MCX False
272 BUY 2026-02-18 11:05:27 TBRU 7,91 3 RUB 0 MCX False
273 SELL 2026-02-21 10:24:30 IVAT 159,55 20 RUB 1,28 MCX False
274 SELL 2026-02-21 10:24:40 ASTR 259,9 20 RUB 2,08 MCX False
275 TAX 2026-02-21 10:24:58 0 0 RUB 32 False Удержание налога по ставке 13%
276 CASH_OUT 2026-02-21 10:24:58 RUB 1 8388,99 RUB 0 False Вывод со счета
277 CASH_IN 2026-02-24 00:00:00 RUB 1 0,0024 RUB 0 False Эта сделка добавлена с целью корректировки баланса по этой валюте, т.к. баланс по данным брокера (16,14) не соответствует балансу рассчитанному по сделкам (16,14).
278 CASH_IN 2026-02-24 00:00:00 RUB 1 2000 RUB 0 False
279 BUY 2026-02-24 11:31:18 LQDT 1,9291 1036 RUB 0 MCX False
280 TAX_RETURN 2026-02-26 02:13:39 0 0 RUB 32 False Корректировка налога по ставке 13%
281 CASH_OUT 2026-03-01 18:17:42 RUB 1 32 RUB 0 False Вывод со счета
282 SELL 2026-03-02 11:31:12 TBRU 7,94 2051 RUB 0 MCX False
283 SELL 2026-03-02 11:31:12 TDIV 10,869698 463 RUB 0 MCX False
284 SELL 2026-03-02 11:31:12 TPAY 100,13 178 RUB 0 MCX False
285 SELL 2026-03-02 11:31:13 TGLD 16,69 303 RUB 0 MCX False
286 BUY 2026-03-02 11:31:13 TMOS 6,73 6144 RUB 0 MCX False
287 BUY 2026-03-02 11:31:14 TMOS 6,73 290 RUB 0 MCX False
288 BUY 2026-03-02 11:31:14 TOFZ 13,8 410 RUB 0 MCX False
289 BUY 2026-03-02 11:31:15 TBRU 7,95 17 RUB 0 MCX False
290 CASH_IN 2026-03-05 14:48:54 RUB 1 5000 RUB 0 False Пополнение счета
291 BUY 2026-03-09 11:01:40 TMOS 6,85 283 RUB 0 MCX False
292 BUY 2026-03-09 11:01:40 TOFZ 13,87 29 RUB 0 MCX False
293 BUY 2026-03-09 11:01:41 TBRU 7,97 17 RUB 0 MCX False
294 BUY 2026-03-09 11:01:41 TGLD 15,9 8 RUB 0 MCX False
295 DIVIDEND 2026-03-10 16:55:28 TPAY 0 199,98 RUB 0 MCX False
296 BUY 2026-03-16 11:35:58 TMOS 6,81 32 RUB 0 MCX False
297 BUY 2026-03-16 11:35:59 TBRU 8,07 1 RUB 0 MCX False
298 BUY 2026-03-16 11:35:59 TOFZ 14,06 3 RUB 0 MCX False
299 BUY 2026-03-23 11:11:19 TMOS 6,76 7 RUB 0 MCX False
300 BUY 2026-03-30 11:53:07 TMOS 6,56 7 RUB 0 MCX False
301 CASH_IN 2026-04-01 14:43:30 RUB 1 1100 RUB 0 False Пополнение счета
302 BUY 2026-04-01 14:47:55 RU000A10BF48 1008 1 RUB 3,02 MCX 7,46 False
303 CASH_IN 2026-04-01 20:05:46 RUB 1 100000 RUB 0 False Пополнение счета
304 BUY 2026-04-01 20:08:22 SU26207RMFS9 964,82 8 RUB 23,16 MCX 12,73 False
305 BUY 2026-04-01 20:08:40 SU26207RMFS9 964,81 8 RUB 23,16 MCX 12,73 False
306 BUY 2026-04-01 20:09:02 SU26207RMFS9 964,84 14 RUB 40,52 MCX 12,73 False
307 BUY 2026-04-01 20:09:33 SU29016RMFS1 1003,88 10 RUB 30,11 MCX 3,24 False
308 BUY 2026-04-01 20:09:47 SU29007RMFS0 1020 10 RUB 30,6 MCX 13,89 False
309 BUY 2026-04-01 20:18:20 RU000A10AT35 1029,1 5 RUB 15,44 MCX 6,62 False
310 BUY 2026-04-01 20:18:33 RU000A10BF48 1008 4 RUB 12,1 MCX 7,46 False
311 BUY 2026-04-01 20:21:25 RU000A10CQ85 999,2 5 RUB 14,99 MCX 8,44 False
312 BUY 2026-04-01 20:22:09 RU000A10B0U0 1070,5 5 RUB 16,06 MCX 1,26 False
313 BUY 2026-04-01 20:23:59 RU000A10B214 1042,4 5 RUB 15,64 MCX 13,36 False
314 CUSTOM_HOLDING_PRICE 2026-04-03 00:00:00 SIBN6P4 11353,38392 0 RUB 0 CUSTOM_HOLDING 0
315 BUY 2026-04-03 16:38:22 SIBN6P4 11353,38392 2 RUB 68,12 CUSTOM_HOLDING 25,35108 False
316 CASH_IN 2026-04-05 14:36:58 RUB 1 5000 RUB 0 False Пополнение счета
317 BUY 2026-04-06 11:24:08 TMOS 6,49 558 RUB 0 MCX False
318 BUY 2026-04-06 11:24:09 TMOS 6,49 543 RUB 0 MCX False
319 BUY 2026-04-06 11:24:10 TPAY 100,92 1 RUB 0 MCX False
320 SELL 2026-04-06 11:51:39 TMOS 6,48 539 RUB 0 MCX False
321 DIVIDEND 2026-04-07 15:55:02 RU000A10B214 0 77,05 RUB 0 MCX False
322 CASH_IN 2026-04-08 00:00:00 RUB 1 3000 RUB 0 False
323 BUY 2026-04-08 15:24:35 LQDT 1,9629 1529 RUB 0 MCX False
324 DIVIDEND 2026-04-09 13:13:04 TPAY 0 33,01 RUB 0 MCX False
325 CASH_IN 2026-04-11 21:18:45 RUB 1 5000 RUB 0 False Пополнение счета
326 BUY 2026-04-12 10:34:35 SU26207RMFS9 966,13 5 RUB 14,49 MCX 15,41 False
327 CASH_IN 2026-04-12 10:46:14 RUB 1 10000 RUB 0 False Пополнение счета
328 BUY 2026-04-12 10:46:24 SU29007RMFS0 1020,5 5 RUB 15,31 MCX 19,63 False
329 BUY 2026-04-12 10:46:32 SU29016RMFS1 1001,35 5 RUB 15,02 MCX 8,13 False
330 BUY 2026-04-13 12:54:35 TBRU 8,16 7 RUB 0 MCX False
331 BUY 2026-04-13 12:54:35 TGLD 14,24 4 RUB 0 MCX False
332 BUY 2026-04-13 12:54:35 TMOS 6,45 136 RUB 0 MCX False
333 BUY 2026-04-13 12:54:35 TOFZ 14,09 13 RUB 0 MCX False
334 DIVIDEND 2026-04-13 13:44:05 RU000A10CQ85 0 57,55 RUB 0 MCX False
335 CASH_IN 2026-04-15 22:54:40 RUB 1 1200 RUB 0 False Пополнение счета
336 SPLIT 2026-04-16 03:00:00 T 10 0 RUB 0 MCX 0
337 SPLIT 2026-04-16 03:00:00 T 10 0 RUB 0 MCX 0
338 CASH_IN 2026-04-16 14:08:35 RUB 1 1100 RUB 0 False Пополнение счета
339 CASH_IN 2026-04-16 14:08:36 RUB 1 1100 RUB 0 False Пополнение счета
340 CASH_IN 2026-04-17 00:00:00 RUB 1 1100 RUB 0 False
341 CASH_IN 2026-04-17 00:00:00 RUB 1 1110 RUB 0 False ЗАЧИСЛЕНИЕ Д/С
342 CASH_OUT 2026-04-17 00:00:00 RUB 1 1017,83999543 RUB 0 False Эта сделка добавлена с целью корректировки баланса по этой валюте, т.к. баланс по данным брокера (357,15) не соответствует балансу рассчитанному по сделкам (1374,99).
343 BUY 2026-04-17 00:00:00 STME 4,285 262 RUB 0,22 MCX False
344 DIVIDEND 2026-04-17 13:25:18 RU000A10BF48 0 69,3 RUB 0 MCX False
345 BUY 2026-04-17 15:04:10 LQDT 1,9714 558 RUB 0 MCX False
346 CASH_IN 2026-04-20 00:00:00 RUB 1 3458,88 RUB 0 False ПЕРЕВОД Д/С С ДОГОВОРА 8184V30, ТП ОСНОВНОЙ РЫНОК НА ДОГОВОР S930W42, ТП ОСНОВНОЙ РЫНОК
347 BUY 2026-04-20 12:23:33 TMOS 6,45629 124 RUB 0 MCX False
348 BUY 2026-04-20 12:23:34 TBRU 8,21 6 RUB 0 MCX False
349 BUY 2026-04-20 12:23:34 TGLD 14,35 4 RUB 0 MCX False
350 BUY 2026-04-20 12:23:34 TOFZ 14,24 12 RUB 0 MCX False
351 BUY 2026-04-20 12:33:50 RU000A10BF48 1008,2 1 RUB 3,02 MCX 2,29 False
352 BUY 2026-04-20 12:34:00 RU000A10B214 1046,6 1 RUB 3,14 MCX 7,71 False
353 BUY 2026-04-20 12:34:08 RU000A10CQ85 999,9 1 RUB 3 MCX 4,22 False
354 BUY 2026-04-20 12:34:16 RU000A10AT35 1026,6 1 RUB 3,08 MCX 1,42 False
355 DIVIDEND 2026-04-20 17:33:41 RU000A10AT35 0 70,9 RUB 0 MCX False
356 BUY 2026-04-22 00:00:00 SBRB 18,6130117 342 RUB 1,27 MCX False
357 SELL 2026-04-22 00:00:00 STME 4,24500713 701 RUB 0,6 MCX False
358 SELL 2026-04-22 15:04:08 TPAY 101,28 34 RUB 0 MCX False
359 BUY 2026-04-22 15:04:09 TBRU 8,24 2115 RUB 0 MCX False
360 SELL 2026-04-22 15:04:09 TOFZ 14,28 1239 RUB 0 MCX False
361 BUY 2026-04-22 15:04:10 TGLD 14,17 320 RUB 0 MCX False
362 BUY 2026-04-22 15:04:10 TMOS 6,51 3 RUB 0 MCX False
363 SELL 2026-04-22 15:22:42 LQDT 1,9735 3123 RUB 0 MCX False
364 BUY 2026-04-22 15:23:14 EQMX 143,2 43 RUB 1,24 MCX False
365 CASH_IN 2026-04-28 00:00:00 RUB 1 4000 RUB 0 False
366 CASH_IN 2026-04-28 00:00:00 RUB 1 4000,81 RUB 0 False ЗАЧИСЛЕНИЕ Д/С
367 BUY 2026-04-28 00:00:00 SBRB 18,68601852 216 RUB 0,8 MCX False
368 BUY 2026-04-28 11:10:13 EQMX 141,6 28 RUB 0,8 MCX False
369 DIVIDEND 2026-04-28 16:28:09 SIBN6P4 0 131,44 RUB 0 CUSTOM_HOLDING False
370 CASH_IN 2026-05-01 00:16:15 RUB 1 760 RUB 0 False Пополнение счета
371 DIVIDEND 2026-05-04 13:13:02 RU000A10B0U0 0 94,5 RUB 0 MCX False
372 CASH_IN 2026-05-05 13:48:20 RUB 1 5000 RUB 0 False Пополнение счета
373 DIVIDEND 2026-05-07 11:34:21 RU000A10B214 0 92,46 RUB 0 MCX False
374 CASH_IN 2026-05-10 10:27:31 RUB 1 2000 RUB 0 False Пополнение счета
375 BUY 2026-05-11 13:45:38 TMOS 6,36 485 RUB 0 MCX False
376 BUY 2026-05-11 13:45:39 TGLD 13,6 24 RUB 0 MCX False
377 BUY 2026-05-11 13:45:39 TMOS 6,36 464 RUB 0 MCX False
378 CASH_IN 2026-05-12 16:23:54 RUB 1 9000 RUB 0 False Пополнение счета
379 BUY 2026-05-12 16:26:57 RU000A10B0U0 1066,7 5 RUB 16 MCX 8,19 False
380 BUY 2026-05-12 16:27:09 RU000A10AT35 1031,5 4 RUB 12,38 MCX 11,82 False
381 BUY 2026-05-12 16:27:44 RU000A10BF48 1008 2 RUB 6,05 MCX 12,19 False
382 DIVIDEND 2026-05-13 12:22:10 RU000A10CQ85 0 69,06 RUB 0 MCX False
383 SELL 2026-05-18 14:13:35 TMOS 6,3 488 RUB 0 MCX False
384 DIVIDEND 2026-05-18 17:46:19 RU000A10AT35 0 141,8 RUB 0 MCX False
385 DIVIDEND 2026-05-19 12:28:16 RU000A10BF48 0 108,16 RUB 0 MCX False
386 BUY 2026-05-25 14:33:45 TMOS 6,28 98 RUB 0 MCX False
387 BUY 2026-05-25 14:33:46 TBRU 8,32 21 RUB 0 MCX False
388 BUY 2026-05-25 14:33:46 TGLD 12,81 5 RUB 0 MCX False
389 DIVIDEND 2026-05-26 18:44:26 SIBN6P4 0 124,94 RUB 0 CUSTOM_HOLDING False
390 CASH_IN 2026-05-28 00:00:00 RUB 1 4000 RUB 0 False
391 CASH_IN 2026-05-28 00:00:00 RUB 1 4000 RUB 0 False ЗАЧИСЛЕНИЕ Д/С
392 BUY 2026-05-28 00:00:00 SBRB 18,89099057 212 RUB 0,8 MCX False
393 BUY 2026-05-28 11:06:55 EQMX 136,3 29 RUB 0,79 MCX False
394 BUY 2026-06-01 16:08:51 TBRU 8,33 1 RUB 0 MCX False
395 BUY 2026-06-01 16:08:51 TMOS 6,15 5 RUB 0 MCX False
396 DIVIDEND 2026-06-02 12:30:21 RU000A10B0U0 0 189 RUB 0 MCX False
397 CASH_IN 2026-06-05 12:10:51 RUB 1 5000 RUB 0 False Пополнение счета
398 DIVIDEND 2026-06-08 12:41:38 RU000A10B214 0 92,46 RUB 0 MCX False
399 BUY 2026-06-08 14:44:52 TMOS 6,1 588 RUB 0 MCX False
400 BUY 2026-06-08 14:44:52 TMOS 6,1 576 RUB 0 MCX False
401 SELL 2026-06-08 14:47:22 TMOS 6,09 569 RUB 0 MCX False
402 BUY 2026-06-08 15:13:02 TMOS 6,09 151 RUB 0 MCX False
403 BUY 2026-06-08 15:13:03 TBRU 8,36 31 RUB 0 MCX False
404 BUY 2026-06-08 15:13:03 TGLD 12,48 8 RUB 0 MCX False
405 DIVIDEND 2026-06-10 13:22:42 RU000A10CQ85 0 69,06 RUB 0 MCX False
406 BUY 2026-06-15 16:28:53 TMOS 6,06 3 RUB 0 MCX False
407 CASH_IN 2026-06-15 19:47:02 RUB 1 10000 RUB 0 False Пополнение счета
408 BUY 2026-06-15 20:22:47 RU000A10B214 1039,9 4 RUB 12,48 MCX 5,65 False
409 BUY 2026-06-15 20:22:56 RU000A10BF48 1008 2 RUB 6,05 MCX 0,44 False
410 BUY 2026-06-15 20:23:06 RU000A10CQ85 1004,2 4 RUB 12,05 MCX 2,68 False
411 DIVIDEND 2026-06-16 15:47:20 RU000A10BF48 0 106,56 RUB 0 MCX False
412 DIVIDEND 2026-06-18 10:10:16 RU000A10AT35 0 141,8 RUB 0 MCX False
413 BUY 2026-06-22 16:08:34 TBRU 8,28 3 RUB 0 MCX False
414 BUY 2026-06-22 16:08:34 TMOS 5,63 17 RUB 0 MCX False
415 DIVIDEND 2026-06-24 16:34:47 SU29016RMFS1 0 538,05 RUB 0 MCX False
416 DIVIDEND 2026-06-25 19:11:14 SIBN6P4 0 131,06 RUB 0 CUSTOM_HOLDING False
417 BUY 2026-06-26 23:28:57 TMOS 5,51 417 RUB 0 MCX False
418 CASH_IN 2026-06-28 00:00:00 RUB 1 4000 RUB 0 False
419 CASH_IN 2026-06-28 00:00:00 RUB 1 4000 RUB 0 False ЗАЧИСЛЕНИЕ Д/С
420 BUY 2026-06-28 00:00:00 SBRB 18,76201878 213 RUB 0,8 MCX False
421 BUY 2026-06-28 14:13:46 EQMX 121,6 33 RUB 0 MCX False
422 DIVIDEND 2026-06-30 12:00:37 RU000A10B0U0 0 189 RUB 0 MCX False
423 CASH_IN 2026-07-01 12:25:32 RUB 1 1000 RUB 0 False Пополнение счета
424 CASH_IN 2026-07-01 12:25:56 RUB 1 1000 RUB 0 False Пополнение счета
425 BUY 2026-07-01 12:39:46 TGLD 12,17 68 RUB 0 MCX False
426 DIVIDEND 2026-07-07 13:30:58 RU000A10B214 0 154,1 RUB 0 MCX False
427 DIVIDEND 2026-07-10 10:36:35 RU000A10CQ85 0 115,1 RUB 0 MCX False
428 BUY 2026-07-10 10:43:57 RU000A10CQ85 997,1 1 RUB 0,4 MCX 1,53 False
429 BUY 2026-07-10 10:44:04 RU000A10CQ85 997,2 2 RUB 0,8 MCX 1,53 False
430 CASH_IN 2026-07-13 11:03:09 RUB 1 10000 RUB 0 False Пополнение счета
431 CASH_IN 2026-07-13 11:03:15 RUB 1 10000 RUB 0 False Пополнение счета
432 CASH_IN 2026-07-13 11:37:10 RUB 1 5000 RUB 0 False Пополнение счета
433 BUY 2026-07-13 12:32:18 TBRU 8,21 217 RUB 0 MCX False
434 BUY 2026-07-13 12:32:18 TMOS 5,29 1523 RUB 0 MCX False
435 BUY 2026-07-13 12:32:19 TGLD 12,23 58 RUB 0 MCX False
436 BUY 2026-07-13 12:33:16 RU000A10B0U0 1044,2 3 RUB 1,25 MCX 9,45 False
437 BUY 2026-07-14 07:18:33 RU000A10B0U0 1045 2 RUB 0,84 MCX 10,08 False
438 BUY 2026-07-14 07:18:48 RU000A10CQ85 998,3 2 RUB 0,8 MCX 2,3 False
439 BUY 2026-07-14 07:18:57 RU000A10AT35 1026,2 5 RUB 2,05 MCX 13,23 False
440 BUY 2026-07-14 07:19:14 SU29016RMFS1 1000,96 2 RUB 0,8 MCX 8,13 False
441 DIVIDEND 2026-07-16 12:32:09 RU000A10BF48 0 131,8 RUB 0 MCX False
442 DIVIDEND 2026-07-17 17:42:53 RU000A10AT35 0 212,7 RUB 0 MCX False
443 BUY 2026-07-19 15:39:27 TMOS 4,77 130 RUB 0 MCX False
444 SELL 2026-07-20 13:22:33 TBRU 8,06 290 RUB 0 MCX False
445 CASH_IN 2026-07-23 00:00:00 RUB 1 10000 RUB 0 False ЗАЧИСЛЕНИЕ Д/С
446 BUY 2026-07-23 00:00:00 SBER 263,68 17 RUB 13,45 MCX False
447 BUY 2026-07-23 00:00:00 SBER 263,68 3 RUB 2,37 MCX False
448 BUY 2026-07-24 00:00:00 MOEX 146,28 10 RUB 4,39 MCX False
449 BUY 2026-07-24 00:00:00 MOEX 146,28 20 RUB 8,78 MCX False
450 DIVIDEND 2026-07-28 09:53:12 SIBN6P4 0 138,06 RUB 0 CUSTOM_HOLDING False
451 DIVIDEND 2026-07-30 11:50:58 RU000A10B0U0 0 283,5 RUB 0 MCX False
452 BUY 2026-07-30 12:13:11 SU26207RMFS9 978,99 1 RUB 2,94 MCX 39,52 False
453 DIVIDEND 2026-08-05 13:31:58 RU000A10B214 0 154,1 RUB 0 MCX False
454 DIVIDEND 2026-08-05 18:29:11 SU26207RMFS9 0 1463,04 RUB 0 MCX False
455 BUY 2026-08-05 22:37:13 SU26207RMFS9 978,88 1 RUB 2,94 MCX 0,22 False
456 DIVIDEND 2026-08-11 13:40:31 RU000A10CQ85 0 172,65 RUB 0 MCX False
457 BUY 2026-08-13 00:00:00 LKOH 4534,5 1 RUB 14,96 MCX False
458 CASH_IN 2026-08-13 00:00:00 RUB 1 4000 RUB 0 False
459 CASH_IN 2026-08-13 00:00:00 RUB 1 10551,18 RUB 0 False ПЕРЕВОД Д/С С ДОГОВОРА 8184V30, ТП ОСНОВНОЙ РЫНОК НА ДОГОВОР S930W42, ТП ОСНОВНОЙ РЫНОК
460 BUY 2026-08-13 00:00:00 TATN 548,3 9 RUB 16,29 MCX False
461 CASH_IN 2026-08-13 11:05:50 RUB 1 14000 RUB 0 False Пополнение счета
462 CASH_IN 2026-08-13 13:06:52 RUB 1 10000 RUB 0 False Пополнение счета
463 BUY 2026-08-13 15:56:15 EQMX 122,9 33 RUB 0,81 MCX False
464 BUY 2026-08-14 14:13:20 SU29016RMFS1 1000,8 5 RUB 15,01 MCX 20,92 False
465 BUY 2026-08-14 14:13:42 SU26207RMFS9 979,08 6 RUB 17,62 MCX 2,68 False
466 DIVIDEND 2026-08-17 13:13:29 RU000A10BF48 0 130,1 RUB 0 MCX False
467 DIVIDEND 2026-08-17 16:37:48 RU000A10AT35 0 212,7 RUB 0 MCX False
468 BUY 2026-08-18 14:52:52 TBRU 8,41 427 RUB 0 MCX False
469 BUY 2026-08-18 14:52:52 TMOS 5,34 1843 RUB 0 MCX False
470 DIVIDEND 2026-08-26 09:18:46 SIBN6P4 0 147,02 RUB 0 CUSTOM_HOLDING False
471 CASH_IN 2026-08-27 11:04:19 RUB 1 450 RUB 0 False Пополнение счета
472 BUY 2026-08-27 11:04:52 TMOS 5,16 121 RUB 0 MCX False
473 CASH_IN 2026-08-28 11:45:37 RUB 1 450 RUB 0 False Пополнение счета
474 CASH_IN 2026-08-29 12:12:09 RUB 1 450 RUB 0 False Пополнение счета
475 CASH_IN 2026-08-30 12:07:12 RUB 1 450 RUB 0 False Пополнение счета
476 BUY 2026-08-31 11:00:16 TBRU 8,44 40 RUB 0 MCX False
477 BUY 2026-08-31 11:00:16 TMOS 5,33 173 RUB 0 MCX False
478 DIVIDEND 2026-08-31 12:29:47 RU000A10B0U0 0 283,5 RUB 0 MCX False
479 CASH_IN 2026-08-31 13:44:09 RUB 1 450 RUB 0 False Пополнение счета
480 BUY 2026-09-01 11:00:11 TBRU 8,46 25 RUB 0 MCX False
481 BUY 2026-09-01 11:00:11 TMOS 5,43 38 RUB 0 MCX False
482 CASH_IN 2026-09-01 12:49:47 RUB 1 450 RUB 0 False Пополнение счета
483 BUY 2026-09-02 11:00:17 TBRU 8,45 18 RUB 0 MCX False
484 BUY 2026-09-02 11:00:17 TMOS 5,4 56 RUB 0 MCX False
485 CASH_IN 2026-09-02 14:10:10 RUB 1 450 RUB 0 False Пополнение счета
486 DIVIDEND 2026-09-03 10:57:34 SU29007RMFS0 0 1307,4 RUB 0 MCX False
487 BUY 2026-09-03 11:00:18 TBRU 8,45 113 RUB 0 MCX False
488 BUY 2026-09-03 11:00:18 TMOS 5,45 425 RUB 0 MCX False
489 BUY 2026-09-03 11:20:02 SU26207RMFS9 980,48 2 RUB 5,88 MCX 6,7 False
490 CASH_IN 2026-09-03 13:52:30 RUB 1 450 RUB 0 False Пополнение счета
491 BUY 2026-09-04 11:00:21 TMOS 5,58 9 RUB 0 MCX False
492 BUY 2026-09-04 11:00:22 TBRU 8,45 50 RUB 0 MCX False
493 CASH_IN 2026-09-04 11:31:06 RUB 1 450 RUB 0 False Пополнение счета
494 DIVIDEND 2026-09-04 12:06:55 RU000A10B214 0 154,1 RUB 0 MCX False
495 CASH_IN 2026-09-05 13:46:10 RUB 1 450 RUB 0 False Пополнение счета
496 CASH_IN 2026-09-06 14:12:11 RUB 1 450 RUB 0 False Пополнение счета
497 BUY 2026-09-07 11:00:18 TBRU 8,46 66 RUB 0 MCX False
498 BUY 2026-09-07 11:00:18 TMOS 5,58 140 RUB 0 MCX False
499 BUY 2026-09-08 11:00:20 TBRU 8,46 2 RUB 0 MCX False
500 DIVIDEND 2026-09-08 13:02:22 RU000A10CQ85 0 172,65 RUB 0 MCX False
501 CASH_IN 2026-09-11 16:30:24 RUB 1 2300 RUB 0 False Пополнение счета
502 BUY 2026-09-11 18:32:59 TBRU 8,47 97 RUB 0 MCX False
503 BUY 2026-09-11 18:32:59 TGLD 14,32 41 RUB 0 MCX False
504 BUY 2026-09-11 18:32:59 TMOS 5,69 151 RUB 0 MCX False
505 CUSTOM_HOLDING_PRICE 2026-09-12 00:00:00 SIBN6P4 12228,06098 0 RUB 0 CUSTOM_HOLDING 0
506 CASH_IN 2026-09-12 11:31:49 RUB 1 450 RUB 0 False Пополнение счета
507 CUSTOM_HOLDING_PRICE 2026-09-13 00:00:00 SIBN6P4 12228,06098 0 RUB 0 CUSTOM_HOLDING 0
508 CASH_IN 2026-09-13 13:32:57 RUB 1 450 RUB 0 False Пополнение счета
509 CASH_IN 2026-09-13 14:37:16 RUB 1 10000 RUB 0 False Пополнение счета
510 CUSTOM_HOLDING_PRICE 2026-09-14 00:00:00 SIBN6P4 12320,94504 0 RUB 0 CUSTOM_HOLDING 0
511 BUY 2026-09-14 11:00:21 TBRU 8,48 60 RUB 0 MCX False
512 BUY 2026-09-14 11:00:21 TMOS 5,78 10 RUB 0 MCX False
513 BUY 2026-09-14 11:00:22 TGLD 14,15 25 RUB 0 MCX False
514 CASH_IN 2026-09-14 14:16:05 RUB 1 450 RUB 0 False Пополнение счета
515 CUSTOM_HOLDING_PRICE 2026-09-15 00:00:00 SIBN6P4 12284,594 0 RUB 0 CUSTOM_HOLDING 0
516 BUY 2026-09-15 11:00:18 TBRU 8,49 39 RUB 0 MCX False
517 BUY 2026-09-15 11:00:18 TGLD 14,2 8 RUB 0 MCX False
518 CASH_IN 2026-09-15 14:34:11 RUB 1 450 RUB 0 False Пополнение счета
519 DIVIDEND 2026-09-15 15:56:39 RU000A10BF48 0 129 RUB 0 MCX False
520 DIVIDEND 2026-09-15 16:51:49 RU000A10AT35 0 212,7 RUB 0 MCX False
521 BUY 2026-09-16 00:00:00 AFLT 32,65 30 RUB 3,22 MCX 0 RUB False
522 BUY 2026-09-16 00:00:00 AFLT 32,66 100 RUB 10,78 MCX 0 RUB False
523 BUY 2026-09-16 00:00:00 CHMF 638,4 2 RUB 4,22 MCX 0 RUB False
524 BUY 2026-09-16 00:00:00 CHMF 639,4 5 RUB 10,55 MCX 0 RUB False
525 BUY 2026-09-16 00:00:00 MSNG 1,44 1000 RUB 4,75 MCX 0 RUB False
526 BUY 2026-09-16 00:00:00 MSNG 1,44 2000 RUB 9,49 MCX 0 RUB False
527 BUY 2026-09-16 00:00:00 MTSS 189,8 2 RUB 1,26 MCX 0 RUB False
528 BUY 2026-09-16 00:00:00 MTSS 189,1 10 RUB 6,25 MCX 0 RUB False
529 BUY 2026-09-16 00:00:00 MTSS 190,25 5 RUB 3,14 MCX 0 RUB False
530 BUY 2026-09-16 00:00:00 PHOR 5309 2 RUB 35,05 MCX 0 RUB False
531 CASH_IN 2026-09-16 00:00:00 RUB 1 10000 RUB 0 False
532 SELL 2026-09-16 00:00:00 SBRB 19,06 983 RUB 3,75 MCX 0 RUB False
533 BUY 2026-09-16 11:00:22 TMOS 5,8 80 RUB 0 MCX False
534 CASH_IN 2026-09-16 11:31:47 RUB 1 450 RUB 0 False Пополнение счета
535 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}}
536 BUY 2026-09-17 11:00:21 TMOS 5,61 80 RUB 0 MCX False
537 CASH_IN 2026-09-17 11:29:23 RUB 1 450 RUB 0 False Пополнение счета
538 BUY 2026-09-17 16:23:00 RU000A10BK09 1014,4 2 RUB 6,09 MCX 8,33 False
539 BUY 2026-09-17 16:23:11 RU000A10BK09 1014,4 3 RUB 9,13 MCX 8,33 False
540 BUY 2026-09-17 16:23:18 RU000A10BK09 1014,5 1 RUB 3,04 MCX 8,33 False
541 BUY 2026-09-17 16:23:50 RU000A10BF48 1006 4 RUB 12,07 MCX 2,15 False
542 SELL 2026-09-17 16:24:21 RU000A10CQ85 1000,2 9 RUB 27 MCX 4,22 False
543 SELL 2026-09-17 16:24:26 RU000A10CQ85 1000,1 1 RUB 3 MCX 4,22 False
544 SELL 2026-09-17 16:24:30 RU000A10CQ85 1000 5 RUB 15 MCX 4,22 False
545 BUY 2026-09-17 16:24:46 RU000A10BK09 1014,4 7 RUB 21,3 MCX 8,33 False
546 BUY 2026-09-17 16:24:54 RU000A10BF48 1006 8 RUB 24,14 MCX 2,15 False
+420
View File
@@ -0,0 +1,420 @@
"""The universal event-CSV parser, checked against the real Snowball export.
The fixture is the only file in the project that covers every brokerage account at once,
so these tests double as its documentation: the exact number of rows of each type, which
of them deliberately never become ledger events, and what the format's ambiguities were
decided to mean. A change in any of those numbers is a change in the import contract, not
a test that needs relaxing.
"""
from __future__ import annotations
import collections
import dataclasses
from decimal import Decimal
from pathlib import Path
import pytest
from fintracker.models.ledger import EventKind
from fintracker.sources.reports.base import BrokerEvent, ParsedReport, fingerprint_key
from fintracker.sources.reports.csv_universal import UniversalCsvParser
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "reports"
SNOWBALL = FIXTURES / "snowball"
HEADER = (
"Event,Date,Symbol,Price,Quantity,Currency,FeeTax,Exchange,NKD,FeeCurrency,DoNotAdjustCash,Note"
)
#: What the fixture must yield, per kind. Sums to 535 of its 545 rows: 5 CUSTOM_HOLDING_PRICE
#: and 1 CUSTOM_HOLDING_SETTINGS are not events at all, and 4 cash rows are Snowball's own
#: balance corrections.
EXPECTED_COUNTS = {
EventKind.buy: 270,
EventKind.sell: 60,
EventKind.dividend: 80,
EventKind.deposit: 96,
EventKind.withdrawal: 5,
EventKind.amortization: 9,
EventKind.commission: 7,
EventKind.tax: 4,
EventKind.tax_refund: 2,
EventKind.stock_split: 2,
}
@pytest.fixture(scope="module")
def parser() -> UniversalCsvParser:
return UniversalCsvParser()
@pytest.fixture(scope="module")
def fixture_path() -> Path:
files = sorted(SNOWBALL.glob("*.csv"))
assert files, f"нет фикстуры в {SNOWBALL}"
return files[0]
@pytest.fixture(scope="module")
def report(parser: UniversalCsvParser, fixture_path: Path) -> ParsedReport:
return parser.parse(fixture_path.read_bytes(), fixture_path.name)
def _csv(*rows: str) -> bytes:
return ("" + "\n".join((HEADER, *rows)) + "\n").encode("utf-8")
def _key(report: ParsedReport, event: BrokerEvent) -> str:
"""The dedupe key `ledger/ingest.py` will build for this event (plan §1.6 A)."""
return fingerprint_key(
report.broker,
report.account_external_id,
event.kind,
event.instrument.key() if event.instrument else "",
event.trade_date,
event.quantity,
event.price,
event.currency,
amount=event.amount,
seq=event.seq,
)
def _floats(value: object, path: str) -> list[str]:
"""Every float reachable from `value`, by path — money must be Decimal end to end."""
if isinstance(value, bool | str | bytes) or value is None:
return []
if isinstance(value, float):
return [f"{path} = {value!r}"]
if dataclasses.is_dataclass(value) and not isinstance(value, type):
return [
bad
for f in dataclasses.fields(value)
for bad in _floats(getattr(value, f.name), f"{path}.{f.name}")
]
if isinstance(value, dict):
return [bad for k, v in value.items() for bad in _floats(v, f"{path}[{k!r}]")]
if isinstance(value, list | tuple | set):
return [bad for i, v in enumerate(value) for bad in _floats(v, f"{path}[{i}]")]
return []
# --- 1. sniff -------------------------------------------------------------------------
def test_sniff_recognises_the_fixture(parser: UniversalCsvParser, fixture_path: Path) -> None:
assert parser.sniff(fixture_path.read_bytes(), fixture_path.name) is True
def test_sniff_ignores_the_filename(parser: UniversalCsvParser) -> None:
"""Recognition is by the header, so a renamed export is still recognised..."""
data = _csv('"CASH_IN","2025-02-26 09:22:54","RUB","1","5000","RUB","0","","","","False",""')
assert parser.sniff(data, "какой-то-файл.csv") is True
assert parser.sniff(data, "") is True
def test_sniff_rejects_other_report_formats(parser: UniversalCsvParser) -> None:
"""...and a file that merely has an extension we read is not ours."""
others = sorted((FIXTURES / "sber").glob("*.html")) + sorted((FIXTURES / "vtb").glob("*.xlsx"))
assert others, "нет чужих фикстур для отрицательной проверки"
for path in others:
assert parser.sniff(path.read_bytes(), path.name) is False, path.name
def test_sniff_rejects_a_csv_with_foreign_columns(parser: UniversalCsvParser) -> None:
assert parser.sniff(b"a,b,c\n1,2,3\n", "x.csv") is False
assert parser.sniff(b"", "x.csv") is False
assert parser.sniff(b"\x00\x01\x02", "x.csv") is False
def test_parse_rejects_a_file_it_cannot_read(parser: UniversalCsvParser) -> None:
from fintracker.sources.reports.base import ParseError
with pytest.raises(ParseError):
parser.parse(b"a,b,c\n1,2,3\n", "x.csv")
with pytest.raises(ParseError):
parser.parse(_csv(), "empty.csv")
# --- 2. what the fixture yields --------------------------------------------------------
def test_report_header(report: ParsedReport) -> None:
import datetime
assert report.broker == "csv"
assert report.parser_version == "1"
# the file names a portfolio, never an account — see the warning below
assert report.account_external_id == "Мой капитал"
assert report.period_from == datetime.date(2025, 2, 26)
assert report.period_to == datetime.date(2026, 9, 17)
def test_event_counts_by_kind(report: ParsedReport) -> None:
counts = collections.Counter(e.kind for e in report.events)
assert dict(counts) == EXPECTED_COUNTS
assert len(report.events) == 535
assert report.meta["row_count"] == 545
def test_instruments_are_deduplicated(report: ParsedReport) -> None:
keys = [ref.key() for ref in report.instruments]
assert len(keys) == len(set(keys)) == 49
# bonds arrive by ISIN, everything else by ticker; cash rows name no instrument
assert "ISIN:RU000A10B7T7" in keys
assert "TICKER:SBER" in keys
assert "TICKER:SU26212RMFS9" in keys, "секунда ОФЗ — не ISIN, а тикер MOEX"
assert not any(k.startswith("TICKER:RUB") for k in keys)
# --- 3. decimals ----------------------------------------------------------------------
def test_decimal_comma_is_parsed(parser: UniversalCsvParser) -> None:
data = _csv(
'"BUY","2025-03-01 10:00:00","SBER","4,48","219","RUB","6,57","MCX","0","RUB","False",""'
)
(event,) = parser.parse(data, "renamed.csv").events
assert event.price == Decimal("4.48")
assert event.quantity == Decimal("219")
assert event.fee == Decimal("6.57")
assert event.amount == Decimal("-987.69") # 4.48 * 219 + 6.57, fee capitalised
def test_no_floats_anywhere_in_the_output(report: ParsedReport) -> None:
bad = _floats(report, "report")
assert bad == []
def test_empty_numeric_cell_is_none_not_zero(parser: UniversalCsvParser) -> None:
"""An absent НКД is not a zero НКД: one is «not a bond», the other is «no interest»."""
data = _csv('"BUY","2025-03-01 10:00:00","SBER","100","1","RUB","0","MCX","","","False",""')
(event,) = parser.parse(data, "x.csv").events
assert event.accrued_interest is None
assert event.fee == Decimal(0)
def test_bond_trade_adds_accrued_interest_to_the_money(report: ParsedReport) -> None:
"""НКД is paid on top of the price by the buyer — it must be inside `amount`."""
buy = next(
e
for e in report.events
if e.kind is EventKind.buy
and e.instrument is not None
and e.instrument.isin == "RU000A107AM4"
and e.accrued_interest == Decimal("5.08")
)
assert buy.quantity == Decimal(4)
assert buy.price == Decimal("929.25")
assert buy.amount == -(Decimal(4) * Decimal("929.25") + Decimal("5.08") + Decimal("11.15"))
# --- 4. dividends and coupons ----------------------------------------------------------
def test_dividend_money_lands_in_amount_not_quantity(report: ParsedReport) -> None:
dividends = [e for e in report.events if e.kind is EventKind.dividend]
assert len(dividends) == 80
assert all(e.quantity is None for e in dividends), "Quantity у DIVIDEND — это деньги"
assert all(e.price is None for e in dividends)
assert all(e.amount > 0 for e in dividends)
assert sum(e.amount for e in dividends) == Decimal("12949.07")
def test_bond_dividends_carry_the_coupon_hint(report: ParsedReport) -> None:
"""The parser cannot know an asset class, so it hands ingest a suspicion, not a kind."""
dividends = [e for e in report.events if e.kind is EventKind.dividend]
hinted = [e for e in dividends if e.meta.get("payout_hint") == "coupon"]
assert len(hinted) == 44
assert all(
e.instrument is not None
and (
(e.instrument.isin or "").startswith("RU000A")
or (e.instrument.ticker or "").startswith("SU")
)
for e in hinted
)
# a share payout must never be hinted
assert all(
e.meta.get("payout_hint") is None
for e in dividends
if e.instrument is not None and e.instrument.ticker in {"T", "ROSN", "SBERP", "TATNP"}
)
# --- 5. splits -------------------------------------------------------------------------
def test_split_ratio_and_collapsing_dedupe_key(report: ParsedReport) -> None:
"""Two identical SPLIT rows for T are one corporate action seen on two accounts.
The format has no account column, so both rows land on the single account the user
points the import at. They therefore share a `fingerprint_key` on purpose: replaying the
ratio twice would multiply the position by 100 instead of by 10. This test pins that
behaviour — if a future format gains an account column, it is expected to break.
"""
splits = [e for e in report.events if e.kind is EventKind.stock_split]
assert len(splits) == 2
assert {e.meta["split_ratio"] for e in splits} == {Decimal(10)}
assert {e.meta["ratio"] for e in splits} == {Decimal(10)}
assert all(e.amount == Decimal(0) and e.quantity is None for e in splits)
assert all(e.instrument is not None and e.instrument.ticker == "T" for e in splits)
assert [e.seq for e in splits] == [0, 0]
assert _key(report, splits[0]) == _key(report, splits[1])
def test_every_other_event_keeps_a_distinct_dedupe_key(report: ParsedReport) -> None:
"""The split pair is the ONLY intentional collapse in the file.
In particular the pairs of equal same-day top-ups (two 1 100 ₽ one second apart, two
4 000 ₽ that differ only by their note) survive as distinct events: they are real money
into two accounts, and netting them would understate the XIRR denominator.
"""
keys = [_key(report, e) for e in report.events]
collapsed = [k for k, n in collections.Counter(keys).items() if n > 1]
assert len(collapsed) == 1
assert len(set(keys)) == 534
same_second = [e for e in report.events if e.kind is EventKind.deposit and e.seq > 0]
assert same_second, "в файле есть одинаковые пополнения одного дня — они должны выживать"
def test_seq_is_stable_across_reparsing(parser: UniversalCsvParser, fixture_path: Path) -> None:
"""A re-import of the same bytes must reproduce every key, or nothing ever upserts."""
data = fixture_path.read_bytes()
first = parser.parse(data, fixture_path.name)
second = parser.parse(data, "совсем другое имя.csv")
assert [_key(first, e) for e in first.events] == [_key(first, e) for e in second.events]
# --- 6. amortisation -------------------------------------------------------------------
def test_amortisation_is_money_on_a_bond(report: ParsedReport) -> None:
amortisations = [e for e in report.events if e.kind is EventKind.amortization]
assert len(amortisations) == 9
assert all(e.amount > 0 for e in amortisations)
assert all(e.quantity is None and e.price is None for e in amortisations)
assert {e.instrument.isin for e in amortisations if e.instrument} == {"RU000A10B7T7"}
assert all(
e.instrument is not None and e.instrument.asset_class_hint == "bond" for e in amortisations
)
assert sum(e.amount for e in amortisations) == Decimal("4530.07")
# --- 7. manual prices ------------------------------------------------------------------
def test_custom_holding_prices_go_to_meta_not_to_events(report: ParsedReport) -> None:
"""CUSTOM_HOLDING_PRICE is a `price_manual` row, not a ledger event."""
prices = report.meta["manual_prices"]
assert len(prices) == 5
assert {p["instrument_key"] for p in prices} == {"TICKER:SIBN6P4"}
assert {p["currency"] for p in prices} == {"RUB"}
assert all(isinstance(p["price"], Decimal) for p in prices)
assert max(p["price"] for p in prices) == Decimal("12320.94504")
assert all(e.meta.get("csv_event") != "CUSTOM_HOLDING_PRICE" for e in report.events)
# the instrument they price must be resolvable, so it is also listed
assert "TICKER:SIBN6P4" in {ref.key() for ref in report.instruments}
def test_the_export_has_no_price_for_the_other_unquoted_paper(report: ParsedReport) -> None:
"""NDM_TBNK-PP-FIXPRCNT-08.25 is simply absent — this file cannot price it."""
named = {(ref.ticker or "") + (ref.isin or "") for ref in report.instruments}
assert not any("NDM" in name for name in named)
assert not any("NDM" in p["instrument_key"] for p in report.meta["manual_prices"])
def test_custom_holding_settings_describe_an_instrument(report: ParsedReport) -> None:
"""Its Note is JSON with `"` rewritten to `@*@` and Cyrillic as \\uXXXX escapes."""
ref = next(r for r in report.instruments if r.ticker == "SIBN6P4")
assert ref.name == "Газпром Нефть 006Р-04"
assert ref.currency == "RUB"
assert ref.meta["custom_holding"] is True
assert ref.meta["sector"] == "Other"
assert ref.meta["settings"]["CustomHoldingType"] == 0
assert all(e.meta.get("csv_event") != "CUSTOM_HOLDING_SETTINGS" for e in report.events)
def test_broken_settings_json_is_a_warning_not_a_crash(parser: UniversalCsvParser) -> None:
data = _csv(
'"CUSTOM_HOLDING_SETTINGS","2026-09-17 00:00:00","XYZ","0","0","RUB","0",'
'"CUSTOM_HOLDING","0","","","{@*@Holding@*@:'
)
result = parser.parse(data, "x.csv")
assert result.events == []
assert any("не удалось разобрать JSON" in w for w in result.warnings)
assert [ref.key() for ref in result.instruments] == ["TICKER:XYZ"]
# --- 8. balance adjustments ------------------------------------------------------------
def test_balance_adjustments_never_become_flows(report: ParsedReport) -> None:
"""Snowball's own corrections would feed XIRR an external flow that never happened."""
adjustments = report.meta["balance_adjustments"]
assert len(adjustments) == 4
assert collections.Counter(a["kind"] for a in adjustments) == {"CASH_OUT": 3, "CASH_IN": 1}
assert all(isinstance(a["amount"], Decimal) for a in adjustments)
# detection is by the note, not by the amount: one correction is 1017,84 ₽ while a
# genuine withdrawal in the same file is 32 ₽
assert min(abs(a["amount"]) for a in adjustments) == Decimal("0.0024")
assert max(abs(a["amount"]) for a in adjustments) == Decimal("1017.83999543")
excluded = {(a["kind"], a["d"], abs(a["amount"])) for a in adjustments}
for event in report.events:
kind = "CASH_IN" if event.kind is EventKind.deposit else "CASH_OUT"
assert (kind, event.trade_date, abs(event.amount)) not in excluded
assert any("правка остатка" in w for w in report.warnings)
def test_real_cash_flows_survive(report: ParsedReport) -> None:
deposits = [e for e in report.events if e.kind is EventKind.deposit]
withdrawals = [e for e in report.events if e.kind is EventKind.withdrawal]
assert len(deposits) == 96 and len(withdrawals) == 5
assert all(e.amount > 0 and e.instrument is None for e in deposits)
assert all(e.amount < 0 and e.instrument is None for e in withdrawals)
assert sum(e.amount for e in withdrawals) == -Decimal("51294.41")
assert sum(e.amount for e in deposits) == Decimal("454058.45")
# --- 9. fees, taxes, refunds -----------------------------------------------------------
def test_fee_tax_and_refund_signs(report: ParsedReport) -> None:
fees = [e for e in report.events if e.kind is EventKind.commission]
taxes = [e for e in report.events if e.kind is EventKind.tax]
refunds = [e for e in report.events if e.kind is EventKind.tax_refund]
assert (len(fees), len(taxes), len(refunds)) == (7, 4, 2)
assert all(e.instrument is None for e in fees + taxes + refunds)
assert all(e.quantity is None and e.price is None for e in fees + taxes + refunds)
assert all(e.amount == Decimal(-45) and e.fee == Decimal(45) for e in fees)
assert [e.amount for e in taxes] == [Decimal(-234), Decimal(-3), Decimal(-22), Decimal(-32)]
assert [e.amount for e in refunds] == [Decimal(3), Decimal(32)]
assert all(e.tax == -e.amount for e in taxes)
assert all(e.tax == e.amount for e in refunds)
# --- unknown types and reconciliation warnings -----------------------------------------
def test_unknown_event_type_is_skipped_with_a_warning(parser: UniversalCsvParser) -> None:
"""Never `EventKind.other`: a silent bucket hides a format that grew a new row type."""
data = _csv(
'"MARGIN_CALL","2025-03-01 10:00:00","SBER","1","1","RUB","0","MCX","0","","False",""',
'"BUY","2025-03-01 10:00:00","SBER","100","1","RUB","0","MCX","0","","False",""',
)
result = parser.parse(data, "x.csv")
assert [e.kind for e in result.events] == [EventKind.buy]
assert result.meta["unknown_events"] == {"MARGIN_CALL": 1}
assert any("MARGIN_CALL" in w for w in result.warnings)
assert not any(e.kind is EventKind.other for e in result.events)
def test_closing_balances_are_absent_and_said_to_be(report: ParsedReport) -> None:
assert report.positions_end == []
assert report.cash_end == []
assert any("positions_end" in w for w in report.warnings)
def test_warns_that_the_target_account_is_the_user_s_to_choose(report: ParsedReport) -> None:
assert any("shadow" in w and "все брокерские счета" in w for w in report.warnings)
@@ -0,0 +1,56 @@
"""Реестр парсеров отчётов: какой файл достаётся какому парсеру (plan §2).
Проверка выглядит тривиальной, но ловит ровно ту ошибку, которую больше не поймает никто:
универсальный CSV узнаёт файл по набору колонок, то есть по определению шире любого
брокерского формата, и достаточно поставить его в списке первым, чтобы отчёт Сбера тихо
уехал в чужой парсер. Порядок в `PARSERS` — это поведение, а не оформление.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from fintracker.sources.reports import registry
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "reports"
CASES = [
(FIXTURES / "sber" / "S930W42_11022026_17092026.html", "report_sber"),
(FIXTURES / "sber" / "S930W42_01082026_31082026.html", "report_sber"),
(FIXTURES / "vtb" / "reportd0235f1e-55ac-fdc8-5c7b-279e4fe26e2b.xlsx", "report_vtb"),
(FIXTURES / "vtb" / "report8ef8347a-2deb-e880-959a-d9a8ca12f735.xlsx", "report_vtb"),
(next((FIXTURES / "snowball").glob("*.csv")), "csv"),
]
@pytest.mark.parametrize("path,expected", CASES, ids=lambda v: getattr(v, "name", v))
def test_pick_routes_each_fixture_to_its_parser(path: Path, expected: str) -> None:
chosen = registry.pick(path.read_bytes(), path.name)
assert chosen is not None, f"{path.name}: ни один парсер не узнал файл"
assert chosen.name == expected
def test_every_registered_parser_is_reachable_by_name() -> None:
assert set(registry.names()) == {"report_sber", "report_vtb", "csv"}
for name in registry.names():
assert registry.get(name).name == name
def test_unknown_format_is_a_none_not_an_exception() -> None:
"""`pick` возвращает None, и роутер превращает это в 415 — не в 500."""
assert registry.pick(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n", "statement.pdf") is None
assert registry.pick(b"", "empty.txt") is None
def test_parser_metadata_is_filled_in() -> None:
"""`parser_name` и `parser_version` уходят в `raw_report_file` — пустых быть не должно."""
for parser in registry.PARSERS:
assert parser.name and parser.broker and parser.version
assert parser.formats
def test_csv_is_last_so_it_cannot_shadow_a_broker_format() -> None:
names = registry.names()
assert names.index("csv") == len(names) - 1
+362
View File
@@ -0,0 +1,362 @@
"""Парсер отчётов Сбера на обезличенных фикстурах (фаза 3).
Фикстуры — два отчёта по одному и тому же счёту: полный (11.02–17.09.2026) и августовский
(01.0831.08.2026). Пара выбрана не случайно: именно перекрывающиеся периоды ломают импорт,
если ключ дедупликации выводится из файла, а не из самой операции.
Главная проверка здесь — арифметическая. Отчёт печатает свои итоги («Итого, RUB» по
сделкам, «Пополнение счета» и «Исходящий остаток» в сводке), и распарсенные события обязаны
их воспроизвести: пополнения минус покупки плюс продажи = остаток на конец периода. Если
парсер потеряет строку, задвоит комиссию или примет расчётную строку за сделку, это
равенство разойдётся — а тест на «количество событий» этого не заметит.
"""
from __future__ import annotations
from collections import Counter
from decimal import Decimal
from pathlib import Path
import pytest
from fintracker.models.ledger import EventKind
from fintracker.sources.reports.base import BrokerEvent, ParsedReport
from fintracker.sources.reports.sber import SberHtmlParser, event_dedupe_key
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "reports"
FULL = FIXTURES / "sber" / "S930W42_11022026_17092026.html"
AUGUST = FIXTURES / "sber" / "S930W42_01082026_31082026.html"
ACCOUNT = "S930W42"
#: Второй договор того же брокера — источник переводов д/с, не внешний поток.
OTHER_AGREEMENT = "8184V30"
ZERO = Decimal(0)
@pytest.fixture(scope="module")
def parser() -> SberHtmlParser:
return SberHtmlParser()
@pytest.fixture(scope="module")
def full(parser: SberHtmlParser) -> ParsedReport:
return parser.parse(FULL.read_bytes(), FULL.name)
@pytest.fixture(scope="module")
def august(parser: SberHtmlParser) -> ParsedReport:
return parser.parse(AUGUST.read_bytes(), AUGUST.name)
def total(events: list[BrokerEvent], kind: EventKind) -> Decimal:
return sum((e.amount for e in events if e.kind == kind), ZERO)
def of_kind(report: ParsedReport, kind: EventKind) -> list[BrokerEvent]:
return [e for e in report.events if e.kind == kind]
# --- 1. распознавание формата ------------------------------------------------------------
def test_sniff_accepts_both_sber_reports(parser: SberHtmlParser) -> None:
assert parser.sniff(FULL.read_bytes(), FULL.name)
assert parser.sniff(AUGUST.read_bytes(), AUGUST.name)
@pytest.mark.parametrize(
"path",
sorted((FIXTURES / "vtb").glob("*.xlsx")) + sorted((FIXTURES / "snowball").glob("*.csv")),
ids=lambda p: p.suffix,
)
def test_sniff_rejects_other_formats(parser: SberHtmlParser, path: Path) -> None:
assert not parser.sniff(path.read_bytes(), path.name)
def test_sniff_never_raises_on_garbage(parser: SberHtmlParser) -> None:
"""`registry.pick` опрашивает парсеры подряд — падение на чужом файле остановило бы перебор."""
assert not parser.sniff(b"", "empty.html")
assert not parser.sniff(b"\x00\x01\x02not html at all", "junk.html")
# --- 2. шапка и состав ---------------------------------------------------------------------
def test_full_report_header(full: ParsedReport) -> None:
assert full.broker == "sber"
assert full.account_external_id == ACCOUNT
assert (full.period_from.isoformat(), full.period_to.isoformat()) == (
"2026-02-11",
"2026-09-17",
)
assert full.meta["opened_at"] == "2026-02-11"
def test_august_report_header(august: ParsedReport) -> None:
assert august.account_external_id == ACCOUNT
assert (august.period_from.isoformat(), august.period_to.isoformat()) == (
"2026-08-01",
"2026-08-31",
)
def test_full_report_event_counts(full: ParsedReport) -> None:
assert Counter(e.kind for e in full.events) == {
EventKind.buy: 24,
EventKind.deposit: 9,
EventKind.sell: 2,
}
def test_august_report_event_counts(august: ParsedReport) -> None:
assert Counter(e.kind for e in august.events) == {
EventKind.buy: 2,
EventKind.deposit: 1,
}
# --- 3. арифметика: события воспроизводят итоги самого отчёта -------------------------------
def test_trade_totals_match_the_reports_own_total_row(full: ParsedReport) -> None:
"""«Итого, RUB» таблицы сделок: 89 205.58 оборота, 138.03 брокеру, 19.54 бирже."""
trades = of_kind(full, EventKind.buy) + of_kind(full, EventKind.sell)
assert len(trades) == 26
# Оборот — это «Сумма» сделки без комиссии, а `amount` её уже включает: у покупки
# прибавляет, у продажи вычитает. Отсюда знаки при обратном пересчёте.
turnover = sum(
(-e.amount - (e.fee or ZERO) if e.kind == EventKind.buy else e.amount + (e.fee or ZERO))
for e in trades
)
assert turnover == Decimal("89205.58")
assert sum((e.fee or ZERO for e in full.events), ZERO) == Decimal("138.03") + Decimal("19.54")
def test_deposits_match_the_summary_line(full: ParsedReport) -> None:
"""«Пополнение счета» сводки считает и переводы с другого договора — как и парсер."""
assert total(full.events, EventKind.deposit) == Decimal("49120.87")
assert Decimal(full.meta["summary"]["Пополнение счета"]) == Decimal("49120.87")
def test_cash_closes_against_the_reported_balance(full: ParsedReport) -> None:
"""Сквозная проверка: сумма денежных эффектов всех событий = исходящий остаток.
Это единственная проверка, которая ловит и потерянную строку, и лишнюю: пропущенная
сделка занижает отток, а расчётная строка «Сделка от …», принятая за сделку, задваивает
его. Входящий остаток нулевой, поэтому сумма событий и есть конечный остаток.
"""
assert Decimal(full.meta["summary"]["Входящий остаток"]) == ZERO
assert sum((e.amount for e in full.events), ZERO) == Decimal("3171.34")
rub = next(c for c in full.cash_end if c.currency == "RUB")
assert rub.balance == Decimal("3171.34")
def test_august_cash_closes_from_its_own_opening_balance(august: ParsedReport) -> None:
opening = Decimal(august.meta["summary"]["Входящий остаток"])
assert opening == Decimal("357.15")
rub = next(c for c in august.cash_end if c.currency == "RUB")
assert opening + sum((e.amount for e in august.events), ZERO) == rub.balance
# --- 4. комиссии учтены ровно один раз -----------------------------------------------------
def test_commission_is_capitalised_and_never_emitted_separately(full: ParsedReport) -> None:
"""Комиссия живёт в `fee` сделки; отдельных `commission`-событий быть не должно.
`ledger/lots.py` капитализирует `fee` в стоимость лота, поэтому вторая, «денежная»
ипостась той же комиссии (строки «Комиссия Брокера от …» в движении денег) ушла бы в
леджер повторно — как отток, которого не было.
"""
assert not of_kind(full, EventKind.commission)
assert all(e.fee is None for e in full.events if e.kind == EventKind.deposit)
fees = sum((e.fee or ZERO for e in full.events), ZERO)
assert fees == Decimal("157.57")
assert Decimal(full.meta["summary"]["Комиссия брокера"]) == Decimal("-138.03")
assert Decimal(full.meta["summary"]["Комиссия биржи"]) == Decimal("-19.54")
def test_parser_reports_no_fee_divergence(full: ParsedReport, august: ParsedReport) -> None:
"""Парсер сам сверяет две ипостаси комиссии и предупреждает при расхождении."""
for report in (full, august):
assert not [w for w in report.warnings if "комиссии расходятся" in w]
# --- 5. дедупликация перекрывающихся периодов ----------------------------------------------
def keys(report: ParsedReport) -> dict[str, BrokerEvent]:
return {event_dedupe_key(report, e): e for e in report.events}
def test_august_events_keep_their_keys_in_the_full_report(
full: ParsedReport, august: ParsedReport
) -> None:
"""Каждое августовское событие должно прийти с тем же ключом из полного отчёта.
Иначе импорт двух пересекающихся отчётов задвоит август — ровно тот случай, который
план §1.6 A называет «перекрывающиеся периоды апсертят в ту же строку».
"""
full_keys, august_keys = keys(full), keys(august)
assert set(august_keys) <= set(full_keys), (
"у августовских событий появились ключи, которых нет в полном отчёте: "
f"{sorted(set(august_keys) - set(full_keys))}"
)
for key, event in august_keys.items():
twin = full_keys[key]
assert (event.kind, event.trade_date, event.quantity, event.price, event.amount) == (
twin.kind,
twin.trade_date,
twin.quantity,
twin.price,
twin.amount,
), f"ключ {key} указывает на разные операции"
def test_overlap_covers_every_august_operation(august: ParsedReport) -> None:
"""Перекрытие не должно оказаться пустым — иначе предыдущий тест проходит вхолостую."""
assert len(keys(august)) == len(august.events) == 3
def test_trades_key_on_the_brokers_deal_number(full: ParsedReport) -> None:
trades = of_kind(full, EventKind.buy) + of_kind(full, EventKind.sell)
assert all(e.trade_no for e in trades)
assert len({e.trade_no for e in trades}) == len(trades)
def test_cash_rows_have_stable_keys_without_a_deal_number(full: ParsedReport) -> None:
"""У денежных строк номера нет, и ключ обязан оставаться уникальным внутри отчёта."""
deposits = of_kind(full, EventKind.deposit)
assert all(e.trade_no is None for e in deposits)
assert len({event_dedupe_key(full, e) for e in deposits}) == len(deposits)
def test_reparsing_the_same_bytes_is_deterministic(parser: SberHtmlParser) -> None:
once = parser.parse(FULL.read_bytes(), FULL.name)
twice = parser.parse(FULL.read_bytes(), FULL.name)
assert [event_dedupe_key(once, e) for e in once.events] == [
event_dedupe_key(twice, e) for e in twice.events
]
# --- 6. закрывающие позиции и остатки ------------------------------------------------------
def test_closing_positions(full: ParsedReport) -> None:
assert len(full.positions_end) == 10
assert sum((p.market_value or ZERO for p in full.positions_end), ZERO) == Decimal("46663.30")
aeroflot = next(p for p in full.positions_end if p.instrument.isin == "RU0009062285")
assert aeroflot.qty == Decimal("130")
assert aeroflot.currency == "RUB"
def test_closing_cash_keeps_every_currency(full: ParsedReport) -> None:
balances = {c.currency: c.balance for c in full.cash_end}
assert balances == {"RUB": Decimal("3171.34"), "EUR": ZERO, "USD": ZERO}
def test_august_closing_snapshot(august: ParsedReport) -> None:
assert len(august.positions_end) == 5
assert sum((p.market_value or ZERO for p in august.positions_end), ZERO) == Decimal("38739.43")
assert next(c for c in august.cash_end if c.currency == "RUB").balance == Decimal("1407.88")
# --- 7. справочник инструментов ------------------------------------------------------------
def test_securities_directory_feeds_instruments(full: ParsedReport) -> None:
assert len(full.instruments) >= 9
assert all(i.isin and i.ticker for i in full.instruments)
assert all(i.asset_class_hint for i in full.instruments)
sber = next(i for i in full.instruments if i.ticker == "SBER")
assert (sber.isin, sber.asset_class_hint) == ("RU0009029540", "share")
fund = next(i for i in full.instruments if i.ticker == "STME")
assert fund.asset_class_hint in {"etf", "fund"}
def test_trades_reference_instruments_by_isin(full: ParsedReport) -> None:
"""Сделка печатает только тикер — ISIN подставляется из справочника того же файла."""
trades = of_kind(full, EventKind.buy) + of_kind(full, EventKind.sell)
assert all(e.instrument is not None and e.instrument.isin for e in trades)
# --- 8. ловушки формата --------------------------------------------------------------------
def test_iis_contributions_table_is_read_but_not_emitted(
full: ParsedReport, august: ParsedReport
) -> None:
"""Таблица зачислений на ИИС кумулятивна за ГОД, а не за период отчёта.
Августовский файл перечисляет в ней восемь пополнений начиная с февраля — всё, что
случилось до даты его формирования (01.09.2026); в полном отчёте их девять, добавилось
сентябрьское. То есть таблица растёт от отчёта к отчёту независимо от периода, и если
бы парсер эмитил её строки, импорт августовского файла поверх полного задвоил бы
пополнения за полгода. Поэтому её содержимое только пересчитывается, в события не идёт:
августовский отчёт даёт ровно одно денежное поступление — то, что реально было в августе.
"""
assert full.meta["iis_contributions_ignored"] == 9
assert august.meta["iis_contributions_ignored"] == 8
assert len(of_kind(august, EventKind.deposit)) == 1
def test_settlement_lines_are_not_mistaken_for_trades(full: ParsedReport) -> None:
"""«Сделка от DD.MM.YYYY» в движении денег — расчёт по уже учтённой сделке."""
assert not [e for e in full.events if (e.description or "").startswith("Сделка от")]
def test_transfer_from_another_agreement_keeps_its_counterparty(full: ParsedReport) -> None:
"""Перевод с другого договора Сбера — deposit, но с сохранённым источником.
Без `internal_transfer_from` эти деньги навсегда останутся внешним потоком: заведи
пользователь второй договор счётом, XIRR увидел бы пополнение здесь и вывод там как два
независимых события.
"""
transfers = [
e
for e in of_kind(full, EventKind.deposit)
if e.meta.get("internal_transfer_from") == OTHER_AGREEMENT
]
assert len(transfers) == 2
assert sum((e.amount for e in transfers), ZERO) == Decimal("3458.88") + Decimal("10551.18")
def test_missing_coupon_section_is_reported_not_assumed(full: ParsedReport) -> None:
"""Раздела «Купонный доход» в этих отчётах нет — это факт для data quality, не молчание."""
assert any("Купонный доход" in w for w in full.warnings)
# --- 9. деньги --------------------------------------------------------------------------------
def numbers(report: ParsedReport) -> list[object]:
values: list[object] = []
for e in report.events:
values += [e.amount, e.quantity, e.price, e.fee, e.tax, e.accrued_interest]
for p in report.positions_end:
values += [p.qty, p.price, p.market_value, p.accrued_interest]
values += [c.balance for c in report.cash_end]
return values
@pytest.mark.parametrize("name", ["full", "august"])
def test_no_floats_anywhere(name: str, request: pytest.FixtureRequest) -> None:
"""Деньги — только Decimal (AGENTS.md). Один float здесь означает потерю копеек ниже."""
report: ParsedReport = request.getfixturevalue(name)
assert not [v for v in numbers(report) if isinstance(v, float)]
assert all(isinstance(v, Decimal) for v in numbers(report) if v is not None)
def test_every_event_carries_currency_and_traceability(full: ParsedReport) -> None:
for event in full.events:
assert event.currency == "RUB"
assert event.raw_line_no > 0
assert event.meta.get("section")
+410
View File
@@ -0,0 +1,410 @@
"""VTB xlsx report parser against the two anonymised fixtures.
The fixtures are the same account rendered twice: the whole life of the IIS (12.0217.09) and
the single month of August, which is a strict subset of it. That overlap is the point — it is
exactly the case the dedupe key exists for, so the deal both files contain must produce one
key, and the monthly file must not invent a trade the full one does not have.
Every number asserted here is taken from «Сводная информация по субсчёту Клиента», the
broker's own totals block: the parser is trusted only insofar as what it extracted adds up to
what VTB printed.
"""
from __future__ import annotations
import io
import zipfile
from collections import Counter
from dataclasses import fields, is_dataclass
from decimal import Decimal
from pathlib import Path
import pytest
from openpyxl import load_workbook
from fintracker.models.ledger import EventKind
from fintracker.sources.reports.base import ParsedReport
from fintracker.sources.reports.vtb import common
from fintracker.sources.reports.vtb.xlsx import VtbXlsxParser
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "reports"
FULL = FIXTURES / "vtb" / "reportd0235f1e-55ac-fdc8-5c7b-279e4fe26e2b.xlsx"
AUGUST = FIXTURES / "vtb" / "report8ef8347a-2deb-e880-959a-d9a8ca12f735.xlsx"
#: The August deal, as printed in both files: «№ сделки» B17476399721, 33 EQMX at 122.9.
AUGUST_TRADE_NO = "B17476399721"
@pytest.fixture(scope="module")
def parser() -> VtbXlsxParser:
return VtbXlsxParser()
@pytest.fixture(scope="module")
def full(parser: VtbXlsxParser) -> ParsedReport:
return parser.parse(FULL.read_bytes(), FULL.name)
@pytest.fixture(scope="module")
def august(parser: VtbXlsxParser) -> ParsedReport:
return parser.parse(AUGUST.read_bytes(), AUGUST.name)
def trades(report: ParsedReport) -> list:
return [event for event in report.events if event.kind in {EventKind.buy, EventKind.sell}]
def count_security_movement_rows(path: Path) -> int:
"""Rows of «Движение ценных бумаг» counted straight off the sheet.
Deliberately independent of the parser: the section is the settlement side of the same
deals, so its row count is an outside check that no trade was dropped or doubled.
"""
workbook = load_workbook(io.BytesIO(path.read_bytes()), read_only=True, data_only=True)
sheet = workbook["brokerage_report"]
sheet.reset_dimensions()
inside = False
rows = 0
for row in sheet.iter_rows(values_only=True):
label = common.normalize_text(row[1] if len(row) > 1 else None)
if label == "движение ценных бумаг":
inside = True
continue
if not inside:
continue
if label in {"заключенные в отчетном периоде сделки с ценными бумагами"}:
break
if "расчеты по заключенным сделкам" in common.normalize_text(
row[16] if len(row) > 16 else None
):
rows += 1
workbook.close()
return rows
# -- 1. sniff ----------------------------------------------------------------------------
def test_sniff_accepts_both_vtb_files(parser: VtbXlsxParser) -> None:
for path in (FULL, AUGUST):
assert parser.sniff(path.read_bytes(), path.name) is True
def test_sniff_rejects_other_brokers(parser: VtbXlsxParser) -> None:
others = list((FIXTURES / "sber").glob("*.html")) + list((FIXTURES / "snowball").glob("*.csv"))
assert others, "фикстуры других брокеров не найдены — проверка бессмысленна"
for path in others:
assert parser.sniff(path.read_bytes(), path.name) is False
def test_sniff_never_raises_on_garbage(parser: VtbXlsxParser) -> None:
"""A registry walks every parser over every upload, so a False must never be an
exception — including for a zip that merely looks like a workbook."""
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as archive:
archive.writestr("xl/workbook.xml", "<workbook/>")
for payload in (b"", b"not a zip", b"PK\x03\x04broken", buffer.getvalue()):
assert parser.sniff(payload, "whatever.xlsx") is False
def test_sniff_ignores_the_file_name(parser: VtbXlsxParser) -> None:
"""The name is a GUID with no format in it; content decides."""
assert parser.sniff(FULL.read_bytes(), "totally-unrelated.bin") is True
# -- 2. the full report ------------------------------------------------------------------
def test_full_report_header(full: ParsedReport) -> None:
assert full.broker == "vtb"
assert full.account_external_id == "77R593"
assert (full.period_from.isoformat(), full.period_to.isoformat()) == (
"2026-02-12",
"2026-09-17",
)
assert full.meta["iis_opened_at"] == "2026-02-12"
def test_full_report_trades_come_only_from_concluded_section(full: ParsedReport) -> None:
"""«Завершенные» repeats «Заключенные» with the same deal numbers, and «Движение ценных
бумаг» settles those same deals — so ten deals must yield ten events, not twenty or
thirty."""
numbers = [event.trade_no for event in trades(full)]
assert len(numbers) == len(set(numbers)) == count_security_movement_rows(FULL) == 10
def test_full_report_parses_without_warnings(full: ParsedReport) -> None:
assert full.warnings == []
# -- 3. money ----------------------------------------------------------------------------
def test_full_report_deposits_and_fees(full: ParsedReport) -> None:
deposits = [event for event in full.events if event.kind is EventKind.deposit]
assert sum(event.amount for event in deposits) == Decimal("26100")
assert all(event.amount > 0 for event in deposits), "пополнение увеличивает счёт"
# The per-deal commissions add up to «Вознаграждение брокера» of the summary block to the
# kopeck, which is why they are capitalised into the deals instead of being emitted
# separately: `ledger/lots.py` already puts `fee` into the cost of the lot.
assert sum((event.fee or Decimal(0)) for event in full.events) == Decimal("3.64")
assert Decimal(full.meta["summary"]["вознаграждение брокера"]) == Decimal("-3.64")
assert not [event for event in full.events if event.kind is EventKind.commission]
def test_full_report_signs(full: ParsedReport) -> None:
for event in trades(full):
assert event.quantity is not None
if event.kind is EventKind.buy:
assert event.quantity > 0 and event.amount < 0
else:
assert event.quantity < 0 and event.amount > 0
def test_settlement_saldo_is_not_emitted_twice(full: ParsedReport) -> None:
"""«Сальдо расчетов по сделкам с ценными бумагами» is the money leg of the trades; the
cash section must contribute deposits only."""
cash_kinds = Counter(event.kind for event in full.events if event.meta.get("section") == "cash")
assert cash_kinds == Counter({EventKind.deposit: 8})
def test_closing_cash_follows_from_the_events(full: ParsedReport) -> None:
"""The whole-file check: opening + everything emitted = closing, as VTB states it."""
summary = full.meta["summary"]
opening = Decimal(summary["входящий остаток денежных средств"])
closing = Decimal(summary["исходящий остаток денежных средств"])
assert opening + sum(event.amount for event in full.events) == closing == Decimal("120.99")
# -- 4. positions and cash balances -------------------------------------------------------
def test_full_report_positions_end(full: ParsedReport) -> None:
positions = {position.instrument.isin: position for position in full.positions_end}
assert set(positions) == {"RU000A101EJ5", "RU000A1014L8"}
assert positions["RU000A101EJ5"].qty == Decimal("197")
assert positions["RU000A1014L8"].qty == Decimal("0")
eqmx = positions["RU000A101EJ5"].instrument
assert (eqmx.ticker, eqmx.asset_class_hint) == ("EQMX", "etf")
assert eqmx.name == "EQMX ETF"
assert eqmx.meta["reg_number"] == "3965"
assert positions["RU000A101EJ5"].price == Decimal("124.653")
def test_currency_is_normalised_to_iso(full: ParsedReport) -> None:
"""VTB prints the pre-ISO `RUR`; the ledger stores three-letter ISO codes only."""
assert [(cash.currency, cash.balance) for cash in full.cash_end] == [("RUB", Decimal("120.99"))]
codes = {cash.currency for cash in full.cash_end}
codes |= {position.currency for position in full.positions_end}
codes |= {event.currency for event in full.events}
codes |= {event.price_currency for event in trades(full)}
assert codes == {"RUB"}
def test_instrument_hint_reaches_trade_events(full: ParsedReport) -> None:
"""Only the positions table names the asset class («ПАЙ»); the trades table does not, so
the hint is carried over by ISIN."""
assert {ref.asset_class_hint for ref in full.instruments} == {"etf"}
assert all(
event.instrument is not None and event.instrument.asset_class_hint == "etf"
for event in trades(full)
)
# -- 5. the monthly report and the cross-file dedupe key ----------------------------------
def test_august_report(august: ParsedReport) -> None:
assert (august.period_from.isoformat(), august.period_to.isoformat()) == (
"2026-08-01",
"2026-08-31",
)
assert august.account_external_id == "77R593"
assert august.warnings == []
deals = trades(august)
assert len(deals) == 1
deal = deals[0]
assert deal.kind is EventKind.buy
assert deal.instrument is not None and deal.instrument.isin == "RU000A101EJ5"
assert deal.quantity == Decimal("33")
assert deal.price == Decimal("122.9")
assert deal.trade_no == AUGUST_TRADE_NO
# 33 * 122.9 = 4055.70 plus the 0.81 commission capitalised into the deal.
assert deal.amount == Decimal("-4056.51")
assert deal.fee == Decimal("0.81")
def test_same_deal_has_one_dedupe_key_in_both_reports(
full: ParsedReport, august: ParsedReport
) -> None:
"""The overlapping-period case from plan §1.6 A: re-importing August after the full
report must upsert, not duplicate."""
keys = {}
for report in (full, august):
for event in trades(report):
if event.trade_no == AUGUST_TRADE_NO:
keys[report.period_to] = common.dedupe_key(report.account_external_id, event)
assert len(keys) == 2
assert len(set(keys.values())) == 1
def test_dedupe_key_uses_the_brokers_own_deal_number(full: ParsedReport) -> None:
"""«№ сделки» (Z), not «№ сделки у организатора торгов» (AC).
The key is `sha1(broker | account | trade_no)` and is therefore already unique within the
broker; what it needs is the number VTB prints in every rendering of its own report. The
organiser's number is a different string (`17476399721` against `B17476399721`) and is
absent for over-the-counter deals in some formats.
"""
deal = next(event for event in trades(full) if event.trade_no == AUGUST_TRADE_NO)
assert deal.meta["exchange_trade_no"] == "17476399721"
assert common.dedupe_key("77R593", deal) != common.dedupe_key(
"77R593",
type(deal)(
**{
**{field.name: getattr(deal, field.name) for field in fields(deal)},
"trade_no": deal.meta["exchange_trade_no"],
}
),
)
def test_cash_rows_get_distinct_fingerprint_keys(full: ParsedReport) -> None:
"""Deposits carry no deal number and fall back to a fingerprint; two 4 000 ₽ top-ups in
different months must still differ."""
keys = [
common.dedupe_key(full.account_external_id, event)
for event in full.events
if event.kind is EventKind.deposit
]
assert len(set(keys)) == len(keys) == 8
# -- 6. concluded vs completed ------------------------------------------------------------
def test_concluded_and_completed_deal_numbers_agree(full: ParsedReport) -> None:
"""Asserted through the parser's own warning channel: a divergence is reported, and for a
fully settled period there is none."""
assert not [warning for warning in full.warnings if "Завершенных" in warning]
parsed = VtbXlsxParser().parse(FULL.read_bytes(), FULL.name)
concluded = {event.trade_no for event in trades(parsed)}
assert concluded == set(_completed_deal_numbers(FULL))
def _completed_deal_numbers(path: Path) -> list[str]:
"""«№ сделки» of «Завершенные…» read straight off the sheet, bypassing the parser."""
workbook = load_workbook(io.BytesIO(path.read_bytes()), read_only=True, data_only=True)
sheet = workbook["brokerage_report"]
sheet.reset_dimensions()
inside = False
numbers: list[str] = []
for row in sheet.iter_rows(values_only=True):
label = common.normalize_text(row[1] if len(row) > 1 else None)
if label.startswith("завершенные в отчетном периоде сделки с ценными бумагами"):
inside = True
continue
if not inside:
continue
value = row[25] if len(row) > 25 else None # column Z
if value and str(value).strip() and common.normalize_text(value) != "№ сделки":
numbers.append(str(value).strip())
workbook.close()
return numbers
# -- 7. no floats anywhere ----------------------------------------------------------------
def _floats(value: object, path: str = "") -> list[str]:
if isinstance(value, bool) or value is None:
return []
if isinstance(value, float):
return [path]
if is_dataclass(value) and not isinstance(value, type):
found: list[str] = []
for field in fields(value):
found += _floats(getattr(value, field.name), f"{path}.{field.name}")
return found
if isinstance(value, dict):
return [p for key, item in value.items() for p in _floats(item, f"{path}[{key}]")]
if isinstance(value, (list, tuple, set)):
return [p for index, item in enumerate(value) for p in _floats(item, f"{path}[{index}]")]
return []
@pytest.mark.parametrize("name", ["full", "august"])
def test_no_floats_in_output(name: str, request: pytest.FixtureRequest) -> None:
"""openpyxl hands back floats for every numeric cell; none of them may survive into the
result, or a kopeck disappears into binary rounding on the way to `NUMERIC(24,10)`."""
report: ParsedReport = request.getfixturevalue(name)
assert _floats(report, name) == []
# -- 8. totals against the summary block --------------------------------------------------
@pytest.mark.parametrize(("name", "expected"), [("full", "-25975.37"), ("august", "-4055.7")])
def test_trade_sums_match_the_settlement_saldo(
name: str, expected: str, request: pytest.FixtureRequest
) -> None:
"""Σ «Сумма сделки» (column M) of the purchases minus the sales equals «Сальдо расчетов по
сделкам с ценными бумагами» in the summary.
The commission has to be taken back out of `amount` to make the comparison: VTB's saldo is
the money of the deals themselves, while `event.amount` carries the fee inside it by the
ledger's convention. The two agree exactly once that is done — the fee lives in the same
file as its own «Вознаграждение брокера» line, and it is that line, not the saldo, that
accounts for it.
"""
report: ParsedReport = request.getfixturevalue(name)
gross = Decimal(0)
for event in trades(report):
fee = event.fee or Decimal(0)
gross += event.amount + fee if event.kind is EventKind.buy else event.amount - fee
assert gross == Decimal(expected)
assert gross == Decimal(report.meta["summary"]["сальдо расчетов по сделкам с ценными бумагами"])
def test_position_quantities_match_the_settled_movements(full: ParsedReport) -> None:
"""Closing position = what the trades did, since the account opened empty."""
by_isin: dict[str, Decimal] = {}
for event in trades(full):
assert event.instrument is not None and event.instrument.isin
assert event.quantity is not None
by_isin[event.instrument.isin] = (
by_isin.get(event.instrument.isin, Decimal(0)) + event.quantity
)
for position in full.positions_end:
assert position.instrument.isin is not None
assert by_isin[position.instrument.isin] == position.qty
# -- parse errors --------------------------------------------------------------------------
def test_unreadable_file_raises_parse_error(parser: VtbXlsxParser) -> None:
from fintracker.sources.reports.base import ParseError
with pytest.raises(ParseError):
parser.parse(b"PK\x03\x04 definitely not a workbook", "report.xlsx")
def test_workbook_without_a_period_raises_parse_error(parser: VtbXlsxParser) -> None:
"""A file we recognise but cannot place on a timeline must fail loudly rather than import
as an empty report."""
from openpyxl import Workbook
from fintracker.sources.reports.base import ParseError
workbook = Workbook()
sheet = workbook.worksheets[0]
sheet.title = "brokerage_report"
sheet["D1"] = "Отчет Банка ВТБ (ПАО) без периода"
buffer = io.BytesIO()
workbook.save(buffer)
with pytest.raises(ParseError):
parser.parse(buffer.getvalue(), "report.xlsx")
+168
View File
@@ -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]}"
+135
View File
@@ -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"