Files
fin-tracker/docs/ai/csv-import.md
T
Dmitry 2e742a093b 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/, когда оно
на месте.
2026-09-19 10:39:37 +03:00

16 KiB
Raw Blame History

Универсальный 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 (см. «Дедупликация»).

Колонки

Обязательные (без них sniffFalse, parseParseError): 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.DescriptionInstrumentRef.name (в фикстуре — «Газпром Нефть 006Р-04»);
  • Holding.CurrencyInstrumentRef.currency;
  • Holding.Sectormeta["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

  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.