← Notes
The non-linear trading balance
architecture·trading·capital-tree

The non-linear trading balance

Why your trading NAV is allowed to be greater than your allocation — and why we keep them in different DBs.

· Mikhail Savchenko

A user’s balance on inite.fund is a tree:

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

Two stacks, one balance. Long allocations are linear: the equity you see is the strategy’s mark-to-market on the cash you put in. Trading is non-linear: NAV diverges from allocation because of PnL, because of margin (you can use the same dollar twice — perp short hedging spot long), because of unrealized swings. By design.

Why two DBs

It’s tempting to fold trading state into the same SQLite file as the portfolio. We didn’t. Trading carries its own schema (tm_orders, tm_fills, tm_positions, tm_account_snapshots, tm_capital_flows) in a separate trading_module.db with its own SQLAlchemy Base.

Two reasons:

  1. The trading layer existed first as a standalone research tool. It had its own DB, its own session factory, its own migrations. Folding it into web/ would’ve meant rewriting trade history serialization for no real upside. The cost of “two DBs in one app” is small: a second sessionmaker and a careful capital-flow API.
  2. Blast radius. A bug in the trading engine that corrupts state stays in trading_module.db. The portfolio side — which holds idle cash, long allocations, audit log, user accounts — is untouched. We can drop the trading DB and rebuild from the order log; we can’t drop the portfolio DB without losing money trail.

Capital flow primitives

The contract between the two DBs is a small set of atomic helpers in web/balance.py:

  • get_user_cash(user_id) — sum of UserCashLedger rows.
  • move_to_long(user, strategy, amount) — append a signed ledger row (negative cash) and increment PortfolioMeta.allocation in a single transaction.
  • move_to_trading(user, strategy_id, amount) — same, but writes a CapitalFlowRow on the trading side too. Two-DB transaction, idempotent on audit_ref.
  • compute_user_balance(user) — joins both sides and returns the tree.

Each helper takes an optional audit_ref. Pass the same ref twice → no-op the second time. Network blip retried four times → ledger has one entry.

What “non-linear” means in practice

Bob has $100k cash. Allocates $30k to paper_levels_bob. The trading engine opens an ETH perp short with 3x notional → margin_used = $9k, position notional = $27k. Price moves against, unrealized = -$2k.

Bob’s balance:

cash             = $70,000
long             = $0  (no long allocations yet)
trading[paper_levels_bob]:
  initial_alloc  = $30,000
  cash           = $21,000   (locked: $9k margin)
  unrealized_pnl = -$2,000
  nav            = $28,000   (cash + unrealized excluding margin lock)
total            = $98,000   (= 70 + 0 + 28)

Note: nav ≠ initial_allocation. Note also: total ≠ Σ allocations. The math is:

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

If you want a “fair” picture you mark trading at NAV (what you’d have if the position closed now), not at allocation. We do that.

Why it matters for the MCP tool

get_my_balance returns the full tree. When a user asks Claude “what’s my balance” it gets back this exact dict. The skill’s job is to phrase it right:

  • Lead with total.
  • Mention cash separately.
  • Long allocations: equity + return %.
  • Trading accounts: NAV + margin warning if margin_used / nav > 0.5.

A user who only had a single number (“$98,000”) would miss the fact that $30k of their capital is at-risk in trading — and that $9k of that is locked in margin. Linear math hides that. The tree shows it.

Lessons

  1. Separate concerns by blast radius, not by table count. Two DBs is fine if they have a thin, atomic contract.
  2. Idempotency-by-default makes retries safe. Every capital-flow API takes an audit_ref; a re-run with the same ref is a no-op. The MCP layer can be naive about retries; the audit layer can’t.
  3. A “balance” with margin in it is a tree, not a number. Showing one number is lying. Showing the tree is the only honest UI.

The full schema is in trading/state/models.py if you want to read it.

Related notes
All notes →