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, 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}.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 × 5) / 10. The collateral that this one position locks for the duration it's open. (CONTRACT_SIZE = 5, matching margin/types.go and both Lua scripts.)
  • 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      = 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, 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.

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.