Skip to content

spec: amm-pricing status: active last-updated: 2026-05-24 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/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 decisions: [0002, 0004, 0005, 0006]


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 ../operations/runbooks.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 100 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 × 100 × 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 = 100 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 × 100) / 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.
  • k_mod — slope of the hybrid pricing curve (default 0.01). 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

Where: - base_price is the player's stored intrinsic price (live_base_price during a match). - k_mod defaults to 0.01 — small enough that a single Nano position barely moves the price, large enough that a herd of Standard longs (or shorts) shows up on the chart. - net_position_imbalance is integer (sum of lot×100 with sign for direction).

A long open adds +lot × 100 to the imbalance; a short open adds -lot × 100. Closes do the opposite — so over a full lifecycle the instrument's imbalance returns to 0.

2. Margin required for one open

margin_required = (current_price × lot_size × 100) / 10

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 × 100 × 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.

REST surface

Method Path Body Returns Notes
POST /api/positions/open {instrumentId, direction, lotSize, stopLoss?, takeProfit?, clientRequestId?} CfdOpenResult Trade-window-gated; cooldown 180 s per user×instrument.
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 100 shares per 1.0 lot both Lua + margin.ContractSize
LEVERAGE 10 both Lua + margin.Leverage
COOLDOWN_SECS 180 (3 min per user×instrument open) position_open.lua
MarginCallThreshold 100% margin/types.go
WashoutThreshold 50% margin/types.go
k_mod (default) 0.01 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

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.