Skip to content

adr: 0006 title: Honest washout policy via backend tick-replay (online == offline outcome) 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, wallet-and-margin] affects-code: - ftl-backend/internal/trade/service.go # margin level evaluator - ftl-backend/internal/ws/hub.go # on-reconnect trigger - ftl-backend/internal/sportmonks/postmatch.go # FT replay supersedes: null superseded-by: null


ADR-0006: Honest washout policy via backend tick-replay

Pending implementation. This ADR explicitly defends a decision that will be under pressure to weaken once washouts start firing in production. It exists as a standalone ADR so the reasoning is durable and cannot be quietly walked back. Read it BEFORE any proposal to "soften the washout policy" lands.

Context

The CFD model (ADR-0004) introduces washouts: when a user's margin level falls to 50%, the backend force-closes the user's largest losing position to limit loss. This is the established forex/CFD pattern; it exists to protect the broker (the system) from accounts going negative, and to cap the user's loss at the threshold price.

The question this ADR resolves: what happens when the user is offline during the dip?

The operator's first proposal was lenient — "if user was offline during the dip and price recovered, don't wash out, because we won't even know it happened". This intuition came from a reasonable cost concern: backend can't realistically recompute every user's margin level on every price tick at 50K concurrent users.

Three problems with the lenient approach surfaced during brainstorming:

  1. Outcome non-determinism. Same trade, same price path, different PnL based on whether the user was watching. Players will figure this out fast — the meta becomes "close the app when losing, reopen when winning". A worse game than honest washouts.

  2. Online/offline unfairness. Your friend, watching, gets washed out at the 50% breach. You, offline, don't. Identical position, opposite outcome. Hard to defend in support tickets, harder to defend in product reviews.

  3. The 50% threshold becomes fake. The threshold exists so losses cap at half your account. If we let offline accounts blow through it and recover, the cap is fiction. Worse, on the unlucky-recovery scenarios where the offline account DOESN'T recover, the loss could exceed the threshold by an arbitrary amount.

The cost concern was real but solvable: backend already stores price ticks; tick-replay is cheap and only needs to fire on user reconnect (or periodically for stranded positions).

Decision

The backend is the sole authority on washouts. A washout fires whenever the user's margin level breaches 50%, regardless of whether the user is online to observe it.

Implementation strategy that keeps cost bounded:

  1. Live-online users: backend re-evaluates margin level on a cheap schedule — every price tick for instruments where the user has an open position, OR every N seconds (where N is small, e.g. 1–5), OR on any tick where the price moved more than M% since last evaluation. Backend pushes the washout via the existing portfolio:<userId> WS channel.

  2. Live-offline users: backend does NOT continuously recompute. Instead:

  3. On user reconnect, the backend replays price_ticks for the time window [user's last-online instant, now] against the user's open positions to detect "would have been washed out at T". If yes, the washout is realized at the price where the 50% breach happened (not at the current price, which may have recovered).
  4. As a backstop, a periodic batch job (e.g. every 5 minutes) replays for all users with open positions who have been offline for more than 5 minutes, so stranded accounts don't accumulate undetected washouts forever.

  5. At FT: post-match processor replays all open positions one final time before ExecuteSystemSell runs, so any washouts that should have fired during the match are realized first.

  6. User feedback: when the backend triggers an offline washout, it sends a push notification: "You were stopped out on Messi at 18:42. Realised loss: 3,500 coins. Open the app for details."

Consequences

Positive

  • Deterministic outcomes. Same trade + same price path = same PnL, regardless of whether the user was watching. The game is fair.
  • Threshold means what it says. Losses really do cap at 50% margin level (approximately — slippage during force-close can push slightly worse).
  • No exploit. Closing the app when losing buys nothing; the washout still fires.
  • Cheap to compute. Per-tick recompute is NOT required. The expensive case (replay on reconnect) only runs once per reconnect, not per tick.

Negative

  • "Lost while AFK" UX is harsh. A user closes their app to take a phone call and reconnects to a washed-out account. We mitigate with push notifications and clear "what happened" history in the app.
  • Tick-replay must be correct. A bug in the replay logic that misses a 50% breach is a financial bug. Comprehensive tests required — replay against historical fixtures with known washout points, fuzz-test edge cases (tick gaps, post-FT pricing, partial closes).
  • Push notification deliverability matters. A user who never enables notifications gets surprised on next open. The in-app history must be detailed enough to compensate.

Neutral / new obligations

  • price_ticks table grows. It already does; the replay use case doesn't materially increase write volume, only read volume.
  • Backend becomes the single authority on washout, even though the frontend computes margin level for display. The frontend's computed margin level is advisory only. If the frontend's computed value diverges from the backend's authoritative value (e.g. due to WS tick drop), the user might see a margin call notification slightly before/after the backend triggers it. Acceptable — show backend-side notifications via the portfolio WS channel; treat frontend display as best-effort.

Alternatives considered

Alternative A: Lenient (operator's original proposal)

If user was offline during a dip below 50% margin level and price recovered before they reconnect, no washout.

Rejected for the three reasons in Context. The cost concern that motivated it is solvable with cheap tick-replay; the determinism and fairness costs are not solvable without a wholesale model change.

Alternative B: Per-position mandatory stop-loss

Instead of account-level washouts, require every position to carry a mandatory stop-loss (e.g. at −70% of margin allocated to it). When the SL hits, the position closes regardless of user online state. No cascading margin-call washouts at all.

Rejected. Removes the "margin level" mechanic the operator explicitly wanted (per the brainstorm). Also creates a different unfairness — a user with one large losing position and several small winners might lose the large one without protection from the winners' unrealised gains (which margin level computation would have considered).

Alternative C: Soft washout (close 50% of position, not full)

When margin level hits 50%, close half the largest losing position instead of all of it.

Rejected. Adds complexity for marginal benefit. The user can effectively achieve this by sizing positions smaller upfront, or by using stop-loss. Forex brokers occasionally implement this; rejected here for V1 simplicity.

Alternative D: Per-tick backend computation for all users

Recompute margin level for every user on every price tick.

Rejected on cost grounds at 50K-user scale. With ~5 open positions per user avg and ~1 tick/sec per instrument, that's 250K equity computations per second. Doable in Redis with smart indexing but unnecessary — the tick-replay approach achieves the same outcome at a fraction of the cost.

Note on the "tempted to weaken" risk

This ADR exists in part to defend its own decision. Three months from now, when a user posts an angry review about getting stopped out while their phone was in airplane mode, the temptation to "just let offline users have a free pass" will be strong. Resist it. The three reasons in Context don't change because someone is angry. Adjust the UX (better notifications, clearer in-app explanation, possibly a one-time "free pass" goodwill feature on first-ever washout per user) before adjusting the policy.

If a future ADR proposes to supersede this one, it must: - Address all three Context concerns (determinism, fairness, threshold meaning) with concrete mitigations, not hand-waves. - Quantify the exploit risk of the proposed alternative. - Get explicit operator sign-off on the trade-off, not a tactical concession to user pressure.