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}` и экраны «Портфель» (позиции + - API `/analytics/*`, `/events`, `/instruments/{id}` и экраны «Портфель» (позиции +
аллокация), карточка инструмента и «События». аллокация), карточка инструмента и «События».
Осталось по фазе 2: API `/links/unmatched` и экран несматченных линков, эндпоинт **Фаза 2 закрыта.** `GET /links/unmatched` + `POST /links` + `DELETE /links/{id}` и экран
брокерских потоков, `price_manual` для двух бумаг без котировок. несматченных линков; `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`): Порядок шагов пересчёта (`analytics/__init__.py:register_steps`):
`fx → classify → matching → corpactions → lots → valuation → returns → allocation `fx → classify → matching → corpactions → lots → valuation → returns → benchmarks
cashflow_broker → networth → cashflow → spending → runway → quality`. `matching` обязан allocation → rebalance → cashflow_broker → income → tax → networth → cashflow → spending →
идти после `classify`: тот пересчитывает `flow_type` с нуля и затёр бы результат линковки. runway → shadow_dedupe → report_reconcile → quality`. `matching` обязан идти после
`classify`: тот пересчитывает `flow_type` с нуля и затёр бы результат линковки.
Известные расхождения derived vs брокерский снапшот по позициям (5 шт) объяснены. Четыре из Известные расхождения derived vs брокерский снапшот по позициям (5 шт) объяснены. Четыре из
пяти — не корпоративные действия, а отменённые заявки: `GetOperationsByCursor` отдаёт пяти — не корпоративные действия, а отменённые заявки: `GetOperationsByCursor` отдаёт
+28
View File
@@ -476,6 +476,26 @@ instruments; экраны: позиции, карточка инструмент
неизвестный ISIN → pending, после резолва лоты пересобраны; pdf и xlsx ВТБ за один период неизвестный ISIN → pending, после резолва лоты пересобраны; pdf и xlsx ВТБ за один период
дают одинаковые `dedupe_key`; парсеры без зависимости от БД. дают одинаковые `dedupe_key`; парсеры без зависимости от БД.
**Статус: закрыта.** Все проверки пройдены на живых отчётах, кроме одной: pdf-выгрузок ВТБ у
пользователя нет, и сверить два формата не на чем. Вместо неё сверены два xlsx с
перекрывающимися периодами, а контракт, который обязан соблюсти будущий `vtb/pdf.py`, чтобы
ключи совпали (брокер, номер соглашения, «№ сделки»), записан в `vtb/common.py`.
Что выяснилось по ходу и чего в плане не было:
- CSV-выгрузка Snowball **сводная по всем брокерам и не содержит разбивки по счетам**, а
также закрывающих позиций и остатков. Как сверка она годится для потоков, сделок и выплат
на уровне портфеля, но не для позиций по счетам. Целевой счёт указывает пользователь, и на
счёте с другим `primary_event_source` события лягут `shadow`;
- в ней же 4 строки — не операции, а правки остатка самим Snowball (3 среди `CASH_OUT`,
1 среди `CASH_IN`), причём одна на −1017,84 ₽, то есть крупнее настоящего вывода. Отличать
их можно только по тексту `Note`, порог по сумме ошибается;
- два `SPLIT` по `T` — одно корпоративное действие, увиденное на двух счетах; схлопываются в
один `dedupe_key`, и это верно: иначе коэффициент 10 применился бы дважды;
- `CUSTOM_HOLDING_PRICE` закрывает ручной ценой только `SIBN6P4`;
`NDM_TBNK-PP-FIXPRCNT-08.25` в выгрузке отсутствует, его дыра остаётся открытой;
- отчёты Сбера не содержат дивидендов, налогов и купонной секции вовсе.
### Фаза 4 — доходы, ребалансировка, бенчмарки, цели, налоги ### Фаза 4 — доходы, ребалансировка, бенчмарки, цели, налоги
`sync_events.py` (GetDividends, GetBondCoupons, GetBondEvents), MOEX как второй источник `sync_events.py` (GetDividends, GetBondCoupons, GetBondEvents), MOEX как второй источник
с правилами приоритета, `analytics/income.py`, `rebalance.py` (целевые веса по категориям, с правилами приоритета, `analytics/income.py`, `rebalance.py` (целевые веса по категориям,
@@ -488,6 +508,14 @@ instruments; экраны: позиции, карточка инструмент
одной сетке без дыр в праздники; лот > 3 лет помечен `ldv_eligible`, налоговый вид одной сетке без дыр в праздники; лот > 3 лет помечен `ldv_eligible`, налоговый вид
сходится с ручным примером. сходится с ручным примером.
**Статус: код и тесты закрыты (157 backend-тестов), прогон на живом портфеле — нет.**
Четыре шага (`income`, `tax`, `benchmarks`, `rebalance`) зарегистрированы в
`register_steps`, оба источника выплат (`tinvest_events`, `moex_payouts`) — в реестре
источников, но ни разу не выполнялись против боевой БД: `metrics refresh` с ними ещё не
запускался. Бенчмарки (IMOEX/MCFTR/RGBITR) — данные, не код: пока в `benchmark` не добавлена
ни одна строка, `/analytics/benchmarks` пуст. S&P (вопрос 2) не тронут — по-прежнему только
ручной CSV.
### Фаза 5 — полировка и эксплуатация ### Фаза 5 — полировка и эксплуатация
Адаптивные раскладки, тёмная тема, offline-кэш с баннером «данные на…», release-сборка Адаптивные раскладки, тёмная тема, offline-кэш с баннером «данные на…», release-сборка
Android, Linux/Windows в CI, пустые/ошибочные состояния, экран настроек, runbook Android, Linux/Windows в CI, пустые/ошибочные состояния, экран настроек, runbook