Skip to content

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 (branch feat/amm-cfd-redesign-v1) will implement it, at which point this ADR's implementation-status flips to implemented and ADR-0001 is marked status: superseded, superseded-by: 0004. Until then, the live system still runs the buy-and-hold AMM described by ADR-0001 and architecture/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_sold resets 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_price driven entirely by event scoring (existing internal/sportmonks/scoring.go).
  • k_mod is a new, smaller per-instrument slope (DB column, default e.g. 0.01) that modulates current_price around base_price based 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_sold field on instruments is repurposed (or replaced with net_position_imbalance for clarity).

Positions

  • Direction: long or short. 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 positions table replaces the implicit shares-in-user_pnl_by_inst model. 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_ft nullable).
  • 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 levelwashout: 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.GetTradeWindow gate stays.
  • Auto-exit at FT: any position still open at FT auto-closes at the current_price snapshot 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.tsx BalancePill popover, the computeTradingMetrics function in src/lib/trading.ts, and the useAuthStore already 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 new margin_events audit table. Disputes are resolvable from history.

Negative

  • Major Lua rewrite. trade_execute.lua needs 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 positions table. 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_mod calibration 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_ticks already; we add an indexed query WHERE 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-watcher service) 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:

  1. DB schema migration. New positions table per schema above. New margin_events audit table (user_id, event_type, fired_at, equity, margin_level, position_id, details). Repurpose or rename instruments.net_shares_soldnet_position_imbalance (signed integer: positive = net long, negative = net short).
  2. Rewrite trade_execute.lua. New input fields (direction, lot_size, optional stop_loss, take_profit). Compute margin, check free margin, write positions row, update aggregate imbalance, recompute current_price, publish on price:<id> and portfolio:<userId>. Keep atomicity. Keep idempotency guard.
  3. 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).
  4. Background stop-watcher. Tail price ticks; close positions where SL/TP triggered. Single-instance per environment via Redis lease (same pattern as internal/sportmonks scheduler singleton — see PR #14 in ftl-backend).
  5. Margin checker. On user WebSocket reconnect, replay price_ticks for the user's open-position window; detect washout. Periodic check every 5 min for any user with open positions still online.
  6. Wire portfolio WS channel on frontend. useWebSocket.ts already has the connection; add a subscribe_portfolio action and consume portfolio:<userId> JSON frames into useAuthStore via syncFromPortfolio.
  7. Frontend booking UI. BuySellModal.tsx becomes BookPositionModal.tsx: direction toggle (Long / Short), lot-size selector (nano / micro / standard tabs), optional SL/TP fields, displays margin required + max loss + max profit.
  8. Frontend positions screen. New PositionsPage.tsx showing open positions with live unrealized PnL, close buttons, SL/TP modify modal.
  9. 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.
  10. 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. Bump last-updated. Update code-refs: to new files. Cross-link to wallet-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.mdstatus: superseded, superseded-by: 0004.
  • architecture/decisions/0003-ft-freeze-drain-wait-system-sell.mdstatus: 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.md all updated to reflect the new state.