> ## Documentation Index
> Fetch the complete documentation index at: https://docs.uniblock.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# 4. Trader Leaderboard

> Rank a watchlist of Hyperliquid traders by account value and live volume using batched portfolio reads and the allFills stream.

A leaderboard ranks traders against each other. The approach here is **watchlist-based**:
you define the set of addresses to track (traders you've discovered from the fill tape,
a curated list, addresses your users follow) and rank them with a single batched read
per refresh, plus live volume from the stream you already have open.

This scales cleanly and costs one request per cycle rather than one per trader.

## 1. Batch the portfolio reads

`batchPortfolioStates` takes a `users` array and returns one result slot per address, in
request order.

```javascript theme={null}
const watchlist = [
  "0xaaa...",
  "0xbbb...",
  // …up to 50
];

const slots = await info({ type: "batchPortfolioStates", users: watchlist });
```

```json theme={null}
[
  {
    "user": "0xaaa...",
    "result": {
      "type": "portfolioState",
      "address": "0xaaa...",
      "chainId": 999,
      "perpetuals": { "marginSummary": { "accountValue": "125043.10", "...": "…" } },
      "spot": { "balances": [] }
    },
    "error": null
  },
  {
    "user": "0xbbb...",
    "result": null,
    "error": "upstream timeout"
  }
]
```

Two properties of that response shape matter:

**Failure is isolated per user.** A slot that failed has `result: null` and a populated
`error`; the rest of the batch still succeeds. One bad address never takes down the
board, but you must handle the null, or your ranking will throw on the first slot that
didn't resolve.

**Slots come back in request order**, with `user` echoed on each, so you can either zip
by index or key by `user`. Keying by `user` is the more robust of the two.

<Warning>
  **The limit is 50 users per request.** Beyond that the request fails; it does not
  silently truncate. Chunk larger watchlists, and remember that each chunk is a separate
  billed request.
</Warning>

```javascript theme={null}
function chunk(arr, size) {
  const out = [];
  for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
  return out;
}

async function fetchWatchlist(addresses) {
  const batches = await Promise.all(
    chunk(addresses, 50).map((users) => info({ type: "batchPortfolioStates", users })),
  );

  return batches.flat().map((slot) => {
    const perps = slot.result?.perpetuals;
    return {
      address: slot.user,
      error: slot.error,
      accountValue: perps ? Number(perps.marginSummary.accountValue) : null,
      positions: (perps?.assetPositions ?? []).map((a) => a.position),
      unrealizedPnl: (perps?.assetPositions ?? []).reduce(
        (sum, a) => sum + Number(a.position.unrealizedPnl),
        0,
      ),
    };
  });
}
```

## 2. Rank

Account value is the headline metric and comes straight from the batch:

```javascript theme={null}
const ranked = rows
  .filter((r) => r.accountValue !== null)
  .sort((a, b) => b.accountValue - a.accountValue)
  .map((r, i) => ({ rank: i + 1, ...r }));
```

Keep the failed rows rather than dropping them silently. Render them unranked with a
"data unavailable" state. A trader vanishing from the board because of one timed-out
request looks like a bug to anyone watching a specific address.

## 3. Keep it live between polls

Refetching the batch every few seconds is the expensive way to make a leaderboard feel
live. The cheap way is to poll slowly and recompute unrealized PnL locally against live
mids in between.

You already have the positions from the batch, and the price store from the
[wallet tracker](/guides/hyperliquid/analytics-dashboard/wallet-tracker#4-keep-mid-prices-live).
That's everything needed:

```javascript theme={null}
function liveAccountValue(row) {
  if (row.accountValue === null) return null;

  // Replace each position's snapshot uPnL with one computed off the live mid.
  const liveUpnl = row.positions.reduce((sum, p) => {
    const mid = prices.get(p.coin);
    if (mid === undefined) return sum + Number(p.unrealizedPnl);
    return sum + Number(p.szi) * (mid - Number(p.entryPx));
  }, 0);

  return row.accountValue - row.unrealizedPnl + liveUpnl;
}
```

A 60-second poll with live uPnL in between gives a board that ticks continuously at
1/30th the request cost of a 2-second poll.

<Tip>
  Pause polling when the tab is hidden. A leaderboard left open in a background tab
  overnight is hundreds of requests nobody looked at.

  ```javascript theme={null}
  document.addEventListener("visibilitychange", () => {
    document.hidden ? stopPolling() : startPolling();
  });
  ```
</Tip>

## 4. Rank by live volume

Account value ranks *wealth*. To rank *activity*, accumulate traded notional per address
from the `allFills` stream, the same subscription already powering prices and the tape.

```javascript theme={null}
const volumeByAddress = new Map();

ws.on("message", (raw) => {
  const msg = JSON.parse(raw.toString());
  if (msg.channel !== "allFills" || !Array.isArray(msg.fills)) return;

  for (const [address, fill] of msg.fills) {
    if (!fill?.coin || !fill.px) continue;
    const notional = Math.abs(Number(fill.px) * Number(fill.sz));
    volumeByAddress.set(address, (volumeByAddress.get(address) ?? 0) + notional);
  }
});
```

Because `allFills` is venue-wide, this observes **every** trader, not just your
watchlist, which makes it a discovery mechanism as much as a ranking one:

```javascript theme={null}
// Rank the watchlist by session volume…
const byVolume = watchlist
  .map((address) => ({ address, volume: volumeByAddress.get(address) ?? 0 }))
  .sort((a, b) => b.volume - a.volume);

// …or surface active traders you aren't tracking yet.
const candidates = [...volumeByAddress.entries()]
  .filter(([address]) => !watchlist.includes(address))
  .sort((a, b) => b[1] - a[1])
  .slice(0, 20);
```

<Note>
  This volume is **session-scoped**: it counts from when your stream connected, and
  resets on reconnect. Label it accordingly ("volume this session", not "24h volume"). If
  you need a persistent figure, accumulate into a datastore server-side with one
  long-lived connection shared across all users, rather than one per browser tab.
</Note>

## 5. Assemble the surface

```javascript theme={null}
async function refreshLeaderboard(watchlist) {
  const rows = (await fetchWatchlist(watchlist)).map((row) => ({
    ...row,
    liveValue: liveAccountValue(row),
    sessionVolume: volumeByAddress.get(row.address) ?? 0,
    openPositions: row.positions.length,
  }));

  // Rank only the rows that have a value to rank. A slot that failed upstream is
  // not "last". It is unknown, and numbering it as last states something false.
  const ranked = rows
    .filter((r) => r.liveValue !== null)
    .sort((a, b) => b.liveValue - a.liveValue)
    .map((row, i) => ({ ...row, rank: i + 1 }));

  const unavailable = rows
    .filter((r) => r.liveValue === null)
    .map((row) => ({ ...row, rank: null }));

  // Unranked rows go after the ranked ones, still visible, still identifiable.
  return [...ranked, ...unavailable];
}

startPolling(() => refreshLeaderboard(watchlist), 60_000);
```

Sensible columns: rank, address, account value (live), unrealized PnL, open position
count, and session volume, with the address linking through to the
[wallet tracker](/guides/hyperliquid/analytics-dashboard/wallet-tracker) and
[PnL](/guides/hyperliquid/analytics-dashboard/pnl) surfaces for a full history on
whoever's at the top.

Render a `rank: null` row with a dash and a "data unavailable" state rather than a
number. A trader whose batch slot timed out on this cycle will be ranked normally on the
next one, and showing them in last place in the meantime reads as a real ranking change to
anyone watching that address.

## Cost summary

Putting the whole dashboard together, the steady-state cost of a running instance is
small and, importantly, **flat in the number of traders you display**:

| Source                                   | Cost                                   |
| ---------------------------------------- | -------------------------------------- |
| `batchPortfolioStates`, 50 traders @ 60s | 50 lookups/min                         |
| `allFills` subscription                  | 1/min, powers prices, tape, and volume |
| `l2Book`, one coin                       | 0.5/min                                |
| `webData2` + `meta` @ 60s                | 2 requests/min                         |

The single `allFills` subscription doing triple duty is the load-bearing design decision
across this series. Adding traders to the watchlist grows only the batch; adding surfaces
to the dashboard costs nothing extra at all.

## Where to go next

* [Info API overview](/guides/hyperliquid/info-api/overview) lists every supported
  Info type.
* [Rate limits, caching & polling](/guides/hyperliquid/info-api/limits) covers poll
  intervals and backoff.
* [Hyperliquid WebSockets](/guides/hyperliquid/websockets/overview) documents every
  available channel and its payload.
