跳至内容
0%

Hyperliquid 订单、成交与成交回报对比

发布于 2026年10月20日5 分钟阅读

Hyperliquid 订单、成交与成交回报对比

在 Hyperliquid 上,订单(order)是买入或卖出的指令,成交(trade)是两个订单之间的一次撮合,而成交回报(fill)是某个账户在这次撮合中的一方。每笔成交恰好产生两条成交回报,每个账户一条。本指南逐一介绍每种记录在 HyperCore 上的负载、关联它们的键,以及在生产环境中破坏对账的故障情形。

这三种记录通过不同的流到达,并在不同的时间发生变化,因此把它们当作同一种东西处理的代码,会逐渐与交易所失去同步。下文所有内容都已对照 Hyperliquid 主网的实时数据核实过,本指南中的两个脚本目前也可以直接在公开的 Hyperliquid API 上运行。

订单、成交和成交回报有什么区别?

HyperCore 是 Hyperliquid 的原生交易引擎,与 HyperEVM 智能合约环境相互独立。它运行一个完全链上的订单簿,因此每一次下单、撤单和撮合都会被记录在区块中。

这三种记录从不同角度描述这些活动:

记录
描述内容
标识符
范围
流
订单更新
订单状态的变化(open、filled、canceled、rejected)
oid
单个账户
orderUpdates(原生)、userOrderUpdates(Alchemy)
成交
某个市场中买方与卖方之间的一次撮合
tid
单个市场,双方账户
trades
成交回报
某个账户在一笔成交中的一方,含手续费和盈亏(PnL)
tid 和 oid
单个账户
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(仅添加流动性)在会穿越价差时被拒绝,Ioc(立即成交否则取消)成交能成交的部分并丢弃剩余部分。
  • cloid。可选的客户端自定义订单 ID,便于把订单与你的系统发出的请求对应起来。

userOrderUpdates 参考文档列出了在节点输出中观察到的所有状态值,并说明该列表并不详尽。把未知的状态字符串视为一种需要记录日志并调查的终态,而不是让程序崩溃。

订单更新的三种行为会让简单的状态机出错。

部分成交不会产生订单更新。我们观察实时账户时发现,一个被部分成交的挂单会先发出 open,然后是一条或多条成交回报,之后便不再有任何更新,直到它进入终态。当这样的订单后来被撤销时,canceled 记录会在 sz 中携带剩余数量。如果你需要某个挂单的实时剩余数量,请根据成交回报计算,或按需读取当前挂单。

filled 状态并不总是意味着完全成交。一个只撮合了部分数量的 Ioc 订单,最终状态是 filled,且 sz 不为零,这个值就是被丢弃的未成交部分。在一个活跃账户的 45 秒样本中,达到 filled 的 127 个 Ioc 订单里,有 72 个仍有剩余数量。在把订单视为已完成之前,先检查 sz。

hash 字段只在 open 时有值。已撤销和被拒绝的记录 hash 为 null,因此交易哈希无法用作订单历史的键。

成交是什么样的?

trades 流会发布某个市场中的每一次撮合。每条记录都很小:

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 是吃单方(taker)的方向,其中 A(ask,卖方报价)表示吃单方卖出,B(bid,买方报价)表示吃单方买入。users 总是先列出买方、再列出卖方,因此当 side 为 B 时,吃单方是 users[0];当 side 为 A 时,吃单方是 users[1]。在上面的例子中吃单方卖出,因此 0xe4f5...c0c4 是吃单方,0x4cf1...e717 是挂单的买方。

一个吃单订单如果吃掉多个挂单,就会产生多笔成交。每笔成交都有自己的 tid,而它们共享相同的 hash 和 time,因为它们全都由同一笔交易引起。这意味着 hash 标识的是交易,而不是成交。

有些成交的 hash 全为零。在横跨八个市场的 80 笔近期成交样本中,有 7 笔的 hash 为零。我们追溯的那一笔是一个 TWAP 子单,由交易所代表用户执行,无需新的签名交易;Hyperliquid 的 info 端点文档也确认 TWAP 成交回报的 hash 为零。通过 hash 将成交关联到交易的代码需要处理这种情况。

trades 适用于全市场数据,例如逐笔成交、成交量和价格图表。凡是涉及某个特定账户的需求,请改为订阅成交回报,因为成交记录不含手续费、订单 ID 和仓位上下文。

成交回报是什么样的?

成交回报是某个账户在一笔成交中的一方。下面是上文那笔 ETH 成交对应的两条成交回报,取自主网:

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 } ]

两条成交回报共享该成交的 tid、价格、数量和时间。其余内容都特定于各自的账户:

  • oid。产生这条成交回报的订单。买方和卖方各有自己的订单。
  • crossed。吃单方为 true,挂单方(maker,即挂在订单簿上的那笔订单)为 false。
  • fee。该账户支付的手续费。负值表示挂单返佣,如上面买方的成交回报所示。builderFee 出现时,表示支付给构建者(builder,即路由该订单的前端或应用)的部分,并且已包含在 fee 中。
  • startPosition、dir 和 closedPnl。成交前的仓位大小、一个展示用标签(例如 Open Long 或 Close Short),以及这次成交平仓所实现的盈亏。借助它们,仅凭成交回报就能重建仓位历史。
  • liquidation。普通成交回报中为 null。对于清算,它会给出被清算的账户、标记价格,以及这次清算是通过市场还是通过后备机制(backstop)完成的。
  • twapId。产生这条成交回报的 TWAP 订单(如果有的话)。

手续费、返佣、已实现盈亏和清算详情只存在于成交回报中,因此任何账本或税务报告都必须基于成交回报来构建。

订单、成交和成交回报如何关联在一起?

两个键承担了几乎所有的关联:

关联
键
对应关系
成交到成交回报
tid,结合 coin 和 time
一笔成交,恰好两条成交回报
成交回报到订单
oid
一个订单,零条或多条成交回报
订单到客户端请求
cloid
一对一,前提是你设置了它
成交回报或成交到交易
hash
多对一,TWAP 子单的 hash 为零

应当以前两行为基础来构建。我们在实时数据上核验了成交到成交回报的关联:从 trades 中取出一笔成交,然后查询 users 数组中两个地址的成交回报。每个地址都恰好有一条带有该成交 tid 的成交回报,其中一条为 crossed: true,另一条为 crossed: false。

订单的剩余数量可以由成交回报到订单的关联推导出来:

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

这个公式对每个订单都成立,比起你最后看到的那条订单更新中的 sz,它更值得信赖。

如何根据成交回报对订单状态进行对账?

下面的脚本订阅某个账户的订单更新和成交回报,根据成交回报跟踪每个订单的已成交数量,并在订单进入终态时,将这一计算结果与交易所报告的 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

第一行是订阅开始时先发送的快照。接下来三行是同一个 Ioc 订单:它吃掉了两个挂单,产生了两条成交回报,最终以 filled 结束,仍有 7.52 HYPE 未成交。最后两行是一个 Alo 订单:它挂在订单簿上,作为挂单方成交了 0.02 ZEC,随后在剩余部分仍挂单时被撤销。在那次运行中,共有 115 个带有成交回报的订单进入了终态,根据成交回报推导出的剩余数量每一次都与交易所报告的 sz 一致。

脚本中有三个细节很重要。数量是字符串,如果把它们当作 JavaScript 浮点数求和,在经历多次部分成交的长期订单上会产生舍入漂移,因此脚本把它们作为缩放后的整数求和。此外,核对会在两个处理函数中都运行。收到终态时会保存交易所报告的 sz,之后到达的任何成交回报都会重新执行这一比较,因此如果某个订单的最后一条成交回报在其 filled 状态之后才到达,它会先显示 waiting for fills,然后显示 ok,而不会被误报为不一致。最后,成交回报在影响状态之前会先去重,因此同一条成交回报到达两次时(例如在重连后的快照中再次出现,或者回填与实时流发生重叠),只会被计算一次。

Hyperliquid 的 WebSocket 文档将 tid 描述为一个 50 位哈希值,并指出区块时间、coin 和 tid 三者合起来才是全局唯一的成交 ID,因此脚本以 coin、time 和 tid 作为成交回报的键。一笔成交及其两条成交回报在这三个字段上完全相同。对单个账户而言,这个键是唯一的。如果你在一个进程中跟踪多个账户,请把 user 也加入键中,因为一笔成交的双方共享其余字段。

生产环境中哪些地方会出问题?

大多数对账错误都来自以下情况。

  • 重叠投递。当你重新订阅时,原生 userFills 流会先发送一份近期成交回报的快照(isSnapshot: true),而回填也会与实时流已经投递的内容发生重叠。成交回报按 user、coin、time 和 tid 去重,订单更新按 oid 加 status 加 statusTimestamp 去重。
  • 跨流顺序。订单更新和成交回报通过不同的频道到达。不要假设成交回报一定先于其订单的 filled 状态到达,反之亦然。像脚本那样以 oid 作为状态的键、允许任一事件创建记录,并在迟到的成交回报到达时重新运行核对,就能消除这种依赖。
  • 断连。WebSocket 订阅只会投递连接期间发生的事情,因此任何停机时间都需要回填。保存你处理过的最后一条成交回报的时间,并从那个时间点开始查询成交回报,如下所示。
  • TWAP 子单的成交回报。TWAP 子单执行时 hash 为零。在公开的 Hyperliquid API 上,我们追溯的那条子单成交回报出现在 userTwapSliceFills 中,而不是 userFillsByTime 中,因此只读取 userFillsByTime 的回填可能会漏掉它们。
  • 未知状态。新的拒绝和撤单原因会不断被加入。处理未知值时不要崩溃,并将 triggerCondition 视为展示用字符串。机器可读的触发价格是 triggerPx。
  • 市场标识符。coin 字符串编码了市场类型。BTC 是永续合约,现货市场使用 @index 形式的字符串(例如 @107),结果市场(outcome market)使用 #N,由第三方部署的 HIP-3 市场使用 dex:name,详见市场、资产和 DEX 文档。coin 字符串区分大小写,因此要精确比较,绝不要把它们转为小写。
  • 过早推进检查点。只有在成交回报写入存储之后,才持久化你的最后成交时间。如果先推进检查点,那么两步之间一旦发生崩溃,就会悄无声息地丢失一条成交回报。

按时间范围回填成交回报只需要一个简短的循环。根据 Hyperliquid 的 info 端点文档,userFillsByTime 按从旧到新的顺序返回成交回报,每次响应最多 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 分钟的时间窗口在四页中返回了 6,049 条成交回报。把结果送入流所使用的同一个 onFill 处理函数,去重集合就能防止回填与实时流之间的重叠被重复计算。

同一份文档指出,通过 userFillsByTime 只能获取最近的 10,000 条成交回报。按上面的速率,这相当于不到一小时的历史,因此对于如此活跃的账户,更长时间的中断会超出公开端点所能返回的范围。请据此设置告警,确保断开连接的消费者能在这个时间窗口内被及早发现。

Alchemy 的 HyperCore 流式传输带来了哪些改变?

Alchemy 上的 HyperCore 流式传输使用原生的订阅封装格式,因此上面的负载和关联逻辑保持不变。区别在于每一帧告诉你的源区块信息,以及一个订阅能够覆盖的范围。

每个数据帧除了原生负载之外,还携带顶层元数据,包括 subscriptionId、blockHeight 和 blockTime。订单更新和成交回报仍然通过不同的频道到达,但每一帧都会标明其来源区块,这样在对齐两个流时,你就有了一个共同的参照点。

订阅也接受数组。原生订阅只接受单个 user 或 coin,而我们的订阅接受 users、coins 和 builders,因此一个订阅就能覆盖一整组账户。每条成交回报和订单记录都带有其 user,这使得上文的多账户去重键成为默认情况,而不再是边缘情况。

有几个流专门提供现成的数据,让你无需从宽泛的数据流中自行推导:

  • liquidationFills。仅包含带有清算信息的成交回报,按市场过滤。
  • builderFills 和 builderOrderUpdates。归属于某个构建者地址的成交回报和订单更新,适用于路由订单、需要跟踪自身订单流和手续费的应用。
  • allFills。所选市场中所有账户的成交回报,采用完整的成交回报结构,而不是更精简的成交记录。

要让对账脚本连接我们的端点,请将 HL_WS_URL 指向你在获得抢先体验资格时收到的 WebSocket URL,按照 HyperCore 连接生命周期文档中的说明进行身份验证,并订阅 userOrderUpdates 而不是 orderUpdates。我们的订单更新帧会把状态记录包装在一个 orders 数组中,因此请从 msg.data.orders 而不是 msg.data 读取它们。在历史数据方面,我们的 HyperCore REST API 将会新增 userFillsByTime 和 historicalOrders,并支持基于游标的分页。两者都已记录在查询钱包交易历史指南中,但目前还无法调用,因此上面的回填脚本暂时使用公开的 Hyperliquid 端点。

如何获得 HyperCore 流式传输的访问权限?

HyperCore 流式传输目前处于抢先体验阶段,是我们在 6 月宣布的 HyperCore 相关工作的一部分。你从我们运营的 Hyperliquid 节点读取数据流,因此无需运行 hl-node;如果你已经在 Alchemy 上构建,我们会在你目前使用的 API key 上为你启用它。先从 HyperCore Data API 概览开始,了解有哪些流和 REST 方法,然后申请 HyperCore 抢先体验,获取连接详情并直接与我们的团队合作。

如果你正在为技术栈的其余部分选择流式传输方式,我们对 webhooks、WebSockets 和 gRPC 的比较介绍了其中的取舍。关于 Hyperliquid 的 HyperEVM 一侧,请参阅我们的 Hyperliquid RPC 指南。

Background gradient

构建区块链应用

Alchemy 将最强大的 Web3 开发者产品和工具与资源、社区及专业支持结合在一起。