Плюс два ограничения, которые стоили времени: AssetClass нельзя выставлять наружу енумом из-за значения index, и сектор не заполнен ни у одного инструмента — его нет в ответе GetInstrumentBy, нужны потиповые Shares/Bonds.
88 lines
7.3 KiB
Markdown
88 lines
7.3 KiB
Markdown
# Архитектура
|
||
|
||
Полный проект с обоснованиями — `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` и коммитится.
|