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이 반복됩니다.

  1. API time ≠ chain time. Settlement lag, RPC lag, WebSocket lag는 모두 bot code가 명시적으로 처리해야 하는 gap을 만듭니다.
  2. Retries need idempotence. client-order-id 없는 retry는 duplicate-order risk입니다. 항상 그렇습니다.
  3. 모든 market property를 명시적으로 읽으세요. NegRisk flag, tick size, expiration. 절대 default로 두지 말고, 항상 source of truth에서 읽어야 합니다.
  4. Kill switch는 feature가 아니라 floor입니다. risk controls는 bug로 인한 손실을 제한합니다. strategy는 bug를 막지 않습니다. bot이 올바르게 동작한다고 가정할 뿐입니다. bot은 항상 올바르게 동작하지 않습니다.

이 시리즈의 모든 chapter에는 이런 pattern 중 하나가 어딘가에 들어 있습니다. 이것들이 production bot의 load-bearing principle입니다. 이를 건너뛰면 여러분의 own postmortem에서 다시 만나게 될 것입니다.

자주 묻는 질문

가장 비싼 Polymarket bot 실수는 무엇인가요?
paper-trading이 30-trade gate를 통과하기 전에 live로 나가는 것입니다. 우리는 실제로 그렇게 했습니다. 이 실수는 단순히 돈을 잃는 것만이 아닙니다. 통제된 환경에서 strategy를 학습할 기회를 잃는다는 뜻입니다. 너무 일찍 live로 간 bot은 nuked되어 버려지거나, re-paper-trading 전에 회복하느라 몇 달을 허비합니다.
phantom fill bug란 무엇인가요?
bot은 order가 filled되었다고 믿지만 exchange는 아직 filled되지 않았다고 기록하는 경우입니다. 증상: position은 bot state에는 보이지만 on-chain에는 없어서 retry 시 double-order가 발생합니다. 우리 trader에서는 세 개의 commit(e68a087, 8bb7761, 06deaef)으로 수정했습니다. buy에는 FOK를 사용하고, matched될 때까지 status를 poll하며, status=delayed를 filled로 절대 믿지 않습니다.
lol-ctg-ccg whipsaw incident는 무엇인가요?
얇은 order book의 esports market에서 우리 trader가 -$2.55 stop-loss를 0.14에 발동시켰고, 이후 2분 안에 가격이 0.325까지 회복되는 것을 지켜본 incident입니다. 우리는 thin esports book에는 너무 빡빡한 -4 percentage points로 stop-loss를 설정해 두었습니다. Fix: low-liquidity market에는 SL을 -8pp로 넓히고, 더 두꺼운 book(NBA, high-liquidity soccer)에만 더 타이트한 SL을 유지했습니다. memory/trader-sl-wider.md를 참고하세요.
NegRisk flag bug는 어떻게 드러났나요?
bot이 multi-outcome market에서 neg_risk=true를 설정하지 않은 채 order를 넣었습니다. 주문은 혼란스러운 error message와 함께 rejected되었고, retry 전 몇 초의 지연이 생겼으며, 결국 missed fills로 이어졌습니다. commit 06deaef에서 수정: market metadata마다 항상 neg_risk를 설정하고, 절대 가정하지 않습니다.
sleep-through-bug incident는 무엇이었나요?
wallet이 새벽 4시에 멈춘 order 때문에 wedged되었습니다. owner는 bot에게 halt를 지시했고, data/halt_autobuy 파일을 건드렸습니다. bot은 다음 trade attempt 전에 이 파일을 감지하고 order placement를 거부했습니다. owner는 더 나쁜 상태가 아니라 깨끗한 상태의 bot을 아침에 확인했습니다. halt-sentinel pattern이 검증되었고, 지금은 모든 bot에 기본으로 포함됩니다.
이 postmortem들에서 가장 일반화 가능한 단 하나의 lesson은 무엇인가요?
happy path를 절대 믿지 마세요. 우리가 배포한 모든 bug는 요청이 성공했다고, fill이 진짜라고, 가격이 움직이지 않을 거라고 가정한 데서 나왔습니다. 방어적으로 code를 작성하세요: order는 실패한다고 가정하고, reconciliation은 어긋난다고 가정하고, 어떤 market은 곧 이상한 일을 벌일 거라고 가정하세요. paranoia tax는 작습니다. 이를 생략하는 비용은 나중에 쓰게 될 postmortem입니다.