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つのパターンが繰り返し現れます。

  1. API time ≠ chain time。settlement lag、RPC lag、WebSocket lagはすべて、botコードが明示的に処理すべきギャップを生みます。
  2. retryには冪等性が必要。client-order-idのないretryはduplicate-orderのリスクです。常にそうです。
  3. すべてのmarketプロパティを明示的に読む。NegRiskフラグ、tick size、expiration。デフォルトにせず、常に真実のソースから読み取ってください。
  4. kill switchは機能ではなく下限。risk controlはバグによる損失を抑えます。strategiesはバグを防ぎません。botが正しく動くことを前提にしているだけです。botはいつも正しく動くとは限りません。

このシリーズの各章には、どこかにこのうちのどれかが埋め込まれています。これらこそが、本番botの土台となる原則です。無視すれば、自分のpostmortemでまた同じものに出会うことになります。

よくある質問

Polymarket botで最も高くつくミスは何ですか?
paper tradingが30-trade gateに達する前に本番公開してしまうことです。私たちはそれをやりました。ミスは単にお金を失うことではなく、制御された環境で戦略から学ぶ機会を失うことです。早すぎる本番投入は、壊滅して放棄されるか、再度paper tradingをやり直すまでの回復に何か月も無駄にします。
phantom fill bugとは何ですか?
botはorderがfilledだと思っているのに、exchange側はまだfilledではないと記録している状態です。症状:positionがbotのstateにはあるのにchain上にはなく、retryでdouble-orderを招きます。自前のtraderでは3つのコミット(e68a087、8bb7761、06deaef)で修正しました。買いにはFOKを使い、約定するまでstatusをポーリングし、status=delayed をfilledだと決して信じないことです。
lol-ctg-ccg whipsawインシデントとは何ですか?
薄いorder bookのesports marketで、自前のtraderが -$2.55 のstop-lossを0.14で発動し、その後2分以内に価格が0.325まで回復するのを見たインシデントです。thinなesports bookには狭すぎる -4 percentage points でstop-lossを設定していました。修正:低流動性marketではSLを -8pp に広げ、tightなSLは厚いbook(NBA、高流動性サッカー)にのみ残しました。memory/trader-sl-wider.md を参照してください。
NegRiskフラグのバグはどのように現れましたか?
botがmulti-outcome marketで neg_risk=true を設定せずにorderを出していました。orderは分かりにくいエラーメッセージで拒否され、retryまでに数秒の遅延が発生し、その結果fillを逃しました。コミット 06deaef で修正:市場メタデータごとに常に neg_risk を設定し、決して推測しないこと。
sleep-through-bugのインシデントとは何でしたか?
walletが午前4時に詰まったorderで身動きできなくなりました。ownerはbotに停止を指示し、data/halt_autobuy ファイルを触りました。botは次のtrade試行前にそのファイルを検知し、orderの発注を拒否しました。ownerは悪化ではなく、きれいな状態で目を覚ましました。halt-sentinelパターンの有効性が確認され、今ではすべてのbotに標準搭載しています。
これらのpostmortemから得られる、最も一般化しやすい教訓は何ですか?
ハッピーパスを信じないことです。私たちが出したバグはすべて、リクエストが成功した、fillが本物だった、価格は動かない、と仮定したことから生まれました。防御的にコードを書いてください。orderは失敗すると仮定し、reconciliationはずれると仮定し、あるmarketは何かおかしなことを起こすと仮定してください。paranoia taxは小さいですが、それを払わない代償は、後で書くpostmortemです。