Skip to content

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:

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.01 per instrument, stored in instruments.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:

bump = eventBumpPercent × liveBasePrice / 100

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.

displayPrice = realPrice × (1 + random(-0.002, +0.002))   -- cosmetic only; idle players

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.

notional = openPrice × lotSize × CONTRACT_SIZE

Example: Buy 0.5 lot of R. Huescas at 428.15

notional = 428.15 × 0.5 × 5 = 1,070.38 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 = 428.15 × 0.5 × 5 / 10 = 107.04 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 428.15, live price 453.83

unrealizedPnL = (453.83 - 428.15) × 0.5 × 5 × (+1)
              = 25.68 × 2.5
              = 64.20 pts profit

Example: Short at 428.15, live price 453.83

unrealizedPnL = (453.83 - 428.15) × 0.5 × 5 × (-1)
              = 25.68 × 2.5 × (-1)
              = -64.20 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 +64.20 unrealized

equity = 10,000 + 64.20 = 10,064.20 pts

2.5 Free Margin

Cash available to open new positions.

freeMargin = equity - usedMargin

Example

freeMargin = 10,064.20 - 107.04 = 9,957.16 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 = (10,064.20 / 107.04) × 100 = 9,402.28%
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 microBump for 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 matchScore will 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.