Skip to content

spec: wallet-and-margin status: active last-updated: 2026-05-24 owners: [@amalkrsihna] code-refs: - ftl-backend/internal/wallet/service.go - ftl-backend/internal/positions/service.go - ftl-backend/internal/margin/service.go - ftl-backend/internal/margin/evaluator.go - ftl-backend/internal/redis/lua/position_open.lua - ftl-backend/internal/redis/lua/position_close.lua - ftl-frontend/src/stores/auth.ts - ftl-frontend/src/components/TopBar.tsx - ftl-frontend/src/pages/PositionsPage.tsx - ftl-frontend/src/lib/trading.ts decisions: [0004, 0006]


Wallet & Margin — Living Spec

TL;DR for an intern

The "balance" you see in your wallet is just the cash sitting in your account. When you open a leveraged position, FTL locks part of that cash as margin — collateral the system holds back so you don't go bankrupt if the trade moves against you. The rest is free margin — what you can use to open more positions. Equity is what your account would be worth if you closed everything right now (cash + the live profit/loss on every open position). The margin level is equity ÷ used margin × 100% — a health score. Below 100% the system warns you; below 50% it force-closes losing positions to protect the rest of your account.

Glossary (defined before first use)

  • Balance — cash. Sits in wallets.balance (PG) + wallet:{userId}.balance hash (Redis, hot). Changes ONLY on a position close (the realised PnL is credited) and on the initial seed (10,000 points for a fresh account).
  • Unrealized PnL — current paper profit/loss across all open positions. Recomputed every time prices move; never written to PG (it's not money the user can spend yet).
  • Equitybalance + Σ unrealized_pnl. The liquidation value of the account right now.
  • Margin required (per position)(current_price × lot_size × 100) / 10. The collateral that this one position locks for the duration it's open.
  • Used margin — sum of margin_required across every open position the user holds.
  • Free marginequity − used_margin. What's available to open new positions with.
  • Margin level(equity / used_margin) × 100. Account-health percentage. Null when used_margin == 0 (no open positions); rendered as ∞ in the UI.
  • Margin call — notification fired once when margin_level reaches 100% (debounced 30 min per user). Informational only — no automatic action.
  • Washout — force-close of one or more losing positions when margin_level breaches 50%. See ADR-0006 + amm-pricing.md §"Margin evaluator & honest washouts".
  • CFD — Contract for Difference — what each cfd_positions row represents.

The numbers, with worked examples

Assume a fresh user with balance = 10_000 opens a 0.05-lot long on Messi at price 200.

On the open:

contract_size      = 100
leverage           = 10
margin_required    = (200 × 0.05 × 100) / 10 = 100
balance            = 10_000  (unchanged on open)
unrealized_pnl     = 0       (just opened — open_price == current_price snapshot)
equity             = 10_000 + 0 = 10_000
used_margin        = 100
free_margin        = 10_000 − 100 = 9_900
margin_level       = (10_000 / 100) × 100 = 10_000%

Price ticks to 220 (10% rise):

unrealized_pnl     = (220 − 200) × 0.05 × 100 × (+1) = +100
equity             = 10_000 + 100 = 10_100
used_margin        = 100             (margin_required doesn't re-mark)
free_margin        = 10_100 − 100 = 10_000
margin_level       = 10_100%

User opens a second 0.05-lot long on Ronaldo at price 180 (no price move yet):

margin_required_2  = (180 × 0.05 × 100) / 10 = 90
used_margin        = 100 + 90 = 190
equity             = 10_100   (Ronaldo open_price == current → 0 unrealized contribution)
free_margin        = 10_100 − 190 = 9_910
margin_level       = (10_100 / 190) × 100 = 5_315.79%

User closes Messi at 220 (realised gain):

realized_pnl       = (220 − 200) × 0.05 × 100 × (+1) = +100
new_balance        = 10_000 + 100 = 10_100
used_margin        = 90       (only Ronaldo's margin remains)
equity             = 10_100   (no open Messi unrealized; balance absorbed it)
free_margin        = 10_100 − 90 = 10_010
margin_level       = 11_222.22%

The same math runs on the short side with direction_sign = −1 — gains and losses flip sign relative to a price rise.

Frontend-display vs backend-enforcement split

Concern Where it lives Authority
Computing margin_level for the BalancePill / PositionsPage strip ftl-frontend/src/lib/trading.ts + the useAuthStore slice fed by the WS portfolio frame Display only. The FE recomputes for snappy UX between WS ticks; the displayed number can briefly drift from the Lua's view.
Deciding whether a new open is allowed (sufficient free margin) position_open.lua walks the user's positions, computes free_margin, and rejects with insufficient_free_margin if the proposed open exceeds it Server-authoritative. The FE pre-disables the Confirm button as a hint; the Lua is the gate.
Firing a margin-call notification margin.Service.LogMarginCall with 30-min cooldown via notif:margin_call:{userId} SetNX Server-only. The FE never raises a margin call by itself.
Firing a washout margin.Service.ExecuteWashouts calls position_close.lua with reason=washout Server-only. The FE shows the resulting closed-position row + push notification but does not initiate.
SL/TP execution positions.StopWatch goroutine in api-server, single-instance via Redis lease Server-only. The FE shows the user-set levels and edits them via PATCH; the trigger is server-side.

The principle: anything that touches money is server-authoritative. The FE renders numbers from the WS portfolio frame and never sends "you owe me X" requests on its own authority.

SL / TP semantics

  • SL (stop loss) is the price at which the user wants out of a losing position before the cascading washout grabs them. For a long, the trigger fires when live_price ≤ SL. For a short, when live_price ≥ SL.
  • TP (take profit) is the symmetric profit-take. Long: live_price ≥ TP. Short: live_price ≤ TP.
  • Both are optional. Either may be set, both may be set, both may be cleared via PATCH /api/positions/:id with null in the field.
  • The Modify endpoint invalidates the StopWatch cache so the new level is enforced immediately rather than waiting up to 5 s for the periodic rebuild.
  • Triggers fire at the live current_price, not at the trigger threshold — fast moves can cause a small slippage between the user-set level and the realised fill price. This is documented behaviour; the closed-position row shows the actual close_price and realized_pnl for transparency.

Washout policy summary (see ADR-0006 + amm-pricing.md)

  • Threshold: margin_level < 50%.
  • Selection: largest unrealised LOSS first. Cascade closes one at a time, recomputes margin_level, repeats until level > 50% or no positions left.
  • Online users: the live evaluator (per-tick throttled in V1 — a UX latency optimisation, not a correctness one) drives the cascade with the current price.
  • Offline users: when the WS reconnects, margin.Service.ReplayLastSeen walks the price_ticks table from the user's last-seen timestamp through now, identifying the exact tick that drove margin_level below 50%. The position closes at THAT price — not at the recovered current price. Ensures the offline experience matches the online experience.
  • Backstop: every 5 minutes, the api-server sweeper (lease-singleton) runs ReplayLastSeen for every user with any open position. Catches stranded sessions (browser killed, airplane mode) before they accumulate undetected drawdown.
  • Pre-FT replay: on FT, the post-match processor runs ReplayUsersAtFT BEFORE the snapshot close-out so any pre-FT washouts realise at their breach prices, not at the later FT snapshot price.

Default seed balance

A fresh user's wallet is created with balance = 10_000 (see migration 000027_wallet_default_10000.up.sql + position_open.lua line 153 fallback for the ultra-fresh case where the wallet hash isn't hydrated yet). The same constant is the sandbox bankroll used by the tutorial overlay.

Audit trail

Every system-driven state change writes to margin_events: - event_type = 'margin_call' — informational, no money moves. - event_type = 'washout' — paired with the closed position's row in cfd_positions. - event_type = 'auto_exit_ft' — FT close-out, one row per closed position.

The details JSONB column captures the price the user was closed at, the realised PnL, and (for replays) the breach time so a support ticket can be reconciled later.

Constants summary

See amm-pricing.md §"Constants" — this spec inherits the same set.