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

201 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Универсальный 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`.