> ## 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.

# 3. Market Explorer

> Build a venue-wide Hyperliquid view: per-asset open interest and funding, a live L2 order book, and a real-time trade tape.

The market explorer switches from one address to the whole venue: every perp asset with
its mark price, open interest, funding rate and 24-hour change; a live order book for a
selected coin; and a streaming tape of every fill on the exchange.

## 1. Build the market table

The asset universe and its market context come from two types that must be read together:

* **`meta`** gives the static universe: asset names, size decimals, max leverage.
* **`webData2`** gives the live context per asset: mark price, open interest, funding,
  24-hour volume.

```javascript theme={null}
const [meta, webData] = await Promise.all([
  info({ type: "meta" }),
  // webData2 is user-scoped, but its assetCtxs slice is global market context.
  // Any valid address works; the account sections are simply ignored.
  info(
    { type: "webData2", user: "0x0000000000000000000000000000000000000000" },
    { provider: "goldrush" },
  ),
]);
```

<Note>
  Pin `webData2` to GoldRush for this read. `webData2` is served by several providers,
  but they don't all populate the `assetCtxs` slice. Leave the request unpinned and the
  waterfall may hand you a response whose `assetCtxs` is an empty array, which renders
  every price, open interest, and funding cell in your table as null. Pinning removes
  the ambiguity. The `meta` read alongside it needs no pin: every provider serves it.
</Note>

### The positional join

Here's the part that's easy to get wrong. `webData2.assetCtxs` is a bare array with **no
coin field on its entries**. It's joined to `meta.universe` **by index**, so that
`assetCtxs[i]` is the context for `universe[i]`.

```json theme={null}
{
  "universe": [
    { "name": "BTC", "szDecimals": 5, "maxLeverage": 50 },
    { "name": "ETH", "szDecimals": 4, "maxLeverage": 50 }
  ],
  "assetCtxs": [
    { "markPx": "62150.0", "openInterest": "1284.5", "funding": "0.0000125", "prevDayPx": "61200.0", "dayNtlVlm": "482000000.0", "midPx": "62149.5", "oraclePx": "62148.0", "dayBaseVlm": "7820.4", "premium": "0.00003", "impactPxs": ["62149.0", "62150.0"] }
  ]
}
```

Two consequences follow, and both are load-bearing:

**Index alignment is the join key**, so you must not filter the universe before indexing
into `assetCtxs`. Filtering delisted assets first shifts every subsequent index by one
and silently mislabels the entire rest of the table, showing BTC's open interest
against ETH. Filter *inside* the loop, after you've read `ctxs[i]`.

**The arrays can be different lengths.** `assetCtxs` is not guaranteed to be as long as
`universe`, so guard every access.

```javascript theme={null}
function joinMarketRows(universe, ctxs) {
  const rows = [];

  for (let i = 0; i < universe.length; i++) {
    const asset = universe[i];
    // Skip delisted assets HERE, after indexing, so alignment is preserved.
    if (!asset || asset.isDelisted) continue;

    const ctx = ctxs[i];
    const markPx = ctx ? Number(ctx.markPx) : null;
    const prevDayPx = ctx ? Number(ctx.prevDayPx) : 0;

    rows.push({
      coin: asset.name,
      szDecimals: asset.szDecimals,
      maxLeverage: asset.maxLeverage,
      markPx,
      midPx: ctx?.midPx != null ? Number(ctx.midPx) : null,
      // openInterest is in base units, so multiply by mark for USD notional.
      oiNotional: ctx && markPx !== null ? Number(ctx.openInterest) * markPx : null,
      funding: ctx ? Number(ctx.funding) : null,
      dayNtlVlm: ctx ? Number(ctx.dayNtlVlm) : null,
      change24h:
        ctx && markPx !== null && prevDayPx > 0 ? (markPx - prevDayPx) / prevDayPx : null,
    });
  }

  return rows;
}
```

A few field notes:

* **`openInterest` is denominated in the base asset**, not USD. Multiply by `markPx` to
  get the notional figure people expect to see in a market table.
* **`funding` is the current hourly rate** as a decimal, so `0.0000125` is `0.00125%` per
  hour. Multiply by `24 * 365` for the annualized figure if you display APR.
* **`szDecimals`** is the asset's size precision. Use it to format sizes correctly rather
  than picking a fixed decimal count for the whole table.

## 2. Overlay live mids

The `markPx` values in `assetCtxs` are a snapshot. Rather than re-polling `webData2` on a
tight loop, reuse the live price store from the
[wallet tracker](/guides/hyperliquid/analytics-dashboard/wallet-tracker#4-keep-mid-prices-live).
The `allFills` stream is already giving you a last-trade price per coin, and it's the
same one subscription regardless of how many assets you display.

```javascript theme={null}
const displayRows = rows.map((row) => ({
  ...row,
  // Live tick wins; fall back to the snapshot mid, then the snapshot mark.
  price: prices.get(row.coin) ?? row.midPx ?? row.markPx,
}));
```

Refresh `webData2` on a slow interval of 30 to 60 seconds for the fields the tape can't
give you (open interest, funding, 24-hour volume), and let the stream carry price.

## 3. Add a live order book

`l2Book` is available over both REST and WebSocket. For a live book, subscribe:
a polling loop costs a request per tick and still lags.

```javascript theme={null}
ws.send(
  JSON.stringify({ method: "subscribe", subscription: { type: "l2Book", coin: "BTC" } }),
);
```

The `coin` field accepts a single asset (`"BTC"`) or an array (`["BTC", "ETH"]`).
Omitting it streams every asset, at **50 credits per minute** rather than 0.5 per coin.
Only take the wildcard if you genuinely need all 200-plus books.

```json theme={null}
{
  "channel": "l2Book",
  "data": {
    "coin": "BTC",
    "time": 1753660812345,
    "levels": [
      [{ "px": "62149.0", "sz": "1.24", "n": 5 }],
      [{ "px": "62151.0", "sz": "0.87", "n": 3 }]
    ]
  }
}
```

`levels` is a two-element array, **`[bids, asks]`**, each sorted best-first. So
`levels[0][0]` is the highest bid and `levels[1][0]` is the lowest ask. Each level
carries `px` (price), `sz` (total size at that price) and `n` (number of orders).

Cumulative depth, for a depth chart or a ladder:

```javascript theme={null}
function cumulativeDepth(levels) {
  let cum = 0;
  return levels.map((l) => {
    const sz = Number(l.sz);
    cum += sz;
    return { px: Number(l.px), sz, n: l.n, cum };
  });
}

const [bids, asks] = book.levels;
const bidDepth = cumulativeDepth(bids);
const askDepth = cumulativeDepth(asks);

const spread = Number(asks[0].px) - Number(bids[0].px);
const midPrice = (Number(asks[0].px) + Number(bids[0].px)) / 2;
```

<Tip>
  If you're rendering a book for one coin at a time, unsubscribe from the previous coin
  when the user switches. Subscriptions are billed per minute and matched for unsubscribe
  by exact body, so leaving old ones open is a slow, silent cost leak.
</Tip>

<Note>
  For a high-frequency book you can subscribe to `l2BookDiff` instead: a full snapshot
  first, then per-block changed levels only, at a fifth the cost. You maintain the book
  locally, replacing each level and deleting it when `sz` is `"0"`. See the
  [WebSocket guide](/guides/quickstart-guides/hyperliquid-websocket-guide) for the
  snapshot/diff handling.
</Note>

## 4. Add the trade tape

You already have the subscription (`allFills`, opened for the price store), so the tape
costs nothing extra. Same `[address, fill]` tuple envelope as `userFills`:

```javascript theme={null}
const MAX_TAPE_ROWS = 200;
const tape = [];

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;
    tape.unshift({
      address,
      coin: fill.coin,
      side: fill.side === "B" ? "buy" : "sell",
      px: Number(fill.px),
      sz: Number(fill.sz),
      notional: Math.abs(Number(fill.px) * Number(fill.sz)),
      time: fill.time,
    });
  }

  tape.length = Math.min(tape.length, MAX_TAPE_ROWS);
});
```

The `address` half of each tuple is what makes this more than a price ticker: it links
each fill to a trader, so a "largest fills" view doubles as trader discovery. Click
through to the [wallet tracker](/guides/hyperliquid/analytics-dashboard/wallet-tracker)
for whoever just moved size.

```javascript theme={null}
// Whale tape: only fills above a notional threshold.
const whaleFills = tape.filter((f) => f.notional >= 100_000);
```

<Warning>
  `allFills` carries the entire venue, with thousands of events per minute in active
  markets. Cap your buffer (as above), and never re-render per message. Accumulate
  into an array and flush to the UI on a timer.
</Warning>

## Next

[Leaderboard](/guides/hyperliquid/analytics-dashboard/leaderboard) ranks traders using
batch reads plus the same `allFills` stream.
