vike

Example 2 of 65

Hyperliquid liquidations: history by coin in Python

Find the biggest BTC hourly move of one day, then total the long and short Hyperliquid liquidations inside that hour.

An event study in two calls: /ohlcv gives the 24 hourly BTC candles of 2026-08-17, and /liquidations gives the forced closes inside the hour of the largest move.

Run
export VIKE_API_KEY=...
python hl-liquidations-around-move.py             # real run: needs the key
python hl-liquidations-around-move.py --dry-run   # prints the requests it would make, calls nothing

It prints the hour, its move, and the count and dollar size of long and short liquidations.

hl-liquidations-around-move.py
"""Find the biggest BTC hourly move of one day, then total the liquidations that landed in that hour.

Run:  VIKE_API_KEY=... python hl-liquidations-around-move.py    (--dry-run lists the requests and makes none)
"""
import os
import sys
from datetime import datetime, timezone
from urllib.parse import urlencode

NAME = "hl-liquidations-around-move"
BASE = "https://data.vike.io"
DRY = "--dry-run" in sys.argv


def fail(why):
    sys.exit(f"{NAME}: {why}")


def call(path, method="GET", **params):
    url = BASE + path + ("?" + urlencode(params) if params else "")
    if DRY:
        print(method, url)
        return None
    key = os.environ.get("VIKE_API_KEY") or fail("set VIKE_API_KEY")
    import requests

    r = requests.request(method, url, headers={"X-API-Key": key}, timeout=60)
    if r.status_code != 200:
        fail(f"{method} {path} answered HTTP {r.status_code}: {r.text[:200]}")
    return r.json()


def iso(ms):
    return datetime.fromtimestamp(ms / 1000, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def main():
    # end is exclusive on /ohlcv: this is the 24 hourly bars of 2026-08-17.
    day = call("/v1/hyperliquid/ohlcv", symbol="BTC", interval="1h", limit=24,
               start="2026-08-17T00:00:00Z", end="2026-08-18T00:00:00Z")
    move_ts = 1786924800000  # --dry-run has no response to read
    if not DRY:
        if not day["candles"]:
            fail("no BTC candles for the day")
        biggest = max(day["candles"], key=lambda c: abs(c["close"] / c["open"] - 1))
        move_ts = biggest["ts"]
        print(f"biggest hour: {iso(move_ts)}  {biggest['close'] / biggest['open'] - 1:+.2%}  ({biggest['open']:g} -> {biggest['close']:g})")

    # /liquidations is newest-first, its bounds are whole seconds and its end is inclusive; 5000 is the most
    # one page holds. The hour's own end is passed, and a fill stamped exactly on it is dropped below.
    hour_end = move_ts + 3_600_000
    liqs = call("/v1/hyperliquid/liquidations", symbol="BTC", limit=5000, start=iso(move_ts), end=iso(hour_end))
    if DRY:
        return
    rows = [r for r in liqs["liquidations"] if datetime.fromisoformat(r["liq_ts"]).timestamp() * 1000 < hour_end]
    if not rows:
        fail("no BTC liquidations in the move's hour")
    if liqs["nextCursor"]:
        print("  (a full page: the totals below cover the newest 5000 rows of the hour only)")
    # `side` is the side of the position that was closed: "long" means longs were liquidated.
    for side in ("long", "short"):
        hit = [r for r in rows if r["side"] == side]
        print(f"  {side:5} liquidated: {len(hit):5} fills  ${sum(r['size_usd'] for r in hit):,.0f}")


if __name__ == "__main__":
    try:
        main()
    except Exception as e:  # a shape the example does not expect fails by name, not by traceback
        fail(f"{type(e).__name__}: {e}")

Details that decide whether the totals are right

  • side is the position that was closed. long means longs were liquidated, which happens when price falls.
  • The bounds are whole seconds and end is inclusive. The script asks for the hour's own end and drops a fill stamped exactly on it, so an hour does not count its neighbour's first instant.
  • Newest first, 5000 rows at most. A busy hour can fill a page. The script says so when nextCursor is set; to total the whole hour, follow the cursor as shown in Paging large reads.
  • Candles are fills-derived and end is exclusive. With start and end both given, limit=24 returns the oldest 24 bars of the window, which is the whole day here.

Cohort labels on old rows are not point-in-time

Each liquidation carries a pnl_cohort, with pnl_cohort_basis saying whether it is the label at that hour or today's. This example does not read it. If you add a split by cohort, check the basis first: rows before 2026-04-07 always carry a current label.

On this page