Skip to main content
The TradingView Advanced Charts library (the charting_library package, not the open-source Lightweight Charts) renders price by calling a datafeed object you implement. This guide wires Struct into that datafeed: historical bars from the candlestick API in getBars, and live bars from the trades WebSocket room fed into the realtime callback.
NeedSource
Historical OHLCV bars for one outcomeclient.markets.getPositionCandlestick
Historical OHLCV bars for a whole marketclient.markets.getCandlestick
Live trades to build the forming barpolymarket_trades (trade_stream_update)
Pre-aggregated live OHLC (server-side)polymarket_position_metrics (position_metrics_update)
Each Polymarket outcome is an ERC-1155 token identified by a numeric position ID. That position ID is the symbol you chart. A binary market has a Yes token and a No token, each with its own price series, so you chart one outcome at a time.

When to use this

  • A candlestick chart of a single market outcome (Yes/No price over time) inside your own app.
  • A live trading view that backfills history on mount and extends the last bar on every fill.
  • Replacing a polling chart with a pushed one, no reconnect bookkeeping.
If you are charting the crypto spot price behind Up/Down markets instead, use getAssetCandlestick, not the market candlestick endpoints below.

The candlestick endpoints

getPositionCandlestick returns OHLCV for a single outcome token; getCandlestick returns it for a market by condition ID. Both return the same bar shape.
import { StructClient } from "@structbuild/sdk";

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

const { data: candles } = await client.markets.getPositionCandlestick({
  position_id: "71321045679252212594626385532706912750332728571942532289631379312455583992563",
  resolution: "60",
  count_back: 500,
});
Each bar is { o, h, l, c, v, t, tc }:
FieldDescription
o h l cOpen, high, low, close (each 01, the outcome’s price)
vVolume in the bar
tBar start time, Unix milliseconds
tcTrade count in the bar
ParameterDescription
position_idOutcome token ID (getCandlestick takes condition_id instead)
resolutionCandle interval, TradingView naming: 1S, 5S, 10S, 30S (seconds), 1, 5, 15, 30, 60, 240 (minutes), D / 1D (daily)
count_backNumber of candles to return (max 2500)
from / toOptional Unix second range to bound the window
pagination_keyCursor from a previous response to page further back
Request bounds (from / to) are in Unix seconds, but each returned bar’s t is in Unix milliseconds. TradingView’s Bar.time wants milliseconds for intraday resolutions, so t maps straight across with no conversion.

Step 1: getBars

TradingView calls getBars on first load and again as the user pans into older data. periodParams gives the window (from, to in Unix seconds), the bar count (countBack), and whether this is the first request. Map the response into the library’s Bar type and sort ascending; out-of-order bars are rejected. Page older history with the cursor, not the window. Send from / to (or just count_back) on the first request to anchor the visible range, then follow the pagination_key from each response on every subsequent call. The pagination.has_more flag — not the returned row count — tells you when the series is exhausted, so keep a small state object between calls to carry the cursor and the more-data flag.
import type {
  Bar,
  ResolutionString,
  PeriodParams,
  HistoryCallback,
  DatafeedErrorCallback,
} from "../charting_library";
import type { StructClient } from "@structbuild/sdk";

type ApiResolution =
  | "1S" | "5S" | "10S" | "30S"
  | "1" | "5" | "15" | "30" | "60" | "240" | "1D";

const toApiResolution = (r: ResolutionString): ApiResolution => {
  if (r === "D" || r === "1D") return "1D";
  if (r.endsWith("S")) {
    const seconds = parseInt(r, 10);
    if (seconds <= 1) return "1S";
    if (seconds <= 5) return "5S";
    if (seconds <= 10) return "10S";
    return "30S";
  }
  const val = parseInt(r, 10);
  if (Number.isNaN(val)) return "60";
  if (val <= 1) return "1";
  if (val <= 5) return "5";
  if (val <= 15) return "15";
  if (val <= 30) return "30";
  if (val <= 60) return "60";
  return "240";
};

type GetBarsState = {
  hasMore: boolean;
  paginationKey: string | number | null;
};

async function getBars(
  client: StructClient,
  positionId: string,
  resolution: ResolutionString,
  periodParams: PeriodParams,
  onResult: HistoryCallback,
  onError: DatafeedErrorCallback,
  state: GetBarsState,
) {
  try {
    const { from, to, countBack, firstDataRequest } = periodParams;
    if (firstDataRequest) {
      state.hasMore = true;
      state.paginationKey = null;
    }
    if (!state.hasMore && !firstDataRequest) {
      onResult([], { noData: true });
      return;
    }

    const { data, pagination } = await client.markets.getPositionCandlestick({
      position_id: positionId,
      resolution: toApiResolution(resolution),
      count_back: countBack,
      // First page anchors the window; older pages follow the cursor.
      ...(state.paginationKey != null
        ? { pagination_key: String(state.paginationKey) }
        : { from, to }),
    });

    if (!data || data.length === 0) {
      state.hasMore = false;
      onResult([], { noData: true });
      return;
    }

    const bars: Bar[] = data
      .filter((c) => c.o != null && c.c != null)
      .map((c) => ({
        time: c.t,
        open: c.o ?? 0,
        high: c.h ?? 0,
        low: c.l ?? 0,
        close: c.c ?? 0,
        volume: c.v ?? 0,
      }))
      .sort((a, b) => a.time - b.time);

    // Drive pagination from the cursor, not the returned row count.
    state.hasMore = pagination?.has_more ?? false;
    state.paginationKey = state.hasMore ? pagination?.pagination_key ?? null : null;

    onResult(bars, { noData: bars.length === 0 });
  } catch (err) {
    onError(err instanceof Error ? err.message : "Failed to fetch candlesticks");
  }
}

Step 2: live bars from the trades room

subscribeBars should only register the chart’s realtime callback. Feed updates from a separate handleRealtimeTrade method driven by the polymarket_trades room, so one socket serves the chart, a trade tape, and anything else. Each trade_stream_update carries price (01), shares_amount, side, and confirmed_at (Unix seconds). Bucket each trade into its bar, opening a new bar from the previous bar’s close so the series stays continuous.
import type { SubscribeBarsCallback } from "../charting_library";
import type { TradeStreamEvent } from "@structbuild/sdk";

const resolutionToMs = (r: ResolutionString): number => {
  if (r === "D" || r === "1D") return 24 * 60 * 60 * 1000;
  if (r.endsWith("S")) {
    const seconds = parseInt(r, 10);
    return Number.isNaN(seconds) ? 1000 : seconds * 1000;
  }
  const val = parseInt(r, 10);
  return Number.isNaN(val) ? 60 * 60 * 1000 : val * 60 * 1000;
};

type BarSubscription = {
  resolution: ResolutionString;
  onTick: SubscribeBarsCallback;
  lastBar: Bar | null;
};

const subscriptions = new Map<string, BarSubscription>();

function handleRealtimeTrade(positionId: string, trade: TradeStreamEvent) {
  if (trade.position_id !== positionId) return;
  if (trade.price == null || trade.confirmed_at == null) return;

  const price = trade.price;
  const size = trade.shares_amount ?? 0;
  const tsMs = trade.confirmed_at * 1000;

  for (const sub of subscriptions.values()) {
    const barMs = resolutionToMs(sub.resolution);
    const barTime = Math.floor(tsMs / barMs) * barMs;
    let bar: Bar;

    if (sub.lastBar && sub.lastBar.time === barTime) {
      bar = {
        ...sub.lastBar,
        high: Math.max(sub.lastBar.high, price),
        low: Math.min(sub.lastBar.low, price),
        close: price,
        volume: (sub.lastBar.volume ?? 0) + size,
      };
    } else if (sub.lastBar && barTime > sub.lastBar.time) {
      bar = {
        time: barTime,
        open: sub.lastBar.close,
        high: Math.max(sub.lastBar.close, price),
        low: Math.min(sub.lastBar.close, price),
        close: price,
        volume: size,
      };
    } else {
      bar = { time: barTime, open: price, high: price, low: price, close: price, volume: size };
    }

    sub.lastBar = bar;
    sub.onTick(bar);
  }
}
The three branches cover every case: extend the current bar, roll into a new one (open at the last close), or start cold. TradingView merges realtime bars by time, so the first trade after load lands on the same timestamp as the last historical bar and updates it in place.
By default the trades room sends confirmed on-chain fills. Pass status: "all" to also receive mempool trades, which arrive before confirmation and carry received_at (Unix milliseconds) instead of confirmed_at. Use them for a faster visual tick and reconcile against confirmed data.

Step 3: assemble the datafeed and mount

onReady advertises supported resolutions. resolveSymbol describes the instrument: prices are 01, so pricescale: 10000 gives four decimals. Then connect the socket, subscribe to the trades room, and pipe each event into handleRealtimeTrade.
import type { IBasicDataFeed, LibrarySymbolInfo, OnReadyCallback, ResolveCallback } from "../charting_library";
import { StructClient, StructWebSocket } from "@structbuild/sdk";

const SUPPORTED_RESOLUTIONS = ["1S", "5S", "10S", "30S", "1", "5", "15", "30", "60", "240", "D"] as ResolutionString[];
const SECONDS_MULTIPLIERS = ["1", "5", "10", "30"];

export function createPredictionDatafeed(positionId: string, client: StructClient) {
  const state: GetBarsState = { hasMore: true, paginationKey: null };

  const datafeed: IBasicDataFeed = {
    onReady: (cb: OnReadyCallback) => {
      setTimeout(() => cb({ supported_resolutions: SUPPORTED_RESOLUTIONS, seconds_multipliers: SECONDS_MULTIPLIERS }), 0);
    },
    searchSymbols: (_input, _exchange, _type, onResult) => onResult([]),
    resolveSymbol: (_name, onResolve: ResolveCallback) => {
      setTimeout(() => {
        onResolve({
          ticker: positionId,
          name: "Polymarket outcome",
          description: "Polymarket outcome",
          type: "index",
          session: "24x7",
          timezone: "Etc/UTC",
          exchange: "Polymarket",
          listed_exchange: "Polymarket",
          format: "price",
          minmov: 1,
          pricescale: 10000,
          has_intraday: true,
          has_seconds: true,
          seconds_multipliers: SECONDS_MULTIPLIERS,
          has_daily: true,
          has_weekly_and_monthly: false,
          supported_resolutions: SUPPORTED_RESOLUTIONS,
          volume_precision: 2,
          data_status: "streaming",
        } as LibrarySymbolInfo);
      }, 0);
    },
    getBars: (_symbolInfo, resolution, periodParams, onResult, onError) =>
      getBars(client, positionId, resolution, periodParams, onResult, onError, state),
    subscribeBars: (_symbolInfo, resolution, onTick, uid) => {
      subscriptions.set(uid, { resolution, onTick, lastBar: null });
    },
    unsubscribeBars: (uid) => subscriptions.delete(uid),
  };

  return datafeed;
}
const client = new StructClient({ apiKey: "pk_jwt_xxx", jwt: userJwt });
const ws = new StructWebSocket({ apiKey: "pk_jwt_xxx", getJwt: () => userJwt });

const datafeed = createPredictionDatafeed(positionId, client);

await ws.connect();
await ws.subscribe("polymarket_trades", { position_ids: [positionId] });
const offTrade = ws.on("trade_stream_update", (trade) => handleRealtimeTrade(positionId, trade));

const widget = new window.TradingView.widget({
  symbol: positionId,
  interval: "60" as ResolutionString,
  container: "tv_chart_container",
  library_path: "/charting_library/",
  datafeed,
  locale: "en",
  autosize: true,
});

// teardown
// offTrade(); ws.unsubscribe("polymarket_trades"); widget.remove();
For a browser chart, authenticate with a pk_jwt_ public key plus the signed-in user’s JWT, as above. The pk_jwt_ key is safe in a frontend bundle because it is useless without a valid JWT from your configured auth provider. On a server, use your sk_ secret key and drop the jwt. See JWT auth.

Common combinations

GoalCall
Backfill one outcomegetPositionCandlestick({ position_id, resolution: "60", count_back: 500 })
Backfill a whole marketgetCandlestick({ condition_id, resolution: "60" })
Live bars from tradessubscribe("polymarket_trades", { position_ids: [id] })trade_stream_update
Live confirmed + pendingsubscribe("polymarket_trades", { position_ids: [id], status: "all" })

Follow-on

Last modified on June 8, 2026