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