adr: 0003 title: FT freeze + drain wait + system-sell at flat price status: superseded date: 2026-05-22 deciders: [@vaishnav-s-01, @amalkrsihna] affects-specs: [amm-pricing] affects-code: - ftl-backend/internal/sportmonks/postmatch.go - ftl-backend/internal/trade/service.go - ftl-backend/internal/redis/lua/trade_execute.lua supersedes: null superseded-by: 0005
Superseded by ADR-0005 on 2026-05-24. The FT freeze + 500 ms drain wait mechanism survives; what changed is the close price. ADR-0003's flat-price system-sell applied to single-direction shares; ADR-0005 closes bidirectional CFD positions at the snapshot
current_priceinstead. The legacy share auto-sell path runs alongside CFD closes during the rollout window (ftl-docs/guides/CFD-CUTOVER.md); the cleanup PR drops it after migration.
ADR-0003: FT freeze + drain wait + system-sell at flat price¶
Context¶
The end of a match is the riskiest moment for the AMM. Three problems converge:
- Closing-book races. The instant the referee blows the whistle, we want to stop accepting new trades against a player whose match is over — but trades in flight at the moment of FT could still hit the Lua script and execute against stale state.
- Stranded holders. At FT, everyone holding the player needs to be liquidated to crystallise their PnL into the leaderboard. If we just let users sell at their own pace, slow users get worse prices as the curve walks down — and a user who's offline at FT is stranded indefinitely.
- Curve cratering. If we let the system-sells run through the normal AMM (each sell
decrementing
net_shares_sold, walking the price down), the first sell gets a fair price and the last one gets the floor — for the same player, at the same logical moment in time. That isn't a market; it's a queue with very unhappy people at the back.
The retired serverless design (archive/old-stack/) did not address these — it predated the
discovery of all three behaviors during integration testing in late P3 (~PRs #16/#17/#19/#20
in ftl-backend).
Decision¶
At Full Time, the post-match processor performs a three-step liquidation: freeze the
instrument, wait 500 ms for in-flight trades to drain, then run a system-sell that liquidates
every holder at a single flat fair price (bypassing the AMM curve entirely). After all
holders are flat, net_shares_sold is reset to 0 in both Postgres and Redis, then the
instrument is unfrozen for the next match.
Concrete steps encoded in internal/sportmonks/postmatch.go:
-
Freeze gate.
HSET instrument:<id> frozen 1. The Lua hot-path checks this flag at the top of every trade and rejects withfrozen(trade_execute.lua:135-145). Adefer HDelensures the flag is cleared even if the liquidation path errors out. -
Drain wait.
time.Sleep(500 * time.Millisecond). This is 5 flusher cycles (the flusher runs every 100 ms), which is empirically enough for any trade that started before the freeze to either commit or fail and be enumerated as part of the snapshot. -
System-sell.
internal/trade/service.go:554-738(ExecuteSystemSell) iterates every holder and writes a SELL at a single fair price computed from the post-matchbase_price. The AMM curve is NOT walked —net_shares_soldis not decremented per-fill. ROI accounting math mirrors the Lua's per-trade delta logic in comments at:686-690. -
Reset. After all system-sells complete,
UPDATE instruments SET net_shares_sold = 0in PG andHSET instrument:<id> net_shares_sold 0in Redis. The next match starts the curve clean (:332-349). -
Unfreeze. The deferred
HDelruns, removing the freeze flag.
Consequences¶
- Positive. Every holder of a player at FT gets the same fair exit price. No racing.
- Positive. The next match for the same player starts with a clean curve — no carry-over imbalance from the previous match.
- Positive. In-flight trades at FT see a clean
frozenrejection rather than an inconsistent partial fill. - Negative. The 500 ms drain wait is a hand-picked constant. If the flusher's tick rate changes or PG latency drifts, the wait may be too short. Mitigation: monitor the post-match processor for "trade arrived after enumeration" warnings.
- Negative. The system-sell bypasses the AMM curve and therefore the regular Lua
idempotency guard —
ExecuteSystemSellwrites directly via Go. Replays of the post-match processor would double-sell. Mitigation: theprocessed_fixturestable (migrations/000025_processed_fixtures.up.sql) is the primary idempotency boundary at the fixture level. - New maintenance burden. The system-sell ROI accounting in
service.go:686-690duplicates the Lua's per-trade delta math. Comments call this out, but the two must stay in sync — any change to per-trade ROI accounting needs to be mirrored in both places.
Alternatives considered¶
Alternative A: No freeze — let users sell through the normal AMM at FT¶
The "fair market" approach: the last user out gets the worst price.
Rejected because: it punishes slow connections, offline users, and anyone whose page hasn't refreshed at the moment of FT. Created an obvious griefer pattern in early playtests where fast traders front-ran the FT bell and dumped onto slower users at the floor.
Alternative B: Freeze without flat-price system-sell — let users sell manually post-FT¶
Freeze on FT, but instead of auto-liquidating, leave the instrument frozen until users manually sell at the post-match settlement price (over hours).
Rejected because: users who closed their browser between matches would have stranded positions accruing nothing. Also, holding open positions across match days complicates the leaderboard's per-match ROI accounting — it's much cleaner to crystallise PnL at FT.
Alternative C: Freeze + system-sell, but walk the AMM curve normally¶
Same flow, but the system-sells decrement net_shares_sold per fill (regular AMM behavior).
Rejected for the reason in #3 of the Context: identical positions at the same logical moment in time would receive wildly different prices based on enumeration order. That's not a market, it's a coin flip on user ordering.