adr: 0004 title: Adopt CFD-style margin trading model (replace buy-and-hold AMM) status: accepted implementation-status: implemented date: 2026-05-22 implemented-date: 2026-05-24 implemented-in: - ftl-backend feat/amm-cfd-redesign-v1 - ftl-frontend feat/amm-cfd-redesign-v1 - ftl-docs feat/amm-cfd-redesign-v1-impl deciders: [@amalkrsihna] affects-specs: [amm-pricing, wallet-and-margin] affects-code: - ftl-backend/internal/redis/lua/trade_execute.lua # major rewrite - ftl-backend/internal/trade/service.go # major rewrite - ftl-backend/internal/amm/amm.go # extend or replace - ftl-backend/internal/instrument/pricing.go # rewrite - ftl-backend/internal/sportmonks/postmatch.go # FT close-out rewrite - ftl-backend/migrations/ # new positions table + schema - ftl-backend/internal/ws/ # wire portfolio channel end-to-end - ftl-frontend/src/components/BuySellModal.tsx # rewrite for direction + lot - ftl-frontend/src/stores/auth.ts # margin semantics - ftl-frontend/src/lib/trading.ts # margin formulas (already partly built) supersedes: 0001 superseded-by: null
ADR-0004: Adopt CFD-style margin trading model¶
Pending implementation. This ADR captures the design decision. The paired backend PR in
ftl-backend(branchfeat/amm-cfd-redesign-v1) will implement it, at which point this ADR'simplementation-statusflips toimplementedand ADR-0001 is markedstatus: superseded, superseded-by: 0004. Until then, the live system still runs the buy-and-hold AMM described by ADR-0001 andarchitecture/specs/amm-pricing.md.
Context¶
The current FTL trading model is a single-direction buy-and-hold AMM (ADR-0001):
- Users BUY shares of a player at a linear bonding-curve price.
- The same users SELL those shares later, walking the curve back.
- At Full Time, anyone still holding gets force-sold at a flat fair price (ADR-0003).
net_shares_soldresets to 0 for the next match.
This works, but creates a thin product surface that's closer to a casual prediction market than to "real trading". The operator wants to deepen the product into a forex/CFD-style margin trading experience:
- Bidirectional positions. Users should be able to bet that a player will UNDERPERFORM (open a short), not just outperform. This roughly doubles the strategic surface area.
- Leverage + margin vocabulary. Balance, Equity, Used Margin, Free Margin, Margin Level. Margin calls, washouts. The financial-product feel that gives FTL meaningful differentiation vs simpler fantasy-sports apps.
- Live PnL on every screen. Equity ticking up and down in real time creates emotional engagement that a static balance does not.
We brainstormed the design over a long session (notes in /Users/amal/.claude/plans/). The
key decisions:
- Hybrid pricing: events drive base, position imbalance modulates. Preserves "trading against the market" feel rather than going pure-sportsbook.
- 1:10 leverage — fast enough to feel tense, slow enough to survive a bad streak.
- Honest washouts (backend tick-replay) — same outcome whether user is online or not, to close the "close the app when losing" exploit.
- Gross margin on hedged positions — simpler to explain than netted.
This ADR locks all the above as one cohesive package because they're inseparable: leverage without margin call is reckless; bidirectional positions without a price-formation answer is incomplete; offline-washout fairness only matters because margin call exists.
Decision¶
We will replace the buy-and-hold AMM with a CFD-style margin trading model. Concretely:
Pricing¶
base_price = scoring(match_events) # existing scoring.go logic
current_price = base_price + k_mod × (Σ longs − Σ shorts)
base_pricedriven entirely by event scoring (existinginternal/sportmonks/scoring.go).k_modis a new, smaller per-instrument slope (DB column, default e.g. 0.01) that modulatescurrent_pricearoundbase_pricebased on aggregate position imbalance.Σ longs= total long-side lot-shares across all users on this instrument.Σ shorts= total short-side lot-shares across all users on this instrument.- The
net_shares_soldfield oninstrumentsis repurposed (or replaced withnet_position_imbalancefor clarity).
Positions¶
- Direction:
longorshort. Replaces today's single-direction BUY/SELL semantics. - Opening a position: BUY = open long. SELL = open short. No buy-first prerequisite.
- Closing a position: explicit close action (or stop-loss/take-profit trigger, or FT auto-exit).
- Multiple positions per user per instrument allowed, with gross margin — each position locks its own margin separately. Hedging (1 long Messi + 1 short Messi) is supported and consumes margin for both legs.
- A new
positionstable replaces the implicit shares-in-user_pnl_by_instmodel. Schema: id(UUID),user_id,instrument_id,direction(long|short),lot_size(DECIMAL),open_price(DECIMAL),opened_at(TIMESTAMPTZ),closed_at(TIMESTAMPTZ nullable),close_price(DECIMAL nullable),realized_pnl(DECIMAL nullable),stop_loss(DECIMAL nullable),take_profit(DECIMAL nullable),closed_by(user|stop_loss|take_profit|washout|auto_exit_ftnullable).- Indexes on
(user_id, closed_at IS NULL)for "open positions" queries and(instrument_id, closed_at IS NULL)for aggregate imbalance.
Leverage and lot sizes¶
- Leverage: 1:10 system-wide (not per-instrument in V1).
- Contract size:
1.0 lot = 100 shares of player exposure. - Lot-size catalog (user-selectable in the booking UI):
- Nano:
0.01, 0.02, 0.03, 0.04, 0.05 - Micro:
0.1, 0.2, 0.3, 0.4, 0.5 - Standard:
1, 2, 3, 4, 5
PnL and margin formulas¶
margin_required = (open_price × lot_size × 100) / 10
unrealized_pnl = (current_price − open_price) × lot_size × 100 × direction
where direction = +1 (long) | −1 (short)
realized_pnl = (close_price − open_price) × lot_size × 100 × direction
Wallet vocabulary (live on every screen)¶
balance = settled cash (changes only on position CLOSE / deposit / withdrawal)
equity = balance + Σ unrealized_pnl across all open positions
used_margin = Σ margin_required across all open positions
free_margin = equity − used_margin
margin_level = (equity / used_margin) × 100 # null when used_margin = 0
Margin call + washout (stop-out)¶
- 100% margin level → notification only ("you are at margin call"). No automatic action.
- 50% margin level → washout: backend force-closes the user's largest losing position first (by absolute unrealized PnL), re-checks margin level, repeats until margin level rises above 50% OR all positions are closed.
- Honest enforcement: see ADR-0006. Backend authoritative via tick-replay; outcome is the same whether user was online or not.
Trading lifecycle constraints¶
- Trading window preserved: bookings (open + close) only allowed during a live match.
Existing
match.GetTradeWindowgate stays. - Auto-exit at FT: any position still open at FT auto-closes at the
current_pricesnapshot taken at the FT instant. PnL realized into balance. Replaces today's flat-price system-sell (ADR-0005 makes this formal). - 3-minute cooldown per instrument: booking a new position on Messi blocks new Messi bookings for 3 minutes. Trading other instruments is unaffected. Closing positions is NOT subject to cooldown — the user can always exit a losing trade.
- Stop-loss + take-profit: optional at booking, modifiable any time while position is open, removable. Triggered server-side (background worker checks against current_price on every tick where the user has SL/TP set).
Compute split (frontend vs backend)¶
- Frontend computes for DISPLAY — every WebSocket price tick, compute equity / free margin / margin level locally and update the UI. Sub-second. No backend round-trip.
- Backend computes for ENFORCEMENT — on every booking, on every close, on user reconnect, periodically (every 5 minutes for users with open positions), and on significant price moves. Backend is the only authority on whether a margin call notification fires or a washout triggers.
Starting balance¶
- 10,000 FTL coins (rebranded from "points"; same numeric value).
Consequences¶
Positive¶
- Strategic depth doubles. Bidirectional positions + hedging give users meaningful decisions beyond "which players will do well".
- Frontend already has the vocabulary built.
TopBar.tsxBalancePill popover, thecomputeTradingMetricsfunction insrc/lib/trading.ts, and theuseAuthStorealready expose Balance/Equity/Margin/Free Margin/Margin Level. We are extending an existing pattern, not building from scratch. - Portfolio WebSocket channel already exists end-to-end on the backend (Lua publishes
to
portfolio:<userId>). The frontend currently doesn't consume it; wiring it up unlocks real-time per-user state without polling. - Audit-friendly. Every position is a row in
positions; every margin event is a row in a newmargin_eventsaudit table. Disputes are resolvable from history.
Negative¶
- Major Lua rewrite.
trade_execute.luaneeds to handle direction, lot size, margin computation, idempotency, slippage — all atomically. Probably grows from ~385 lines to ~600–700. - Schema migration. The implicit "shares per user per instrument" model becomes an
explicit
positionstable. Migration of in-flight data at cutover is non-trivial. - Washout logic is psychologically heavy. Users WILL get washed out. We need clear in-app explanation, push notifications, and a "what happened" history view.
k_modcalibration is empirical. We can't know the right value from theory. Plan for a staging tuning loop with synthetic traders before tournament cutover.
Neutral / new obligations¶
- Tick-replay code needs to be cheap. Backend stores price ticks in
price_ticksalready; we add an indexed queryWHERE instrument_id = ? AND ts BETWEEN ? AND ?and walk it on user reconnect. Verify performance at 50K users. - Stop-loss / take-profit background worker. A new goroutine in the api-server (or a
separate
stop-watcherservice) tails the price tick stream and triggers position closes when SL/TP levels hit. Needs careful idempotency.
Alternatives considered¶
Alternative A: Pure CFD (no position modulation)¶
Price driven entirely by match events; users place bets but do not move price.
Rejected. Removes the "trading against the market" feel — FTL becomes a sportsbook with continuous odds. The user wants to evolve toward that long-term as an option, but not be locked there now. Hybrid (with k_mod modulation) preserves the option to dial modulation toward zero later if pure CFD turns out to feel better.
Alternative B: Pure bidirectional AMM (no event base)¶
Drop the event-driven base entirely; price moves only from aggregate long/short imbalance.
Rejected. Match events are the WHOLE POINT of a sports trading game. Without event-driven price moves, opening a position has nothing to react to.
Alternative C: 1:5 conservative leverage¶
Half the leverage, half the per-lot exposure, much longer washout horizon (~15–30 min on bad luck).
Rejected. The 1:10 → ~5–10 min washout horizon was preferred because it creates real tension during a single match. 1:5 risks feeling sluggish. Easy to revisit if 1:10 turns out to be too brutal in playtests; just change one constant.
Alternative D: 1:50 aggressive leverage¶
5× more leverage, washouts in 30s–2min on bad streaks.
Rejected as retention-toxic for a casual audience. Closer to offshore retail forex broker mechanics; not appropriate for a game targeting first-time-trader users in tier-2/3 Indian cities. The operator's own research (concept-03 psychology notes) flags washout-induced rage-quit as a top retention risk.
Alternative E: One position per user per instrument¶
Simplify by allowing at most one open position per user-instrument pair.
Rejected. Loses hedging, layering, and scaling-in as strategic primitives. The complexity
saved is modest (a WHERE clause on the booking endpoint); the strategic depth lost is
material.
Alternative F: Netted margin on hedged same-instrument positions¶
If a user holds 1 long Messi + 1 short Messi, charge margin only on the imbalance (0 in this case).
Rejected for V1 (kept as V2 capital-efficiency feature). Gross margin is easier to explain ("each position costs its own margin"), easier to implement, and matches what most retail forex brokers do.
Alternative G: Lenient offline washouts¶
If user was offline during a dip below 50% margin level, don't wash them out (the operator's original instinct).
Rejected. Captured separately in ADR-0006 because the rejection is load-bearing and will be tempted to weaken under user complaints. See that ADR for the full argument.
Alternative H: Keep flat-price system-sell at FT (ADR-0003)¶
Liquidate all positions at a single fair price at FT, the way ADR-0003 mandates for shares.
Rejected for CFD positions. ADR-0005 captures this — flat-price system-sell exists to prevent the AMM curve from cratering the price for late-sold users. CFD positions don't walk the curve on close (positions close at current_price; the modulation term updates by removing the position's contribution), so the rationale doesn't apply. Close-at-current is simpler and matches what the user sees.
Worked example — the lifecycle of a 1-lot long position¶
A clean lifecycle to anchor the math. Starting balance: 10,000 FTL coins. Messi price 200.
| Step | Event | Balance | Open positions (unrealized) | Equity | Used margin | Margin level |
|---|---|---|---|---|---|---|
| 1 | User opens 1.0 lot LONG Messi at 200 | 10,000 | 1 long @ 200 (PnL 0) | 10,000 | 2,000 | 500% (very safe) |
| 2 | Messi price moves to 215 (+15) on a goal | 10,000 | 1 long, PnL = +15×100×1 = +1,500 | 11,500 | 2,000 | 575% |
| 3 | Messi price drops to 195 (−5 from open) | 10,000 | 1 long, PnL = −500 | 9,500 | 2,000 | 475% |
| 4 | Messi crashes to 175 (own-goal own player) | 10,000 | 1 long, PnL = −2,500 | 7,500 | 2,000 | 375% |
| 5 | Messi keeps falling to 160 | 10,000 | 1 long, PnL = −4,000 | 6,000 | 2,000 | 300% |
| 6 | Messi 150 — margin call zone approaching | 10,000 | 1 long, PnL = −5,000 | 5,000 | 2,000 | 250% |
| 7 | Messi 130 — margin call (100%) NOTIFIES | 10,000 | 1 long, PnL = −7,000 | 3,000 | 2,000 | 100% ← margin call |
| 8 | Messi 120 — margin level breaches 50% WASHOUT | 10,000 | (position force-closed) | 9,000 | 0 | n/a |
| 9 | After washout | 9,000 | (none) | 9,000 | 0 | n/a |
In step 8, the backend force-closes the position at the price where margin level hit 50% (≈ 120 coins). Realized PnL = (120 − 200) × 1.0 × 100 × (+1) = −8,000 coins swept into balance. Wait — that takes balance to 2,000? Let me recompute. Actually realized PnL on close is added to balance; the unrealized PnL was already mirrored in equity. Let's redo:
- Pre-close: balance 10,000, used_margin 2,000, unrealized −8,000 → equity 2,000, margin level 100% (close to washout). Actually with PnL −8,000, equity 2,000, margin level (2,000/2,000) × 100 = 100% — that's margin call, not washout. Washout triggers at 50%, so we need equity = 1,000, PnL = −9,000.
- Stop-out fires at PnL = −9,000 → equity 1,000, margin level 50%.
- Force-close at the price where this happened: 200 + (−9,000 / 100) = 200 − 90 = 110.
- Position closes at 110. Realized PnL = (110 − 200) × 100 × 1.0 × 1 = −9,000.
- Balance: 10,000 + realized PnL = 10,000 − 9,000 = 1,000.
- Used margin: 2,000 freed. Free margin: 1,000 − 0 = 1,000.
- Result: user lost 90% of their account on a 45% price drop. With 1:10 leverage, leverage amplifies losses 10×.
A stop-loss at 150 in step 1 would have closed the position at 150 → realized PnL = −5,000, balance becomes 5,000. Half the loss vs the washout.
Implementation outline (NOT the implementation itself; this is the plan)¶
The eventual paired backend PR (feat/amm-cfd-redesign-v1 in ftl-backend) should:
- DB schema migration. New
positionstable per schema above. Newmargin_eventsaudit table (user_id, event_type, fired_at, equity, margin_level, position_id, details). Repurpose or renameinstruments.net_shares_sold→net_position_imbalance(signed integer: positive = net long, negative = net short). - Rewrite
trade_execute.lua. New input fields (direction,lot_size, optionalstop_loss,take_profit). Compute margin, check free margin, writepositionsrow, update aggregate imbalance, recomputecurrent_price, publish onprice:<id>andportfolio:<userId>. Keep atomicity. Keep idempotency guard. - New API endpoints.
POST /api/positions/open,POST /api/positions/:id/close,PATCH /api/positions/:id(modify SL/TP),GET /api/positions(list user's positions). - Background stop-watcher. Tail price ticks; close positions where SL/TP triggered.
Single-instance per environment via Redis lease (same pattern as
internal/sportmonksscheduler singleton — see PR #14 in ftl-backend). - Margin checker. On user WebSocket reconnect, replay
price_ticksfor the user's open-position window; detect washout. Periodic check every 5 min for any user with open positions still online. - Wire portfolio WS channel on frontend.
useWebSocket.tsalready has the connection; add asubscribe_portfolioaction and consumeportfolio:<userId>JSON frames intouseAuthStoreviasyncFromPortfolio. - Frontend booking UI.
BuySellModal.tsxbecomesBookPositionModal.tsx: direction toggle (Long / Short), lot-size selector (nano / micro / standard tabs), optional SL/TP fields, displays margin required + max loss + max profit. - Frontend positions screen. New
PositionsPage.tsxshowing open positions with live unrealized PnL, close buttons, SL/TP modify modal. - Push notifications. Use the existing notify infrastructure (from the referral system in PRs #23–#27 of ftl-backend) to send "you were stopped out" alerts on offline washouts.
- Migration runbook in
guides/. When cutting over: how to liquidate in-flight buy-and-hold positions at the cutover boundary; comms to users about the new model; rollback plan if the new model misbehaves in the first match.
Spec impact (will land in the paired backend PR, not now)¶
When this ADR's implementation lands:
architecture/specs/amm-pricing.md— full rewrite for CFD model. Bumplast-updated. Updatecode-refs:to new files. Cross-link towallet-and-margin.md.architecture/specs/wallet-and-margin.md— NEW spec authored at code-land time, capturing the live wallet/margin semantics. (Not authored now because the principle is "spec = current truth"; we don't write specs for hypothetical future state.)architecture/decisions/0001-amm-linear-bonding-curve.md—status: superseded,superseded-by: 0004.architecture/decisions/0003-ft-freeze-drain-wait-system-sell.md—status: superseded,superseded-by: 0005.architecture/decisions/0004-cfd-style-margin-trading-redesign.md(this file) —implementation-status: implemented.architecture/decisions/INDEX.md,architecture/specs/INDEX.md,README.md,PENDING.mdall updated to reflect the new state.