Polymarket Bot Tutorial · 32章中32章目
実際のPolymarket botのミスとポストモーテム:phantom fills、sticky-fail dedup、lol-ctg-ccgのwhipsaw、NegRiskフラグのバグ、premature go-live - それぞれを修正したコミットと日付つきで解説します。
この章で扱う内容
実運用で実際に損失を出したバグの記録です。重要なのは個々の詳細よりもパターンです。同じ種類のバグはbot全体で繰り返し発生し、対策はたいてい、より良い戦略ではなく足りなかったウォッチドッグです。この章は、その授業料を払わずに済むようにするためのものです。
- Phantom fills(コミット e68a087、8bb7761)
- NegRiskフラグのバグ(コミット 06deaef)
- Sticky-fail dedup(コミット 4c0bef1)
- Whipsawインシデント: lol-ctg-ccg
- Premature go-live: 2025 wipe
- Sleep-through-bug: kill switchは動作した
- 一般化できる教訓
Phantom fills(コミット e68a087、8bb7761)
2025年5月、自前のtraderで起きた最初の大きなphantom-fillインシデントです。botは22件のFOK買いを出し、すべてがCLOBで約定しました。botは直ちに22件のGTC売りを出そうとしました。ところがそのうち8件が "balance: 0 / sum of active orders: 0 / order amount: 10000000." で拒否されました。
原因はsettlement lag(第12章)です。CLOBは100msで約定した一方、botは200ms後に売りを出しましたが、PolygonのERC-1155 transferには約2秒かかりました。チェーン上ではまだ残高ゼロだったため、CLOBがその売りを拒否したのです。
修正:成功した買いと同じtokenへのGTCフォローアップの間に、5秒のblocking waitを挟む。コミット e68a087 と 8bb7761。以降、phantom-fillインシデントはゼロです。
教訓:API timeとchain timeは別の時間軸です。同時進行だと仮定したコードは、このまさに同じ失敗モードにぶつかります。
NegRiskフラグのバグ(コミット 06deaef)
候補が8つあるNegRiskのmulti-outcomeイベントで、YES asks合計が0.982となり、一時的に1.8cのarbが発生しました。arberは8件すべてのFOK買いを発動しました。6件は約定し、2件は誤ったexchange contractに決済されました。
原因は、botがflagsオブジェクトに negRisk: true を設定せずに createAndPostOrder を呼んでいたことです。2つのmarketは履歴上の作成日時が異なり、このフラグが必要でした。一方、残りの6つは、基盤となるcontractがデフォルトでNegRisk経由にルーティングされていたため、フラグは不要でした。
修正:すべてのmarketについてGammaから market.negRisk を読み取り、すべてのorder呼び出しに引き渡す。コミット 06deaef。フラグ設定後にarbを再実行したところ、残りの2件も正しく決済されました。
教訓:marketのプロパティをデフォルト扱いしてはいけません。毎回、真実のソースから明示的に読み取ってください。
Sticky-fail dedup(コミット 4c0bef1)
botは失敗したbuyを12秒の間に5回リトライしました。最初の試行は実際には成功していましたが、network timeoutのせいでbotが応答を見られなかったのです。続く4回のretryで、さらに4つのポジションが作られました。1つ欲しかったのに、同じmarketで合計5ポジションになってしまいました。
原因は、冪等な client-order-id がなかったことです。botのretryロジックは「失敗したら、新しいsaltで再試行」でした。CLOBにはそれらのretryをduplicateとして認識する方法がありませんでした。
修正:最初の試行の前に、意図したorderごとに決定論的なUUIDを生成する。すべてのretryで同じ client-order-id を使うことで、CLOBがdedupできるようにします。コミット 4c0bef1。
教訓:冪等性のないretryはduplicateです。すべてのorderには、安定したクライアント側識別子が必要です。
Whipsawインシデント: lol-ctg-ccg
esports match(CTG対CCG)で、imbalanceが正に反転した際にbotが0.45でbuyに入りました。30秒以内にimbalanceは負に反転し、0.50のGTC sellが他人のorderにヒットしました。PnLは +5c × 10 shares = +$0.50 でした。
10分後、同じmarketのimbalanceが再び正に反転しました。botは0.42で再度entryしました。今回はimbalanceが回復せず、midは0.18まで下落し、そのポジションはresolutionまで0で戻りました。
原因は、この戦略がimbalanceを方向性シグナルとして扱っていた一方で、imbalanceが行ったり来たりしていることを追跡していなかったことです。どちらのシグナルも情報ではなくノイズでした。botは20分以内に、同じmarket上の2つの失敗シグナルでwhipsawされたのです。
修正:marketごとのcooldownを導入する。約定後は、同じmarketへの新規entryを30分禁止します。異なるmarketへの複数entryは許可しつつ、同一marketでの連続entryは防ぎます。
教訓:行ったり来たりするシグナルはシグナルではありません。行動する前に持続性をフィルタしてください。
Premature go-live: 2025 wipe
新しいmarket-making戦略は12回のpaper tradeに合格しました。builderは30回まで待たず、「良さそうだ」と判断して、$500の資金で本番投入しました。18時間以内にwalletは$200まで減りました。
原因は、12回のtradeでは60% WRと35% WRを見分けるにはサンプルが足りないことです。実際の戦略は35% WRで、12回のpaper windowにはたまたま偏った連勝が入っていただけでした。
30-trade gateには理由があります。12回のtradeサンプルの分散では、「戦略が機能していない」状態と見分けがつきません。
教訓:信念より規律です。30-trade gateは譲れません。
Sleep-through-bug: kill switchは動作した
botのtime-of-day filterにオフバイワンがあり、02:00 UTCで止めるつもりが、実際には03:00 UTCで止まる設定になっていました。停止されていない02:00-03:00の間、Polygon RPCはリクエストを激しくrate-limitしており、botのread pathは古いデータを返していました。
botは古い価格で取引を続けました。その1時間のPnLは、22件のtradeで -$3.20 でした。daily-loss kill switchが -5% で発動し、botを停止、03:08 UTCにTelegram alertを送信しました。builderが09:00に目を覚ますとbotは停止済みで、被害はkill threshold内に抑えられていました。
教訓:バグ自体は本物でしたが、kill switchは機能しました。-$3.20で済み、-$50.00 にはなりませんでした。risk controlはバグを防ぐものではありません。想定外のバグのコストを上限で抑えるものです。
一般化できる教訓
すべてのpostmortemを通じて、4つのパターンが繰り返し現れます。
- API time ≠ chain time。settlement lag、RPC lag、WebSocket lagはすべて、botコードが明示的に処理すべきギャップを生みます。
- retryには冪等性が必要。client-order-idのないretryはduplicate-orderのリスクです。常にそうです。
- すべてのmarketプロパティを明示的に読む。NegRiskフラグ、tick size、expiration。デフォルトにせず、常に真実のソースから読み取ってください。
- kill switchは機能ではなく下限。risk controlはバグによる損失を抑えます。strategiesはバグを防ぎません。botが正しく動くことを前提にしているだけです。botはいつも正しく動くとは限りません。
このシリーズの各章には、どこかにこのうちのどれかが埋め込まれています。これらこそが、本番botの土台となる原則です。無視すれば、自分のpostmortemでまた同じものに出会うことになります。





