spec: amm-pricing status: active last-updated: 2026-06-11 owners: [@amalkrsihna] code-refs: - ftl-backend/internal/redis/lua/position_open.lua - ftl-backend/internal/redis/lua/position_close.lua - ftl-backend/internal/positions/service.go - ftl-backend/internal/positions/handlers.go - ftl-backend/internal/positions/stopwatch.go - ftl-backend/internal/margin/service.go - ftl-backend/internal/margin/evaluator.go - ftl-backend/internal/margin/replay.go - ftl-backend/internal/sportmonks/postmatch.go - ftl-backend/internal/sportmonks/scoring.go - ftl-backend/internal/sportmonks/events_feed.go - ftl-backend/migrations/000024_create_cfd_positions.up.sql - ftl-backend/migrations/000025_create_margin_events.up.sql - ftl-backend/migrations/000026_add_position_imbalance_and_kmod.up.sql - ftl-backend/migrations/000040_normalize_and_extend_event_bumps.up.sql decisions: [0002, 0004, 0005, 0006, 0017, 0018, 0019, 0020, 0021, 0022, 0031, 0035]
AMM Pricing — Living Spec¶
This is the canonical description of how FTL prices player contracts. The model is CFD-style margin trading (ADR-0004). Bidirectional leveraged positions, 1:10 leverage, hybrid pricing curve, automatic FT close-out at the snapshot price, and backend- authoritative washouts on margin breach.
Margin / equity / free-margin definitions live in the paired
wallet-and-margin.mdspec — read that first if you're new.This spec supersedes the buy-and-hold model described by ADR-0001 + ADR-0003 (both superseded). The cutover runbook is in
../../guides/CFD-CUTOVER.md.
TL;DR for an intern¶
A player's price moves based on how many people are betting it'll go UP vs DOWN. If 100 lots are long and 60 are short, the price ticks slightly above its "fair" base because there's net upward pressure. Users open positions — picking long (you win when price rises) or short (you win when price falls) — with leverage: a 1.0-lot position controls 5 shares of exposure but only locks 10% of that as margin. When you close (or the system closes you), your profit or loss is the price move × lot size × 5 × your direction.
Glossary (defined before first use)¶
- CFD — Contract for Difference. A leveraged bet on a price moving in either direction; the trader never actually owns the underlying.
- Long / Short — direction. Long profits when price rises, short profits when it falls.
- Lot size — exposure unit. 1.0 lot = 5 shares of notional exposure. Catalog is Nano (0.01–0.10), Micro (0.25–0.50), Standard (1.0–5.0).
- Leverage — system-wide 1:10. Margin required is (current_price × lot × 5) / 10.
- base_price — the player's intrinsic price from form/rating. Recomputed after each match.
- live_base_price — separately tracked base used during a live match so in-match events
(goal, assist, card) move the price without overwriting the stored
base_price. - net_position_imbalance — running counter on each instrument:
Σ long shares − Σ short shares. Positive = net long pressure → price > base. Negative = net short → price < base. Reflects only currently open interest — a close removes its open's contribution. - permanent_impact — lasting, slowly-decaying footprint of closed trades (ADR-0017). A close
retains a fraction (
PERM_FRACTION = 0.30) of its directional pressure here so the chart keeps a memory of the trade after the position unwinds. Enters the price exactly likenet_position_imbalance. Decayed toward 0 by the flusher (~2.3 h half-life); reset to 0 at FT. - event_score — durable, within-match contribution of match events (ADR-0019). On a goal, the
conceding side's active instruments drop (−2 % outfield, −3 % GK) and — when the scorer can't be
resolved to a specific instrument — the whole scoring team lifts (+1.5 %). The durable part lands
here (folded into
live_base_priceevery poll); reset at FT and absence-decay. Cross-match carry comes from the form-index EWMA, notevent_score. - micro_bump — transient (intra-match) accumulator written to the instrument hash on every
discrete event bump and stat-delta. Does not decay between polls as of 2026-06-11
(
MicroBumpDecay = 1.0inscoring.go:64) — operator decision that events and stats must persist for the rest of the match. Only the buy/sell trade channel (permanent_impact, ADR-0017) is allowed to bleed off over time. Capped at ±30 % of anchor (MicroBumpCapPercentOfBase = 30) and wiped at FT alongside the rest of the instrument hash. - noise — server-side synthetic ticks that keep the chart moving between real Sportmonks ticks.
Pattern-based (each instrument follows a randomly-chosen trading pattern — trend/breakout/
consolidation/…) per ADR-0018. The noise rides as a fractional offset on the AMM-shifted
price, so it composes with trades +
permanent_impactrather than replacing them. The offset is a mean-reverting random walk (ADR-0021): it carries across pattern windows so a player can trend for 1–2 min, but relaxes toward 0 overNOISE_MEANREVERT_MINUTES(default 10) so the pure-noise component nets ~0 long-term while events/trades persist. Bounded to ±8 %. Players in a live match (live_match_until, set by the ticker each poll) tick every cycle for a near-real-time feel; idle non-match players stay activity-gated (no fan-out).NOISE_DELTA_SKIP_PCTdefaults to 0 so every changed tick publishes. - k_mod — slope of the hybrid pricing curve (default 0.01 since ADR-0035; was 0.05 from ADR-0017). Stored on each instrument.
- AMM — Automated Market Maker. The math that decides the displayed price.
- Lua —
position_open.lua/position_close.lua— atomic Redis scripts that do all the in-memory mutations under a single Redis lock so concurrent traders never see a torn read. - Washout — system force-close of one (or more) of your positions when your account-wide
margin level falls below 50%. See
wallet-and-margin.md. - FT — Full Time. The moment a match's official state transitions to a completed state.
- portfolio:{userId} — Redis pub/sub channel the Lua publishes on after every state- changing event so the frontend's BalancePill + PositionsPage stay in sync.
Formula / algorithm¶
1. Hybrid pricing curve¶
Where:
- base_price is the player's stored intrinsic price (live_base_price during a match).
- k_mod defaults to 0.01 (ADR-0035; was 0.05 from ADR-0017) — small enough that a single lot
moves the price by only ~₹0.05 on a ₹100 instrument, so the event/stat channel (live_base_price)
dominates user-trade-driven movement. The event ceiling (MicroBumpCapPercentOfBase) was widened
to 30% of base in the same change (ADR-0035).
- net_position_imbalance is integer (sum of lot×5 with sign for direction).
- permanent_impact is the decaying footprint of closed trades (ADR-0017).
A long open adds +lot × 5 to the imbalance; a short open adds -lot × 5. Closes do the
opposite — so the live imbalance returns to 0 over a lifecycle. But the close also retains
PERM_FRACTION = 0.30 of its directional pressure in permanent_impact, so the price does
not snap fully back to base: a buy→close round-trip leaves the price elevated by
k_mod × lot_shares × 0.30, fading over hours. This is what gives the chart a memory of demand
(ADR-0017). A floor on permanent_impact prevents a heavy net-short from driving price ≤ 0.
The live ticker uses this same formula (2026-06-09 fix).
processPlayerStats(internal/sportmonks/ticker.go) republishes each player's price every ~10s poll. It now computeslive_base_price + k_mod × (net_position_imbalance + permanent_impact)— identical to the Lua above. Previously it used the legacy buy-and-hold AMM (live_base_price + k × net_shares_sold), which ignored the CFD imbalance and so erased a trade's price impact on the next poll (the "trade spike snaps back instantly" bug, plus a 5M-shows-spike / 6H-flat chart inconsistency). With the formula aligned, an open position's impact persists across polls and only eases off after close viapermanent_impact.
2. Margin required for one open¶
CONTRACT_SIZE = 5 (units per 1.0 lot). [Set to 5 in code; the earlier spec value of 100 was never shipped — corrected 2026-06-02.]
The Lua walks the user's existing open positions (positions:{userId} sorted set), totals
their used margin and unrealized PnL into equity, computes free_margin = equity − used,
and rejects the open if free_margin < margin_required. The full equity walk is documented
in wallet-and-margin.md.
3. Realized PnL on close¶
Where direction_sign = +1 for long, −1 for short. The user's wallet balance is credited
by exactly realized_pnl (positive or negative).
4. FT auto-exit (ADR-0005)¶
When a fixture transitions to a completed Sportmonks state, the post-match processor:
- Sets
HSET instrument:{id} frozen 1so opens reject. - Waits ~500 ms for in-flight Lua to drain.
- Calls
margin.Service.ReplayUsersAtFT(ADR-0006) so any pre-FT washouts realise at their breach prices BEFORE the snapshot is read. - Snapshots
current_price(=live_base_price + k_mod × imbalance). - Calls
positions.Service.CloseAtFTSnapshotwhich enumerates every open CFD position on that instrument and closes each at the snapshot price withreason=auto_exit_ft. - Resets the instrument's
net_position_imbalanceto 0. - Unfreezes the instrument ready for the next match.
The legacy buy-and-hold auto-seller (trade.Service.ExecuteSystemSell) runs alongside this
during the rollout so any pre-cutover share positions still get liquidated. The cleanup PR
post-cutover deletes that path.
5. Stop-loss / take-profit (Phase 6 of the CFD redesign)¶
positions.StopWatch is an in-process goroutine in api-server, single-instance via the
Redis lease lease:stop-watcher. It tails price:* pub/sub, maintains a 5-second-refreshed
cache of all open positions with non-null stop_loss or take_profit, and fires
position_close.lua with reason={stop_loss|take_profit} when the trigger price is crossed.
The clientRequestId is bucketed to 1-second windows (sl:<positionId>:<bucket>) so tick
bursts at the trigger price coalesce in the Lua's idempotency cache.
6. Idempotency¶
Every Lua call uses the same idempotency pattern as the legacy trade_execute.lua (ADR-0002):
SET idem:<clientRequestId> NX EX 86400 claims the slot; cached responses are replayed via
idem_result:<clientRequestId>. The Go-side persistence layer also catches duplicates via
the partial-unique index on cfd_positions.client_request_id.
A claimed key is released (DEL) on any rejection that occurs before the first durable
mutation — slippage (PRICE_MOVED), invalid SL/TP, insufficient margin, missing wallet,
etc. (the reject() helper in position_open.lua / position_close.lua). The key therefore
persists only for a successful trade, so a retry after a rejection re-evaluates cleanly
instead of replaying an empty {status:"duplicate"} marker (which previously surfaced as a
500). The success-replay short-circuit still runs first, so a completed trade's retry returns
its cached fill and is never re-evaluated for slippage. See ADR-0020.
7. Slippage (trade integrity)¶
A user-supplied clientPrice (the price shown in the UI at click-time) is validated against
the server's last published price (last_published_price — the value broadcast over the
WS, i.e. what the user saw), falling back to last_noisy_price → the live server_price when
unset. If |ref − clientPrice| / clientPrice exceeds the tolerance
(TRADE_SLIPPAGE_TOLERANCE_PCT, default 2.5%) the trade is rejected with PRICE_MOVED and
the client re-quotes; otherwise the fill is at clientPrice so the wallet debit/credit always
matches the price the user agreed to. Comparing against the published price (not the internal
last_noisy_price, which the noise generator moves between publishes) avoids rejecting trades
against a price the user never saw. See ADR-0020.
REST surface¶
| Method | Path | Body | Returns | Notes |
|---|---|---|---|---|
| POST | /api/positions/open |
{instrumentId, direction, lotSize, stopLoss?, takeProfit?, clientRequestId?} |
CfdOpenResult |
Trade-window-gated; 180 s minimum hold set on open per user×instrument (see ADR-0013). |
| GET | /api/positions?status=open\|closed&limit&offset |
— | {positions, count} |
Default status=open. |
| PATCH | /api/positions/:id |
{stopLoss?, takeProfit?} |
{status:"ok"} |
Pass null to clear a level. |
| POST | /api/positions/:id/close |
{clientRequestId?} |
CfdCloseResult |
HTTP callers always close with reason=user (server-enforced). |
Legacy POST /api/trade stays mounted during rollout — it routes through the unchanged
buy-and-hold service.
Pub/sub channels¶
price:{instrumentId}— published by both Lua scripts after a mutation. Wire format:{instrumentId, price, basePrice, netImbalance, source}. ws-server's Subscriber translates UUID→idx and broadcasts compact msgpack to subscribed clients.portfolio:{userId}— published by both Lua scripts (and the margin evaluator) with{kind:'portfolio', balance, equity, usedMargin, freeMargin, marginLevel, lastEvent, positionId?, instrumentId?, realizedPnl?, closedBy?}. Forwarded unchanged as a JSON text frame to that user's connected sockets. The FE auth store ingests it viauseAuthStore.syncFromCfdPortfolio.user:reconnect— ws-server PUBLISHes the userId on every 0→1 connection transition.api-serversubscribes (runMarginReconnectListener) and triggersmargin.Service.ReplayLastSeenso offline-window washouts realise at their breach prices.
Margin evaluator & honest washouts (ADR-0006)¶
internal/margin/ owns the policy:
- LIVE path (
EvaluateAndApply) — reads current Redis state for a user, computesmargin_level, and cascades washouts (largest-losing first) until level > 50% or no positions remain. Margin call notification fires once between 50%–100% with a 30-minute per-user Redis cooldown. - OFFLINE path (
ReplayWindow,ReplayLastSeen,ReplayUsersAtFT) — walksprice_ticksin[last_seen, now]for the user's open-position instruments. Recomputes margin_level at each tick; returns the first breach as aWashoutStepcarrying the breach price asClosePrice(passed asOverridePricetoposition_close.lua). - Backstop sweeper — every 5 minutes,
api-server(lease-singleton vialease:margin-backstop) finds every user with any open CFD position and runs the offline replay against them. Catches stranded users (browser closed, airplane mode). - Audit — every applied washout writes a row to
margin_events(event_type=washout|margin_call|auto_exit_ft).
Schema¶
cfd_positions (migration 000024):
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
instrument_id UUID NOT NULL,
direction TEXT CHECK (direction IN ('long','short')),
lot_size NUMERIC(10,4) NOT NULL,
open_price NUMERIC(12,2) NOT NULL,
opened_at TIMESTAMPTZ DEFAULT NOW(),
closed_at TIMESTAMPTZ,
close_price NUMERIC(12,2),
realized_pnl NUMERIC(14,2),
stop_loss NUMERIC(12,2),
take_profit NUMERIC(12,2),
closed_by TEXT,
client_request_id TEXT,
-- partial unique index on client_request_id for idempotency
margin_events (migration 000025): audit rows with event_type, equity, margin_level,
position_id, details (JSONB). One row per washout, margin_call, and FT auto-exit.
instruments (migration 000026, additive): adds net_position_imbalance INTEGER (backfilled
from legacy net_shares_sold) and k_mod NUMERIC(10,6) DEFAULT 0.01.
Note on table naming. ADR-0004 specifies a
positionstable; the implementation usescfd_positionsso the migration stays additive (the legacypositionstable stays in place during rollout). The post-cutover cleanup PR drops the legacy table and renamescfd_positions→positions. Until then, all code and SQL referencescfd_positions.
Constants¶
| Constant | Value | Where it lives |
|---|---|---|
CONTRACT_SIZE |
5 (units per 1.0 lot) — [set to 5 in code; the earlier spec value of 100 was never shipped — corrected 2026-06-02] | both Lua + margin.ContractSize |
LEVERAGE |
10 | both Lua + margin.Leverage |
COOLDOWN_SECS |
180 (3 min minimum hold per user×instrument, set on OPEN) | position_open.lua |
MarginCallThreshold |
100% | margin/types.go |
WashoutThreshold |
50% | margin/types.go |
k_mod (default) |
0.01 (ADR-0035; was 0.05 from ADR-0017) | DB column default + Lua fallback |
| Margin-call notification cooldown | 30 min | margin.Service.LogMarginCall |
| Backstop sweeper interval | 5 min | cmd/api-server/main.go runMarginBackstopSweeper |
Event-bump table¶
Each Sportmonks event the live ticker observes is mapped to a signed percentage
of the player's anchor price. The percentage is applied as
delta = (rawBump / 100.0) * anchor (scoring.go:ComputeEventBumpAndCap), then
accumulated into micro_bump and event_score and folded into
live_base_price. Positive rows push the price up; negative rows push it
down — see ADR-0022.
Ripple (ADR-0031). As of 2026-06-09 the actor's bump (below) is only the primary move. Every event AND every marker-eligible stat increment also ripples across both teams by role (teammates same direction, opponents opposite; opposing goalkeeper hit hardest on a goal). The 15 stat-delta types that previously had no bump now have one too. The full role-weighted matrices, the missing-
player_idredistribution, and thescore_event_points.event_ripple_matricesadmin overlay are documented in the Pricing Ripple Engine spec.
Keys are stored in the canonical underscored form normalizeEventType
produces (events_feed.go:43-62). EventBump normalises any input — raw
Sportmonks names ("Yellow Card", "Own Goal") as well as compact variants
("yellowcard", "owngoal") — through the same helper before lookup, so the
caller never has to canonicalise.
| Event type | Default bump (% of anchor) | Notes |
|---|---|---|
goal |
+6.0 % | Direct scorer; team-fallback / opponent-drop are separate (ADR-0019) |
penalty |
+6.0 % | Direct taker, same magnitude as open-play goal |
own_goal |
-4.0 % | Negative impact on the own-goaler |
assist |
+3.0 % | Secondary helper lift |
red_card |
-4.0 % | Ejection — high penalty |
yellow_card |
-1.5 % | Caution |
penalty_missed |
-5.0 % | Wasted chance |
substitution |
+0.5 % | Low-signal, neutral-ish |
corner |
+0.3 % | Set play (attacker side) |
free_kick |
+0.2 % | Set play |
throw_in |
+0.1 % | Minimal — restart |
foul |
-0.2 % | Discipline / tactical |
offside |
-0.1 % | Attack-neutral negative |
var |
+0.5 % | Intervention marker |
foul_drawn |
+0.15 % | Defensive value created (stat-delta) |
shot_blocked |
+0.10 % | Defensive action (stat-delta) |
dispossessed |
-0.10 % | Possession lost (stat-delta) |
long_ball |
+0.05 % | Chance-creation attempt (stat-delta) |
error_leading_to_goal |
-3.00 % | Direct concession responsibility (stat-delta) |
save |
+1.50 % | Keeper-specific positive (stats path) |
Defaults live in ftl-backend/internal/sportmonks/scoring.go:defaultEventBumps.
Admins override (or extend) via the score_event_points.event_bumps JSON on
admin_settings; SetEventBumps is an overlay so an omitted key keeps its
default. Migration 40 ships the canonical keyspace to existing deployments.
After the events path applies a bump, the accumulator is symmetrically clamped
to PercentOfBaseCap(anchor) = (MicroBumpCapPercentOfBase / 100) × anchor so
runaway moves can't pin a single player to the cap on a hot match.
MicroBumpCapPercentOfBase is 30.0 (ADR-0035; was 10.0), giving ±₹30 headroom on a
₹100 base. This was raised in the same change that lowered k_mod 0.05 → 0.01 to keep
the event channel dominant as the primary price signal.
What this spec replaces¶
The buy-and-hold AMM (price = base + k × net_shares_sold) lived under ADR-0001 + ADR-0003.
Those ADRs are now status: superseded and point forward to ADR-0004 / ADR-0005. The
related internal/amm/, internal/instrument/pricing.go, and internal/trade/service.go
files still exist for the legacy /api/trade endpoint during rollout but are slated for
deletion in the post-cutover cleanup PR.