adr: 0005 title: Close all open positions at current_price at Full Time (CFD model) status: accepted implementation-status: implemented date: 2026-05-22 implemented-date: 2026-05-24 implemented-in: - ftl-backend feat/amm-cfd-redesign-v1 deciders: [@amalkrsihna] affects-specs: [amm-pricing] affects-code: - ftl-backend/internal/sportmonks/postmatch.go - ftl-backend/internal/trade/service.go supersedes: 0003 superseded-by: null
ADR-0005: Close at current_price at Full Time (CFD model)¶
Pending implementation. This ADR replaces ADR-0003's flat-price system-sell with a close-at-current-price mechanism appropriate for CFD positions. Lands together with ADR-0004 in the paired backend PR.
Context¶
ADR-0003 mandated a flat fair price system-sell at Full Time: every holder of an instrument gets sold out at a single computed fair price, bypassing the AMM curve. The explicit reason in ADR-0003 was problem #3 in its Context:
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.
That rationale does not apply to CFD positions:
- CFD positions don't represent shares. There's nothing to "dump" through the curve.
- Closing a long position removes its contribution from
Σ longs, so the position-imbalance modulation moves price DOWN slightly. Closing a short does the inverse. These are smaller effects than walking the curve through aggregate share sales. - All closes happen at the same FT instant snapshot of
current_price, so user order doesn't matter — first and last close get identical prices.
The freeze gate from ADR-0003 (reject new bookings between FT and close-out) is still needed and is preserved.
Decision¶
At Full Time, every open position on the just-completed match's instruments auto-closes
at the current_price snapshot taken at the FT instant. Realized PnL is computed using
that snapshot price as the close price; PnL is swept into the user's balance; positions are
marked closed with closed_by: auto_exit_ft.
Concretely:
- At FT,
internal/sportmonks/postmatch.goruns: HSET instrument:<id> frozen 1— freeze gate from ADR-0003 still applies. Rejects any new booking/close from users that arrives during liquidation.- Read the current
current_pricefor the instrument. This is the snapshot used for all FT auto-exits on this instrument. - Enumerate all open positions on this instrument. For each:
- Compute realized PnL =
(snapshot_price − open_price) × lot_size × 100 × direction. - Write
positionsrow update:closed_at = NOW(),close_price = snapshot_price,realized_pnl = ...,closed_by = 'auto_exit_ft'. - Add realized PnL to user's balance via Redis Lua (atomic; same idempotency guard).
- Emit
portfolio:<userId>WebSocket message.
- Compute realized PnL =
- Reset
net_position_imbalancefor the instrument to 0 (no open positions remain). - Clear
live_base_price(it only applies during the match). HDEL instrument:<id> frozen— unfreeze ready for the next match.- The 500ms drain wait from ADR-0003 is preserved at the start of the sequence, before enumerating positions, to let in-flight bookings/closes drain from Redis to Postgres.
Consequences¶
Positive¶
- First-closed and last-closed get identical prices. Enumeration order doesn't matter; no race-condition unfairness.
- Simpler than flat-price. No need to compute a "fair flat price" per instrument — current_price is already known.
- User sees the same price they were watching. Whatever current_price was when the whistle blew is what they close at.
- Auditable. The snapshot price is in the
positions.close_pricecolumn, easy to reconstruct in disputes.
Negative¶
current_priceat the FT instant may be momentarily off if a goal lands in stoppage time and event scoring lags. Mitigation: hold the freeze until all in-flight scoring events for the fixture have committed. Operationally enforced by ordering: scoring writes first, freeze + close-out runs after.processed_fixturestable from ADR-0003 still required to guard against double-running the post-match liquidation.
Neutral / new obligations¶
- The FT freeze gate from ADR-0003 is preserved. Don't accidentally drop it when implementing this ADR — the freeze prevents users from opening a new position between FT and the auto-exit completing.
- The 500ms drain wait from ADR-0003 is preserved. Same reason.
Alternatives considered¶
Alternative A: Keep flat-price system-sell¶
Continue computing a single fair flat price per instrument at FT for all auto-exits.
Rejected. The rationale that motivated flat-price (curve cratering) only applies to share-based AMMs. CFD positions don't crater the curve on close — closing them just removes a small modulation contribution.
Alternative B: Let positions roll across matches¶
Allow a user to hold an open position across the boundary into the player's next match.
Rejected. net_position_imbalance resets per instrument per match, and the live_base_price
clears, so a position carried across would have an undefined open-vs-close pricing reference.
Cleaner to force-close at FT and let the user re-open on the next match if they still want
exposure to the player.
Alternative C: Close at the last user-observed price (per-user snapshot)¶
For each user, use the last current_price their WebSocket received as the close price.
Rejected. Creates per-user prices for an identical event, which destroys auditability and creates support nightmares ("why did my friend get a better price than me?"). The system-snapshot approach is uniform and defensible.