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}.balancehash (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).
- Equity —
balance + Σ 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_requiredacross every open position the user holds. - Free margin —
equity − used_margin. What's available to open new positions with. - Margin level —
(equity / used_margin) × 100. Account-health percentage. Null whenused_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_positionsrow 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, whenlive_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/:idwithnullin 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 actualclose_priceandrealized_pnlfor 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.ReplayLastSeenwalks theprice_tickstable 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
ReplayLastSeenfor 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
ReplayUsersAtFTBEFORE 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.