Skip to main content
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.
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.
FamilyEventsCarries timeframe?Fires on
Full-row batchtrader_global_pnl_batch, trader_market_pnl_batch, trader_category_pnl_batchYesA trade landed or a rolling window boundary crossed.
Price ticktrader_global_tick_batch, trader_market_tick_batch, trader_category_tick_batchNo (window-agnostic)Outcome prices moved (mark-to-market refresh).
Resolutiontrader_global_resolution_batch, trader_market_resolution_batch, trader_category_resolution_batchNo (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.
import { StructWebSocket } from "@structbuild/sdk";

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

await ws.subscribe("polymarket_trader_pnl", {
  traders: ["0xd8da6bf26964af9d7eed9e03e53415d37aa96045"],
});
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.
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.
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.
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:
FilterEffect
update_typesSubset of ["global","market","category"]. A global-only summary card should pass ["global"] and skip the per-market firehose entirely.
timeframesSubset 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_kindsSubset 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.
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 for the REST-seed-then-stream pattern.

Follow-on

Last modified on June 13, 2026