Backtest Hyperliquid data as a fixed experiment, not a loose collection of rows. Freeze the venue family, symbol, UTC window, route families, schema version, and coverage check before the strategy sees a value.
Core rule: pull one bounded window, record its response shape and request IDs, normalize timestamps to UTC, model execution costs, and keep one later period untouched until the strategy is locked.
Pull one bounded window
Fixed historical request
This request asks for up to 100 BTC trades from 00:00 to 00:10 UTC on January 1, 2026. Build and above include this historical window. Save the returned pages and request IDs to freeze the dataset used by your backtest.
curl "https://api.0xarchive.io/v1/hyperliquid/trades/BTC?start=1767225600000&end=1767226200000&limit=100" \
-H "X-API-Key: $OXARCHIVE_API_KEY"The request uses Unix milliseconds and a small result bound. Keep the returned meta.next_cursor chain, meta.request_id, and the exact query beside the output file.
Recent-window alternative
With a Free key, request yesterday's first ten UTC minutes on the same trades route and print the resolved bounds with the response:
import json
import os
import time
import requests
start_ms = (int(time.time()) // 86400 - 1) * 86400000
end_ms = start_ms + 600000
response = requests.get(
"https://api.0xarchive.io/v1/hyperliquid/trades/BTC",
headers={"X-API-Key": os.environ["OXARCHIVE_API_KEY"]},
params={"start": start_ms, "end": end_ms, "limit": 100},
timeout=30,
)
print(json.dumps({"start": start_ms, "end": end_ms,
"status": response.status_code, "body": response.json()}))Keep the printed start and end with the result so another reader can repeat the same window.
Select the data
| Input | Use it when | Common failure |
|---|---|---|
| Trades | The strategy reacts to executed price, size, side, and direction | Assuming every signal fills at the next trade |
| L2 depth | Spread, displayed depth, and price impact are part of the rule | Using one snapshot as a continuous book |
| L4 depth | Queue position, resting order identity, or reconstruction matters | Calling lifecycle events a resting book |
| Candles | The rule is bar-based and does not need event order | Using a candle close with look-ahead from the same bar |
| Funding | Carry changes the return or position cost | Joining the first funding value after the decision |
| Open interest | Positioning regime is a feature or filter | Mixing snapshot time with trade time without a join rule |
| Liquidations | Forced-flow events are a signal or a mechanism to study | Treating projected levels or pending triggers as completed fills |
Coverage is route, symbol, and data-type specific. Check the catalog and status for the selected family before using an empty page as evidence that no events existed.
Use symbol coverage with gaps to check coverage for the selected API family and UTC study window. The catalog lists Parquet exports; status reports incidents.
Assemble the pages
This bounded Python pull follows cursors, records request IDs, preserves the returned schema, normalizes timestamps, and reports gaps in the event stream.
import datetime as dt
import json
import os
from pathlib import Path
import requests
BASE = "https://api.0xarchive.io"
SYMBOL = "BTC"
START = 1767225600000
END = 1767226200000
LIMIT = 1000
URL = f"{BASE}/v1/hyperliquid/trades/{SYMBOL}"
headers = {"X-API-Key": os.environ["OXARCHIVE_API_KEY"]}
params = {"start": START, "end": END, "limit": LIMIT}
rows = []
request_ids = []
seen_cursors = set()
for page_number in range(20):
response = requests.get(URL, headers=headers, params=params, timeout=30)
response.raise_for_status()
envelope = response.json()
if envelope.get("success") is not True:
raise RuntimeError("API response was not successful")
meta = envelope.get("meta") or {}
page_rows = envelope.get("data") or []
rows.extend(page_rows)
if meta.get("request_id"):
request_ids.append(meta["request_id"])
cursor = meta.get("next_cursor")
if not cursor:
break
if cursor in seen_cursors:
raise RuntimeError("cursor repeated")
seen_cursors.add(cursor)
params["cursor"] = cursor
else:
raise RuntimeError("page bound reached")
def parse_timestamp(value):
if value is None:
return None
if isinstance(value, (int, float)):
return dt.datetime.fromtimestamp(value / 1000, tz=dt.timezone.utc)
return dt.datetime.fromisoformat(str(value).replace("Z", "+00:00")).astimezone(dt.timezone.utc)
times = [parse_timestamp(row.get("timestamp")) for row in rows]
missing_timestamps = sum(value is None for value in times)
ordered = [value for value in times if value is not None]
non_monotonic = sum(right < left for left, right in zip(ordered, ordered[1:]))
identity = [
(row.get("timestamp"), row.get("trade_id"), row.get("order_id"))
for row in rows
]
duplicate_rows = len(identity) - len(set(identity))
manifest = {
"symbol": SYMBOL,
"start_ms": START,
"end_ms": END,
"schema": sorted({key for row in rows for key in row}),
"rows": len(rows),
"pages": len(request_ids),
"request_ids": len(request_ids),
"missing_timestamps": missing_timestamps,
"non_monotonic_timestamps": non_monotonic,
"duplicate_rows": duplicate_rows,
"gap_policy": "inspect missing or non-monotonic intervals; do not interpolate event rows",
}
Path("btc-trades-manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
Path("btc-trades.jsonl").write_text("\n".join(json.dumps(row) for row in rows) + "\n")For event data, do not infer a regular interval from row spacing. If the selected route has a missing interval or the returned sequence cannot be resolved, record it, stop or rebuild, and never interpolate market events into a complete-looking file.
Execution assumptions for the notebook
| Assumption | Rule to register before the run |
|---|---|
| Population | One exact family and symbol per run; all attempted windows retained |
| Time split | Development, validation and a later untouched holdout; no shuffled event rows |
| Decision clock | Exchange time and receive time stored separately; use only inputs available by decision |
| Aggressive fills | Walk observable depth with explicit latency; cap size at returned depth |
| Passive fills | A queue model, not "next trade equals fill"; no queue claims from candles |
| Partial fills | Carry remaining quantity under the declared order model |
| Costs | Effective fee schedule and funding settlements; no guessed universal fee |
| Missingness | Stop, censor or rebuild under a frozen rule; never interpolate market events |
| Outputs | Gross return, fees, funding, walked-book slippage and sensitivity reported separately; P&L already computed at modeled fill prices does not subtract slippage a second time |
Research estimates need a declared multi-window population, deterministic selection and a later holdout. Bootstrap uncertainty by independent day or session, not individual correlated rows. Report eligible, selected, executable and excluded windows as separate denominators.
Model the costs
Fees
Use the returned fee and fee-token fields when they are present. If the route does not carry a fee field for the selected venue family, store the fee schedule and its effective time beside the run. Do not subtract a guessed rate from every fill.
Funding
Join each position to the last funding observation at or before the decision time. Keep the funding timestamp and interval in the feature table. A later observation is look-ahead.
Slippage and depth
Use the order book that was observable at the decision time. L2 gives aggregated price levels. L4 gives resting orders and queue detail. Compare the simulated fill with the mark, spread, consumed depth, and resulting trade tape. A candle close is not an execution model. To take a tested rule live, see how to build a Hyperliquid trading bot.
Latency
Separate signal time, request time, order-send time, venue acceptance, matching, and data-arrival time. Run the strategy with a bounded latency assumption and keep the assumption in the manifest. Do not make a zero-latency fill look like a networked order.
Split the experiment
| Window | Role | Rule |
|---|---|---|
| Development | Feature design and strategy selection | May be read repeatedly |
| Validation | Parameter and implementation checks | Use after the strategy is specified |
| Holdout | Final estimate of out-of-sample behavior | Keep untouched until the decision is made |
Split by time, not by randomly shuffled rows. Keep the route, symbol, schema, cost assumptions, and gap policy identical across the three windows. If the holdout needs a new rule, it is no longer a holdout.
Native and archived data
The native Hyperliquid API returns the venue's own response. 0xArchive adds bounded historical routes, cursor envelopes, request IDs, coverage checks, replay guidance, and file-oriented exports. Compare the same symbol, UTC window, route grain, and field semantics before choosing a source. A matching row count is not enough. Compare timestamps, prices, sizes, side codes, missing values, and the treatment of empty pages. For the free sources and their limits, see where to get Hyperliquid historical data.
Use the native rate-limit guidance when you collect directly. Use Historical Data API for route contracts and Point-in-time backtesting for reproducibility. The WebSocket backtesting reference covers replay when event order matters.
References
For depth, use the Hyperliquid order-book guide and the order-book API reference. For completed forced executions, use the liquidation guide and the liquidation API reference. Compare product delivery on the Hyperliquid data API, then check catalog, pricing, and status.