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_noisy_price with slippageTolerancePct tolerance. 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.
  9. ZADD positions:{userId} <nowMs> <positionId> (sorted by open time).
  10. Sets last_open:{userId}:{instrumentId} with 180s TTL (minimum hold gate for closes).
  11. Publishes price:{instrumentId} and portfolio:{userId}.
  12. Marks dirty sets.

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 180s cooldown, 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.

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.