# Универсальный 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`.