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:
@@ -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]
|
||||
|
||||
@@ -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
+546
@@ -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",""
|
||||
|
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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
|
||||
@@ -0,0 +1,362 @@
|
||||
"""Парсер отчётов Сбера на обезличенных фикстурах (фаза 3).
|
||||
|
||||
Фикстуры — два отчёта по одному и тому же счёту: полный (11.02–17.09.2026) и августовский
|
||||
(01.08–31.08.2026). Пара выбрана не случайно: именно перекрывающиеся периоды ломают импорт,
|
||||
если ключ дедупликации выводится из файла, а не из самой операции.
|
||||
|
||||
Главная проверка здесь — арифметическая. Отчёт печатает свои итоги («Итого, RUB» по
|
||||
сделкам, «Пополнение счета» и «Исходящий остаток» в сводке), и распарсенные события обязаны
|
||||
их воспроизвести: пополнения минус покупки плюс продажи = остаток на конец периода. Если
|
||||
парсер потеряет строку, задвоит комиссию или примет расчётную строку за сделку, это
|
||||
равенство разойдётся — а тест на «количество событий» этого не заметит.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from fintracker.models.ledger import EventKind
|
||||
from fintracker.sources.reports.base import BrokerEvent, ParsedReport
|
||||
from fintracker.sources.reports.sber import SberHtmlParser, event_dedupe_key
|
||||
|
||||
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "reports"
|
||||
FULL = FIXTURES / "sber" / "S930W42_11022026_17092026.html"
|
||||
AUGUST = FIXTURES / "sber" / "S930W42_01082026_31082026.html"
|
||||
|
||||
ACCOUNT = "S930W42"
|
||||
#: Второй договор того же брокера — источник переводов д/с, не внешний поток.
|
||||
OTHER_AGREEMENT = "8184V30"
|
||||
|
||||
ZERO = Decimal(0)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def parser() -> SberHtmlParser:
|
||||
return SberHtmlParser()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def full(parser: SberHtmlParser) -> ParsedReport:
|
||||
return parser.parse(FULL.read_bytes(), FULL.name)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def august(parser: SberHtmlParser) -> ParsedReport:
|
||||
return parser.parse(AUGUST.read_bytes(), AUGUST.name)
|
||||
|
||||
|
||||
def total(events: list[BrokerEvent], kind: EventKind) -> Decimal:
|
||||
return sum((e.amount for e in events if e.kind == kind), ZERO)
|
||||
|
||||
|
||||
def of_kind(report: ParsedReport, kind: EventKind) -> list[BrokerEvent]:
|
||||
return [e for e in report.events if e.kind == kind]
|
||||
|
||||
|
||||
# --- 1. распознавание формата ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sniff_accepts_both_sber_reports(parser: SberHtmlParser) -> None:
|
||||
assert parser.sniff(FULL.read_bytes(), FULL.name)
|
||||
assert parser.sniff(AUGUST.read_bytes(), AUGUST.name)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
sorted((FIXTURES / "vtb").glob("*.xlsx")) + sorted((FIXTURES / "snowball").glob("*.csv")),
|
||||
ids=lambda p: p.suffix,
|
||||
)
|
||||
def test_sniff_rejects_other_formats(parser: SberHtmlParser, path: Path) -> None:
|
||||
assert not parser.sniff(path.read_bytes(), path.name)
|
||||
|
||||
|
||||
def test_sniff_never_raises_on_garbage(parser: SberHtmlParser) -> None:
|
||||
"""`registry.pick` опрашивает парсеры подряд — падение на чужом файле остановило бы перебор."""
|
||||
assert not parser.sniff(b"", "empty.html")
|
||||
assert not parser.sniff(b"\x00\x01\x02not html at all", "junk.html")
|
||||
|
||||
|
||||
# --- 2. шапка и состав ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_full_report_header(full: ParsedReport) -> None:
|
||||
assert full.broker == "sber"
|
||||
assert full.account_external_id == ACCOUNT
|
||||
assert (full.period_from.isoformat(), full.period_to.isoformat()) == (
|
||||
"2026-02-11",
|
||||
"2026-09-17",
|
||||
)
|
||||
assert full.meta["opened_at"] == "2026-02-11"
|
||||
|
||||
|
||||
def test_august_report_header(august: ParsedReport) -> None:
|
||||
assert august.account_external_id == ACCOUNT
|
||||
assert (august.period_from.isoformat(), august.period_to.isoformat()) == (
|
||||
"2026-08-01",
|
||||
"2026-08-31",
|
||||
)
|
||||
|
||||
|
||||
def test_full_report_event_counts(full: ParsedReport) -> None:
|
||||
assert Counter(e.kind for e in full.events) == {
|
||||
EventKind.buy: 24,
|
||||
EventKind.deposit: 9,
|
||||
EventKind.sell: 2,
|
||||
}
|
||||
|
||||
|
||||
def test_august_report_event_counts(august: ParsedReport) -> None:
|
||||
assert Counter(e.kind for e in august.events) == {
|
||||
EventKind.buy: 2,
|
||||
EventKind.deposit: 1,
|
||||
}
|
||||
|
||||
|
||||
# --- 3. арифметика: события воспроизводят итоги самого отчёта -------------------------------
|
||||
|
||||
|
||||
def test_trade_totals_match_the_reports_own_total_row(full: ParsedReport) -> None:
|
||||
"""«Итого, RUB» таблицы сделок: 89 205.58 оборота, 138.03 брокеру, 19.54 бирже."""
|
||||
trades = of_kind(full, EventKind.buy) + of_kind(full, EventKind.sell)
|
||||
assert len(trades) == 26
|
||||
|
||||
# Оборот — это «Сумма» сделки без комиссии, а `amount` её уже включает: у покупки
|
||||
# прибавляет, у продажи вычитает. Отсюда знаки при обратном пересчёте.
|
||||
turnover = sum(
|
||||
(-e.amount - (e.fee or ZERO) if e.kind == EventKind.buy else e.amount + (e.fee or ZERO))
|
||||
for e in trades
|
||||
)
|
||||
assert turnover == Decimal("89205.58")
|
||||
assert sum((e.fee or ZERO for e in full.events), ZERO) == Decimal("138.03") + Decimal("19.54")
|
||||
|
||||
|
||||
def test_deposits_match_the_summary_line(full: ParsedReport) -> None:
|
||||
"""«Пополнение счета» сводки считает и переводы с другого договора — как и парсер."""
|
||||
assert total(full.events, EventKind.deposit) == Decimal("49120.87")
|
||||
assert Decimal(full.meta["summary"]["Пополнение счета"]) == Decimal("49120.87")
|
||||
|
||||
|
||||
def test_cash_closes_against_the_reported_balance(full: ParsedReport) -> None:
|
||||
"""Сквозная проверка: сумма денежных эффектов всех событий = исходящий остаток.
|
||||
|
||||
Это единственная проверка, которая ловит и потерянную строку, и лишнюю: пропущенная
|
||||
сделка занижает отток, а расчётная строка «Сделка от …», принятая за сделку, задваивает
|
||||
его. Входящий остаток нулевой, поэтому сумма событий и есть конечный остаток.
|
||||
"""
|
||||
assert Decimal(full.meta["summary"]["Входящий остаток"]) == ZERO
|
||||
assert sum((e.amount for e in full.events), ZERO) == Decimal("3171.34")
|
||||
|
||||
rub = next(c for c in full.cash_end if c.currency == "RUB")
|
||||
assert rub.balance == Decimal("3171.34")
|
||||
|
||||
|
||||
def test_august_cash_closes_from_its_own_opening_balance(august: ParsedReport) -> None:
|
||||
opening = Decimal(august.meta["summary"]["Входящий остаток"])
|
||||
assert opening == Decimal("357.15")
|
||||
rub = next(c for c in august.cash_end if c.currency == "RUB")
|
||||
assert opening + sum((e.amount for e in august.events), ZERO) == rub.balance
|
||||
|
||||
|
||||
# --- 4. комиссии учтены ровно один раз -----------------------------------------------------
|
||||
|
||||
|
||||
def test_commission_is_capitalised_and_never_emitted_separately(full: ParsedReport) -> None:
|
||||
"""Комиссия живёт в `fee` сделки; отдельных `commission`-событий быть не должно.
|
||||
|
||||
`ledger/lots.py` капитализирует `fee` в стоимость лота, поэтому вторая, «денежная»
|
||||
ипостась той же комиссии (строки «Комиссия Брокера от …» в движении денег) ушла бы в
|
||||
леджер повторно — как отток, которого не было.
|
||||
"""
|
||||
assert not of_kind(full, EventKind.commission)
|
||||
assert all(e.fee is None for e in full.events if e.kind == EventKind.deposit)
|
||||
|
||||
fees = sum((e.fee or ZERO for e in full.events), ZERO)
|
||||
assert fees == Decimal("157.57")
|
||||
assert Decimal(full.meta["summary"]["Комиссия брокера"]) == Decimal("-138.03")
|
||||
assert Decimal(full.meta["summary"]["Комиссия биржи"]) == Decimal("-19.54")
|
||||
|
||||
|
||||
def test_parser_reports_no_fee_divergence(full: ParsedReport, august: ParsedReport) -> None:
|
||||
"""Парсер сам сверяет две ипостаси комиссии и предупреждает при расхождении."""
|
||||
for report in (full, august):
|
||||
assert not [w for w in report.warnings if "комиссии расходятся" in w]
|
||||
|
||||
|
||||
# --- 5. дедупликация перекрывающихся периодов ----------------------------------------------
|
||||
|
||||
|
||||
def keys(report: ParsedReport) -> dict[str, BrokerEvent]:
|
||||
return {event_dedupe_key(report, e): e for e in report.events}
|
||||
|
||||
|
||||
def test_august_events_keep_their_keys_in_the_full_report(
|
||||
full: ParsedReport, august: ParsedReport
|
||||
) -> None:
|
||||
"""Каждое августовское событие должно прийти с тем же ключом из полного отчёта.
|
||||
|
||||
Иначе импорт двух пересекающихся отчётов задвоит август — ровно тот случай, который
|
||||
план §1.6 A называет «перекрывающиеся периоды апсертят в ту же строку».
|
||||
"""
|
||||
full_keys, august_keys = keys(full), keys(august)
|
||||
assert set(august_keys) <= set(full_keys), (
|
||||
"у августовских событий появились ключи, которых нет в полном отчёте: "
|
||||
f"{sorted(set(august_keys) - set(full_keys))}"
|
||||
)
|
||||
|
||||
for key, event in august_keys.items():
|
||||
twin = full_keys[key]
|
||||
assert (event.kind, event.trade_date, event.quantity, event.price, event.amount) == (
|
||||
twin.kind,
|
||||
twin.trade_date,
|
||||
twin.quantity,
|
||||
twin.price,
|
||||
twin.amount,
|
||||
), f"ключ {key} указывает на разные операции"
|
||||
|
||||
|
||||
def test_overlap_covers_every_august_operation(august: ParsedReport) -> None:
|
||||
"""Перекрытие не должно оказаться пустым — иначе предыдущий тест проходит вхолостую."""
|
||||
assert len(keys(august)) == len(august.events) == 3
|
||||
|
||||
|
||||
def test_trades_key_on_the_brokers_deal_number(full: ParsedReport) -> None:
|
||||
trades = of_kind(full, EventKind.buy) + of_kind(full, EventKind.sell)
|
||||
assert all(e.trade_no for e in trades)
|
||||
assert len({e.trade_no for e in trades}) == len(trades)
|
||||
|
||||
|
||||
def test_cash_rows_have_stable_keys_without_a_deal_number(full: ParsedReport) -> None:
|
||||
"""У денежных строк номера нет, и ключ обязан оставаться уникальным внутри отчёта."""
|
||||
deposits = of_kind(full, EventKind.deposit)
|
||||
assert all(e.trade_no is None for e in deposits)
|
||||
assert len({event_dedupe_key(full, e) for e in deposits}) == len(deposits)
|
||||
|
||||
|
||||
def test_reparsing_the_same_bytes_is_deterministic(parser: SberHtmlParser) -> None:
|
||||
once = parser.parse(FULL.read_bytes(), FULL.name)
|
||||
twice = parser.parse(FULL.read_bytes(), FULL.name)
|
||||
assert [event_dedupe_key(once, e) for e in once.events] == [
|
||||
event_dedupe_key(twice, e) for e in twice.events
|
||||
]
|
||||
|
||||
|
||||
# --- 6. закрывающие позиции и остатки ------------------------------------------------------
|
||||
|
||||
|
||||
def test_closing_positions(full: ParsedReport) -> None:
|
||||
assert len(full.positions_end) == 10
|
||||
assert sum((p.market_value or ZERO for p in full.positions_end), ZERO) == Decimal("46663.30")
|
||||
|
||||
aeroflot = next(p for p in full.positions_end if p.instrument.isin == "RU0009062285")
|
||||
assert aeroflot.qty == Decimal("130")
|
||||
assert aeroflot.currency == "RUB"
|
||||
|
||||
|
||||
def test_closing_cash_keeps_every_currency(full: ParsedReport) -> None:
|
||||
balances = {c.currency: c.balance for c in full.cash_end}
|
||||
assert balances == {"RUB": Decimal("3171.34"), "EUR": ZERO, "USD": ZERO}
|
||||
|
||||
|
||||
def test_august_closing_snapshot(august: ParsedReport) -> None:
|
||||
assert len(august.positions_end) == 5
|
||||
assert sum((p.market_value or ZERO for p in august.positions_end), ZERO) == Decimal("38739.43")
|
||||
assert next(c for c in august.cash_end if c.currency == "RUB").balance == Decimal("1407.88")
|
||||
|
||||
|
||||
# --- 7. справочник инструментов ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_securities_directory_feeds_instruments(full: ParsedReport) -> None:
|
||||
assert len(full.instruments) >= 9
|
||||
assert all(i.isin and i.ticker for i in full.instruments)
|
||||
assert all(i.asset_class_hint for i in full.instruments)
|
||||
|
||||
sber = next(i for i in full.instruments if i.ticker == "SBER")
|
||||
assert (sber.isin, sber.asset_class_hint) == ("RU0009029540", "share")
|
||||
fund = next(i for i in full.instruments if i.ticker == "STME")
|
||||
assert fund.asset_class_hint in {"etf", "fund"}
|
||||
|
||||
|
||||
def test_trades_reference_instruments_by_isin(full: ParsedReport) -> None:
|
||||
"""Сделка печатает только тикер — ISIN подставляется из справочника того же файла."""
|
||||
trades = of_kind(full, EventKind.buy) + of_kind(full, EventKind.sell)
|
||||
assert all(e.instrument is not None and e.instrument.isin for e in trades)
|
||||
|
||||
|
||||
# --- 8. ловушки формата --------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_iis_contributions_table_is_read_but_not_emitted(
|
||||
full: ParsedReport, august: ParsedReport
|
||||
) -> None:
|
||||
"""Таблица зачислений на ИИС кумулятивна за ГОД, а не за период отчёта.
|
||||
|
||||
Августовский файл перечисляет в ней восемь пополнений начиная с февраля — всё, что
|
||||
случилось до даты его формирования (01.09.2026); в полном отчёте их девять, добавилось
|
||||
сентябрьское. То есть таблица растёт от отчёта к отчёту независимо от периода, и если
|
||||
бы парсер эмитил её строки, импорт августовского файла поверх полного задвоил бы
|
||||
пополнения за полгода. Поэтому её содержимое только пересчитывается, в события не идёт:
|
||||
августовский отчёт даёт ровно одно денежное поступление — то, что реально было в августе.
|
||||
"""
|
||||
assert full.meta["iis_contributions_ignored"] == 9
|
||||
assert august.meta["iis_contributions_ignored"] == 8
|
||||
|
||||
assert len(of_kind(august, EventKind.deposit)) == 1
|
||||
|
||||
|
||||
def test_settlement_lines_are_not_mistaken_for_trades(full: ParsedReport) -> None:
|
||||
"""«Сделка от DD.MM.YYYY» в движении денег — расчёт по уже учтённой сделке."""
|
||||
assert not [e for e in full.events if (e.description or "").startswith("Сделка от")]
|
||||
|
||||
|
||||
def test_transfer_from_another_agreement_keeps_its_counterparty(full: ParsedReport) -> None:
|
||||
"""Перевод с другого договора Сбера — deposit, но с сохранённым источником.
|
||||
|
||||
Без `internal_transfer_from` эти деньги навсегда останутся внешним потоком: заведи
|
||||
пользователь второй договор счётом, XIRR увидел бы пополнение здесь и вывод там как два
|
||||
независимых события.
|
||||
"""
|
||||
transfers = [
|
||||
e
|
||||
for e in of_kind(full, EventKind.deposit)
|
||||
if e.meta.get("internal_transfer_from") == OTHER_AGREEMENT
|
||||
]
|
||||
assert len(transfers) == 2
|
||||
assert sum((e.amount for e in transfers), ZERO) == Decimal("3458.88") + Decimal("10551.18")
|
||||
|
||||
|
||||
def test_missing_coupon_section_is_reported_not_assumed(full: ParsedReport) -> None:
|
||||
"""Раздела «Купонный доход» в этих отчётах нет — это факт для data quality, не молчание."""
|
||||
assert any("Купонный доход" in w for w in full.warnings)
|
||||
|
||||
|
||||
# --- 9. деньги --------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def numbers(report: ParsedReport) -> list[object]:
|
||||
values: list[object] = []
|
||||
for e in report.events:
|
||||
values += [e.amount, e.quantity, e.price, e.fee, e.tax, e.accrued_interest]
|
||||
for p in report.positions_end:
|
||||
values += [p.qty, p.price, p.market_value, p.accrued_interest]
|
||||
values += [c.balance for c in report.cash_end]
|
||||
return values
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["full", "august"])
|
||||
def test_no_floats_anywhere(name: str, request: pytest.FixtureRequest) -> None:
|
||||
"""Деньги — только Decimal (AGENTS.md). Один float здесь означает потерю копеек ниже."""
|
||||
report: ParsedReport = request.getfixturevalue(name)
|
||||
assert not [v for v in numbers(report) if isinstance(v, float)]
|
||||
assert all(isinstance(v, Decimal) for v in numbers(report) if v is not None)
|
||||
|
||||
|
||||
def test_every_event_carries_currency_and_traceability(full: ParsedReport) -> None:
|
||||
for event in full.events:
|
||||
assert event.currency == "RUB"
|
||||
assert event.raw_line_no > 0
|
||||
assert event.meta.get("section")
|
||||
@@ -0,0 +1,410 @@
|
||||
"""VTB xlsx report parser against the two anonymised fixtures.
|
||||
|
||||
The fixtures are the same account rendered twice: the whole life of the IIS (12.02–17.09) and
|
||||
the single month of August, which is a strict subset of it. That overlap is the point — it is
|
||||
exactly the case the dedupe key exists for, so the deal both files contain must produce one
|
||||
key, and the monthly file must not invent a trade the full one does not have.
|
||||
|
||||
Every number asserted here is taken from «Сводная информация по субсчёту Клиента», the
|
||||
broker's own totals block: the parser is trusted only insofar as what it extracted adds up to
|
||||
what VTB printed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
from collections import Counter
|
||||
from dataclasses import fields, is_dataclass
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from fintracker.models.ledger import EventKind
|
||||
from fintracker.sources.reports.base import ParsedReport
|
||||
from fintracker.sources.reports.vtb import common
|
||||
from fintracker.sources.reports.vtb.xlsx import VtbXlsxParser
|
||||
|
||||
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "reports"
|
||||
FULL = FIXTURES / "vtb" / "reportd0235f1e-55ac-fdc8-5c7b-279e4fe26e2b.xlsx"
|
||||
AUGUST = FIXTURES / "vtb" / "report8ef8347a-2deb-e880-959a-d9a8ca12f735.xlsx"
|
||||
|
||||
#: The August deal, as printed in both files: «№ сделки» B17476399721, 33 EQMX at 122.9.
|
||||
AUGUST_TRADE_NO = "B17476399721"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def parser() -> VtbXlsxParser:
|
||||
return VtbXlsxParser()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def full(parser: VtbXlsxParser) -> ParsedReport:
|
||||
return parser.parse(FULL.read_bytes(), FULL.name)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def august(parser: VtbXlsxParser) -> ParsedReport:
|
||||
return parser.parse(AUGUST.read_bytes(), AUGUST.name)
|
||||
|
||||
|
||||
def trades(report: ParsedReport) -> list:
|
||||
return [event for event in report.events if event.kind in {EventKind.buy, EventKind.sell}]
|
||||
|
||||
|
||||
def count_security_movement_rows(path: Path) -> int:
|
||||
"""Rows of «Движение ценных бумаг» counted straight off the sheet.
|
||||
|
||||
Deliberately independent of the parser: the section is the settlement side of the same
|
||||
deals, so its row count is an outside check that no trade was dropped or doubled.
|
||||
"""
|
||||
workbook = load_workbook(io.BytesIO(path.read_bytes()), read_only=True, data_only=True)
|
||||
sheet = workbook["brokerage_report"]
|
||||
sheet.reset_dimensions()
|
||||
inside = False
|
||||
rows = 0
|
||||
for row in sheet.iter_rows(values_only=True):
|
||||
label = common.normalize_text(row[1] if len(row) > 1 else None)
|
||||
if label == "движение ценных бумаг":
|
||||
inside = True
|
||||
continue
|
||||
if not inside:
|
||||
continue
|
||||
if label in {"заключенные в отчетном периоде сделки с ценными бумагами"}:
|
||||
break
|
||||
if "расчеты по заключенным сделкам" in common.normalize_text(
|
||||
row[16] if len(row) > 16 else None
|
||||
):
|
||||
rows += 1
|
||||
workbook.close()
|
||||
return rows
|
||||
|
||||
|
||||
# -- 1. sniff ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sniff_accepts_both_vtb_files(parser: VtbXlsxParser) -> None:
|
||||
for path in (FULL, AUGUST):
|
||||
assert parser.sniff(path.read_bytes(), path.name) is True
|
||||
|
||||
|
||||
def test_sniff_rejects_other_brokers(parser: VtbXlsxParser) -> None:
|
||||
others = list((FIXTURES / "sber").glob("*.html")) + list((FIXTURES / "snowball").glob("*.csv"))
|
||||
assert others, "фикстуры других брокеров не найдены — проверка бессмысленна"
|
||||
for path in others:
|
||||
assert parser.sniff(path.read_bytes(), path.name) is False
|
||||
|
||||
|
||||
def test_sniff_never_raises_on_garbage(parser: VtbXlsxParser) -> None:
|
||||
"""A registry walks every parser over every upload, so a False must never be an
|
||||
exception — including for a zip that merely looks like a workbook."""
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w") as archive:
|
||||
archive.writestr("xl/workbook.xml", "<workbook/>")
|
||||
for payload in (b"", b"not a zip", b"PK\x03\x04broken", buffer.getvalue()):
|
||||
assert parser.sniff(payload, "whatever.xlsx") is False
|
||||
|
||||
|
||||
def test_sniff_ignores_the_file_name(parser: VtbXlsxParser) -> None:
|
||||
"""The name is a GUID with no format in it; content decides."""
|
||||
assert parser.sniff(FULL.read_bytes(), "totally-unrelated.bin") is True
|
||||
|
||||
|
||||
# -- 2. the full report ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_full_report_header(full: ParsedReport) -> None:
|
||||
assert full.broker == "vtb"
|
||||
assert full.account_external_id == "77R593"
|
||||
assert (full.period_from.isoformat(), full.period_to.isoformat()) == (
|
||||
"2026-02-12",
|
||||
"2026-09-17",
|
||||
)
|
||||
assert full.meta["iis_opened_at"] == "2026-02-12"
|
||||
|
||||
|
||||
def test_full_report_trades_come_only_from_concluded_section(full: ParsedReport) -> None:
|
||||
"""«Завершенные» repeats «Заключенные» with the same deal numbers, and «Движение ценных
|
||||
бумаг» settles those same deals — so ten deals must yield ten events, not twenty or
|
||||
thirty."""
|
||||
numbers = [event.trade_no for event in trades(full)]
|
||||
assert len(numbers) == len(set(numbers)) == count_security_movement_rows(FULL) == 10
|
||||
|
||||
|
||||
def test_full_report_parses_without_warnings(full: ParsedReport) -> None:
|
||||
assert full.warnings == []
|
||||
|
||||
|
||||
# -- 3. money ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_full_report_deposits_and_fees(full: ParsedReport) -> None:
|
||||
deposits = [event for event in full.events if event.kind is EventKind.deposit]
|
||||
assert sum(event.amount for event in deposits) == Decimal("26100")
|
||||
assert all(event.amount > 0 for event in deposits), "пополнение увеличивает счёт"
|
||||
|
||||
# The per-deal commissions add up to «Вознаграждение брокера» of the summary block to the
|
||||
# kopeck, which is why they are capitalised into the deals instead of being emitted
|
||||
# separately: `ledger/lots.py` already puts `fee` into the cost of the lot.
|
||||
assert sum((event.fee or Decimal(0)) for event in full.events) == Decimal("3.64")
|
||||
assert Decimal(full.meta["summary"]["вознаграждение брокера"]) == Decimal("-3.64")
|
||||
assert not [event for event in full.events if event.kind is EventKind.commission]
|
||||
|
||||
|
||||
def test_full_report_signs(full: ParsedReport) -> None:
|
||||
for event in trades(full):
|
||||
assert event.quantity is not None
|
||||
if event.kind is EventKind.buy:
|
||||
assert event.quantity > 0 and event.amount < 0
|
||||
else:
|
||||
assert event.quantity < 0 and event.amount > 0
|
||||
|
||||
|
||||
def test_settlement_saldo_is_not_emitted_twice(full: ParsedReport) -> None:
|
||||
"""«Сальдо расчетов по сделкам с ценными бумагами» is the money leg of the trades; the
|
||||
cash section must contribute deposits only."""
|
||||
cash_kinds = Counter(event.kind for event in full.events if event.meta.get("section") == "cash")
|
||||
assert cash_kinds == Counter({EventKind.deposit: 8})
|
||||
|
||||
|
||||
def test_closing_cash_follows_from_the_events(full: ParsedReport) -> None:
|
||||
"""The whole-file check: opening + everything emitted = closing, as VTB states it."""
|
||||
summary = full.meta["summary"]
|
||||
opening = Decimal(summary["входящий остаток денежных средств"])
|
||||
closing = Decimal(summary["исходящий остаток денежных средств"])
|
||||
assert opening + sum(event.amount for event in full.events) == closing == Decimal("120.99")
|
||||
|
||||
|
||||
# -- 4. positions and cash balances -------------------------------------------------------
|
||||
|
||||
|
||||
def test_full_report_positions_end(full: ParsedReport) -> None:
|
||||
positions = {position.instrument.isin: position for position in full.positions_end}
|
||||
assert set(positions) == {"RU000A101EJ5", "RU000A1014L8"}
|
||||
assert positions["RU000A101EJ5"].qty == Decimal("197")
|
||||
assert positions["RU000A1014L8"].qty == Decimal("0")
|
||||
eqmx = positions["RU000A101EJ5"].instrument
|
||||
assert (eqmx.ticker, eqmx.asset_class_hint) == ("EQMX", "etf")
|
||||
assert eqmx.name == "EQMX ETF"
|
||||
assert eqmx.meta["reg_number"] == "3965"
|
||||
assert positions["RU000A101EJ5"].price == Decimal("124.653")
|
||||
|
||||
|
||||
def test_currency_is_normalised_to_iso(full: ParsedReport) -> None:
|
||||
"""VTB prints the pre-ISO `RUR`; the ledger stores three-letter ISO codes only."""
|
||||
assert [(cash.currency, cash.balance) for cash in full.cash_end] == [("RUB", Decimal("120.99"))]
|
||||
codes = {cash.currency for cash in full.cash_end}
|
||||
codes |= {position.currency for position in full.positions_end}
|
||||
codes |= {event.currency for event in full.events}
|
||||
codes |= {event.price_currency for event in trades(full)}
|
||||
assert codes == {"RUB"}
|
||||
|
||||
|
||||
def test_instrument_hint_reaches_trade_events(full: ParsedReport) -> None:
|
||||
"""Only the positions table names the asset class («ПАЙ»); the trades table does not, so
|
||||
the hint is carried over by ISIN."""
|
||||
assert {ref.asset_class_hint for ref in full.instruments} == {"etf"}
|
||||
assert all(
|
||||
event.instrument is not None and event.instrument.asset_class_hint == "etf"
|
||||
for event in trades(full)
|
||||
)
|
||||
|
||||
|
||||
# -- 5. the monthly report and the cross-file dedupe key ----------------------------------
|
||||
|
||||
|
||||
def test_august_report(august: ParsedReport) -> None:
|
||||
assert (august.period_from.isoformat(), august.period_to.isoformat()) == (
|
||||
"2026-08-01",
|
||||
"2026-08-31",
|
||||
)
|
||||
assert august.account_external_id == "77R593"
|
||||
assert august.warnings == []
|
||||
|
||||
deals = trades(august)
|
||||
assert len(deals) == 1
|
||||
deal = deals[0]
|
||||
assert deal.kind is EventKind.buy
|
||||
assert deal.instrument is not None and deal.instrument.isin == "RU000A101EJ5"
|
||||
assert deal.quantity == Decimal("33")
|
||||
assert deal.price == Decimal("122.9")
|
||||
assert deal.trade_no == AUGUST_TRADE_NO
|
||||
# 33 * 122.9 = 4055.70 plus the 0.81 commission capitalised into the deal.
|
||||
assert deal.amount == Decimal("-4056.51")
|
||||
assert deal.fee == Decimal("0.81")
|
||||
|
||||
|
||||
def test_same_deal_has_one_dedupe_key_in_both_reports(
|
||||
full: ParsedReport, august: ParsedReport
|
||||
) -> None:
|
||||
"""The overlapping-period case from plan §1.6 A: re-importing August after the full
|
||||
report must upsert, not duplicate."""
|
||||
keys = {}
|
||||
for report in (full, august):
|
||||
for event in trades(report):
|
||||
if event.trade_no == AUGUST_TRADE_NO:
|
||||
keys[report.period_to] = common.dedupe_key(report.account_external_id, event)
|
||||
assert len(keys) == 2
|
||||
assert len(set(keys.values())) == 1
|
||||
|
||||
|
||||
def test_dedupe_key_uses_the_brokers_own_deal_number(full: ParsedReport) -> None:
|
||||
"""«№ сделки» (Z), not «№ сделки у организатора торгов» (AC).
|
||||
|
||||
The key is `sha1(broker | account | trade_no)` and is therefore already unique within the
|
||||
broker; what it needs is the number VTB prints in every rendering of its own report. The
|
||||
organiser's number is a different string (`17476399721` against `B17476399721`) and is
|
||||
absent for over-the-counter deals in some formats.
|
||||
"""
|
||||
deal = next(event for event in trades(full) if event.trade_no == AUGUST_TRADE_NO)
|
||||
assert deal.meta["exchange_trade_no"] == "17476399721"
|
||||
assert common.dedupe_key("77R593", deal) != common.dedupe_key(
|
||||
"77R593",
|
||||
type(deal)(
|
||||
**{
|
||||
**{field.name: getattr(deal, field.name) for field in fields(deal)},
|
||||
"trade_no": deal.meta["exchange_trade_no"],
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_cash_rows_get_distinct_fingerprint_keys(full: ParsedReport) -> None:
|
||||
"""Deposits carry no deal number and fall back to a fingerprint; two 4 000 ₽ top-ups in
|
||||
different months must still differ."""
|
||||
keys = [
|
||||
common.dedupe_key(full.account_external_id, event)
|
||||
for event in full.events
|
||||
if event.kind is EventKind.deposit
|
||||
]
|
||||
assert len(set(keys)) == len(keys) == 8
|
||||
|
||||
|
||||
# -- 6. concluded vs completed ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_concluded_and_completed_deal_numbers_agree(full: ParsedReport) -> None:
|
||||
"""Asserted through the parser's own warning channel: a divergence is reported, and for a
|
||||
fully settled period there is none."""
|
||||
assert not [warning for warning in full.warnings if "Завершенных" in warning]
|
||||
parsed = VtbXlsxParser().parse(FULL.read_bytes(), FULL.name)
|
||||
concluded = {event.trade_no for event in trades(parsed)}
|
||||
assert concluded == set(_completed_deal_numbers(FULL))
|
||||
|
||||
|
||||
def _completed_deal_numbers(path: Path) -> list[str]:
|
||||
"""«№ сделки» of «Завершенные…» read straight off the sheet, bypassing the parser."""
|
||||
workbook = load_workbook(io.BytesIO(path.read_bytes()), read_only=True, data_only=True)
|
||||
sheet = workbook["brokerage_report"]
|
||||
sheet.reset_dimensions()
|
||||
inside = False
|
||||
numbers: list[str] = []
|
||||
for row in sheet.iter_rows(values_only=True):
|
||||
label = common.normalize_text(row[1] if len(row) > 1 else None)
|
||||
if label.startswith("завершенные в отчетном периоде сделки с ценными бумагами"):
|
||||
inside = True
|
||||
continue
|
||||
if not inside:
|
||||
continue
|
||||
value = row[25] if len(row) > 25 else None # column Z
|
||||
if value and str(value).strip() and common.normalize_text(value) != "№ сделки":
|
||||
numbers.append(str(value).strip())
|
||||
workbook.close()
|
||||
return numbers
|
||||
|
||||
|
||||
# -- 7. no floats anywhere ----------------------------------------------------------------
|
||||
|
||||
|
||||
def _floats(value: object, path: str = "") -> list[str]:
|
||||
if isinstance(value, bool) or value is None:
|
||||
return []
|
||||
if isinstance(value, float):
|
||||
return [path]
|
||||
if is_dataclass(value) and not isinstance(value, type):
|
||||
found: list[str] = []
|
||||
for field in fields(value):
|
||||
found += _floats(getattr(value, field.name), f"{path}.{field.name}")
|
||||
return found
|
||||
if isinstance(value, dict):
|
||||
return [p for key, item in value.items() for p in _floats(item, f"{path}[{key}]")]
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [p for index, item in enumerate(value) for p in _floats(item, f"{path}[{index}]")]
|
||||
return []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["full", "august"])
|
||||
def test_no_floats_in_output(name: str, request: pytest.FixtureRequest) -> None:
|
||||
"""openpyxl hands back floats for every numeric cell; none of them may survive into the
|
||||
result, or a kopeck disappears into binary rounding on the way to `NUMERIC(24,10)`."""
|
||||
report: ParsedReport = request.getfixturevalue(name)
|
||||
assert _floats(report, name) == []
|
||||
|
||||
|
||||
# -- 8. totals against the summary block --------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("name", "expected"), [("full", "-25975.37"), ("august", "-4055.7")])
|
||||
def test_trade_sums_match_the_settlement_saldo(
|
||||
name: str, expected: str, request: pytest.FixtureRequest
|
||||
) -> None:
|
||||
"""Σ «Сумма сделки» (column M) of the purchases minus the sales equals «Сальдо расчетов по
|
||||
сделкам с ценными бумагами» in the summary.
|
||||
|
||||
The commission has to be taken back out of `amount` to make the comparison: VTB's saldo is
|
||||
the money of the deals themselves, while `event.amount` carries the fee inside it by the
|
||||
ledger's convention. The two agree exactly once that is done — the fee lives in the same
|
||||
file as its own «Вознаграждение брокера» line, and it is that line, not the saldo, that
|
||||
accounts for it.
|
||||
"""
|
||||
report: ParsedReport = request.getfixturevalue(name)
|
||||
gross = Decimal(0)
|
||||
for event in trades(report):
|
||||
fee = event.fee or Decimal(0)
|
||||
gross += event.amount + fee if event.kind is EventKind.buy else event.amount - fee
|
||||
assert gross == Decimal(expected)
|
||||
assert gross == Decimal(report.meta["summary"]["сальдо расчетов по сделкам с ценными бумагами"])
|
||||
|
||||
|
||||
def test_position_quantities_match_the_settled_movements(full: ParsedReport) -> None:
|
||||
"""Closing position = what the trades did, since the account opened empty."""
|
||||
by_isin: dict[str, Decimal] = {}
|
||||
for event in trades(full):
|
||||
assert event.instrument is not None and event.instrument.isin
|
||||
assert event.quantity is not None
|
||||
by_isin[event.instrument.isin] = (
|
||||
by_isin.get(event.instrument.isin, Decimal(0)) + event.quantity
|
||||
)
|
||||
for position in full.positions_end:
|
||||
assert position.instrument.isin is not None
|
||||
assert by_isin[position.instrument.isin] == position.qty
|
||||
|
||||
|
||||
# -- parse errors --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_unreadable_file_raises_parse_error(parser: VtbXlsxParser) -> None:
|
||||
from fintracker.sources.reports.base import ParseError
|
||||
|
||||
with pytest.raises(ParseError):
|
||||
parser.parse(b"PK\x03\x04 definitely not a workbook", "report.xlsx")
|
||||
|
||||
|
||||
def test_workbook_without_a_period_raises_parse_error(parser: VtbXlsxParser) -> None:
|
||||
"""A file we recognise but cannot place on a timeline must fail loudly rather than import
|
||||
as an empty report."""
|
||||
from openpyxl import Workbook
|
||||
|
||||
from fintracker.sources.reports.base import ParseError
|
||||
|
||||
workbook = Workbook()
|
||||
sheet = workbook.worksheets[0]
|
||||
sheet.title = "brokerage_report"
|
||||
sheet["D1"] = "Отчет Банка ВТБ (ПАО) без периода"
|
||||
buffer = io.BytesIO()
|
||||
workbook.save(buffer)
|
||||
|
||||
with pytest.raises(ParseError):
|
||||
parser.parse(buffer.getvalue(), "report.xlsx")
|
||||
@@ -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]}"
|
||||
Generated
+135
@@ -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"
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
# Универсальный CSV импорта событий
|
||||
|
||||
Фаза 3. Формат — экспорт Snowball Income, но парсер
|
||||
(`sources/reports/csv_universal/parser.py`, `broker = "csv"`, `name = "csv"`) обращается с
|
||||
ним как с **универсальным CSV событий**: файл узнаётся по набору колонок заголовка, а не
|
||||
по имени. Любая будущая выгрузка с тем же заголовком читается тем же кодом.
|
||||
|
||||
Общие правила проекта действуют: деньги и количества — `Decimal` (ни одного float ни в
|
||||
событиях, ни в `meta`), даты — ISO, знаки — как в `models/ledger.Event` (`quantity`: + в
|
||||
позицию, − из позиции; `amount`: + получено, − уплачено; `fee`/`tax` положительные и уже
|
||||
внутри `amount`).
|
||||
|
||||
## Файл
|
||||
|
||||
- UTF-8, допускается BOM; запасная кодировка — cp1251.
|
||||
- Разделитель — запятая, все значения в кавычках (заголовок — без).
|
||||
- **Десятичный разделитель — запятая** (`4,48`, `11353,38392`), но целые пишутся без неё
|
||||
(`219`). Пробелы (обычный, NBSP, узкий NBSP) внутри числа вырезаются.
|
||||
- Пустая ячейка — это `None`, а **не** ноль. Пустой `NKD` значит «это не облигация»,
|
||||
`NKD = 0` — «облигация, но НКД нулевой»; схлопывать их нельзя.
|
||||
- Дата: `2025-02-26 09:22:54` (принимаются также `2025-02-26T…` и `2025-02-26`).
|
||||
В леджер идёт только дата; время используется для `seq` (см. «Дедупликация»).
|
||||
|
||||
### Колонки
|
||||
|
||||
Обязательные (без них `sniff` → `False`, `parse` → `ParseError`):
|
||||
`Event`, `Date`, `Symbol`, `Price`, `Quantity`, `Currency`.
|
||||
|
||||
Необязательные: `FeeTax`, `Exchange`, `NKD`, `FeeCurrency`, `DoNotAdjustCash`, `Note`.
|
||||
Выгрузка без них читается; `DoNotAdjustCash` парсер игнорирует — это внутренний флаг
|
||||
Snowball о том, правил ли он остаток, а не факт о деньгах.
|
||||
|
||||
## Смысл колонок зависит от типа события
|
||||
|
||||
Это главная особенность формата: `Quantity` — это штуки на `BUY` и **рубли** на
|
||||
`DIVIDEND`, `Price` — цена на сделке и **коэффициент** на `SPLIT`.
|
||||
|
||||
| `Event` | `Symbol` | `Price` | `Quantity` | `FeeTax` | Остальное |
|
||||
|---|---|---|---|---|---|
|
||||
| `BUY` / `SELL` | тикер или ISIN | цена за штуку (для облигаций — рубли за бумагу, не % номинала) | штук | комиссия сделки | `NKD` — весь НКД сделки, `Exchange` = `MCX` или `CUSTOM_HOLDING`, `FeeCurrency` — валюта комиссии |
|
||||
| `DIVIDEND` | тикер/ISIN | `0` | **сумма денег** | `0` | на облигации это купон, см. «Контракт с ingest» |
|
||||
| `AMORTISATION` | ISIN облигации | `0` | **сумма денег** | `0` | |
|
||||
| `CASH_IN` / `CASH_OUT` | код валюты (`RUB`) | `1` | сумма | `0` | `Note` отличает реальный поток от правки остатка |
|
||||
| `FEE` / `TAX` / `TAX_RETURN` | пусто | `0` | `0` | **сумма** | инструмента нет даже если сбор относится к бумаге — формат не говорит, к какой |
|
||||
| `SPLIT` | тикер | **коэффициент** (`10`) | `0` | `0` | деньги и позиция не двигаются |
|
||||
| `CUSTOM_HOLDING_PRICE` | тикер | **ручная цена** | `0` | `0` | `Exchange` = `CUSTOM_HOLDING` |
|
||||
| `CUSTOM_HOLDING_SETTINGS` | тикер | `0` | `0` | `0` | `Note` — JSON, где `"` заменены на `@*@` |
|
||||
|
||||
Деньги считаются так:
|
||||
|
||||
- `BUY`: `amount = −(Quantity·Price + NKD + FeeTax)`, `quantity = +Quantity`;
|
||||
- `SELL`: `amount = +(Quantity·Price + NKD − FeeTax)`, `quantity = −Quantity`;
|
||||
- `DIVIDEND` / `AMORTISATION`: `amount = +Quantity`, `quantity = None`;
|
||||
- `CASH_IN` / `CASH_OUT`: `amount = ±Quantity`, валюта — из `Symbol`, инструмента нет;
|
||||
- `FEE` / `TAX`: `amount = −FeeTax`; `TAX_RETURN`: `amount = +FeeTax`;
|
||||
- `SPLIT`: `amount = 0`, `quantity = None`, коэффициент — в `meta`.
|
||||
|
||||
## Маппинг в `EventKind`
|
||||
|
||||
| `Event` | `EventKind` |
|
||||
|---|---|
|
||||
| `BUY` | `buy` |
|
||||
| `SELL` | `sell` |
|
||||
| `CASH_IN` | `deposit` |
|
||||
| `CASH_OUT` | `withdrawal` |
|
||||
| `DIVIDEND` | `dividend` (+ `meta["payout_hint"] = "coupon"` для облигаций) |
|
||||
| `AMORTISATION` | `amortization` |
|
||||
| `FEE` | `commission` |
|
||||
| `TAX` | `tax` |
|
||||
| `TAX_RETURN` | `tax_refund` |
|
||||
| `SPLIT` | `split` (`EventKind.stock_split`) |
|
||||
|
||||
Событиями леджера **не становятся** `CUSTOM_HOLDING_PRICE` и `CUSTOM_HOLDING_SETTINGS`
|
||||
(см. ниже) и строки-правки остатка.
|
||||
|
||||
### Неизвестный тип события
|
||||
|
||||
Строка **пропускается**, в `warnings` добавляется `неизвестный тип события 'X': пропущено
|
||||
строк — N`, счётчик кладётся в `meta["unknown_events"]`. В `EventKind.other` такие строки
|
||||
не маппятся сознательно: `other` — молчаливая корзина, а нам нужен сигнал в data quality о
|
||||
том, что формат отрастил новый тип строки.
|
||||
|
||||
## Инструменты
|
||||
|
||||
`Symbol` — либо ISIN, либо тикер. ISIN распознаётся по форме (12 символов, две буквы,
|
||||
контрольная цифра) **с проверкой контрольной суммы** и с явным исключением секьюрити-id
|
||||
ОФЗ вида `SU26212RMFS9`: он проходит ту же контрольную сумму, но ISIN-ом не является, и
|
||||
резолвер искал бы по нему несуществующую бумагу. ОФЗ уходят тикером.
|
||||
|
||||
`asset_class_hint = "bond"` ставится, когда символ — валидный ISIN или секьюрити-id ОФЗ.
|
||||
Кастомный холдинг вроде `SIBN6P4` облигацией по символу не выглядит и подсказки не
|
||||
получает — класс определится при резолве инструмента.
|
||||
|
||||
`Exchange` (`MCX` / `CUSTOM_HOLDING`) кладётся в `InstrumentRef.meta["exchange"]`; доской
|
||||
(`board`) он не является, поэтому поле `board` остаётся пустым.
|
||||
|
||||
### `CUSTOM_HOLDING_SETTINGS` — карточка пользовательского инструмента
|
||||
|
||||
`Note` — это JSON, в котором все `"` заменены на `@*@` (чтобы CSV не пришлось экранировать),
|
||||
а не-ASCII текст записан `\uXXXX`-escape'ами. Парсер делает обратную замену и `json.loads`,
|
||||
после чего берёт:
|
||||
|
||||
- `Holding.Description` → `InstrumentRef.name` (в фикстуре — «Газпром Нефть 006Р-04»);
|
||||
- `Holding.Currency` → `InstrumentRef.currency`;
|
||||
- `Holding.Sector` → `meta["sector"]`, плюс `meta["custom_holding"] = True` и весь блок
|
||||
`Settings` в `meta["settings"]`.
|
||||
|
||||
Если JSON не разобрался — это `warning`, а не исключение: одна битая строка настроек не
|
||||
должна стоить пользователю пятисот хороших сделок. Инструмент тогда остаётся голым тикером.
|
||||
|
||||
### `CUSTOM_HOLDING_PRICE` — цена, а не событие
|
||||
|
||||
Это цена, введённая пользователем для бумаги, которую биржа не котирует. Её место —
|
||||
`price_manual`. В `BrokerEvent` положить цену некуда, поэтому строки едут в
|
||||
`ParsedReport.meta["manual_prices"]`:
|
||||
|
||||
```python
|
||||
{"instrument_key": "TICKER:SIBN6P4", "d": date(2026, 9, 14),
|
||||
"price": Decimal("12320.94504"), "currency": "RUB"}
|
||||
```
|
||||
|
||||
Сами инструменты дублируются в `ParsedReport.instruments`, чтобы `ingest` сначала
|
||||
отрезолвил бумагу обычным путём (pending instrument), а потом записал цены.
|
||||
|
||||
## Правки остатка — не поток и не сделка
|
||||
|
||||
Snowball сам вставляет строки `CASH_IN`/`CASH_OUT` с `Note` вида
|
||||
|
||||
> Эта сделка добавлена с целью корректировки баланса по этой валюте, т.к. баланс по данным
|
||||
> брокера (…) не соответствует балансу рассчитанному по сделкам (…).
|
||||
|
||||
Это правка расхождения его собственного пересчёта с остатком брокера, а не деньги, которые
|
||||
пересекли границу портфеля. Эмитить их как `deposit`/`withdrawal` нельзя: XIRR получит
|
||||
фиктивный внешний поток.
|
||||
|
||||
Такие строки в `events` **не попадают**, а уходят в `ParsedReport.meta["balance_adjustments"]`
|
||||
(`{"kind", "d", "amount", "currency", "note", "line_no"}`) и каждая — в `warnings`.
|
||||
|
||||
Определяются **по тексту `Note`** (маркеры `корректировки баланса` и
|
||||
`не соответствует балансу`), а не по величине суммы: в реальной выгрузке такая правка
|
||||
бывает и на 0,0024 ₽, и на 1017,84 ₽, тогда как настоящий вывод — на 32 ₽. Любой порог по
|
||||
сумме ошибся бы на обоих.
|
||||
|
||||
## Дедупликация
|
||||
|
||||
Номеров сделок в формате нет, поэтому `dedupe_key` всегда `fingerprint_key(...)`, и всю
|
||||
работу по различению «две реальные сделки» и «одна сделка, выгруженная дважды» делает
|
||||
`seq`.
|
||||
|
||||
`seq` — это ранг пары `(время суток, Note)` строки среди различных таких пар у всех строк
|
||||
с тем же содержимым отпечатка (`kind`, инструмент, дата, `quantity`, `price`, валюта,
|
||||
`amount`). Отсюда два следствия, оба намеренные:
|
||||
|
||||
- два настоящих пополнения по 1100 ₽ с разницей в секунду получают разные ключи, а
|
||||
повторный импорт того же файла воспроизводит те же ключи — ранг зависит только от
|
||||
содержимого и времени, никогда от номеров строк;
|
||||
- две строки, совпадающие вплоть до секунды и до `Note`, дают **один** ключ и схлопываются.
|
||||
Для этого формата это правильное поведение по умолчанию: колонки счёта в нём нет, поэтому
|
||||
повторённая строка почти всегда — одно событие, увиденное на двух счетах (в фикстуре — две
|
||||
строки `SPLIT` по `T` на `2026-04-16 03:00:00`), а применить коэффициент сплита дважды к
|
||||
единственному целевому счёту означало бы умножить позицию на 100 вместо 10.
|
||||
|
||||
Цена компромисса честная: перекрывающаяся выгрузка, в которой части одинаковых строк нет,
|
||||
сдвинет ранги оставшихся, и перекрытие импортируется как новые строки вместо upsert.
|
||||
Формат с номерами сделок этой проблемы не имел бы — здесь номеров нет.
|
||||
|
||||
## Область действия выгрузки
|
||||
|
||||
Файл покрывает **все брокерские счета сразу**: в фикстуре 49 инструментов трёх брокеров
|
||||
(T-Invest, Сбер, ВТБ) вперемешку за 2025-02-26…2026-09-17. Номера счёта в формате нет и
|
||||
вывести его неоткуда.
|
||||
|
||||
Поэтому `account_external_id` заполняется **именем портфеля из имени файла**
|
||||
(`Snowball_Export_<портфель>_<дд.мм.гггг>.csv` → `Мой капитал`; то же значение — в
|
||||
`meta["portfolio_name"]`), и парсер добавляет `warning`: целевой счёт обязан указать
|
||||
пользователь, а по plan §1.6 B у счёта один `primary_event_source` — значит почти все
|
||||
события лягут со `status = shadow` и работают как сверка, а не как леджер.
|
||||
|
||||
`positions_end` и `cash_end` формат не даёт вообще: закрывающих позиций и остатков денег в
|
||||
нём нет. Об этом тоже говорит `warning` — сверка остатков по этой выгрузке невозможна.
|
||||
|
||||
## Контракт с `ledger/ingest.py`
|
||||
|
||||
1. **Дивиденд на облигации — это купон.** Парсер не ходит в БД и не знает класс актива,
|
||||
поэтому `kind` остаётся `dividend`, а подозрение едет в `meta["payout_hint"] = "coupon"`.
|
||||
Окончательную переклассификацию в `EventKind.coupon` делает `ingest` после резолва
|
||||
инструмента, по его настоящему `asset_class`. Подсказка — ускорение для резолвера, а не
|
||||
ответ: кастомный холдинг-облигация подсказки не получит, и решать всё равно `ingest`.
|
||||
2. **`meta["manual_prices"]`** пишутся в `price_manual` после резолва инструментов из
|
||||
`instruments` (ключ — `InstrumentRef.key()`).
|
||||
3. **`meta["balance_adjustments"]`** в леджер не пишутся никогда; их место — data quality
|
||||
(расхождение derived vs брокер, которое Snowball уже зафиксировал).
|
||||
4. **`meta["split_ratio"]` / `meta["ratio"]`** на событии `split` — это заявленный
|
||||
коэффициент. `ledger/corporate_actions.py:_stated_ratio` читает обе эти ключевые метки,
|
||||
поэтому сплит из CSV попадает в `corporate_action` как **заявленный**, и выводить
|
||||
коэффициент из пары переводов не нужно.
|
||||
5. **`meta["csv_event"]`** хранит исходный тип строки — по нему в `raw_report_line` видно,
|
||||
из чего получилось событие.
|
||||
6. Ожидаемый статус событий — `shadow` для всех счетов, у которых `primary_event_source`
|
||||
не `csv`.
|
||||
Reference in New Issue
Block a user