# Архитектура Полный проект с обоснованиями — `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:`, поэтому ручной и плановый запуски не пересекаются. После синка, изменившего данные, — `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:`, `portfolio:`. `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 = "_"` → читаемые методы 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: | portfolio:`) резолвится через `analytics/valuation.account_scopes`, неизвестный scope — 404, а не пустой график. ## Клиент (`app/`) Flutter, Riverpod, go_router, fl_chart; клиент `app/packages/api_client` генерируется `just gen-client` из `openapi/openapi.json` и коммитится.