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 と同様に扱ってください。
  • postedlive とも呼ばれる): 注文が未約定のまま板に残っている状態です。即座にマッチしなかった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最小サイズ未満といった一般的な失敗モードに耐えます。戦略レイヤーが、どれをリトライし、どれをログし、どれをアラートにするかを決めるのに十分な情報を返します。

よくある質問

Polymarketのフェントムフィルとは何ですか?
フェントムフィルとは、ボットが注文は約定したと思っているのに、取引所側ではまだ未約定(または部分約定)として記録されている状態です。クライアントコードがHTTP 200レスポンスを約定確定と誤解し、実際にはレスポンスがマッチングエンジンに注文が受理されたことを意味するだけのときに起こります。私たちはこれを痛い形で学びました。トレーダー履歴の commit 06deaef、8bb7761、e68a087 が、まさにこの問題を修正しています。
フェントムフィルを避けるにはどうすればいいですか?
3つのルールがあります。(1) 買いにはFOK注文を使う。注文は完全約定するか完全に消えるかのどちらかで、曖昧さがありません。(2) matched 以外のステータスは未約定として扱う。status=matched または amount_filled > 0 になるまで注文ステータスをポーリングします。(3) 冪等性のために client_order_id(V2では clientOrderId)を使い、リトライで二重約定しないようにします。
status=delayed は何を意味しますか?
注文はマッチングエンジンには入っていますが、まだ完全には約定していない状態です。数秒以内に約定することもあれば、板に残ることもあります。常にポーリングしてください。status が5〜10秒以上 delayed のままで、amount_filled が0なら、未約定として扱い、キャンセルを検討してください。
二重約定せずに安全にリトライするにはどうすればいいですか?
論理的な取引試行ごとに一意の client_order_id を生成し、すべてのリトライでそれを渡してください。取引所は client_order_id で重複排除するため、同じIDでの再試行は再発注ではなく重複として拒否されます。実装例: Python の OrderArgs.client_order_id、Node の CreateOrderOptions.clientOrderId。
注文エンドポイントからの 200 OK は信頼できますか?
いいえ。200 OK は「リクエストがマッチングエンジンに受理された」ことを意味するだけで、「注文が約定した」ことではありません。送信後は orderId で注文ステータスをポーリングし、status=matched(または amount_filled > 0)だけを真の約定として扱ってください。
注文送信とレスポンス確認の間にボットがクラッシュしたらどうなりますか?
再起動後、SDKを使って未決済注文と最近の約定を照会してください。ローカルの記録/状態と突き合わせます。時刻 T に注文を送ったのに記録がなければ、T 以降の注文を query し、client_order_id で照合します。それでも見つからなければ、注文はマッチングエンジンに届いていないので、安全に再送できます。