Files
fin-tracker/docs/ai/plan.md
T
Dmitry ffc5ed959a feat(app): offline-кэш GET-запросов на дашборде — фаза 5
CacheInterceptor кэширует каждый успешный GET по путь+параметры в drift
(sqlite нативно, wasm+OPFS в браузере) и подменяет им сетевую ошибку;
провайдер отдаёт Cached<T>, экран показывает баннер «данные на …».
Контракт для остальных экранов — docs/ai/offline-cache.md.

flake.nix: libsecret/pkg-config для линуксовой сборки, jq/curl для
just app-web-assets (сборка sqlite3.wasm + drift_worker.js).
2026-09-19 12:40:08 +03:00

574 lines
47 KiB
Markdown
Raw 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.
# План разработки (фазы, чек-листы, открытые вопросы)
## Context
Есть пожизненная подписка ZenMoney (повседневные финансы, синк с банками) и опыт
Snowball Income (аналитика инвестпортфеля по всем брокерам). Snowball платный, публичного
API у него нет. Цель — своё приложение, объединяющее ZenMoney и брокеров, с аналитикой по
финансам и инвестициям, которое заменяет Snowball.
Решения, принятые в диалоге:
- **Чистый лист.** Соседний `fin-dashboard` (DuckDB, ни одного коммита) не продолжаем;
из него и из `t_tech-gyro` переносим *паттерны и куски кода*, не проект.
- **Бэкенд:** Python 3.12+, FastAPI, PostgreSQL, `uv`, ruff + pyright + pytest.
- **Клиент:** Flutter (web + Linux + Windows + Android) → REST/OpenAPI (gRPC не подходит
Flutter Web без прокси). Dart-клиент генерируется из OpenAPI и коммитится.
- **Хостинг:** VPS, Docker Compose (`db`, `api`, `worker`, `caddy`, `pg-backup`).
Один пользователь: пароль → JWT, TLS через Caddy.
- **Источники:** ZenMoney API, T-Invest API, отчёты Сбера (xlsx/html) и ВТБ (xlsx/pdf),
MOEX ISS, ЦБ РФ, ручной ввод / универсальный CSV.
- Пользователь даст: отчёты Сбера/ВТБ за всю историю, скриншоты важных экранов Snowball,
выгрузку сделок из Snowball (если найдётся). Без файлов парсеры не пишутся — только
их интерфейс и каркас.
## Что выяснила разведка
### ZenMoney
- Единственный способ читать — `POST https://api.zenmoney.ru/v8/diff/` (Bearer).
Инкрементально по `serverTimestamp`, на первом запросе `forceFetch`. Отдаёт
`instrument, company, user, account, tag, merchant, budget, reminder, reminderMarker,
transaction` + `deletion`. REST по сущностям нет.
- Токен живёт 86400 с, есть `refresh_token` → worker обязан его ротировать.
- Transaction: `income/outcome`, `incomeAccount/outcomeAccount`,
`incomeInstrument/outcomeInstrument`, `opIncome/opOutcome`, `tag[]`, `merchant`, `payee`,
`comment`, `mcc`, `hold`, `deleted`, `changed`. Перевод между своими счетами — одна
транзакция с обеими сторонами > 0.
- Account: `type ∈ cash|ccard|checking|loan|deposit|emoney|debt`, `instrument`, `balance`,
`startBalance`, `creditLimit`, `inBalance`, `savings`, `archive`, депозитные поля.
- Референс: `fin-dashboard/src/fin_dashboard/ingest/zenmoney.py` (цикл diff, удаления),
`.../ingest/fx.py` (ЦБ: cp1251, деление на `Nominal`, `trust_env=False`).
### Snowball Income (заменяем, не интегрируем)
- API нет; экспорт наружу только CSV кастомных активов и бэкап при удалении портфеля.
Бесплатно: 1 портфель, 10 активов. Цены с MOEX раз в 30 мин, дивиденды прогнозирует по
истории.
- Что воспроизводим, по приоритету:
- **P1**: единые позиции по всем брокерам, стоимость/вложено/P&L (реализ./нереализ.),
XIRR и TWR, аллокация (класс/сектор/страна/валюта), календарь дивидендов и купонов
(объявленные + прогноз), история выплат, внешние потоки по счетам.
- **P2**: категории с целевыми весами + ребалансировка, бенчмарки (IMOEX/MCFTR, S&P),
цели (капитал / пассивный доход), несколько портфелей + составной, налоговый вид
(13 % с дивидендов, ЛДВ 3 года).
- **P3**: Sharpe/Sortino/beta, фундаментал, «дивидендный рейтинг».
### T-Invest API
- gRPC; SDK `t-tech-investments` ставится с индекса T-Bank
(`https://opensource.tbank.ru/api/v4/projects/238/packages/pypi/simple`, через
`[tool.uv.index]` + `[tool.uv.sources]`), нужен бандл российских CA через
`GRPC_DEFAULT_SSL_ROOTS_FILE_PATH`. Референс: `t_tech-gyro/app/tinvest.py`,
`t_tech-gyro/config/certs/*.pem`, `t_tech-gyro/flake.nix` (`NIX_LD_LIBRARY_PATH`,
`no_proxy` для `*.tinkoff.ru`).
- Лимиты: Operations 200/мин, Instruments 200/мин, MarketData 600/мин, ≤ 50 rps с IP;
заголовки `x-ratelimit-*`.
- Методы: `GetOperationsByCursor` (курсор, ≤ 1000/стр, 70+ `OperationType`),
`GetPortfolio`/`GetPositions` (снапшоты для сверки); `Shares/Bonds/Etfs/Currencies`,
`GetInstrumentBy`, `FindInstrument`, `GetDividends`, `GetBondCoupons`, `GetBondEvents`,
`GetAccruedInterests`, `GetAssetFundamentals`; `GetCandles`, `GetLastPrices`,
`GetClosePrices`.
### MOEX ISS (бесплатно, без ключа)
- `/iss/securities/{secid}.json`; история
`/iss/history/engines/stock/markets/{shares|bonds}/boards/{board}/securities/{secid}.json`;
текущие котировки `/iss/engines/stock/markets/shares/securities.json`;
`/iss/securities/{secid}/dividends.json`; `/iss/securities/{secid}/bondization.json`
(купоны + амортизация); фиксинги валют; индексы IMOEX/MCFTR как бенчмарки.
- Облигации приходят в % от номинала + `ACCRUEDINT`.
### Прочее
- ЦБ РФ: `cbr.ru/scripts/XML_daily.asp`, `XML_dynamic.asp`.
- Референсы парсеров отчётов: `spacious-team/investbook` (Java, Сбер/ВТБ/Тинькофф,
форматы описаны), `i-savelev/invest_toolkit` (Python, Сбер + ВТБ).
- XIRR: `pyxirr` (Rust, без зависимостей).
## Переносимые паттерны (из fin-dashboard / t_tech-gyro)
- Три яруса: `raw_*` (сырой JSON, идемпотентный upsert по стабильному id) → `core_*`
(нормализовано, пересчитывается) → `metric_*` (готовые серии для API).
- Мультивалютный инвариант: хранить `(amount, currency)`, не конвертировать на записи,
конвертировать по курсу **на дату операции**, курсы ЦБ протягивать через выходные
(`is_carried`), нет курса → NULL + строка в data quality. RUB = 1.0 на любую дату.
- Правила классификации (паттерн → категория/payee/savings/one_off) + отчёт о протухших.
Отличие: правила живут в таблице и редактируются через API, а не в SQL-файле.
- Потоки: `income | expense | internal_transfer | savings_transfer | broker_external_flow`.
- SDK T-Invest импортируется ровно в одном модуле; `units + nano/1e9 → Decimal`.
- `Decimal` для всех денег; float только внутри расчёта коэффициентов.
## 1. Доменная модель
Соглашения: деньги/количества `NUMERIC(24,10)` + `currency CHAR(3)` рядом; у каждой
core-строки `source` (`zenmoney|tinvest|moex|cbr|report_sber|report_vtb|csv|manual`),
`source_id`, ссылка на raw; мягкое удаление `deleted_at`; `timestamptz`; торговые даты
в MSK.
### 1.1 Raw-ярус
| Таблица | PK | Заметки |
|---|---|---|
| `raw_zenmoney_entity` | `(entity_type, id)` | `changed`, `payload`; удаления — в `raw_zenmoney_deletion` |
| `raw_tinvest_operation` | `(account_id, id)` | полный `OperationItem` |
| `raw_tinvest_snapshot` | `(account_id, kind, captured_at)` | portfolio / positions |
| `raw_tinvest_instrument` | `uid` | из Shares/Bonds/Etfs/Currencies |
| `raw_tinvest_event` | `(instrument_uid, kind, source_id)` | дивиденды, купоны, события, фундаментал |
| `raw_moex_doc` | `(endpoint, key, fetched_at)` | страницы history, dividends, bondization, индексы |
| `raw_cbr_rate` | `(rate_date, ccy)` | `rate_rub_per_unit` |
| `raw_report_file` | `id` | `broker`, `filename`, `sha256` UNIQUE, `parse_status`, `parser_version`; байты на volume |
| `raw_report_line` | `(file_id, line_no)` | распарсенная, но не нормализованная строка (JSONB) |
### 1.2 Счета и портфели
```
account id, kind ENUM(zm_cash, zm_card, zm_checking, zm_deposit, zm_loan, zm_emoney, zm_debt,
broker, manual_asset),
source, source_id, broker ENUM(tinvest, sber, vtb, other) NULL, name, currency,
include_in_net_worth BOOL, role ENUM(liquid, savings, investment, debt),
mirror_of_account_id NULL, -- ZM-счёт, зеркалящий брокерский
primary_event_source, -- tinvest_api | report_sber | report_vtb | manual
opened_at, archived, deposit_terms JSONB
portfolio id, name, is_default, base_currency
portfolio_account (portfolio_id, account_id) -- m:n; составной портфель = все счета
account_link zm_account_id -> broker account_id -- явная карта для матчинга (1.6)
```
### 1.3 Справочник инструментов
```
instrument id, asset_class ENUM(share, bond, etf, fund, currency, index, deposit, real_estate, crypto, custom),
isin, figi, tinvest_uid, ticker, board, exchange, name, issuer, currency, lot,
nominal, nominal_currency, maturity_date, sector, country, is_active, meta JSONB
-- частичные UNIQUE на isin, figi, tinvest_uid, (ticker, board)
instrument_alias instrument_id, source, source_key UNIQUE(source, source_key)
-- ('report_vtb', 'NAME:Газпром ао'), ('moex', 'GAZP/TQBR') ...
bond_nominal_schedule instrument_id, effective_date, nominal -- после каждой амортизации
pending_instrument нераспознанные из отчётов; пользователь подтверждает в UI, никогда не угадываем
```
Порядок резолва: ISIN → FIGI → tinvest_uid → (ticker, board) → alias.
### 1.4 Единый леджер событий
Одна таблица, куда маппится каждый источник (кроме повседневных транзакций ZM, см. 1.7):
```
event id, account_id, instrument_id NULL, kind ENUM(...), ts, trade_date, settle_date NULL,
quantity NUMERIC NULL (знак: + в позицию, - из позиции), price, price_currency,
amount, currency (знаковый денежный эффект на счёт), fee, fee_currency, tax, tax_currency,
accrued_interest NULL (НКД), group_id UUID NULL (ноги одного события, напр. конвертация),
status ENUM(confirmed, pending, shadow, ignored),
source, source_id, raw_ref, dedupe_key TEXT UNIQUE, description, meta JSONB, timestamps
```
| kind | позиция | деньги | внешний поток для XIRR |
|---|---|---|---|
| buy / sell | ±qty | -(qty·price+fee) / +(qty·price-fee) | нет |
| dividend / coupon / interest | 0 | + | нет |
| tax / tax_refund / commission | 0 | ∓ | нет |
| deposit / withdrawal | 0 | ± | **да** |
| transfer_in / transfer_out (бумагами) | ±qty | 0 | **да**, по рынку на дату |
| split | qty·(ratio1) | 0 | нет |
| amortization | 0 | + (часть номинала) | нет |
| repayment | qty (закрывает лоты) | + | нет |
| fx_exchange | 0 | две ноги с общим `group_id` | нет |
| other | 0 | ± | флаг в data quality |
Маппинг `OperationType` → kind — явный словарь в `sources/tinvest/mapper.py`, покрытый
тестом «каждое значение enum SDK замаплено», чтобы новый тип ломал тест, а не молча
уходил в `other`.
### 1.5 Лоты и себестоимость
```
lot id, account_id, instrument_id, open_event_id, open_date, qty_open, qty_remaining,
cost_per_unit, cost_currency, cost_total_rub (по ЦБ на дату открытия), closed_at
lot_disposal id, lot_id, close_event_id, qty, proceeds, proceeds_currency, proceeds_rub, cost_rub,
realized_pnl_native, realized_pnl_rub, holding_days, ldv_eligible BOOL
```
- FIFO на (account, instrument) — как в ст. 214.1 НК. Движок — чистая функция
`apply(events) -> (lots, disposals)` в `ledger/lots.py`, пересчёт с нуля при каждом
refresh (объёмы личные, это секунды).
- Корпдействия правят лоты: split умножает qty и делит cost; амортизация уменьшает cost
пропорционально номиналу; repayment закрывает лоты по номиналу.
- Комиссии капитализируются в покупку и вычитаются из продажи.
- ЛДВ (ст. 219.1): `ldv_eligible` при `holding_days ≥ 3·365` и биржевом инструменте.
### 1.6 Позиции, цены, дедупликация
```
position_snapshot (account_id, instrument_id, as_of, source) qty, avg_price, market_value
cash_snapshot (account_id, currency, as_of, source) balance, blocked
position_current VIEW = Σ lot.qty_remaining; cash_balance VIEW = Σ event.amount по валюте
price_daily (instrument_id, d) close, open, high, low, volume, currency, source, price_pct, accrued_interest
price_last instrument_id; ts, price, currency, source
price_manual ручные цены (недвижимость, крипта, custom)
fx_rate_daily (d, ccy) rate_rub, source, is_carried
corporate_action id, instrument_id, kind ENUM(dividend, coupon, amortization, repayment, split, offer),
record_date, ex_date, pay_date, amount_per_unit, currency, ratio, status ENUM(forecast, announced, paid, cancelled), source
```
Производные позиции — истина для аналитики; снапшоты — для сверки. `metric_data_quality`
показывает каждое расхождение derived vs snapshot по qty и по кэшу — главный детектор
ошибок маппинга типов операций.
Приоритет источника цен (`pricing/resolver.py`): T-Invest candles (есть uid) → MOEX history
(есть ticker+board) → `price_manual`. Облигации: `close = price_pct/100 · nominal(d)`,
стоимость = close + НКД.
**Дедупликация, три случая:**
- **A. Тот же источник повторно.** Стабильные id (T-Invest `operation.id`, ZM `id`), для
отчётов `dedupe_key = sha1(broker|account|trade_no)` если есть номер сделки, иначе
fingerprint `sha1(broker|account|kind|instrument_key|trade_date|qty|price|currency)`.
Перекрывающиеся периоды отчётов апсертят в ту же строку.
- **B. Одна сделка из двух источников на один счёт.** У счёта один
`primary_event_source`; события других источников пишутся со `status = shadow`,
`ledger/dedupe.py` матчит их к confirmed по (instrument, trade_date, kind, qty,
|price| ± 0,5 %). Несматченные shadow → data quality («есть в отчёте, нет в API»).
Аналитика читает только `confirmed`.
- **C. Перевод ZM ↔ пополнение брокера** (двойной счёт net worth, корректность XIRR):
```
flow_link id, cash_txn_id UNIQUE, event_id UNIQUE, kind ENUM(auto, manual), confidence
```
`ledger/matching.py` после каждого синка: кандидаты ZM — `outcome > 0` на счёт с
`mirror_of_account_id` (через `account_link`) или по правилу `broker_target`; кандидаты
брокера — deposit/withdrawal без линка; скоринг: та же валюта, |Δamount| ≤ max(1 ₽, 0,5 %),
≤ 3 рабочих дня; жадно 1:1; остатки → `GET /links/unmatched` для ручной привязки.
Следствия: net worth = ZM-счета с `include_in_net_worth` (зеркала исключены) + стоимость
инвестсчетов из леджера; внешние потоки XIRR только из брокерского леджера; связанная
ZM-транзакция классифицируется как `internal_transfer` (чинит асимметрию «инвестировал →
net worth упал», которую fin-dashboard только репортил).
### 1.7 Сторона ZenMoney
```
cash_txn id, ts, date, income, income_account_id, income_currency, outcome, outcome_account_id,
outcome_currency, payee, merchant_id, comment, mcc, hold, deleted, changed, primary_tag_id,
flow_type ENUM(income, expense, internal_transfer, savings_transfer, broker_external_flow, deleted, other),
category_id (после правил), payee_canonical, is_one_off, trip_id
cash_txn_tag (txn_id, tag_id, ord); category id, parent_id, name, source_id (дерево тегов ZM)
rule id, kind ENUM(savings, one_off, category, payee, broker_target, ignore),
match_type ENUM(id, payee, comment, category, mcc), pattern, value, note, enabled,
last_matched_at, match_count
trip id, name, date_from, date_to, country
```
## 2. Структура репозитория и модулей
```
fin-tracker/
flake.nix dev shell: uv, python312, flutter, openapi-generator-cli, postgresql (клиент), just;
NIX_LD_LIBRARY_PATH (zlib, openssl) для grpc-wheels; no_proxy для tinkoff-хостов
justfile up / test / gen-client / openapi / deploy
docker-compose.yml db, api, worker, caddy, pg-backup (ночной pg_dump)
deploy/Caddyfile, deploy/certs/russian_trusted_*.pem
openapi/openapi.json экспорт из FastAPI, коммитится, CI проверяет дрейф
backend/
pyproject.toml uv, hatchling, [tool.uv.index] tbank; ruff, pyright, pytest
alembic/ миграции
src/fintracker/
config.py pydantic-settings (DATABASE_URL, токены, JWT secret, TZ=Europe/Moscow)
db/ async engine, session, типы Money
models/ ORM по доменам: accounts, instruments, ledger, pricing, zenmoney, sync, auth
sources/
base.py Source protocol: sync(ctx) -> SyncResult; RateLimiter; retry
zenmoney/ client, sync (diff-курсор, ротация refresh_token), mapper
tinvest/ client (ЕДИНСТВЕННЫЙ импорт t_tech.invest; CA bundle; Decimal),
sync_operations, sync_snapshots, sync_instruments, sync_events, mapper
moex/ client (iss meta), prices, dividends, bonds, indices
cbr/ client, sync
reports/ base (ReportParser, ParsedReport, BrokerEvent), registry,
sber/{xlsx,html}.py, vtb/{xlsx,pdf}.py, csv_universal/parser.py
ledger/ kinds (таблица эффектов), ingest (BrokerEvent -> event), dedupe, lots (FIFO),
corporate_actions, matching (ZM<->брокер), reconciliation
pricing/ providers (PriceProvider protocol), resolver, fx (датированный конвертер)
analytics/ valuation, returns (pyxirr, TWR), income (календарь + прогноз), allocation,
rebalance, benchmarks, tax, networth, cashflow, quality
metrics/ refresh (порядок пересборки metric_*), tables
api/ app, deps, auth, errors, routers/*, schemas/* (Decimal -> str)
worker/ scheduler (APScheduler), jobs, locks (pg advisory), cli (typer)
tests/ unit (ledger, mappers, парсеры на фикстурах, golden-числа аналитики),
api (httpx AsyncClient + testcontainers-postgres); fixtures/reports/ (обезличенные)
app/ Flutter
packages/api_client/ сгенерированный Dart-клиент, коммитится
```
### Библиотеки
| Задача | Выбор | Почему |
|---|---|---|
| ORM / миграции | SQLAlchemy 2 async (`asyncpg`) + Alembic | 30+ таблиц, схема будет жить годами; голый asyncpg без миграций отвергнут |
| HTTP | `httpx` async | ZenMoney, MOEX, ЦБ; `trust_env` per-client (ЦБ мимо прокси) |
| T-Invest | `t-tech-investments` (`AsyncClient`) | официальный; обёртка `RequestError`, уважение `ratelimit_reset` |
| XIRR | `pyxirr` | Rust, корректно на нерегулярных потоках и краевых случаях |
| Датафреймы | `polars` | join позиций × цен × курсов по дневной сетке; строже pandas |
| xlsx / html / pdf | `openpyxl` (read-only) / `lxml` + `pandas.read_html` / `pdfplumber` | таблицы с геометрией ячеек; `pymupdf` в запасе |
| Auth | `pyjwt` + `pwdlib[argon2]` | минимум; один пользователь |
| Планировщик | APScheduler 3 (`AsyncIOScheduler`) | in-process, cron-триггеры; 4.x ещё pre-release |
| Валидация | pydantic v2, Decimal как строки | никаких float в проводе |
| CLI | `typer` | `fintracker sync <source>`, `openapi`, `create-user`, `worker`, `metrics refresh` |
### Интерфейсы плагинов
```python
class ReportParser(Protocol):
broker: str; formats: tuple[str, ...]
def sniff(self, data: bytes, filename: str) -> bool
def parse(self, data: bytes, filename: str) -> ParsedReport
# ParsedReport: broker, account_external_id, period_from/to, parser_version, events: list[BrokerEvent],
# positions_end, cash_end, instruments: list[InstrumentRef], warnings
# BrokerEvent: kind, trade_date, settle_date, instrument: InstrumentRef|None, qty, price, amount, currency,
# fee, fee_currency, accrued_interest, trade_no, description, raw_line_no
class PriceProvider(Protocol):
name: str
def supports(self, inst) -> bool
async def daily_history(self, inst, d_from, d_to) -> list[Bar]
async def last_prices(self, insts) -> dict[int, LastPrice]
```
Реестр парсеров — список в `registry.py` (entry points избыточны). Поток импорта:
upload → `raw_report_file` → parse → `raw_report_line` → превью (счётчики, нераспознанные
инструменты, будущие дубли) → commit → `ledger/ingest.py`. Парсеры — чистые функции без
БД, тестируются на фикстурах.
### Worker
Отдельный контейнер из того же образа (`fintracker worker`), APScheduler in-process.
API остаётся stateless; единственный экземпляр планировщика; долгоживущий gRPC-канал и
rate-limiter'ы. Advisory lock на источник (`pg_try_advisory_lock`), чтобы ручной запуск
(`POST /sync/{source}` → `sync_job(queued)`, worker опрашивает каждые 5 с) не пересекался
с расписанием. Каждый запуск — строка `sync_run`.
| Job | Когда (MSK) |
|---|---|
| zenmoney diff | каждые 30 мин |
| cbr | 13:45 и 18:00 |
| tinvest operations + snapshots | каждый час 09–24, полный в 03:00 |
| tinvest instruments | 03:10 |
| tinvest/moex дивиденды, купоны, события | 04:00 |
| last prices | каждые 15 мин 10–24 |
| history backfill | 19:30 |
| metrics refresh + reconciliation + data quality | после любого job, изменившего данные (`dirty`), не чаще раза в 5 мин |
## 3. Стратегия аналитики
**Python (polars + pyxirr) пересобирает `metric_*` после каждого синка**; несколько простых
Postgres VIEW (`position_current`, `cash_balance`, `event_confirmed`); on-demand Python
только для пользовательских параметров (произвольный период XIRR/TWR, what-if
ребалансировка, экспорт) с 30-секундным кэшем.
Почему не SQL/materialized views: XIRR, TWR, FIFO, детект периодичности дивидендов и
ребалансировка — процедурные; объёмы личные (10³–10⁴ событий, 10⁵ цен) — полный пересчёт
за секунды, инкрементальность не окупает своих багов. Precompute даёт клиенту чтение
< 100 мс и одинаковые цифры на всех экранах.
| Таблица | Гранулярность | Содержимое |
|---|---|---|
| `metric_portfolio_value_daily` | scope, d | market_value, cash, bonds_accrued, invested_net, pnl_unrealized, pnl_realized_cum, income_cum, external_flow_on_day, stale_price_count |
| `metric_holding` | scope, instrument | qty, avg_cost, market_price, value, unrealized, realized_cum, income_cum, weight, xirr, first_buy, days_held, ldv_eligible_qty |
| `metric_returns` | scope, period (1m,3m,ytd,1y,3y,all) | xirr, twr, abs_pnl, twr бенчмарков |
| `metric_allocation` | scope, dimension, bucket | value, weight, target_weight, drift |
| `metric_income_calendar` | scope, instrument, expected_date | kind, amount, currency, status, basis (`schedule`/`announced`/`history`) |
| `metric_income_monthly` | scope, month, kind | received native + RUB |
| `metric_cashflow_broker` | account, month | deposits, withdrawals, net |
| `metric_net_worth_daily` | d | cash, deposits, investments, debts, total RUB, per-currency JSONB |
| `metric_cash_flow_monthly`, `metric_spending_by_category`, `metric_runway` | ZM-сторона | как в fin-dashboard |
| `metric_tax_year` | year, account | dividends_gross, tax_withheld, realized_gain_rub, ldv_exempt_rub, estimated_tax |
| `metric_rebalance` | portfolio, category | current, target, delta_value, suggested_qty (по лотам и кэшу) |
| `metric_data_quality` | check, severity | detail, count |
Ключевые расчёты:
- **Дневная стоимость**: кумулятивная сумма `quantity` по календарной сетке × `price_daily`
(forward-fill до 10 дней, дальше `stale`) + НКД, × `fx_rate_daily`; + кэш по валютам.
- **XIRR**: внешние потоки scope (deposit/withdrawal/transfer по рынку) + терминальный поток
= текущая стоимость → `pyxirr.xirr`. Per-instrument: сделки + доходы + комиссии +
терминальная стоимость.
- **TWR**: `r_t = (V_t F_t) / V_{t1}`, цепочка; бенчмарки по той же сетке дат.
- **Прогноз доходов**: облигации — точно по `bond_nominal_schedule` + купонному графику
(`schedule`); акции/ETF — `announced` из T-Invest/MOEX с будущей record_date, иначе
`history`: периодичность по последним 24 мес (1/2/4 в год), последняя сумма на те же
месяцы × текущее qty.
- **Порядок refresh**: fx → prices → lots → reconciliation → value series → holdings →
returns → income → allocation/rebalance → net worth/cashflow → data quality. Каждая
таблица пересобирается в одной транзакции.
## 4. REST API (`/api/v1`)
Деньги — строки, даты ISO-8601, `?currency=RUB|USD|native` (по умолчанию RUB), ошибки
RFC 7807. `generate_unique_id_function = f"{tag}_{route.name}"`, чтобы Dart-методы
назывались `holdingsList`, а не `list_holdings_api_v1_holdings_get`.
| Группа | Эндпоинты |
|---|---|
| auth | `POST /auth/login` (access 1 ч + refresh 30 д), `/auth/refresh`, `/auth/logout`, `GET /auth/me` |
| accounts | `GET /accounts`, `PATCH /accounts/{id}`, `POST /accounts` (manual), `GET /accounts/{id}/cash` |
| portfolios | CRUD, `PUT /portfolios/{id}/accounts`, `PUT .../targets`, `GET .../rebalance` |
| instruments | `GET /instruments?q=&asset_class=`, `GET/PATCH /instruments/{id}`, `GET /instruments/pending`, `POST .../pending/{id}/resolve`, `POST /instruments/{id}/prices` |
| events | `GET /events?...`, `POST`, `PATCH`, `DELETE`, `GET /events/{id}/raw` |
| imports | `POST /imports` (multipart) → preview, `GET /imports/{id}`, `POST /imports/{id}/commit` |
| links | `GET /links/unmatched`, `POST /links`, `DELETE /links/{id}` |
| analytics | `/analytics/summary`, `/value-series`, `/holdings`, `/allocation?dimension=`, `/returns` (on demand), `/benchmarks`, `/tax?year=` |
| income | `/income/calendar`, `/income/history?group=month`, `/income/forecast?months=12` |
| networth / cashflow | `/networth/series`, `/networth/breakdown`, `/cashflow/monthly`, `/spending/categories`, `/transactions`, `/runway` |
| rules / goals / settings | CRUD `/rules`, `GET /rules/stale`, `POST /rules/apply`; CRUD `/goals`, `GET /goals/{id}/progress`; `GET/PUT /settings` |
| sync / quality | `POST /sync/{source}`, `GET /sync/runs`, `GET /sync/status`, `GET /data-quality` |
OpenAPI → Dart: `uv run fintracker openapi` пишет `openapi/openapi.json` (pre-commit и CI
ловят дрейф); `just gen-client` → `openapi-generator-cli -g dart-dio
--additional-properties=serializationLibrary=json_serializable` в `app/packages/api_client`
+ `build_runner`. Decimal-строки маппятся в `String` и оборачиваются в `Decimal` (пакет
`decimal`) на стороне приложения.
Auth: одна строка `app_user` (argon2), логин rate-limited (Caddy + приложение), JWT HS256,
refresh-токены хранятся хэшами → logout отзывает.
## 5. Flutter-приложение
- **State**: Riverpod 2 + `riverpod_generator`; `apiClientProvider` оборачивает Dio-клиент
с интерцептором (подставить токен, один refresh на 401, затем logout).
- **Routing**: `go_router`, `ShellRoute` (NavigationRail на desktop/web, bottom bar на
Android), auth-guard; маршруты `/dashboard`, `/holdings`, `/holdings/:id`, `/events`,
`/imports`, `/income`, `/allocation`, `/networth`, `/cashflow`, `/links`, `/rules`,
`/settings`.
- **Графики**: `fl_chart` (MIT); при нужде в свечах/зуме — `syncfusion_flutter_charts`
по community-лицензии.
- **Offline-кэш**: последний JSON на endpoint+params в `drift` (SQLite, web через wasm/OPFS),
`fetched_at` в UI, без offline-записей.
- **Токены**: `flutter_secure_storage` (Keystore / libsecret / DPAPI); на web access-токен в
памяти, refresh в `localStorage` (см. открытый вопрос 1).
- **Структура**: `lib/core/{api,auth,cache,theme,format}`,
`lib/features/<screen>/{data,providers,widgets,page.dart}`; `intl` ru_RU; деньги через
`Decimal` + свой форматтер.
- **Загрузка отчётов**: `file_picker` → multipart `/imports` → экран превью → commit.
## 6. Фазы и проверки
### Фаза 0 — каркас
Монорепо; `flake.nix`; `backend/pyproject.toml` (ruff/pyright/pytest, индекс T-Bank);
SQLAlchemy + первая миграция (`app_user`, `account`, `instrument`, `sync_run`, `sync_job`);
FastAPI с `/health` и auth; CLI; Dockerfile (multi-stage, uv), compose с Caddy (авто-TLS) и
`pg_dump`-сайдкаром; CI (ruff, pyright, pytest, дрейф OpenAPI); Flutter-скелет с логином и
сгенерированным клиентом; `AGENTS.md` + `docs/ai/` по конвенции соседних проектов.
Проверка: `just up` на VPS отдаёт `https://host/api/v1/health` через Caddy; логин выдаёт
токены, неверный пароль — rate-limit; `alembic upgrade head` с пустой базы и
`downgrade base` проходят; `fintracker openapi` совпадает с закоммиченным; `just gen-client`
собирается; Flutter web/Linux/Android логинится на VPS; worker стартует, берёт advisory
lock, корректно завершается по SIGTERM.
### Фаза 1 — ZenMoney + ЦБ + net worth
`sources/zenmoney` (курсор, ротация refresh_token в `source_credential`), `sources/cbr`,
маппинг в `account`/`category`/`cash_txn`, таблица `rule` + классификатор,
`fx_rate_daily` с протяжкой, метрики net worth / cash flow / spending / runway / data
quality; API accounts/transactions/rules/networth/cashflow/sync; экраны: дашборд, потоки,
категории, транзакции, правила, статус синка.
Проверка: первый синк полный, второй двигает курсор без изменения счётчиков; удаление в
ZM исчезает из `cash_txn`; доходы/расходы за месяц сходятся с отчётом ZenMoney до
округления; покупка в валюте видна в своей категории в рублях по курсу того дня;
транзакция в выходной берёт пятничный курс (`is_carried`); криптосчёт даёт NULL и одну
строку data quality; refresh ZM-токена работает сутки+ без участия.
### Фаза 2 — T-Invest + MOEX + позиции / P&L / XIRR
`sources/tinvest` (операции по курсору, снапшоты, справочник, CA bundle, rate-limiter с
backoff по `ratelimit_reset`), `mapper.py` на весь `OperationType`, `sources/moex`,
провайдеры цен + resolver, `ledger/lots.py`, корпдействия из операций,
`ledger/matching.py` + `account_link`, `analytics/{valuation,returns,allocation}`,
метрики holdings/value/returns/allocation/cashflow_broker; API analytics/events/links/
instruments; экраны: позиции, карточка инструмента (лоты, события, график), аллокация,
потоки, несматченные линки, список событий.
Проверка: для каждого счёта T-Invest derived qty = `GetPortfolio`, derived cash =
`GetPositions` ± 1 ед., расхождения — в data quality с указанием типов операций;
незамапленный `OperationType` роняет `test_all_operation_types_mapped`; на синтетике
(взнос 100, через год 110) XIRR = 10,0 %, TWR = XIRR при одном потоке, второй взнос
меняет XIRR, но не TWR подпериодов; перевод ZM на зеркальный счёт и `INPUT` в T-Invest
линкуются автоматически, net worth считает деньги один раз, ZM-транзакция →
`internal_transfer`; облигация = % MOEX × номинал + НКД, частичная амортизация уменьшает
номинал и cost; полный бэкфилл истории проходит без `RESOURCE_EXHAUSTED`.
### Фаза 3 — парсеры отчётов (Сбер, ВТБ, универсальный CSV)
Протокол `ReportParser`, парсеры Сбер xlsx/html и ВТБ xlsx/pdf, CSV с документированным
контрактом колонок, превью/commit, UI резолва `pending_instrument`, shadow-дедупликация,
сверка со снапшотами отчётов; обезличенные фикстуры в `tests/fixtures/reports`.
**Вход от пользователя**: реальные отчёты за всю историю — в `backend/tests/fixtures/reports/raw/`
(в `.gitignore`); в git идут только обезличенные копии.
Проверка: тот же файл дважды → 0 новых событий; перекрывающиеся периоды (янв–июн,
апр–сен) дают каждую сделку один раз; закрывающие позиции отчёта = derived после commit;
неизвестный ISIN → pending, после резолва лоты пересобраны; pdf и xlsx ВТБ за один период
дают одинаковые `dedupe_key`; парсеры без зависимости от БД.
**Статус: закрыта.** Все проверки пройдены на живых отчётах, кроме одной: pdf-выгрузок ВТБ у
пользователя нет, и сверить два формата не на чем. Вместо неё сверены два xlsx с
перекрывающимися периодами, а контракт, который обязан соблюсти будущий `vtb/pdf.py`, чтобы
ключи совпали (брокер, номер соглашения, «№ сделки»), записан в `vtb/common.py`.
Что выяснилось по ходу и чего в плане не было:
- CSV-выгрузка Snowball **сводная по всем брокерам и не содержит разбивки по счетам**, а
также закрывающих позиций и остатков. Как сверка она годится для потоков, сделок и выплат
на уровне портфеля, но не для позиций по счетам. Целевой счёт указывает пользователь, и на
счёте с другим `primary_event_source` события лягут `shadow`;
- в ней же 4 строки — не операции, а правки остатка самим Snowball (3 среди `CASH_OUT`,
1 среди `CASH_IN`), причём одна на −1017,84 ₽, то есть крупнее настоящего вывода. Отличать
их можно только по тексту `Note`, порог по сумме ошибается;
- два `SPLIT` по `T` — одно корпоративное действие, увиденное на двух счетах; схлопываются в
один `dedupe_key`, и это верно: иначе коэффициент 10 применился бы дважды;
- `CUSTOM_HOLDING_PRICE` закрывает ручной ценой только `SIBN6P4`;
`NDM_TBNK-PP-FIXPRCNT-08.25` в выгрузке отсутствует, его дыра остаётся открытой;
- отчёты Сбера не содержат дивидендов, налогов и купонной секции вовсе.
### Фаза 4 — доходы, ребалансировка, бенчмарки, цели, налоги
`sync_events.py` (GetDividends, GetBondCoupons, GetBondEvents), MOEX как второй источник
с правилами приоритета, `analytics/income.py`, `rebalance.py` (целевые веса по категориям,
учёт лотов и кэша), `benchmarks.py` (IMOEX, MCFTR; S&P — см. вопрос 2), цели (прогресс и
прогнозная дата по trailing XIRR), `tax.py` (13 % с дивидендов с учётом удержанного,
реализованная прибыль FIFO в рублях, ЛДВ по лотам); метрики, API, экраны.
Проверка: каждый полученный дивиденд/купон за 12 мес имеет `corporate_action(paid)`;
квартальный плательщик даёт 4 будущих записи с последней суммой; купоны на 12 мес
совпадают с bondization; рекомендации ребалансировки уважают лот и кэш; TWR и MCFTR на
одной сетке без дыр в праздники; лот > 3 лет помечен `ldv_eligible`, налоговый вид
сходится с ручным примером.
**Статус: код и тесты закрыты (157 backend-тестов), прогон на живом портфеле — нет.**
Четыре шага (`income`, `tax`, `benchmarks`, `rebalance`) зарегистрированы в
`register_steps`, оба источника выплат (`tinvest_events`, `moex_payouts`) — в реестре
источников, но ни разу не выполнялись против боевой БД: `metrics refresh` с ними ещё не
запускался. Бенчмарки (IMOEX/MCFTR/RGBITR) — данные, не код: пока в `benchmark` не добавлена
ни одна строка, `/analytics/benchmarks` пуст. S&P (вопрос 2) не тронут — по-прежнему только
ручной CSV.
### Фаза 5 — полировка и эксплуатация
Адаптивные раскладки, тёмная тема, offline-кэш с баннером «данные на…», release-сборка
Android, Linux/Windows в CI, пустые/ошибочные состояния, экран настроек, runbook
backup/restore, страница здоровья (`/sync/status`, счётчики data quality).
Проверка: приложение работает offline на кэше; APK подписывается и ставится; restore из
ночного `pg_dump` на чистом VPS + `fintracker metrics refresh` воспроизводит все метрики.
**Статус: в работе.** Готово до этой правки (не с нуля):
адаптивные раскладки (`app_shell.dart`, breakpoints 600/1200), тёмная тема
(`theme_controller.dart`), общий паттерн пустых/ошибочных состояний
(`AsyncValueView` — 25 экранов, `EmptyState` — 22), backup — `pg-backup`-сайдкар в
`docker-compose.yml` (ночной `pg_dump -Fc`, retention 14 дней) и runbook в `ops.md`.
Сделано в этом заходе:
- **Offline-кэш** — контракт целиком в [offline-cache.md](docs/ai/offline-cache.md):
`CacheInterceptor` в `apiProvider` кэширует каждый успешный `GET` по `путь+параметры` в
`drift` (sqlite нативно, wasm+OPFS в браузере) и подменяет им сетевую ошибку;
провайдер отдаёт `Cached<T>`, экран показывает один баннер «Нет соединения — данные на …».
Реализовано и протестировано целиком на `features/home/` (дашборд — самый частый экран);
остальные ~24 экрана переводятся по тому же контракту независимо друг от друга.
`app/web/sqlite3.wasm` + `app/web/drift_worker.js` закоммичены, пересборка —
`just app-web-assets`.
- `flutter build linux --debug` не пройден до конца: `flutter_secure_storage_linux` падает на
устаревшем `nlohmann/json.hpp` под `-Werror=deprecated-literal-operator` современных
компиляторов — не связано с кэшем, чинить отдельно перед пунктом CI/Linux. `libsecret` +
`pkg-config` для линуксовой сборки уже добавлены в `flake.nix`.
- `docs/ai/ops.md` поправлен: убрана ссылка на «появится в фазе 2» у `metrics refresh`.
Не начато: CI-джоба для Flutter (`just app-check` в CI, сборка Linux/Windows), консолидация
страницы здоровья (сейчас статусы синка на `/sync`, data quality — блоком на дашборде),
Android release-подпись (нужен keystore от пользователя), off-site бэкап (см. открытый
вопрос §7.9 — решения ещё нет).
## 7. Открытые вопросы (дефолт в скобках, можно менять по ходу)
1. Хранение refresh-токена в web: `localStorage` (дефолт: да, один пользователь, TLS) или
HttpOnly cookie + CSRF только для web-сборки.
2. Источник S&P 500: бесплатного официального нет. (Дефолт: ручной CSV; опционально
Stooq/Yahoo как неофициальный.)
3. ЛДВ после изменений 2025 (иностранные эмитенты, ИИС-3). (Дефолт: классическое
3-летнее правило только для бумаг MOEX, помечено как оценка.)
4. Налоговая база валютных бумаг: пересчёт по ЦБ на даты покупки и продажи (валютная
переоценка облагается), кроме еврооблигаций Минфина. (Дефолт: моделируем в фазе 4.)
5. Несколько счетов T-Invest (брокерский, ИИС) — каждый отдельный `account`; ИИС-специфика
налогов вне scope.
6. Депозиты из ZenMoney: только баланс (фаза 1) или ежедневное начисление процентов как
инструмент (фаза 4). (Дефолт: баланс, начисление позже.)
7. Какие именно выгрузки Сбера/ВТБ (брокерский отчёт за период vs справка о доходах,
помесячно vs произвольный период). Выяснится по файлам.
8. Порог устаревания цены (10 дней) и оценка заблокированных иностранных бумаг
(последняя цена / ноль / вручную). (Дефолт: последняя цена + флаг.)
9. Бэкап: ночной `pg_dump` на диск VPS + off-site через rclone — куда?
10. Таймзона: MSK для торговых дат; T-Invest отдаёт UTC → конвертируем до вывода
`trade_date`.