Skip to main content
What you’ll build: a live “my positions” view that stays in sync as a wallet trades, as outcome prices move, and as markets resolve. You seed it once from REST, then keep it warm over a websocket so the open and closed sections never go stale.
polymarket_trader_positions pushes per-position updates for a set of wallets as they happen, batched per block. This recipe seeds a portfolio map from REST, then keeps it warm with three envelope types so the UI reflects new trades, moving prices, and market resolutions without polling.

When to use this

  • A live “my positions” view with open and closed sections.
  • A portfolio value ticker that moves with outcome prices between trades.
  • Surfacing redeemable and mergeable flags so a user knows when they can claim or convert.

The three envelope types

EventFires onShape
trader_position_batchA trade landed (buy, sell, merge, split, redemption, NegRisk convert).Full position rows.
trader_position_price_batchOutcome prices moved.Compact price ticks (dirty_kinds is ["price"]).
trader_position_resolution_batchThe position’s market resolved.Resolution ticks (dirty_kinds is ["position_resolved"]).
Full rows are the complete record of a position. Price ticks and resolution ticks are light updates that patch the live fields of a row you already hold, so you keep a single map keyed by position_id and merge each envelope in place. Note that timestamps in this room (first_trade_at, last_trade_at) are Unix milliseconds, unlike the trader PnL room where they are seconds.

Step 1: seed from REST

Pull the current position book so the portfolio is painted before the first block arrives. Use a trader outcome PnL call with min_shares: 0 to include fully exited positions, then build a map keyed by position_id.
import { StructClient } from "@structbuild/sdk";

const client = new StructClient({ apiKey: "sk_live_xxx" });

const { data: rows } = await client.trader.getTraderOutcomePnl({
  address: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
  min_shares: 0,
  timeframe: "lifetime",
});

const positions = new Map<string, TraderPositionRow>();
for (const row of rows) positions.set(row.position_id, row);
See Showing a trader’s open and closed positions for more on the REST shape and the open versus closed split.

Step 2: subscribe and merge full rows

traders is the only required filter. Each trader_position_batch carries full rows; upsert them by position_id.
import { StructWebSocket } from "@structbuild/sdk";

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

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

ws.on("trader_position_batch", (event) => {
  for (const row of event.data) {
    positions.set(row.position_id, row);
    classify(row);
  }
});
A position is open when current_shares_balance > 0 and closed when it reaches 0. The full row also carries realized_pnl_usd, realized_pnl_pct, avg_entry_price, and the descriptive fields (title, question, outcome, image_url) you need to render a card.

Step 3: patch the mark-to-market value from price ticks

Between trades, prices move, and trader_position_price_batch is how the live value tracks them. Each tick carries current_price, current_value, and the refreshed realized_pnl_usd for one position_id. The unrealized value of an open position is current_price times current_shares_balance, and the tick’s current_value is exactly that product, so you can use it directly.
ws.on("trader_position_price_batch", (event) => {
  for (const tick of event.data) {
    const pos = positions.get(tick.position_id);
    if (!pos) continue;
    pos.current_price = tick.current_price;
    pos.current_value = tick.current_value;
    pos.realized_pnl_usd = tick.realized_pnl_usd;
    pos.realized_pnl_pct = tick.realized_pnl_pct;
  }
  renderPortfolioValue();
});
Recompute the portfolio total as the sum of current_value across open positions plus realized PnL on closed ones, and the ticker moves smoothly with the market.

Step 4: handle resolutions

When a position’s market resolves, trader_position_resolution_batch delivers the verdict: resolved, won, and the final realized_pnl_usd. Apply it, then re-check the redeemable flag.
ws.on("trader_position_resolution_batch", (event) => {
  for (const res of event.data) {
    const pos = positions.get(res.position_id);
    if (!pos) continue;
    pos.open = false;
    pos.won = res.won ?? undefined;
    pos.realized_pnl_usd = res.realized_pnl_usd;
    classify(pos);
  }
});

Surfacing redeemable and mergeable flags

The full row carries two action flags worth surfacing in the UI:
  • redeemable is true when the market has resolved and the wallet still holds shares, meaning there is a payout to claim.
  • mergeable is true for an unresolved NegRisk market where the wallet holds shares, meaning positions can be merged back to collateral.
function classify(pos: TraderPositionRow) {
  if (pos.redeemable) flagAsRedeemable(pos);
  if (pos.mergeable) flagAsMergeable(pos);
  if ((pos.current_shares_balance ?? 0) > 0) moveToOpen(pos.position_id);
  else moveToClosed(pos.position_id);
}
Both flags live on full rows, so they update on the next trader_position_batch after a trade or, for resolution-driven redeemability, after you apply a resolution tick and re-evaluate.

Cutting message volume

This room bills per message. The dirty_kinds filter (a subset of ["trade","price","position_resolved"] or ["all"]) lets you drop families you do not render. A portfolio view that only redraws on trades and resolutions, not on every price move, can omit price:
await ws.subscribe("polymarket_trader_positions", {
  traders: ["0xd8da6bf26964af9d7eed9e03e53415d37aa96045"],
  dirty_kinds: ["trade", "position_resolved"],
});

Follow-on

Last modified on June 13, 2026