# 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:`, `portfolio:`), сверка со снапшотами брокера; `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`.