Files
fin-tracker/docs/ai/architecture.md
T

10 KiB
Raw Blame History

Архитектура

Полный проект с обоснованиями — docs/ai/plan.md. Здесь — то, что нужно держать в голове при изменении кода. Всё описанное ниже реализовано; чего в коде нет, здесь не упоминается.

Домен

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

  • account — и ZenMoney-счета (kind = zm_*), и брокерские (broker), и ручные активы. source + source_id уникальны. mirror_of_account_id помечает ZM-счёт, который лишь зеркалит брокерский (исключается из net worth). primary_event_source — чей леджер для этого счёта истина; события других источников становятся shadow.
  • account.disabled — переключатель пользователя («Активен» на экране Счета); синки его не пишут. Отключённый счёт выпадает из скоупов (valuation.account_scopes: all, account, portfolio), из капитала ZM-счетов и из подсказок импорта; события и транзакции остаются в леджере.
  • Ручные события: POST /events (source = manual, всегда confirmed, знаки выводятся из вида события) и DELETE /events/{id} — только для manual; брокерские события синк вернёт. Виды: buy, sell, transfer_in/out, dividend, coupon, interest, deposit, withdrawal, commission, tax, tax_refund. Комиссия входит в amount. После записи нужен POST /metrics/refresh (лоты).
  • 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, подтверждает пользователь (/instruments/pending).

Леджер (models/ledger.py)

  • 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 — мимо прокси), tinvest (gRPC: счета, операции, инструменты, снапшоты для сверки) и tinvest_events (дивиденды и купоны), moex (котировки по бумагам из леджера) и moex_payouts (выплаты ISS). Расписание — worker/jobs.py. Отчёты брокеров (sources/reports/: Сбер HTML, ВТБ xlsx, универсальный CSV) — не синк, а загрузка через /imports; ledger/report_import.py показывает превью и пишет события только на commit.

Контракт 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_all (ошибка пересчёта помечает сам синк как error). Ручной пересчёт метрик тоже идёт через sync_job (см. ниже).

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

Шаги регистрируются в analytics/__init__.py и выполняются refresh_all по порядку: fx → classify → matching → corpactions → lots → valuation → returns → benchmarks → allocation → rebalance → cashflow_broker → income → tax → networth → cashflow → spending → runway → shadow_dedupe → report_reconcile → quality. Порядок объяснён комментариями в register_steps — например, matching обязан идти после classify, а quality — последним, потому что забирает находки остальных шагов. Запуск: worker после синка с changed=True, fintracker metrics refresh, POST /metrics/refresh, POST /rules/apply. POST /metrics/refresh только ставит задачу в sync_job (source = METRICS_JOB) и отвечает 202 — пересчёт делает worker; клиент опрашивает GET /metrics/status, пока refreshing не станет false. Шаги коммитятся по одному: при падении metric_refresh_log хранит failed_step и step_timings, а /metrics/status отдаёт consistent: false, пока следующий полный пересчёт не пройдёт. (/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 и коммитится.