Trade Lifecycle¶
This page traces a CFD position open from the client's HTTP POST to the Postgres INSERT. The same pattern applies to user-initiated closes, stop-loss/take-profit fires, and margin washouts — they all run through a Lua script followed by a flusher write.
Entry Point¶
The client sends:
POST /api/positions/open
Authorization: Bearer <jwt>
{
"instrumentId": "<uuid>",
"direction": "long",
"lotSize": 1.0,
"clientRequestId": "<uuid>",
"clientPrice": 142.50,
"slippageTolerancePct": 0.005
}
The handler lives in ftl-backend/internal/handler/. It validates the JWT with auth.NewJWTValidator, then calls positions.Service.Open.
Idempotency¶
Before calling the Lua script, the Go service builds the idempotency key:
The Lua script performs SET idem:{userId}:{clientRequestId} 1 NX EX 86400 as its first atomic operation after input validation. If the key already exists, the script reads idem_result:{userId}:{clientRequestId} and returns the cached response with replayed = true. The Go handler sees replayed = true and skips non-idempotent side-effects (XP awards, notifications, activity feed writes).
Decision: Redis SET NX as the idempotency boundary over a Postgres unique index — the Postgres index on client_request_id is a partition-local fallback only. A globally unique constraint is impossible on a RANGE-partitioned table. Redis SET NX is globally consistent across replicas via a single Redis instance.
If the api-server crashes after the Lua script commits to Redis but before the Postgres INSERT, the trade-outbox drainer (flusher.NewOutboxDrainer) retries the INSERT from trade_outbox. For CFD positions, flusher.NewPositionOutboxDrainer handles the same fallback from position_outbox.
Lua Script: position_open.lua¶
File: ftl-backend/internal/redis/lua/position_open.lua
Keys passed in:
| Key | Purpose |
|---|---|
instrument:{id} |
Live instrument state |
wallet:{userId} |
User balance |
positions:{userId} |
Sorted set of open positionIds (score = opened_at ms) |
dirty:instruments, dirty:wallets, dirty:positions |
Flusher pickup queues |
idem:{userId}:{clientRequestId} |
Idempotency guard |
last_open:{userId}:{instrumentId} |
180s minimum-hold anchor |
The script executes these checks in order:
- Input validation —
directionmust belongorshort,lotSize> 0,positionIdnon-empty. - Instrument state — reads
live_base_price(live match price) or falls back tobase_price. Rejects ifis_active == falseorfrozen == 1. - Idempotency claim —
SET NX idem:... EX 86400. On collision returns cached result. - Slippage check — if
clientPriceis supplied, compares it againstlast_noisy_price(the most recent noisy-channel price). If the delta exceedsslippageTolerancePct, returnsPRICE_MOVED. On acceptance, fills atclientPriceso the wallet debit matches what the user agreed to. - SL/TP directional validation — for a long,
stop_loss < current_price < take_profit. Inverted values would trigger the StopWatch immediately after open. - Equity and free-margin check — iterates
positions:{userId}(the user's open positionIds), reads eachposition:{id}hash to computeused_marginandunrealized_pnl.equity = balance + unrealized. Iffree_margin < margin_required, returnsinsufficient_free_margin. - Mutations — writes
position:{positionId}hash,ZADD positions:{userId}, updatesnet_position_imbalanceon the instrument, setslast_open:{userId}:{instrumentId}with a 180s TTL (minimum hold),SADDto all three dirty sets. - Pub/sub —
PUBLISH price:{instrumentId}with new price,PUBLISH portfolio:{userId}with updated equity/margin snapshot.
The leverage is 1:10, contract size is 5 shares per lot. Margin required = (openPrice × lotSize × 5) / 10.
Dirty Sets → Flusher → Postgres¶
The Lua script adds three members to dirty sets:
- SADD dirty:instruments <instrumentId>
- SADD dirty:wallets <userId>
- SADD dirty:positions <positionId>
The flusher (ftl-backend/internal/flusher/flusher.go) runs every FlushInterval (100ms in production). On each tick it calls flush(), which processes all three dirty sets.
For each dirty set, the flusher:
1. Atomically renames dirty:instruments → dirty:instruments:flushing:<ts> via RENAME. New writes accumulate in a fresh live key untouched by this cycle.
2. Reads all members from the snapshot key with SMEMBERS.
3. For each member, reads the Redis hash (instrument:{id}, wallet:{userId}, position:{id}) and writes to Postgres.
4. Requeues failed members back to the live dirty set so the next tick retries.
5. Deletes the snapshot key.
For instruments the Postgres write is:
For legacy positions (the buy/sell model):
INSERT INTO positions (user_id, instrument_id, shares, avg_price)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, instrument_id)
DO UPDATE SET shares = $3, avg_price = $4, updated_at = NOW()
For CFD positions, the position_outbox table holds any row that failed the synchronous Postgres write. flusher.NewPositionOutboxDrainer drains it every 5 seconds.
On flusher startup, RecoverOrphanSnapshots scans for dirty:*:flushing:* keys left by a prior crash and merges them back into the live sets before the first tick.
Price Broadcast After Trade¶
The Lua script publishes to price:{instrumentId} inside the same atomic execution. Each ws-server replica runs a ws.Subscriber goroutine that subscribes to all price:* channels on startup. When a publish arrives, the subscriber decodes the JSON, marshals a PriceUpdate struct to msgpack, and calls hub.Broadcast. The hub iterates the subscription map for that instrument's numeric index and calls writeWithDeadline (100ms timeout) on each connection.
Portfolio state is published to portfolio:{userId} in the same Lua execution. The ws-server routes this via hub.BroadcastToUser to all connections subscribed to that user's portfolio channel.
Sequence Diagram¶
sequenceDiagram
participant C as Client
participant API as api-server
participant RD as Redis
participant WS as ws-server
participant Browser as Browser (other users)
participant FL as flusher
participant PG as Postgres
C->>API: POST /api/positions/open {instrumentId, direction, lotSize, clientRequestId, clientPrice}
API->>API: Validate JWT, build Lua keys
API->>RD: EVALSHA position_open.lua (8 keys, 11 args)
Note over RD: Lua atomic:<br/>1. Validate inputs<br/>2. Check instrument active/frozen<br/>3. SET NX idem key<br/>4. Slippage check vs clientPrice<br/>5. Compute equity + free_margin<br/>6. Write position:{id} hash<br/>7. ZADD positions:{userId}<br/>8. Update net_position_imbalance<br/>9. SADD dirty sets<br/>10. PUBLISH price:{instrumentId}<br/>11. PUBLISH portfolio:{userId}
RD-->>API: {status:"opened", positionId, openPrice, marginRequired, equity}
API->>PG: INSERT INTO cfd_positions (sync, may fail → outbox)
API-->>C: 200 {positionId, openPrice, marginRequired}
Note over RD,WS: Async — Redis pub/sub backplane
RD->>WS: PUBLISH price:{instrumentId}
WS->>Browser: msgpack PriceUpdate frame (binary, ~25 bytes)
RD->>WS: PUBLISH portfolio:{userId}
WS->>C: JSON portfolio frame (equity, usedMargin, freeMargin)
Note over FL,PG: Async — flusher 100ms tick
FL->>RD: RENAME dirty:instruments → dirty:instruments:flushing:<ts>
FL->>RD: SMEMBERS dirty:instruments:flushing:<ts>
FL->>PG: UPDATE instruments SET net_position_imbalance = ...
FL->>RD: RENAME dirty:wallets → dirty:wallets:flushing:<ts>
FL->>PG: UPDATE wallets SET balance = ...
FL->>RD: RENAME dirty:positions → dirty:positions:flushing:<ts>
FL->>PG: INSERT INTO cfd_positions ON CONFLICT DO UPDATE
Legacy Trade Path¶
The original BUY/SELL spot trade (POST /api/trades) follows the same pattern through ftl-backend/internal/redis/lua/trade_execute.lua. That script uses position:{userId}:{instrumentId} hashes instead of per-position UUIDs, and records the fill in the trades table (range-partitioned by day). The idempotency key, dirty sets, pub/sub publish, and flusher drain are identical.
During the ADR-0004 rollout both endpoints are live. The CFD path is the preferred path for new frontend work.