Skip to navigation

Charting Best Practices

View as Markdown

This guide describes the recommended end-to-end process for building charts on top of the market data WebSocket API. It reflects the approach teams use in production for large symbol universes (hundreds to thousands of contracts), and it builds on the chart request and response shapes in the Market Data Request Reference.

At a Glance

  • A reliable charting pipeline has four stages: shard your connections, order the incoming data, calculate and warehouse candles, then overlay the live edge.
  • Shard a large symbol universe across multiple WebSocket connections so one disconnect takes down only a slice of your charts.
  • Each symbol is its own md/getChart subscription. Route incoming data by the subscription ID from the acknowledgment, not the request ID.
  • Buffer and sort ticks before aggregating them. Plotting as-received is the most common source of chart errors.
  • Warehouse calculated candles and serve history from storage, so you do not open an exchange subscription per viewer.

Stage 1: Shard Your Connections

A single WebSocket connection can hold subscriptions for many symbols, but putting a large symbol universe on one socket creates a single point of failure and a processing bottleneck: every symbol’s messages serialize through one socket, and one disconnect takes every chart down at once.

Sharding distributes your symbol universe across multiple connections instead. It limits the blast radius of any single disconnect, keeps message processing parallel, and helps you avoid bursting past rate limits when you subscribe to many symbols at startup. (This is distinct from the shardingExpression sharding on the user sync socket, which routes user and account events rather than distributing market data symbols.)

Recommended strategies:

  • Fixed-size buckets: assign symbols round-robin so each connection carries a similar count (for example, 50 to 100 symbols per socket).
  • Group by product family: keep related contracts together (all ES contract months on one shard, all NQ on another). This makes operational debugging clearer.
  • Load-weighted sharding: split by expected message volume rather than symbol count, since actively traded contracts generate far more ticks than thin ones.
  • Keep a static symbol-to-socket map so reconnect logic knows exactly which subset to re-subscribe, without re-deriving the scheme.

Routing Data to the Right Symbol

Each symbol subscription is its own message. There is no request body that lists multiple symbols, so subscribing to two symbols on one connection means sending two separate md/getChart messages, each with a unique request ID. Every WebSocket request follows the format (endpoint, request ID, an optional query, and the body, each on its own line):

endpoint
requestId
body

For example, subscribing to ESU6 as request 101 and NQU6 as request 102:

md/getChart
101
{"symbol":"ESU6","chartDescription":{"underlyingType":"MinuteBar","elementSize":15,"elementSizeUnit":"UnderlyingUnits","withHistogram":false},"timeRange":{"asMuchAsElements":100}}
md/getChart
102
{"symbol":"NQU6","chartDescription":{"underlyingType":"MinuteBar","elementSize":15,"elementSizeUnit":"UnderlyingUnits","withHistogram":false},"timeRange":{"asMuchAsElements":100}}

Each md/getChart acknowledgment echoes your request ID in the i field and returns the subscription ID in both d.historicalId and d.realtimeId:

{"s":200,"i":101,"d":{"mode":"Delayed","historicalId":12432,"realtimeId":12432}}
{"s":200,"i":102,"d":{"mode":"Delayed","historicalId":12434,"realtimeId":12434}}

historicalId and realtimeId carry the same value, so read the subscription ID off the acknowledgment. Build a lookup table from the acknowledgments:

Request ID (i)SymbolSubscription ID
101ESU612432
102NQU612434

From that point on, every chart data packet arrives with no request ID, tagged only with the subscription ID inside each chart entry:

{"e":"chart","d":{"charts":[{"id":12432,"s":"db","td":20260818,"bars":[]}]}}
{"e":"chart","d":{"charts":[{"id":12434,"s":"","td":20260818,"bars":[]}]}}

Match the id field against the lookup table to know which symbol each packet belongs to: here 12432 routes to ESU6 and 12434 to NQU6. Keep both mappings: request ID to symbol (to match acknowledgments) and subscription ID to symbol (to match every message afterward, and to call md/cancelChart later). Two other fields are worth tracking per chart entry:

  • s: a status tag on the batch. db marks bars replayed from historical storage, an empty string marks live bars, and s marks the in-progress bar at the moment history ends.
  • eoh: sent as its own chart entry (for example, {"id":12432,"eoh":true}) once the historical replay for that subscription ID finishes. This is your per-symbol signal to switch that symbol from backfilling history to overlaying live updates.

Stage 2: Order the Incoming Data

Before you calculate any candle, choose the right data granularity and order what arrives correctly.

Choose raw ticks only if you need custom bars. If standard bar types such as MinuteBar or DailyBar meet your needs, request them directly from md/getChart. This is simpler and avoids the ordering work below. Use raw Tick data only when you need full control over bar construction, for bar types or intervals that md/getChart does not build natively.

Buffer and sort before aggregating. Ticks can arrive out of order: multiple packets per message, overlap at the historical-to-real-time boundary, and normal network batching all contribute. Building candles from an unsorted stream produces gaps and false price spikes. Buffer incoming ticks, sort by timestamp with the tick ID as a tiebreaker, and only then aggregate into candles. Raw ticks also arrive as deltas from a per-packet base price and timestamp rather than absolute values. For how to reconstruct them, see Tick Charts.

Stage 3: Calculate and Warehouse Candles

Rather than re-requesting historical data every time a user opens a chart, calculate candles once and persist them to a warehouse that serves all users.

  • Aggregate sorted ticks, or accept pre-built bars from md/getChart, into OHLCV candles at the interval you need.
  • Write each closed candle to a database, keyed by symbol and interval.
  • Serve historical chart requests from the warehouse rather than from a live exchange subscription. This scales to many concurrent viewers without opening a new subscription per user.

Choosing a Time Range

The timeRange field controls which slice of history a md/getChart request loads. The strategies are mutually exclusive, so pick one:

GoaltimeRange to use
Load the last N bars, then stream live{ "asMuchAsElements": N }
Load a fixed window{ "closestTimestamp": "…", "asFarAsTimestamp": "…" }
Load from a point in time, then stream live{ "closestTimestamp": "…" }
Paginate back through historyLoop closestTimestamp backward (see below)

For example, to load the 100 most recent bars and then stream live:

{ "asMuchAsElements": 100 }

To load a fixed window, give both ends (closestTimestamp is the most recent end of the range, asFarAsTimestamp is how far back to go):

{
"closestTimestamp": "2025-03-20T16:00:00.000Z",
"asFarAsTimestamp": "2025-01-01T00:00:00.000Z"
}

A chart’s controls map onto these fields: increasing asMuchAsElements loads more bars and decreasing it loads fewer (the zoom control), changing it cancels the old subscription and re-requests, the Size input sets elementSize, and the Type dropdown sets underlyingType.

function requestChart(sym) {
var type = el('chart-type').value; // MinuteBar / DailyBar / Tick
var size = parseInt(el('chart-size').value) || 5; // elementSize
var bars = parseInt(el('chart-bars').value) || 100; // asMuchAsElements
ws.send('md/getChart\n' + (wsMsgId++) + '\n\n' + JSON.stringify({
symbol: sym,
chartDescription: {
underlyingType: type,
elementSize: size,
elementSizeUnit: 'UnderlyingUnits',
withHistogram: false
},
timeRange: { asMuchAsElements: bars }
}));
}

Backfilling Long History Windows

The data-size limit per request is flexible rather than a fixed hard cap, and it can vary from day to day, but MinuteBar and Tick requests tend to return at most about two months of data per request. To backfill further:

  1. When the eoh entry arrives for that subscription, find the oldest bar you have gathered.
  2. Issue a new request using that bar’s timestamp as closestTimestamp, keeping your original target date as asFarAsTimestamp.
  3. Repeat until the oldest bar received reaches your target start date.

For more on this pattern, see How to Handle Data Size Limits.

Stage 4: Overlay Real-Time Data

With history warehoused, the display layer combines two sources:

  • Closed historical candles, read directly from the warehouse for fast chart loads.
  • The current, still-forming candle, updated live from the real-time stream until it closes. When it closes, write it to the warehouse and begin a new live candle.

This gives users fast-loading historical charts with a live-updating right edge, without keeping an exchange subscription open per viewer for historical data.

Best Practices Checklist

AreaRecommendation
ConnectionsUse multiple WebSocket connections for market data. Do not put an entire large symbol universe on a single socket.
ShardingGroup symbols by product family or load, and keep a static symbol-to-socket map for clean reconnects.
SubscriptionsSend one md/getChart request per symbol with a unique request ID. Track request ID to symbol and subscription ID to symbol.
Bar sourcePrefer native MinuteBar or DailyBar from md/getChart. Use raw Tick data only when custom bar types are required.
Tick decodingReconstruct price and time from packet-relative deltas using the base price, base time, and tick size.
OrderingAlways buffer and sort ticks by timestamp and ID before aggregating. Never plot as-received.
StoragePersist calculated candles to a warehouse, and serve historical chart data from storage rather than a live request per viewer.
Live renderingRead closed candles from the warehouse, and overlay only the in-progress candle from the live stream.
BackfillPaginate long history requests using closestTimestamp walked backward against a fixed asFarAsTimestamp target.
ResilienceHandle disconnects per shard. A drop on one socket should not require re-subscribing symbols on unaffected sockets.

The core idea across all four stages is the same: do not make the exchange subscription do double duty as your storage and distribution layer. Shard connections so any single socket stays resilient, calculate candles once from correctly ordered data, warehouse the result, and overlay only the live edge on top.