Files

109 lines
10 KiB
Markdown
Raw Permalink 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/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` (лоты).
- `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`, подтверждает пользователь
(`/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` и коммитится.