DocumentationData Loading

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.

live
Rendering live chart…
Simulated NIFTY around 23,800 with irregular candles, changing volume and live price ticks. Fail a refresh, then use Retry on the chart. Pause holds the display while live data continues; it does not start historical replay.
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.

OperationBehavior
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:

OptionMeaning
refreshOnBarClosetrue, 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.
refreshOnGapRefresh 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.
refreshWindowBarsRe-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 barResult
NewerAppends a new bar; auto-scrolls if the viewport is already at the right edge
SameReplaces the last bar in place (intra-bar tick update)
OlderInserts 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.

live
Rendering live chart…
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’s ltq field (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.

live
Rendering live chart…
Pan left to trigger history paging. Each page prepends 60 more hourly bars while keeping the viewport stable.
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 group

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