Files
fin-tracker/AGENTS.md
T
Dmitry fcb3fe964e docs: состояние фазы 2 после матчинга, корпдействий и бэкфилла цен
Три утверждения в разделе «Состояние» оказались неверны и стоили агентам времени на
перепроверку.

Пять расхождений позиций описаны как корпоративные действия — на деле корпоративное
там только FIVE→X5, остальные четыре были отменёнными заявками. Причина тонкой истории
цен — не «большинство облигаций куплено в апреле» (у них min(d) совпадает с днём
первого владения), а четыре фонда и переезд с доски TQTF на TQBR. И сектор больше не
пуст у всех инструментов.

Добавлен порядок шагов пересчёта с объяснением, почему matching обязан идти после
classify: это единственное место, где перестановка тихо отменяет результат.
2026-09-18 15:08:34 +03:00

114 lines
10 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` |
| Деплой | `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: API `/links/unmatched` и экран несматченных линков, эндпоинт
брокерских потоков, `price_manual` для двух бумаг без котировок.
Порядок шагов пересчёта (`analytics/__init__.py:register_steps`):
`fx → classify → matching → corpactions → lots → valuation → returns → allocation →
cashflow_broker → networth → cashflow → spending → runway → 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`.