Hyperliquid注文Botの二重発注を防ぐ|WebSocketが途切れた注文の扱い
約18分で読めます
約18分
目次(タップで折りたたみ)
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・判定・署名・同期は、それぞれ別の担当にする
同期の仕組みは、ほかの担当と切り離して作ります。流れは次のとおりです。
- AIの提案を判定器(Policy/Risk Engine)が判定し、
intent_idと変更できない要求を作ります。 - SQLite・PostgreSQLのトランザクションで、注文と送信用の待ち行列(outbox)を記録します。
- 署名・送信Workerが、記録済みの要求だけをHyperliquidへ送ります。
- Hyperliquidからの
orderUpdates・userFillsを受け取ります。 - 照合処理が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公式仕様)。
切断から戻るときは、次の順で進めます。
- 接続の世代を表す
connection_epochに新しい番号を振り、orderUpdatesとuserFillsを購読し直す。 - 最初のスナップショットは「過去のイベントの再配信」として重複を除く。DBの状態を無条件に巻き戻さない。
- 購読の応答を受けたら、Info APIで照合を走らせ、切断の直前から今までの抜けを埋める。
- 照合が終わるまでは、新しい注文意図を送らない。取消やreduce-only(ポジションを減らすだけの注文)も、判定ルールに従って制限する。
SDKのコールバックのスレッドで、DBの処理や照合まで済ませようとしないでください。受け取ったイベントは、永続化した待ち行列へすぐ渡します。
接続の状態と注文の状態も混ぜません。接続が戻っても(WebSocketがCONNECTEDでも)、取引所の状態と手元の記録を突き合わせ終わるまでは、新しい注文を送りません。
注文の状態に「結果不明」を持たせる
冒頭のとおり、送った注文は、応答がなくても取引所に届いているかもしれません。そのため、再送はどの状態からも行いません。そして結果不明(unknown)からは、照合で確定したときにしか抜けません。
ただ1つの例外として、取消中(cancel_pending)の取消は、同じ操作IDで判定します。状態の移り方は次のとおりです。
| いまの状態 | 移ってよい次の状態 | 根拠 |
|---|---|---|
pending | accepted / open / partially_filled / filled / rejected / unknown | 送信前のDB確保、応答、照会、約定 |
accepted | open / partially_filled / filled / cancel_pending / rejected / unknown | 取引所が受け付けた後 |
open | partially_filled / filled / cancel_pending / canceled / unknown | 注文の更新と約定 |
partially_filled | partially_filled / filled / cancel_pending / canceled / unknown | 累積約定量は増える一方 |
cancel_pending | partially_filled / filled / canceled / unknown | 取消中の約定もあり得る |
unknown | accepted / 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:業務上の、一つひとつの判断を表すIDcloid:取引所への照会に使う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、人の承認です。
再起動は、発注を止めた状態から始める
- すべての送信Workerを止め、緊急停止と本番の無効を確かめる。DBのスナップショットと時刻同期の状態を保存する。
- outbox、pending、unknown、cancel_pending、partially_filledを抜き出し、要求のハッシュに重複がないか調べる。
- 読み取り専用のInfo APIで、各cloid、未約定注文、切断前からの約定を照合する。署名鍵は要らない。
- WebSocketへ接続し、pongを確かめて購読し直す。スナップショットの重複を除いてから、もう一度Info APIで照合する。
- 次のすべてを確かめる。取引所とDBの差が0。unknownは運用者が承認済み。市場データが新しい。レート制限の予算が戻った。
- テストネット・dry-runで、カナリア(試しに流す1件)の注文意図を通す。送信回数、cloid、状態、約定量、監査ログを確かめる。
- 人が承認したネットワークと上限の範囲だけ、段階的に再開する。異常があれば新規の送信を止めて、手順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の注文量とレート制限の予算に合わせて照合の優先順位を決め、テストネットでの障害注入まで一緒に進めます。売買戦略や投資判断は私たちの仕事の範囲に含みません。注文同期の設計を相談したい場合はお問い合わせください。
参考資料
- Hyperliquid Docs: WebSocket
- Hyperliquid Docs: Subscriptions
- Hyperliquid Docs: Timeouts and heartbeats
- Hyperliquid Docs: Exchange endpoint
- Hyperliquid Docs: Info endpoint
- Hyperliquid Docs: Rate limits and user limits
- Hyperliquid Docs: Nonces and API wallets
- 公式Python SDKのリリース一覧(0.24.0が2026年9月24日時点の最新)
資料の確認日と注意
仕様は2026年9月24日に確認し直しました(SDKは同日時点の最新リリース0.24.0)。ここで示したのは、2026年9月24日時点の公開仕様にもとづく技術解説です。投資助言ではありません。本番環境や本番資金での検証は行っていないため、API仕様・SDK・制限値は実装時に公式資料で確かめてください。