adr: 0013 title: Minimum hold (anti-flip) — 180 s hold set on OPEN, not post-CLOSE cooldown status: accepted implementation-status: implemented date: 2026-06-02 implemented-date: 2026-06-02 implemented-in: - ftl-backend feat/cfd-trading-bug-fixes (issue #69) deciders: [@amalkrsihna] affects-specs: [cfd-pricing-and-wallet] affects-code: - ftl-backend/internal/redis/lua/position_open.lua - ftl-backend/internal/redis/lua/position_close.lua supersedes: null superseded-by: null
ADR-0013: Minimum hold (anti-flip) — 180 s hold set on OPEN, not post-CLOSE cooldown¶
Context¶
ADR-0004 specified a "3-minute cooldown per instrument" to prevent rapid flip-trading. The
original wording in that ADR and in cfd-pricing-and-wallet.md §6 was ambiguous, and the
first implementation treated the 180-second window as a post-close cooldown: after a user
closed a position on instrument A, they could not close another position on instrument A for
180 seconds.
This had two problems:
- Wrong semantics. A post-close cooldown blocks future closes, which punishes the user for correctly managing risk (taking profit or cutting a loss). The original intent was to prevent rapid open → close → open flip-trading, which is better addressed by a minimum hold from the open time.
- Cross-instrument bleed. The buggy implementation keyed the cooldown on the user alone
(not on
(user, instrument)), so closing any position blocked closes on ALL other instruments for the same user. This was not the intended behavior.
A code review (issue #69, 2026-06 trading audit) identified both issues. The product owner confirmed: the intent is a minimum hold applied from the moment of open, not a post-close blocking window.
Decision¶
We implement the 180-second window as a per-(user, instrument) minimum hold set at OPEN time. The exact shipped behavior:
position_open.luasetsSET last_open:{userId}:{instrumentId} {positionId} EX 180on every successful open. The TTL is 180 seconds from the open timestamp.position_close.luachecks this key on any user-initiated close request. If the key exists and its stored positionId matches the position being closed, the close is rejected with error codecooldown_active. The position cannot be user-closed until the TTL expires.- System-initiated closes (stop-loss, take-profit, washout, full-time auto-exit) bypass the
hold entirely. These paths call
position_close.luawith a system reason and skip the min-hold check. The platform must always be able to liquidate.
Additional behavioral constraints encoded in this decision:
- Independent per instrument. Opening B starts B's 180s hold independently of A's hold. Closing A has no effect on B's hold. The old cross-instrument bleed bug is fixed.
- Edge case — second open on same instrument. The hold key stores the most-recently opened positionId on that instrument. If a user opens a second position on instrument A, only the newest open is min-held; the earlier position on A becomes immediately closeable by the user. This is an accepted edge case: the common pattern is one open position per user per instrument, and the min-hold's purpose (prevent flip-trading the same position) is preserved for the typical case.
Consequences¶
Positive¶
- Correct anti-flip semantics. Users can always open new positions immediately (only closes are held). The hold targets exactly the flip-trading pattern: open → instant close → reopen.
- No cross-instrument interference. Users can close positions on any other instrument at any time. The bug that locked all closes after any single close is eliminated.
- System liquidation always works. Washout, SL/TP, and FT auto-exit are unblocked, preserving the safety invariant that the platform can always exit a position.
Negative¶
- Min-hold is invisible unless a user tries to close early. The 180s window is not displayed in the UI on position open. A user who opens and immediately tries to close will see an error. Consider adding a countdown timer to the open-position row in a future UI pass.
- Second-open edge case may surprise. If a user has two open positions on the same instrument, closing the earlier one (which is now unheld) while the newer one is still in its 180s window could be confusing. Documented as accepted behavior.
Neutral / new obligations¶
- The error code
cooldown_activesurfaces to the frontend viaCfdCloseResult.error. The frontend should display a human-readable message ("Hold your position for a bit longer — you can close in N seconds") in a follow-up UI ticket.
Alternatives considered¶
Alternative A: Post-close cooldown (the original buggy design)¶
After closing any position on instrument A, block new closes on A for 180 seconds.
Rejected. Punishes valid risk management (cutting a loss, taking profit). A user who correctly sets a stop-loss at open and then manually closes at a profit before SL triggers is penalized. This is exactly the behavior we want to encourage, not block.
Alternative B: Open cooldown (no close cooldown)¶
After opening on instrument A, block new opens on A for 180 seconds.
Rejected. This prevents users from adding to a winning position or hedging an existing position (opening a short while a long is running). The actual flip-trading pattern we want to prevent is open → close → open in rapid succession, which requires a hold on the close side of the cycle, not on opens.
Alternative C: No cooldown at all¶
Remove the 180s restriction entirely; let users open and close as fast as they like.
Rejected. Without any anti-flip mechanism, high-frequency flip-trading around event bumps (e.g., goal scored → instant open at spike price → close 1s later as price decays) creates an exploitable risk-free arbitrage against the AMM. The minimum hold forces users to hold through at least some of the decay, making the exploit significantly less attractive.