Files
fin-tracker/docs/ai/architecture.md
T
Dmitry 220f027652 docs: контракт для агентов, архитектура и план фаз
AGENTS.md — индекс, команды и обязательные ограничения (Decimal для денег, не
конвертировать валюту на записи, SDK T-Invest ровно в одном модуле, аналитика
читает только confirmed). docs/ai/ — архитектура, соглашения, эксплуатация и
полный план на шесть фаз с проверками для каждой.
2026-09-18 13:43:31 +03:00

6.3 KiB
Raw Blame History

Архитектура

Полный проект с обоснованиями — docs/ai/plan.md. Здесь — то, что нужно держать в голове при изменении кода. Таблицы, которых ещё нет в models/, помечены (план).

Домен

Счета и портфели (models/accounts.py)

  • account — и ZenMoney-счета (kind = zm_*), и брокерские (broker), и ручные активы. source + source_id уникальны. mirror_of_account_id помечает ZM-счёт, который лишь зеркалит брокерский (исключается из net worth). primary_event_source — чей леджер для этого счёта истина; события других источников становятся shadow.
  • portfolioaccount m:n через portfolio_account; составной портфель = все счета.
  • account_link — явная карта «ZM-счёт → брокерский счёт» для матчинга переводов.

Инструменты (models/instruments.py)

  • instrument с частичными UNIQUE по isin, figi, tinvest_uid, (ticker, board). Резолв: ISIN → FIGI → tinvest_uid → (ticker, board) → instrument_alias.
  • Нераспознанные из отчётов → pending_instrument (план), подтверждает пользователь.

Леджер (план, фаза 2)

  • event — единая таблица событий всех брокерских источников: buy/sell/dividend/coupon/ tax/commission/deposit/withdrawal/transfer_in/out/split/amortization/repayment/fx_exchange. quantity знаковый, amount — знаковый денежный эффект на счёт, dedupe_key UNIQUE, status ∈ confirmed|pending|shadow|ignored. Аналитика читает только confirmed.
  • lot/lot_disposal — FIFO, пересчёт с нуля (ledger/lots.py).
  • flow_link — связь ZM-транзакции и брокерского deposit/withdrawal (ledger/matching.py).
  • Повседневные транзакции ZM — отдельно в cash_txn (фаза 1), с flow_type и правилами.

Синк (models/sync.py)

  • sync_state — курсор на источник; sync_run — журнал; sync_job — очередь ручных запусков; source_credential — ротируемые креды (refresh_token ZenMoney).

Источники (sources/)

Реализованы: zenmoney (diff-курсор, два режима auth: статический токен или OAuth-ротация через source_credential; маппер всегда пересобирает core из полных raw_*), cbr (валюты берутся из счетов и транзакций, trust_env=False — мимо прокси). Расписание — worker/jobs.py.

Контракт sources/base.py: Source.sync(SyncContext) -> SyncResult. Источник пишет raw_* идемпотентно и возвращает новый курсор; всё остальное (lock, журнал, курсор, ошибки) делает worker/runner.run_source. Регистрация — sources/registry.register.

Worker (worker/)

Отдельный процесс: APScheduler по расписанию из worker/jobs.default_schedule() + опрос sync_job каждые 5 с. На источник — advisory lock sync:<name>, поэтому ручной и плановый запуски не пересекаются. После синка, изменившего данные, — refresh метрик (план).

Аналитика (analytics/, pricing/{fx,prices}.py, metrics/refresh.py)

Шаги регистрируются в analytics/__init__.py и выполняются refresh_all по порядку: fx → classify → lots → valuation → returns → networth → cashflow → spending → runway → quality (фаза 2 вставит income и allocation после returns). Запуск: worker после синка с changed=True, fintracker metrics refresh, POST /metrics/refresh, POST /rules/apply. Каждая metric_* таблица пересобирается целиком. Net worth считается от текущего account.balance назад по транзакциям; конвертация — FxTable по дате операции, цены — PriceTable (протяжка вперёд, STALE_AFTER_DAYS = 10, назад не тянем).

valuation строит дневную серию стоимости (реплей event) и текущие холдинги (из lot — там есть себестоимость и учтены сплиты); расхождение между ними на последний день — находка, а не выбор одного из двух. Единица отчётности — scope: all, account:<id>, portfolio:<id>. returns читает только metric_portfolio_value_daily: XIRR по внешним потокам и терминальной стоимости, TWR цепочкой V_t / (V_{t-1} + F_t). Покупка бумаги без цены трактуется как вывод из оцениваемого портфеля (unvalued_flow_rub), иначе дыра в данных читалась бы как обвал.

Деньги в JSON — Money (api/schemas/common.py), всегда позиционная строка.

API (api/)

  • Префикс /api/v1, OpenAPI на /api/v1/openapi.json, docs на /api/v1/docs.
  • operationId = "<tag>_<name>" → читаемые методы Dart-клиента.
  • Ошибки — RFC 7807 (application/problem+json), см. api/errors.py.
  • Auth: POST /auth/login → access JWT (1 ч) + refresh (30 д, хранится хэшем, ротируется); login rate-limited в памяти (api/ratelimit.py).
  • Деньги в JSON — строки.

Клиент (app/)

Flutter, Riverpod, go_router, fl_chart; клиент app/packages/api_client генерируется just gen-client из openapi/openapi.json и коммитится.