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.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
../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)¶
- 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 = 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.
- 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 — 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¶
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.
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 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 |
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.