vike
GuidesHyperliquid

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

  1. GET /v1/hyperliquid/archive/url with dataset and date. It costs one credit and one request of the hourly budget, and answers with a url, expires_in (300) and the file's bytes.
  2. GET that url. It needs no API key, names exactly one file, and spends nothing: downloads are recorded as archive_download and never count against the hourly budget.

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 positions file, sorted by asset, wallet, direction, in row groups of about 65,000 rows. snapshot_ts is 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 maximum snapshot_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/datasets under latest.positions: snapshot_ts (the newest cycle in the file) and generated_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. positions has 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 snapshots field of the index says how many a day holds, and a source outage shows up as a long gap between two snapshot_ts values.
  • snapshot_ts is 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_ts is 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 before snapshot_ts. state_ts of 1970-01-01 00:00:00 means the row has no state file or was written before 2026-08-22.
  • A few hours of positions are 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 as reconstructed_hours (UTC hour starts) on the day's positions entry. In a listed hour the position sizes matched the next real snapshot within about 97 to 99 %, margin_used and liquidation_price are 0, and state_ts is 1970-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_at is 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 seeWhereCause and what to do
400 with a detail naming the published rangethe askThat 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 pagethe askThe key is missing or invalid. Send X-API-Key on the ask.
403 with {"error":"download link expired or invalid", ...}the downloadThe link is past its 300 seconds, or was altered. Ask again and use the new link.
404any direct /archive/venue=hyperliquid/... pathThere is no direct path, with or without a key. Ask for a link.
429the askThe hourly budget or the per-minute cap. A download never returns it. See Rate limits.

On this page