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/, когда оно на месте.
16 KiB
Универсальный 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"]:
{"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
- Дивиденд на облигации — это купон. Парсер не ходит в БД и не знает класс актива,
поэтому
kindостаётсяdividend, а подозрение едет вmeta["payout_hint"] = "coupon". Окончательную переклассификацию вEventKind.couponделаетingestпосле резолва инструмента, по его настоящемуasset_class. Подсказка — ускорение для резолвера, а не ответ: кастомный холдинг-облигация подсказки не получит, и решать всё равноingest. meta["manual_prices"]пишутся вprice_manualпосле резолва инструментов изinstruments(ключ —InstrumentRef.key()).meta["balance_adjustments"]в леджер не пишутся никогда; их место — data quality (расхождение derived vs брокер, которое Snowball уже зафиксировал).meta["split_ratio"]/meta["ratio"]на событииsplit— это заявленный коэффициент.ledger/corporate_actions.py:_stated_ratioчитает обе эти ключевые метки, поэтому сплит из CSV попадает вcorporate_actionкак заявленный, и выводить коэффициент из пары переводов не нужно.meta["csv_event"]хранит исходный тип строки — по нему вraw_report_lineвидно, из чего получилось событие.- Ожидаемый статус событий —
shadowдля всех счетов, у которыхprimary_event_sourceнеcsv.