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.
BUY cost for qty shares:
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:
- Input validation — quantity > 0, limit_price > 0, side is
BUYorSELL. Runs before idempotency so a malformed request does not burn an idempotency key. - Idempotency guard —
SETNX idem:{userId}:{clientRequestId} 1 EX 86400. If the key is already claimed, return the cached result fromidem_result:{userId}:{clientRequestId}withreplayed:true. Replayed responses include the exact same payload the original caller received; the Go handler skips XP, notifications, and activity feed on replays. - Instrument state — reads
base_price,live_base_price(preferred during matches),k,net_shares_sold,is_active,frozen. Returnsinstrument_frozenerror iffrozen=1. - Balance read — reads
wallet:{userId}.balance. Default seed 10,000 if missing. - Slippage check — BUY:
fill_price > limit_price→ reject. SELL:fill_price < limit_price→ reject. - Balance check (BUY only) —
balance < total_cost→ reject. - State writes — updates wallet hash, position hash, instrument
net_shares_sold. - Leaderboard update — maintains
user_pnl_by_inst:{userId}hash (per-instrument unrealized PnL). Computes delta between old and new value,ZINCRBYonleaderboard:overallandleaderboard:district:{districtId}. - ROI tracking (SELL only) — computes
roi_pct = realized_pnl / cost_basis × 100,ZINCRBYonleaderboard:roi:overallandleaderboard:roi:district:{districtId}. Updatesuser:{userId}:trade_statshash (invested,realized_pnl,wins,sells). - Pub/sub —
PUBLISH price:{instrumentId}with new price tick.PUBLISH portfolio:{userId}with updated balance and unrealized PnL sum. - Dirty sets —
SADD dirty:instruments,dirty:wallets,dirty:positionsfor the flusher. - 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.
- Trade window check — handler reads match state; rejects if match has not started or has already ended.
position_open.lua— atomically:- Validates direction (
long/short), lot size (max 100), SL/TP range. - Checks
clientPriceagainstlast_noisy_pricewithslippageTolerancePcttolerance. ReturnsPRICE_MOVEDif breached. - Computes equity (balance + unrealized PnL across all open positions by iterating
positions:{userId}sorted set). - Computes
margin_required = (current_price × lot_size × CONTRACT_SIZE) / LEVERAGE(CONTRACT_SIZE=5, LEVERAGE=10). - Rejects if
free_margin < margin_required. - Writes
position:{positionId}hash withdirection,lot_size,open_price,opened_at_ms, optionalstop_loss,take_profit. ZADD positions:{userId} <nowMs> <positionId>(sorted by open time).- Sets
last_open:{userId}:{instrumentId}with 180s TTL (minimum hold gate for closes). - Publishes
price:{instrumentId}andportfolio:{userId}. - 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.luafor any open position whosestop_lossortake_profitis breached. - Uses Redis lease
lease:stop-watcherso 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:
- Reconnect hook — ws-server publishes
user:reconnect <userId>.runMarginReconnectListenerin api-server callsmargin.ReplayLastSeen(ctx, uid)which replays ticks stored in theprice_ticksRedis list sincelast_seen:{userId}, closing any positions that breached at the breach price. - 5-min backstop sweeper —
runMarginBackstopSweeperqueries all users with open positions every 5 min and callsReplayLastSeenfor each. - Pre-FT replay —
scheduler.SetMarginReplayerfiresmarginSvc.ReplayUsersAtFTbefore 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.Executewrites a row totrade_outbox.positions.Service.Open/Closewrites toposition_outbox.flusher.OutboxDrainerandflusher.PositionOutboxDrainerretry these rows every 5s until they succeed.
This guarantees durability even under transient Postgres unavailability.