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

# 2. PnL Analytics

> Derive realized PnL, fees, funding, win rate, and per-coin breakdowns for any Hyperliquid address from its fill history.

Hyperliquid doesn't hand you a PnL curve; it hands you fills. Every fill that reduces a
position carries the profit or loss that closing it realized, so a trader's whole
performance history is derivable from `userFillsByTime` plus `userFunding`.

This page builds the derivation: realized PnL over time, fees, funding, win rate, volume,
and a per-coin breakdown.

<Info>
  `userFills`, `userFillsByTime`, `userFunding`, and `candleSnapshot` are all served by
  GoldRush. The waterfall routes to it automatically, so you only need
  `?provider=goldrush` if you're pinning for consistency or debugging.
</Info>

## 1. Fetch the fill history

`userFillsByTime` takes a millisecond-epoch window. `endTime` is optional and defaults to
now.

```javascript theme={null}
const now = Date.now();
const startTime = now - 30 * 24 * 60 * 60 * 1000; // last 30 days

const fills = await info({ type: "userFillsByTime", user: address, startTime });
```

Each fill carries everything the derivation needs:

```json theme={null}
{
  "coin": "ETH",
  "px": "2410.5",
  "sz": "1.5",
  "side": "A",
  "time": 1753660812345,
  "startPosition": "1.5",
  "dir": "Close Long",
  "closedPnl": "128.40",
  "hash": "0x...",
  "oid": 88123456,
  "fee": "1.81",
  "tid": 445566778899
}
```

The two load-bearing fields:

* **`closedPnl`** is the realized profit or loss from the portion of the position that
  this fill closed. It's `"0.0"` on fills that only *open* or *increase* a position,
  because nothing was realized. Only closing fills carry a non-zero value.
* **`fee`** is what you paid to trade, always positive, and always deducted. A negative
  fee (a maker rebate) appears as a negative value, which the arithmetic below handles
  without a special case.

### Handling the 2000-fill cap

Hyperliquid caps a fills response at **2000 rows**, keeping the most recent. For an
active trader over a long window, that's a truncated view, and nothing in the response
tells you it happened. You have to infer it:

```javascript theme={null}
const FILL_CAP = 2000;
const truncated = fills.length >= FILL_CAP;
```

To reconstruct a full history, page backwards: fetch a window, and if you hit the cap,
fetch again ending just before the oldest fill you received.

```javascript theme={null}
// Not every fill is backed by a real trade. Synthetic fills (spot dust conversions
// are the common case) carry `tid: 0` and a zero `hash`, so one account can hold
// dozens of distinct fills sharing both. Compose the key from fields that stay
// distinct across them.
const fillKey = (f) => `${f.tid}:${f.oid}:${f.time}:${f.coin}`;

async function fetchAllFills(address, startTime) {
  const all = [];
  let endTime = Date.now();

  while (true) {
    const page = await info({ type: "userFillsByTime", user: address, startTime, endTime });
    if (page.length === 0) break;

    all.push(...page);
    if (page.length < FILL_CAP) break; // partial page, so we've reached the start

    // Step back to just before the oldest fill in this page.
    const oldest = Math.min(...page.map((f) => f.time));
    if (oldest <= startTime) break;
    endTime = oldest - 1;
  }

  // Pages can overlap at boundaries, so de-duplicate, but not on `tid` alone.
  return [...new Map(all.map((f) => [fillKey(f), f])).values()];
}
```

<Warning>
  Don't de-duplicate on `tid` by itself. It's a *trade* id, and fills that aren't trades
  don't have one. A spot dust conversion is emitted with `tid: 0` and
  `hash: "0x000…0"`. Key a map on `tid` and an account with 42 dust conversions collapses
  to a single row, quietly undercounting volume, fill count, and per-coin activity.
</Warning>

<Note>
  Each page is a billed request, so an unbounded backfill over a very active address can
  be expensive. Bound the loop: cap the number of pages, or show the truncated window
  with a "showing the most recent 2000 fills" note and let the user opt in to the full
  history.
</Note>

## 2. Derive the numbers

With the fills in hand, everything else is arithmetic. Sort oldest-first, walk once, and
accumulate.

```javascript theme={null}
// Does this fill reduce or flip exposure? `dir` is Hyperliquid's own description of
// what the fill did to the position, so read it rather than inferring from PnL.
//   "Open Long" / "Open Short"      → adds exposure
//   "Close Long" / "Close Short"    → reduces it
//   "Long > Short" / "Short > Long" → flips it (closes, then reopens)
//   "Liquidated …" / "Auto-Deleveraged …" → closed involuntarily
//   "Spot Dust Conversion", "Buy", "Sell"  → spot, no perp exposure at all
function isClosingFill(f) {
  const dir = f.dir ?? "";
  return dir.includes("Close") || dir.includes(">") || /^(Liquidated|Auto-Deleveraged)/.test(dir);
}

function derivePnl(fills) {
  const asc = [...fills].sort((a, b) => a.time - b.time);

  const series = [];         // cumulative realized PnL, one point per fill
  const coins = new Map();   // per-coin breakdown
  let cumulative = 0;
  let fees = 0;
  let volume = 0;
  let closingFills = 0;
  let wins = 0;

  for (const f of asc) {
    const closedPnl = Number(f.closedPnl);
    const fee = Number(f.fee);
    const notional = Math.abs(Number(f.px) * Number(f.sz));

    cumulative += closedPnl - fee;
    fees += fee;
    volume += notional;

    // Win rate counts only fills that closed exposure. Opening fills must not enter
    // the denominator, since including them would tank the rate for everyone.
    if (isClosingFill(f)) {
      closingFills++;
      if (closedPnl > 0) wins++;
    }

    series.push({ time: f.time, value: cumulative });

    const row = coins.get(f.coin) ?? { coin: f.coin, realizedPnl: 0, volume: 0, fills: 0 };
    row.realizedPnl += closedPnl - fee;
    row.volume += notional;
    row.fills++;
    coins.set(f.coin, row);
  }

  return {
    series,
    realizedPnl: cumulative,
    fees,
    volume,
    totalFills: asc.length,
    closingFills,
    winRate: closingFills > 0 ? wins / closingFills : null,
    byCoin: [...coins.values()].sort((a, b) => b.volume - a.volume),
    truncated: asc.length >= 2000,
  };
}
```

Three definitions in that function are worth stating explicitly, because they're the ones
people get wrong:

**Realized PnL is net of fees.** `Σ(closedPnl − fee)`. Hyperliquid's `closedPnl` is
gross, so it doesn't deduct what you paid to trade. Report gross PnL as "realized PnL" and
your numbers will consistently overstate performance, by more the more actively the
address trades.

**Volume is notional, `Σ|px × sz|`.** The absolute value matters: `sz` can be negative
depending on direction, and a trader's volume is a magnitude, not a signed quantity.

**Win rate is wins ÷ *closing* fills**, not wins ÷ all fills. Every position takes at
least one opening fill that realizes nothing, so dividing by the total fill count
silently halves the win rate of anyone who opens and closes in single fills, and
distorts it unpredictably for everyone else.

Get the denominator from `dir`, not from `closedPnl`. The tempting shortcut ("a closing
fill is one where `closedPnl ≠ 0`") is wrong in both directions. A position closed
exactly at its entry price realizes nothing and would be dropped from the denominator,
inflating the win rate of anyone who scratches trades. And a spot dust conversion also
carries `closedPnl: 0` despite touching no perp position at all. `dir` states what the
fill actually did, so it answers the question directly.

<Warning>
  This derivation covers **realized** PnL only: settled facts from closed positions.
  It deliberately excludes unrealized PnL on positions still open. If your dashboard
  shows a combined figure, compute the unrealized half separately from live positions
  and mids (see the
  [wallet tracker](/guides/hyperliquid/analytics-dashboard/wallet-tracker#4-keep-mid-prices-live)),
  and label the two clearly. Mixing them into one number produces a figure that jumps
  around with the market and reconciles against nothing.
</Warning>

## 3. Add funding

Perp traders pay or receive funding continuously, and over a long holding period it can
dominate trading PnL. It's a separate ledger from fills:

```javascript theme={null}
const funding = await info({ type: "userFunding", user: address, startTime });
```

```json theme={null}
[
  {
    "time": 1753660800000,
    "hash": "0x...",
    "delta": {
      "type": "funding",
      "coin": "BTC",
      "usdc": "-0.42",
      "szi": "0.12",
      "fundingRate": "0.0000125"
    }
  }
]
```

`delta.usdc` is **signed**: negative is funding you paid, positive is funding you
received. So the accumulation is a plain sum, no sign flip:

```javascript theme={null}
function deriveFunding(updates) {
  const asc = [...updates].sort((a, b) => a.time - b.time);
  const series = [];
  let net = 0;

  for (const u of asc) {
    net += Number(u.delta.usdc); // + received, − paid
    series.push({ time: u.time, value: net });
  }

  return { series, net };
}
```

Keep funding on its own line rather than folding it into realized PnL. They answer
different questions (one is trading skill, the other is carry cost) and a trader whose
strategy is profitable but whose funding bill exceeds it needs to see both.

## 4. Add price context

A PnL curve is easier to read against the market it was earned in. `candleSnapshot`
supplies the backdrop.

This is the one type in the series with a **nested request body**. Its parameters go
inside a `req` object rather than at the top level:

```javascript theme={null}
const candles = await info({
  type: "candleSnapshot",
  req: { coin: "BTC", interval: "1h", startTime, endTime: Date.now() },
});
```

```json theme={null}
[
  {
    "t": 1753657200000,
    "T": 1753660799999,
    "s": "BTC",
    "i": "1h",
    "o": "62010.0",
    "c": "62150.0",
    "h": "62300.0",
    "l": "61890.0",
    "v": "142.85",
    "n": 3218
  }
]
```

Candles arrive oldest-first. The keys are terse: `t` open time, `T` close time, `s`
coin, `i` interval, `o`/`h`/`l`/`c` the OHLC prices, `v` volume, `n` trade count.

Match the interval to the window so the chart stays readable rather than returning
thousands of points:

```javascript theme={null}
function intervalForWindow(days) {
  return days <= 7 ? "1h" : days <= 30 ? "4h" : "1d";
}
```

## 5. Assemble the surface

```javascript theme={null}
async function loadPnl(address, days) {
  const startTime = Date.now() - days * 24 * 60 * 60 * 1000;

  const [fills, funding] = await Promise.all([
    info({ type: "userFillsByTime", user: address, startTime }),
    info({ type: "userFunding", user: address, startTime }),
  ]);

  const pnl = derivePnl(fills);
  const fundingDerivation = deriveFunding(funding);

  return {
    ...pnl,
    funding: fundingDerivation,
    net: pnl.realizedPnl + fundingDerivation.net,
  };
}
```

A reasonable set of headline tiles from that: **realized PnL** (net of fees),
**funding**, **fees paid**, **volume**, **win rate**, and **fill count**, with the
cumulative `series` as the chart and `byCoin` as a sortable table underneath.

<Tip>
  Surface `truncated` in the UI. A user comparing a 90-day figure against their own
  records will otherwise conclude your numbers are wrong, when in fact the window was
  capped. One line, "showing the most recent 2000 fills", turns a bug report into
  understood behavior.
</Tip>

## Next

[Market explorer](/guides/hyperliquid/analytics-dashboard/market-explorer) moves from
one address to the whole venue.
