diff --git a/AGENTS.md b/AGENTS.md index 542373c..bcfe686 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,7 +102,7 @@ just revision "msg" # новая alembic-миграция из изме Импорт CSV на живую базу ещё не запускался — это на живых данных не проверено, только на фикстурах. -Фаза 3 завершена и прогнана на живых отчётах (399 backend-тестов, 27 flutter): +Фаза 3 завершена и прогнана на живых отчётах: - `sources/reports/` — протокол `ReportParser` + три парсера: Сбер (HTML), ВТБ (xlsx), универсальный CSV (экспорт Snowball). `registry.py` выбирает парсер по содержимому файла, @@ -149,9 +149,9 @@ just revision "msg" # новая alembic-миграция из изме source_id)`, так что строки обоих источников сосуществуют, и приоритет можно поменять без ресинка истории. Амортизация из MOEX идёт не в `corporate_action`, а в `bond_nominal_schedule` — этим типом безраздельно владеет `ledger/corporate_actions.py`; - оба источника зарегистрированы (`tinvest_events`, `moex_payouts`), но не добавлены в - `worker/jobs.default_schedule()` — как и сами `tinvest`/`moex`, они туда не входили и до - этой фазы, расписание синков за её рамками; + оба источника зарегистрированы (`tinvest_events`, `moex_payouts`) и стоят в + `worker/jobs.default_schedule()` вместе с `tinvest`/`moex`; источники с `needs="tinvest_token"` + не попадают в расписание, пока токен не задан; - `analytics/income.py` — `metric_income_monthly` (факт, только confirmed) и `metric_income_calendar` (прошлое и прогноз) с явным `basis` (`paid` / `announced` / `history`) на каждой строке — три источника числа никогда не смешиваются в одно; diff --git a/docs/ai/README.md b/docs/ai/README.md index 6c1c0f5..984d117 100644 --- a/docs/ai/README.md +++ b/docs/ai/README.md @@ -20,7 +20,7 @@ sources/* ──sync──▶ raw_* (JSONB, идемпотентно) ──map worker (APScheduler, advisory locks) ▼ ledger (лоты FIFO, дедуп, матчинг ZM↔брокер) │ - analytics (polars, pyxirr) ──▶ metric_* ──▶ api ──▶ Flutter + analytics (pyxirr) ─────────▶ metric_* ──▶ api ──▶ Flutter ``` - `backend/src/fintracker/sources/` — по одному пакету на источник, контракт в `base.py`. @@ -37,5 +37,5 @@ worker (APScheduler, advisory locks) ▼ - [links.md](links.md) — внешние API и референсы. - [offline-cache.md](offline-cache.md) — офлайн-кэш Flutter-клиента: контракт `Cached`, `CacheInterceptor`, баннер «данные на …» (фаза 5). -- [design-system.md](design-system.md) — визуальный язык Flutter-клиента: токены темы, - `SectionHeader`/`TileCarousel`, группированный `NavSidebar` (обкатано на Обзоре). +- [design-system.md](design-system.md) — визуальный язык Flutter-клиента по образцу Snowball: токены + темы, верхняя панель навигации, сетка карточек на Обзоре, таблица активов, вкладки Аналитики. diff --git a/docs/ai/architecture.md b/docs/ai/architecture.md index ffe0609..342c718 100644 --- a/docs/ai/architecture.md +++ b/docs/ai/architecture.md @@ -1,7 +1,7 @@ # Архитектура Полный проект с обоснованиями — `docs/ai/plan.md`. Здесь — то, что нужно держать в голове -при изменении кода. Таблицы, которых ещё нет в `models/`, помечены *(план)*. +при изменении кода. Всё описанное ниже реализовано; чего в коде нет, здесь не упоминается. ## Домен @@ -10,15 +10,23 @@ `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` *(план)*, подтверждает пользователь. +- Нераспознанные из отчётов → `pending_instrument`, подтверждает пользователь + (`/instruments/pending`). -### Леджер *(план, фаза 2)* +### Леджер (`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, @@ -35,8 +43,12 @@ Реализованы: `zenmoney` (diff-курсор, два режима auth: статический токен или OAuth-ротация через `source_credential`; маппер всегда пересобирает core из полных `raw_*`), `cbr` -(валюты берутся из счетов и транзакций, `trust_env=False` — мимо прокси). Расписание — -`worker/jobs.py`. +(валюты берутся из счетов и транзакций, `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, журнал, курсор, @@ -45,15 +57,24 @@ ## Worker (`worker/`) Отдельный процесс: APScheduler по расписанию из `worker/jobs.default_schedule()` + опрос `sync_job` каждые 5 с. На источник — advisory lock `sync:`, поэтому ручной и -плановый запуски не пересекаются. После синка, изменившего данные, — refresh метрик -*(план)*. +плановый запуски не пересекаются. После синка, изменившего данные, — `refresh_all` +(ошибка пересчёта помечает сам синк как error). Ручной пересчёт метрик тоже идёт через +`sync_job` (см. ниже). ## Аналитика (`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). Запуск: +`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`. Каждая `metric_*` таблица пересобирается целиком. Net worth считается +`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`, назад не тянем).