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:
@@ -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