Files
fin-tracker/AGENTS.md
T
Dmitry d2df86ce33 feat(moex): история бумаг с листинга и цены индексов для бенчмарков
Окно бэкфилла начинается с history_from доски, а не с первой покупки: график цены показывает историю бумаги целиком. Для активных бенчмарков source=moex синк создаёт instrument и качает индекс с его доски (IMOEX и RGBITR на SNDX, MCFTR на RTSI). Миграция засевает IMOEX, MCFTR и RGBITR; тесты чистят таблицы перед каждым тестом, чтобы сид не попадал в первый из них.
2026-09-20 10:46:24 +03:00

196 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AGENTS.md
Контракт для AI-агентов в этом репозитории. Подробности — в [`docs/ai/`](docs/ai/README.md);
этот файл — индекс, команды и ограничения, не описание архитектуры.
## Проект одной фразой
Личная аналитика финансов и инвестиций: ZenMoney (повседневные деньги) + брокеры
(T-Invest API, отчёты Сбера и ВТБ) → PostgreSQL → FastAPI → Flutter-клиент. Заменяет
Snowball Income. Один пользователь, свой VPS.
## Команды
```bash
nix develop # backend toolchain (uv, python 3.12, postgres 17, just, openapi-generator)
nix develop .#app # + Flutter
just db-start # локальный Postgres в ./.pgdata на порту 54329
just migrate # alembic upgrade head
just api # FastAPI с reload, http://127.0.0.1:8000/api/v1/docs
just web # то же + Flutter web-сборка с того же origin (WEB_DIR=app/build/web)
just worker # планировщик синков
just check # ruff + pyright + pytest + проверка дрейфа OpenAPI
just openapi # экспорт openapi/openapi.json (коммитится)
just gen-client # Dart-клиент в app/packages/api_client (коммитится)
just revision "msg" # новая alembic-миграция из изменений моделей
```
Тесты поднимают собственный Postgres через `pg_ctl` (pytest-postgresql) — Docker не нужен.
## Карта задача → контекст
| Задача | Что читать |
|---|---|
| Любое изменение | [docs/ai/README.md](docs/ai/README.md), затем нужный раздел [architecture.md](docs/ai/architecture.md) |
| Новый источник данных (`backend/src/fintracker/sources/`) | architecture.md §Источники, `sources/base.py`, `worker/runner.py` |
| Модель данных / миграции (`models/`, `alembic/`) | architecture.md §Домен, [conventions.md](docs/ai/conventions.md) |
| Аналитика / метрики | architecture.md §Аналитика, [conventions.md](docs/ai/conventions.md) §Деньги |
| API / Flutter-клиент | architecture.md §API, `openapi/openapi.json` |
| Offline-кэш экрана (фаза 5) | [offline-cache.md](docs/ai/offline-cache.md) — контракт `Cached<T>` + `CacheInterceptor` |
| Деплой | `docker-compose.yml`, `deploy/Caddyfile`, [ops.md](docs/ai/ops.md) |
## Обязательные ограничения
- **Деньги — `Decimal`**, в БД `NUMERIC(24,10)` + колонка валюты рядом. Никаких float в
хранении и в API (в JSON — строки). Float допустим только внутри расчёта коэффициентов.
- **Не конвертировать валюту на записи.** Конвертация — по курсу на дату операции, в
аналитике. Нет курса → NULL и запись в data quality, не подмена.
- **SDK T-Invest (`t_tech.invest`) импортируется только в `sources/tinvest/client.py`.**
Ставится с индекса T-Bank (`[tool.uv.index]` в `backend/pyproject.toml`) — не менять на PyPI.
- **Аналитика читает только `event.status = confirmed`.** Snapshot-таблицы — для сверки.
- **Сырые данные (`raw_*`) append-only и идемпотентны** по стабильному id источника.
- `openapi/openapi.json` и `app/packages/api_client` — генерируются, но коммитятся;
после изменения роутов запускай `just gen-client`. В схемах API не выставляй наружу
`AssetClass`: одно из его значений — `index`, а Dart-енум не может иметь члена с таким
именем (конфликт с `Enum.index`), и сгенерированный клиент перестаёт компилироваться.
Стабильные ключи (`asset_class`, `price_status`, `bucket`, `period`) идут строками.
- Секреты только в `.env``.gitignore`). Реальные отчёты брокеров — в
`backend/tests/fixtures/reports/raw/``.gitignore`); в git только обезличенные фикстуры.
- Коммиты — Conventional Commits на русском (`feat(ledger): …`), автор — только пользователь.
## Состояние
Фазы 0 и 1 завершены и проверены на живых данных (31 счёт, 5575 транзакций ZenMoney, курсы ЦБ,
все экраны работают). Правил классификации пока ноль — переводы и сбережения попадают в расходы.
Фаза 2 в работе. Готово и прогнано на живых данных:
- `sources/tinvest` — 7 счетов, 2756 операций → `event`, маппер на все 67 `OperationType`;
- `ledger/lots.py` + `rebuild.py` — FIFO, 804 лота, 672 закрытия, реализовано +1892 ₽;
короткие позиции поддержаны, `qty_remaining` хранится со знаком;
- `sources/moex` — 77 инструментов, 33 380 дневных цен, `price_daily` / `price_last`;
бэкфилл ведётся по инструменту через `price_coverage`, а не по общему курсору источника,
и режет окно по доскам: фонды T-Bank переехали с TQTF на TQBR 22.06.2026, и до переезда
история отвечает только на старой доске;
- `analytics/valuation` — дневная серия стоимости и холдинги по scope (`all`, `account:<id>`,
`portfolio:<id>`), сверка со снапшотами брокера; `analytics/returns` — XIRR и TWR по
периодам (`1m 3m 6m ytd 1y 3y all`) плюс XIRR на инструмент; `pricing/prices.py` — цена на
дату с протяжкой вперёд и порогом устаревания 10 дн.;
- `analytics/allocation` — разрезы по классу актива, сектору, стране и валюте; каждый
покрывает один и тот же итог (бумаги + кэш), поэтому веса любого из них дают единицу;
- `ledger/matching.py` + `flow_link` — связывание перевода ZenMoney с брокерским
deposit/withdrawal (та же валюта, |Δ| ≤ max(1 ₽, 0,5 %), ≤ 3 рабочих дня, жадно 1:1);
связанная ZM-транзакция становится `internal_transfer`. Кандидаты берутся по
`mirror_of_account_id`, `account_link` и правилу `broker_target`;
- `ledger/corporate_actions.py` — сплиты, амортизации и погашения выводятся из леджера в
`corporate_action` до пересборки лотов;
- `analytics/cashflow_broker` — пополнения и выводы по scope и месяцу; читает `event`
заново, а не дневную сумму `external_flow_rub`: та неттит потоки внутри дня и прячет
512 550 ₽ выводов;
- `sources/tinvest` — сектор берётся из потиповых `Shares`/`Bonds`/`Etfs` (в ответе
`GetInstrumentBy` его нет): 73 из 80 инструментов, аллокация по сектору — 9 бакетов;
- API `/analytics/*`, `/events`, `/instruments/{id}` и экраны «Портфель» (позиции +
аллокация), карточка инструмента и «События».
**Фаза 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 завершена и прогнана на живых отчётах:
- `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`; источники с `needs="tinvest_token"`
не попадают в расписание, пока токен не задан;
- `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 без
дивидендов льстит портфелю на несколько % годовых, это осознанный выбор пользователя,
какой индекс сравнивать. Цены индексов тянет `sources/moex` (`_ensure_benchmark_instruments`
создаёт `instrument` для активной строки `benchmark`; доска берётся из ISS — MCFTR на RTSI,
IMOEX и RGBITR на SNDX); миграция `f3a91c7d5e28` засевает IMOEX, MCFTR (оба `is_default`)
и RGBITR. История бумаг и индексов качается с листинга, а не с первой покупки;
- `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 → benchmarks →
allocation → rebalance → cashflow_broker → income → tax → networth → cashflow → spending →
runway → shadow_dedupe → report_reconcile → quality`. `matching` обязан идти после
`classify`: тот пересчитывает `flow_type` с нуля и затёр бы результат линковки.
Известные расхождения derived vs брокерский снапшот по позициям (5 шт) объяснены. Четыре из
пяти — не корпоративные действия, а отменённые заявки: `GetOperationsByCursor` отдаёт
операции с `state = OPERATION_STATE_CANCELED`, у них `quantity_done = 0` и нет платежа,
и покупка читалась как бесплатное приобретение (SIBN6P4 +5, HEAD +2, FIVE +2, MGNT 1).
`sync._is_executed` их теперь отсеивает и подчищает при перечитывании; уже импортированные
строки уходят только при повторном чтении того же окна. Пятое — редомициляция FIVE→X5:
зачисления X5 нет ни в одной операции ленты, лот невыводим из данных и требует ручного
`transfer_in`. Без цен два инструмента — `SIBN6P4` и `NDM_TBNK-PP-FIXPRCNT-08.25`, им нужен
`price_manual`. По кэшу derived выше снапшота на счетах 46, 48 и 52 (+6313, +56447, +146 ₽)
— вероятно та же отменённая заявка, отдельно не проверялось: счёт 51 сходится точно.
TWR пропускает 3 дня вместо прежних 32 — остаток держат две бумаги без котировок.
План фаз — в `docs/ai/plan.md`.