spec: cfd-pricing-and-wallet status: active last-updated: 2026-05-27 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]
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.1 per instrument)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.1
netPositionImbalance = +15 (15 more long lots than short)
priceShift = 0.1 × 15 = 1.50
preEventPrice = 428.00 + 1.50 = 429.50 pts
Layer 3: microBump (from live events)¶
When a discrete event happens (goal, card, tackle, etc.), an instant % bump is applied:
The bump decays over subsequent ticker polls (EWMA decay) so it doesn't permanently distort the price.
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 ±10% of base price per instrument to prevent runaway prices.
Example: R. Huescas scores a goal¶
Before: currentPrice = 429.50
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 + 1.50 + 25.68 = 455.18 pts
Layer 4: Frontend noise (display only)¶
Between the 10-second backend ticks, the frontend adds ±0.2% random micro-fluctuations every 500ms to make the UI feel alive. These do NOT affect the backend price, trade execution, or wallet calculations. They're purely cosmetic.
2. Wallet Calculations¶
Constants¶
CONTRACT_SIZE = 100 (1 lot = 100 units of the player)
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 429.50¶
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 429.50, live price 455.18¶
Example: Short at 429.50, live price 455.18¶
2.4 Equity¶
The user's real-time net worth.
Example: Balance 10,000, one long position with +1,284 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 455.18¶
realizedPnL = (455.18 - 429.50) × 0.5 × 100 × 1 = 1,284.00 pts
newBalance = 10,000 + 1,284.00 = 11,284.00 pts
usedMargin = 0 (released)
equity = 11,284.00 (no open positions)
freeMargin = 11,284.00
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 | 3,500.00 |
| 2 | R. Huescas | Short | 0.5 | 429.50 | 2,147.50 |
| 3 | G. Plata | Long | 0.2 | 280.00 | 560.00 |
Total usedMargin = 3,500 + 2,147.50 + 560 = 6,207.50 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 × 100 × 1 = +1,000.00
Huescas: (420 - 429.50) × 0.5 × 100 × -1 = +475.00 (short profits when price drops)
Plata: (275 - 280) × 0.2 × 100 × 1 = -100.00
Total unrealized = +1,000 + 475 - 100 = +1,375.00 pts
Wallet state:
Balance = 10,000.00 pts (unchanged until a position closes)
Equity = 10,000 + 1,375 = 11,375.00 pts
Used Margin = 6,207.50 pts
Free Margin = 11,375 - 6,207.50 = 5,167.50 pts
Margin Level = (11,375 / 6,207.50) × 100 = 183.25%
Status: Healthy (183% > 100%). The user can still open new positions worth up to 5,167.50 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:startNoise |
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 | ₹40 – ₹200 |
| Mini | 0.10, 0.20, 0.30, 0.40, 0.50 | ₹400 – ₹2,000 |
| Standard | 1, 2, 3, 4, 5 | ₹4,000 – ₹20,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 ₹40 — the user can open 250 nano positions before running out of margin.
6. Cooldown¶
A 180-second (3 minute) per-instrument cooldown fires after a user closes a position. The user cannot close another position on the same instrument during the cooldown window. Opening new positions has no cooldown.
System closes (stop-loss, take-profit, washout, full-time auto-exit) bypass the cooldown entirely — the platform must always be able to liquidate regardless of user cooldowns.
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.