docs: состояние после фаз 2–4

AGENTS.md и docs/ai/plan.md — фаза 2 закрыта (линки, брокерские потоки,
ручные цены), фазы 3 и 4 закрыты и протестированы на живых отчётах и
фикстурах; порядок шагов пересчёта дополнен benchmarks/rebalance/income/tax.
This commit is contained in:
Dmitry
2026-09-19 10:57:00 +03:00
parent df49d99af4
commit 95cd6f176e
2 changed files with 111 additions and 5 deletions
+83 -5
View File
@@ -91,13 +91,91 @@ just revision "msg" # новая alembic-миграция из изме
- API `/analytics/*`, `/events`, `/instruments/{id}` и экраны «Портфель» (позиции +
аллокация), карточка инструмента и «События».
Осталось по фазе 2: API `/links/unmatched` и экран несматченных линков, эндпоинт
брокерских потоков, `price_manual` для двух бумаг без котировок.
**Фаза 2 закрыта.** `GET /links/unmatched` + `POST /links` + `DELETE /links/{id}` и экран
несматченных линков; `GET /analytics/cashflow-broker`; `POST /instruments/{id}/prices`
(апсерт по `(instrument_id, d)`, `pricing/prices.py` подмешивает `price_manual` в ту же
серию, что и `price_daily`, с тем же протягиванием и порогом устаревания). Оба инструмента
без котировок закрываются одним и тем же механизмом: вручную через этот эндпоинт, либо
импортом Snowball CSV (фаза 3) — тот пишет `price_manual` из `CUSTOM_HOLDING_PRICE`, но
закрывает только `SIBN6P4`, для `NDM_TBNK-PP-FIXPRCNT-08.25` цены нет ни в одной выгрузке.
Импорт CSV на живую базу ещё не запускался — это на живых данных не проверено, только на
фикстурах.
Фаза 3 завершена и прогнана на живых отчётах (399 backend-тестов, 27 flutter):
- `sources/reports/` — протокол `ReportParser` + три парсера: Сбер (HTML), ВТБ (xlsx),
универсальный CSV (экспорт Snowball). `registry.py` выбирает парсер по содержимому файла,
и CSV стоит последним: он узнаёт файл по набору колонок и иначе перехватил бы чужой формат;
- **комиссия капитализируется в сделку, отдельным событием не эмитится.** И Сбер, и ВТБ
печатают её дважды — колонками в сделках и строками в движении денег; обе суммы совпадают
(Сбер 138.03 + 19.54, ВТБ 3.64), и второе прочтение задвоило бы её, потому что
`ledger/lots.py` уже кладёт `fee` в стоимость лота. Парсеры сверяют обе ипостаси на каждом
файле и предупреждают при расхождении;
- расчётные строки («Сделка от …» у Сбера, «Сальдо расчетов по сделкам» у ВТБ, «Движение
ценных бумаг» и «Завершенные сделки» у ВТБ) не эмитятся: это денежные и депозитарные ноги
уже учтённых сделок;
- **таблица «Информация о зачислениях на ИИС» у Сбера кумулятивна за год до даты
формирования отчёта** (8 строк в августовском файле, 9 в полном) — читается, но в леджер
не идёт, иначе два пересекающихся отчёта задвоили бы пополнения за полгода;
- `ledger/ingest.py``BrokerEvent``event`, апсерт по `dedupe_key`; `ledger/dedupe.py`
shadow-матчинг случая B двумя проходами (точная дата, затем ±1 рабочий день, жадно 1:1,
`|price|` ±0,5 %); `ledger/report_import.py` — upload/preview/commit и резолв
`pending_instrument`. Инструмент никогда не угадывается: не резолвится — событие ждёт;
- шаги `shadow_dedupe` и `report_reconcile` зарегистрированы перед `quality`: оба говорят
через `FINDINGS`, который сбрасывается первым шагом refresh и осушается последним;
- обезличивание — `sources/reports/anonymize.py` + `scripts/anonymize_reports.py`;
секреты собираются сразу по всему корпусу отчётов, а не пофайлово (Snowball цитирует номер
договора Сбера в примечании к переводу). `tests/test_fixtures_anonymized.py` падает, если
в `tests/fixtures/reports/` вне `raw/` появится ИНН, ФИО или номер счёта;
- API `/imports` и `/instruments/pending` (контракт — `docs/ai/import-contract.md`), экраны
импорта и резолва. `pending_router` включается ДО `routers/instruments.py`: FastAPI
сопоставляет маршруты по порядку, и `/instruments/{instrument_id}` с типом `int` отвечает
422 на нечисловой сегмент, а не проваливается дальше.
Чего в отчётах нет: у Сбера — дивидендов, налогов и купонной секции; у CSV — закрывающих
позиций и остатков денег, а также разбивки по счетам (выгрузка сводная по всем брокерам,
поэтому годится как независимая сверка потоков, сделок и выплат по портфелю целиком, но не
позиций по счетам). Проверку «pdf и xlsx ВТБ дают одинаковые `dedupe_key`» выполнить не на
чем — pdf-выгрузок нет; контракт для будущего pdf-парсера записан в `vtb/common.py`.
Фаза 4 завершена (157 backend-тестов на аналитику доходов/ребалансировки/налогов/бенчмарков
и источники выплат):
- `sources/tinvest/sync_events.py` (`GetDividends`, `GetBondCoupons`, `GetBondEvents`) и
`sources/moex/payouts.py` (bondization + dividends ISS) — второй источник выплат;
`pricing/payouts.resolve_payouts` решает, чей купон/дивиденд считать источником истины,
на чтении, а не на записи: `corporate_action` уникален по `(instrument_id, kind, source,
source_id)`, так что строки обоих источников сосуществуют, и приоритет можно поменять без
ресинка истории. Амортизация из MOEX идёт не в `corporate_action`, а в
`bond_nominal_schedule` — этим типом безраздельно владеет `ledger/corporate_actions.py`;
оба источника зарегистрированы (`tinvest_events`, `moex_payouts`), но не добавлены в
`worker/jobs.default_schedule()` — как и сами `tinvest`/`moex`, они туда не входили и до
этой фазы, расписание синков за её рамками;
- `analytics/income.py``metric_income_monthly` (факт, только confirmed) и
`metric_income_calendar` (прошлое и прогноз) с явным `basis` (`paid` / `announced` /
`history`) на каждой строке — три источника числа никогда не смешиваются в одно;
- `analytics/rebalance.py` — предложенные сделки по `portfolio_target`, пропорционально
внутри каждого бакета (никогда не по инструменту — у бакета нет целевого веса на бумагу),
округление лотов только вниз, покупки ограничены реальным остатком кэша
(`blocked_by_cash`, не заимствует у ещё не свершившихся продаж);
- `analytics/tax.py` — оценка, не замена справки брокера: дивиденды/купоны берутся gross
(`amount + tax`), реализованный результат — из `lot_disposal` в рублях с переоценкой каждой
ноги на свою дату, ЛДВ — по `lot.holding_days`;
- `analytics/benchmarks.py` — TWR индекса на сетке дат портфеля; `kind` (`price` vs
`total_return`) выставляется наружу, а не скрывается: сравнение с ценовым IMOEX без
дивидендов льстит портфелю на несколько % годовых, это осознанный выбор пользователя,
какой индекс сравнивать;
- `analytics/goals.py` + `api/routers/goals.py` — прогресс цели и требуемый ежемесячный
взнос по trailing XIRR;
- четыре новых шага в `register_steps`: `benchmarks` после `returns` (общая сетка дат),
`rebalance` после `allocation` (переиспользует её веса, не пересчитывает), `income` и `tax`
после `lots` (нужен `lot_disposal`).
Порядок шагов пересчёта (`analytics/__init__.py:register_steps`):
`fx → classify → matching → corpactions → lots → valuation → returns → allocation
cashflow_broker → networth → cashflow → spending → runway → quality`. `matching` обязан
идти после `classify`: тот пересчитывает `flow_type` с нуля и затёр бы результат линковки.
`fx → classify → matching → corpactions → lots → valuation → returns → benchmarks
allocation → rebalance → cashflow_broker → income → tax → networth → cashflow → spending →
runway → shadow_dedupe → report_reconcile → quality`. `matching` обязан идти после
`classify`: тот пересчитывает `flow_type` с нуля и затёр бы результат линковки.
Известные расхождения derived vs брокерский снапшот по позициям (5 шт) объяснены. Четыре из
пяти — не корпоративные действия, а отменённые заявки: `GetOperationsByCursor` отдаёт