Polymarket Bot Tutorial · 第32章中第12章
Polymarketのフェントムフィル(約定したように見えるが実際には約定していない注文)の検出方法、冪等なリトライの実装、status=matched と rested-on-book の見分け方、一時的な障害を乗り切る方法。
この章で扱う内容
フェントムフィルとは、CLOBがマッチを返したのに、チェーン側ではまだERC-1155の転送が確定していない、Polymarket特有の失敗モードです。直後の注文は約5秒以内に、誤解を招く "balance: 0" エラーで拒否されます。対策は冪等性と、決済待ちです。この章は、実運用でお金を払って学んだ本番向けプレイブックです。
- フェントムフィルとは何か
- status=matched / status=delayed / status=posted の違い
- ポーリングパターン: 喜ぶ前にステータスを確認する
- フェントムフィル対策としてのFOK
- client_order_id を使った冪等リトライ
- 実際の本番障害: どう直したか
- コード: detect-then-act の注文後パターン
フェントムフィルとは何か
フェントムフィルとは、CLOB APIが注文に対して status: "matched" を返したのに、オンチェーンのERC-1155転送がまだ決済されていない状態です。CLOBのマッチング処理はPolygonのブロック生成(約2秒/ブロック)より速いです。APIでマッチが返ってから約2〜5秒の間、あなたのウォレットは、マッチャーが所有していると言うトークンをチェーン上ではまだ保持していません。
ボットのバグは、この時間帯にフォローアップ操作-通常は利益確定のためのGTC売り-を実行したときに発生します。CLOBはチェーン残高を確認し、ゼロだと判断して not enough balance / allowance: balance: 0, order amount: N で拒否します。エラーメッセージはallowanceを原因のように見せますが、実際の原因は決済遅延です。
初めてこれが起きると、allowanceのバグだと思って1時間を無駄にします。対策はシンプルです。待つ、確認する、それから出す。
status=matched / status=delayed / status=posted の違い
注文発行のレスポンスには、重要な3つのステータスがあります。
matched: 注文が即座に板とマッチした状態です。残高は2〜5秒で決済されます。成功したFOK/FAKが返すのはこれです。delayed: マッチャーが同期的に決済できず、マッチをキューに入れた状態です。まれですが、通常は混雑を示します。待機 + 確認パターンの観点ではmatchedと同様に扱ってください。posted(liveとも呼ばれる): 注文が未約定のまま板に残っている状態です。即座にマッチしなかったGTC注文が返します。残高への影響はなく、まだフォローアップ操作は不要です。
判断ルール: ステータスが matched または delayed の場合、新しい残高を必要とするフォローアップは、チェーン転送を確認するまで行わないでください。
ポーリングパターン: 喜ぶ前にステータスを確認する
確認パターン: マッチ成功後、CTF残高が新しいトークンを反映するまでポーリングし、それから次へ進みます。
def wait_for_settlement(token_id, expected_size, timeout=15):
"""Block until on-chain balance reaches expected_size or timeout."""
start = time.time()
while time.time() - start < timeout:
bal = ctf_contract.functions.balanceOf(PROXY, token_id).call()
if bal >= expected_size:
return True
time.sleep(0.5)
return False
典型的な決済時間: 通常のネットワーク状況では2〜5秒、Polygonの混雑時は最大15秒です。5秒待機で95%のケースをカバーできますが、本番ではtimeoutを15秒に設定し、タイムアウト時はアラートを出してください。
待機で戦略ループを止められない高頻度ボットでは、代替としてイベント購読があります。CTFの TransferSingle イベントを自分のプロキシアドレスで監視し、受信時に下流のアクションを起動します。これにより、待機を戦略ループ内でブロックするのではなく、キュー側に逃がせます。
フェントムフィル対策としてのFOK
FAKよりFOKを選ぶことは、フェントムフィルの混乱に対する部分的な防御になります。FOKは注文全体が約定するか、cancelled を返すかのどちらかです。FAKは部分約定の filled_size を返すことがあります。部分約定の後に、元の注文サイズのままGTC売りを出すと、売りは決済遅延とサイズ不一致の両方で失敗します。これは2つのバグが重なったものです。
FOKならサイズは二択です。全サイズがマッチしたか、何も起きなかったかのどちらかです。後続の発注ロジックは常に想定を合わせやすくなります。
ただし、これで待機が不要になるわけではありません。完璧なFOKマッチでも2〜5秒の決済ウィンドウの影響を受けます。とはいえ、帳簿上の不整合の1種類は除去できます。
client_order_id を使った冪等リトライ
注文発行中のネットワーク障害は最悪のケースを生みます。ボットのHTTP呼び出しはタイムアウトしたが、注文が届いたかどうかは不明、という状態です。無差別にリトライすると二重発注になる可能性があり、リトライしないとポジションを落とす可能性があります。
解決策は注文発行時の client_order_id フィールドです。意図した注文ごとに決定的なUUIDを生成します。サーバーがそのIDを以前に見ていれば、新規作成ではなく既存注文のステータスを返します。
import uuid
oid = str(uuid.uuid4()) # generate once, retry with same value
for attempt in range(3):
try:
resp = c.create_and_post_order(args, OrderType.FOK, client_order_id=oid)
return resp
except (TimeoutError, ConnectionError):
time.sleep(0.5 * (2 ** attempt))
raise RuntimeError("post failed after 3 attempts")
パターンはこうです。最初にIDを生成し、トランスポート障害ではリトライし、論理的な拒否ではリトライしない。サーバー側の重複排除はAPIキー単位で、約5分間有効です。
実際の本番障害: どう直したか
自分たちの本番日誌より、2025年5月の事例です。60分の間にトレーダーボットが22件の買い注文を出し、すべてマッチしたのに、GTC売りが受け付けられたのは14件だけでした。8件のポジションで出口注文が出ていませんでした。
原因は、買い約定から800ms後にGTC売りを出していたことでした。これはチェーンがERC-1155転送を確定するよりはるか前です。CLOBは "balance: 0" メッセージで拒否し、ボットはエラーを記録しただけで再試行しませんでした。8件のポジションは利益確定保護なしで、そのまま清算まで引っ張られました。3件は不利な価格でクローズし、1件は運よく0.99で閉じました。
修正は、同一トークンに対する買い約定とGTC発注の間に5秒のブロッキング待機を入れる形でリリースしました。30回のペーパー取引と30回のライブ取引で検証し、それ以降、balance-zero エラーはゼロです。
教訓: 静かなエラーパスは、派手なエラーパスより高くつきます。この件以降、フェントムフィル関連のエラーはすべてTelegramアラートを飛ばすようにし、将来のドリフトモードも数秒以内に可視化されるようにしました。
コード: detect-then-act の注文後パターン
本番向けの買い→発注パターンです。
def buy_then_post_tp(token_id, size, buy_price, tp_price):
# 1. Place FOK buy
buy_args = OrderArgs(token_id=token_id, price=buy_price, size=size, side="BUY")
buy_resp = c.create_and_post_order(buy_args, OrderType.FOK)
if buy_resp.status != "matched":
return {"ok": False, "stage": "buy", "reason": buy_resp.status}
# 2. Wait for on-chain settlement (5s sane default; bump for congestion)
if not wait_for_settlement(token_id, expected_size=size, timeout=15):
return {"ok": False, "stage": "settle", "reason": "timeout"}
# 3. Confirm minimum size for GTC
if size < 5:
# GTC won't accept; fall back to ride-to-resolve or FOK sell at TP later
return {"ok": True, "stage": "buy_only", "note": "size<5, no GTC posted"}
# 4. Post GTC sell
sell_args = OrderArgs(token_id=token_id, price=tp_price, size=size, side="SELL")
sell_resp = c.create_and_post_order(sell_args, OrderType.GTC)
return {"ok": sell_resp.status in ("posted","live"), "buy": buy_resp, "sell": sell_resp}
このパターンは、フェントムフィル、一時的なネットワーク切断、GTC最小サイズ未満といった一般的な失敗モードに耐えます。戦略レイヤーが、どれをリトライし、どれをログし、どれをアラートにするかを決めるのに十分な情報を返します。














