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, 0015, 0020]
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 × 5) / 10. The collateral that this one position locks for the duration it's open. (CONTRACT_SIZE = 5, matchingmargin/types.goand both Lua scripts.) - 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 = 5 (CONTRACT_SIZE in margin/types.go and both Lua scripts)
leverage = 10
margin_required = (200 × 0.05 × 5) / 10 = 5
balance = 10_000 (unchanged on open)
unrealized_pnl = 0 (just opened — open_price == current_price snapshot)
equity = 10_000 + 0 = 10_000
used_margin = 5
free_margin = 10_000 − 5 = 9_995
margin_level = (10_000 / 5) × 100 = 200_000%
Price ticks to 220 (10% rise):
unrealized_pnl = (220 − 200) × 0.05 × 5 × (+1) = +5
equity = 10_000 + 5 = 10_005
used_margin = 5 (margin_required doesn't re-mark)
free_margin = 10_005 − 5 = 10_000
margin_level = (10_005 / 5) × 100 = 200_100%
User opens a second 0.05-lot long on Ronaldo at price 180 (no price move yet):
margin_required_2 = (180 × 0.05 × 5) / 10 = 4.50
used_margin = 5 + 4.50 = 9.50
equity = 10_005 (Ronaldo open_price == current → 0 unrealized contribution)
free_margin = 10_005 − 9.50 = 9_995.50
margin_level = (10_005 / 9.50) × 100 = 105_315.79%
User closes Messi at 220 (realised gain):
realized_pnl = (220 − 200) × 0.05 × 5 × (+1) = +5
new_balance = 10_000 + 5 = 10_005
used_margin = 4.50 (only Ronaldo's margin remains)
equity = 10_005 (no open Messi unrealized; balance absorbed it)
free_margin = 10_005 − 4.50 = 10_000.50
margin_level = (10_005 / 4.50) × 100 = 222_333.33%
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.
ADR-0020 — idempotency + close durability changes that affect wallet accounting:
- Idempotency keys are now released on any pre-mutation rejection (slippage, SL/TP invalid,
insufficient free margin, wallet missing). The
reject()helper in both Lua scripts DELs the freshly-claimed key before returning, so a retry after PRICE_MOVED re-evaluates cleanly instead of hitting a burned key. The idem key persists only for a completed trade. position_close.lua's post-close equity-snapshot loop is best-effort: if a sibling instrument's hash has been evicted from Redis, that position is skipped from the snapshot rather than aborting an already-committed close. The wallet credit, position stamp, and imbalance update are all durable before the loop runs. The next portfolio fetch corrects the numbers. This closes the zero-price ledger-UPDATE corruption path that previously surfaced when a committed close was replayed after a sibling eviction.
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.
Position-open error codes (wallet-relevant)¶
| HTTP | Error / Go sentinel | Meaning |
|---|---|---|
| 409 | PRICE_MOVED |
Client price deviated >2.5% from last_published_price. Retry with a fresh clientRequestId — the idem key was released. |
| 409 | ErrDuplicateInFlight |
Replayed or in-flight request with no usable cached result (Lua responded with openPrice == 0). Previously fell through to 500. Retry with a new clientRequestId. |
| 409 | ErrCorruptPositionState |
Fresh zero-price open — corrupt position state. Previously 500. |
| 422 | ErrInvalidSLTP |
stop_loss or take_profit level is on the wrong side of the current price for the requested direction. |
| 422 | insufficient_free_margin |
Free margin too low to cover margin_required for this lot size. |
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.