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_published_price(the price broadcast over the WebSocket), falling back tolast_noisy_price→server_pricewhen unset. Tolerance isslippageTolerancePct(default 2.5%, envTRADE_SLIPPAGE_TOLERANCE_PCT). Both fields are pre-fetched in the sameHMGETcall as the rest of the instrument state — no extra round-trip. 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.opened_at_msis the source of truth for the close cooldown (see below). ZADD positions:{userId} <nowMs> <positionId>(sorted by open time).last_open:{userId}:{instrumentId}is no longer written (retired 2026-06-11). KEYS[8] is unused; per-position cooldown is enforced insideposition_close.luafrom the position hash's ownopened_at_ms.- Publishes
price:{instrumentId}andportfolio:{userId}. - Marks dirty sets.
- 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.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.
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
aggregateTeamStatspath 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.