Skip to content

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:

currentPrice = liveBasePrice + (k_mod × netPositionImbalance) + microBump

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:

matchScore = Σ(stat × positionWeight)

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:

priceShift = k_mod × netPositionImbalance
  • 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:

bump = eventBumpPercent × liveBasePrice / 100

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.

displayPrice = realPrice × (1 + random(-0.002, +0.002))

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.

notional = openPrice × lotSize × CONTRACT_SIZE

Example: Buy 0.5 lot of R. Huescas at 429.50

notional = 429.50 × 0.5 × 100 = 21,475.00 pts

2.2 Margin Required (usedMargin)

Cash locked by the system to back the leveraged position.

marginRequired = notional / LEVERAGE
               = openPrice × lotSize × CONTRACT_SIZE / LEVERAGE

Example

marginRequired = 429.50 × 0.5 × 100 / 10 = 2,147.50 pts

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

unrealizedPnL = (455.18 - 429.50) × 0.5 × 100 × (+1)
              = 25.68 × 50
              = 1,284.00 pts profit

Example: Short at 429.50, live price 455.18

unrealizedPnL = (455.18 - 429.50) × 0.5 × 100 × (-1)
              = 25.68 × 50 × (-1)
              = -1,284.00 pts loss

2.4 Equity

The user's real-time net worth.

equity = balance + Σ(unrealizedPnL for all open positions)

Example: Balance 10,000, one long position with +1,284 unrealized

equity = 10,000 + 1,284.00 = 11,284.00 pts

2.5 Free Margin

Cash available to open new positions.

freeMargin = equity - usedMargin

Example

freeMargin = 11,284.00 - 2,147.50 = 9,136.50 pts

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.

marginLevel = (equity / usedMargin) × 100

When usedMargin = 0 (no open positions), margin level is displayed as .

Example

marginLevel = (11,284.00 / 2,147.50) × 100 = 525.30%
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.