Download the Hyperliquid file archive
Ask for a temporary link, download one whole Parquet file, then read it locally with DuckDB, pyarrow or pandas — fills and 5-minute position snapshots by UTC day, and the latest positions file — with the traps that cost a wrong number.
Hyperliquid history is published as Parquet, one file per UTC day, from 2026-05-01: fills (every fill of every wallet)
and positions (every 5-minute snapshot of every open position). A third file, latest, holds the current open positions
of every asset and is replaced every 5 minutes. The files are for work that would take thousands of paged API calls:
a backtest, a research dataset, a full-day aggregate.
There is no folder to browse and no file has a permanent URL. You ask the API for a temporary link to one file and download it; the link needs no key and works for five minutes.
Two requests per file
GET /v1/hyperliquid/archive/urlwithdatasetanddate. It costs one credit and one request of the hourly budget, and answers with aurl,expires_in(300) and the file'sbytes.GETthaturl. It needs no API key, names exactly one file, and spends nothing: downloads are recorded asarchive_downloadand never count against the hourly budget.
Ask for a link and download it
curl
# 1. Ask for the link: the fills of one UTC day. jq -r prints the bare address.
URL=$(curl -s -H "X-API-KEY: $VIKE_API_KEY" \
"https://data.vike.io/v1/hyperliquid/archive/url?dataset=fills&date=2026-10-19" | jq -r '.url')
# 2. Download the whole file. The link carries the credential, so no key header here.
# -f makes curl exit non-zero on a 403 instead of saving the error body as a .parquet.
curl -f -o fills-2026-10-19.parquet "$URL"Name the output with -o, never -O: the last segment of every link is fills.parquet or positions.parquet, so -O
overwrites the previous day with the next.
To ask and download in one command, redirect=1 answers 302 with the link in Location, and -L follows it:
curl -s -L -H "X-API-KEY: $VIKE_API_KEY" -o positions-latest.parquet \
"https://data.vike.io/v1/hyperliquid/archive/url?dataset=positions&date=latest&redirect=1"An interrupted daily file resumes with curl -f -C - -o <file> "$URL", but the resume is a new request and needs a link
that is still valid, so ask for a fresh one first. Do not resume a latest file: it may have been replaced in between.
Start it again.
Python
requests, streaming to disk so a 1.6 GB day never has to fit in memory, and a size check against the bytes the ask
returned, because a truncated Parquet file looks fine until its footer is read:
import os
import requests
API = "https://data.vike.io"
KEY = os.environ["VIKE_API_KEY"] # a free key from vike.io/account
def download(dataset: str, day: str, path: str) -> str:
"""Ask for a temporary link, then stream the one file it names to `path`."""
ask = requests.get(
f"{API}/v1/hyperliquid/archive/url?dataset={dataset}&date={day}",
headers={"X-API-Key": KEY},
timeout=30,
)
ask.raise_for_status()
link = ask.json()
# The link is the credential: no key header on this request.
with requests.get(link["url"], stream=True, timeout=(10, 120)) as r:
r.raise_for_status()
size = 0
with open(path, "wb") as f:
for chunk in r.iter_content(chunk_size=1 << 20):
f.write(chunk)
size += len(chunk)
# `latest` is replaced every 5 minutes, so its size can move between the ask and the download.
if day != "latest" and size != link["bytes"]:
raise RuntimeError(f"{path}: got {size} bytes, expected {link['bytes']}")
return path
download("fills", "2026-10-19", "fills-2026-10-19.parquet")
download("positions", "latest", "positions-latest.parquet")For several days, ask for each link right before its download, as the function does. A link collected up front and downloaded later can be past its 300 seconds by the time its turn comes:
for day in ["2026-10-17", "2026-10-18", "2026-10-19"]:
download("fills", day, f"fills-{day}.parquet")Each file costs one request of the hourly budget and of the per-minute cap (Rate limits), so a full pull of every day of both datasets is a few hundred requests. Bytes are the real cost: the connection is capped at 50 MB/s like the rest of the archive, so a 1.6 GB positions day takes about half a minute at best.
Read it locally
Open the downloaded file, not the link. These read fills-2026-10-19.parquet; the same calls work on positions files.
DuckDB
SET TimeZone = 'UTC';
-- BTC fills per hour. Only the four columns used are read, and only the row groups that can hold BTC.
SELECT date_trunc('hour', fill_ts) AS hour,
count(*) AS fills,
sum(size_usd) FILTER (WHERE crossed) AS taker_volume_usd
FROM read_parquet('fills-2026-10-19.parquet')
WHERE asset = 'BTC'
GROUP BY 1
ORDER BY 1;
-- Several days at once: a glob reads them as one table.
SELECT asset, count(*) FROM read_parquet('fills-2026-10-*.parquet') GROUP BY 1 ORDER BY 2 DESC LIMIT 10;pyarrow
import pyarrow.parquet as pq
# Name the columns you need (a Parquet file is columnar, the rest are never read) and push the asset filter down.
t = pq.read_table(
"fills-2026-10-19.parquet",
columns=["fill_ts", "price", "size_usd", "crossed"],
filters=[("asset", "=", "BTC")],
)
print(t.num_rows, t.schema.names)pandas
import pandas as pd
df = pd.read_parquet(
"fills-2026-10-19.parquet",
columns=["fill_ts", "price", "size_usd", "crossed"],
filters=[("asset", "==", "BTC")],
)
hourly = df[df["crossed"]].set_index("fill_ts")["size_usd"].resample("1h").sum()Why one asset is cheap to read
Both daily datasets are sorted by asset first and written in row groups of 1,046,544 rows, each with min and max
statistics. A reader skips every row group whose asset range cannot contain what you asked for. A busy fills day has
about a dozen row groups and most assets sit in one or two of them; BTC, the largest, was two of the four row groups of a
quieter day. A positions day has on the order of a hundred row groups, and BTC is about a tenth of the file. Look at
the statistics yourself:
import pyarrow.parquet as pq
f = pq.ParquetFile("fills-2026-10-19.parquet")
col = f.schema_arrow.get_field_index("asset")
for i in range(f.metadata.num_row_groups):
s = f.metadata.row_group(i).column(col).statistics
print(i, f.metadata.row_group(i).num_rows, s.min, s.max)Why 300 seconds, and reading over HTTP
Download the file, then read it locally
The link is checked when a request starts. A download that is running when the five minutes pass is not cut. A
reader that works over HTTP is different: DuckDB's read_parquet('https://…') and pyarrow over fsspec send one request
for the footer and more for every column chunk they need, and each of those is checked on its own. Such a reader works
while the link is valid and gets 403 as soon as it is not, in the middle of a query or on the next one.
So: download the whole file (this page), or, for a single short query, ask for a fresh link immediately before it and let it finish inside the 300 seconds. Never keep a link for a second query.
300 seconds is sized for a download, not for a session: our files run from about 100 MB for a fills day to 1.7 GB for a
positions day, and a reader that opens new requests needs the link for as long as it reads. If you must query remotely,
this works for a query that fits the window:
import os
import duckdb
import requests
KEY = os.environ["VIKE_API_KEY"]
link = requests.get(
"https://data.vike.io/v1/hyperliquid/archive/url?dataset=fills&date=2026-10-19",
headers={"X-API-Key": KEY},
timeout=30,
).json()["url"]
# INSTALL httpfs; LOAD httpfs; (once per DuckDB install)
print(duckdb.sql(f"SELECT count(*), sum(size_usd) FROM read_parquet('{link}') WHERE asset = 'BTC'").fetchall())It repeats the transfer on every query, which a local file does not.
The latest file
dataset=positions&date=latest is the current open positions of every asset in one file of about 30 MB, replaced in
place about every 5 minutes. Use it instead of paging /positions when you want
the whole book.
- Download it whole. A ranged read can fetch the footer of one version and a row group of the next, and fail. The link names a path, not a version, so it serves whatever is on disk when the request starts. If a download fails halfway, start it again.
- It has the 17 columns of the daily
positionsfile, sorted byasset,wallet,direction, in row groups of about 65,000 rows.snapshot_tsis per row: the ingestion cycle that last wrote that position. - A position that closed in the last 15 minutes can still be present with an older
snapshot_ts, the same rule as the API. To keep only the newest cycle, keep the rows at the maximumsnapshot_ts:
import pandas as pd
lat = pd.read_parquet("positions-latest.parquet")
now = lat[lat["snapshot_ts"] == lat["snapshot_ts"].max()]
long_short = now.groupby(["asset", "direction"])["size_usd"].sum().unstack()- How old it is is in
/archive/datasetsunderlatest.positions:snapshot_ts(the newest cycle in the file) andgenerated_at(when the file was written). - There is no
opened_at. The API computes it per page; the file does not carry it.
Counting positions: snapshots and the hourly double count
A positions day is every 5-minute snapshot of every open position, not an hourly or daily figure. Summing
size_usd over a day, or over an hour, counts each position once per snapshot it was in: about 12 times for an hour. A
day of BTC on 2026-05-08, summed naively, read 169 billion USD of open interest against 2.4 billion in any one snapshot.
Pick one snapshot per bucket (the last of the hour is the usual choice), or group by snapshot_ts first:
SET TimeZone = 'UTC';
-- BTC open interest at the last snapshot of each hour, long and short.
WITH s AS (
SELECT snapshot_ts,
date_trunc('hour', snapshot_ts) AS hour,
sum(size_usd) FILTER (WHERE direction = 1) AS long_usd,
sum(size_usd) FILTER (WHERE direction = -1) AS short_usd
FROM read_parquet('positions-2026-10-19.parquet')
WHERE asset = 'BTC'
GROUP BY snapshot_ts
)
SELECT hour,
arg_max(long_usd, snapshot_ts) AS long_usd,
arg_max(short_usd, snapshot_ts) AS short_usd,
count(*) AS snapshots
FROM s
GROUP BY hour
ORDER BY hour;snapshots in that result is how many snapshots the hour holds, usually about 10 to 12, fewer where the source has a gap.
The number of snapshots a whole day holds is in the snapshots field of
/archive/datasets, so you can tell a thin day before you open it.
The same holds for fills: both sides of a trade are in the file, one fill per wallet, and they share a trade_id. A
sum of size_usd over every row counts a trade's notional about twice. The taker side (crossed = true) counts it once,
which is why the queries above filter on it.
What the files do and do not contain
- The archive starts on 2026-05-01 for every dataset. Earlier history is not published.
positionshas no file before 2026-05-08, the first day the 5-minute snapshots exist, and that day is partial (71 snapshots). - Eleven positions days hold fewer than 250 snapshots, because the source captured fewer. A normal day holds about
250 to 290. 2026-05-08: 71. 2026-06-08: 226. 2026-06-26 to 2026-06-29: 124 to 134 each. 2026-06-30: 248.
2026-08-12, 2026-08-13 and 2026-08-17: 236 to 238. 2026-09-28: 248. A day is never padded to fill the gap. The
snapshotsfield of the index says how many a day holds, and a source outage shows up as a long gap between twosnapshot_tsvalues. snapshot_tsis the start of the ingestion cycle, cut to the minute, and the spacing is irregular (usually 5 to 7 minutes). It is not an exact 5-minute grid, and most rows are off a 5-minute mark. The state a row describes is older:state_tsis the instant of the node state that the size, entry, mark, unrealized PnL, leverage and margin columns were read from, typically 5 to 10 minutes beforesnapshot_ts.state_tsof1970-01-01 00:00:00means the row has no state file or was written before 2026-08-22.- A few hours of
positionsare rebuilt, not measured. Where the node left no state file for a span, the source replayed the fills forward from the last real snapshot to fill the missing 5-minute marks. The index lists those hours asreconstructed_hours(UTC hour starts) on the day'spositionsentry. In a listed hour the position sizes matched the next real snapshot within about 97 to 99 %,margin_usedandliquidation_priceare0, andstate_tsis1970-01-01 00:00:00. A span that could not reach that match stays a gap and is not filled. - Fills carry
start_position, the position before the fill, only from 2026-09-29. Earlier files hold NULL. opened_atis not in any file. Hourly files do not exist: the unit is the 5-minute snapshot.- A daily file is written once, when its day closes. The exporter does not rewrite a published day on its own.
See what exists
/archive/datasets lists every published day with its rows and bytes, the
snapshots count of each positions day, the column schema, and the latest file. It costs one request and is the right
first call:
# The newest day with a positions file, its size and its snapshot count.
curl -s -H "X-API-KEY: $VIKE_API_KEY" "https://data.vike.io/v1/hyperliquid/archive/datasets" \
| jq '[.dates[] | select(.streams.positions)][0]'What a client sees when it goes wrong
| What you see | Where | Cause and what to do |
|---|---|---|
400 with a detail naming the published range | the ask | That day has no file: no fills file for 2026-04-30; published: 2026-05-01 to 2026-10-20 (173 days). Pick a day inside the range. date=latest exists for positions only, and dataset must be fills or positions. |
401, an HTML page | the ask | The key is missing or invalid. Send X-API-Key on the ask. |
403 with {"error":"download link expired or invalid", ...} | the download | The link is past its 300 seconds, or was altered. Ask again and use the new link. |
404 | any direct /archive/venue=hyperliquid/... path | There is no direct path, with or without a key. Ask for a link. |
429 | the ask | The hourly budget or the per-minute cap. A download never returns it. See Rate limits. |
Related
/archive/url— the link request and its parameters./archive/datasets— the index, with the columns of both files.- Paging large reads — when you need a slice of history through the API instead of a file.
- Rate limits — what the ask spends and what the download does not.
Count liquidations around the biggest move
Find the largest BTC hourly candle of a day, then total the long and short liquidations that landed inside that hour.
Find a market and check its tape
Search Polymarket markets by slug, take the first hit's condition and token ids, and ask whether that outcome token has a recorded tape.