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.
| Step | Source | Result |
|---|---|---|
| Find the vaults | Hyperliquid's stats file | 9,478 entries, 493 open vault accounts to read |
| Read every position | Dwellir batchClearinghouseStates | 4,185 positions in one request, oldest state 1.9 s |
| Find spot and HIP-3 holdings | Dwellir batchPortfolioState | None in the 18 largest vaults or HLP's 7 children |
| Follow fills live | Dwellir gRPC StreamFills with a users filter | 311 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.

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:
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.
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:
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 resizedThe 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
timeit 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:
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.jsonEach 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
| Approach | Requests per snapshot | Cost |
|---|---|---|
Public API, one clearinghouseState per vault | 493 | 986 of 1,200 weight per minute per IP |
Dwellir batchClearinghouseStates | 1 | 493 API credits |
Dwellir StreamFills for all vaults | 1 open stream | 1 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.




