Stream Live Polymarket Candles into a TradingView Chart

Build a TradingView Advanced Charts datafeed for Polymarket: backfill OHLCV from the candlestick API and stream live candles from the trades WebSocket room.

Struct Team
Struct Team
7 min read
Stream Live Polymarket Candles into a TradingView Chart

You want a Polymarket outcome rendered the way a trader expects to see price: a TradingView chart with real candles, real volume, and a last bar that grows on every fill. Two pieces get you there. The candlestick API backfills history. The trades WebSocket room streams the live ticks that extend the forming bar.

This guide wires both into a TradingView Advanced Charts datafeed, the same getBars + subscribeBars contract the library expects from any exchange.

The short version:

  • Struct exposes OHLCV for any Polymarket market (getCandlestick, by condition ID) or any single outcome (getPositionCandlestick, by position ID). Both take a resolution of 1S, 5S, 10S, 30S, 1, 5, 15, 30, 60, 240, or D and return up to 2500 bars.
  • A TradingView Advanced Charts datafeed needs four methods: onReady, resolveSymbol, getBars, and subscribeBars. History goes in getBars; live updates go in subscribeBars.
  • Live candles come from the polymarket_trades room. Each trade_stream_update carries a price (0 to 1) and a confirmed_at timestamp. You bucket trades by bar start time and push the updated bar to the chart.
  • In the browser, authenticate with a pk_jwt_ public key plus your user's JWT so your secret key never ships client-side. The SDK handles reconnects and replays your subscriptions.
  • The same trade stream powers a live trade tape beside the chart, no second connection needed.

Which TradingView library is this?

This is the TradingView Advanced Charts library (the charting_library package you get access to from TradingView), not the open-source Lightweight Charts on npm. Advanced Charts does not take a data array. It takes a datafeed object and calls your methods when it needs bars. That inversion is the whole integration: you implement the JS Datafeed API, and the library handles rendering, panning, resolution switching, and crosshairs.

A datafeed is just an object with a handful of methods:

interface IBasicDataFeed {
	onReady(callback: OnReadyCallback): void;
	resolveSymbol(symbol: string, onResolve, onError): void;
	getBars(symbolInfo, resolution, periodParams, onResult, onError): void;
	subscribeBars(symbolInfo, resolution, onTick, uid, onReset): void;
	unsubscribeBars(uid: string): void;
}

We fill those in with Struct calls.

Set up the SDK clients

Install the SDK and create one REST client for history and one WebSocket for live data:

pnpm add @structbuild/sdk
import { StructClient, StructWebSocket } from "@structbuild/sdk";

const STRUCT_PUBLIC_KEY = "pk_jwt_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4";

export function createStructClients(userJwt: string) {
	const client = new StructClient({
		apiKey: STRUCT_PUBLIC_KEY,
		jwt: userJwt,
	});

	const ws = new StructWebSocket({
		apiKey: STRUCT_PUBLIC_KEY,
		getJwt: () => userJwt,
	});

	return { client, ws };
}

The pk_jwt_ key is safe to hardcode in a frontend bundle. It does nothing without a valid JWT from the auth provider you configured in the dashboard, so the user's token is what actually authorizes the connection. Passing getJwt instead of a static jwt means reconnects always rebuild the URL with a fresh token, which matters when your JWT rotates while the socket stays open. On a server, swap the pk_jwt_ key for your sk_ secret key and drop the JWT.

Each Polymarket outcome is an ERC-1155 token with a numeric position ID. That position ID is the symbol we chart. A market like "Will X happen?" has a Yes token and a No token, each with its own price series, so you chart one outcome at a time.

The candlestick API

Both candlestick endpoints return the same PredictionCandlestickBar shape:

FieldMeaning
tBar start time, Unix milliseconds
oOpen price (0 to 1)
hHigh price
lLow price
cClose price
vVolume
tcTrade count in the bar

getCandlestick({ condition_id }) gives you the market-level series. getPositionCandlestick({ position_id }) gives you a single outcome. For a per-outcome chart, reach for the position variant:

const { data: candles } = await client.markets.getPositionCandlestick({
	position_id: "71321045679252212594626385532706912750332728571942532289631379312455583992563",
	resolution: "60",
	count_back: 300,
});

resolution is one of 1S, 5S, 10S, 30S (seconds), 1, 5, 15, 30, 60, 240 (minutes), or D (daily) as a string. count_back is how many bars to return (max 2500). You bound the window with from and to in Unix seconds, while each returned bar's t comes back in milliseconds. The response is paginated: pass pagination_key from a prior response to walk further back.

getBars: backfill history

TradingView calls getBars whenever it needs candles, on first load and again as the user pans left into older data. periodParams tells you the window (from, to in Unix seconds), how many bars it wants (countBack), and whether this is the initial request (firstDataRequest).

The library wants bar times in milliseconds, which is exactly what Struct's t already is, so map field-for-field. One thing to track yourself: page older history with the response cursor. Send from / to on the first request to anchor the visible window, then follow the pagination_key from each response on every later call, and stop when pagination.has_more comes back false. Keep a small state object between calls to carry the cursor and the more-data flag.

import type {
	IBasicDataFeed,
	Bar,
	ResolutionString,
	PeriodParams,
	LibrarySymbolInfo,
	HistoryCallback,
	DatafeedErrorCallback,
	SubscribeBarsCallback,
	OnReadyCallback,
	ResolveCallback,
} from "../charting_library";
import type { StructClient, TradeStreamEvent } 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"];

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 ApiResolution =
	| "1S" | "5S" | "10S" | "30S"
	| "1" | "5" | "15" | "30" | "60" | "240" | "1D";

const resolutionToApiResolution = (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";
};

getBars maps the candlestick response straight into the library's Bar type:

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: resolutionToApiResolution(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 candlestick data");
	}
}

Sorting matters. TradingView rejects bars that arrive out of order, so always sort ascending by time before handing them over. The cursor is what bounds the backfill: keep paging while pagination.has_more is true, and once it flips false the next getBars short-circuits with noData: true so the library stops asking.

subscribeBars and the live trade feed

This is the live half, and it splits in two. subscribeBars only registers the chart's realtime callback. The actual updates come from a separate handleRealtimeTrade method that the trades WebSocket feeds. Keeping the socket out of subscribeBars means one connection serves the chart, the tape, and anything else, instead of one per series.

Each trade_stream_update from the polymarket_trades room carries a price (0 to 1), 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:

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

export function createPredictionDatafeed(positionId: string, client: StructClient) {
	const subscriptions = new Map<string, BarSubscription>();
	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: (_symbolName, 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),
	};

	const handleRealtimeTrade = (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);
		}
	};

	return { datafeed, handleRealtimeTrade };
}

The three branches cover every case: extend the current bar, roll into a new bar (open at the last close), or start cold when no bar exists yet. Because TradingView merges realtime bars by time, the first trade after load lands on the same timestamp as the last historical bar and updates it in place.

Mount the widget and wire the socket

Create the datafeed, connect the socket, subscribe to the trades room, and pipe each event into handleRealtimeTrade:

const { client, ws } = createStructClients(userJwt);
const { datafeed, handleRealtimeTrade } = createPredictionDatafeed(positionId, client);

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

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

On teardown, drop the listener and remove the widget:

offTrade();
ws.unsubscribe("polymarket_trades");
tvWidget.remove();

That is a working chart: history from REST, a last bar that ticks up on every Polymarket fill.

Add the live trade tape from the same stream

You are already subscribed to the trades room, so a live tape is free. Listen to the same event and keep a rolling list:

import { useEffect, useState } from "react";
import type { TradeStreamEvent } from "@structbuild/sdk";

export function useTradeTape(ws: StructWebSocket, positionId: string) {
	const [trades, setTrades] = useState<TradeStreamEvent[]>([]);

	useEffect(() => {
		ws.subscribe("polymarket_trades", { position_ids: [positionId] }).catch(() => {});

		const off = ws.on("trade_stream_update", (trade) => {
			if (trade.position_id !== positionId) return;
			setTrades((prev) => [trade, ...prev].slice(0, 50));
		});

		return () => {
			off();
			ws.unsubscribe("polymarket_trades");
		};
	}, [ws, positionId]);

	return trades;
}

Each TradeStreamEvent gives you side ("Buy" or "Sell"), price, shares_amount, usd_amount, and a trader object with name and profile_image. That is enough for a tape that reads like Polymarket's own: green buys, red sells, size, and who.

Key decisions

Trades room or position metrics room. We built live bars from raw trades because it gives tick-accurate candles and a trade tape from one subscription. If you would rather not aggregate client-side, the polymarket_position_metrics room emits a pre-built OHLC bar (price_open, price_high, price_low, price_close) per timeframe. Subscribe with { position_ids: [...] }, map position_metrics_update straight to a Bar, and skip the bucketing math. The tradeoff is less control over how the forming bar updates and a separate room if you also want the tape.

Throttle the realtime callback. A hot market can fire many trades a second. Each one calls onTick, and on a busy chart that adds up. The production build coalesces updates per subscriber on a short timer (around 50ms) so the chart repaints at most once per frame. Skip it for a first pass, add it when you see the chart working harder than the market.

Pending trades for a faster feel. By default the trades room sends confirmed, on-chain fills. Pass status: "all" in the subscribe filters to also receive mempool trades, which arrive before confirmation and carry received_at (Unix milliseconds) instead of confirmed_at. It makes the last bar feel instant, at the cost of the occasional trade that never confirms. Use it for the visual tick, reconcile against confirmed data.

Let the SDK own reconnects. StructWebSocket reconnects with exponential backoff and replays your subscriptions, so you do not rebuild them on every drop. Watch the lifecycle events to surface state in the UI:

ws.on("reconnecting", ({ attempt }) => setStatus(`reconnecting (${attempt})`));
ws.on("connected", () => setStatus("live"));
ws.on("auth_failed", () => setStatus("auth failed"));

When a reconnect lands, TradingView may need its cache reset. Call the onResetCacheNeededCallback that subscribeBars hands you if you detect a gap, and the library will re-request bars through getBars.

The full surface, every room and filter, is in the WebSocket docs; the candle endpoints are in the REST reference.

Get a Struct API key: candlestick and trade-stream access starts on the free plan

WebSocket documentation

TypeScript SDK

Frequently asked questions

Ship faster with Struct

REST API, WebSockets, and Webhooks for Polymarket — free to start, no credit card required.