From 95cd6f176e25a53ba59b4afd1aadced5a5b57af2 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sat, 19 Sep 2026 10:57:00 +0300 Subject: [PATCH] =?UTF-8?q?docs:=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D1=84?= =?UTF-8?q?=D0=B0=D0=B7=202=E2=80=934?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md и docs/ai/plan.md — фаза 2 закрыта (линки, брокерские потоки, ручные цены), фазы 3 и 4 закрыты и протестированы на живых отчётах и фикстурах; порядок шагов пересчёта дополнен benchmarks/rebalance/income/tax. --- AGENTS.md | 88 ++++++++++++++++++++++++++++++++++++++++++++++--- docs/ai/plan.md | 28 ++++++++++++++++ 2 files changed, 111 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8d6ce36..abaeeb4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` отдаёт diff --git a/docs/ai/plan.md b/docs/ai/plan.md index ea34724..6e24f58 100644 --- a/docs/ai/plan.md +++ b/docs/ai/plan.md @@ -476,6 +476,26 @@ instruments; экраны: позиции, карточка инструмент неизвестный ISIN → pending, после резолва лоты пересобраны; pdf и xlsx ВТБ за один период дают одинаковые `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 — доходы, ребалансировка, бенчмарки, цели, налоги `sync_events.py` (GetDividends, GetBondCoupons, GetBondEvents), MOEX как второй источник с правилами приоритета, `analytics/income.py`, `rebalance.py` (целевые веса по категориям, @@ -488,6 +508,14 @@ instruments; экраны: позиции, карточка инструмент одной сетке без дыр в праздники; лот > 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 — полировка и эксплуатация Адаптивные раскладки, тёмная тема, offline-кэш с баннером «данные на…», release-сборка Android, Linux/Windows в CI, пустые/ошибочные состояния, экран настроек, runbook