Files
fin-tracker/docs/ai/architecture.md
T
Dmitry 400d9922bb docs: состояние фазы 2 после аллокации, API и экранов портфеля
Плюс два ограничения, которые стоили времени: AssetClass нельзя выставлять наружу
енумом из-за значения index, и сектор не заполнен ни у одного инструмента — его нет
в ответе GetInstrumentBy, нужны потиповые Shares/Bonds.
2026-09-18 14:21:06 +03:00

7.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 → allocation → networth → cashflow → spending → runway → quality (фаза 4 вставит income и rebalance после allocation). Запуск: 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), иначе дыра в данных читалась бы как обвал. allocation режет тот же итог четырьмя способами (класс, сектор, страна, валюта), включая кэш в каждый разрез: четыре диаграммы одного портфеля обязаны быть одного размера. Бакет — стабильный ключ (cash, unknown, код страны), язык живёт в клиенте.

Деньги в 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 — строки; енумы моделей наружу отдаются не всегда: asset_class идёт строкой, потому что значение index ломает генератор Dart-клиента.
  • Аналитика инвестиций читает только metric_* — на запрос ничего не считается. Параметр scope (all | account:<id> | portfolio:<id>) резолвится через analytics/valuation.account_scopes, неизвестный scope — 404, а не пустой график.

Клиент (app/)

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