Data loading
This page covers every way to get price data into a chart: bulk load, live updates, tick aggregation, infinite history paging, large-dataset downsampling, and gap recovery after a feed drop.
Shared loading controller (2.1.6)
DataLoadingController owns one instrument’s history, live bar store, retries
and older pages. It has no DOM dependency. The widget uses it automatically when
supplied a feed; custom terminals can subscribe to the same state contract.
View example code
el.style.display = 'flex';
el.style.flexDirection = 'column';
const controls = document.createElement('div');
controls.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap;padding:8px;min-height:48px;flex-shrink:0';
const stage = document.createElement('div');
stage.style.cssText = 'flex:1;min-height:0';
el.append(controls, stage);
const now = 1789093800;
const price = value => Math.round((23800 + (value - 100) * 4) * 20) / 20;
const bars = lib.generateBars(now - 359 * 60, 360, 60).map(bar => ({
...bar, open: price(bar.open), high: price(bar.high),
low: price(bar.low), close: price(bar.close),
volume: Math.round(bar.volume / 65) * 65,
}));
let failNext = false;
let empty = false;
let requests = 0;
let reconnect;
const counter = document.createElement('span');
counter.style.cssText = 'font:12px system-ui;padding:7px';
const source = {
async getBars(request) {
counter.textContent = 'History requests: ' + ++requests;
await new Promise((resolve, reject) => {
const stop = () => { clearTimeout(timer); reject(new Error('Cancelled')); };
const timer = setTimeout(() => {
request.signal?.removeEventListener('abort', stop);
resolve();
}, 700);
request.signal?.addEventListener('abort', stop, { once: true });
if (request.signal?.aborted) stop();
});
if (failNext) { failNext = false; throw new Error('Simulated connection failure'); }
if (empty) return [];
return bars.filter(bar => bar.time >= request.from && bar.time <= request.to);
},
async getBarsPage(request) {
const older = await this.getBars({ ...request, from: bars[0].time });
return { bars: older.slice(-60), hasMore: older.length > 60 };
},
subscribeBars(request, onBar, options) {
reconnect = options.onResync;
let seed = 0x31f2c7;
const random = () => {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
return seed / 4294967296;
};
const timer = setInterval(() => {
if (empty) return;
const last = bars[bars.length - 1];
const close = Math.round((last.close + (random() - 0.5) * 2) * 20) / 20;
const live = { ...last, close, high: Math.max(last.high, close),
low: Math.min(last.low, close), volume: last.volume + (1 + Math.floor(random() * 6)) * 65 };
bars[bars.length - 1] = live;
onBar({ ...live });
}, 800);
return () => clearInterval(timer);
},
};
const feed = lib.withBarCache(source, { now: () => now * 1000 });
const widget = lib.createWidget(stage, {
feed, symbol: 'NIFTY SIM', exchange: 'NFO', interval: '1m', intervals: ['1m'],
rail: false, indicators: false, lookbackBars: 120, loading: { now: () => now, pageSize: 60 },
navigation: { defaultVisibleBars: 100 },
});
function button(label, action) {
const button = document.createElement('button');
button.textContent = label;
button.type = 'button';
button.style.cssText = 'padding:5px 9px;border:1px solid #64748b;border-radius:4px;font:12px system-ui';
button.addEventListener('click', action);
controls.appendChild(button);
}
button('Load older', () => void widget.dataController.loadMore());
button('Fail refresh', () => { failNext = true; void widget.reload(); });
button('Reconnect', () => reconnect?.());
button('Pause / resume display', () => {
widget.dataController.setPaused(!widget.dataController.getState().paused);
});
button('Empty / restore', () => {
empty = !empty;
widget.setSymbol(empty ? 'EMPTY SIM' : 'NIFTY SIM', 'NFO');
});
controls.appendChild(counter);
return widget;import { DataLoadingController, withBarCache } from 'openalgo-charts';
const data = new DataLoadingController(withBarCache(sourceFeed), {
timeoutMs: 15_000,
pageSize: 500,
maxBars: 100_000,
pollIntervalMs: 0,
// Repair driven by the stream (2.3.2), all off by default: see below.
refreshOnBarClose: true,
refreshOnGap: true,
refreshWindowBars: 5,
});
const off = data.subscribe(state => {
renderLoadingState(state.status, state.error);
// A custom host maps load/prepend/live/refresh to its displayed series.
// Skip chart writes while replay owns the display.
if (!state.paused) renderSnapshot(state);
});
await data.load({ symbol: 'NIFTY', exchange: 'NFO', interval: '1m', from, to });
await data.loadMore();
await data.refresh();
// On unmount:
off();
data.destroy();Use the widget for a ready-made canvas binding, accessible status and Retry
controls. A custom host should use series.update for reason: 'live', prepend
only new older bars for reason: 'prepend', and retain the visible time anchor
when a refresh replaces its source. Do not fit the chart on every response.
Pair chart.setHistoryLoader(() => data.loadMore().finally(() => chart.historyLoadComplete())) with that subscriber. The widget supplies these
bindings and preserves a saved view when the original instrument matches.
| Operation | Behavior |
|---|---|
load(req) | Cancel the old context, clear its bars, optionally paint closed cache bars, fetch current history and seed the stream. |
refresh() | Fetch authoritative history, buffer incoming bars, then publish the merged result. A failure retains a stale display and Retry remains available. |
loadMore() | Share a pending page, deduplicate older bars and reject obsolete responses after a context change. |
pushBar(bar, meta?) | Accept live bars from an existing host-owned subscription. Omit feed.subscribeBars to avoid a second subscription. meta.provisional marks a bar whose open is only the first tick a builder saw; it keeps the open history holds for that bucket. |
getState() / subscribe(fn) | Read/observe display bars and typed loading status. Subscribe does not immediately invoke the listener. |
bars() | Read the current live store, including updates held away from the display. Treat arrays and bars as read-only. |
setPaused(true) | Hold the display snapshot while the live store continues. A paused historical replay still needs this fence. |
setPaused(false) | Publish current live bars with reason: 'resume'; stop the replay owner before resuming. |
setVisible(false) | Suspend periodic repair; returning to visible refreshes history. Live subscription ownership remains unchanged. |
destroy() | Abort owned work, detach subscriptions and stop polling. Shared feeds remain their owner’s responsibility. |
Primary status is idle, loading, ready, empty, refreshing, stale or
error. Older history has independent historyStatus: idle, loading,
error, exhausted or limited. Errors are published in state; managed methods
complete with retained bars, so a resolved promise alone does not prove success.
hasMore is true, false, or null when the provider has not established it.
Optional getBarsPage(request) returns { bars, hasMore?, nextBefore? } with an
exclusive before cursor and countBack target. Without it the controller scans
at most maxEmptyPages date windows per gesture (default 4). An empty weekend
window does not prove exhaustion. pageWindowSec defaults to the initial request
width. Reaching maxBars stops paging with limited, separately from provider
exhaustion; live appends retain the newest bars.
Repair that follows the stream (2.3.2)
pollIntervalMs re-fetches the whole load window on a clock whether or not
anything happened. The stream already says when something happened, so three
options, each off by default, let repair react to it instead:
| Option | Meaning |
|---|---|
refreshOnBarClose | true, or { delayMs, retries, retryDelayMs } (defaults 2500 ms, 2, 5000 ms). A pushed bar that opens a new bucket means the bar before it just closed: one refresh runs delayMs later, long enough for a broker’s history to have published the close, and retries while the reply still stops short of that bar. Nothing fires while the market is quiet. |
refreshOnGap | Refresh at once when a pushed bar skips whole buckets, which is what a dropped socket, a hidden tab or a sleeping machine leaves behind. The window reaches back to the last bar the stream delivered. |
refreshWindowBars | Re-fetch only this many bars back from the tail on every refresh, instead of the whole window. |
Together they cost about one small request per bar instead of two full-window
requests a minute, and a skipped bucket is repaired the moment the stream
resumes. Keep a slow pollIntervalMs as the backstop for silent drift. A
controller built without these options makes exactly the requests it always
did.
Requests and cache freshness
HistoryRequestPool deduplicates identical pending requests for the same feed
instance, limits concurrency (default 4), and schedules higher priorities first.
sharedHistoryRequests(feed) returns that feed’s pool. Each consumer has its own
signal and timeoutMs (default 15 seconds), including time spent queued. One
consumer cancelling does not cancel another; the final consumer releases the
underlying request. Custom feeds should pass req.signal into their transport.
The OpenAlgo REST adapter bounds both fetch and JSON decoding.
withBarCache supplies optional getCachedBars(req): a validated closed-bar
snapshot for immediate display, followed by authoritative refresh. Durable-store
read, write, delete or invalid-entry failures fall back to bounded memory. A warm
snapshot is not a claim that the latest candle is current. Cache reads are bounded
to 500 ms in the controller, and forming candles are never stored.
Refresh preserves observed live extrema and close; overlapping whole-bar volumes use their maximum because a REST snapshot cannot identify overlap with sampled updates. Exact tick replay and reconstruction of missed trades require upstream sequence/trade data. See cache behavior and OpenAlgo compatibility.
External study context
Set the chart identity before loading the next source:
series.setData([]);
chart.setDataContext({ symbol, exchange, interval });
series.setData(nextBars);The widget does this automatically. createTier2Indicator receives
ctx.dataContext and ctx.signal, refreshes on context changes and extends its
requested range when history is prepended. supports(ctx) can explicitly decline
unavailable data. Read indicator.dataStatus(), observe
indicator.subscribeDataStatus(fn), and invoke indicator.retryData() for
loading, ready, empty, unsupported and error states. Unsubscribe the status
listener on teardown. Live studies append through their subscription; history-only
studies refresh the tail as the source grows. No future external value is aligned
into an earlier replay candle.
The following sections cover direct series APIs for hosts that manage their own loading lifecycle.
Initial load
Create a series, then call setData with a sorted array of bars:
import { createChart } from 'openalgo-charts';
const chart = createChart(el);
const series = chart.addSeries('candlestick');
series.setData(bars);
chart.timeScale.fitContent(bars.length);setData replaces the entire series in one pass. The first call also triggers
an automatic fit so the chart shows all bars without you needing to call
fitContent yourself, but it is good practice to call it explicitly whenever
you replace data.
time is UTC seconds (a plain integer), not a Date and not milliseconds.
Pass bars sorted ascending by time; the DataLayer re-sorts on its own, but
supplying pre-sorted input is faster.
Bars use the same shape across all series types:
interface Bar {
time: number; // UTC seconds
open: number;
high: number;
low: number;
close: number;
volume?: number;
}Line and area series read only close; you can supply the same bar objects to
every series. Each bar gets a gapless logical index on the shared time axis,
so weekends, holidays, and overnight breaks collapse automatically. See
Core Concepts for how the index space works.
Incremental live updates
series.update(bar) is the hot path for live data. Pass the current
in-progress bar on each tick; the chart decides what to do based on time:
Incoming time vs last bar | Result |
|---|---|
| Newer | Appends a new bar; auto-scrolls if the viewport is already at the right edge |
| Same | Replaces the last bar in place (intra-bar tick update) |
| Older | Inserts into history or replaces an existing bar at that time |
// Intra-bar update (same time -> replaces)
series.update({ time: currentBarTime, open, high, low, close, volume });
// New bar (newer time -> appends)
series.update({ time: nextBarTime, open: close, high: close, low: close, close, volume: 0 });Auto-scroll on append only fires when the user’s viewport is already at the
right edge (rightOffset >= 0). If the user has scrolled left into history,
new bars accumulate off-screen without disturbing the view.
Contrast this with setData, which replaces the full in-memory series and can
rebuild derived indicator data. Use update for ticks; use setData when you
need to swap in a completely different dataset (timeframe change, symbol
change).
To inspect the current bar occasionally (for example, from a click handler),
read it with getData:
// getData() returns bars sorted oldest -> newest
const bars = series.getData();
const last = bars[bars.length - 1];
series.update({ ...last, close: newPrice, high: Math.max(last.high, newPrice) });getData() creates a new array. Keep the forming bar in your feed or component
state instead of calling it for every incoming tick. For bounded live history,
see Performance & Operations.
Live streaming demo
This chart streams: a timer ticks the forming candle a few times, then rolls a
new one, all through series.update. Clean the timer up on teardown so it stops
when the chart is destroyed.
View example code
const chart = lib.createChart(el);
const series = chart.addSeries('candlestick');
series.setData(lib.generateBars(1700000000, 90, 3600));
chart.timeScale.fitContent(90);
let last = series.getData().slice(-1)[0];
let t = last.time, ticks = 0, seed = 7;
const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
const id = setInterval(() => {
const move = (rnd() - 0.5) * 1.8;
if (ticks >= 6) { // roll a fresh candle
t += 3600;
last = { time: t, open: last.close, high: last.close, low: last.close, close: last.close + move };
ticks = 0;
} else { // update the forming candle in place
const c = last.close + move;
last = { time: last.time, open: last.open, high: Math.max(last.high, c), low: Math.min(last.low, c), close: c };
ticks++;
}
series.update(last);
}, 700);
// Stop the stream when the chart is torn down.
chart.on('destroy', () => clearInterval(id));
return chart;Bounded history for long-running charts
series.update does not discard old bars. For a chart that remains open for a
session, retain a fixed source window and do the full replacement only when a
new bar crosses that boundary:
const MAX_BARS = 5_000;
let retained = initialBars.slice(-MAX_BARS);
series.setData(retained);
function applyLiveBar(bar) {
const previous = retained[retained.length - 1];
if (previous?.time === bar.time) {
retained[retained.length - 1] = bar;
series.update(bar);
return;
}
retained.push(bar);
if (retained.length > MAX_BARS) {
retained = retained.slice(-MAX_BARS);
series.setData(retained);
} else {
series.update(bar);
}
}Do not run setData for forming-bar ticks. It replaces the complete series;
the example above pays that cost only when the oldest retained bar is dropped.
See Performance & Operations for indicator,
canvas, cache, and lifecycle budgets.
Building bars from a tick stream
When your WebSocket feed delivers raw trade ticks (price + quantity) rather
than completed candles, use CandleBuilder to bucket them into OHLC bars.
import { CandleBuilder } from 'openalgo-charts';
const builder = new CandleBuilder({
intervalSec: 60, // 1-minute bars
volumeMode: 'ltq-sum', // accumulate last-traded qty per tick
lateTickPolicy: 'foldIntoBar',
sessionAnchorSec: 0, // align buckets to epoch; set to a session open (UTC seconds)
// to align to the exchange instead. Independent of the
// chart's display timezone.
});
// Seed from the last historical bar so the first live tick continues it
const bars = await feed.getHistory(...);
series.setData(bars);
builder.seed(bars[bars.length - 1]);
// Feed each tick as it arrives
ws.onTick((tick) => {
// tick: { time: UTCSeconds, price: number, ltq?: number, cumDayVolume?: number }
const update = builder.onTick(tick);
if (update !== null) {
series.update(update.bar); // update.isNew === true on a bar boundary
}
});A bar the builder opened without having streamed the bar before it, a cold
start or a seed from an older bucket, carries only the ticks it saw: the trades
between the bucket’s true open and the first tick were missed. update.provisional
says so, and builder.reconcile(historyBar) adopts history’s open, the union of
the extremes and the larger volume for that bucket while keeping the close with
the ticks. Through the controller, data.pushBar(update.bar, { provisional: true })
keeps the open history holds rather than replacing it, so a repair that found
the true open is not undone by the next tick.
onTick returns null only when the lateTickPolicy is
'dropOlderThanPrevBar' and the tick predates the current bar’s open. The
CandleUpdate.isNew flag is true on the first tick of a new bucket (useful
for logging or triggering signal evaluation).
The volumeMode option controls volume accounting:
'ltq-sum'— add each tick’sltqfield (last-traded quantity).'day-delta'— diff a cumulative daily volume counter (cumDayVolume); handles intraday resets automatically.
Lazy / infinite history
When a user pans left past the oldest loaded bar, the chart fires a history loader so you can fetch and prepend older data without rebuilding the chart.
Register the loader once after creating the chart:
chart.setHistoryLoader(async () => {
const older = await myApi.getBarsBefore(oldestLoadedTime, 500);
if (older.length > 0) {
series.prependData(older);
oldestLoadedTime = older[0].time;
}
// Call this from finally in an async loader, including failures.
// Without it the loader will never fire again.
chart.historyLoadComplete();
});prependData merges the incoming bars into the existing series by time: new
timestamps are inserted and existing ones are replaced. The viewport is
preserved — the same bars stay on screen because the time-scale baseIndex
shifts to compensate for the prepended count.
You must call chart.historyLoadComplete() after every loader invocation,
whether or not new bars were returned. Omitting it latches the “loading” flag
permanently and paging stalls.
Paging is intentionally unbounded unless the host imposes a policy. A long-running live chart should fetch pages outside its retained window from the feed or an application cache, rather than continually prepending them into the same chart.
You can also listen to the 'lazy-load' event on the unified bus (for example,
to log or debounce independently of the loader function):
chart.on('lazy-load', (e) => {
console.log('paging backward from', e.from, 'to', e.to);
});See Events for the full event reference.
Live demo: infinite history
Seed the chart with 120 bars, then pan left — the loader prepends 60 more bars each time you reach the oldest edge.
View example code
const chart = lib.createChart(el);
const intervalSec = 3600;
const seedStart = 1700000000;
const bars = lib.generateBars(seedStart, 120, intervalSec);
const series = chart.addSeries('candlestick');
series.setData(bars);
chart.timeScale.fitContent(bars.length);
let pageStart = seedStart;
chart.setHistoryLoader(function () {
const olderStart = pageStart - 60 * intervalSec;
const olderBars = lib.generateBars(olderStart, 60, intervalSec);
series.prependData(olderBars);
pageStart = olderStart;
chart.historyLoadComplete();
});
return chart;Large datasets: conflation
When zoomed far out, many bars collapse to sub-pixel widths. Enable conflation to let the renderer merge groups of bars into a single OHLC-preserving drawn bar, which keeps rendering fast and keeps the candle shape meaningful:
const chart = createChart(el, {
conflate: true,
conflationFactor: 1, // default; raise to 2-4 for more aggressive merging
});Conflation is OHLC-preserving — open comes from the first bar in the
group, close from the last, high and low from the extremes, and volume is
summed. It kicks in automatically when barSpacing * devicePixelRatio falls
below the pixel threshold and is transparent to your data layer; the underlying
bars are unchanged.
The same merge helpers are also available for manual use:
import { conflateBars, mergeBars } from 'openalgo-charts';
const merged = conflateBars(bars, 5); // merge every 5 bars into one
const single = mergeBars(bars.slice(0, 5)); // merge a specific groupSee Core Concepts for the gapless index model that makes conflation align correctly.
Conflation reduces rendering work for zoomed-out data. It does not reduce the memory retained by the source series or indicator outputs. Pair it with a history window for sustained sessions.
Reconnect and gap recovery
When a WebSocket feed drops and reconnects, re-fetch the missed window and pass
it to setData or prependData. The DataLayer merges by time and
deduplicates: bars whose timestamps already exist in the series are replaced
(not duplicated), and genuinely new bars are inserted in sorted order. You do
not need to compute the exact gap — overlapping re-fetched windows are safe.
ws.on('reconnect', async () => {
// Fetch everything since the last known bar, with some overlap for safety
const missed = await myApi.getBars({ from: lastKnownTime - 60, to: Date.now() / 1000 });
// DataLayer upserts by time -- no duplicates, no manual de-overlap needed
series.prependData(missed);
});If the gap is large enough that you want to reload the full visible range
instead, use setData — it is a full replace and runs the same O(n log n)
sort that the initial load does.
Empty state
setData([]) is valid and renders an empty chart. You can add data later with
another setData call or a sequence of update calls.
const series = chart.addSeries('candlestick');
series.setData([]); // chart is blank; axes are visible
// Later, once data arrives:
series.setData(historicalBars);
chart.timeScale.fitContent(historicalBars.length);This is useful for showing a chart shell while an async fetch is in flight.