diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..68f0bae --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,81 @@ +# AGENTS.md + +Контракт для AI-агентов в этом репозитории. Подробности — в [`docs/ai/`](docs/ai/README.md); +этот файл — индекс, команды и ограничения, не описание архитектуры. + +## Проект одной фразой + +Личная аналитика финансов и инвестиций: ZenMoney (повседневные деньги) + брокеры +(T-Invest API, отчёты Сбера и ВТБ) → PostgreSQL → FastAPI → Flutter-клиент. Заменяет +Snowball Income. Один пользователь, свой VPS. + +## Команды + +```bash +nix develop # backend toolchain (uv, python 3.12, postgres 17, just, openapi-generator) +nix develop .#app # + Flutter +just db-start # локальный Postgres в ./.pgdata на порту 54329 +just migrate # alembic upgrade head +just api # FastAPI с reload, http://127.0.0.1:8000/api/v1/docs +just web # то же + Flutter web-сборка с того же origin (WEB_DIR=app/build/web) +just worker # планировщик синков +just check # ruff + pyright + pytest + проверка дрейфа OpenAPI +just openapi # экспорт openapi/openapi.json (коммитится) +just gen-client # Dart-клиент в app/packages/api_client (коммитится) +just revision "msg" # новая alembic-миграция из изменений моделей +``` + +Тесты поднимают собственный Postgres через `pg_ctl` (pytest-postgresql) — Docker не нужен. + +## Карта задача → контекст + +| Задача | Что читать | +|---|---| +| Любое изменение | [docs/ai/README.md](docs/ai/README.md), затем нужный раздел [architecture.md](docs/ai/architecture.md) | +| Новый источник данных (`backend/src/fintracker/sources/`) | architecture.md §Источники, `sources/base.py`, `worker/runner.py` | +| Модель данных / миграции (`models/`, `alembic/`) | architecture.md §Домен, [conventions.md](docs/ai/conventions.md) | +| Аналитика / метрики | architecture.md §Аналитика, [conventions.md](docs/ai/conventions.md) §Деньги | +| API / Flutter-клиент | architecture.md §API, `openapi/openapi.json` | +| Деплой | `docker-compose.yml`, `deploy/Caddyfile`, [ops.md](docs/ai/ops.md) | + +## Обязательные ограничения + +- **Деньги — `Decimal`**, в БД `NUMERIC(24,10)` + колонка валюты рядом. Никаких float в + хранении и в API (в JSON — строки). Float допустим только внутри расчёта коэффициентов. +- **Не конвертировать валюту на записи.** Конвертация — по курсу на дату операции, в + аналитике. Нет курса → NULL и запись в data quality, не подмена. +- **SDK T-Invest (`t_tech.invest`) импортируется только в `sources/tinvest/client.py`.** + Ставится с индекса T-Bank (`[tool.uv.index]` в `backend/pyproject.toml`) — не менять на PyPI. +- **Аналитика читает только `event.status = confirmed`.** Snapshot-таблицы — для сверки. +- **Сырые данные (`raw_*`) append-only и идемпотентны** по стабильному id источника. +- `openapi/openapi.json` и `app/packages/api_client` — генерируются, но коммитятся; + после изменения роутов запускай `just gen-client`. +- Секреты только в `.env` (в `.gitignore`). Реальные отчёты брокеров — в + `backend/tests/fixtures/reports/raw/` (в `.gitignore`); в git только обезличенные фикстуры. +- Коммиты — Conventional Commits на русском (`feat(ledger): …`), автор — только пользователь. + +## Состояние + +Фазы 0 и 1 завершены и проверены на живых данных (31 счёт, 5575 транзакций ZenMoney, курсы ЦБ, +все экраны работают). Правил классификации пока ноль — переводы и сбережения попадают в расходы. + +Фаза 2 в работе. Готово и прогнано на живых данных: + +- `sources/tinvest` — 7 счетов, 2756 операций → `event`, маппер на все 67 `OperationType`; +- `ledger/lots.py` + `rebuild.py` — FIFO, 804 лота, 672 закрытия, реализовано +1892 ₽; + короткие позиции поддержаны, `qty_remaining` хранится со знаком; +- `sources/moex` — 77 инструментов, 31 154 дневные цены, `price_daily` / `price_last`; +- `analytics/valuation` — дневная серия стоимости и холдинги по scope (`all`, `account:`, + `portfolio:`), сверка со снапшотами брокера; `analytics/returns` — XIRR и TWR по + периодам (`1m 3m 6m ytd 1y 3y all`) плюс XIRR на инструмент; `pricing/prices.py` — цена на + дату с протяжкой вперёд и порогом устаревания 10 дн. + +Осталось: `analytics/allocation`, корпоративные действия, API и экраны позиций. + +Известные расхождения derived vs брокерский снапшот по позициям (5 шт, не ошибки разбора): +редомициляция FIVE→X5, MGNT, HEAD, внебиржевая SIBN6P4. Без цен два инструмента — `SIBN6P4` +и `NDM_TBNK-PP-FIXPRCNT-08.25`, им нужен `price_manual`. По кэшу derived выше снапшота на +счетах 46, 48 и 52 (+6313, +56447, +146 ₽) — это неполнота леджера T-Invest, а не оценки: +счёт 51 сходится точно. История цен тонкая (большинство облигаций — только с апреля 2026, +TBRU@ — с 14.09.2026), поэтому TWR пропускает 32 дня и сообщает об этом в +`metric_data_quality`. План фаз — в `docs/ai/plan.md`. diff --git a/docs/ai/README.md b/docs/ai/README.md new file mode 100644 index 0000000..b4f3cc5 --- /dev/null +++ b/docs/ai/README.md @@ -0,0 +1,37 @@ +# docs/ai — обзор проекта + +## Назначение + +`fin-tracker` объединяет два мира личных финансов: + +- **ZenMoney** — повседневные счета и транзакции (синкается с банками сам), читаем через + `POST /v8/diff/`; +- **брокеры** — T-Invest по gRPC API, Сбер и ВТБ через загрузку отчётов, прочее вручную/CSV; + +и считает поверх них то, за что раньше платили Snowball Income: позиции по всем брокерам, +P&L, XIRR/TWR, аллокацию, календарь дивидендов и купонов с прогнозом, ребалансировку, +бенчмарки, налоги — плюс net worth, cash flow и runway по ZenMoney. + +## Система в общих чертах + +``` +sources/* ──sync──▶ raw_* (JSONB, идемпотентно) ──map──▶ core (account, instrument, event, cash_txn…) + │ +worker (APScheduler, advisory locks) ▼ + ledger (лоты FIFO, дедуп, матчинг ZM↔брокер) + │ + analytics (polars, pyxirr) ──▶ metric_* ──▶ api ──▶ Flutter +``` + +- `backend/src/fintracker/sources/` — по одному пакету на источник, контракт в `base.py`. +- `worker/` — планировщик и запуск синков (`runner.run_source`): lock, `sync_run`, курсор. +- `api/` — FastAPI, `/api/v1`, ошибки RFC 7807, деньги строками. +- `app/` — Flutter, клиент сгенерирован из `openapi/openapi.json`. + +## Документы + +- [architecture.md](architecture.md) — домен, модули, потоки данных, API. +- [conventions.md](conventions.md) — деньги, валюты, идемпотентность, стиль. +- [plan.md](plan.md) — фазы и чек-листы проверки. +- [ops.md](ops.md) — деплой на VPS, бэкапы, секреты. +- [links.md](links.md) — внешние API и референсы. diff --git a/docs/ai/architecture.md b/docs/ai/architecture.md new file mode 100644 index 0000000..0a887ed --- /dev/null +++ b/docs/ai/architecture.md @@ -0,0 +1,80 @@ +# Архитектура + +Полный проект с обоснованиями — `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` ↔ `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` *(план)*, подтверждает пользователь. + +### Леджер *(план, фаза 2)* +- `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` — мимо прокси). Расписание — +`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:`, поэтому ручной и +плановый запуски не пересекаются. После синка, изменившего данные, — refresh метрик +*(план)*. + +## Аналитика (`analytics/`, `pricing/{fx,prices}.py`, `metrics/refresh.py`) +Шаги регистрируются в `analytics/__init__.py` и выполняются `refresh_all` по порядку: +`fx → classify → lots → valuation → returns → networth → cashflow → spending → runway → +quality` (фаза 2 вставит income и allocation после returns). Запуск: +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:`, +`portfolio:`. `returns` читает только `metric_portfolio_value_daily`: XIRR по внешним +потокам и терминальной стоимости, TWR цепочкой `V_t / (V_{t-1} + F_t)`. Покупка бумаги без +цены трактуется как вывод из оцениваемого портфеля (`unvalued_flow_rub`), иначе дыра в +данных читалась бы как обвал. + +Деньги в 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 — строки. + +## Клиент (`app/`) +Flutter, Riverpod, go_router, fl_chart; клиент `app/packages/api_client` генерируется +`just gen-client` из `openapi/openapi.json` и коммитится. diff --git a/docs/ai/conventions.md b/docs/ai/conventions.md new file mode 100644 index 0000000..b8b1871 --- /dev/null +++ b/docs/ai/conventions.md @@ -0,0 +1,35 @@ +# Соглашения + +## Деньги и валюты + +- Все суммы и количества — `Decimal`; в БД `NUMERIC(24,10)`; в JSON — строки. +- Рядом с суммой всегда колонка валюты (`amount` + `currency`, `fee` + `fee_currency`). +- Хранить в нативной валюте, **не конвертировать на записи**. +- Конвертация — в аналитике по `fx_rate_daily` на дату операции; курсы ЦБ протянуты через + выходные (`is_carried = true`); RUB→RUB = 1.0 на любую дату; нет курса → NULL + запись в + `metric_data_quality`. +- Float допустим только внутри расчётов, результат которых — коэффициент (XIRR, TWR, beta). + +## Идемпотентность и источники + +- `raw_*` — append-only, upsert по стабильному id источника; повторный синк ничего не + дублирует. +- Каждая core-строка знает `source` и `source_id`. +- `event.dedupe_key` UNIQUE: номер сделки, если брокер его даёт, иначе fingerprint. +- У счёта один `primary_event_source`; события других источников — `status = shadow`. + +## Время + +- Торговые даты (`trade_date`) — в MSK; T-Invest отдаёт UTC, конвертируем до вывода даты. +- Все `timestamp` — `timestamptz`. + +## Код + +- Python 3.12, типизация обязательна (pyright `standard`), ruff (`E F I UP B SIM RUF`). +- Async везде, где есть I/O; SQLAlchemy 2.0 style (`Mapped`, `select()`). +- Один модуль импортирует SDK T-Invest: `sources/tinvest/client.py`. +- Парсеры отчётов — чистые функции `bytes -> ParsedReport`, без БД. +- Тесты: pytest + pytest-asyncio, Postgres поднимается фикстурой; golden-числа для + финансовой математики. +- Комментарии и docstrings — по-английски (кириллица допустима в пользовательских строках), + документация для людей и агентов — по-русски. diff --git a/docs/ai/links.md b/docs/ai/links.md new file mode 100644 index 0000000..499368e --- /dev/null +++ b/docs/ai/links.md @@ -0,0 +1,48 @@ +# Внешние API и референсы + +| Что | Ссылка | Заметки | +|---|---|---| +| ZenMoney API | https://github.com/zenmoney/ZenPlugins/wiki/ZenMoney-API | только `/v8/diff/` и `/v8/suggest/`; токен 24 ч + refresh | +| T-Invest API | https://developer.tbank.ru/invest/api | gRPC; лимиты Operations/Instruments 200/мин, MarketData 600/мин | +| T-Invest Python SDK | индекс `https://opensource.tbank.ru/api/v4/projects/238/packages/pypi/simple` | пакет `t-tech-investments` | +| MOEX ISS | https://iss.moex.com/iss/reference/ | без ключа; history, dividends, bondization, индексы | +| ЦБ РФ курсы | https://www.cbr.ru/scripts/XML_dynamic.asp | cp1251, делить на `Nominal` | +| pyxirr | https://github.com/Anexen/pyxirr | XIRR/TWR | +| investbook | https://github.com/spacious-team/investbook | референс форматов отчётов Сбер/ВТБ (Java) | +| invest_toolkit | https://github.com/i-savelev/invest_toolkit | парсеры Сбер/ВТБ на Python | +| Соседние проекты | `../fin-dashboard`, `../t_tech-gyro` | ZenMoney diff-цикл, CBR fx, T-Invest адаптер + CA-сертификаты | + +## Что выяснилось при реализации фазы 1 (источники) + +### ZenMoney `/v8/diff/` +- Тело запроса: `{"currentClientTimestamp": , "serverTimestamp": <курсор|0>, + "forceFetch": [типы]}`; `forceFetch` отправляем только при курсоре 0. Ответ — те же + массивы сущностей (только изменённые), `deletion: [{id, object, stamp, user}]` и новый + `serverTimestamp`. +- Типы сущностей: `instrument, company, user, account, tag, merchant, budget, reminder, + reminderMarker, transaction`. `company`, `user`, `budget`, `reminder*` храним только в + raw — в core они пока не нужны. +- `account.type = ccard` маппится в `AccountKind.zm_card` (в enum нет `zm_ccard`), + остальные типы — буквально `zm_`. +- Токен живёт 86400 с. Ротация: `POST https://api.zenmoney.ru/oauth2/token/`, + form-encoded `grant_type=refresh_token&refresh_token=…&client_id=…&client_secret=…`, + ответ `{access_token, refresh_token, expires_in, token_type}`. Пара лежит в + `source_credential(source='zenmoney')`, засеивается из `ZENMONEY_REFRESH_TOKEN`. +- Статический токен (zerro.app/token) при 401 нечем обновить — источник падает с + явным требованием обновить `ZENMONEY_TOKEN`. + +### Прокси на хосте `ada-x1` +- `ALL_PROXY=socks5h://…`, поэтому `httpx.AsyncClient(trust_env=True)` требует extra + `httpx[socks]` (пакет `socksio`) — он добавлен в зависимости, иначе клиент падает на + конструировании ещё до запроса. +- api.zenmoney.ru доступен через прокси (`trust_env=True`, по умолчанию). + cbr.ru через этот прокси роняет TLS → CBR-клиент строится с `trust_env=False`. + +### ЦБ РФ +- `XML_daily.asp?date_req=DD/MM/YYYY` нужен только ради карты `CharCode -> ID` + (`USD -> R01235`, `JPY -> R01820`); на 17.09.2026 отдаёт 54 валюты. +- `XML_dynamic.asp?date_req1=…&date_req2=…&VAL_NM_RQ=` — `` + с `` и `` (запятая как разделитель). Кодировка windows-1251. + В `raw_cbr_rate` кладём `value` и `nominal` как напечатано; деление на номинал — + в `pricing/fx.py`. +- Крипта и металлы (XAU, BTC …) ЦБ не котирует — уходят в `warnings`, не в ошибку. diff --git a/docs/ai/ops.md b/docs/ai/ops.md new file mode 100644 index 0000000..a3b62b9 --- /dev/null +++ b/docs/ai/ops.md @@ -0,0 +1,37 @@ +# Эксплуатация + +## VPS (Docker Compose) + +```bash +cp .env.example .env # заполнить POSTGRES_PASSWORD, JWT_SECRET (openssl rand -hex 32), DOMAIN, токены +just up # docker compose up -d --build: db, api (миграции при старте), worker, caddy, pg-backup +docker compose exec api fintracker create-user you@example.com +``` + +- Caddy: авто-TLS на `DOMAIN`, `/api/*` → api:8000, остальное — Flutter web из `app/build/web`. +- Worker — единственный экземпляр планировщика; ручной синк через `POST /api/v1/sync/{source}` + ставит задачу в `sync_job`, worker забирает её раз в 5 с. +- `pg-backup` делает `pg_dump -Fc` раз в сутки в `./backups`, хранит 14 дней; off-site копию + настраивает хост (rclone/cron), см. открытый вопрос в плане. + +## Восстановление + +```bash +docker compose up -d db +docker compose exec -T db pg_restore -U $POSTGRES_USER -d $POSTGRES_DB --clean < backups/fintracker_.dump +docker compose up -d +``` +Метрики пересчитываются из raw/core: `docker compose exec worker fintracker metrics refresh` +(появится в фазе 2). + +## Секреты + +Только `.env` на сервере. В чат и в git не попадают. T-Invest токен — read-only. + +## Проверено с podman (2026-09-17) + +- `podman 5.8` + `podman-compose 1.6`: стек `db api worker` поднимается, `depends_on.condition: service_healthy` работает. +- Имена образов в `docker-compose.yml` и `Dockerfile` полностью квалифицированы (`docker.io/library/...`) — podman без `unqualified-search-registries` короткие имена не резолвит. +- **Прокси хоста впекается в образ** при `podman build`/`compose --build` (`HTTP_PROXY` попадает в `Config.Env`), после чего запросы к `localhost` внутри контейнера идут в недостижимый прокси. Поэтому `just up` собирает с `env -u …proxy…`. На VPS без прокси это ни на что не влияет. +- `env_file: .env` в compose — буквальный путь; `--env-file` меняет только подстановку `${…}` в самом compose-файле. +- Команда `api` использует `exec`, иначе `sh -c` не передаёт SIGTERM uvicorn'у и compose добивает контейнер по таймауту. diff --git a/docs/ai/plan.md b/docs/ai/plan.md new file mode 100644 index 0000000..ea34724 --- /dev/null +++ b/docs/ai/plan.md @@ -0,0 +1,518 @@ +# План разработки (фазы, чек-листы, открытые вопросы) + +## 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·(ratio−1) | 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 `, `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_{t−1}`, цепочка; бенчмарки по той же сетке дат. +- **Прогноз доходов**: облигации — точно по `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//{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`; парсеры без зависимости от БД. + +### Фаза 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`, налоговый вид +сходится с ручным примером. + +### Фаза 5 — полировка и эксплуатация +Адаптивные раскладки, тёмная тема, offline-кэш с баннером «данные на…», release-сборка +Android, Linux/Windows в CI, пустые/ошибочные состояния, экран настроек, runbook +backup/restore, страница здоровья (`/sync/status`, счётчики data quality). +Проверка: приложение работает offline на кэше; APK подписывается и ставится; restore из +ночного `pg_dump` на чистом VPS + `fintracker metrics refresh` воспроизводит все метрики. + +## 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`.