CacheInterceptor кэширует каждый успешный GET по путь+параметры в drift (sqlite нативно, wasm+OPFS в браузере) и подменяет им сетевую ошибку; провайдер отдаёт Cached<T>, экран показывает баннер «данные на …». Контракт для остальных экранов — docs/ai/offline-cache.md. flake.nix: libsecret/pkg-config для линуксовой сборки, jq/curl для just app-web-assets (сборка sqlite3.wasm + drift_worker.js).
193 lines
19 KiB
Markdown
193 lines
19 KiB
Markdown
# 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 завершена и прогнана на живых отчётах (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 → 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`.
|