# How to read the Hyperliquid leaderboard and wallet positions

> Hyperliquid publishes every wallet’s positions, but the leaderboard that names those wallets lives on a separate host and isn’t in the official reference. Here’s where it sits, how to read a wallet with clearinghouseState, and where the pairing runs out of room.

- Source: https://papaproxy.net/blog/hyperliquid-leaderboard-api.php
- Published: 2026-08-07
- Author: Alex Young
- Category: API & data · PapaProxy.net Blog

---

## Key takeaways

- The leaderboard is served from `stats-data.hyperliquid.xyz/Mainnet/leaderboard` as a plain GET and isn’t in the official reference — treat its shape as unstable, cache it hourly, and health-check that host separately from `api.hyperliquid.xyz`.
- `clearinghouseState` returns a wallet’s open positions with no key or signature, at weight 2 — the cheap tier; an empty `assetPositions` means flat, not failed.
- Per-user WebSocket subscriptions cap at 10 unique users per IP — beyond that, either distribute them across addresses or filter the market-wide trades stream (every fill carries a `users` array) and reconcile the affected wallets through `clearinghouseState`.
- `userFills` returns at most 2,000 recent fills (10,000 available via `userFillsByTime`); a full 2,000-row response costs about 120 weight — roughly 60 `clearinghouseState` calls — so page with an overlapping cursor and deduplicate by `tid`.
- There’s no copy-trading endpoint and no dedicated close: you place your own reduce-only order as an aggressive limit with a slippage bound. Vaults are the native alternative — exposure to a leader’s strategy, not mirroring an arbitrary wallet.
- In our August 2026 test, the first 429 appeared at 1,212–1,381 calls per minute, about double the nominal arithmetic — but the rate we sustained, 574/min per datacenter address, sits close to the documented 600. Plan from the docs and treat the headroom as burst tolerance; limits are counted per address rather than per /24, and no response exposes the remaining quota.

## Where the leaderboard actually lives

The main API answers on `POST https://api.hyperliquid.xyz/info`, and the info API documentation covers it thoroughly — every wallet-level query in this article is there. The leaderboard is the exception: it is served from `https://stats-data.hyperliquid.xyz/Mainnet/leaderboard` as a plain GET, and you will not find that path in the reference. Public SDKs treat it as a second base URL alongside the main API rather than as an info request, which is the clearest signal of how it’s built: a different host, a different response shape, no `type` field, no POST body.

Two consequences follow, and both are operational rather than theoretical. First, availability is independent: the two hosts are separate services, so a health check against `api.hyperliquid.xyz` tells you nothing about whether the stats host is answering. Monitor them separately, or your leaderboard job will fail silently while every other call keeps working. Second, the response is heavy and slow compared with an info call. Measuring straight from our own server, the leaderboard’s median response time was roughly 0.7 seconds against about 0.3 seconds for a `clearinghouseState` request — and it returns the full ranking rather than a page.

The response is a ranking, one object per wallet, and it’s worth knowing its shape before you build on it:

JSONCopy code

```json
{
  "ethAddress": "0x...",
  "accountValue": "1234567.89",
  "displayName": "trader name or null",
  "prize": 0,
  "windowPerformances": [
    ["day",     {"pnl": "12345.6", "roi": "0.0123", "vlm": "9876543.2"}],
    ["week",    {"pnl": "...", "roi": "...", "vlm": "..."}],
    ["month",   {"pnl": "...", "roi": "...", "vlm": "..."}],
    ["allTime", {"pnl": "...", "roi": "...", "vlm": "..."}]
  ]
}
```

`ethAddress` is the key you carry into every wallet-level call below. Performance arrives as window/metrics pairs rather than flat fields, so “top by weekly PnL” means selecting the right window first, and every number is a string — consistent across the whole API and a routine source of comparison bugs. `displayName` is optional and frequently null.

That shapes how you should use it. Per Hyperliquid’s API announcements channel, the stats snapshot refreshes on an hourly cadence, so polling it more often normally buys you no fresher data — while the positions behind those wallets change constantly. So fetch the leaderboard rarely, cache it, and build your fast loop around wallet-level calls using the addresses you already extracted:

PythonCopy code

```python
import json, time, requests

STATS = "https://stats-data.hyperliquid.xyz/Mainnet/leaderboard"
CACHE, TTL = "leaderboard.json", 3600          # matches the snapshot's refresh cadence

def leaderboard():
    try:                                        # serve from cache while it's fresh
        with open(CACHE) as f:
            blob = json.load(f)
        if time.time() - blob["fetched"] < TTL:
            return blob["rows"]
    except (FileNotFoundError, json.JSONDecodeError, KeyError):
        pass
    r = requests.get(STATS, timeout=30)          # generous: this host is slower than /info
    r.raise_for_status()
    rows = r.json().get("leaderboardRows", [])
    with open(CACHE, "w") as f:
        json.dump({"fetched": time.time(), "rows": rows}, f)
    return rows

rows = leaderboard()
print(len(rows), "wallets")
print(rows[0]["ethAddress"], rows[0]["accountValue"])
```

What to look at: the timeout is deliberately generous because this host is slower than the main API, and the cache is what keeps a slow, undocumented dependency off your critical path. Validate the fields you depend on rather than assuming them — an endpoint absent from the formal reference carries no compatibility promise, even though its move to this host was announced publicly. What this snippet does not do: it has no fallback if the stats host is down, so production code should keep serving the last good cache and alert instead of failing.

## Reading the state of a single wallet

Once you have addresses, everything else is the documented API. The clearinghouseState request returns a wallet’s perpetuals state: margin summary, withdrawable balance, and the open positions for a user in `assetPositions`. It weighs 2 — the cheap tier, alongside `l2Book` and `allMids` — and needs no key, no signature, and no account. One scoping detail that older guides miss: the request accepts an optional `dex` parameter, and with it omitted you get the first perp DEX. Positions on builder-deployed HIP-3 DEXes have to be queried separately when they matter to you.

PythonCopy code

```python
import requests

INFO = "https://api.hyperliquid.xyz/info"

def wallet_state(address):
    r = requests.post(INFO, json={"type": "clearinghouseState", "user": address}, timeout=10)
    r.raise_for_status()
    return r.json()

state = wallet_state("0x0000000000000000000000000000000000000000")   # user or sub-account address
for item in state["assetPositions"]:
    p = item["position"]
    print(p["coin"], p["szi"], "entry", p["entryPx"], "uPnL", p["unrealizedPnl"])
```

Reading positions by address is that direct: `szi` is signed size, so a negative value is a short; `entryPx` is the average entry; `unrealizedPnl` and `marginUsed` come alongside. For a correctly resolved user or sub-account address, an empty `assetPositions` list means no open positions rather than an error — which matters when you iterate over hundreds of addresses and need to tell “flat” apart from “failed.” One caveat the documentation calls out: don’t point this at an agent or API-wallet address, since that can return an empty result for a wallet that plainly holds positions.

Two limits define what you can build on this call. It’s a snapshot, not a feed — you learn that a position changed only by asking again, so your resolution equals your polling interval. And per-user WebSocket subscriptions stop scaling on a single address: Hyperliquid’s documented limits allow a maximum of **10 unique users across user-specific WebSocket subscriptions** per IP.

That ceiling shapes the architecture rather than closing it. Three patterns work, and they combine:

- **Up to ten wallets per address** — user-specific subscriptions, the lowest-latency option and the right choice for a dashboard.
- **Distribute the subscriptions** — ten unique users per egress address, so the watchlist grows with the number of addresses.
- **Watch the market instead of the wallets** — the market-wide trades subscription carries a `users` array naming the buyer and seller of each fill, so you can filter every trade against your watchlist for change detection and then reconcile only the affected wallets through `clearinghouseState`. Several current wallet trackers are built exactly this way, and it converts a per-wallet subscription problem into a filtering problem.

The third pattern doesn’t remove polling — it retargets it. Instead of asking every wallet on a timer, you ask the wallets that just traded, plus a slow full sweep to catch whatever the stream missed. The question then becomes how fast that reconciliation can run, which is where budgets come in.

## Pulling the fills behind a position

A position tells you where a wallet stands; fills tell you how it got there. The userFills endpoint returns up to 2,000 most recent fills for an address, with `aggregateByTime` available to combine partial fills of one crossing order. For anything time-bounded, userFillsByTime takes `startTime` in milliseconds — required — plus an optional `endTime` that defaults to now, and the documentation notes at most 2,000 fills per response with only the 10,000 most recent available.

The startTime behavior on fills is where a first implementation usually goes wrong, and the trap isn’t window size — it’s cursor handling around truncated responses. `startTime` is inclusive and expressed in milliseconds, and several fills can share the same millisecond, so advancing to `last_time + 1` can silently drop rows that sat behind the last one you saw. The safe pattern is an overlapping cursor: restart from the last timestamp rather than past it, deduplicate on a fill identifier such as `tid` or the transaction hash, and advance only after processing everything the response returned. Mind the horizon too — at most 2,000 fills per response and only the 10,000 most recent overall, so deep history isn’t available here at all, and pretending otherwise produces silently truncated backfills.

Their cost is easy to misjudge in both directions. Both fills endpoints carry weight 20 — the default tier for info requests — plus an **additional weight per 20 items returned**. A full 2,000-fill response therefore costs about 120 weight under the documented formula, roughly the equivalent of 60 `clearinghouseState` calls: expensive enough that fill history belongs in a slow background job, cheap enough that a single such call won’t wreck a minute’s budget on its own.

## Following a wallet in practice

Two honest notes before any architecture, because the phrase “copy trading” promises more than the platform exposes. There is no copy trading API on Hyperliquid in the sense of an endpoint that mirrors another trader’s actions to your account: what exists is public read access to every wallet’s state and a normal exchange API for your own orders. Any “following” system you build is a pipeline you own — read a wallet, decide, then place your own order — and that means the latency, the slippage, and the decision to trade are all yours.

Vaults are the closest native equivalent, and they work differently: a depositor puts funds into a vault and shares proportionally in the profits and losses of the vault leader’s strategy. That gives you exposure to a strategy natively — it does not let you pick an arbitrary leaderboard wallet and mirror its individual trades.

Closing a position with a market order is the same story from the other side. Hyperliquid’s exchange API has no dedicated close endpoint; you close by submitting an order in the opposite direction with `reduce_only` set, and market execution is expressed as an aggressive limit order with a slippage bound rather than a `MARKET` type. The official Python SDK’s `market_close` helper does exactly that under the hood — read it before writing your own.

So the architecture reduces to a polling budget, and the budget is documented: REST requests share an aggregated weight limit of **1,200 per minute per IP address**. With `clearinghouseState` at weight 2, arithmetic gives 600 wallet checks per minute from one address. Before you plan capacity around that number, three software levers matter more than any of them:

- **Cache what doesn’t move.** The leaderboard hourly, `meta` daily. Those are weight-20 calls with no reason to be in a loop.
- **Poll by tier, not uniformly.** Wallets with open positions deserve seconds; flat wallets deserve minutes. Uniform polling spends most of its budget confirming that nothing happened.
- **Back off on the observed signal, not the assumed one.** No response carries a remaining-quota header, so track spend yourself and treat 429 as data — our measurements below show the recovery is far shorter than the “per minute” phrasing implies.

## Our test: how many wallets one address covers

**Hypothesis.** The documented budget describes real behavior, and one egress address is not enough to watch a large wallet set.

**Environment and profile.** PapaProxy.net addresses over SOCKS5 with IP whitelisting, targeting the public `POST /info` on `api.hyperliquid.xyz`; no keys and no signing, with wallet addresses taken from the leaderboard response itself. Datacenter and ISP pools were run as separate series and never merged into a single figure. We raised in-flight requests per address in 150-second stages — each covering a full clock minute — and stopped a ladder at the first HTTP 429. Run dated August 6–7, 2026.

**The first observed 429 point.** Note the wording: a ladder that stops at the first refusal measures where enforcement began, not a confirmed sustained ceiling. Those are different measurements, and this run made the first one.

| Pool | `clearinghouseState` calls/min at first observed 429 | Concurrency | Latency p50 |
| --- | --- | --- | --- |
| Datacenter | 1,212 | 8 | 284 ms |
| ISP | 1,381 | 12 | 332 ms |

**Enforcement started well above the nominal arithmetic.** A 1,200 weight/min budget at weight 2 implies 600 calls; the first refusal came at roughly twice that, and the same pattern appeared on every endpoint where we reached a limit:

| Endpoint | Pool | Calls before 429 | Documented weight | Weight spent per the docs |
| --- | --- | --- | --- | --- |
| `clearinghouseState` | Datacenter | 1,212 | 2 | 2,424 |
| `clearinghouseState` | ISP | 1,381 | 2 | 2,762 |
| `allMids` | ISP | 1,089 | 2 | 2,178 |
| `meta` | ISP | 131 | 20 | 2,620 |

In all four cases, the consumed budget came out 1.8–2.3× above the documented 1,200. The measured behavior broadly preserved the documented weight tiers, but the first-429 point varied materially between runs and endpoints — around 21% between two weight-2 endpoints on the same pool.

**And here is the conclusion that matters more than the headroom.** The rate we settled on for sustained multi-address work — 574 calls per minute per datacenter address — lands close to the documented planning figure of 600. So the documentation remains a sensible capacity-planning baseline, and the extra room before the first refusal is best treated as burst tolerance rather than as capacity you can plan to buy less of.

**Three findings that change how you write the client.** Remaining quota is not exposed: no response carried a rate-limit header, on success or on 429, so maintain a client-side counter from the documented weights — a 429 only tells you that enforcement has already been reached. Recovery was quick in these runs: after a 429, the address answered again within 5.3–5.4 seconds. Treat that as an observation rather than a guaranteed cooldown, and keep backoff plus your own weight accounting in place. And the limit is counted per address, not per subnet — four addresses inside one /24 sustained 2,278 calls/min on datacenter against 2,274 for four addresses in separate /24s (2,052 against 2,044 on ISP), with zero refusals in either group, so there’s nothing to gain from scattering addresses across blocks.

**One anomaly we could not explain.** Converted to documented weight, three of the four ceilings cluster around 2,500 (±12%). But `allMids` from datacenter ran to 1,622 calls per minute — 3,244 weight — without a single refusal, while the same call from ISP hit 429 at 1,089. The gap points the opposite way from the `clearinghouseState` result, so no systematic pool advantage is visible. Plausible explanations, in order: the ceiling isn’t a constant and drifts between runs; the larger `allMids` payload kept us from reaching the required rate before the ladder ended; the limit is counted differently on that endpoint. Our data can’t separate them. The practical lesson outranks the puzzle: **the ceiling isn’t a constant, so don’t plan against the measured maximum — leave margin.**

**The sustained operating point.** This is the number to plan from — the rate we held, not the one that broke:

| Pool | Sustained calls/min per address | Against the documented 600 |
| --- | --- | --- |
| Datacenter | 574 | ~96% |
| ISP | 514 | ~86% |

Aggregate throughput scaled with address count: exactly linear to five addresses, and close to it beyond. One `clearinghouseState` call equals one wallet, so the addresses you need are simply wallets divided by refresh interval:

| Wallets watched | Refresh every 10s | every 30s | every 60s |
| --- | --- | --- | --- |
| 100 | 2 | 1 | 1 |
| 250 | 3 | 1 | 1 |
| 500 | 6 | 2 | 1 |
| 1,000 | 11 | 4 | 2 |
| 5,000 | 53 | 18 | 9 |

Put plainly: one address covers roughly 574 wallets at a 60-second refresh, 287 at 30 seconds, and 95 at 10 seconds. So a couple of hundred wallets at a half-minute refresh needs exactly one address — while five thousand wallets need nine even at a leisurely one-minute cadence, or more than fifty at ten seconds. And if you use the market-wide trade stream for change detection, these numbers describe your reconciliation sweep rather than a per-wallet timer, which is usually a much smaller bill.

Where does that leave the pairing this article started with? Everything up to a few hundred wallets is a software problem — cache the leaderboard, filter the trade stream instead of polling every wallet, tier whatever polling remains, and back off on your own counter. Past that, the constraint is the documented per-IP budget, and capacity is added the same way you’d add workers: more egress addresses, each with its own budget, sized from the table above. That’s the part our [proxies for crypto projects](/crypto-proxy.php) cover — dedicated datacenter and ISP IPv4 addresses, static for the whole plan term, with contiguous blocks carrying no penalty since the limit is counted per address. Check the platform’s current terms of use before scaling a polling fleet, and keep the leaderboard call out of the fast loop regardless of how many addresses you have.
