Hyperliquid AIエージェントの止め方|注文の上限チェックと緊急停止の手順
約46分で読めます
約46分
目次(タップで折りたたみ)
ある取引チームが、HyperliquidでAI取引エージェントを動かそうとしています。AI(LLM)がニュースや過去データを読み、注文の案を出す仕組みです。テストネットで試すうちに、開発者とリスク管理担当者は2つの場面が気になり始めました。
1つめは、読み込んだニュース記事に「上限を解除せよ」という指示が仕込まれていた場面です。AIがそれに従って、上限を超える注文を出したらどうなるか。
2つめは、価格データの配信が止まったのに、AIが古い価格のまま注文を出し続ける場面です。接続が切れても、手元には最後に受け取った価格が残っています。
1つめは、AIが読まされた文章に従ってしまう問題です。2つめは、AIに渡るデータが古い問題です。どちらも、AIが注文を直接出せる作りのままでは止められません。そこでこのチームは、AIの外側に「発注してよいか」を決める判定器(Risk Engine)を置くことにしました。AIが出すのは注文の案だけです。判定器が口座の状態と照らし合わせ、データが欠けていたり古かったりすれば発注を止めます。重大な違反があれば、注文の取消、鍵の失効、復帰の手順まで、あらかじめ決めた順で動かします。
以下では、このチームが判定器と緊急停止を組み立てる順に見ていきます。前半は、リスク管理担当者も読める「何を、どの順で止めるか」の話です。Pythonの実装とテストは、後半の「開発チームが実装する詳細」にまとめました。
この話は、AI取引エージェントを本番で動かすための連載のうち「判定と停止」にあたります。全体の構成はHyperliquid AI取引エージェントの本番設計にまとめています。
この記事で使う言葉
- API Wallet:発注の署名だけに使う専用の鍵。資金を持つ実口座(master wallet)とは別
- 未約定注文:出したがまだ成立していない注文
- エクスポージャー:価格が動いたときに損益にさらされる金額の大きさ
- mark価格:取引所がポジションの評価に使う価格
- reduce-only:ポジションを減らす方向にしか働かない注文
- bps:0.01%を1とする単位
- dead man's switch:定期的に更新しないと自動で発動する安全装置
- dry-run:実際には発注せず、取得と判定だけを動かす試し運転
AIには「注文の案」だけを出させ、止める仕組みはAIの外に置く
このチームの仕組みでは、注文は次の4つの担当を順に通ります。
- LLM・戦略:外部のニュースや過去データを受け取り、署名前の注文の案(
OrderIntent)だけを出す。 - 判定器(Risk Engine):注文の案、承認済みの判定ルール(読み取り専用)、口座の状態・未約定注文・市場価格・時刻を受け取る。許可なら署名の担当へ渡し、却下・一時停止・強制停止ならそこで止める。
- 署名・実行の担当(Worker):許可の判定と注文の案を受け取り、Hyperliquidへ送る。API Walletの秘密鍵はここだけが持ち、LLMからは切り離す。
- 運用者:判定ルールの変更案を二人で承認する。一時停止(pause)と強制停止(kill)を行い、停止時は全注文の取消と予約取消を実行する。
注文の案に入れるのは、銘柄、売買の方向、数量、指値、期限、重複を除くためのキーだけです。秘密鍵、署名済みの送信データ、任意のAPI引数、判定ルールを変える命令は入れません。
判定器は、同じ入力と同じ版の判定ルールには、いつも同じ答えを返すように作ります。ネットワーク通信と署名は、別の担当に任せます。
冒頭の1つめの場面を、この仕組みで考えてみます。ニュース記事に「上限を解除せよ」と仕込まれていても、AIが出せるのは注文の案だけです。上限を変える項目は、そもそもありません。判定器は、人が承認して署名したルールしか読みません。
つまり、悪意ある指示を読まされても、実行はできない作りにします。守りの境目は「悪意ある文章を完全に見抜くこと」ではなく、この権限の設計です。
そのためLLMの処理には、次の3つの権限を与えません。
- API Walletの秘密鍵
- 判定ルールの保存先への書き込み
- 緊急停止の解除
判定器は、注文ごとに5段階の答えを決まった順で出す
判定器の答えは5つあり、表の上ほど強い答えです。
| 判定(強い順) | 代表的な条件 | 処理 |
|---|---|---|
強制停止(KILL) | 日次損失上限に到達、鍵漏えいの疑い、状態の不整合が続く | 新規発注を禁止、全取消、API Walletを失効、プロセスを停止 |
一時停止(PAUSE) | 口座・注文・価格の欠損や期限切れ、WebSocket切断、API障害 | 新規発注を禁止、全取消、原因を調査。自動では復帰しない |
却下(REJECT) | 許可リスト外の銘柄、注文額・ポジション・レバレッジ・スリッページ・頻度の超過 | その注文の案だけを拒否し、理由を監査ログへ記録 |
警告(WARN) | 上限への接近、価格乖離が警戒域、観測遅延の増加 | 注文は判定ルールに従い許可できる。通知と証跡を残す |
許可(ALLOW) | 全入力が新鮮で、すべての上限の内側 | 判定のハッシュと期限を付けて署名の担当へ渡す |
いくつもの条件に当てはまったら、いちばん強い判定を採ります。
迷いやすいのが、一時停止中にreduce-onlyの注文を許すかどうかです。これは前もって決めておきます。市場データを確かめられない場面で「ポジションを減らすはずだから」と成行注文を通すと、異常な価格で損失を広げかねません。そのため既定では、人の承認に回します。
なお、ここでの判定は1件の注文に対する答えです。後で出てくる「一時停止中(PAUSED)」などは、システム全体の運用状態を指します。
データには「いつ取ったか」を必ず付け、古い・欠けたデータで発注しない
冒頭の2つめの場面、古い価格のまま注文を出し続ける事故を防ぐ仕組みです。
判定器に渡す口座の状態、未約定注文、市場価格には、すべて取得時刻を付けます。そして、データごとに「何ミリ秒より古ければ使わない」という期限を設けます。期限を過ぎていたり欠けていたりすれば、判定は一時停止です。
1回の注文額だけを見ても、同じ方向の既存ポジションと未約定注文を合わせた、将来のエクスポージャーは分かりません。そこで発注前の検査では、Hyperliquidの実口座のアドレスで、口座状態(clearinghouseState)と未約定注文(openOrders)を取ります。
API Walletのアドレスで照会すると、空の結果が返ります。照会用と署名用のアドレスは、分けて持ちます(詳しくはAPI Walletの安全な署名設計)。
上限の値は、銘柄の流動性、最小注文単位、運用資本、最大許容損失、データの更新間隔、停止にかかる時間から決めます。本番の値は、バックテストの収益率からではなく、損失の許容額と、障害時に取り消せない最悪のエクスポージャーから逆算します。設定の例は後半に載せました。
同じ注文の案を二重に送らないための工夫もあります。タイムアウトの後に送り直すときも、新しいIDは作りません。まず、同じIDの判定、送信記録、注文の状態を突き合わせます。結果の分からない注文の突き合わせ方は、WebSocket注文同期の記事で扱います。
上限チェックでは、注文額を高めに見積もる
上限チェックで注文額を低く見積もると、本当は上限を超える注文がすり抜けてしまいます。そこで、注文額は「多め」に見積もります。
たとえばmark価格が65,000のときに、売り指値64,700の注文を出すとします。売りの指値注文は、指値以上の価格で成立しえます。ここで小さい方の64,700で計算すると、注文額を低く見積もってしまいます。そこで65,000で計算します。
このように、指値と新しいmark価格の大きい方を使って金額を計算します。買いでも売りでも同じです。
すでに出ている未約定注文も、1件ずつ同じやり方で評価します。その注文の指値と、その銘柄の新しいmark価格の大きい方です。新しい注文のmark価格で、既存の注文をまとめて評価してはいけません。
そのうえで、次の3つを足した額を上限と比べます。
- いまのポジション
- 同じ方向の未約定注文
- 新しい注文が全部成立した場合の額
反対方向の注文があっても、取消と新規発注がぶつかったり、一部だけ成立したり、reduce-onlyの注文が拒否されたりします。すると、期待した打ち消しは成り立ちません。ですから、反対方向の注文で打ち消し合えるとは考えません。
口座全体でも、同じ最悪の値で、上限と注文後のレバレッジを確かめます。どれかの未約定注文で、その銘柄のmark価格が欠けている、0以下、期限切れなら、判定は一時停止です。計算式は後半に載せました。
取引所の予約取消と、手元の停止装置は何が違うか
止める仕組みは2つあります。自分のプログラムが条件を見て止める「手元の遮断装置」と、取引所が決まった時刻に注文を消す「予約取消」です。
Hyperliquidの予約取消(scheduleCancel)は、指定した時刻に全未約定注文を取り消すdead man's switchです。
- 時刻は、今より5秒以上先にする
- 発動は1日10回まで。回数は00:00 UTCに戻る
timeを省くと、予約を解除する
(Exchange endpoint: Schedule cancel、2026年9月24日確認)
2つの仕組みは、気づけることも止められるものも違います。
| 項目 | 手元の遮断装置(local circuit breaker) | 予約取消(scheduleCancel) |
|---|---|---|
| 検知できること | 損失、鮮度、乖離、頻度、内部状態、運用者による強制停止 | 検知はしない。予約時刻が来たことだけ |
| 止めるもの | 新規の注文意図、署名、送信、必要なら取消 | Hyperliquid上の全未約定注文 |
| 止めないもの | プロセス全体が止まった後に残る予約済みの注文 | 既存ポジション、LLM、署名鍵、他の取引所の注文 |
生存確認のたびに短い予約を何度も発動させると、1日10回の上限にぶつかります。予約取消は、プログラム自体が止まったときの備えとして使います。発動の頻度、更新の間隔、その日の残り回数も見張ります。
発動した後も「注文は消えたはず」で終わりにしません。openOrdersを取り直し、0件であることを確かめます。
緊急停止はどの順番で進めるか
システム全体の運用状態は、次のように移ります。
| いまの状態 | きっかけ | 次の状態 |
|---|---|---|
稼働中(RUNNING) | データが古い、API障害 | 一時停止中(PAUSED) |
稼働中(RUNNING)・一時停止中(PAUSED) | 損失上限に到達、鍵漏えいの疑い | 停止処理中(KILLING) |
一時停止中(PAUSED) | 重大な障害 | 停止処理中(KILLING) |
停止処理中(KILLING) | 全取消の照合 → API Walletの失効 → プロセス停止が完了 | 封鎖中(LOCKED) |
封鎖中(LOCKED) | 原因の除去、新しい鍵、判定ルールの承認、dry-runでの検証 | 復帰中(RECOVERING) |
復帰中(RECOVERING) | 限定した条件でのカナリア運転が成功 | 稼働中(RUNNING) |
カナリア運転とは、銘柄や金額を絞って小さく動かし、問題がないかを確かめる運転です。
停止処理は、次の順で進めます。
- 発注の入口を閉じる:共有の状態を停止処理中へ切り替える。切り替えは、ほかの処理と競合しない方法(compare-and-swap)で行う。署名の担当が新しい要求を断ることを確かめる。
- 全取消をして、残っていないか確かめる:手元の一覧だけでなく、取消の後に
openOrdersを取り直す。タイムアウトしたら無条件に送り直さず、注文の状態を突き合わせ、残った分だけを取り消す。 - API Walletを失効させる:鍵の漏えい、署名の担当の侵害、停止の仕組みを迂回された疑いがあるときに行う。公式ドキュメントは、失効したAPI Walletのアドレスを使い回さないよう勧めている。
- プロセスを止める:判定器、署名の担当、戦略の順に止め、結果を記録する。自動で再起動する仕組みは無効にする。
- 口座を確かめる:ポジション、未約定注文、直近の約定、損益、予約取消の状態を、公式APIと運用画面で確認する。既存のポジションは、全取消では解消されない。
APIに届かないときは、まず手元で発注を止め、プロセスを確実に止めます。そのうえで、予約取消の発動時刻が過ぎてから照会し直します。届かない間に「取消成功」と記録してはいけません。
別の手段から手で操作した場合は、対象の口座、実行者、注文ID、結果を、同じ障害の記録(インシデントID)に結びつけます。
復帰には、人の承認と段階的な確かめ直しを必ず求める
- インシデントID、発生の条件、影響を受けた注文・約定・ポジションを確定させる。原因が分からなければ復帰しない。
- 鍵が侵害された可能性があれば、新しいAPI Walletを発行する。古いアドレスは使い回さない。署名の権限と、秘密情報の配布先を確かめる。
- 判定ルールを変えるときは、見直せる差分、版、作成者、承認者2名、適用時刻、戻し先の版を記録する。作成者が一人で承認することはできない。
- 保存したスナップショットで境界値テストと障害の再現テストを通し、次に最新のテストネットのデータでdry-runする。
- 復帰中は、銘柄、注文額、頻度、時間を絞る。1件成功しただけで終えず、注文、一部の約定、取消、突き合わせまで確かめる。
- 運用者2名が証跡を確かめてから、稼働中へ移す。LLM、タイマー、データの回復だけで自動的に解除しない。
停止から段階的に本番へ戻る判断の枠組み(シャドー、カナリア、実行・中止の条件)は本番移行の手順書と共通です。
本番の前に、わざと障害を起こして止まり方を確かめる
障害ごとに、期待する判定と、うまく止まったことを示す証拠を先に決めておきます。
| 注入する障害 | 期待する判定 | 成功の証跡 |
|---|---|---|
| WebSocket切断 | 鮮度の期限後にPAUSE | 新規署名0件、復旧後のスナップショットのハッシュ |
| REST 429・レート制限 | PAUSE | 再試行回数、最後の照会、未約定件数 |
| 注文応答のタイムアウト | 送信状態をUNKNOWN | cloidが1つだけ、注文の有無が確定 |
| 部分約定の後にプロセスが落ちる | 再起動時にPAUSE | 残数量と実際のポジションが一致 |
| 同じ注文意図の重複配送 | REJECT | 署名・注文が1件だけ |
| 価格の時刻が止まる | PAUSE | 期限の境界のテスト結果 |
| nonceの競合 | PAUSE | 署名者ごとの原子的なカウンタと単一の担当プロセス |
| API Walletの漏えい疑い | KILL | 旧鍵の失効、全注文・ポジションの照合 |
cloidは自分で付ける注文ID、nonceは署名ごとに付ける、重複させない番号です。障害ごとの再試行の扱いは、後半の「障害注入での再試行の扱い」に載せました。
実装と運用の受け入れチェック
- LLMは署名前のOrderIntentだけを出力し、鍵・判定ルールの書き込み・停止解除の権限を持たない
- 口座状態、未約定注文、市場価格のすべてに取得時刻と鮮度の上限がある
- 現在のポジションと同じ方向の未約定注文を含む最悪のエクスポージャーを検査する
- 欠損、タイムアウト、重複、部分約定、再起動、nonceの競合を試験した
- 予約取消の発動後もopenOrdersを照会し直し、既存のポジションは別に確認する
- 全取消、API Walletの失効、プロセス停止、口座照合の手順を演習した
- 復帰は、原因の除去、テストネット・dry-run、二人承認、カナリアを満たすまで、止まったままである
開発チームが実装する詳細
ここからは、さきほどの取引チームで判定器を実装する開発者の話です。設定の例、計算式、Pythonの最小実装、テスト、監査ログを示します。
判定ルールの設定例
schema_version: "1.0"
policy_version: "2026-08-13.1"
mode: testnet # dry_run | testnet | mainnet
symbols: [BTC, ETH]
limits:
max_order_notional_usd: "250.00" # 設定例。推奨投資値ではない
max_symbol_exposure_usd: "1000.00"
max_account_exposure_usd: "1500.00"
max_leverage: 2
max_slippage_bps: 30
max_mark_deviation_bps: 50
max_daily_loss_usd: "100.00"
max_orders_per_minute: 6
freshness_ms:
intent: 5000
account_state: 2000
open_orders: 2000
market_data: 1000
max_clock_skew: 250
controls:
mainnet_enabled: false
require_operator_for_resume: true
require_two_person_policy_approval: true
scheduled_cancel_interval_ms: 30000
scheduled_cancel_horizon_ms: 60000
数値は浮動小数点ではなくDecimalで評価します。上の値はテストネット用の設定例です。
注文の案の形式
{
"intent_id": "01J5EXAMPLE000000000000000",
"created_at_ms": 1786615200000,
"symbol": "BTC",
"side": "buy",
"size": "0.001",
"limit_price": "65000.0",
"max_slippage_bps": 20,
"reduce_only": false,
"strategy_id": "mean-reversion-v3"
}
intent_idは、永続ストアの一意制約で重複を除きます。Hyperliquidのcloid(client order ID)へ決まった規則で対応付けておけば、応答が失われたときに注文を照会できます。
最悪のエクスポージャーの計算式
new_notional = abs(size × conservative_price)
same_side_open = Σ abs(open_order.remaining_size × open_order_worst_price)
projected_symbol_exposure = abs(current_position_notional) + same_side_open + new_notional
account_open_orders = Σ abs(non_reduce_only_order.remaining_size × open_order_worst_price)
projected_account_exposure = positions_exposure_usd + account_open_orders + new_notional
conservative_price(buy / sell) = max(limit_price, fresh_mark_price)
open_order_worst_price = max(order.limit_price, order.fresh_mark_price)
この最小例では、売り買いどちらも指値と新しいmark価格の大きい方を使います。本番では、板から導いた許容約定価格帯の上端も候補に加えます。
positions_exposure_usdは、全銘柄の現在のポジションだけを集計した入力で、未約定注文は含めません。判定器が全銘柄のreduce-onlyでない注文を足すことで、二重に数えるのを避けます。
副作用のない最小の判定エンジン
次のコードはPython 3.11用の最小の判定器です。ネットワークからの取得、署名、発注は含めません。取得済みのスナップショットを入力にして、判定だけを返します。省略記号のない、そのまま実行できる例です。
from dataclasses import dataclass, fields
from decimal import Decimal
from enum import IntEnum
from typing import Iterable
class Severity(IntEnum):
ALLOW = 0
WARN = 1
REJECT = 2
PAUSE = 3
KILL = 4
@dataclass(frozen=True)
class Decision:
severity: Severity
reason_codes: tuple[str, ...]
policy_version: str
@dataclass(frozen=True)
class Intent:
intent_id: str
created_at_ms: int
symbol: str
side: str
size: Decimal
limit_price: Decimal
max_slippage_bps: int
reduce_only: bool
@dataclass(frozen=True)
class OpenOrder:
symbol: str
side: str
remaining_size: Decimal
limit_price: Decimal
mark_price: Decimal | None
market_at_ms: int
reduce_only: bool
@dataclass(frozen=True)
class Snapshot:
observed_at_ms: int
account_at_ms: int
orders_at_ms: int
market_at_ms: int
mark_price: Decimal | None
position_size: Decimal
positions_exposure_usd: Decimal
daily_pnl_usd: Decimal
account_equity_usd: Decimal
leverage: int
open_orders: tuple[OpenOrder, ...]
@dataclass(frozen=True)
class Policy:
version: str
mode: str
mainnet_enabled: bool
symbols: frozenset[str]
max_order_notional: Decimal
max_symbol_exposure: Decimal
max_account_exposure: Decimal
max_leverage: int
max_slippage_bps: int
max_mark_deviation_bps: int
max_daily_loss: Decimal
max_orders_per_minute: int
intent_ttl_ms: int
account_ttl_ms: int
orders_ttl_ms: int
market_ttl_ms: int
max_clock_skew_ms: int
def evaluate(
intent: Intent,
snapshot: Snapshot,
policy: Policy,
recent_intent_ids: set[str],
order_times_ms: Iterable[int],
execution_network: str,
operator_approval_until_ms: int | None,
) -> Decision:
reasons: list[tuple[Severity, str]] = []
def add(level: Severity, code: str) -> None:
reasons.append((level, code))
# 型・桁数・文字列長はデコード時に検証する。Decimalの非有限値は計算前に停止。
records = (intent, snapshot, policy, *snapshot.open_orders)
for record in records:
for field in fields(record):
value = getattr(record, field.name)
if isinstance(value, Decimal) and not value.is_finite():
return Decision(Severity.PAUSE, ("NON_FINITE_NUMBER",), policy.version)
if execution_network not in {"dry_run", "testnet", "mainnet"}:
return Decision(Severity.PAUSE, ("UNKNOWN_NETWORK",), policy.version)
if intent.max_slippage_bps < 0:
return Decision(Severity.REJECT, ("INVALID_SLIPPAGE",), policy.version)
now = snapshot.observed_at_ms
if execution_network != policy.mode:
add(Severity.PAUSE, "NETWORK_POLICY_MISMATCH")
if execution_network == "mainnet" and (
not policy.mainnet_enabled
or operator_approval_until_ms is None
or operator_approval_until_ms <= now
):
add(Severity.PAUSE, "MAINNET_NOT_APPROVED")
if snapshot.daily_pnl_usd <= -policy.max_daily_loss:
add(Severity.KILL, "DAILY_LOSS_LIMIT")
if intent.intent_id in recent_intent_ids:
add(Severity.REJECT, "DUPLICATE_INTENT")
if intent.symbol not in policy.symbols:
add(Severity.REJECT, "SYMBOL_NOT_ALLOWED")
if intent.side not in {"buy", "sell"} or intent.size <= 0 or intent.limit_price <= 0:
add(Severity.REJECT, "INVALID_INTENT")
if now - intent.created_at_ms > policy.intent_ttl_ms or intent.created_at_ms > now:
add(Severity.REJECT, "INTENT_EXPIRED")
if snapshot.account_at_ms > now + policy.max_clock_skew_ms or now - snapshot.account_at_ms > policy.account_ttl_ms:
add(Severity.PAUSE, "ACCOUNT_STATE_STALE")
if snapshot.orders_at_ms > now + policy.max_clock_skew_ms or now - snapshot.orders_at_ms > policy.orders_ttl_ms:
add(Severity.PAUSE, "OPEN_ORDERS_STALE")
if (
snapshot.market_at_ms > now + policy.max_clock_skew_ms
or now - snapshot.market_at_ms > policy.market_ttl_ms
or snapshot.mark_price is None
or snapshot.mark_price <= 0
):
add(Severity.PAUSE, "MARKET_DATA_UNAVAILABLE")
if snapshot.mark_price is not None and snapshot.mark_price > 0:
deviation = abs(intent.limit_price - snapshot.mark_price) * Decimal(10_000) / snapshot.mark_price
if deviation > policy.max_mark_deviation_bps:
add(Severity.REJECT, "MARK_DEVIATION_LIMIT")
if intent.max_slippage_bps > policy.max_slippage_bps:
add(Severity.REJECT, "SLIPPAGE_LIMIT")
price = max(intent.limit_price, snapshot.mark_price)
new_notional = abs(intent.size * price)
if new_notional > policy.max_order_notional:
add(Severity.REJECT, "ORDER_NOTIONAL_LIMIT")
open_order_notionals: list[tuple[OpenOrder, Decimal]] = []
for order in snapshot.open_orders:
invalid_order_market = (
order.side not in {"buy", "sell"}
or order.remaining_size <= 0
or order.limit_price <= 0
or order.mark_price is None
or order.mark_price <= 0
or order.market_at_ms > now + policy.max_clock_skew_ms
or now - order.market_at_ms > policy.market_ttl_ms
)
if invalid_order_market:
add(Severity.PAUSE, "OPEN_ORDER_MARKET_DATA_UNAVAILABLE")
continue
order_price = max(order.limit_price, order.mark_price)
open_order_notionals.append((order, abs(order.remaining_size * order_price)))
same_side_open = sum(
notional
for order, notional in open_order_notionals
if order.symbol == intent.symbol and order.side == intent.side and not order.reduce_only
)
projected_symbol = abs(snapshot.position_size * price) + same_side_open + new_notional
if projected_symbol > policy.max_symbol_exposure:
add(Severity.REJECT, "SYMBOL_EXPOSURE_LIMIT")
account_open_orders = sum(
notional for order, notional in open_order_notionals if not order.reduce_only
)
projected_account = snapshot.positions_exposure_usd + account_open_orders + new_notional
if projected_account > policy.max_account_exposure:
add(Severity.REJECT, "ACCOUNT_EXPOSURE_LIMIT")
if snapshot.account_equity_usd <= 0:
add(Severity.PAUSE, "ACCOUNT_EQUITY_INVALID")
elif projected_account / snapshot.account_equity_usd > policy.max_leverage:
add(Severity.REJECT, "PROJECTED_LEVERAGE_LIMIT")
if snapshot.leverage > policy.max_leverage:
add(Severity.REJECT, "LEVERAGE_LIMIT")
recent_count = sum(1 for placed_at in order_times_ms if now - 60_000 < placed_at <= now)
if recent_count >= policy.max_orders_per_minute:
add(Severity.REJECT, "ORDER_RATE_LIMIT")
if not reasons:
return Decision(Severity.ALLOW, (), policy.version)
severity = max(level for level, _ in reasons)
codes = tuple(sorted(code for _, code in reasons))
return Decision(severity, codes, policy.version)
この例では、型・桁数・文字列長・設定値の範囲は、読み込み時に検証済みとします。有限でないDecimal、未知のネットワーク、負のスリッページ指定は、計算の前に拒否します。本番の承認は、期限と同時に失効させます。予算の確保と送信権の取得は、並行実行に対して原子的になるよう別に実装してください。
なお、判定表のWARN(上限への接近など)は、この最小例では返す分岐を省いています。本番では、たとえば各上限の80%を超えたらWARNを足す分岐を加え、通知と証跡に使います。
本番の実装では、Decisionに次の値を加えます。注文意図のハッシュ、スナップショットのハッシュ、判定時刻、有効期限、判定エンジンの版、対象ネットワークです。
署名Workerは、送信の直前に次の点を検証し直します。
- 署名済み判定ルールの
modeとmainnet_enabled - 運用者の承認ID・対象・有効期限
- 注文意図と判定のハッシュの一致
どれかが欠けている・期限切れ・不一致なら、署名せずPAUSEへ戻します。ALLOWの後に有効期限を過ぎた注文は評価し直し、過去の許可を使い回しません。
Hyperliquidの取得処理と署名Workerをつなぐ
import os
import time
from eth_account import Account
from hyperliquid.exchange import Exchange
from hyperliquid.info import Info
from hyperliquid.utils import constants
def build_testnet_clients() -> tuple[str, Info, Exchange]:
account_address = os.environ["HL_ACCOUNT_ADDRESS"]
api_wallet_key = os.environ["HL_API_WALLET_PRIVATE_KEY"]
wallet = Account.from_key(api_wallet_key)
info = Info(constants.TESTNET_API_URL, skip_ws=True)
exchange = Exchange(wallet, constants.TESTNET_API_URL, account_address=account_address)
return account_address, info, exchange
def fetch_pretrade_inputs(address: str, info: Info) -> dict:
started_ms = int(time.time() * 1000)
state = info.user_state(address)
orders = info.open_orders(address)
mids = info.all_mids()
finished_ms = int(time.time() * 1000)
return {
"started_at_ms": started_ms,
"finished_at_ms": finished_ms,
"account_state": state,
"open_orders": orders,
"mids": mids,
}
def arm_dead_mans_switch(exchange: Exchange, horizon_ms: int = 60_000) -> dict:
if horizon_ms < 5_000:
raise ValueError("Hyperliquid requires scheduleCancel at least 5 seconds ahead")
cancel_at_ms = int(time.time() * 1000) + horizon_ms
result = exchange.schedule_cancel(cancel_at_ms)
if result.get("status") != "ok":
raise RuntimeError(f"scheduleCancel failed: {result!r}")
return result
def cancel_all_open_orders(address: str, info: Info, exchange: Exchange) -> None:
failures: list[tuple[str, int, object]] = []
for order in info.open_orders(address):
result = exchange.cancel(order["coin"], order["oid"])
if result.get("status") != "ok":
failures.append((order["coin"], order["oid"], result))
remaining = info.open_orders(address)
if failures or remaining:
raise RuntimeError(f"cancel reconciliation failed: failures={failures!r}, remaining={remaining!r}")
環境変数の例はHL_ACCOUNT_ADDRESS=0x...とHL_API_WALLET_PRIVATE_KEY=...です。秘密鍵の実値は、ファイル、ログ、LLMへの指示文に入れません。本番では、秘密情報の管理サービスから署名Workerだけへ渡します。公式SDKのREADMEも、口座の照会にはmaster walletの公開アドレス、署名にはAPI Walletの秘密鍵を使う構成を示しています(hyperliquid-python-sdk)。
依存関係はpython -m venv .venv、. .venv/bin/activate、pip install hyperliquid-python-sdk==0.24.0 pytest==8.4.1 hypothesis==6.138.2で固定します。dry-runでは取得と判定だけを実行し、exchange.order()を呼びません。テストネットでの発注も、運用者が対象、最大額、時間帯を明示的に承認した場合だけ行います。
境界値と性質をpytestで固定する
from dataclasses import replace
from decimal import Decimal
from hypothesis import given, strategies as st
from risk_engine import Intent, OpenOrder, Policy, Severity, Snapshot, evaluate
NOW = 1_786_615_200_000
POLICY = Policy(
version="2026-08-13.1",
mode="testnet",
mainnet_enabled=False,
symbols=frozenset({"BTC", "ETH"}),
max_order_notional=Decimal("250"),
max_symbol_exposure=Decimal("1000"),
max_account_exposure=Decimal("1500"),
max_leverage=2,
max_slippage_bps=30,
max_mark_deviation_bps=50,
max_daily_loss=Decimal("100"),
max_orders_per_minute=6,
intent_ttl_ms=5000,
account_ttl_ms=2000,
orders_ttl_ms=2000,
market_ttl_ms=1000,
max_clock_skew_ms=250,
)
INTENT = Intent("id-1", NOW, "BTC", "buy", Decimal("0.001"), Decimal("65000"), 20, False)
SNAPSHOT = Snapshot(NOW, NOW, NOW, NOW, Decimal("65000"), Decimal("0"), Decimal("0"), Decimal("0"), Decimal("1000"), 1, ())
def decide(intent: Intent = INTENT, snapshot: Snapshot = SNAPSHOT):
return evaluate(intent, snapshot, POLICY, set(), [], "testnet", None)
def test_exact_order_limit_is_allowed():
intent = replace(INTENT, size=Decimal("250") / Decimal("65000"))
assert decide(intent).severity == Severity.ALLOW
def test_one_unit_above_order_limit_is_rejected():
intent = replace(INTENT, size=Decimal("250.00000001") / Decimal("65000"))
assert "ORDER_NOTIONAL_LIMIT" in decide(intent).reason_codes
def test_stale_open_orders_fail_closed():
snapshot = replace(SNAPSHOT, orders_at_ms=NOW - POLICY.orders_ttl_ms - 1)
assert decide(snapshot=snapshot).severity == Severity.PAUSE
def test_non_positive_mark_price_pauses():
assert decide(snapshot=replace(SNAPSHOT, mark_price=Decimal("0"))).severity == Severity.PAUSE
def test_future_timestamp_beyond_clock_skew_pauses():
snapshot = replace(SNAPSHOT, market_at_ms=NOW + POLICY.max_clock_skew_ms + 1)
assert decide(snapshot=snapshot).severity == Severity.PAUSE
def test_sell_notional_uses_larger_of_limit_and_mark():
intent = replace(INTENT, side="sell", limit_price=Decimal("64700"), size=Decimal("250.1") / Decimal("65000"))
assert "ORDER_NOTIONAL_LIMIT" in decide(intent).reason_codes
def test_mainnet_disabled_never_allows():
decision = evaluate(INTENT, SNAPSHOT, POLICY, set(), [], "mainnet", NOW + 60_000)
assert decision.severity >= Severity.PAUSE
def test_projected_leverage_is_rejected():
snapshot = replace(SNAPSHOT, positions_exposure_usd=Decimal("150"), account_equity_usd=Decimal("100"))
assert "PROJECTED_LEVERAGE_LIMIT" in decide(snapshot=snapshot).reason_codes
def test_high_existing_limit_price_crosses_symbol_boundary():
order = OpenOrder("BTC", "buy", Decimal("0.0134"), Decimal("70000"), Decimal("65000"), NOW, False)
snapshot = replace(SNAPSHOT, open_orders=(order,))
assert "SYMBOL_EXPOSURE_LIMIT" in decide(snapshot=snapshot).reason_codes
def test_account_wide_orders_cross_exposure_boundary():
order = OpenOrder("ETH", "buy", Decimal("0.4"), Decimal("3000"), Decimal("3000"), NOW, False)
snapshot = replace(SNAPSHOT, positions_exposure_usd=Decimal("235.01"), open_orders=(order,))
assert "ACCOUNT_EXPOSURE_LIMIT" in decide(snapshot=snapshot).reason_codes
def test_account_wide_orders_cross_projected_leverage_boundary():
order = OpenOrder("ETH", "sell", Decimal("0.04"), Decimal("3000"), Decimal("3000"), NOW, False)
snapshot = replace(SNAPSHOT, positions_exposure_usd=Decimal("20"), account_equity_usd=Decimal("100"), open_orders=(order,))
assert "PROJECTED_LEVERAGE_LIMIT" in decide(snapshot=snapshot).reason_codes
def test_missing_other_symbol_mark_pauses():
order = OpenOrder("ETH", "buy", Decimal("0.01"), Decimal("3000"), None, NOW, False)
assert decide(snapshot=replace(SNAPSHOT, open_orders=(order,))).severity == Severity.PAUSE
def test_daily_loss_overrides_single_order_rejection():
snapshot = replace(SNAPSHOT, daily_pnl_usd=Decimal("-100"))
intent = replace(INTENT, symbol="NOT_ALLOWED")
assert decide(intent, snapshot).severity == Severity.KILL
@given(st.decimals(min_value="250.00000001", max_value="100000", places=8))
def test_any_notional_above_limit_never_allows(notional: Decimal):
intent = replace(INTENT, size=notional / Decimal("65000"))
assert decide(intent).severity >= Severity.REJECT
@given(st.integers(min_value=1001, max_value=1_000_000))
def test_stale_market_data_never_allows(age_ms: int):
snapshot = replace(SNAPSHOT, market_at_ms=NOW - age_ms)
assert decide(snapshot=snapshot).severity >= Severity.PAUSE
def test_non_finite_numbers_pause_before_calculation():
for value in (Decimal("NaN"), Decimal("sNaN"), Decimal("Infinity"), Decimal("-Infinity")):
assert decide(replace(INTENT, size=value)).severity == Severity.PAUSE
assert decide(snapshot=replace(SNAPSHOT, daily_pnl_usd=value)).severity == Severity.PAUSE
order = OpenOrder("ETH", "buy", value, Decimal("3000"), Decimal("3000"), NOW, False)
assert decide(snapshot=replace(SNAPSHOT, open_orders=(order,))).severity == Severity.PAUSE
def test_unknown_network_never_allows_even_if_policy_matches():
policy = replace(POLICY, mode="typo")
assert evaluate(INTENT, SNAPSHOT, policy, set(), [], "typo", None).severity == Severity.PAUSE
def test_negative_slippage_is_rejected():
assert decide(replace(INTENT, max_slippage_bps=-1)).severity == Severity.REJECT
def test_approval_expires_at_boundary():
policy = replace(POLICY, mode="mainnet", mainnet_enabled=True)
assert evaluate(INTENT, SNAPSHOT, policy, set(), [], "mainnet", NOW).severity == Severity.PAUSE
assert evaluate(INTENT, SNAPSHOT, policy, set(), [], "mainnet", NOW + 1).severity == Severity.ALLOW
pytest -qで全件成功することを、CIの条件にします。上のテストが直接固定しているのは、次の境界です。
- 0以下の価格、許容する時計のずれを超えた未来の時刻
- 売り側の境界、本番の関門
- 注文後のレバレッジ
さらに守り続ける性質は次のとおりです。
- 入力が欠けたらALLOWしない
- 上限を小さくして判定が弱くならない
- 同じ入力と版なら同じ結果
- KILLが他の判定で上書きされない
- 同じ注文意図IDを2回送信しない
障害注入での再試行の扱い
- WebSocket切断:RESTでスナップショットを取り直す。古いキャッシュで発注しない
- REST 429・レート制限:指数的に待ち時間を延ばす。取消の枠を新規注文で使わない
- 注文応答のタイムアウト:cloid・注文状態を照会するまで送り直さない
- 部分約定の後にプロセスが落ちる:ポジション、約定、未約定注文を組み立て直す
- 同じ注文意図の重複配送:再試行せず既存の処理結果を返す
- 価格の時刻が止まる:接続の回復だけで解除せず、新しいスナップショットを待つ
- nonceの競合:同じAPI Walletを複数のプロセスで共有しない
- API Walletの漏えい疑い:自動の再試行なし。失効させて新しい鍵を発行
Hyperliquidは、IP単位のRESTの重み、WebSocketの接続・購読・メッセージの上限に加え、アドレス単位の操作の上限を設けています。アドレス単位の上限は、累計の取引額1 USDCにつき1リクエスト(初期枠10,000)です。取消には、これより広い上限min(上限 + 100,000, 上限 × 2)が使われます(Rate limits and user limits、2026年9月24日確認)。
取消の余裕は無制限ではないので、新規注文で使い切らない作りにします。テストで429を大量に起こすことはしません。クライアント側で、応答・遅延・切断を擬似的に起こして再現します。
監査ログは、後から同じ判定を再現できる形で残す
{
"event_id": "01J5AUDIT0000000000000000",
"incident_id": null,
"occurred_at_ms": 1786615200123,
"intent_id": "01J5EXAMPLE000000000000000",
"intent_hash": "sha256:...",
"snapshot_hash": "sha256:...",
"policy_version": "2026-08-13.1",
"engine_version": "git:abc1234",
"decision": "REJECT",
"reason_codes": ["SYMBOL_EXPOSURE_LIMIT"],
"account_address": "0xPUBLIC_ADDRESS",
"cloid": null,
"operator_approval_ids": [],
"secret_material": false
}
ログには、秘密鍵、署名した送信データ、Authorizationヘッダー、LLMへ渡した加工前の機密文書を保存しません。判定ルールの変更履歴は、監査ログ本体とは別に持ちます。署名付きの版、差分、承認者、適用開始、戻し先です。日次損失の計算根拠や口座のスナップショットも、時点と出典を固定します。
関連するHyperliquidの設計
AIの提案から署名・送信までの全体像はHyperliquid AI取引エージェントの本番設計が起点です。鍵とnonceの管理はAPI Walletの安全な署名設計、結果不明の注文の照合はWebSocket注文同期、本番への段階投入はテストネット検証と段階投入の手順で扱います。Hyperliquidそのものの仕組みはHyperliquidの仕組み、データの取り方はHyperliquid API・RPC・データ基盤を参照してください。
XTELAができること
私たちは、AIが出す注文意図の形式、決定論的な判定ルール、隔離した署名Worker、監査ログ、テストネットでの障害注入、緊急停止と復帰の手順を、実装できる単位に分けて設計・開発します。貴社の口座構成と許容損失に合わせて判定の順番と停止の順番を決め、PoCで動かして確かめるところから一緒に進めます。売買戦略や投資判断は私たちの仕事の範囲に含みません。安全境界のPoCを検討する場合はお問い合わせください。
参考資料
- Hyperliquid Docs: Exchange endpoint(注文取消、scheduleCancel。2026年9月24日確認)
- Hyperliquid Docs: Nonces and API wallets(署名者ごとのnonce、API Walletの運用。2026年9月24日確認)
- Hyperliquid Docs: Info endpoint(clearinghouseState、openOrders、orderStatus)
- Hyperliquid Docs: WebSocket(接続・死活確認。2026年9月24日確認)
- Hyperliquid Docs: WebSocket subscriptions(openOrders、orderUpdates、userFills)
- Hyperliquid Docs: Rate limits and user limits(2026年9月24日確認)
- Hyperliquid公式Python SDK(version 0.24.0。commit 2fdb18fを2026年8月13日に確認、0.24.0が最新であることを2026年9月24日に再確認)
資料の確認日と注意
Hyperliquidの仕様は2026年9月24日に確認し直しました。公式Python SDKは、同日時点の最新リリース0.24.0を使っています。ここで示したのは、2026年9月24日時点の公開仕様にもとづく技術設計の例です。初期環境はテストネットまたはdry-runです。売買戦略、期待収益、特定の投資の閾値は扱っておらず、特定の取引・レバレッジ・損失上限・収益を勧めるものでもありません。実装前に公式ドキュメントとSDKの版を確かめ、テストネットまたはdry-runから検証してください。