Skip to content

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:

  1. At FT, internal/sportmonks/postmatch.go runs:
  2. HSET instrument:<id> frozen 1 — freeze gate from ADR-0003 still applies. Rejects any new booking/close from users that arrives during liquidation.
  3. Read the current current_price for the instrument. This is the snapshot used for all FT auto-exits on this instrument.
  4. Enumerate all open positions on this instrument. For each:
    • Compute realized PnL = (snapshot_price − open_price) × lot_size × 100 × direction.
    • Write positions row 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.
  5. Reset net_position_imbalance for the instrument to 0 (no open positions remain).
  6. Clear live_base_price (it only applies during the match).
  7. HDEL instrument:<id> frozen — unfreeze ready for the next match.
  8. 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_price column, easy to reconstruct in disputes.

Negative

  • current_price at 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_fixtures table 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.