コンテンツへスキップ
0%

Hyperliquidの注文・トレード・フィルの違い

Uttam Singh headshot

執筆者 Uttam Singh

2026年10月20日 公開読了時間 4 分

Hyperliquidの注文・トレード・フィルの違い

Hyperliquidでは、注文(order)は売買の指示、トレード(trade)は2つの注文の間の1回のマッチング、フィル(fill)はそのマッチングにおける1つのアカウント側の記録だ。すべてのトレードは、アカウントごとに1つずつ、ちょうど2つのフィルを生む。本ガイドでは、HyperCore上の各レコードのペイロード、それらを結び付けるキー、そして本番環境で照合を破綻させる失敗ケースを順に解説する。

3つのレコードは別々のストリームで届き、変化するタイミングも異なる。そのため、これらを同じものとして扱うコードは、取引所の状態から徐々にずれていく。以下の内容はすべてHyperliquid mainnetのライブデータで確認しており、本ガイドの2つのスクリプトは現時点で公開Hyperliquid APIに対して動作する。

注文、トレード、フィルの違いは何か

HyperCoreはHyperliquidのネイティブな取引エンジンで、HyperEVMのスマートコントラクト環境とは別物だ。完全にオンチェーンのオーダーブックを運用しているため、注文の発注、キャンセル、マッチングはすべてブロックに記録される。

3つのレコードは、その活動をそれぞれ異なる角度から記述する。

レコード
記述する内容
識別子
範囲
ストリーム
注文更新
注文ステータスの変化(open、filled、canceled、rejected)
oid
1アカウント
orderUpdates(ネイティブ)、userOrderUpdates(Alchemy)
トレード
ある市場における買い手と売り手の1回のマッチング
tid
1市場、双方のアカウント
trades
フィル
トレードにおける1アカウント側の記録(手数料とPnLを含む)
tidとoid
1アカウント
userFills

トレードはマッチングを市場の視点から見たものだ。そのため両方のアカウントが記載され、手数料は含まれない。フィルは同じマッチングをアカウントの視点から見たもので、そのアカウントが支払った手数料、約定前に保有していたポジション、元になった注文を含む。注文更新は注文そのものを記述するが、次のセクションで示すように、多くの開発者が想定するよりもまばらにしか届かない。

トレーディングボットやポートフォリオトラッカーを構築するなら、何が約定したかについての信頼できる情報源はフィルであり、何がまだオーダーブックに残っているかについての信頼できる情報源は注文更新だ。

注文更新はどのような形をしているか

注文更新は、注文のステータスが変化したときに発行される。ペイロードは、注文をステータスレコードで包んだ形になっている。

json
Copied
{ "status": "open", "statusTimestamp": 1780000000000, "hash": "0xabc...def", "order": { "oid": 12345, "coin": "BTC", "side": "B", "limitPx": "100000.0", "sz": "0.10", "origSz": "0.10", "orderType": "Limit", "tif": "Gtc", "reduceOnly": false, "isTrigger": false, "triggerPx": "0.0", "cloid": null } }

状態の追跡で最も重要なフィールドは次のとおりだ。

  • oid。取引所が割り当てる注文ID。フィルはこれを参照するため、注文とその約定を結び付ける結合キーになる。
  • origSzとsz。発注時のサイズと、このステータスレコードの時点でまだオーダーブックに残っているサイズ。
  • status。新しい状態。よく見られるのはopen、filled、canceled、triggeredで、ほかにもbadAloPxRejected、reduceOnlyCanceled、selfTradeCanceled、liquidatedCanceledのような、個別の拒否理由やキャンセル理由が数多く存在する。
  • tif。Time in force(有効期間)。Gtcはキャンセルされるまでオーダーブックに残り、Alo(add liquidity only)はスプレッドをまたぐ場合に拒否され、Ioc(immediate or cancel)は約定できる分だけ約定して残りを破棄する。
  • cloid。クライアントが任意で指定する注文ID。注文を自システムが送ったリクエストと照合するのに役立つ。

userOrderUpdatesのリファレンスは、ノード出力で観測されたすべてのステータス値を列挙しているが、このリストは網羅的ではないと明記している。未知のステータス文字列は、クラッシュの原因にするのではなく、ログに記録して調査すべき終了状態として扱う。

注文更新には、素朴なステートマシンを破綻させる挙動が3つある。

部分約定では注文更新は発生しない。ライブのアカウントを観察したところ、オーダーブックに残っていて部分的に約定した注文は、openを発行し、続いて1件以上のフィルが届き、その後は終了状態に達するまで何も発行しなかった。そうした注文が後でキャンセルされると、canceledレコードのszに残りのサイズが入る。オープン注文の現在の残りサイズが必要なら、フィルから計算するか、必要なときにオープン注文を照会する。

filledステータスは、必ずしも全量約定を意味しない。サイズの一部だけがマッチしたIoc注文は、ステータスfilledで終わり、szはゼロ以外の値になる。これは破棄された未約定の残りだ。アクティブなアカウントから取得した45秒間のサンプルでは、filledに達した127件のIoc注文のうち72件にサイズが残っていた。注文を完了として扱う前に、szを確認すること。

hashフィールドに値が入るのはopenの場合だけだ。キャンセルや拒否のレコードではhashがnullになるため、トランザクションハッシュは注文履歴のキーとして使えない。

トレードはどのような形をしているか

tradesストリームは、1つの市場で起きたすべてのマッチングを配信する。各レコードは小さい。

json
Copied
{ "coin": "ETH", "side": "A", "px": "2658.9", "sz": "1.0", "time": 1790608928951, "hash": "0xd8c5bd7a...25ae9e33", "tid": 825882878864874, "users": ["0x4cf1...e717", "0xe4f5...c0c4"] }

sideはテイカー側を示し、A(ask)はテイカーが売ったこと、B(bid)はテイカーが買ったことを意味する。usersは常に買い手を先、売り手を後に並べるため、sideがBならテイカーはusers[0]、sideがAならusers[1]になる。上の例ではテイカーが売ったので、0xe4f5...c0c4がテイカー、0x4cf1...e717がオーダーブックに注文を置いていた買い手となる。

1つのテイカー注文がオーダーブック上の複数の注文を一掃すると、複数のトレードが生成される。それぞれが固有のtidを持ち、1つのトランザクションがすべてを引き起こしたため、同じhashとtimeを共有する。つまりhashが識別するのはトランザクションであって、トレードではない。

一部のトレードは、すべてゼロのhashを持つ。8つの市場にまたがる直近80件のトレードのサンプルでは、7件のhashがゼロだった。追跡した1件はTWAPスライスで、これは取引所が新たな署名付きトランザクションなしにユーザーに代わって執行するものだ。Hyperliquidのinfoエンドポイントのドキュメントでも、TWAPのフィルがゼロのhashを持つことが確認できる。hashでトレードとトランザクションを結合するコードは、このケースに対処する必要がある。

tradesは、歩み値、出来高、価格チャートなど、市場全体のデータに使う。特定のアカウントに関することには、代わりにフィルを購読する。トレードレコードには、手数料も注文IDもポジションの文脈もないからだ。

フィルはどのような形をしているか

フィルは、トレードにおける1つのアカウント側の記録だ。以下は、上のETHトレードに対応する2つのフィルをmainnetから取得したものだ。

json
Copied
[ { "coin": "ETH", "px": "2658.9", "sz": "1.0", "side": "B", "time": 1790608928951, "startPosition": "19.3599", "dir": "Open Long", "closedPnl": "0.0", "hash": "0xd8c5bd7a...25ae9e33", "oid": 559115773954, "crossed": false, "fee": "-0.079767", "tid": 825882878864874, "feeToken": "USDC", "twapId": null, "cloid": "0xdd0cd482496f45f79c5ffbe7c27e1511" }, { "coin": "ETH", "px": "2658.9", "sz": "1.0", "side": "A", "time": 1790608928951, "startPosition": "-776.0", "dir": "Open Short", "closedPnl": "0.0", "hash": "0xd8c5bd7a...25ae9e33", "oid": 559115788953, "crossed": true, "fee": "1.196505", "builderFee": "0.26589", "tid": 825882878864874, "feeToken": "USDC", "twapId": null } ]

2つのフィルは、トレードのtid、価格、サイズ、時刻を共有する。それ以外はすべてアカウント固有だ。

  • oid。このフィルを生んだ注文。買い手と売り手はそれぞれ自分の注文を持つ。
  • crossed。テイカーならtrue、メイカー(オーダーブックに残っていた注文)ならfalse。
  • fee。このアカウントが支払った手数料。上の買い手のフィルのように、負の値はメイカーリベートを表す。builderFeeがある場合、それはビルダー(注文をルーティングしたフロントエンドやアプリ)に支払われた部分で、すでにfeeに含まれている。
  • startPosition、dir、closedPnl。フィル前のポジションサイズ、Open LongやClose Shortのような表示用ラベル、そしてこのフィルで確定した実現PnL。これらを使えば、フィルだけからポジション履歴を再構築できる。
  • liquidation。通常のフィルではnull。清算の場合は、清算されたアカウント、マーク価格、そして清算がマーケット経由かバックストップ経由かを示す。
  • twapId。フィルを生んだTWAP注文(存在する場合)。

手数料、リベート、実現PnL、清算の詳細はフィルにしか存在しない。そのため、台帳や税務レポートはすべてフィルから構築する必要がある。

注文、トレード、フィルはどのように結合されるか

ほぼすべての結合は、2つのキーで成り立つ。

結合
キー
カーディナリティ
トレードからフィル
tid(coinとtimeを併用)
1トレードにつき、ちょうど2つのフィル
フィルから注文
oid
1注文につき、0個以上のフィル
注文からクライアントリクエスト
cloid
設定した場合は1対1
フィルまたはトレードからトランザクション
hash
多対1、TWAPスライスではhashがすべてゼロ

土台にすべきなのは最初の2行だ。トレードからフィルへの結合は、tradesからトレードを1件取得し、users配列にある両方のアドレスのフィルを照会することで、ライブデータ上で確認した。各アドレスには、そのトレードのtidを持つフィルがちょうど1件ずつあり、一方はcrossed: true、もう一方はcrossed: falseだった。

注文の残りサイズは、フィルから注文への結合から求められる。

text
Copied
remaining = origSz - sum(fill.sz for every fill where fill.oid == order.oid)

この式はすべての注文で成り立つ。最後に受け取った注文更新のszよりも、この式を信頼すべきだ。

フィルから注文の状態をどう照合するか

以下のスクリプトは、1つのアカウントの注文更新とフィルを購読し、各注文の約定済みサイズをフィルから追跡し、注文が終了状態に達したときに取引所が報告するszとその計算結果を突き合わせる。WebSocketを標準でサポートする最近のNodeリリースで動作し、依存関係は不要だ。

javascript
Copied
// Tracks one account's orders by joining order-status events with fills. // Usage: node reconcile.mjs 0xYourAddress const WS_URL = process.env.HL_WS_URL ?? "wss://api.hyperliquid.xyz/ws"; const user = process.argv[2]; if (!/^0x[0-9a-fA-F]{40}$/.test(user ?? "")) throw new Error("pass a 0x address"); // Sizes arrive as decimal strings. Sum them as scaled integers so partial fills add up exactly. const SCALE = 10; const units = (s) => { const [i, f = ""] = s.split("."); return BigInt(i + f.padEnd(SCALE, "0").slice(0, SCALE)); }; const fmt = (u) => (Number(u) / 10 ** SCALE).toString(); const orders = new Map(); // oid -> { coin, origSz, filled, status, reported } const seenFills = new Set(); // coin|time|tid, so a fill delivered twice is counted once const fillKey = (f) => `${f.coin}|${f.time}|${f.tid}`; const order = (oid) => { if (!orders.has(oid)) orders.set(oid, { filled: 0n, status: "unknown" }); return orders.get(oid); }; // Runs whenever a terminal order learns something new, so either stream can arrive first. function check(oid, o) { if (o.reported === undefined) return; // not terminal yet const remaining = o.origSz - o.filled; if (remaining === o.reported && o.filled === 0n) return; // plain cancel or reject const result = remaining === o.reported ? "ok" : remaining > o.reported ? `waiting for fills (${fmt(remaining - o.reported)} outstanding)` : `MISMATCH (fills say ${fmt(remaining)})`; console.log(`${oid} ${o.coin} ${o.status}: filled ${fmt(o.filled)} of ${fmt(o.origSz)}, sz=${fmt(o.reported)} ${result}`); } function onOrderUpdate(u) { const o = order(u.order.oid); Object.assign(o, { coin: u.order.coin, origSz: units(u.order.origSz), status: u.status }); if (u.status === "open") return; o.reported = units(u.order.sz); check(u.order.oid, o); } function onFill(f, quiet = false) { const key = fillKey(f); if (seenFills.has(key)) return; seenFills.add(key); const o = order(f.oid); o.filled += units(f.sz); if (!quiet) { const role = f.crossed ? "taker" : "maker"; console.log(`${f.oid} ${f.coin} fill tid=${f.tid} ${f.side} ${f.sz}@${f.px} ${role} fee=${f.fee}${f.liquidation ? " LIQUIDATION" : ""}`); } check(f.oid, o); } const ws = new WebSocket(WS_URL); ws.onopen = () => { for (const type of ["orderUpdates", "userFills"]) { ws.send(JSON.stringify({ method: "subscribe", subscription: { type, user } })); } }; ws.onmessage = ({ data }) => { const msg = JSON.parse(data); if (msg.channel === "orderUpdates") msg.data.forEach(onOrderUpdate); if (msg.channel !== "userFills") return; // The first message is a snapshot of recent fills. Count it, so orders already partly // filled at startup add up, but don't print it. The dedupe set absorbs it on reconnect. const { fills, isSnapshot } = msg.data; fills.forEach((f) => onFill(f, isSnapshot)); if (isSnapshot) console.log(`loaded ${fills.length} fills from the snapshot`); }; ws.onclose = () => console.log("closed; reconnect and backfill from your last stored fill time");

任意のアクティブなアドレス、たとえば直近のトレードのusers配列から取得したアドレスに対して実行してみてほしい。取引量の多いアカウントに対して45秒間実行したときの出力は次のようになった。

text
Copied
loaded 30 fills from the snapshot 580847595557 HYPE fill tid=524471650157202 B [email protected] taker fee=1.800529 580847595557 HYPE fill tid=1042881790178215 B [email protected] taker fee=0.71124 580847595557 HYPE filled: filled 214.47 of 221.99, sz=7.52 ok 580847576684 ZEC fill tid=38729269326008 B [email protected] maker fee=-0.0005 580847576684 ZEC canceled: filled 0.02 of 2.47, sz=2.45 ok

最初の行は、サブスクリプション開始時に届くスナップショットだ。続く3行は1つのIoc注文で、オーダーブック上の2つの注文を一掃して2つのフィルを生み、7.52 HYPEが未約定のままfilledで終わった。最後の2行は1つのAlo注文で、オーダーブックに残ったままメイカーとして0.02 ZECが約定し、残りがオープンのままキャンセルされた。この実行でフィルを伴って終了状態に達した115件の注文すべてで、フィルから導いた残りサイズは取引所が報告したszと一致した。

スクリプトには重要な点が3つある。サイズは文字列であり、JavaScriptの浮動小数点数として合計すると、部分約定の多い長寿命の注文で丸め誤差が蓄積する。そのため、このスクリプトはスケーリングした整数として合計している。また、チェックは両方のハンドラーから実行される。終了ステータスが届くと報告されたszが保存され、その後に届くフィルはどれも比較を再実行する。そのため、最後のフィルがfilledステータスより後に届いた注文は、誤って不一致と判定されることなく、waiting for fillsの後にokと表示される。最後に、フィルは状態に反映される前に重複排除されるため、たとえば再接続後のスナップショットや、ライブストリームと重なるバックフィルなどで2回届いたフィルも、1回しかカウントされない。

HyperliquidのWebSocketドキュメントは、tidを50ビットのハッシュと説明し、ブロック時刻、coin、tidの組み合わせをグローバルに一意なトレードIDとしている。そのため、このスクリプトはcoin、time、tidをフィルのキーにしている。トレードとその2つのフィルは、この3つをすべて共有する。このキーは1つのアカウント内では一意だ。1つのプロセスで多数のアカウントを追跡する場合は、トレードの双方が残りの要素を共有するため、キーにuserを加える。

本番環境では何が壊れるか

照合のバグのほとんどは、次のケースから生じる。

  • 配信の重複。再購読すると、ネイティブのuserFillsストリームは直近のフィルのスナップショット(isSnapshot: true)から始まり、バックフィルはライブストリームがすでに配信した分と重なる。フィルはuser、coin、time、tidの組み合わせで、注文更新はoid、status、statusTimestampの組み合わせで重複排除する。
  • ストリーム間の順序。注文更新とフィルは別々のチャネルで届く。フィルがその注文のfilledステータスより先に届くとは限らず、その逆も同様だ。スクリプトのようにoidをキーにして状態を管理し、どちらのイベントからでもレコードを作成できるようにし、遅れて届いたフィルでチェックを再実行すれば、この依存関係はなくなる。
  • 切断。WebSocketのサブスクリプションは接続中に起きたことしか配信しないため、ダウンタイムが生じたらバックフィルが必要になる。最後に処理したフィルの時刻を保存し、以下に示すように、その時点からフィルを照会する。
  • TWAPスライスのフィル。TWAPスライスはゼロのhashで執行される。公開Hyperliquid APIでは、追跡したスライスのフィルはuserFillsByTimeではなくuserTwapSliceFillsに現れた。そのため、userFillsByTimeしか読まないバックフィルでは取りこぼす可能性がある。
  • 未知のステータス。拒否理由やキャンセル理由は新たに追加されていく。未知の値はクラッシュさせずに処理し、triggerConditionは表示用の文字列として扱う。機械可読なトリガー価格はtriggerPxだ。
  • 市場識別子。コイン文字列は市場の種類をエンコードしている。市場、資産、DEXに関するドキュメントにあるとおり、BTCは無期限先物、現物市場は@107のような@index文字列、アウトカム市場は#N、サードパーティがデプロイしたHIP-3市場はdex:nameを使う。コイン文字列は大文字と小文字を区別するため、厳密に比較し、決して小文字に変換しないこと。
  • チェックポイントの早すぎる更新。最後のフィルの時刻は、そのフィルをストレージに書き込んだ後にだけ永続化する。先に更新すると、2つのステップの間でクラッシュした場合に、フィルが気付かれないまま失われる。

ある時間範囲のフィルのバックフィルは、短いループで済む。Hyperliquidのinfoエンドポイントのドキュメントによると、userFillsByTimeはフィルを古い順に、1レスポンスあたり最大2,000件返す。そのためループは、これまでに見た最新の時刻から再開し、重なった分は同じ重複排除キーで吸収する。

javascript
Copied
// Pulls every fill for an account between two times, for use after a stream gap. // Usage: node backfill.mjs 0xYourAddress <startMs> [endMs] const INFO_URL = process.env.HL_INFO_URL ?? "https://api.hyperliquid.xyz/info"; const [user, start, end = Date.now()] = process.argv.slice(2); async function fillsBetween(user, startTime, endTime) { const byKey = new Map(); let from = Number(startTime); while (true) { const res = await fetch(INFO_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: "userFillsByTime", user, startTime: from, endTime: Number(endTime) }), }); const page = await res.json(); const before = byKey.size; for (const f of page) byKey.set(`${f.coin}|${f.time}|${f.tid}`, f); // Pages are capped. Restart at the newest time seen; the dedupe key absorbs the overlap. if (page.length === 0 || byKey.size === before) break; from = Math.max(...page.map((f) => f.time)); } return [...byKey.values()].sort((a, b) => a.time - b.time); } const fills = await fillsBetween(user, start, end); console.log(`${fills.length} fills`);

同じ取引量の多いアカウントに対して、30分間のウィンドウで4ページにわたり6,049件のフィルが返ってきた。結果をストリームと同じonFillハンドラーに渡せば、重複排除用のセットによって、バックフィルとライブストリームの重なりが二重にカウントされることはない。

同じドキュメントには、userFillsByTimeで取得できるのは直近10,000件のフィルだけだと記載されている。上記のペースでは1時間に満たない履歴にすぎないため、これほどアクティブなアカウントでそれより長く停止すると、公開エンドポイントが返せる範囲を超えてしまう。切断されたコンシューマーにその時間枠よりも十分早く気付けるよう、アラートを設計すること。

AlchemyのHyperCoreストリーミングで何が変わるか

AlchemyのHyperCoreストリーミングはネイティブのサブスクリプションエンベロープを使うため、上記のペイロードと結合ロジックはそのまま変わらない。違いは、各フレームが元のブロックについて何を伝えるかと、1つのサブスクリプションでどこまでカバーできるかにある。

すべてのデータフレームは、ネイティブのペイロードに加えて、subscriptionId、blockHeight、blockTimeなどのトップレベルのメタデータを持つ。注文更新とフィルは引き続き別々のチャネルで届くが、各フレームが元のブロックを示すため、2つのストリームを突き合わせる際の共通の基準点になる。

サブスクリプションは配列も受け付ける。ネイティブのサブスクリプションは単一のuserまたはcoinしか取らないが、当社のサブスクリプションはusers、coins、buildersを受け付けるため、1つのサブスクリプションで複数のアカウントをまとめてカバーできる。すべてのフィルと注文のレコードにはuserが含まれるため、上述した複数アカウント向けの重複排除キーが、例外ではなく標準のケースになる。

広範なフィードから自分で導き出さなくても済むよう、いくつかのストリームが用意されている。

  • liquidationFills。清算を伴うフィルだけを、市場ごとにフィルタして配信する。
  • builderFillsとbuilderOrderUpdates。ビルダーアドレスに帰属するフィルと注文更新。注文をルーティングし、自らのフローと手数料を追跡する必要があるアプリ向け。
  • allFills。選択した市場における全アカウントのフィル。簡素なトレードレコードではなく、完全なフィルのスキーマで配信される。

照合スクリプトを当社のエンドポイントで実行するには、HL_WS_URLを早期アクセスで受け取るWebSocket URLに向け、HyperCore接続ライフサイクルのドキュメントの説明に従って認証し、orderUpdatesの代わりにuserOrderUpdatesを購読する。当社の注文更新フレームはステータスレコードをorders配列で包んでいるため、msg.dataではなくmsg.data.ordersから読み取る。履歴については、当社のHyperCore REST APIに、カーソルベースのページネーションに対応したuserFillsByTimeとhistoricalOrdersが追加される予定だ。どちらもウォレットの取引履歴を照会するガイドに記載されているが、まだ呼び出せないため、当面は上記のバックフィルスクリプトで公開Hyperliquidエンドポイントを使っている。

HyperCoreストリーミングはどうすれば利用できるか

HyperCoreストリーミングは早期アクセス段階にあり、6月に発表したHyperCoreへの取り組みの一環だ。当社が運用するHyperliquidノードからストリーミングするため、自分でhl-nodeを動かす必要はない。すでにAlchemyで開発しているなら、現在使っているAPIキーで有効化する。まずHyperCore Data APIの概要でストリームとRESTメソッドを確認し、次にHyperCoreの早期アクセスをリクエストして接続情報を入手し、当社チームと直接連携してほしい。

スタックの他の部分でストリーミングのトランスポートを選ぶなら、webhooks、WebSockets、gRPCの比較記事でトレードオフを解説している。HyperliquidのHyperEVM側については、Hyperliquid RPCガイドを参照してほしい。

Background gradient

ブロックチェーンで魔法を生み出す

Alchemyは、最も強力なweb3開発者向けプロダクトとツールを、豊富なリソース、コミュニティ、そして卓越したサポートと組み合わせて提供します。