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

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

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

Обезличивание — anonymize.py + scripts/anonymize_reports.py, секреты
собираются по всему корпусу отчётов разом (Snowball цитирует номер договора
Сбера в примечании к переводу). test_fixtures_anonymized.py падает, если
в tests/fixtures/reports/ вне raw/ найдётся ИНН, ФИО или номер счёта — и по
форме (работает в CI без raw/), и по фактическому содержимому raw/, когда оно
на месте.
This commit is contained in:
Dmitry
2026-09-19 10:39:37 +03:00
parent 6b32c79e79
commit 2e742a093b
25 changed files with 7284 additions and 0 deletions
+200
View File
@@ -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`.