Три утверждения в разделе «Состояние» оказались неверны и стоили агентам времени на перепроверку. Пять расхождений позиций описаны как корпоративные действия — на деле корпоративное там только FIVE→X5, остальные четыре были отменёнными заявками. Причина тонкой истории цен — не «большинство облигаций куплено в апреле» (у них min(d) совпадает с днём первого владения), а четыре фонда и переезд с доски TQTF на TQBR. И сектор больше не пуст у всех инструментов. Добавлен порядок шагов пересчёта с объяснением, почему matching обязан идти после classify: это единственное место, где перестановка тихо отменяет результат.
10 KiB
AGENTS.md
Контракт для AI-агентов в этом репозитории. Подробности — в docs/ai/;
этот файл — индекс, команды и ограничения, не описание архитектуры.
Проект одной фразой
Личная аналитика финансов и инвестиций: ZenMoney (повседневные деньги) + брокеры (T-Invest API, отчёты Сбера и ВТБ) → PostgreSQL → FastAPI → Flutter-клиент. Заменяет Snowball Income. Один пользователь, свой VPS.
Команды
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, затем нужный раздел architecture.md |
Новый источник данных (backend/src/fintracker/sources/) |
architecture.md §Источники, sources/base.py, worker/runner.py |
Модель данных / миграции (models/, alembic/) |
architecture.md §Домен, conventions.md |
| Аналитика / метрики | architecture.md §Аналитика, conventions.md §Деньги |
| API / Flutter-клиент | architecture.md §API, openapi/openapi.json |
| Деплой | docker-compose.yml, deploy/Caddyfile, 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, маппер на все 67OperationType;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.