In Chartbreaker, data is added in the form of timeseries via the Controller.addTimeSeries method.
The method expects an alphanumeric identifer to reference a specific timeseries.
This identifier is then used to create an instance of a so-called 'data loader' (a class extending the abstract DataLoader class).
To add a timeseries backed by the Custom data loader to the chart, simply call:
controller.addTimeSeries('Custom(AAPL,bid)');
All arguments in the identifier (e.g. "AAPL" and "bid") are passed to the constructor of the data loader class as args.params.
Of course, the data loader must first be created before it can be used by Chartbreaker. The following example shows a loader for a fictional quote API. It loads historic data, forwards realtime updates and exposes the quote source as an option, so users can switch between bid, ask and last prices.
import {
DataLoader,
ObjectEOptionFlags,
RecoverableChartbreakerError,
utils,
type DataLoaderTArgs,
type DataLoaderTMetaData,
type DataLoaderTPush,
type OHLCVData,
type TContext,
} from '@stock3/charting-bundle-chartbreaker';
type QuoteSource = 'bid' | 'ask' | 'last';
const QUOTE_SOURCES: Record<QuoteSource, string> = { bid: 'Bid', ask: 'Ask', last: 'Last' };
function isQuoteSource(value: unknown): value is QuoteSource {
return typeof value === 'string' && Object.hasOwn(QUOTE_SOURCES, value);
}
export default class CustomLoader extends DataLoader {
resolution: number;
symbol: string;
quoteSource: QuoteSource;
subscription: { unbind: () => void } | null = null;
/**
* push source shared by all instances; replaced by the application via `setPush`
*/
static push(id: string, cb: (data: DataLoaderTPush) => void): { unbind: () => void } {
return { unbind() {} };
}
/**
* called by the application (not by Chartbreaker) to provide a realtime data source
*/
static override setPush(maxGapDuration: number, push: (id: string, cb: (data: DataLoaderTPush) => void) => { unbind: () => void }) {
CustomLoader.push = push;
}
/*
* args.resolution (time per data point in milliseconds, 0 for ticks) is supplied by Chartbreaker
* args.params contains the arguments from the identifier, e.g. ['AAPL', 'bid'] for "Custom(AAPL,bid)"
*/
constructor(args: DataLoaderTArgs) {
super(args);
const { resolution, params: [ symbol, quoteSource ] } = args;
if (quoteSource !== undefined && !isQuoteSource(quoteSource)) {
throw new Error(`[CustomLoader] invalid quoteSource '${quoteSource}'`);
}
this.resolution = resolution;
this.symbol = `${symbol}`;
this.quoteSource = quoteSource ?? 'last'; // default if the identifier does not specify a quote source
}
override async load(context: TContext): ReturnType<DataLoader['load']> {
let response: { name: string, precision: number, candles: OHLCVData[], tick: number };
try {
const res = await utils.fetch(`https://api.example.com/history/${this.symbol}?qs=${this.quoteSource}&res=${this.resolution}`);
response = await res.json();
} catch {
// recoverable errors allow the user to retry loading
return { err: new RecoverableChartbreakerError('CustomLoader::LoadError', '__i18n__.loadingFailed') };
}
const metaData: DataLoaderTMetaData = {
/*
* To support conversions between resolutions, a timezone, start and
* end time (in milliseconds since beginning of a day) must be provided.
*/
timezone: 'Europe/Berlin',
startTime: 3600 * 9 * 1000, // 09:00
endTime: 3600 * 18 * 1000, // 18:00
name: response.name, // display name of the timeseries
hasVolume: this.quoteSource === 'last',
precision: response.precision, // number of decimal places
/*
* options are exposed on the timeseries object; when the user changes the value of an option,
* `mapOptions` is called with the new value and the timeseries is replaced by the returned identifier
*/
options: {
quoteSource: {
flags: ObjectEOptionFlags.EXPOSED,
name: 'Quote source',
type: QUOTE_SOURCES, // map of allowed values to their labels
value: this.quoteSource,
},
},
// additional information, available via `timeseries.getProperties()` and passed on to generators
properties: {
symbol: this.symbol,
},
};
// forward realtime updates; pushes are only accepted after the historic data has been returned
this.subscription = CustomLoader.push(`${this.symbol}:${this.quoteSource}`, (data) => {
this.emit('push', { data });
});
/*
* Each entry in `values` must contain 6 values in this particular order:
* timestamp in milliseconds, close, open, high, low, volume
* Data _should_ be pre-aggregated for the requested resolution
* If it is not, Chartbreaker will try to automatically group/merge it
*
* `tick` is the sequence number of the most recent value; following pushes continue from there
*/
return { metaData, values: response.candles, tick: response.tick };
}
/**
* called once the timeseries is no longer used; unregister any active push updates or other subscriptions here
*/
override destroy() {
this.subscription?.unbind();
this.subscription = null;
return true;
}
/**
* map options to the unique identifier of this time series
*
* without options, the identifier including all defaults is returned, e.g. "Custom(AAPL)" => "Custom(AAPL,last)"
* this makes sure a serialized chart always looks the same, even if the defaults change in the future
*/
mapOptions(options?: { [key: string]: string | number }) {
const quoteSource = options?.quoteSource ?? this.quoteSource;
return `Custom(${this.symbol},${quoteSource})`;
}
/**
* allows generators to subscribe to related push data (e.g. bid/ask prices of the same symbol)
*/
subscribePush(identifier: string, cb: (data: DataLoaderTPush) => void): { unbind: () => void } {
return CustomLoader.push(identifier, cb);
}
/**
* called when the user retries after a RecoverableChartbreakerError was emitted via the 'push'-event
* return true if a recovery was attempted, false otherwise
*/
recoverPush(): boolean {
return false;
}
}
The next step is to register the loader as a plugin with Chartbreaker. This is done via setClazz; the method receives an object with all required options, like supported resolutions and the type identifier of the loader. 'type' defines the identifier used for the time series, which can then be added to a chart via the Controller (see previous section).
import { setClazz, ESpans, DataTClazzDescriptorFlags } from '@stock3/charting-bundle-chartbreaker';
setClazz({
clazzType: 'loader',
type: 'Custom',
resolutions: [ ESpans.MINUTE, ESpans.DAY ],
flags: DataTClazzDescriptorFlags.NONE,
clazz: CustomLoader,
});
The resolution represents the timespan of a single data point. For each supported resolution and timeseries parameters, a seperate instance of the loader is created. Chartbreaker will automatically try to group data on the fly for resolutions that are not supported by a loader. E.g., if you want to display a 1-hour chart, but the loader only supports a resolution of 1 minute (such as in this example), Chartbreaker will simply create a 1-minute loader, and then merge the resultant data.
The DataLoader class extends an EventEmitter and provides a 'push'-event. This event
can be used to provide realtime data updates to chart. How this data is retrieved is up to the loader (e.g. WebSocket or polling).
In the example above, the realtime source is provided by the application via the static setPush method, so multiple loaders can share a single connection:
CustomLoader.setPush(0, (id, cb) => {
// e.g. subscribe to "AAPL:bid" on a shared WebSocket connection
const handler = (data: DataLoaderTPush) => cb(data);
socket.subscribe(id, handler);
return { unbind: () => socket.unsubscribe(id, handler) };
});
The first argument (maxGapDuration) is not used by the example; It can be used to tolerate small gaps between push updates.
Each push update has the following format:
this.emit('push', {
data: {
time: Date.now(), // unix timestamp in milliseconds
quote: 123.45, // new data value (alternatively: open, high, low, close for aggregated values)
tick: 1234, // sequential tick number (optional, recommended to detect missing updates)
volume: 0, // volume for this tick (optional, will be summed up automatically)
prev: 122.0, // close of previous day (optional, just needs to be provided once for displaying changes)
},
});
The 'push'-event is also used to report the state of the push updates: { data: true } signals that they are currently catching up, { data: false } or { data: null } that they are in sync (again).
If an error occurs, { err } with a ChartbreakerError can be emitted instead. For a RecoverableChartbreakerError, Chartbreaker may later call recoverPush to trigger a recovery.
If the previously loaded data becomes invalid, the loader can emit the 'invalidate'-event. Chartbreaker then reloads all charts displaying this timeseries.