Polymarket Bot Tutorial · 32/32장
실제 Polymarket bot 실수와 postmortem: phantom fills, sticky-fail dedup, lol-ctg-ccg whipsaw, NegRisk flag bug, premature go-live - 각각을 고친 commits와 날짜와 함께.
이 장에서 다루는 내용
실제 돈이 날아간 버그들의 우리 own production diary입니다. 세부 사항보다 패턴이 더 중요합니다. 같은 종류의 bug는 여러 bot에서 반복되고, 해결책은 대개 더 나은 strategy가 아니라 빠진 watchdog입니다. 이 장은 여러분의 tuition을 아껴주기 위해 마련되었습니다.
- Phantom fills (commits e68a087, 8bb7761)
- NegRisk flag bug (commit 06deaef)
- Sticky-fail dedup (commit 4c0bef1)
- Whipsaw incident: lol-ctg-ccg
- Premature go-live: 2025 wipe
- Sleep-through-bug: kill switch worked
- Generalize되는 lessons
Phantom fills (commits e68a087, 8bb7761)
우리 trader에서 발생한 첫 번째 주요 phantom-fill incident는 2025년 5월이었습니다. bot이 22개의 FOK buys를 넣었고, 모두 CLOB에서 matched되었습니다. bot은 즉시 22개의 GTC sells를 post하려 했습니다. 그중 8개는 "balance: 0 / sum of active orders: 0 / order amount: 10000000." 오류로 rejected되었습니다.
Root cause: settlement lag (chapter 12). CLOB는 100ms 만에 matched되었고, bot은 200ms 후 sell을 올렸지만 Polygon ERC-1155 transfer는 약 2초가 걸렸습니다. chain에는 여전히 zero balance로 표시되었기 때문에 CLOB가 sell을 거부했습니다.
Fix: 동일한 token에서 성공적인 buy와 그 다음 GTC follow-up 사이에 5초 blocking wait를 삽입했습니다. commits e68a087과 8bb7761. 그 이후 phantom-fill incidents는 0건입니다.
Lesson: API time과 chain time은 서로 다른 timeline입니다. 이 둘이 synchronous하다고 가정하는 code는 정확히 이 failure mode를 맞게 됩니다.
NegRisk flag bug (commit 06deaef)
후보가 8개인 NegRisk multi-outcome event에서 momentary arb가 1.8c였습니다(YES asks의 합 = 0.982). 우리 arber는 8개의 FOK buys를 모두 실행했습니다. 그중 6개는 filled되었고, 2개는 잘못된 exchange contract에 정산되었습니다.
Root cause: bot이 flags object에 negRisk: true를 설정하지 않은 채 createAndPostOrder를 호출하고 있었습니다. 시장 중 2개는 historical creation date가 달라 이 flag가 필요했지만, 나머지 6개는 underlying contract가 이미 기본적으로 NegRisk를 통해 routing되고 있었기 때문에 필요하지 않았습니다.
Fix: 모든 market에 대해 Gamma에서 market.negRisk를 읽어, 모든 order call에 그대로 전달했습니다. commit 06deaef. flag를 설정한 뒤 arb를 다시 실행했을 때, 나머지 2개도 올바르게 정산되었습니다.
Lesson: market property를 절대 default로 두지 마세요. 매번 source of truth에서 명시적으로 읽어야 합니다.
Sticky-fail dedup (commit 4c0bef1)
bot이 12초 동안 실패한 buy를 5번 재시도했습니다. 첫 시도는 실제로 성공했지만(network timeout 때문에 bot이 응답을 보지 못함), 이후 4번의 retry가 추가로 4개의 position을 만들었습니다. 원했던 것은 1개였는데 같은 market에 총 5개의 position이 생겼습니다.
Root cause: idempotent client-order-id가 없었습니다. bot의 retry logic은 "실패하면 새 salt로 다시 시도"였습니다. CLOB는 이 retry들을 duplicate로 인식할 방법이 없었습니다.
Fix: 첫 시도 전에 의도한 order마다 deterministic UUID를 생성했습니다. 모든 retry가 같은 client-order-id를 사용하도록 하여 CLOB가 dedup할 수 있게 했습니다. commit 4c0bef1.
Lesson: idempotence 없는 retry는 duplicate입니다. 모든 order에는 안정적인 client-side identifier가 필요합니다.
Whipsaw incident: lol-ctg-ccg
esports match(CTG vs CCG)에서 imbalance가 positive로 바뀌자 bot이 0.45에 buy를 넣었습니다. 30초 안에 imbalance가 negative로 뒤집혔고, 0.50에 걸어둔 GTC sell이 다른 사람의 order에 체결되었습니다. PnL: +5c × 10 shares = +$0.50.
10분 후 같은 market의 imbalance가 다시 positive로 바뀌었습니다. bot은 다시 0.42에 진입했습니다. 이번에는 imbalance가 회복되지 않았고, mid는 0.18까지 drift했으며 position은 resolution 시점에 0까지 내려갔습니다.
Root cause: strategy가 imbalance를 directional signal로 취급했지만, imbalance가 bouncing하고 있다는 점은 추적하지 않았습니다. 두 signal 모두 information이 아니라 noise였습니다. bot은 20분 안에 같은 market에서 두 번의 failed signal에 걸려 whipsaw를 당했습니다.
Fix: market별 cooldown을 두었습니다. fill 후에는 같은 market에 30분 동안 새 entry를 허용하지 않았습니다. 다른 market들에서는 여러 번 entry가 가능했지만, 같은 market에서 연속 진입은 막았습니다.
Lesson: 튀는 signal은 signal이 아닙니다. 행동하기 전에 persistence를 걸러내세요.
Premature go-live: 2025 wipe
새로운 market-making strategy가 12번의 paper trade를 통과했습니다. builder는 30번을 기다리지 않았고, "괜찮아 보인다"고 판단해 $500 capital로 live 배포했습니다. 18시간 만에 wallet은 $200까지 내려갔습니다.
Root cause: 12 trades는 60% WR과 35% WR을 구분하기에 sample이 충분하지 않습니다. 실제 strategy는 35% WR이었고, 12-trade paper window는 우연히 비정상적으로 좋은 streak를 보였을 뿐이었습니다.
30-trade gate는 이유가 있어서 존재합니다. 12-trade sample의 variance는 "이 strategy는 작동하지 않는다"와 구분이 되지 않을 정도입니다.
Lesson: conviction보다 discipline이 중요합니다. 30-trade gate는 협상 대상이 아닙니다.
Sleep-through-bug: kill switch worked
bot의 time-of-day filter에 off-by-one이 있었습니다. 02:00 UTC에 pause해야 했지만 실제로는 03:00 UTC에 pause하고 있었습니다. pause되지 않은 02:00-03:00 사이, Polygon RPC가 요청을 심하게 rate-limiting했고, bot의 read path는 stale data를 반환하고 있었습니다.
bot은 stale prices를 기준으로 계속 trading했습니다. 그 한 시간의 PnL은 22 trades에서 -$3.20이었습니다. daily-loss kill switch가 -5%에서 trigger되어 bot을 halted했고, 03:08 UTC에 Telegram alert를 보냈습니다. builder는 09:00에 halted bot을 확인했고, 총 피해는 kill threshold로 제한되었습니다.
Lesson: bug는 실제였지만 kill switch가 작동했습니다. -$3.20로 끝났지, -$50.00는 아니었습니다. risk controls는 bug를 막아주지는 않지만, 예상 못 한 bug의 비용을 상한선으로 묶어줍니다.
Generalize되는 lessons
모든 postmortem을 통틀어 네 가지 pattern이 반복됩니다.
- API time ≠ chain time. Settlement lag, RPC lag, WebSocket lag는 모두 bot code가 명시적으로 처리해야 하는 gap을 만듭니다.
- Retries need idempotence. client-order-id 없는 retry는 duplicate-order risk입니다. 항상 그렇습니다.
- 모든 market property를 명시적으로 읽으세요. NegRisk flag, tick size, expiration. 절대 default로 두지 말고, 항상 source of truth에서 읽어야 합니다.
- Kill switch는 feature가 아니라 floor입니다. risk controls는 bug로 인한 손실을 제한합니다. strategy는 bug를 막지 않습니다. bot이 올바르게 동작한다고 가정할 뿐입니다. bot은 항상 올바르게 동작하지 않습니다.
이 시리즈의 모든 chapter에는 이런 pattern 중 하나가 어딘가에 들어 있습니다. 이것들이 production bot의 load-bearing principle입니다. 이를 건너뛰면 여러분의 own postmortem에서 다시 만나게 될 것입니다.










