# План разработки (фазы, чек-листы, открытые вопросы) ## 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`; парсеры без зависимости от БД. **Статус: закрыта.** Все проверки пройдены на живых отчётах, кроме одной: 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` воспроизводит все метрики. ## 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`.