Skip to content

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.md spec — 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)

  • CFDContract 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 like net_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_price every poll); reset at FT and absence-decay. Cross-match carry comes from the form-index EWMA, not event_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.0 in scoring.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_impact rather 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 over NOISE_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_PCT defaults 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.
  • AMMAutomated Market Maker. The math that decides the displayed price.
  • Luaposition_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

current_price = base_price + k_mod × (net_position_imbalance + permanent_impact)

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 computes live_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 via permanent_impact.

2. Margin required for one open

margin_required = (current_price × lot_size × 5) / 10

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

realized_pnl = (close_price − open_price) × lot_size × 5 × direction_sign

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:

  1. Sets HSET instrument:{id} frozen 1 so opens reject.
  2. Waits ~500 ms for in-flight Lua to drain.
  3. Calls margin.Service.ReplayUsersAtFT (ADR-0006) so any pre-FT washouts realise at their breach prices BEFORE the snapshot is read.
  4. Snapshots current_price (= live_base_price + k_mod × imbalance).
  5. Calls positions.Service.CloseAtFTSnapshot which enumerates every open CFD position on that instrument and closes each at the snapshot price with reason=auto_exit_ft.
  6. Resets the instrument's net_position_imbalance to 0.
  7. 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 via useAuthStore.syncFromCfdPortfolio.
  • user:reconnect — ws-server PUBLISHes the userId on every 0→1 connection transition. api-server subscribes (runMarginReconnectListener) and triggers margin.Service.ReplayLastSeen so 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, computes margin_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) — walks price_ticks in [last_seen, now] for the user's open-position instruments. Recomputes margin_level at each tick; returns the first breach as a WashoutStep carrying the breach price as ClosePrice (passed as OverridePrice to position_close.lua).
  • Backstop sweeper — every 5 minutes, api-server (lease-singleton via lease: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 positions table; the implementation uses cfd_positions so the migration stays additive (the legacy positions table stays in place during rollout). The post-cutover cleanup PR drops the legacy table and renames cfd_positionspositions. Until then, all code and SQL references cfd_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_id redistribution, and the score_event_points.event_ripple_matrices admin 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.