Skip to content

Trade Execution

AMM pricing formula

FTL uses a linear AMM (Automated Market Maker). The price of each share depends on how many shares have already been sold net.

current_price = base_price + k × net_shares_sold

BUY cost for qty shares:

total_cost = Σ(i=1..qty) [base_price + k × (net_shares_sold + i)]
fill_price = total_cost / qty

SELL proceeds for qty shares:

total_proceeds = Σ(i=1..qty) [base_price + k × (net_shares_sold - i)]
fill_price = total_proceeds / qty

All intermediate values are rounded to 2 decimal places inside the Lua script using math.floor(x * 100 + 0.5) / 100.

Decision: Redis Lua scripts over a message queue — single-threaded execution, zero lock contention. Each script runs atomically inside Redis, so balance, position, and leaderboard state are always consistent.

trade_execute.lua

File: internal/redis/lua/trade_execute.lua

Handles legacy BUY/SELL. Called by trade.Service.Execute.

Execution steps:

  1. Input validation — quantity > 0, limit_price > 0, side is BUY or SELL. Runs before idempotency so a malformed request does not burn an idempotency key.
  2. Idempotency guardSETNX idem:{userId}:{clientRequestId} 1 EX 86400. If the key is already claimed, return the cached result from idem_result:{userId}:{clientRequestId} with replayed:true. Replayed responses include the exact same payload the original caller received; the Go handler skips XP, notifications, and activity feed on replays.
  3. Instrument state — reads base_price, live_base_price (preferred during matches), k, net_shares_sold, is_active, frozen. Returns instrument_frozen error if frozen=1.
  4. Balance read — reads wallet:{userId}.balance. Default seed 10,000 if missing.
  5. Slippage check — BUY: fill_price > limit_price → reject. SELL: fill_price < limit_price → reject.
  6. Balance check (BUY only) — balance < total_cost → reject.
  7. State writes — updates wallet hash, position hash, instrument net_shares_sold.
  8. Leaderboard update — maintains user_pnl_by_inst:{userId} hash (per-instrument unrealized PnL). Computes delta between old and new value, ZINCRBY on leaderboard:overall and leaderboard:district:{districtId}.
  9. ROI tracking (SELL only) — computes roi_pct = realized_pnl / cost_basis × 100, ZINCRBY on leaderboard:roi:overall and leaderboard:roi:district:{districtId}. Updates user:{userId}:trade_stats hash (invested, realized_pnl, wins, sells).
  10. Pub/subPUBLISH price:{instrumentId} with new price tick. PUBLISH portfolio:{userId} with updated balance and unrealized PnL sum.
  11. Dirty setsSADD dirty:instruments, dirty:wallets, dirty:positions for the flusher.
  12. Cache response — stores the JSON result at idem_result:... EX 86400 before returning.

CFD position lifecycle

File: internal/redis/lua/position_open.lua, position_close.lua
Service: internal/positions/

Opening a position

Client: POST /api/positions/open with Idempotency-Key header.

  1. Trade window check — handler reads match state; rejects if match has not started or has already ended.
  2. position_open.lua — atomically:
  3. Validates direction (long/short), lot size (max 100), SL/TP range.
  4. Checks clientPrice against last_published_price (the price broadcast over the WebSocket), falling back to last_noisy_priceserver_price when unset. Tolerance is slippageTolerancePct (default 2.5%, env TRADE_SLIPPAGE_TOLERANCE_PCT). Both fields are pre-fetched in the same HMGET call as the rest of the instrument state — no extra round-trip. Returns PRICE_MOVED if breached.
  5. Computes equity (balance + unrealized PnL across all open positions by iterating positions:{userId} sorted set).
  6. Computes margin_required = (current_price × lot_size × CONTRACT_SIZE) / LEVERAGE (CONTRACT_SIZE=5, LEVERAGE=10).
  7. Rejects if free_margin < margin_required.
  8. Writes position:{positionId} hash with direction, lot_size, open_price, opened_at_ms, optional stop_loss, take_profit. opened_at_ms is the source of truth for the close cooldown (see below).
  9. ZADD positions:{userId} <nowMs> <positionId> (sorted by open time).
  10. last_open:{userId}:{instrumentId} is no longer written (retired 2026-06-11). KEYS[8] is unused; per-position cooldown is enforced inside position_close.lua from the position hash's own opened_at_ms.
  11. Publishes price:{instrumentId} and portfolio:{userId}.
  12. Marks dirty sets.
  13. Idempotency key released on rejection — a reject() helper DELs the freshly-claimed idem key before returning any pre-mutation rejection (PRICE_MOVED, SL/TP invalid, insufficient margin, wallet missing). The idem key persists only on a successful open (cache_and_return). A retry after a PRICE_MOVED therefore re-evaluates from scratch rather than hitting the burned-key {status:"duplicate"} path (ADR-0020).

Error → HTTP status mapping

Error HTTP status Message
PRICE_MOVED 409 "PRICE_MOVED"
ErrDuplicateInFlight (replayed/duplicate response with no cached fill) 409 "duplicate request; please retry"
ErrCorruptPositionState 409 "position is in an invalid state; contact support"
ErrInvalidSLTP (invalid stop_loss or take_profit direction against current price) 422 "stop-loss / take-profit level invalid for the current price"
ErrCooldownActive 429 "cooldown active"
ErrInstrumentFrozen 423 "instrument frozen"

Closing a position

Client: POST /api/positions/:id/close or triggered by StopWatch / margin sweep / FT.

position_close.lua mirrors the open script: checks the per-position 180s cooldown sourced from opened_at_ms on the position hash (HMGET pulls it alongside user_id, instrument_id, direction, lot_size, open_price, closed_at_ms), rejects with cooldown_active + retry_after_secs when now_ms - opened_at_ms < 180_000, computes realized P&L, updates net_position_imbalance, publishes price and portfolio, marks dirty sets, writes close reason and closed_at to the position hash. KEYS[9] is unused — the old last_open:{user}:{instr} external key was retired 2026-06-11 because its overwrite-on-open shape released older siblings' cooldowns as a side effect. System reasons (stop_loss, take_profit, washout, auto_exit_ft) bypass the cooldown gate via the close_reason == 'user' check.

Post-close equity-snapshot is best-effort. After the close commits (wallet credited, position stamped closed, imbalance updated), position_close.lua recomputes used_margin and unrealized PnL across all remaining open positions to publish a portfolio snapshot. If a sibling instrument's hash has been evicted from Redis, that position is skipped from the snapshot rather than aborting an already-committed close. The wallet credit and position state are durable before this loop runs — a sibling eviction can only affect the snapshot payload, not the close itself. The next portfolio fetch corrects the numbers (ADR-0020 §6).

StopWatch SL/TP enforcement

positions.NewStopWatch in internal/positions/stopwatch.go. Starts in api-server/main.go.

  • Subscribes to price:* Redis pub/sub.
  • On each tick, calls position_close.lua for any open position whose stop_loss or take_profit is breached.
  • Uses Redis lease lease:stop-watcher so only one api-server replica runs the watcher.

Margin washout (ADR-0006)

If a user is offline (WebSocket disconnected), price ticks can breach a position's margin without the StopWatch seeing a reconnect. Three recovery paths:

  1. Reconnect hook — ws-server publishes user:reconnect <userId>. runMarginReconnectListener in api-server calls margin.ReplayLastSeen(ctx, uid) which replays ticks stored in the price_ticks Redis list since last_seen:{userId}, closing any positions that breached at the breach price.
  2. 5-min backstop sweeperrunMarginBackstopSweeper queries all users with open positions every 5 min and calls ReplayLastSeen for each.
  3. Pre-FT replayscheduler.SetMarginReplayer fires marginSvc.ReplayUsersAtFT before the FT snapshot close-out, so mid-match washouts are settled at their breach prices before the FT price is used.

Outbox pattern

If the synchronous Postgres INSERT for a trade or position fails after the Lua script has already settled the state in Redis:

  • trade.Service.Execute writes a row to trade_outbox.
  • positions.Service.Open/Close writes to position_outbox.
  • flusher.OutboxDrainer and flusher.PositionOutboxDrainer retry these rows every 5s until they succeed.

This guarantees durability even under transient Postgres unavailability.

Known issues (Sportmonks upstream)

The following are upstream data limitations, not backend bugs:

  • Goal and card event feed empty for some fixtures. Sportmonks does not supply goals and cards for every fixture on the current plan. The event list may be populated in Redis but contain no goal/card entries for affected fixtures.
  • Per-player long-ball, aerial, and tackle stats absent for U18 competitions. These stat types are not available for under-18 fixtures on the current Sportmonks plan. The aggregateTeamStats path omits these keys for U18 matches.
  • Kazakhstan U18 squad returns 0 players. The squad is absent from Sportmonks coverage. Instruments are created but have no squad data; the Stadium screen will be blank for these players.