Hyperliquid wallet tracker: positions, balances, live fills
Build a Hyperliquid wallet tracker that shows perp positions, spot balances, open orders, live unrealized PnL, and a real-time fill feed for any address.
The wallet tracker answers what does this address hold right now?: open perp
positions with entry, mark, and liquidation prices, spot balances, resting orders, and a
live fill feed that ticks as the address trades.This page introduces three patterns the rest of the series reuses: reading account state,
maintaining a live mid-price store, and subscribing to a user-scoped WebSocket channel.
All examples use the info() helper and the key-holding proxy from the
series overview.
You have two options, and the choice is about how much normalization you want.portfolioState is a Uniblock synthetic type: one call returns perps, spot, and
account-abstraction mode composed into a single object, with each section isolated so a
failure in one doesn’t take down the others.
Note that perpetuals and spot are nullable. A section that failed upstream comes
back null rather than throwing, so guard before you read into it.clearinghouseState and spotClearinghouseState are the native Hyperliquid
reads, if you’d rather compose them yourself:
marginSummary.accountValue is perps margin only: it does not include vault
equity. A wallet with a large vault stake can show a small accountValue here while
actually holding far more. Rendering this field alone as “Account Value” or “Total
Value” understates such wallets, sometimes by orders of magnitude.Join it with userVaultEquities (one extra Info call per wallet), which returns the
wallet’s vault deposits as vault-address/equity pairs. Fetch the two independently so a
failure in one doesn’t take down the other, because Promise.all rejects the whole pair the
moment either call throws, which is exactly the failure case you’re trying to degrade
gracefully from:
const [perps, vaultEquities] = await Promise.all([ info({ type: "clearinghouseState", user: address }).catch(() => null), info({ type: "userVaultEquities", user: address }).catch(() => null),]);function toFiniteNumber(value) { if (value === null || value === undefined) return null; const trimmed = String(value).trim(); if (trimmed === "") return null; // Number(" ") is 0, so whitespace is blank too const parsed = Number(trimmed); return Number.isFinite(parsed) ? parsed : null;}// Blank or unparseable accountValue is unknown, not $0. Number("") is 0, and// rendering that as a real figure is the same species of lie as the missing vault leg.const perpsMargin = perps ? toFiniteNumber(perps.marginSummary.accountValue) : null;let unweighed = 0;const vaultEquity = vaultEquities === null ? null // the vault call itself failed; don't fold that into a silent 0 : vaultEquities.reduce((sum, v) => { const equity = toFiniteNumber(v.equity); if (equity === null) unweighed++; // counted, not silently dropped; see below return sum + (equity ?? 0); }, 0);const knownValue = perpsMargin === null || vaultEquity === null ? null : perpsMargin + vaultEquity;
Don’t call this a total, and don’t stop at two of three buckets. Perps margin +
vault equity still leaves out spot balances, and spotClearinghouseState gives you a cost
basis (entryNtl), not a mark price, so valuing spot needs spotMeta and spot mids on
top. Naming the perps+vault sum “Total Value” just moves the same understatement to a
different set of wallets (spot-heavy ones instead of vault-heavy ones). Call the field
something honest about its scope, such as knownValue or “Perps + Vaults”, until spot is
actually priced in.Two honesty rules matter more than the arithmetic:
if the vault call failed outright, treat the contribution as null, not 0. A
figure that silently falls back to perps-only margin looks complete but understates
the wallet exactly as before.
a single stake whose equity string doesn’t parse (unweighed above) is different
from a failed call: the call succeeded, one entry just isn’t numeric. Counting it as
0 keeps knownValue a floor rather than discarding the whole scan over one bad
entry, but the UI must say so: show a (floor) qualifier whenever unweighed > 0,
so the figure never reads as complete when it isn’t.
That gives three display states, not two:
perpsMargin and vaultEquity both known → render the combined figure and a
labelled Perps Margin tile side by side. Collapsing them into one number loses
the perps-only figure some readers specifically want.
perpsMargin known, vaultEquitynull → render only the Perps Margin tile,
noting the vault call failed. There is no combined figure to show.
perps is null → render neither metric; report that the perps request failed.
perps is available but perpsMargin is null (blank or unparseable
accountValue) → render neither metric; report the value as unavailable. This is a
data-quality gap, not a request failure, so don’t word it as one.
Positions live one level down, under assetPositions[].position. The field that trips
people up is szi, which is the signed position size, so its sign is what tells you
long from short. There is no side field.
const positions = (perps?.assetPositions ?? []).map(({ position: p }) => ({ coin: p.coin, side: Number(p.szi) > 0 ? "long" : "short", size: Math.abs(Number(p.szi)), entryPx: Number(p.entryPx), // null for a cross-margin position that can't be individually liquidated liquidationPx: p.liquidationPx === null ? null : Number(p.liquidationPx), leverage: `${p.leverage.value}x ${p.leverage.type}`, marginUsed: Number(p.marginUsed), unrealizedPnl: Number(p.unrealizedPnl),}));
liquidationPx is legitimately null for some positions. Cross-margin positions
don’t each have their own liquidation price, since the whole account is collateral.
Render a dash, not a zero.
side is "B" for bid (buy) and "A" for ask (sell), Hyperliquid’s convention
throughout, in fills as well as orders. sz is what’s left unfilled; origSz is what
the order was placed for, so origSz - sz is the filled portion.
Use frontendOpenOrders rather than openOrders unless you have a reason not to. Both
return the same resting orders, but frontendOpenOrders adds order-type and trigger
metadata, and it’s served by all four providers, whereas openOrders is not served by
GoldRush. If your request fails over to GoldRush, frontendOpenOrders keeps working.
Positions are only half the picture: unrealizedPnl in the snapshot was true at
time, and it drifts the moment the market moves. To keep it live you need a current
mark for each coin.There is no allMids WebSocket channel. This is the single most common wrong
assumption when building on this stack: the REST type exists, so people look for the
matching stream and don’t find one. The working pattern is a hybrid:
Seed and correct from REST allMids on a slow interval (30 seconds is plenty).
Update tick-by-tick from the allFills WebSocket channel, taking each fill’s price
as the latest trade price for its coin.
const prices = new Map();// Seed from REST, and re-seed slowly to correct any drift.async function seedPrices() { const mids = await info({ type: "allMids" }); // { "BTC": "62150.0", "ETH": "2410.5", ... } for (const [coin, px] of Object.entries(mids)) prices.set(coin, Number(px));}await seedPrices();setInterval(seedPrices, 30_000);// Update from the live trade stream.ws.send(JSON.stringify({ method: "subscribe", subscription: { type: "allFills" } }));ws.on("message", (raw) => { const msg = JSON.parse(raw.toString()); if (msg.channel !== "allFills") return; for (const [, fill] of msg.fills) { if (fill?.coin && fill.px) prices.set(fill.coin, Number(fill.px)); }});
Two things to note about that subscription, both covered in full in the next section:
allFills needs no parameters (it’s the whole venue), and its payload arrives as
[address, fill]tuples, which is why the loop destructures past the address.
allFills is a busy stream, with thousands of events a minute at peak. Don’t
re-render on every message. Batch price updates and flush on a timer (250ms works
well) so a busy market doesn’t pin your UI thread.
With a live price map, unrealized PnL becomes a derivation rather than a fetch:
function liveUnrealizedPnl(positions) { return positions.reduce((sum, p) => { const mid = prices.get(p.coin); // Fall back to the snapshot value until the first price for that coin arrives. if (mid === undefined) return sum + Number(p.unrealizedPnl); return sum + Number(p.szi) * (mid - Number(p.entryPx)); }, 0);}
Because szi is signed, that one expression is correct for both directions: a short has
negative szi, so a falling price yields a positive product.
userFills takes addresses: string[], not user: string. This diverges from
Hyperliquid’s native WebSocket contract, and it fails quietly: send the native
{ type: "userFills", user: "0x..." } form and the subscription is accepted but never
delivers anything. The same applies to orderUpdates. If a user-scoped stream is
silent, check this first.
The payload diverges too. Fills arrive as [address, fill] tuples under a fills
key, not in Hyperliquid’s native { data: { user, fills } } envelope:
The dir field is worth surfacing directly in the feed. It is a pre-formatted
description of what the fill did to the position, which saves you inferring it from
startPosition and szi.
Subscriptions are matched for unsubscribe by their exact body. Send
{ method: "unsubscribe", subscription: { type: "userFills", addresses: [address] } }
with the identical addresses array you subscribed with, or the subscription (and its
per-minute billing) stays open. When a user switches wallets, unsubscribe the old
address before subscribing the new one.
The stream will drop. Handle it with exponential backoff, and re-send every active
subscription on reconnect, because the server keeps no state across connections:
let retry = 0;function connect() { const ws = new WebSocket("ws://localhost:8787/ws"); ws.on("open", () => { retry = 0; for (const sub of activeSubscriptions) { ws.send(JSON.stringify({ method: "subscribe", subscription: sub })); } }); ws.on("close", () => { setTimeout(connect, Math.min(15_000, 500 * 2 ** retry++)); }); return ws;}