Skip to content
New guideAgent Cookbook 12 copy-ready prompts for AI coding agents.See recipes
All blog posts
Hyperliquid vault positions: read every vault in one request

Hyperliquid vault positions: read every vault in one request

By Elias Faltin11min read

You want to show where Hyperliquid's vault money sits: which coins vaults hold long, which they hold short, and what changed in the last minute. No single request on Hyperliquid's API answers that. Its vault list returns an empty array, and it returns positions for one account per request.

Reading every open vault that way takes 493 requests. Each weighs 2, so one snapshot uses 986 of the 1,200 weight per minute that Hyperliquid's public API allows per IP address. A second snapshot in the same minute goes over the limit.

Dwellir's Info endpoint reads all 493 vault accounts in one batchClearinghouseStates request. This guide builds that snapshot, adds up exposure by coin, and keeps it current with one gRPC fill stream.

What you build

We ran every step on 5 November 2026, between 15:36 and 15:56 UTC. The client was in Sweden and connected through Dwellir's Tokyo gateway. Counts change with the market.

StepSourceResult
Find the vaultsHyperliquid's stats file9,478 entries, 493 open vault accounts to read
Read every positionDwellir batchClearinghouseStates4,185 positions in one request, oldest state 1.9 s
Find spot and HIP-3 holdingsDwellir batchPortfolioStateNone in the 18 largest vaults or HLP's 7 children
Follow fills liveDwellir gRPC StreamFills with a users filter311 fills from 18 vaults in 60 s, one stream

A vault pools depositor funds under one trading strategy. Each vault is a normal Hyperliquid account, so account methods work on its address.

Find the vaults

The Info API's vaultSummaries request returned [] on both the public and Dwellir endpoints. Hyperliquid publishes the vault list in a stats file instead: GET https://stats-data.hyperliquid.xyz/Mainnet/vaults.

Each entry has a summary with the vault's address, name, leader, total value locked (TVL), closed state and parent relationship. Our copy at 15:36 UTC was 14,195,910 bytes, last modified at 14:54:43 UTC. Sending its ETag back through If-None-Match returned HTTP 304 with no body, so a refresh costs nothing until the file changes.

The file comes from a separate host. If it fails or returns an empty list, keep your last good copy.

Count HLP once

Hyperliquidity Provider (HLP) is a protocol vault with 7 child vaults. HLP's TVL already includes the capital in its children, and the stats file lists the parent and every child.

In an earlier snapshot on 5 November, at 00:21 UTC, summing every entry gave $384.28 million. The children contributed $139.61 million of that sum. Skipping entries whose summary.relationship.type is child gave $244.67 million.

HLP's total already includes its 7 child vaults, so skipping them gives $244.67M of vault TVL instead of $384.28M.

Summing every vault entry in the stats file gave $384.28M. HLP's parent total already includes its 7 child vaults, which hold $139.61M. Skipping entries whose relationship type is child gives $244.67M, with each dollar counted once. Keep the parent for aggregate TVL, and read the child accounts too when you need positions.

For positions, do the opposite: read the children. At 15:36 UTC HLP's own account held no positions. Its children HLP Strategy A and HLP Strategy B held 177 each.

leadingVaults lists the vaults one address leads. It needs user; omitting it returned HTTP 422. Passing HLP's address to Dwellir's endpoint returned its 7 children:

Bash
curl --fail-with-body --max-time 30 \
  "https://api-hyperliquid-mainnet-info.n.dwellir.com/${DWELLIR_API_KEY}/info" \
  -H 'content-type: application/json' \
  -d '{"type":"leadingVaults","user":"0xdfc24b077bc1425ad1dea75bcb6f8158e10df303"}'

Read every vault's positions in one request

batchClearinghouseStates takes a list of addresses in users. It returns successful_states, a list of [address, clearinghouseState] pairs, and failed_wallets. Each state is the same object clearinghouseState returns for one account, including its positions and the time it describes.

The script below reads the stats file, keeps open vaults with at least $1,000 of TVL plus HLP's children, and reads them all in one request. It then adds up exposure by coin and compares the positions with the previous run.

Install Python 3 and requests. Get a Dwellir API key, set DWELLIR_API_KEY, save the script as vault_exposure.py and run python vault_exposure.py in its own directory. It saves the vault list and the last snapshot there.

Python
import json
import os
import re
import time
from collections import defaultdict
from decimal import Decimal, InvalidOperation
from pathlib import Path

import requests

STATS_URL = "https://stats-data.hyperliquid.xyz/Mainnet/vaults"
DWELLIR_INFO = (
    "https://api-hyperliquid-mainnet-info.n.dwellir.com/"
    f"{os.environ['DWELLIR_API_KEY']}/info"
)
MIN_TVL = Decimal("1000")
CHUNK = 500  # Our batch size, not a documented maximum.
MAX_AGE_MS = 30_000


def load_vault_list():
    cache, etag = Path("vaults.json"), Path("vaults.etag")
    headers = {"If-None-Match": etag.read_text()} if cache.exists() and etag.exists() else {}
    response = requests.get(STATS_URL, headers=headers, timeout=60)
    if response.status_code == 304:
        return json.loads(cache.read_text())
    response.raise_for_status()
    data = response.json()
    if not isinstance(data, list) or not data:
        raise ValueError("Missing vault list; keep the last good copy")
    cache.write_bytes(response.content)
    etag.write_text(response.headers["ETag"])
    return data


def pick_vaults(vaults):
    picked = []
    for entry in vaults:
        try:
            s = entry["summary"]
            kind = s["relationship"]["type"]
            tvl = Decimal(s["tvl"])
            valid = re.fullmatch(r"0x[0-9a-fA-F]{40}", s["vaultAddress"]) is not None
        except (KeyError, TypeError, InvalidOperation):
            raise ValueError("Invalid vault summary") from None
        if not valid:
            raise ValueError("Invalid vault address")
        # Children hold HLP's positions; their TVL is already in the parent.
        if not s["isClosed"] and (kind == "child" or tvl >= MIN_TVL):
            picked.append(s)
    return picked


def read_positions(addresses):
    states = {}
    for i in range(0, len(addresses), CHUNK):
        chunk = addresses[i:i + CHUNK]
        response = requests.post(
            DWELLIR_INFO,
            json={"type": "batchClearinghouseStates", "users": chunk},
            timeout=60,
        )
        response.raise_for_status()
        batch = response.json()
        got = {a.lower(): s for a, s in batch["successful_states"]}
        if batch["failed_wallets"] or set(got) != {a.lower() for a in chunk}:
            raise RuntimeError("Incomplete batch; do not report missing vaults as flat")
        oldest = min(s["time"] for s in got.values())
        if time.time() * 1000 - oldest > MAX_AGE_MS:
            raise RuntimeError("Stale state; check your endpoint region before trusting it")
        states.update(got)
    return states


def main():
    vaults = pick_vaults(load_vault_list())
    states = read_positions([s["vaultAddress"] for s in vaults])
    tvl = sum(Decimal(s["tvl"]) for s in vaults if s["relationship"]["type"] != "child")
    net, gross, holders = defaultdict(Decimal), defaultdict(Decimal), defaultdict(int)
    snapshot = {}
    for address, state in states.items():
        for item in state["assetPositions"]:
            p = item["position"]
            value = Decimal(p["positionValue"])
            side = 1 if Decimal(p["szi"]) > 0 else -1
            net[p["coin"]] += side * value
            gross[p["coin"]] += value
            holders[p["coin"]] += 1
            snapshot[f"{address}:{p['coin']}"] = p["szi"]
    previous_file = Path("positions.json")
    previous = json.loads(previous_file.read_text()) if previous_file.exists() else None
    previous_file.write_text(json.dumps({"vaults": list(states), "positions": snapshot}))

    age = time.time() - min(s["time"] for s in states.values()) / 1000
    print(f"{len(vaults)} vaults, ${tvl:,.0f} TVL, {len(snapshot):,} positions, "
          f"oldest state {age:.1f} s")
    print(f"{'coin':<6} {'net USD':>14} {'gross USD':>14} {'vaults':>6}")
    for coin in sorted(gross, key=gross.get, reverse=True)[:8]:
        print(f"{coin:<6} {net[coin]:>14,.0f} {gross[coin]:>14,.0f} {holders[coin]:>6}")
    if previous is not None:
        # Compare only vaults in both runs; a vault leaving the list is not a close.
        both = set(previous["vaults"]) & set(states)
        now = {k: v for k, v in snapshot.items() if k.split(":")[0] in both}
        before = {k: v for k, v in previous["positions"].items() if k.split(":")[0] in both}
        opened, closed = now.keys() - before.keys(), before.keys() - now.keys()
        flipped = [k for k in now.keys() & before.keys()
                   if (Decimal(now[k]) > 0) != (Decimal(before[k]) > 0)]
        resized = [k for k in now.keys() & before.keys()
                   if now[k] != before[k] and k not in flipped]
        print(f"since last run: {len(opened)} opened, {len(closed)} closed, "
              f"{len(flipped)} flipped, {len(resized)} resized")


if __name__ == "__main__":
    main()

The first run, at 15:55:43 UTC, printed the snapshot. The second, 16 seconds later, also compared it with the first:

Text
493 vaults, $238,141,184 TVL, 4,185 positions, oldest state 1.9 s
coin          net USD      gross USD vaults
HYPE       36,669,488     37,027,279    133
BTC        31,010,934     34,454,561    198
ETH         9,862,496     14,283,968    133
LINK        7,639,673      7,886,930     56
XMR         5,101,926      5,166,369     45
SOL         1,564,504      4,666,100    105
AAVE        4,529,414      4,590,784     54
BNB           884,344      1,964,940     63
since last run: 1 opened, 1 closed, 1 flipped, 59 resized

The whole script, including the stats file check, took 3.2 seconds.

Vaults held $37.0 million of HYPE positions, and $36.7 million of that was net long. SOL shows the other pattern: $4.7 million gross but only $1.6 million net, because some of the 105 vaults holding SOL were short.

Three checks keep the numbers honest:

  • No silent gaps. A wallet in failed_wallets, or one missing from the response, stops the run. A vault you could not read is not a flat vault.
  • Fresh state only. Each state carries the time it describes. The script stops when the oldest is more than 30 seconds old. Our oldest was 1.9 seconds.
  • Same vaults only. The comparison skips vaults that joined or left the list between runs, so a vault leaving the list never counts as a close.

The 500-address chunk is our choice, not a documented maximum. A 496-address request at 15:36 UTC returned 1.7 MB.

Spot balances and HIP-3 markets

batchClearinghouseStates reads Hyperliquid's native perpetual market. Builder-deployed (HIP-3) markets and spot balances need batchPortfolioState with "dex": "ALL_DEXES". It returns every DEX's perpetual state, the spot balances and the account mode for each address.

At 15:36 UTC none of the 18 largest vaults or HLP's 7 children held a HIP-3 position or a spot balance. The native read covered all of their exposure.

That request costs more. Each address counts as N + 2 credits, where N is the number of DEX keys returned, plus 1 for DEX discovery. With 10 keys, a vault costs 12 credits instead of 1. Run it once a day to catch a vault that starts trading HIP-3 markets, and use the native batch for every refresh.

Compare the returned keys with perpDexs. Ours had 10 keys while perpDexs listed 11 DEXes, including the native one, and the documentation notes that the DEX list can be truncated without an error.

Follow vault fills between snapshots

A snapshot shows positions at one moment. To see each trade as it happens, open one gRPC stream for every vault. Dwellir's StreamFills accepts a users filter, and its values combine with OR. One stream carries the fills of all 493 accounts.

These commands write the request from the vault list the script saved, then open the stream. Install grpcurl and run them in the script's directory:

Bash
curl -fsSLo v3.proto https://www.dwellir.com/docs/hyperliquid/grpc/v3.proto

python -c 'import json; vaults = json.load(open("positions.json"))["vaults"]; json.dump({"filter": {"fields": [{"field": "users", "values": vaults}]}, "progress": {"interval_ms": 15000}}, open("stream_request.json", "w"))'

grpcurl -proto v3.proto -H "x-api-key: ${DWELLIR_API_KEY}" -d @ \
  api-hyperliquid-mainnet-grpc.n.dwellir.com:443 \
  hyperliquid_l1_gateway.v3.HyperliquidL1Gateway/StreamFills < stream_request.json

Each message carries a block_number and a base64 data field. The decoded data has an events list of [address, fill] pairs. A fill has coin, side, sz, startPosition, dir and closedPnl.

startPosition is the position before the fill. A buy (side B) adds sz to it and a sell (A) subtracts sz. That gives the vault's new position in the coin without another request.

In 60 seconds from 15:54:15 UTC, a stream for 496 vault accounts sent 171 messages with 311 fills from 18 vaults. The median message arrived 327 ms after its block time at our client in Sweden. The Tokyo latency benchmark measures the feed next to the validators.

The stream sends nothing for blocks without a vault fill. The progress setting asks for a progress frame after 15 seconds of silence. It keeps proxies from closing a quiet stream and advances the resume cursor. After 60 seconds without a matching fill, the server still closes the stream with UNAVAILABLE and stream idle.

Save the last block_number, or the scanned_through cursor from a progress frame. Reconnect from it with position.block_number. Resume is inclusive, so skip the block you already applied. If the cursor is older than the retained history, the server returns NOT_FOUND: take a new snapshot and open the stream again.

Budget

ApproachRequests per snapshotCost
Public API, one clearinghouseState per vault493986 of 1,200 weight per minute per IP
Dwellir batchClearinghouseStates1493 API credits
Dwellir StreamFills for all vaults1 open stream1 credit per message; 171 messages in our minute

Dwellir bills batchClearinghouseStates per distinct valid address, including addresses in failed_wallets. A snapshot every minute costs 493 × 1,440 = 709,920 credits a day. A snapshot every 5 minutes with the stream between snapshots costs 141,984 credits a day for snapshots, plus one credit per stream message.

The gRPC stream is an add-on: $299 a month on an API plan, plus 1 credit per message. A Dedicated Node Cluster includes it without usage charges. See Hyperliquid pricing for both.

Host the script near the data. The Tokyo benchmark explains why Hyperliquid clients run best in Tokyo. The copy trading guide turns the same fill stream into trades.

Get a Dwellir API key to read every vault in one request, or contact the Dwellir team about gRPC access.

Start free.1 response = 1 credit.

100,000 responses a day at 20 per second, on 170+ networks. Upgrade when you need more.