Files

49 KiB
Raw Permalink Blame History

План разработки (фазы, чек-листы, открытые вопросы)

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

Интерфейсы плагинов

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 мин 1024
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-clientopenapi-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: 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.

Сделано дополнительно, параллельно в отдельных worktree:

  • CI для Flutter — джоба app (flutter analyze && flutter test) и отдельные app-build-linux/app-build-windows в .github/workflows/ci.yml. По пути найден и починен пре-существующий баг: flutter build linux не собирался вовсе — не хватало libsecret-1-dev/pkg-config, а после этого flutter_secure_storage_linux падал на -Werror=deprecated-literal-operator в своём вендоренном nlohmann/json.hpp (подавлено через add_compile_options в app/linux/CMakeLists.txt). Windows-джоба добавлена по аналогии, но не проверена — нет Windows-машины.
  • Страница здоровья/sync и блок data quality с дашборда сведены в один /health (вкладки «Источники» / «Качество данных»); список находок вынесен в общий DataQualityList, дашборд ссылается на /health?tab=quality вместо своего bottom sheet.
  • Offline-кэш на остальных экранах — accounts, cashflow, categories, goals, income, portfolio (+ карточка инструмента), rebalance, tax, rules переведены на Cached<T> по контракту offline-cache.md. Второстепенные cross-screen селекторы (scopesProvider, categoriesListProvider и т.п.) оставлены без баннера — их GET всё равно кэшируется интерцептором прозрачно, баннер просто не нужен для не-основного контента экрана.

Не начато: 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.