What happens when a market close order fails?
You’d think closing a position is simple. Place a market order, done. But on Binance Futures, market close orders can fail:
-2021error — Order would immediately trigger (a timing race condition)-4131PERCENT_PRICE — Price moved too far too fast, order rejected- Network timeout — The request never reached Binance
- Exchange maintenance — Binance goes down for 30 seconds (always at the worst time)
When a close order fails, you have an unmanaged position. No trailing stop. No exit plan. Just an open trade bleeding money while your bot throws errors into the void.
The AGT incident: -121U from a failed market close
This actually happened. Here’s the timeline:
- Bot detects exit signal on AGT SHORT position
- Places market close order
- Binance returns
-4131 PERCENT_PRICE— price moved too far, order rejected - Bot treats the failure as success (the original bug: returning
__MARKET_CLOSED__on failure) - Bot removes the position from its state
- Position is still open on the exchange — completely unmanaged
- AGT price moves against the position
- Unrealized loss reaches -121U before I manually intervene
- I manually close 30% at a loss of -37.2U
- Remaining position recovered via
sync_with_exchangeon bot restart
Why did the bot think the close succeeded?
The bug was in the error handling:
|
|
The function returned __MARKET_CLOSED__ in both success and failure cases. The caller assumed the position was closed and stopped monitoring it.
How did I fix it?
1. Three-attempt retry with delay
|
|
2. Explicit failure state
When all 3 attempts fail, the bot now returns __MARKET_CLOSE_FAILED__ — a distinct value from __MARKET_CLOSED__. The caller knows the position is still open.
3. SL re-placement loop
Failed-close positions are kept in the bot’s state with sl_order_id = ''. The main loop detects this and tries to place a new stop loss every cycle:
|
|
This means even if the close failed, the position gets a stop loss as soon as the exchange accepts orders again.
4. Exchange sync on restart
If the bot crashes or restarts, sync_with_exchange compares exchange positions against the state file. Any position on the exchange that’s not in the state file gets recovered with sl_price = 0 (triggering immediate SL placement).
What other close failures can happen?
| Error | Cause | Solution |
|---|---|---|
-2021 |
Order would trigger immediately | Retry after 1.5 seconds |
-4131 PERCENT_PRICE |
Price moved too far too fast | Retry — price calms down |
-2022 ReduceOnly rejected |
Position already closed | Treat as success — ignore |
| Network timeout | Connection dropped | Retry with backoff |
-1015 Too many orders |
Rate limited | Wait 10 seconds, retry |
The key insight: most close failures are temporary. The exchange is busy, the price is volatile, the network hiccupped. A retry 1.5 seconds later usually succeeds.
The dangerous case is when all 3 retries fail. That means something is seriously wrong — exchange downtime, API changes, or a problem with the specific trading pair.
How do you protect against unmanaged positions?
Defense in depth:
- Exchange-side STOP_LIMIT — Placed when the position opens. Survives bot crashes and close failures.
- 3-attempt market close — Most failures are temporary.
__MARKET_CLOSE_FAILED__state — Explicit tracking of positions that couldn’t be closed.- SL re-placement loop — If the close failed, at least get a stop loss on it.
- Exchange sync on restart — Catch anything that fell through the cracks.
No single layer is perfect. Together, they’ve prevented any unmanaged position from lasting more than one scan cycle since the fix.
The exchange doesn’t care about your exit strategy. It cares about whether your order is valid right now, this millisecond. Build for the millisecond it isn’t.
Related:
- Orphan Orders — Another exchange API nightmare
- What Happens When Your Bot Crashes at 3 AM — Full crash recovery system
- STOP_MARKET vs STOP_LIMIT — Why exchange-side stops matter