# 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.

Published: 2026-06-08
Category: Guides
Source: https://www.struct.to/blog/streaming-polymarket-tradingview-charts

---

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](https://docs.struct.to/api-reference/market/get-market-candlesticks-by-condition_id) backfills history. The [trades WebSocket room](https://docs.struct.to/websockets/rooms/trades) 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](https://github.com/tradingview/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](https://www.tradingview.com/charting-library-docs/latest/connecting_data/datafeed-api/), and the library handles rendering, panning, resolution switching, and crosshairs.

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

```typescript
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:

```bash
pnpm add @structbuild/sdk
```

```typescript
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](https://www.struct.to/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:

| Field | Meaning                           |
| ----- | --------------------------------- |
| `t`   | Bar start time, Unix milliseconds |
| `o`   | Open price (0 to 1)               |
| `h`   | High price                        |
| `l`   | Low price                         |
| `c`   | Close price                       |
| `v`   | Volume                            |
| `tc`  | Trade 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:

```typescript
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.

```typescript
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:

```typescript
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`](https://docs.struct.to/websockets/rooms/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:

```typescript
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`:

```typescript
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:

```typescript
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:

```typescript
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`](https://docs.struct.to/websockets/rooms/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:

```typescript
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](https://docs.struct.to/websockets/rooms/trades); the candle endpoints are in the [REST reference](https://docs.struct.to/api-reference/market/get-market-candlesticks-by-condition_id).

> [Get a Struct API key](https://www.struct.to/dashboard): candlestick and trade-stream access starts on the free plan
>
> [WebSocket documentation](https://docs.struct.to/websockets/rooms/trades)
>
> [TypeScript SDK](https://www.npmjs.com/package/@structbuild/sdk)
