基于 K 线的 Hyperliquid 回测,是按某个价格而不是对着订单簿来成交每一笔订单的,而且从不支付手续费。这会错得多离谱,取决于币种。样本日 BTC 的订单簿很深,价差只有一个 tick,因此一笔 $100k 市价单的成本只比中间价高半个 tick,而成交记录中的吃单手续费是它的五十倍。HYPE 的 25 个可见档位只挂着约 $0.2M:同样一笔 $100k 订单在典型时刻要比中间价多付 2.2 bps,而一笔 $1M 订单在一天中 99% 的时间都放不进可见订单簿。本教程用 pandas 加载历史订单簿,按该区块上实际挂着的各档价位逐档吃单,为每一笔模拟订单定价,并按成交记录计入手续费。所有数字都来自免费样本日和文中展示的脚本。
Hyperliquid 大约每 71.5 ms 出一个区块。样本日 BTC 的订单簿在其中 68.7% 的区块里发生了变化,共 824,716 个订单簿状态,而链上归档只发布了 15,999 个订单簿快照,每 5.4 秒一个。快照序列大约只能看到五十分之一的 BTC 订单簿状态。info API 则只返回当前的订单簿。
而真正要紧的订单,恰恰是那些逐档吃单的订单。从成交还原出每一笔吃单订单后可以看到,BTC 只有 5.3% 的吃单订单(HYPE 为 11.9%)以不止一个价格成交,但在两个币种上,它们都占了当天吃单名义金额的 61%。按中间价或最新成交价给这些订单定价的回测,在真正能推动价格的大部分成交量上都是错的。
Hyperliquid 数据按天出售,可按币种筛选:选好日期范围、数据表和币种,数据包里每张表、每个币种、每一天各一个 parquet 文件。有三种获取方式:
import requests, timeAPI = "https://tickfoundry.com/api/v1" # Explorer and upH = {"Authorization": "Bearer tf_live_…"} # minted in the dashboardjob = requests.post(f"{API}/hyperliquid/downloads", headers=H, json={"start": "2026-08-01", "end": "2026-08-31","datasets": ["hl_l2", "hl_trades"],"coins": ["HYPE"],}).json()while (s := requests.get(f"{API}/downloads/{job['id']}", headers=H).json())["status"] == "staging":time.sleep(10)open("hyperliquid.zip", "wb").write(requests.get(s["url"], headers=H).content)
自己动手搭建(从请求方付费的 S3 中拉取链上订单事件归档,再回放重建订单簿),仅流量与算力费用就约需 $10,000。
数据包按表组织:hl_l2/date=D/<COIN>/l2.parquet,hl_l1 和 hl_trades 同理,另有 quality/date=D/<COIN>.json。行以 msg_seq(区块高度)为键,这是值得信赖的排序依据;ts_src_ms 是区块自身的时间。订单簿表中,25 档内有任何变化的区块各占一行,采用宽表列 bid_px_1 … ask_sz_25,每档的挂单笔数在 bid_n_* / ask_n_* 中。数量以币为单位。
import pandas as pd# the free sample day, pulled from /catalog/hyperliquid and unzippedbook = pd.read_parquet("hl_l2/date=2026-08-12/BTC/l2.parquet") # 25-level book, a row per block it changedtrades = pd.read_parquet("hl_trades/date=2026-08-12/BTC/trades.parquet") # every fill: taker side, wallet, feetop = pd.read_parquet("hl_l1/date=2026-08-12/BTC/l1.parquet") # top of book, every change# msg_seq is the block height and orders everything; ts_src_ms is block timebook = book.sort_values("msg_seq")book["ts"] = pd.to_datetime(book.ts_src_ms, unit="ms", utc=True)book["mid"] = (book.best_bid + book.best_ask) / 2# or every coin and day in the pull at once; date comes from the pathbooks = pd.read_parquet("hl_l2", columns=["coin", "msg_seq", "ts_src_ms", "best_bid", "best_ask"])
完整字段说明和覆盖范围注意事项见 Hyperliquid schema 文档(英文)。
这样,为市价单定价就是对每一行做算术:从每一档依次取用美元金额,直到订单成交完毕,再做除法。由于行的间隔并不规则(繁忙的一秒有几十行,清淡的一秒一行都没有),要按每个订单簿状态的存续时长加权,这样统计量回答的才是“这笔订单在一天中随机某一刻会付出多少成本”。当 25 档的挂单量小于订单规模时,结果是 NaN,而不是猜测值。
# hl_slippage.py (core) — the full script is linked aboveimport numpy as npdef walk(px, sz, usd):"""Average fill price of a market order for `usd` notional, taking levelsbest-first (px/sz: rows x 25). NaN where the 25 levels hold less."""cost = px * sz # $ resting at each levelcum = np.nancumsum(cost, axis=1)take = np.minimum(cost, np.maximum(usd - (cum - cost), 0)) # $ taken per levelqty = np.nansum(take / px, axis=1)with np.errstate(divide="ignore", invalid="ignore"):return np.where(cum[:, -1] >= usd, usd / qty, np.nan)levels = range(1, 26)ask_px = book[[f"ask_px_{i}" for i in levels]].to_numpy()ask_sz = book[[f"ask_sz_{i}" for i in levels]].to_numpy() # size in coin unitsmid = book.mid.to_numpy()live_ms = book.ts_src_ms.shift(-1).sub(book.ts_src_ms).fillna(0).to_numpy() # time-weightvwap = walk(ask_px, ask_sz, 1_000_000)slip_bps = (vwap / mid - 1) * 1e4 # worse than mid, per book stateprint("fits in 25 levels:", live_ms[~np.isnan(vwap)].sum() / live_ms.sum())
| 市价单 | 25 档内可容纳 | 相对中间价 · 中位数 | 相对中间价 · p90 | 相对最优价 · 中位数 |
|---|---|---|---|---|
| 买入 $10,000 | 100.0% | 0.08 | 0.08 | 0.00 |
| 卖出 $10,000 | 100.0% | 0.08 | 0.08 | 0.00 |
| 买入 $100,000 | 100.0% | 0.08 | 0.48 | 0.00 |
| 卖出 $100,000 | 100.0% | 0.08 | 0.46 | 0.00 |
| 买入 $1,000,000 | 100.0% | 0.35 | 1.31 | 0.27 |
| 卖出 $1,000,000 | 100.0% | 0.29 | 1.26 | 0.21 |
| 市价单 | 25 档内可容纳 | 相对中间价 · 中位数 | 相对中间价 · p90 | 相对最优价 · 中位数 |
|---|---|---|---|---|
| 买入 $10,000 | 100.0% | 0.33 | 1.37 | 0.21 |
| 卖出 $10,000 | 100.0% | 0.32 | 1.41 | 0.19 |
| 买入 $100,000 | 96.4% | 2.20 | 3.32 | 2.08 |
| 卖出 $100,000 | 97.4% | 2.37 | 3.44 | 2.26 |
| 买入 $1,000,000 | 0.9% | — | — | — |
| 卖出 $1,000,000 | 0.4% | — | — | — |
2026-08-12 UTC · 滑点单位为 bps,即平均成交价比参考价差出的部分 · 按当天文件夹覆盖的 23.8 小时做时间加权 · “可容纳”指一天中 25 档能吃下整笔订单的时间占比;$1M 的 HYPE 订单能被容纳的时间太少,其成本数字没有意义,因此留空。
把它和大多数回测的假设对照来看。在 BTC 上,约 $200k 以内按中间价成交只差半个 tick,一笔 $1M 订单的成本中位数为 0.35 bps,每十个时刻中有一个达到 1.3 bps。虚线才是更大的那个数字:同一天成交记录中的吃单手续费中位数为 4.3 bps,所以在 BTC 上,决定短周期策略能否存活的是手续费,而不是订单簿。在 HYPE 上,问题出在订单簿:一笔 $100k 订单会深入可见的 25 档,成本中位数为 2.2 bps;到 $200k 时,一天中超过一半的时间可见订单簿都太薄。
# every taker order of the day, rebuilt from its fills: one order id, one blockt = trades.assign(usd=trades.price * trades["size"])orders = t.groupby(["oid", "msg_seq"]).agg(side=("side", "first"), usd=("usd", "sum"),prices=("price", "nunique"), fee=("fee", "sum"))walked = orders[orders.prices > 1]print(f"{len(walked) / len(orders):.1%} of taker orders filled at more than one price,")print(f"{walked.usd.sum() / orders.usd.sum():.1%} of taker notional")
上面的中位数描述的是普通时刻。代价高昂的是行情快速变化的时刻,而基于 K 线的回测恰恰在这些时刻记下它最漂亮的交易。样本日 12:30 UTC,BTC 在一分钟内下跌 34.8 bps,其间最高到最低的振幅为 52.6 bps。卖方 25 档当天挂单的中位数为 $8.7M,此时最低跌到 $196k;价差从一个 tick 扩大到 5.45 bps;一笔在典型时刻成本为 0.35 bps 的 $1M 市价买单,在这一分钟内的成本中位数为 0.76 bps,最差瞬间达到 9.41 bps,并且在这一分钟里有 3.8% 的时间放不进可见订单簿。
HYPE 最快的一分钟出现在 14:36 UTC,上涨 52.2 bps。它的 25 档从中位数约 $195k 降到每侧约 $71k,一笔中位数成本为 2.2 bps 的 $100k 买单,在那一分钟最差的瞬间成本高达 34.9 bps。基于 K 线定价的回测永远看不到这两个数字。
样本日覆盖平台上的所有币种,而不只是这两个。用免费账户拉取你所交易币种在 2026-08-12 的数据,脚本无需修改即可运行:python hl_slippage.py . SOL ETH。