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

88 lines
7.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Архитектура
Полный проект с обоснованиями — `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`.
- `portfolio``account` 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` и коммитится.