Hyperliquid注文Botの二重発注を防ぐ|WebSocketが途切れた注文の扱い

コラム

/約18分で読めます

コラム

/約18分

Hyperliquid注文Botの二重発注を防ぐ|WebSocketが途切れた注文の扱い
目次(タップで折りたたみ)

    Hyperliquidのテストネットで、注文Botを作っている開発者がいます。Botが注文を送った直後、通信が切れて応答が返ってきませんでした。注文は通ったのか、通っていないのか。分からないまま「失敗した」と判断して送り直すと、同じ注文が2回通ることがあります。そうなれば、持つつもりの倍のポジションを抱えるおそれがあります。

    これを防ぐには、応答が途切れた注文を「結果不明」として扱います。取引所に、注文ID・未約定の注文・約定履歴を問い合わせて確かめ終わるまで、新しい注文は送りません。

    そのために、3つの仕組みを別々に持ちます。WebSocketの通知、処理の履歴を持つ手元のDB、そして取引所への問い合わせです。

    注文にはcloidという追跡用のIDを付けます。cloidは、注文を照会するための目印です。付けただけで再送が一度に抑えられるわけではありません。そこで、送信する権利をアプリの側で1回分だけ確保し、応答の分からない要求は、確かめ終わるまで保留します。

    以下では、この開発者が注文の同期を組み立てる順に、状態の持ち方、イベントとDBの形、公式Python SDK 0.24.0につなげられる最小実装、障害を起こすテスト、再起動の手順を見ていきます。投資戦略や売買のシグナルは扱いません。既定値はdry-run(実際には発注しない試し運転)です。

    この話は、AI取引エージェントを本番で動かすための連載のうち「注文の同期」にあたり、結果不明(UNKNOWN)の扱いと照合の手順をここに集めています。全体の構成はHyperliquid AI取引エージェントの本番設計にまとめています。

    この記事で使う言葉

    • cloid/OID:自分で付ける注文ID(client order ID)と、取引所が付ける注文ID
    • 冪等キー:同じ要求が何度届いても、1回分として扱うためのID
    • outbox:送信する要求を、DBの中に先に記録しておく待ち行列。送信前にプロセスが落ちても、未送信の要求から戻れる
    • 照合:取引所の状態と、手元のDBの記録を突き合わせること
    • Info API:Hyperliquidの読み取り専用の照会API(Info endpoint)

    AI・判定・署名・同期は、それぞれ別の担当にする

    同期の仕組みは、ほかの担当と切り離して作ります。流れは次のとおりです。

    1. AIの提案を判定器(Policy/Risk Engine)が判定し、intent_idと変更できない要求を作ります。
    2. SQLite・PostgreSQLのトランザクションで、注文と送信用の待ち行列(outbox)を記録します。
    3. 署名・送信Workerが、記録済みの要求だけをHyperliquidへ送ります。
    4. HyperliquidからのorderUpdates・userFillsを受け取ります。
    5. 照合処理がInfo APIで取引所の状態を確かめ、DBへ書き戻します。

    AIが作るのは、決まった形式の注文意図までです。価格・数量・銘柄・期限・上限は、判定器が機械的な規則で検査します。秘密鍵は、AIへの指示文にもログにも同期Workerにも渡さず、切り離した署名Workerだけが持ちます。

    同期Workerは、発注の前に冪等キーを確保し、取引所で見えた結果を状態機械に反映します。判定の中身は判定エンジンと緊急停止、鍵の扱いはAPI Walletの安全な署名設計で扱います。

    本番では最初から注文を出さない設定(enabled=false、想定元本0)にしておきます。人が明示的に承認したときだけ、注文を出せるようにします。テストネットでも実注文は取引を伴うので、ここでの自動テストは外部へ送信しません。

    WebSocketのスナップショットと更新を区別する

    公式のWebSocketは、本番のwss://api.hyperliquid.xyz/wsと、テストネットのwss://api.hyperliquid-testnet.xyz/wsを提供しています。

    気をつけたいのは、購読した直後の動きです。userFillsなどは、購読直後に過去の分をまとめたスナップショットを送ってきます。その最初のメッセージにはisSnapshot: trueが付くので、その後の更新と区別できます。

    また、サーバーは60秒間クライアントから何も届かない接続を閉じます。そこで{"method":"ping"}を送り、pongが返るかを見張ります(WebSocket公式仕様、Subscription公式仕様)。

    切断から戻るときは、次の順で進めます。

    1. 接続の世代を表すconnection_epochに新しい番号を振り、orderUpdatesとuserFillsを購読し直す。
    2. 最初のスナップショットは「過去のイベントの再配信」として重複を除く。DBの状態を無条件に巻き戻さない。
    3. 購読の応答を受けたら、Info APIで照合を走らせ、切断の直前から今までの抜けを埋める。
    4. 照合が終わるまでは、新しい注文意図を送らない。取消やreduce-only(ポジションを減らすだけの注文)も、判定ルールに従って制限する。

    SDKのコールバックのスレッドで、DBの処理や照合まで済ませようとしないでください。受け取ったイベントは、永続化した待ち行列へすぐ渡します。

    接続の状態と注文の状態も混ぜません。接続が戻っても(WebSocketがCONNECTEDでも)、取引所の状態と手元の記録を突き合わせ終わるまでは、新しい注文を送りません。

    注文の状態に「結果不明」を持たせる

    冒頭のとおり、送った注文は、応答がなくても取引所に届いているかもしれません。そのため、再送はどの状態からも行いません。そして結果不明(unknown)からは、照合で確定したときにしか抜けません。

    ただ1つの例外として、取消中(cancel_pending)の取消は、同じ操作IDで判定します。状態の移り方は次のとおりです。

    いまの状態移ってよい次の状態根拠
    pendingaccepted / open / partially_filled / filled / rejected / unknown送信前のDB確保、応答、照会、約定
    acceptedopen / partially_filled / filled / cancel_pending / rejected / unknown取引所が受け付けた後
    openpartially_filled / filled / cancel_pending / canceled / unknown注文の更新と約定
    partially_filledpartially_filled / filled / cancel_pending / canceled / unknown累積約定量は増える一方
    cancel_pendingpartially_filled / filled / canceled / unknown取消中の約定もあり得る
    unknownaccepted / open / partially_filled / filled / canceled / rejected照合で確定した結果だけ
    filled / canceled / rejectedなし終端。重複イベントは監査のためだけに保存

    unknownはエラーではありません。「外に与えた影響を確定できない」状態です。運用者が照合する対象として残し、タイムアウトをrejectedに書き換えません。

    公式の注文状態には、open、filled、canceled、triggered、rejectedがあります。ほかにもscheduledCancelやreduceOnlyCanceledなど、取消・拒否の理由ごとの値があります。手元の状態にまとめ直しても、元の状態とデータは監査ログに残します(注文照会の公式仕様)。

    intent IDとcloidで、何度届いても1回分として扱う

    「少なくとも1回は届く」処理では、同じものが重なって届くことがあります。重なっても1回分として扱えるよう、2つのIDを使い分けます。

    • intent_id:業務上の、一つひとつの判断を表すID
    • cloid:取引所への照会に使う128ビットのclient order ID。UUIDの32桁の16進数に0xを付けて使う

    cloidは照会の目印なので、同じ意図を再試行するときに作り直してはいけません。注文データを正規化したJSONにしてハッシュを取り、同じintent IDで中身が変わった要求は断ります。cloidの形式と指定方法はExchange endpoint公式仕様で確認できます。

    {
      "intent_id": "018f0b7d-...",
      "cloid": "0x018f0b7d...32-hex-digits",
      "account": "0xMAIN_ACCOUNT",
      "network": "testnet",
      "order": {"coin":"BTC","side":"buy","size":"0.001","limit_px":"10000","reduce_only":false},
      "policy": {"market_data_max_age_ms":2000,"max_notional_usd":"10","approval_id":"human-test-001"}
    }

    DBのトランザクションの中で、orders.intent_idとorders.cloidのUNIQUE制約と、送信用のoutboxを同時に作ります。送信するのは、コミットの後だけです。

    こうしておけば、送信の前にプロセスが落ちても、未送信のoutboxから戻れます。送信の後に落ちても、cloidで照会して戻れます。どちらの場合も、同じ意図を送り直す必要はありません。

    イベントは、重複・逆順・出どころを確かめられる形にする

    {
      "$schema": "https://json-schema.org/draft/2020-12/schema",
      "required": ["event_id","intent_id","source","received_at_ms","payload"],
      "properties": {
        "event_id": {"type":"string","description":"source+oid+status+timestampのhash"},
        "intent_id": {"type":"string","format":"uuid"},
        "source": {"enum":["ws_order_updates","ws_user_fills","info_query","local_timeout","operator"]},
        "connection_epoch": {"type":["integer","null"]},
        "exchange_at_ms": {"type":["integer","null"]},
        "received_at_ms": {"type":"integer"},
        "is_snapshot": {"type":"boolean"},
        "payload": {"type":"object"}
      }
    }

    event_idを主キーにすれば、同じイベントが重なって届いても害はありません。同じ注文については、取引所のタイムスタンプと累積約定量を比べます。古いイベントは、監査用に保存しても状態には反映しません。

    ただし、時計だけを信用しないでください。filledなどの終端をopenへ戻さない、という遷移の規則と組み合わせます。

    手で出した注文は、未知のcloid、またはcloidなしとして、別のレコードに取り込みます。自動Botの意図に、推測で結びつけることはしません。

    DBは注文・イベント・outboxの3つの表に分ける

    CREATE TABLE orders (
      intent_id UUID PRIMARY KEY, cloid CHAR(34) UNIQUE NOT NULL,
      request_hash CHAR(64) NOT NULL, account_address CHAR(42) NOT NULL,
      state TEXT NOT NULL, oid BIGINT, requested_size NUMERIC NOT NULL,
      filled_size NUMERIC NOT NULL DEFAULT 0,
      last_exchange_ms BIGINT, version BIGINT NOT NULL DEFAULT 0
    );
    CREATE TABLE order_events (
      event_id CHAR(64) PRIMARY KEY, intent_id UUID NOT NULL REFERENCES orders,
      source TEXT NOT NULL, exchange_ms BIGINT, received_ms BIGINT NOT NULL,
      connection_epoch BIGINT, payload JSONB NOT NULL
    );
    CREATE TABLE order_outbox (
      intent_id UUID PRIMARY KEY REFERENCES orders, request JSONB NOT NULL,
      delivery_state TEXT NOT NULL DEFAULT 'pending',
      claimed_at TIMESTAMPTZ, sent_at TIMESTAMPTZ,
      attempt_count INTEGER NOT NULL DEFAULT 0
    );

    送信Workerは、外へ送る前に、行の状態をpendingからsendingへ移して保存します。sendingのまま再起動した行は、未送信に戻しません。unknownとして、cloidでの照会を先にします。

    Workerが複数あるときは、行ロックか比較交換(compare-and-swap、値が変わっていないときだけ書き換える方法)でversionを更新します。

    SQLiteの例は、1プロセスで学ぶためのものです。本番では、次のものも必要になります。

    • 署名・送信を同時に1件だけ受け持つための担当権(リース)
    • 監査ログの保持、DBのバックアップと暗号化
    • 時刻の同期

    動く最小実装で「タイムアウトの後に送らない」ことを試す

    参照実装(ZIP)には、pyproject.toml、SQLiteの保存層、状態機械、照合Worker、pytestを収めました。鍵やネットワークへの送信は含まず、既定はdry-runです。

    python -m venv .venv
    . .venv/bin/activate
    pip install -e '.[test]'
    pytest -q
    
    # expected
    12 passed

    最小実装のsubmit_once()は、まずDBで意図と送信の権利を確保し、それから送信します。TimeoutErrorが起きたらunknownへ移します。同じ意図の2回目はalready_claimedとなり、送信関数を呼びません。

    reconcile()は、cloidでの照会、未約定注文、約定履歴の順に確かめます。公式の注文照会が返す外側の{"status":"order","order":{...}}と{"status":"unknownOid"}は、アダプタでそろえます。cloidを持たない約定は、cloidの照会でOIDが確定するまで結びつけません。

    照合の問い合わせは、レート制限の範囲で優先順位を付ける

    Info endpointには、IPごとに使える重みの上限があります。主な数字は次のとおりです。

    • IP単位の重み(weight)の上限は1,200/分
    • 照合でよく使うorderStatusとclearinghouseStateは、重み2と軽い
    • 未約定注文の一覧を含む、ほかの多くの照会は重み20
    • userFillsやhistoricalOrdersなどは、返す件数20件ごとに重みが加わる

    ですから、個別の注文を確かめるなら、一覧を取り直すよりorderStatusを使う方が、制限の枠を食いません。

    Exchange endpointには、アドレスごとの累計取引額に応じた上限もあります(2026年9月24日確認)。固定の値を記事から写さず、運用を始めるときに公式のRate Limitsを確かめ直してください。そのうえで、共通のトークンバケット(一定の速さで補充される利用枠)で予算を確保します。

    照合の優先順位は次のように決めます。

    • 優先度0:送信のタイムアウト、取消のタイムアウト、部分約定の後のunknown。cloidでの個別照会を先にする。
    • 優先度1:再接続の後の、終端に達していない注文。未約定注文と直近の約定をまとめて照合する。
    • 優先度2:通常の定期照合。15〜60秒など、注文の数と予算から動的に決める。
    • 429・5xx:Retry-After(待つべき時間を示す応答ヘッダー)があれば従う。なければ待ち時間を指数的に延ばし、全幅の揺らぎ(full jitter)を加える。発注の再試行とは分ける。

    市場データが古いときは、新しい注文を止めます。照合がうまくいっても、市場データが新しいとは限りません。WebSocketの接続数、購読数、アドレスごとの制限も、別々の予算として見張ります。

    わざと障害を起こして、止まるべきところで止まるかを確かめる

    注入する障害正しい動きやってはいけない動き
    送信後のタイムアウトunknownにし、cloidで照会、送信回数は1失敗扱いにして発注し直す
    重複・順序が逆のイベントイベントを重複排除し、終端と累積約定量を保つfilledからopenへ巻き戻す
    WebSocket切断再購読+スナップショット+Info照合が終わるまで新規の送信を止める再接続の直後に発注する
    部分約定の後にプロセスが落ちるDBから復元し、残数量と取消の状態を照合元の数量で発注し直す
    429・5xx予算を減らし、揺らぎ付きで待つ。優先度0を先に全Workerが同時に再試行する
    市場データが古い新規発注は止め、照合は続ける古い価格で注文を作る
    手動の注文外部の注文として分けて運用者へ通知時刻の近い意図に自動で結び付ける
    API Walletのnonce競合署名Workerを一列に並べ、Walletを分ける複数プロセスで同じWalletを調整なしに使う

    nonce(署名ごとに付ける、使い回せない番号)は、API Walletごとに管理されます。そのため、同じAPI Walletを複数のプロセスで使うと、ぶつかることがあります。対策は、プロセスごとにAPI Walletを分けることです。秘密鍵を同期Workerにまで広げる解決策は採りません。nonceの規則と鍵の管理はAPI Walletの安全な署名設計にまとめています。

    監視では、結果不明の滞留と照合の抜けを中心に見る

    • orders_unknown_total、最も古いunknownの経過秒数、口座・ネットワーク・理由ごとの件数
    • WebSocketの切断回数、pongの遅れ、接続の世代、再購読から照合完了までの秒数
    • 重複・逆順のイベント数、イベントを受け取ってからDBに反映するまでの遅れ
    • 照合の待ち行列の深さ、APIの重みの消費量、429・5xx、待機した秒数
    • 要求数量と累積約定量の差、取引所の未約定注文と手元の終端前の注文との差
    • 古い市場データ、判定での拒否、緊急停止、手動注文の検知

    unknownの注文について、運用者が確かめるのは次のものです。cloid、OID、送信時刻、要求のハッシュ、署名Workerの監査ID、cloidでの照会結果、未約定注文、約定履歴。

    証拠がないまま押せる「再送」ボタンは用意しません。新しい注文が必要なら、3つを求めます。元の意図を終端にした根拠、別のintent ID、人の承認です。

    再起動は、発注を止めた状態から始める

    1. すべての送信Workerを止め、緊急停止と本番の無効を確かめる。DBのスナップショットと時刻同期の状態を保存する。
    2. outbox、pending、unknown、cancel_pending、partially_filledを抜き出し、要求のハッシュに重複がないか調べる。
    3. 読み取り専用のInfo APIで、各cloid、未約定注文、切断前からの約定を照合する。署名鍵は要らない。
    4. WebSocketへ接続し、pongを確かめて購読し直す。スナップショットの重複を除いてから、もう一度Info APIで照合する。
    5. 次のすべてを確かめる。取引所とDBの差が0。unknownは運用者が承認済み。市場データが新しい。レート制限の予算が戻った。
    6. テストネット・dry-runで、カナリア(試しに流す1件)の注文意図を通す。送信回数、cloid、状態、約定量、監査ログを確かめる。
    7. 人が承認したネットワークと上限の範囲だけ、段階的に再開する。異常があれば新規の送信を止めて、手順2へ戻る。

    止めるときは、新しい意図の受け付けを止めます。outboxは処理し切らず、保存したまま残します。WebSocketのコールバックが終わってから、DBのチェックポイントを取ります。

    強制終了の後は「未送信」と決めつけず、必ず照合します。本番へ段階的に戻す判断は本番移行の手順書と共通です。

    実装の受け入れチェック

    • 同じintent ID、cloid、データのハッシュが、全ての再試行で変わらない。
    • 送信のタイムアウト、プロセスの停止、WebSocket切断の後も、送信回数が最大1回である。
    • スナップショット、重複、逆順のイベント、部分約定、取消との競合をテストしている。
    • Info APIでの照合がWebSocketから独立し、レート制限の予算と優先度を持つ。
    • AI、判定、署名、同期の責任を分け、秘密鍵を指示文やログに出さない。
    • 本番が既定で無効で、人間の承認、上限、緊急停止がなければ有効にできない。
    • unknownを自動で失敗に変えず、運用者が判断できる証跡と手順書がある。

    関連する記事

    AIの提案から署名・送信までの全体像はHyperliquid AI取引エージェントの本番設計が起点です。同じ連載では、判定と停止を判定エンジンと緊急停止、鍵とnonceをAPI Walletの安全な署名設計、段階投入をテストネット検証と段階投入の手順で扱います。Hyperliquidの全体像はHyperliquidの仕組み、API・RPC・インデクサーの役割分担はHyperliquid API・RPC・データ基盤で確認できます。

    XTELAができること

    私たちは、注文の状態機械、outboxと照合処理、WebSocketの再接続と重複排除、再起動の手順書を、テスト付きの実装として設計・開発します。貴社のBotの注文量とレート制限の予算に合わせて照合の優先順位を決め、テストネットでの障害注入まで一緒に進めます。売買戦略や投資判断は私たちの仕事の範囲に含みません。注文同期の設計を相談したい場合はお問い合わせください。

    参考資料

    資料の確認日と注意

    仕様は2026年9月24日に確認し直しました(SDKは同日時点の最新リリース0.24.0)。ここで示したのは、2026年9月24日時点の公開仕様にもとづく技術解説です。投資助言ではありません。本番環境や本番資金での検証は行っていないため、API仕様・SDK・制限値は実装時に公式資料で確かめてください。

    お問い合わせ

    どんなフェーズからでも、お持ちのアイデアや企画をもとにご提案可能です。
    まずはお気軽にご相談下さい!