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 daily or latest 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. To look at the data first, two small
free samples are open with no key and no link.
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.
Free samples
Before you ask for a link, samples_base in /archive/datasets is
https://data.vike.io/archive/hl-samples/, and it needs no key. It holds fills-sample.parquet (every BTC fill of one
UTC hour), positions-sample.parquet (every open BTC position at one snapshot) and a manifest.json that says which
day they are cut from and lists the full schema. They have the columns of the daily files, so everything under Read it
locally works on them. What each file covers is on
/archive/datasets.
curl -f -O https://data.vike.io/archive/hl-samples/positions-sample.parquetRead 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 19 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). - Ten positions days hold fewer than 250 snapshots, because the source wrote fewer. A normal day holds about
250 to 290. 2026-05-08: 71. 2026-06-08: 226. 2026-06-26 to 2026-06-29: 154, 138, 125 and 124 (on those days the source
wrote a snapshot about every 12 minutes instead of every 5, with no long gap). 2026-06-30: 248. 2026-08-12,
2026-08-13 and 2026-08-17: 236 to 245. A day is never padded to look complete: spans the source missed were rebuilt
only as described in the bullet on rebuilt hours below. The
snapshotsfield of the index says how many a day holds. 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.- Some hours of
positionsare rebuilt, not measured. Where the node left no state file for a span, or a snapshot came out with whole wallets missing, the source replayed the fills forward from the last real snapshot and wrote the missing 5-minute marks (or only the missing wallets of a thin snapshot). The index lists those hours asreconstructed_hours(UTC hour starts) on the day'spositionsentry. In a listed hourmargin_usedandliquidation_priceare0,state_tsis1970-01-01 00:00:00, andmark_pricecomes from the last fill of the asset (or a straight line between the real marks before and after), not from the node. The replayed position sizes matched the next real snapshot at 97 % or better, except in the hours that are also listed inreconstructed_lower_match_hours: there the match was 83 to 97 %, so treat those hours as approximate. A span that matched below 83 % was not filled and stays a gap. - 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 with {"error":{"code":"UNAUTHORIZED", ...}} | 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, the per-minute cap, or the allowance of 20 GB of links per user per rolling 24 hours (the message says how much was issued, and an allowance 429 carries no Retry-After: retrying within minutes is refused again). A download never returns it. See Rate limits and /archive/url. |
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.
Parquet archive
Every Hyperliquid fill and every 5-minute position snapshot as one Parquet file per UTC day, plus a latest positions file replaced every 5 minutes.
Archive file index
The index of the Hyperliquid Parquet file archive — every UTC day that has a fills or positions file, with rows, bytes and the snapshot count, the column schema of both datasets, and the entry for the 5-minute latest positions file.