← Заметки
Нелинейный торговый баланс
architecture·trading·capital-tree

Нелинейный торговый баланс

Почему торговому NAV разрешено быть больше аллокации — и почему мы держим их в разных БД.

· Mikhail Savchenko · Обновлено

Баланс пользователя на inite.fund — это дерево:

cash (idle)
 ├─ long allocations[]    →  PortfolioMeta(allocation, equity)         [linear]
 └─ trading allocations[] →  TradingAccount + AccountSnapshot          [non-linear]
                              cash + nav + margin_used
                              + realized_pnl + unrealized_pnl
total = cash + Σ long.equity + Σ trading.nav

Два стека, один баланс. Длинные аллокации линейны: equity, который вы видите, — это mark-to-market стратегии на тот кэш, что вы туда положили. Торговая часть нелинейна: NAV расходится с аллокацией из-за PnL, из-за маржи (один доллар можно использовать дважды — perp-шорт хеджит spot-лонг), из-за нереализованных колебаний. И это — по задумке, а не баг.

Почему две БД

Соблазнительно сложить состояние торговли в тот же SQLite-файл, что и портфель. Мы не стали. У торговой части собственная схема (tm_orders, tm_fills, tm_positions, tm_account_snapshots, tm_capital_flows) в отдельной trading_module.db со своим SQLAlchemy Base.

Две причины:

  1. Торговый слой раньше жил как отдельный исследовательский инструмент. У него была своя БД, своя фабрика сессий, свои миграции. Перенести его в web/ значило бы переписывать сериализацию истории сделок без явной выгоды. Цена «двух БД в одном приложении» невелика: второй sessionmaker и аккуратное API для движений капитала.
  2. Радиус поражения. Баг в торговом движке, портящий состояние, остаётся в trading_module.db. Портфельная сторона — где лежит свободный кэш, длинные аллокации, аудит-лог, аккаунты пользователей — не задета. Торговую БД можно уронить и пересобрать из лога ордеров; портфельную нельзя уронить, не потеряв след денег.

Примитивы движений капитала

Контракт между двумя БД — это маленький набор атомарных хелперов в web/balance.py:

  • get_user_cash(user_id) — сумма строк в UserCashLedger.
  • move_to_long(user, strategy, amount) — добавляет знаковую запись в ledger (минус по кэшу) и в той же транзакции увеличивает PortfolioMeta.allocation.
  • move_to_trading(user, strategy_id, amount) — то же самое, плюс пишет CapitalFlowRow на стороне торговой БД. Транзакция через две БД, идемпотентна по audit_ref.
  • compute_user_balance(user) — джоинит обе стороны и возвращает дерево.

Каждый хелпер принимает опциональный audit_ref. Передали тот же ref дважды — второй вызов ничего не делает. Сетевой сбой, четыре ретрая — в ledger всё равно одна запись.

Что «нелинейный» значит на практике

У Боба $100k кэша. Аллоцирует $30k на paper_levels_bob. Торговый движок открывает ETH perp short с 3× notional → margin_used = $9k, notional позиции = $27k. Цена идёт против, нереализованный PnL = −$2k.

Баланс Боба:

cash             = $70,000
long             = $0  (длинных аллокаций пока нет)
trading[paper_levels_bob]:
  initial_alloc  = $30,000
  cash           = $21,000   (заперто: $9k маржа)
  unrealized_pnl = -$2,000
  nav            = $28,000   (cash + нереализованный, без маржи)
total            = $98,000   (= 70 + 0 + 28)

Заметьте: nav ≠ initial_allocation. Заметьте также: total ≠ Σ allocations. Формула:

total = cash + Σ long.equity + Σ trading.nav

Чтобы картинка была честной, торговая часть размечается по NAV (то, что получилось бы, закройся позиция сейчас), а не по аллокации. Так мы и делаем.

Почему это важно для MCP-инструмента

get_my_balance возвращает дерево целиком. Когда пользователь спрашивает у Claude «какой у меня баланс», он получает ровно этот объект. Задача скилла — правильно его подать:

  • Сначала total.
  • Кэш — отдельной строкой.
  • Длинные аллокации: equity + доходность в %.
  • Торговые аккаунты: NAV + предупреждение по марже, если margin_used / nav > 0.5.

Пользователь, видящий только одно число ($98,000), упустил бы тот факт, что $30k его капитала в риске в торговле, и что $9k из них заперты в марже. Линейная арифметика это прячет. Дерево — показывает.

Уроки

  1. Разделяйте слои по радиусу поражения, а не по числу таблиц. Две БД — нормально, если контракт между ними тонкий и атомарный.
  2. Идемпотентность по умолчанию делает ретраи безопасными. Каждый API движений капитала принимает audit_ref; повторный вызов с тем же ref ничего не делает. MCP-слой может быть наивным насчёт ретраев; аудит-слой — не может.
  3. «Баланс» с маржой внутри — это дерево, а не число. Показ одного числа — это враньё. Показ дерева — единственно честный UI.

Полная схема — в trading/state/models.py, если хотите почитать.

Похожие заметки
Все заметки →