spec: cfd-pricing-and-wallet
status: active
last-updated: 2026-06-11
recent-changes:
- 2026-06-11 - per-position close cooldown sourced from opened_at_ms; last_open:* external key retired
- 2026-06-11 - MicroBumpDecay = 1.0 — event + stat bumps no longer decay between polls
- 2026-06-11 - sportmonks ticker include adds timeline (offside, corner, shot-on/off, woodwork now ingested)
owners: [@amal-krishna-m-u]
code-refs:
- ftl-backend/internal/sportmonks/scoring.go
- ftl-backend/internal/sportmonks/ticker.go
- ftl-backend/internal/redis/lua/position_open.lua
- ftl-backend/internal/redis/lua/position_close.lua
- ftl-backend/internal/margin/types.go
- ftl-frontend/src/stores/auth.ts
- ftl-frontend/src/lib/trading.ts
- ftl-frontend/src/components/CFDTradeForm.tsx
decisions: [0004, 0005, 0006, 0012, 0013, 0016, 0017, 0019, 0020, 0035]
CFD Pricing & Wallet Calculations — Living Spec¶
Written for a 1st-year intern. Every formula includes a worked example with real numbers.
TL;DR¶
FTL is a football player trading game. Each player has a price that moves based on real match performance. Users open long (price goes up = profit) or short (price goes down = profit) positions with leverage. The wallet tracks balance, equity, margin, and free margin — exactly like a forex trading platform.
1. How Player Prices Are Calculated¶
A player's live price has three layers that stack on top of each other:
Layer 1: liveBasePrice (from matchScore)¶
Every 10 seconds during a live match, the system polls Sportmonks for each player's real stats (goals, assists, tackles, passes, etc.). These stats feed into a composite matchScore via weighted formula:
Position weights differ by role: - FWD: Goals (×3.0), Assists (×2.0), Shots On Target (×1.5), Key Passes (×1.0) - DEF: Tackles Won (×2.0), Interceptions (×2.0), Clearances (×1.5), Aerials Won (×1.0) - GK: Saves (×3.0), Saves Inside Box (×2.5), Clean Sheet (×4.0) - MID: balanced across all categories
The matchScore drives liveBasePrice via EWMA (Exponential Weighted Moving Average):
formIndex = 0.7 × currentMatchScore + 0.3 × previousFormIndex
liveBasePrice = floorPrice + (formIndex / maxExpectedScore) × (ceilingPrice - floorPrice)
Where floorPrice = 50, ceilingPrice = 500, maxExpectedScore = 25.
Example: R. Huescas (DEF, FC København)¶
| Stat | Value | Weight (DEF) | Contribution |
|---|---|---|---|
| Tackles Won | 3 | ×2.0 | 6.0 |
| Interceptions | 2 | ×2.0 | 4.0 |
| Clearances | 4 | ×1.5 | 6.0 |
| Aerials Won | 2 | ×1.0 | 2.0 |
| Passes (accurate) | 45 | ×0.02 | 0.9 |
| Rating (6.8/10) | 6.8 | ×0.5 | 3.4 |
matchScore = 22.3
formIndex = 0.7 × 22.3 + 0.3 × 18.0 (previous) = 21.0
liveBasePrice = 50 + (21.0 / 25) × 450 = 50 + 378 = 428.00 pts
Layer 2: AMM Imbalance (from trading activity)¶
When users open positions, the net buying/selling pressure shifts the price:
k_mod= price sensitivity constant (default 0.01 per instrument, stored ininstruments.k_mod)netPositionImbalance= (total long lots) - (total short lots)
If more people go long → price goes up. More short → price goes down.
Example¶
liveBasePrice = 428.00
k_mod = 0.01
netPositionImbalance = +15 (15 more long lots than short)
priceShift = 0.01 × 15 = 0.15
preEventPrice = 428.00 + 0.15 = 428.15 pts
Layer 3: microBump (from live events)¶
When a discrete event happens (goal, card, tackle, etc.), an instant % bump is applied:
The bump no longer decays between polls as of 2026-06-11 (MicroBumpDecay = 1.0 in scoring.go:64). Once written to micro_bump the lift stays for the rest of the match, capped at ±30 % of anchor (MicroBumpCapPercentOfBase = 30). Match end (FT) wipes the instrument hash, so the lift does not leak into the next match — only the form-index EWMA carries forward. Only the buy/sell trade channel (permanent_impact, ADR-0017) is allowed to bleed off over time; events and stats are otherwise permanent until FT.
All 35 configured event bumps¶
| Event | Bump | Example on ₹400 player |
|---|---|---|
| goal | +6.0% | +₹24.00 |
| penalty | +6.0% | +₹24.00 |
| assist | +3.0% | +₹12.00 |
| own-goal | -4.0% | -₹16.00 |
| redcard | -4.0% | -₹16.00 |
| error_leading_to_goal | -3.0% | -₹12.00 |
| yellowcard | -1.5% | -₹6.00 |
| save_inside_box | +0.7% | +₹2.80 |
| big_chance_created | +0.6% | +₹2.40 |
| save / shot_on_target | +0.5% | +₹2.00 |
| var / substitution | +0.5% | +₹2.00 |
| key_pass / chance_created | +0.4% | +₹1.60 |
| big_chance_missed | -0.4% | -₹1.60 |
| tackle_won / interception / hit_woodwork | +0.3% | +₹1.20 |
| corner | +0.3% | +₹1.20 |
| clearance / dribble / shot_blocked / shot | +0.2% | +₹0.80 |
| freekick | +0.2% | +₹0.80 |
| tackle / aerial_won | +0.15% | +₹0.60 |
| dispossessed | -0.15% | -₹0.60 |
| foul_drawn / shot_off_target / throw-in | +0.1% | +₹0.40 |
| foul | -0.2% | -₹0.80 |
| offside | -0.1% | -₹0.40 |
| long_ball | +0.05% | +₹0.20 |
| penalty-missed | -5.0% | -₹20.00 |
All bumps are capped at ±30% of base price per instrument to prevent runaway prices (ADR-0035; was ±10% from ADR-0017).
Example: R. Huescas scores a goal¶
Before: currentPrice = 428.15
Event: goal → +6.0% of liveBasePrice (428.00)
Bump = 0.06 × 428.00 = 25.68
microBump (before) = 0.00
microBump (after) = 25.68
currentPrice = 428.00 + 0.15 + 25.68 = 453.83 pts
Layer 4: Backend noise + optional frontend cosmetic jitter¶
After ADR-0021 the primary noise source for live-match players is the backend noise engine (internal/sportmonks/noise.go). With NOISE_DELTA_SKIP_PCT=0 (default as of 2026-06-03) every player whose live_match_until timestamp is in the future publishes a synthetic price:<instrumentId> tick approximately every 2 s via Redis pub/sub — no frontend interpolation needed for chart movement during live matches.
Any residual frontend cosmetic jitter (prices.ts) is secondary and applies only to idle players. It does NOT affect backend prices, trade execution, or wallet calculations.
2. Wallet Calculations¶
Constants¶
CONTRACT_SIZE = 5 (1 lot = 5 shares of exposure — confirmed in margin/types.go and both Lua scripts)
LEVERAGE = 10 (10× leverage — user puts up 1/10th of notional)
2.1 Notional Value¶
The full exposure of a position — what you'd need without leverage.
Example: Buy 0.5 lot of R. Huescas at 428.15¶
2.2 Margin Required (usedMargin)¶
Cash locked by the system to back the leveraged position.
Example¶
This is frozen in the account while the position is open. The user cannot use it for other trades.
2.3 Unrealized PnL¶
Profit or loss on an open position, computed from the live price.
directionSign = +1 for long, -1 for short
unrealizedPnL = (livePrice - openPrice) × lotSize × CONTRACT_SIZE × directionSign
Example: Long at 428.15, live price 453.83¶
Example: Short at 428.15, live price 453.83¶
2.4 Equity¶
The user's real-time net worth.
Example: Balance 10,000, one long position with +64.20 unrealized¶
2.5 Free Margin¶
Cash available to open new positions.
Example¶
If free margin hits zero, the user cannot open new trades. If it goes sufficiently negative, the system triggers a washout (forced liquidation).
2.6 Margin Level¶
Health indicator — how much equity cushion exists relative to locked margin. Displayed as a percentage.
When usedMargin = 0 (no open positions), margin level is displayed as —.
Example¶
| Margin Level | Meaning |
|---|---|
| > 500% | Very safe — plenty of cushion |
| 200–500% | Healthy |
| 100–200% | Caution — watch closely |
| < 100% | Equity < margin — washout risk |
— |
No open positions |
2.7 Realized PnL (on close)¶
When a position is closed, the unrealized PnL becomes realized and is added to the balance.
realizedPnL = (closePrice - openPrice) × lotSize × CONTRACT_SIZE × directionSign
newBalance = oldBalance + realizedPnL
Margin is released (goes back to 0 for that position), and the position moves from "Open" to "Closed" tab.
Example: Close the long at 453.83¶
realizedPnL = (453.83 - 428.15) × 0.5 × 5 × 1 = 64.20 pts
newBalance = 10,000 + 64.20 = 10,064.20 pts
usedMargin = 0 (released)
equity = 10,064.20 (no open positions)
freeMargin = 10,064.20
marginLevel = — (no open positions)
3. Multi-Position Example¶
User starts with balance = 10,000 pts and opens three positions:
| # | Player | Direction | Lot | Open Price | Margin Required |
|---|---|---|---|---|---|
| 1 | E. Cavani | Long | 1.0 | 350.00 | 175.00 |
| 2 | R. Huescas | Short | 0.5 | 429.50 | 107.38 |
| 3 | G. Plata | Long | 0.2 | 280.00 | 28.00 |
Total usedMargin = 175.00 + 107.38 + 28.00 = 310.38 pts
After some time, live prices are: - Cavani: 360.00 (+10) - Huescas: 420.00 (-9.50, good for short) - Plata: 275.00 (-5.00)
Unrealized PnL per position:
Cavani: (360 - 350) × 1.0 × 5 × 1 = +50.00
Huescas: (420 - 429.50) × 0.5 × 5 × -1 = +23.75 (short profits when price drops)
Plata: (275 - 280) × 0.2 × 5 × 1 = -5.00
Total unrealized = +50.00 + 23.75 - 5.00 = +68.75 pts
Wallet state:
Balance = 10,000.00 pts (unchanged until a position closes)
Equity = 10,000 + 68.75 = 10,068.75 pts
Used Margin = 310.38 pts
Free Margin = 10,068.75 - 310.38 = 9,758.38 pts
Margin Level = (10,068.75 / 310.38) × 100 = 3,244.01%
Status: Healthy (3,244% > 100%). The user can still open new positions worth up to 9,758.38 pts of margin.
4. Where Each Calculation Lives in Code¶
| Calculation | Backend (authoritative) | Frontend (display) |
|---|---|---|
| matchScore → liveBasePrice | scoring.go:ComputeMatchScore |
— |
| Event bumps (instant spikes) | scoring.go:EventBump + ticker.go:processEvents |
— |
| Stat-delta bumps | ticker.go:detectStatDeltas |
— |
| AMM price (+ imbalance) | position_open.lua:160 |
— |
| Margin required | position_open.lua:168-170 |
CFDTradeForm.tsx:188 |
| Equity | position_open.lua:260-264 |
auth.ts:recomputeEquityFromPositions |
| Free margin | position_open.lua:265 |
auth.ts:197 / trading.ts:26 |
| Margin level | position_open.lua:266-268 |
auth.ts:198 / trading.ts:27 |
| Realized PnL (on close) | position_close.lua:148-152 |
— |
| Frontend noise (±0.2%) | — | prices.ts (see Layer 4 note) |
The Lua scripts are the source of truth for all trade-time calculations. The frontend recompute (recomputeEquityFromPositions) runs on every price tick (every 500ms with noise) to keep the TopBar wallet display fresh between the 10s server frames. When they diverge, the next server frame (WS portfolio message) snaps the frontend back to the authoritative value.
5. Lot Size Tiers¶
Users pick from three lot-size tiers:
| Tier | Options | Margin at ₹400 player |
|---|---|---|
| Nano | 0.01, 0.02, 0.03, 0.04, 0.05 | ₹2 – ₹10 |
| Mini | 0.10, 0.20, 0.30, 0.40, 0.50 | ₹20 – ₹100 |
| Standard | 1, 2, 3, 4, 5 | ₹200 – ₹1,000 |
Default on modal open: Nano, 0.01 (lowest margin, encourages experimentation).
Starting balance: 10,000 pts. With a Nano 0.01 lot on a ₹400 player, margin is just ₹2 (= 0.01 × 5 × 400 / 10) — the user can open thousands of nano positions before running out of margin.
6. Minimum Hold (ADR-0013, revised 2026-06-11)¶
A 180-second (3-minute) minimum hold is enforced per position from its own open time. position_open.lua records opened_at_ms on the position:{positionId} hash on open (line 363). On any user-initiated close, position_close.lua reads opened_at_ms via HMGET and rejects with cooldown_active when now_ms - opened_at_ms < 180_000. The reject payload includes retry_after_secs (integer ceiling of the remaining window).
This prevents rapid open → close flip-trading on every position independently. Opening new positions is unrestricted.
Per-position isolation (revised 2026-06-11): earlier behavior keyed the lock on last_open:{userId}:{instrumentId} — a single Redis string whose value was overwritten on every fresh open. Opening a second position on the same instrument freed the first one's cooldown as a side effect, so only the most-recently-opened sibling was actually locked. The new implementation sources the lock from each position's own opened_at_ms, so every sibling holds its own 180 s window. The last_open:* external key is retired; KEYS[8] on position_open.lua and KEYS[9] on position_close.lua are unused.
Legacy positions opened before opened_at_ms started being written fall through the gate (the field is nil → skip) and close immediately on a user click — same as the prior best-effort policy for missing state.
System closes (stop-loss, take-profit, washout, full-time auto-exit) bypass the hold entirely via the close_reason == 'user' check — the platform must always be able to liquidate regardless of the minimum-hold state.
7. Player Tiers (S/A/B/C/D)¶
Players are tiered based on team prestige and position:
| Tier | Color | Who | % of players |
|---|---|---|---|
| S | Gold | FWD/MID on elite clubs (Flamengo, Boca, Palmeiras, etc.) | ~9% |
| A | Purple | DEF/GK on elite + FWD/MID on strong clubs | ~20% |
| B | Blue | DEF/GK on strong + FWD/MID on mid clubs | ~23% |
| C | Green | Remaining mid/lower-tier players | ~30% |
| D | Gray | All players on weakest clubs | ~20% |
Tier is stored in the instruments.tier column and displayed as a colored badge on player cards in the Squad page.
8. POST /api/positions/open — Error Codes (ADR-0020)¶
| HTTP | Error body | Cause |
|---|---|---|
| 409 | {"error":"PRICE_MOVED"} |
clientPrice deviates more than 2.5% from last_published_price |
| 409 | {"error":"duplicate request; please retry"} |
Replayed request with no cached fill (ErrDuplicateInFlight) — mint a fresh clientRequestId before retrying |
| 409 | {"error":"position is in an invalid state; contact support"} |
Corrupt position state (ErrCorruptPositionState) |
| 422 | {"error":"stop-loss / take-profit level invalid for the current price"} |
stopLoss or takeProfit value violates direction rules (ErrInvalidSLTP) |
| 422 | instrument inactive / insufficient free margin | Standard validation errors |
Previously PRICE_MOVED and corrupt-state fell through to HTTP 500; SL/TP errors also returned 500. All are now typed status codes.
Slippage tolerance (ADR-0020)¶
The open and close Lua scripts compare clientPrice against last_published_price — the price last broadcast over the WebSocket (what the user actually saw) — falling back to last_noisy_price, then the computed server_price. The default tolerance is 2.5% (env TRADE_SLIPPAGE_TOLERANCE_PCT; previously 0.5%). Comparing against last_published_price prevents spurious PRICE_MOVED rejections when the internal noise engine moves last_noisy_price between publishes. Both fields are fetched in the same instrument HMGET, so no extra Redis round-trips are needed.
On a 409 PRICE_MOVED response the client should mint a fresh clientRequestId before re-quoting — the idempotency key is released on every pre-mutation rejection (via a reject() helper), so the retry re-evaluates cleanly against the new price.
9. Known Issues (Sportmonks Upstream)¶
These are upstream data gaps in the Sportmonks feed, not FTL application bugs:
- Goal and card event feed empty for some fixtures. Sportmonks does not always supply goals and cards for every fixture on the current plan. The live
microBumpfor goal/card events will be absent for affected matches. - Per-player long-ball, aerial, and tackle stats absent for U18 competitions. The Sportmonks plan does not include these stat types for under-18 fixtures. The Layer 1
matchScorewill be computed from whichever stats are available. - Kazakhstan U18 squad returns 0 players. The squad is absent from the Sportmonks plan. Instruments are created but have no squad data — the Stadium screen will show no players for this team.