Extending the chart
Important: Please note that this feature is currently in preview
In general, the chart has not been designed to be extended by 3rd parties. However, one aspect of the chart can be extended: the list of studies (and fields).
To signal to the chart that you will extend it with studies, you should add the following to the chart config during the initialization, described in the Quick start.
{ const feed = initFeed(ExampleFeed, { throttleMillis: 250, apiKey: "<YOUR_API_KEY>", extensions: { studyProvider: new StudyProvider(), // <-- this is the important part }, });}The StudyProvider class is a class you will have to implement yourself. It should conform to the StudyProvider interface, which is defined as follows:
export type StudyProvider = { getStudies(): StudyModel[]; getFields(): FieldModel[]; factory(studyId: string, options: StudyOptions, innerSeries: TimeSeries): StudySeries | null;};The getStudies and getFields methods return the extension studies’ and fields’ taxonomies which conform to the schema of the the respective taxonomies as described in detail in the Study taxonomy and Fields taxonomy sections (the StudyModel and FieldModel types are described in the TypeScript declarations file barchart.chart.d.ts, as are all other types mentioned in this document).
The fields and the studies are integrated very early during the initialization and can be used even in the default template for the charts. This was mentioned before but it’s worth repeating: the study and field ids must be unique for all charts - please ensure that your ids don’t clash with the existing ones; the simplest way to do this is to use a prefix for your custom studies and fields (typically an abbreviation of your company name).
The factory method is called when the user adds a study to the chart and the studyId doesn’t match any of the built-in studies. It should return an object implementing StudySeries interface described below, or null if the studyId does not match (note that this should not happen). NB: the easiest way to implement the extension study is to derive from the StudySeries class which is exported and conforms to the interface shown below:
export type StudySeries = StudyTimeSeries & StudyOptions & { new (options: StudyOptions, ...innerSeries: TimeSeries[]): StudySeries; calculateAt?(pos: number): CalculationResult | null; wrapInner(field: Field, index: number): FieldToValuesMapping; getWrappers?(): FieldToValuesMapping[]; };(the StudyTimeSeries and StudyOptions will be described later on)
The study is basically a computation based on the “basis” of the study, which is currently limited to the main plot’s timeseries data. Other scenarios are possible but aren’t currently supported in the extension scenario.
The chart handles all of the complex issues like recomputing the study in an optimal way, ensuring that when the main timeseries is prepended with a chunk or updated through the realtime stream, the study is also updated in the same way.
The key methods are calculateAt and getWrappers (which uses getWrappers). NB: there is no simple way in the type system to indicate that you need to provide one or the other (or both) but not neither (more details follow).
The example code provided with the SDK and the npm package contains two different implementations of the same study: the ChandeForecastOscillatorSeries1 uses the “compact” approach where a DSL-like combination of existing studies plus a few simple transformations produces the desired result; the other, ChandeForecastOscillatorSeries2 demonstrates an “extended” approach where every detail of the study is implemented explicitly. Both studies produce exactly the same result.
Simplified approach using built-in studies
The entire ChandeForecastOscillatorSeries1 study is presented here for reference:
class ChandeForecastOscillatorSeries1 extends StudySeries { constructor(options, innerSeries) { super(options, innerSeries); const linRegSeries = linReg(innerSeries, this.period, this.source); const that = this; // chart replaces `this` inside `convertSeries` with its return value const pluckField = function (pos) { return { [that.source.id]: this.baseVal(that.source, pos), }; }; const sourceSeries = convertSeries(this.source, pluckField, innerSeries); const oscFunc = (_pos, src, linReg) => (null === src ? null : (100 * (src - linReg)) / src); const oscSeries = mapSeries(this.target, oscFunc, sourceSeries, linRegSeries); this.addInner(oscSeries); } // no need for `calculateAt`, just need to expose the computed value from inner series getWrappers() { return [this.wrapInner(this.target, 1)]; }}As usual, you should start by deriving from the StudySeries which provides quite a bit of functionality. The constructor takes two arguments: the options and the series (the basis) the study is based on. In practice, the innerSeries will always be the main timeseries of the chart, but the code doesn’t make any assumptions about it - as long as this is a TimeSeries object, it will work. This is important because it allows to nest studies (a study can be based on another study), which is very often the case.
The options conforms to the StudyOptions interface:
export type StudyOptions = { source: Field | null; inputs: { [key: string]: FieldValue; }; outFields?: Field[]; target?: Field; aggregation?: Aggregation;};The source is the field used for the computation, if any; some studies implicitly use a specific field like Close or Volume and some use multiple fields. The implementer of the study knows inherently which fields are used and how.
The inputs is a “transposed” variation of the inputs property in the study taxonomy (for easier consumption in the code).
For example, the input
{ "name": "Period", "value": 20}translates into
{ Period: 20;}The outFields is a list of fields the study produces (should always be at least one).
The target is the field the study produces, assuming there’s only one output field (IOW, the outFields has a single field).
The aggregation is the aggregation of the chart.
For the custom implementation, only inputs and optionally source and target are interesting - the rest is used by the chart interally.
The rest of the study is basically a combination of an existing study and a few simple transformations. The linReg study is a built-in study (called Moving Linear Regression) which calculates the linear regression of a given series for the last Period bars. Like any other study, it produces a TimeSeries object, which can be used as input/basis for other studies.
The sourceSeries is a simple transformation of the innerSeries (the main timeseries, the basis) which extracts a single field from it. It uses the convertSeries helper which is basically a stand-alone calculateAt method (described below). We do this because the basis consists of multiple fields (typically Open, High, Low, Close and Volume) and we only need one of them. Important: the this inside the pluckField function is replaced with the instance of the series returned by the convertSeries call; it’s the study series wrapping the last parameter of the convertSeries call, not the current this. It’s also why we’re not making the pluckField an arrow function.
The mapSeries is a built-in helper function with the following signature:
function mapSeries(target: Field, calculateFunc: Function, ...innerSeries: TimeSeries[]);It takes N series as an input and returns a new series which is the result of applying the calculateFunc to the values of all of them at a given position (so a function with N parameters); when given 3 series, it will provide 3 values to the calculateFunc (one from each series), therefore it assumes that each series has a single value at the given position.
The calculateFunc is called for every position in the series and is expected to return a single value (or null if the value cannot be computed).
Finally, the result of the mapSeries is added as an inner series of the study (stored internally) because we need its values to be shown on the chart.
The getWrappers method is used to expose the computed value from the inner series as the value of the study. Since the computation is defined in a “declarative” manner as described above, there is no need to implement the calculateAt method. The wrapping is performed using the wrapInner method which accepts a field and an index (the index 0 is the innerSeries given in the constructor aka the basis, the rest are indices of series added through the addInner method).
The chart SDK exports several helper methods/studies: almost all well-known moving averages, true range, typical value, rate of change, standard deviation etc. These can be used to implement the custom studies in a very concise manner.
The base class StudySeries provides several “shortcuts” for the important concepts or often used inputs; for example the source and target fields (if applicable) are available as this.source and this.target respectively; also, since lots of studies operate on a given period (number of bars), the this.period is available as well (alternatively, you can use the this.inputs.Period, assuming you did have one parameter named Period). The this.baseVal is a helper function which returns the value of the given field at the given position (the pos argument) of the base series (the innerSeries in the constructor, or the basis).
Whether you prefer this approach or the explicit one is mainly a matter of personal preference. The explicit approach is more verbose but is sometimes necessary when the computation is more involved or impossible to express using the built-in studies; sometimes the computation is so convoluted that explicit approach is the only way to go.
Explicit approach with complete implementation
The ChandeForecastOscillatorSeries2 won’t be reproduced here in its entirety, only the most important parts will be shown. The full code is available in the example project in the SDK.
class ChandeForecastOscillatorSeries2 extends StudySeries { constructor(options, innerSeries) { super(options, innerSeries); // prepare the computation, if needed } calculateAt(pos) { let cfosc = null; // the result is null by default if (this.atLeast(pos, this.inputs.Period)) { // if we have at least period bars... let lastN = this.past(pos, per, this.source); // ...get the last N bars... if (notEmpty(lastN)) { //... check that all are != null const mlr = // ... some computation here const src = this.baseVal(this.source, pos); // pluck the source value from basis cfosc = (100 * (src - mlr)) / src; } } return { [this.target.id]: cfosc, }; }}The logic is very similar. The library will call your calculateAt method when applicable, for each pos (position) in the basis series (this can be another series, depending on yet another series - the library will call compute for all the series we depend on in the proper order and will not call them more than once). In other words, the pos is an index into the series array. Quite often, for the first M values (for a period of M), the computation cannot be performed because there’s not enough data. In this case, the method should return null.
The calculateAt method should return an object with the computed values for each output field (the keys are the field ids, the values are the computed values). If the computation cannot be performed, the method should return null.
Note that the return value is often multiple values, hence an object with field ids as keys.
There’s nothing else to implement, the rest is handled by the library.
You can implement the getWrappers method as well, but it’s not necessary in this case. The case to use both methods would be some kind of a “hybrid” study which uses the built-in studies for some parts of the computation and the explicit approach for the rest.
There are a few more interesting methods present in the StudyTimeSeries type, please check the TypeScript declaration file.
Finally, the methods declared in the CalculableTimeSeries (one of the base types) aren’t as interesting, they are automatically called by the chart, you can safely ignore them.