AGENTS.md — индекс, команды и обязательные ограничения (Decimal для денег, не конвертировать валюту на записи, SDK T-Invest ровно в одном модуле, аналитика читает только confirmed). docs/ai/ — архитектура, соглашения, эксплуатация и полный план на шесть фаз с проверками для каждой.
42 KiB
План разработки (фазы, чек-листы, открытые вопросы)
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, ZMid), для отчётовdedupe_key = sha1(broker|account|trade_no)если есть номер сделки, иначе fingerprintsha1(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), confidenceledger/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 мин 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/<screen>/{data,providers,widgets,page.dart};intlru_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. Открытые вопросы (дефолт в скобках, можно менять по ходу)
- Хранение refresh-токена в web:
localStorage(дефолт: да, один пользователь, TLS) или HttpOnly cookie + CSRF только для web-сборки. - Источник S&P 500: бесплатного официального нет. (Дефолт: ручной CSV; опционально Stooq/Yahoo как неофициальный.)
- ЛДВ после изменений 2025 (иностранные эмитенты, ИИС-3). (Дефолт: классическое 3-летнее правило только для бумаг MOEX, помечено как оценка.)
- Налоговая база валютных бумаг: пересчёт по ЦБ на даты покупки и продажи (валютная переоценка облагается), кроме еврооблигаций Минфина. (Дефолт: моделируем в фазе 4.)
- Несколько счетов T-Invest (брокерский, ИИС) — каждый отдельный
account; ИИС-специфика налогов вне scope. - Депозиты из ZenMoney: только баланс (фаза 1) или ежедневное начисление процентов как инструмент (фаза 4). (Дефолт: баланс, начисление позже.)
- Какие именно выгрузки Сбера/ВТБ (брокерский отчёт за период vs справка о доходах, помесячно vs произвольный период). Выяснится по файлам.
- Порог устаревания цены (10 дней) и оценка заблокированных иностранных бумаг (последняя цена / ноль / вручную). (Дефолт: последняя цена + флаг.)
- Бэкап: ночной
pg_dumpна диск VPS + off-site через rclone — куда? - Таймзона: MSK для торговых дат; T-Invest отдаёт UTC → конвертируем до вывода
trade_date.