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

# Building a live trader PnL dashboard

> Stream realized PnL for a set of wallets from the trader PnL room across three grains and four rolling windows, and keep a mark-to-market view warm with price ticks.

<Info>
  **What you'll build:** a dashboard that shows a wallet's profit and loss live and refreshes the moment a trade lands or outcome prices move. Reach for this when you want PnL summary cards, a leaderboard, or a running "since you opened the page" number that stays current without polling.
</Info>

`polymarket_trader_pnl` pushes realized PnL for a set of traders as it changes on chain. PnL is computed at three grains (`global` for the whole portfolio, `market` for a single condition, `category` for a market category) and each grain is maintained over four rolling windows: `1d`, `7d`, `30d`, and `lifetime`. This recipe wires those streams into a dashboard that shows summary cards per grain and a live mark-to-market number that moves between trades.

## When to use this

* Wallet dashboards with global, per-market, and per-category PnL cards.
* Leaderboards that re-rank traders as their realized PnL updates.
* A "since you opened the page" running PnL that tracks price moves, not just trades.

## The three families

Each grain emits updates in three disjoint families. A given dirty row lands in exactly one of them per block, so you never double-count.

| Family         | Events                                                                                                 | Carries `timeframe`? | Fires on                                               |
| -------------- | ------------------------------------------------------------------------------------------------------ | -------------------- | ------------------------------------------------------ |
| Full-row batch | `trader_global_pnl_batch`, `trader_market_pnl_batch`, `trader_category_pnl_batch`                      | Yes                  | A trade landed or a rolling window boundary crossed.   |
| Price tick     | `trader_global_tick_batch`, `trader_market_tick_batch`, `trader_category_tick_batch`                   | No (window-agnostic) | Outcome prices moved (mark-to-market refresh).         |
| Resolution     | `trader_global_resolution_batch`, `trader_market_resolution_batch`, `trader_category_resolution_batch` | No (window-agnostic) | The owning market resolved with no accompanying trade. |

Full-row batches are the heavy, complete snapshots: a global row carries win rate, profit factor, volume breakdowns, best and worst trade, and the realized PnL for one specific window. The server sends one envelope per grain per family per block, and for full-row batches one per window as well, so a `trader_global_pnl_batch` envelope has a top-level `timeframe` telling you which window its rows describe.

Ticks and resolutions are deliberately window-agnostic. They are light updates that say "the live numbers changed" without re-sending every window. Because they carry boundary snapshots, any per-window delta is recoverable on the client, which is why you can keep four windows warm from a single tick stream rather than four full-row streams.

## Step 1: subscribe

`traders` is the only required filter. Pass the wallets your dashboard is tracking.

<CodeGroup>
  ```typescript SDK theme={null}
  import { StructWebSocket } from "@structbuild/sdk";

  const ws = new StructWebSocket({ apiKey: "sk_live_xxx" });
  await ws.connect();

  await ws.subscribe("polymarket_trader_pnl", {
    traders: ["0xd8da6bf26964af9d7eed9e03e53415d37aa96045"],
  });
  ```

  ```json Subscribe message theme={null}
  {
    "type": "room_message",
    "payload": {
      "room_id": "polymarket_trader_pnl",
      "message": {
        "action": "subscribe",
        "traders": ["0xd8da6bf26964af9d7eed9e03e53415d37aa96045"]
      }
    }
  }
  ```
</CodeGroup>

The subscribe response echoes the accepted `traders`, `update_types`, and `timeframes`, plus a `rejected` array for any wallet addresses that failed validation. Check `rejected` before assuming every requested wallet is live.

## Step 2: handle full-row batches keyed by timeframe

Each full-row envelope describes one window. Key your store by `(grain, timeframe, trader[, condition_id | category])` so the four windows of a wallet's global PnL live side by side and a window switcher in the UI is a lookup, not a refetch.

```typescript theme={null}
type GlobalKey = `${string}:${string}`;
const globalPnl = new Map<GlobalKey, TraderGlobalPnlRow>();

ws.on("trader_global_pnl_batch", (event) => {
  const timeframe = event.timeframe;
  for (const row of event.data) {
    globalPnl.set(`${row.trader}:${timeframe}`, row);
  }
  renderGlobalCards();
});

ws.on("trader_market_pnl_batch", (event) => {
  for (const row of event.data) {
    upsertMarketCard(row.trader, event.timeframe, row.condition_id, row);
  }
});

ws.on("trader_category_pnl_batch", (event) => {
  for (const row of event.data) {
    upsertCategoryCard(row.trader, event.timeframe, row.category, row);
  }
});
```

A global row gives you everything a summary card needs: `realized_pnl_usd`, `market_win_rate_pct`, `profit_factor`, `total_volume_usd`, and `best_trade_pnl_usd` / `worst_trade_pnl_usd` with their condition ids. The `first_trade_at` and `last_trade_at` fields here are Unix seconds.

## Step 3: keep a live mark-to-market number from ticks

Full-row batches fire on trades and window boundaries. Between those, prices still move, and the tick family is how you reflect that. A `trader_global_tick_batch` row carries `realized_pnl_usd` and `open_positions_value`, so the trader's live equity is the sum of the two.

```typescript theme={null}
const liveEquity = new Map<string, number>();

ws.on("trader_global_tick_batch", (event) => {
  for (const row of event.data) {
    liveEquity.set(row.trader, row.realized_pnl_usd + row.open_positions_value);
  }
  renderLiveEquity();
});
```

Because ticks are window-agnostic, the same tick updates the mark-to-market figure regardless of which window the card is showing. Realized PnL only changes on a trade or resolution, so when you need a per-window realized number, read it from the full-row store; use ticks for the unrealized component that moves continuously.

## Step 4: fold in resolutions

When a market resolves with no accompanying trade, the change arrives as a resolution batch rather than a full-row batch. A market-grain resolution row carries `resolved`, `won`, and the updated `realized_pnl_usd`; the global resolution row updates `markets_won`, `markets_lost`, and `markets_resolved`. Apply these to the same store so win counts and realized PnL stay correct without waiting for the next full-row flush.

```typescript theme={null}
ws.on("trader_global_resolution_batch", (event) => {
  for (const row of event.data) {
    bumpResolvedCounts(row.trader, row);
  }
});

ws.on("trader_market_resolution_batch", (event) => {
  for (const row of event.data) {
    if (row.resolved) markRedeemed(row.trader, row.condition_id, row.won, row.realized_pnl_usd);
  }
});
```

## Reading dirty\_kinds

Every row carries a `dirty_kinds` array naming what triggered it: `trade`, `price`, `window`, or `market_resolved`. The family already tells you the broad reason (ticks are always `price`, resolutions are always `market_resolved`), but on full-row batches `dirty_kinds` distinguishes a fresh trade from a pure window roll. Use it to decide what to animate: highlight a card on `trade`, refresh quietly on `window`.

## Cutting message volume and cost

The room bills per message, so subscribe to only what the dashboard renders. Three filters narrow the stream:

| Filter         | Effect                                                                                                                                                                                   |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `update_types` | Subset of `["global","market","category"]`. A global-only summary card should pass `["global"]` and skip the per-market firehose entirely.                                               |
| `timeframes`   | Subset of `["1d","7d","30d","lifetime"]`. A dashboard that only shows lifetime numbers passes `["lifetime"]`. Ticks and resolutions ignore this filter because they are window-agnostic. |
| `dirty_kinds`  | Subset of `["trade","price","window","market_resolved"]` or `["all"]`. Drop `price` if you do not render a live mark-to-market number and you cut the tick stream at the source.         |

```typescript theme={null}
await ws.subscribe("polymarket_trader_pnl", {
  traders: ["0xd8da6bf26964af9d7eed9e03e53415d37aa96045"],
  update_types: ["global"],
  timeframes: ["lifetime"],
  dirty_kinds: ["trade", "market_resolved"],
});
```

A row is delivered only if its own `dirty_kinds` intersects the filter, so the example above yields lifetime global rows on trades and resolutions while suppressing the price-tick traffic. An unknown value in any of the three filters rejects the whole subscription with an error, so validate inputs before sending.

## Seeding from REST

The room streams changes, not a starting snapshot. To paint the dashboard before the first block arrives, pull an initial picture from the REST `/pnl` endpoints (for example a trader outcome PnL call for the per-market cards) and merge the live rows on top by the same keys. See [Showing a trader's open and closed positions](/guides/trader-open-and-closed-positions) for the REST-seed-then-stream pattern.

## Follow-on

* To track individual positions rather than aggregate PnL, pair this with [Tracking positions in real time](/guides/real-time-positions-tracking).
* To plot realized PnL over time with exit annotations, see [Charting PnL with exit markers](/guides/pnl-chart-with-exit-markers).
* Full payload schemas live on the [Trader PnL room](/websockets/rooms/trader-pnl) page.
