Polymarket Bot Tutorial · Chapter 32 of 32
Real Polymarket bot mistakes and postmortems: phantom fills, sticky-fail dedup, lol-ctg-ccg whipsaw, NegRisk flag bug, premature go-live - with the commits and dates that fixed each.
What this chapter covers
Our own production diary of bugs that cost real money. The pattern matters more than the specifics - the same classes of bug recur across bots, and the cure is usually a missing watchdog, not a better strategy. This chapter is intended to save you the tuition.
- Phantom fills (commits e68a087, 8bb7761)
- NegRisk flag bug (commit)
- Sticky-fail dedup (commit)
- Whipsaw incident: lol-ctg-ccg
- Premature go-live: 2025 wipe
- Sleep-through-bug: kill switch worked
- Lessons that generalize
Phantom fills (commits e68a087, 8bb7761)
The first major phantom-fill incident on our trader, May 2025. Bot placed 22 FOK buys, all matched at the CLOB. The bot immediately attempted to post 22 GTC sells. 8 of them rejected with "balance: 0 / sum of active orders: 0 / order amount: 10000000."
Root cause: settlement lag (chapter 12). CLOB matched in 100ms, the bot posted the sell in 200ms, but the Polygon ERC-1155 transfer took ~2 seconds. The CLOB rejected the sell because the chain still showed zero balance.
Fix: insert a 5-second blocking wait between any successful buy and any GTC follow-up on the same token. Commits e68a087 and 8bb7761. Zero phantom-fill incidents since.
Lesson: API time and chain time are different timelines. Code that assumes they're synchronous will hit this exact failure mode.
NegRisk flag bug (commit)
A NegRisk multi-outcome event with 8 candidates had a momentary arb of 1.8c (sum of YES asks = 0.982). Our arber fired all 8 FOK buys. 6 of them filled; 2 settled into the wrong exchange contract.
Root cause: the bot was calling createAndPostOrder without setting negRisk: true in the flags object. Two of the markets had a different historical creation date and required the flag; six did not need it because their underlying contract was already routing through NegRisk by default.
Fix: read market.negRisk from Gamma for every market, pass through to every order call. We re-ran the arb with the flag set; the remaining 2 settled correctly.
Lesson: never default a market property. Read it explicitly from the source of truth every time.
Sticky-fail dedup (commit)
The bot retried a failed buy 5 times in 12 seconds. The first attempt actually succeeded (network timeout caused the bot to not see the response); the next 4 retries created 4 additional positions. Total: 5 positions on the same market when we wanted 1.
Root cause: no idempotent client-order-id. The bot's retry logic was "if it failed, try again with a new salt." The CLOB had no way to recognize the retries as duplicates.
Fix: generate a deterministic UUID per intended order before the first attempt. All retries use the same client-order-id, allowing the CLOB to dedup. Commit.
Lesson: retries without idempotence are duplicates. Every order needs a stable client-side identifier.
Whipsaw incident: lol-ctg-ccg
An esports match (CTG vs CCG) had the bot enter a buy at 0.45 when imbalance flipped positive. Within 30 seconds, the imbalance flipped negative and our GTC sell at 0.50 was hit by someone else's order. PnL: +5c × 10 shares = +$0.50.
10 minutes later, the same market's imbalance flipped positive again. Bot entered again at 0.42. This time the imbalance never recovered; mid drifted to 0.18 and the position rode to resolution at 0.
Root cause: the strategy treated imbalance as a directional signal but didn't track that the imbalance was bouncing - both signals were noise, not information. The bot was whipsawed across two failed signals on the same market within 20 minutes.
Fix: cooldown per market - after a fill, no new entries on the same market for 30 minutes. Allowed multiple entries across different markets, but not back-to-back on the same one.
Lesson: a signal that bounces is not a signal. Filter for persistence before acting.
Premature go-live: 2025 wipe
A new market-making strategy passed 12 paper trades. The builder didn't wait for 30, decided "looking good," deployed live with $500 of capital. Within 18 hours the wallet was at $200.
Root cause: 12 trades isn't enough sample to distinguish 60% WR from 35% WR. The strategy was actually 35% WR; the 12-trade paper window happened to have an unrepresentative streak.
The 30-trade gate exists for a reason. The variance on a 12-trade sample makes it indistinguishable from "the strategy doesn't work."
Lesson: discipline beats conviction. The 30-trade gate is not negotiable.
Sleep-through-bug: kill switch worked
Bot had an off-by-one in its time-of-day filter - meant to pause at 02:00 UTC, was actually pausing at 03:00 UTC. During the un-paused 02:00-03:00 hour, Polygon RPC was rate-limiting our requests heavily; the bot's read path was returning stale data.
The bot kept trading on stale prices. PnL on the hour: -$3.20 across 22 trades. The daily-loss kill switch triggered at -5%, halted the bot, sent a Telegram alert at 03:08 UTC. Builder woke up to a halted bot at 09:00, total damage limited to the kill threshold.
Lesson: the bug was real but the kill switch worked. -$3.20 instead of -$50.00. The risk controls don't prevent bugs; they cap the cost of bugs you didn't see coming.
Lessons that generalize
Across all postmortems, four patterns repeat.
- API time ≠ chain time. Settlement lag, RPC lag, WebSocket lag - all introduce gaps that bot code must explicitly handle.
- Retries need idempotence. A retry without a client-order-id is a duplicate-order risk. Always.
- Read every market property explicitly. NegRisk flag, tick size, expiration. Never default; always read from the source of truth.
- The kill switch is the floor, not a feature. Risk controls cap losses on bugs. Strategies don't prevent bugs; they assume the bot works correctly. The bot will not always work correctly.
Every chapter in this series has one of these patterns embedded somewhere. They're the load-bearing principles of a production bot. Skip them and you'll find them again in your own postmortems.


