Skip to main content

Creating Custom Studies

A custom study is an indicator of your own, which a SciTrader chart treats like the built-in ones. Once written, you can:

This page walks through a complete custom study, a Z-Score oscillator, which SciTrader doesn't have built in. The z-score is how many standard deviations the close is from its mean over the last period candles. Above +2, price is stretched high; below −2, stretched low. It draws in a pane of its own, with guide lines at +2, 0 and −2.

The TypeScript and React examples below run it on live Coinbase data.

What a Study Implements​

A study extends StudyBase<T>, where T is the type of value it reads (number for prices). The chart calls its methods in this order when it is attached:

StepMethodWhat it does
1createInputOutputIdsSays which data it reads (inputIds) and writes (outputIds)
2createDataSeriesCreates the SciChart data series that hold its values for drawing
3createAxesReturns the Y axes of its pane. An overlay returns none and uses the price axis
4createRenderableSeriesReturns the series that draw it, each bound to a data series
5onDataChangedCalculates its values whenever the input data changes, from the first load on
6onInitCompleteOptional: anything to add once it has drawn, such as guide lines

The other methods are optional, and make it a full citizen of the chart:

MethodWhat it adds
getRequiredWarmupExtra candles the chart loads to the left, so the line starts at the left edge
configureDescriptor, static getOptionsFromDescriptorIts settings, for the settings form and for saving
supportsLiveApply, onDescriptorAppliedApplying edited settings in place, with no reload
onThemeChangedRecolouring what isn't a setting, such as guide lines, when the theme changes

The steps below build ZScoreStudy one piece at a time. The complete code is in the examples.

Step 1: A Study Type​

Every study has a type: the key it is registered with, created by and saved under. Built-in studies use EStudyType. A custom study uses a string of its own:

const Z_SCORE = "ZScore" as EStudyType;

Step 2: The Study's Options​

Its options are its inputs and its styles. Extend IStudyOptions, which carries the pane placement and the colours per theme that every study saves:

interface IZScoreOptions extends IStudyOptions {
/** Candles in the rolling window. Default 20 */
period?: number;
/** Where the guide lines go, in standard deviations from the mean. Default 2 */
threshold?: number;
/** Line colour. Default "#26c6da" */
stroke?: string;
/** Line thickness in pixels. Default 2 */
strokeThickness?: number;
/** "solid", "dashed" or "dotted". Default "solid" */
strokeStyle?: string;
}

Step 3: The Study Class and Its Pane​

Extend StudyBase<number> and pass the base constructor three things: the type, the pane to draw in, and the options.

  • An oscillator passes PaneId.createUnique(type), for a new pane each time one is added.
  • An overlay passes PaneId.DEFAULT, the price pane.

The user can still move it with the paneId option. paneSizeConfig sets the pane's height relative to the others; the price pane is { grow: 3 }.

/**
* Z-Score: how many standard deviations the close is from its `period`-candle mean.
* Above `+threshold` price is stretched high; below `-threshold`, stretched low.
* An oscillator, so it draws in a pane of its own, with guide lines at +threshold, 0 and -threshold.
*/
class ZScoreStudy extends StudyBase<number> {
// Pane size relative to the others (the price pane is 3)
public readonly paneSizeConfig = { grow: 1 };

public period = 20;
public threshold = 2;
public stroke = "#26c6da";
public strokeThickness = 2;
public strokeStyle = "solid";

private inputId: DataSourceId; // the close prices it reads
private outputId: DataSourceId; // the z-scores it writes, which the legend shows
private series: XyDataSeries;
private line: FastLineRenderableSeries;
private guideLines: HorizontalLineAnnotation[] = [];

constructor(options?: IZScoreOptions) {
// PaneId.createUnique: a new pane for every Z-Score added.
// An overlay would pass PaneId.DEFAULT to share the price pane instead
super(Z_SCORE, PaneId.createUnique(Z_SCORE), options);
this.period = options?.period ?? this.period;
this.threshold = options?.threshold ?? this.threshold;
this.stroke = options?.stroke ?? this.stroke;
this.strokeThickness = options?.strokeThickness ?? this.strokeThickness;
this.strokeStyle = options?.strokeStyle ?? this.strokeStyle;
}

Warm-up. A rolling-window indicator has no value for its first period - 1 candles. getRequiredWarmup tells the chart, which loads that many extra candles to the left of the visible period, so the line starts at the left edge:

public getRequiredWarmup(): number {
return this.period;
}

Step 4: Inputs and Outputs, Data Series, Axes and Series​

The chart calls these four in order as the study attaches.

Inputs and outputs. Data on a SciTrader chart lives in its data manager, as columns of values index-aligned with the candles, each named by a DataSourceId. getInputSourceId(EDataSourceStringId.CLOSE) is the default feed's close column; OPEN, HIGH, LOW and VOLUME work the same way. createOutputSourceId makes a column for the study's own values, which the legend shows under the name given:

public createInputOutputIds(): void {
this.inputId = this.getInputSourceId(EDataSourceStringId.CLOSE);
this.inputIds.push(this.inputId);
this.outputId = this.createOutputSourceId("Z", this.inputId); // "Z" is its legend name
this.outputIds.push(this.outputId);
}

To build an indicator on another study's output, such as a moving average of the RSI, read that study's outputIds instead.

The data series holds what is drawn:

public createDataSeries(): void {
this.series = new XyDataSeries(this.wasmContext, { dataSeriesName: `Z-Score ${this.period}` });
}

The axes of its pane. Spread the chart's axisDefaults first, so the axis looks like the chart's other axes and follows its theme. An overlay returns []:

public createAxes(): AxisBase2D[] {
return [
new NumericAxis(this.wasmContext, {
...this.parentFinanceChart.axisDefaults,
axisAlignment: EAxisAlignment.Right,
autoRange: EAutoRange.Always,
growBy: new NumberRange(0.1, 0.1),
labelPrecision: 1,
maxAutoTicks: 4,
}),
];
}

The renderable series, each bound to a data series. Any SciChart renderable series works: lines, bands, columns, mountains, scatter. STROKE_STYLE_TO_DASH_ARRAY turns a line-style setting ("solid", "dashed" or "dotted") into SciChart's strokeDashArray:

public createRenderableSeries(): FastLineRenderableSeries[] {
this.line = new FastLineRenderableSeries(this.wasmContext, {
stroke: this.stroke,
strokeThickness: this.strokeThickness,
strokeDashArray: STROKE_STYLE_TO_DASH_ARRAY[this.strokeStyle],
});
this.line.dataSeries = this.series;
return [this.line];
}

Step 5: Calculating the Values and Keeping Them Live​

onDataChanged is called with the first load and every change after it. The update says what changed, as an EDataUpdateType:

EDataUpdateTypeWhenWhat the Z-Score does
ReplaceThe first load, and a new symbol, interval or periodRecalculates everything
InsertOlder candles loaded as the user scrolls backRecalculates everything
EvictCandles dropped from either end to save memoryRecalculates everything
AppendA new candle opensRecalculates everything
UpdateA live tick changes the latest candleRecalculates that one value

Recalculating everything is simplest, and fast enough for a few thousand candles; a live tick, which can arrive many times a second, updates just the last value. Write the values to both the data series, which draws them, and the output column, which the legend reads. Keep the output index-aligned with the candles, with NaN where there is no value yet:

// (5) Step 5: keep up with the data. Called with the first load, history loaded as the user scrolls back
// (Insert), candles dropped to save memory (Evict), each new candle (Append) and each live tick (Update)
protected onDataChanged(update: TDataUpdate<number>): void {
const change = update[this.inputId.id];
const closes = this.dataManager.getValues<number>(this.inputId) ?? [];
if (change?.updateType === EDataUpdateType.Update && change.startIndex < this.series.count()) {
// A live tick changes the last candle's close: recompute that one value only
const i = change.startIndex;
const z = this.zScoreAt(closes, i);
this.series.update(i, z);
this.dataManager.updateValues([this.outputId], [z], i);
return;
}
this.recalculate(closes);
}

// Recompute every value. The output is index-aligned with the candles, with NaN for the warm-up,
// so the legend reads the value of the candle under the cursor
private recalculate(closes: number[]) {
const zScores = closes.map((_, i) => this.zScoreAt(closes, i));
this.series.clear();
this.series.appendRange(this.dataManager.xValues, zScores);
this.dataManager.replaceValues([this.outputId], [zScores]);
}

private zScoreAt(closes: number[], i: number): number {
if (i < this.period - 1) return NaN;
const window = closes.slice(i - this.period + 1, i + 1);
const mean = window.reduce((sum, c) => sum + c, 0) / this.period;
const sd = Math.sqrt(window.reduce((sum, c) => sum + (c - mean) ** 2, 0) / this.period);
return sd === 0 ? 0 : (closes[i] - mean) / sd;
}

Step 6: Guide Lines and Theme Colours​

Add guide lines and other annotations in onInitComplete, once the pane exists, with addReferenceAnnotation. It removes them when the study is detached.

warning

Don't add a study's annotations with parentSurface.modifierAnnotations.add directly. An annotation left on the chart after its study is detached outlives the study's axis, and crashes the chart's render loop. addReferenceAnnotation removes them for you.

Colours that are settings, like the line colour, move between themes by themselves. Recolour anything else in onThemeChanged. this.theme is the chart's theme, and theme.chart.referenceLine is its guide-line colour:

// Step 6. Guide lines, once the pane exists. addReferenceAnnotation removes them when the study
// is detached; an annotation left behind outlives its axis and breaks the chart
protected onInitComplete(): void {
this.guideLines = [this.threshold, 0, -this.threshold].map(
(y) =>
new HorizontalLineAnnotation({
y1: y,
stroke: this.theme.chart.referenceLine, // the chart theme's guide-line colour
strokeThickness: 1,
strokeDashArray: [4, 4],
showLabel: false,
})
);
this.addReferenceAnnotation(...this.guideLines);
}

// Colours that aren't settings, like the guide lines, follow the theme here.
// The line colour is a setting, so StudyBase moves it between themes for you
protected onThemeChanged(theme: ISciTraderTheme): void {
this.guideLines.forEach((line) => (line.stroke = theme.chart.referenceLine));
}

Step 7: Settings: the Descriptor​

A study's settings are its descriptor (see Study Settings Are a Descriptor). Declare them in configureDescriptor, one control each, in the INPUTS or STYLES section. The descriptor classes are:

ClassSettings field
NumberDescriptor(name, label, value)A number box
TextDescriptor(name, label, value)A text box
ColorDescriptor(name, label, value)A colour picker, with opacity
SelectDescriptor(name, label, value, options)A dropdown of { value, label } options
StrokeTypeDescriptor(name, label, value)A solid, dashed or dotted line-style dropdown
GradientDescriptor(name, label, stops)A gradient editor

The base class calls configureDescriptor every time the descriptor is read, so the values are always current. getOptionsFromDescriptor turns settings back into options; read controls with ?. and a default, so settings saved by an older version of the study still load:

// Step 7. The settings form. Each control has a name, a label and its current value, in an
// "inputs" (what it calculates) or "styles" (how it looks) section. StudySettingsForm and
// StudyList draw the form from these, and toJSON saves them
protected configureDescriptor(): void {
this.addDescriptor(EDescriptorSectionName.INPUTS, new NumberDescriptor("period", "Period", this.period));
this.addDescriptor(EDescriptorSectionName.INPUTS, new NumberDescriptor("threshold", "Threshold (σ)", this.threshold));
this.addDescriptor(EDescriptorSectionName.STYLES, new ColorDescriptor("strokeColor", "Line colour", this.stroke));
this.addDescriptor(EDescriptorSectionName.STYLES, new NumberDescriptor("strokeThickness", "Line thickness", this.strokeThickness));
this.addDescriptor(EDescriptorSectionName.STYLES, new StrokeTypeDescriptor("strokeStyle", "Line style", this.strokeStyle));
}

// The settings back to options: applyDescriptor calls this, and so does the registration below
// when a saved chart is restored. Optional chaining, so settings saved by an older version still load
public static getOptionsFromDescriptor(descriptor: TStudyDescriptor): IZScoreOptions {
const inputs = descriptor.inputs.controls;
const styles = descriptor.styles.controls;
return {
period: Number(inputs.period?.default ?? 20),
threshold: Number(inputs.threshold?.default ?? 2),
stroke: String(styles.strokeColor?.default ?? "#26c6da"),
strokeThickness: Number(styles.strokeThickness?.default ?? 2),
strokeStyle: String(styles.strokeStyle?.default ?? "solid"),
};
}

Step 8: Applying Edited Settings in Place​

When the user edits a setting, the form calls applyDescriptor. StudyBase turns the descriptor into options with your getOptionsFromDescriptor and passes them to onDescriptorApplied. Return true from supportsLiveApply, and update the study there: restyle for a style change, recalculate for an input change.

Without it, StudyList removes the study and adds a new one with the new settings, which works but redraws the pane.

// Step 8. Apply edited settings in place, with no reload. Without this (supportsLiveApply false),
// StudyList rebuilds the study through studyProvider instead
public get supportsLiveApply(): boolean {
return true;
}

protected onDescriptorApplied(options: IZScoreOptions): void {
this.stroke = this.line.stroke = options.stroke;
this.strokeThickness = this.line.strokeThickness = options.strokeThickness;
this.strokeStyle = options.strokeStyle;
this.line.strokeDashArray = STROKE_STYLE_TO_DASH_ARRAY[options.strokeStyle];
if (options.threshold !== this.threshold) {
this.threshold = options.threshold;
[this.guideLines[0].y1, this.guideLines[2].y1] = [this.threshold, -this.threshold];
}
if (options.period > 1 && options.period !== this.period) {
this.period = options.period;
this.series.dataSeriesName = `Z-Score ${this.period}`;
this.recalculate(this.dataManager.getValues<number>(this.inputId) ?? []);
}
}
}

Step 9: Registering the Study​

Register the study with studyProvider, once, at app startup. Then:

  • studyProvider.getStudy("ZScore") creates one, e.g. for a study picker;
  • SciFinanceChartPanel.fromJSON can restore it from a saved chart, settings included.

createStudy is passed the saved settings, if there are any, and the saved options:

// Step 9. Register it, once, at app startup: studyProvider.getStudy("ZScore") can then create it
// (for a study picker), and fromJSON can restore it from a saved chart
studyProvider.registerStudy(Z_SCORE, {
type: Z_SCORE,
name: "Z-Score",
description: "Standard deviations from the mean",
createStudy: (descriptor, options) =>
new ZScoreStudy({ ...options, ...(descriptor ? ZScoreStudy.getOptionsFromDescriptor(descriptor) : {}) }),
});

Using it is then the same as using a built-in study:

// When the chart is created
studies: [new OHLCStudy(), new ZScoreStudy({ period: 20, threshold: 2 })],

// At runtime, by type
await financeChartPanel.financeApi.attachStudy(studyProvider.getStudy(Z_SCORE));

To add it to a picker built on STUDY_MENU_ITEMS (see Building a Technical Indicator Picker), add an item for it:

const items = [
...STUDY_MENU_ITEMS,
{ type: Z_SCORE, label: "Z-Score", description: "Standard deviations from the mean", category: EStudyCategory.Momentum },
];

TypeScript Example: A Custom Z-Score Study​

A BTC-USD chart from Coinbase with a 20-candle Z-Score pane.

  • Studies on the right is a StudyList. Click Z-Score to edit its settings, drawn from its descriptor: change the period, threshold, colour or line style, and the pane updates in place.
  • Add a Z-Score adds another, by type, through studyProvider.

The code for this example is found below.

import "scichart-scitrader/scitrader.css";
import {
AxisBase2D,
EAutoRange,
EAxisAlignment,
FastLineRenderableSeries,
HorizontalLineAnnotation,
NumberRange,
NumericAxis,
XyDataSeries,
} from "scichart";
import {
CandleInterval,
CoinbaseExchange,
ColorDescriptor,
DataSourceId,
defaultExchangeProvider,
EDataSourceStringId,
EDataUpdateType,
EDescriptorSectionName,
EStudyType,
idbPriceStore,
ISciTraderTheme,
IStudyOptions,
NumberDescriptor,
OHLCStudy,
PaneId,
SciFinanceChartPanel,
STROKE_STYLE_TO_DASH_ARRAY,
StrokeTypeDescriptor,
StudyBase,
StudyList,
studyProvider,
TDataUpdate,
TStudyDescriptor,
VolumeStudy,
} from "scichart-scitrader";

// This example expects this HTML:
// <div style="display: flex; flex-direction: column; height: 100%">
// <div id="toolbar"></div>
// <div style="display: flex; flex: 1; min-height: 0">
// <div id="chart" style="flex: 1; min-width: 0"></div>
// <div id="studies" style="width: 320px; overflow-y: auto"></div>
// </div>
// </div>

// ---------------------------------------------------------------------------------------------
// Step 1. A study type: the key it is registered and saved under.
// IStudy.type is typed as the built-in EStudyType, so a custom type needs a cast for now
// ---------------------------------------------------------------------------------------------
const Z_SCORE = "ZScore" as EStudyType;

// Step 2. The study's options: its inputs (period, threshold) and styles (line colour, thickness, style).
// Extend IStudyOptions, which carries pane placement and the per-theme colours saved with the chart
interface IZScoreOptions extends IStudyOptions {
/** Candles in the rolling window. Default 20 */
period?: number;
/** Where the guide lines go, in standard deviations from the mean. Default 2 */
threshold?: number;
/** Line colour. Default "#26c6da" */
stroke?: string;
/** Line thickness in pixels. Default 2 */
strokeThickness?: number;
/** "solid", "dashed" or "dotted". Default "solid" */
strokeStyle?: string;
}

/**
* Z-Score: how many standard deviations the close is from its `period`-candle mean.
* Above `+threshold` price is stretched high; below `-threshold`, stretched low.
* An oscillator, so it draws in a pane of its own, with guide lines at +threshold, 0 and -threshold.
*/
class ZScoreStudy extends StudyBase<number> {
// Pane size relative to the others (the price pane is 3)
public readonly paneSizeConfig = { grow: 1 };

public period = 20;
public threshold = 2;
public stroke = "#26c6da";
public strokeThickness = 2;
public strokeStyle = "solid";

private inputId: DataSourceId; // the close prices it reads
private outputId: DataSourceId; // the z-scores it writes, which the legend shows
private series: XyDataSeries;
private line: FastLineRenderableSeries;
private guideLines: HorizontalLineAnnotation[] = [];

constructor(options?: IZScoreOptions) {
// PaneId.createUnique: a new pane for every Z-Score added.
// An overlay would pass PaneId.DEFAULT to share the price pane instead
super(Z_SCORE, PaneId.createUnique(Z_SCORE), options);
this.period = options?.period ?? this.period;
this.threshold = options?.threshold ?? this.threshold;
this.stroke = options?.stroke ?? this.stroke;
this.strokeThickness = options?.strokeThickness ?? this.strokeThickness;
this.strokeStyle = options?.strokeStyle ?? this.strokeStyle;
}

// Step 3. Warm-up: candles needed before the first value. The chart loads this many extra
// candles to the left of the period, so the line starts at the left edge
public getRequiredWarmup(): number {
return this.period;
}

// Step 4. The five methods every study implements, called in this order when it attaches.
// (1) What it reads and writes: the default feed's close in, one output column out
public createInputOutputIds(): void {
this.inputId = this.getInputSourceId(EDataSourceStringId.CLOSE);
this.inputIds.push(this.inputId);
this.outputId = this.createOutputSourceId("Z", this.inputId); // "Z" is its legend name
this.outputIds.push(this.outputId);
}

// (2) Where its values are kept for drawing: a SciChart data series
public createDataSeries(): void {
this.series = new XyDataSeries(this.wasmContext, { dataSeriesName: `Z-Score ${this.period}` });
}

// (3) The Y axis of its pane. Spread axisDefaults first, so it is styled like the chart's other axes.
// An overlay returns [] and uses the price axis
public createAxes(): AxisBase2D[] {
return [
new NumericAxis(this.wasmContext, {
...this.parentFinanceChart.axisDefaults,
axisAlignment: EAxisAlignment.Right,
autoRange: EAutoRange.Always,
growBy: new NumberRange(0.1, 0.1),
labelPrecision: 1,
maxAutoTicks: 4,
}),
];
}

// (4) How it is drawn: one line, bound to its data series
public createRenderableSeries(): FastLineRenderableSeries[] {
this.line = new FastLineRenderableSeries(this.wasmContext, {
stroke: this.stroke,
strokeThickness: this.strokeThickness,
strokeDashArray: STROKE_STYLE_TO_DASH_ARRAY[this.strokeStyle],
});
this.line.dataSeries = this.series;
return [this.line];
}

// (5) Step 5: keep up with the data. Called with the first load, history loaded as the user scrolls back
// (Insert), candles dropped to save memory (Evict), each new candle (Append) and each live tick (Update)
protected onDataChanged(update: TDataUpdate<number>): void {
const change = update[this.inputId.id];
const closes = this.dataManager.getValues<number>(this.inputId) ?? [];
if (change?.updateType === EDataUpdateType.Update && change.startIndex < this.series.count()) {
// A live tick changes the last candle's close: recompute that one value only
const i = change.startIndex;
const z = this.zScoreAt(closes, i);
this.series.update(i, z);
this.dataManager.updateValues([this.outputId], [z], i);
return;
}
this.recalculate(closes);
}

// Recompute every value. The output is index-aligned with the candles, with NaN for the warm-up,
// so the legend reads the value of the candle under the cursor
private recalculate(closes: number[]) {
const zScores = closes.map((_, i) => this.zScoreAt(closes, i));
this.series.clear();
this.series.appendRange(this.dataManager.xValues, zScores);
this.dataManager.replaceValues([this.outputId], [zScores]);
}

private zScoreAt(closes: number[], i: number): number {
if (i < this.period - 1) return NaN;
const window = closes.slice(i - this.period + 1, i + 1);
const mean = window.reduce((sum, c) => sum + c, 0) / this.period;
const sd = Math.sqrt(window.reduce((sum, c) => sum + (c - mean) ** 2, 0) / this.period);
return sd === 0 ? 0 : (closes[i] - mean) / sd;
}

// Step 6. Guide lines, once the pane exists. addReferenceAnnotation removes them when the study
// is detached; an annotation left behind outlives its axis and breaks the chart
protected onInitComplete(): void {
this.guideLines = [this.threshold, 0, -this.threshold].map(
(y) =>
new HorizontalLineAnnotation({
y1: y,
stroke: this.theme.chart.referenceLine, // the chart theme's guide-line colour
strokeThickness: 1,
strokeDashArray: [4, 4],
showLabel: false,
})
);
this.addReferenceAnnotation(...this.guideLines);
}

// Colours that aren't settings, like the guide lines, follow the theme here.
// The line colour is a setting, so StudyBase moves it between themes for you
protected onThemeChanged(theme: ISciTraderTheme): void {
this.guideLines.forEach((line) => (line.stroke = theme.chart.referenceLine));
}

// Step 7. The settings form. Each control has a name, a label and its current value, in an
// "inputs" (what it calculates) or "styles" (how it looks) section. StudySettingsForm and
// StudyList draw the form from these, and toJSON saves them
protected configureDescriptor(): void {
this.addDescriptor(EDescriptorSectionName.INPUTS, new NumberDescriptor("period", "Period", this.period));
this.addDescriptor(EDescriptorSectionName.INPUTS, new NumberDescriptor("threshold", "Threshold (σ)", this.threshold));
this.addDescriptor(EDescriptorSectionName.STYLES, new ColorDescriptor("strokeColor", "Line colour", this.stroke));
this.addDescriptor(EDescriptorSectionName.STYLES, new NumberDescriptor("strokeThickness", "Line thickness", this.strokeThickness));
this.addDescriptor(EDescriptorSectionName.STYLES, new StrokeTypeDescriptor("strokeStyle", "Line style", this.strokeStyle));
}

// The settings back to options: applyDescriptor calls this, and so does the registration below
// when a saved chart is restored. Optional chaining, so settings saved by an older version still load
public static getOptionsFromDescriptor(descriptor: TStudyDescriptor): IZScoreOptions {
const inputs = descriptor.inputs.controls;
const styles = descriptor.styles.controls;
return {
period: Number(inputs.period?.default ?? 20),
threshold: Number(inputs.threshold?.default ?? 2),
stroke: String(styles.strokeColor?.default ?? "#26c6da"),
strokeThickness: Number(styles.strokeThickness?.default ?? 2),
strokeStyle: String(styles.strokeStyle?.default ?? "solid"),
};
}

// Step 8. Apply edited settings in place, with no reload. Without this (supportsLiveApply false),
// StudyList rebuilds the study through studyProvider instead
public get supportsLiveApply(): boolean {
return true;
}

protected onDescriptorApplied(options: IZScoreOptions): void {
this.stroke = this.line.stroke = options.stroke;
this.strokeThickness = this.line.strokeThickness = options.strokeThickness;
this.strokeStyle = options.strokeStyle;
this.line.strokeDashArray = STROKE_STYLE_TO_DASH_ARRAY[options.strokeStyle];
if (options.threshold !== this.threshold) {
this.threshold = options.threshold;
[this.guideLines[0].y1, this.guideLines[2].y1] = [this.threshold, -this.threshold];
}
if (options.period > 1 && options.period !== this.period) {
this.period = options.period;
this.series.dataSeriesName = `Z-Score ${this.period}`;
this.recalculate(this.dataManager.getValues<number>(this.inputId) ?? []);
}
}
}

// Step 9. Register it, once, at app startup: studyProvider.getStudy("ZScore") can then create it
// (for a study picker), and fromJSON can restore it from a saved chart
studyProvider.registerStudy(Z_SCORE, {
type: Z_SCORE,
name: "Z-Score",
description: "Standard deviations from the mean",
createStudy: (descriptor, options) =>
new ZScoreStudy({ ...options, ...(descriptor ? ZScoreStudy.getOptionsFromDescriptor(descriptor) : {}) }),
});

// ---------------------------------------------------------------------------------------------
// Using it: the same as a built-in study
// ---------------------------------------------------------------------------------------------

// Register a data source, once, at app startup
defaultExchangeProvider.registerExchange(CoinbaseExchange, idbPriceStore);

async function createChart() {
const toolbar = document.getElementById("toolbar");
Object.assign(toolbar.style, toolbarStyle);

const financeChartPanel = await SciFinanceChartPanel.create(document.getElementById("chart"), {
exchangeName: CoinbaseExchange.name, // name="Coinbase" matches the built-in CoinbaseExchange
symbol: "BTC-USD",
interval: CandleInterval.SIX_HOURS,
period: "3M",
studies: [new OHLCStudy(), new VolumeStudy(), new ZScoreStudy({ period: 20, threshold: 2 })],
});
const chart = financeChartPanel.financeApi;

// StudyList gives the custom study a settings form, drawn from its descriptor, and × to remove it
const studyList = StudyList.create(document.getElementById("studies"), { chart, title: "Studies" });

// Created by type, the way a study picker or a restored chart creates it
const add = document.createElement("button");
add.textContent = "Add a Z-Score";
Object.assign(add.style, buttonStyle);
add.onclick = () => chart.attachStudy(studyProvider.getStudy(Z_SCORE));
toolbar.append(add);

// When the page goes away: studyList.destroy(); financeChartPanel.cleanup();
return { financeChartPanel, studyList };
}

// The toolbar uses SciTrader's UI colours: its --st-ui-* CSS variables, with the stylesheet's defaults
const toolbarStyle: Partial<CSSStyleDeclaration> = {
display: "flex",
alignItems: "center",
gap: "8px",
padding: "8px",
background: "var(--st-ui-background, #2b2839)",
borderBottom: "1px solid var(--st-ui-divider, rgba(255,255,255,0.1))",
};
const buttonStyle: Partial<CSSStyleDeclaration> = {
padding: "4px 10px",
border: "1px solid var(--st-ui-divider, rgba(255,255,255,0.1))",
borderRadius: "6px",
background: "var(--st-ui-surface, #373447)",
color: "var(--st-ui-text, #ffffff)",
font: "inherit",
fontSize: "13px",
cursor: "pointer",
};

createChart();

React Example: A Custom Z-Score Study​

The same study in a React app. The study class is plain TypeScript, the same as above; only the chart and the study list are React components.

The code for this example is found below.

import "scichart-scitrader/scitrader.css";
import * as React from "react";
import {
AxisBase2D,
EAutoRange,
EAxisAlignment,
FastLineRenderableSeries,
HorizontalLineAnnotation,
NumberRange,
NumericAxis,
XyDataSeries,
} from "scichart";
import {
CandleInterval,
CoinbaseExchange,
ColorDescriptor,
DataSourceId,
defaultExchangeProvider,
EDataSourceStringId,
EDataUpdateType,
EDescriptorSectionName,
EStudyType,
idbPriceStore,
ISciFinanceChartPanelApi,
ISciFinanceChartPanelConfig,
ISciTraderTheme,
IStudyOptions,
NumberDescriptor,
OHLCStudy,
PaneId,
SciFinanceChart,
STROKE_STYLE_TO_DASH_ARRAY,
StrokeTypeDescriptor,
StudyBase,
studyProvider,
TDataUpdate,
TStudyDescriptor,
VolumeStudy,
} from "scichart-scitrader";
import { SciFinanceChartView, StudyList } from "scichart-scitrader-react";

// ---------------------------------------------------------------------------------------------
// Step 1. A study type: the key it is registered and saved under.
// IStudy.type is typed as the built-in EStudyType, so a custom type needs a cast for now
// ---------------------------------------------------------------------------------------------
const Z_SCORE = "ZScore" as EStudyType;

// Step 2. The study's options: its inputs (period, threshold) and styles (line colour, thickness, style).
// Extend IStudyOptions, which carries pane placement and the per-theme colours saved with the chart
interface IZScoreOptions extends IStudyOptions {
/** Candles in the rolling window. Default 20 */
period?: number;
/** Where the guide lines go, in standard deviations from the mean. Default 2 */
threshold?: number;
/** Line colour. Default "#26c6da" */
stroke?: string;
/** Line thickness in pixels. Default 2 */
strokeThickness?: number;
/** "solid", "dashed" or "dotted". Default "solid" */
strokeStyle?: string;
}

/**
* Z-Score: how many standard deviations the close is from its `period`-candle mean.
* Above `+threshold` price is stretched high; below `-threshold`, stretched low.
* An oscillator, so it draws in a pane of its own, with guide lines at +threshold, 0 and -threshold.
*/
class ZScoreStudy extends StudyBase<number> {
// Pane size relative to the others (the price pane is 3)
public readonly paneSizeConfig = { grow: 1 };

public period = 20;
public threshold = 2;
public stroke = "#26c6da";
public strokeThickness = 2;
public strokeStyle = "solid";

private inputId: DataSourceId; // the close prices it reads
private outputId: DataSourceId; // the z-scores it writes, which the legend shows
private series: XyDataSeries;
private line: FastLineRenderableSeries;
private guideLines: HorizontalLineAnnotation[] = [];

constructor(options?: IZScoreOptions) {
// PaneId.createUnique: a new pane for every Z-Score added.
// An overlay would pass PaneId.DEFAULT to share the price pane instead
super(Z_SCORE, PaneId.createUnique(Z_SCORE), options);
this.period = options?.period ?? this.period;
this.threshold = options?.threshold ?? this.threshold;
this.stroke = options?.stroke ?? this.stroke;
this.strokeThickness = options?.strokeThickness ?? this.strokeThickness;
this.strokeStyle = options?.strokeStyle ?? this.strokeStyle;
}

// Step 3. Warm-up: candles needed before the first value. The chart loads this many extra
// candles to the left of the period, so the line starts at the left edge
public getRequiredWarmup(): number {
return this.period;
}

// Step 4. The five methods every study implements, called in this order when it attaches.
// (1) What it reads and writes: the default feed's close in, one output column out
public createInputOutputIds(): void {
this.inputId = this.getInputSourceId(EDataSourceStringId.CLOSE);
this.inputIds.push(this.inputId);
this.outputId = this.createOutputSourceId("Z", this.inputId); // "Z" is its legend name
this.outputIds.push(this.outputId);
}

// (2) Where its values are kept for drawing: a SciChart data series
public createDataSeries(): void {
this.series = new XyDataSeries(this.wasmContext, { dataSeriesName: `Z-Score ${this.period}` });
}

// (3) The Y axis of its pane. Spread axisDefaults first, so it is styled like the chart's other axes.
// An overlay returns [] and uses the price axis
public createAxes(): AxisBase2D[] {
return [
new NumericAxis(this.wasmContext, {
...this.parentFinanceChart.axisDefaults,
axisAlignment: EAxisAlignment.Right,
autoRange: EAutoRange.Always,
growBy: new NumberRange(0.1, 0.1),
labelPrecision: 1,
maxAutoTicks: 4,
}),
];
}

// (4) How it is drawn: one line, bound to its data series
public createRenderableSeries(): FastLineRenderableSeries[] {
this.line = new FastLineRenderableSeries(this.wasmContext, {
stroke: this.stroke,
strokeThickness: this.strokeThickness,
strokeDashArray: STROKE_STYLE_TO_DASH_ARRAY[this.strokeStyle],
});
this.line.dataSeries = this.series;
return [this.line];
}

// (5) Step 5: keep up with the data. Called with the first load, history loaded as the user scrolls back
// (Insert), candles dropped to save memory (Evict), each new candle (Append) and each live tick (Update)
protected onDataChanged(update: TDataUpdate<number>): void {
const change = update[this.inputId.id];
const closes = this.dataManager.getValues<number>(this.inputId) ?? [];
if (change?.updateType === EDataUpdateType.Update && change.startIndex < this.series.count()) {
// A live tick changes the last candle's close: recompute that one value only
const i = change.startIndex;
const z = this.zScoreAt(closes, i);
this.series.update(i, z);
this.dataManager.updateValues([this.outputId], [z], i);
return;
}
this.recalculate(closes);
}

// Recompute every value. The output is index-aligned with the candles, with NaN for the warm-up,
// so the legend reads the value of the candle under the cursor
private recalculate(closes: number[]) {
const zScores = closes.map((_, i) => this.zScoreAt(closes, i));
this.series.clear();
this.series.appendRange(this.dataManager.xValues, zScores);
this.dataManager.replaceValues([this.outputId], [zScores]);
}

private zScoreAt(closes: number[], i: number): number {
if (i < this.period - 1) return NaN;
const window = closes.slice(i - this.period + 1, i + 1);
const mean = window.reduce((sum, c) => sum + c, 0) / this.period;
const sd = Math.sqrt(window.reduce((sum, c) => sum + (c - mean) ** 2, 0) / this.period);
return sd === 0 ? 0 : (closes[i] - mean) / sd;
}

// Step 6. Guide lines, once the pane exists. addReferenceAnnotation removes them when the study
// is detached; an annotation left behind outlives its axis and breaks the chart
protected onInitComplete(): void {
this.guideLines = [this.threshold, 0, -this.threshold].map(
(y) =>
new HorizontalLineAnnotation({
y1: y,
stroke: this.theme.chart.referenceLine, // the chart theme's guide-line colour
strokeThickness: 1,
strokeDashArray: [4, 4],
showLabel: false,
})
);
this.addReferenceAnnotation(...this.guideLines);
}

// Colours that aren't settings, like the guide lines, follow the theme here.
// The line colour is a setting, so StudyBase moves it between themes for you
protected onThemeChanged(theme: ISciTraderTheme): void {
this.guideLines.forEach((line) => (line.stroke = theme.chart.referenceLine));
}

// Step 7. The settings form. Each control has a name, a label and its current value, in an
// "inputs" (what it calculates) or "styles" (how it looks) section. StudySettingsForm and
// StudyList draw the form from these, and toJSON saves them
protected configureDescriptor(): void {
this.addDescriptor(EDescriptorSectionName.INPUTS, new NumberDescriptor("period", "Period", this.period));
this.addDescriptor(EDescriptorSectionName.INPUTS, new NumberDescriptor("threshold", "Threshold (σ)", this.threshold));
this.addDescriptor(EDescriptorSectionName.STYLES, new ColorDescriptor("strokeColor", "Line colour", this.stroke));
this.addDescriptor(EDescriptorSectionName.STYLES, new NumberDescriptor("strokeThickness", "Line thickness", this.strokeThickness));
this.addDescriptor(EDescriptorSectionName.STYLES, new StrokeTypeDescriptor("strokeStyle", "Line style", this.strokeStyle));
}

// The settings back to options: applyDescriptor calls this, and so does the registration below
// when a saved chart is restored. Optional chaining, so settings saved by an older version still load
public static getOptionsFromDescriptor(descriptor: TStudyDescriptor): IZScoreOptions {
const inputs = descriptor.inputs.controls;
const styles = descriptor.styles.controls;
return {
period: Number(inputs.period?.default ?? 20),
threshold: Number(inputs.threshold?.default ?? 2),
stroke: String(styles.strokeColor?.default ?? "#26c6da"),
strokeThickness: Number(styles.strokeThickness?.default ?? 2),
strokeStyle: String(styles.strokeStyle?.default ?? "solid"),
};
}

// Step 8. Apply edited settings in place, with no reload. Without this (supportsLiveApply false),
// StudyList rebuilds the study through studyProvider instead
public get supportsLiveApply(): boolean {
return true;
}

protected onDescriptorApplied(options: IZScoreOptions): void {
this.stroke = this.line.stroke = options.stroke;
this.strokeThickness = this.line.strokeThickness = options.strokeThickness;
this.strokeStyle = options.strokeStyle;
this.line.strokeDashArray = STROKE_STYLE_TO_DASH_ARRAY[options.strokeStyle];
if (options.threshold !== this.threshold) {
this.threshold = options.threshold;
[this.guideLines[0].y1, this.guideLines[2].y1] = [this.threshold, -this.threshold];
}
if (options.period > 1 && options.period !== this.period) {
this.period = options.period;
this.series.dataSeriesName = `Z-Score ${this.period}`;
this.recalculate(this.dataManager.getValues<number>(this.inputId) ?? []);
}
}
}

// Step 9. Register it, once, at app startup: studyProvider.getStudy("ZScore") can then create it
// (for a study picker), and fromJSON can restore it from a saved chart
studyProvider.registerStudy(Z_SCORE, {
type: Z_SCORE,
name: "Z-Score",
description: "Standard deviations from the mean",
createStudy: (descriptor, options) =>
new ZScoreStudy({ ...options, ...(descriptor ? ZScoreStudy.getOptionsFromDescriptor(descriptor) : {}) }),
});

// ---------------------------------------------------------------------------------------------
// Using it in React: the same as a built-in study
// ---------------------------------------------------------------------------------------------

// Register a data source, once, at app startup
defaultExchangeProvider.registerExchange(CoinbaseExchange, idbPriceStore);

export function CustomStudyChart() {
// The chart's finance API, once it has initialised. StudyList needs it
const [chart, setChart] = React.useState<SciFinanceChart>(null);

// Create the config once per mounted chart: studies are live objects bound to one chart
const config = React.useMemo<ISciFinanceChartPanelConfig>(
() => ({
exchangeName: CoinbaseExchange.name, // name="Coinbase" matches the built-in CoinbaseExchange
symbol: "BTC-USD",
interval: CandleInterval.SIX_HOURS,
period: "3M",
studies: [new OHLCStudy(), new VolumeStudy(), new ZScoreStudy({ period: 20, threshold: 2 })],
}),
[]
);

// Created by type, the way a study picker or a restored chart creates it
const addZScore = () => chart?.attachStudy(studyProvider.getStudy(Z_SCORE));

// Fills its parent, so give the parent a height
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div style={toolbarStyle}>
<button style={buttonStyle} disabled={!chart} onClick={addZScore}>
Add a Z-Score
</button>
</div>
<div style={{ display: "flex", flex: 1, minHeight: 0 }}>
<div style={{ flex: 1, minWidth: 0 }}>
{/* financeApi throws until the chart has initialised, so read it in onInit */}
<SciFinanceChartView config={config} onInit={(api: ISciFinanceChartPanelApi) => setChart(api.financeApi)} />
</div>
{/* StudyList gives the custom study a settings form, drawn from its descriptor, and × to remove it */}
{chart && <StudyList chart={chart} title="Studies" style={sidePanelStyle} />}
</div>
</div>
);
}

// The toolbar uses SciTrader's UI colours: its --st-ui-* CSS variables, with the stylesheet's defaults
const toolbarStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
padding: 8,
background: "var(--st-ui-background, #2b2839)",
borderBottom: "1px solid var(--st-ui-divider, rgba(255,255,255,0.1))",
};
const buttonStyle: React.CSSProperties = {
padding: "4px 10px",
border: "1px solid var(--st-ui-divider, rgba(255,255,255,0.1))",
borderRadius: 6,
background: "var(--st-ui-surface, #373447)",
color: "var(--st-ui-text, #ffffff)",
font: "inherit",
fontSize: 13,
cursor: "pointer",
};
const sidePanelStyle: React.CSSProperties = {
width: 320,
overflowY: "auto",
borderLeft: "1px solid var(--st-ui-divider, rgba(255,255,255,0.1))",
};

Writing an Overlay Instead of an Oscillator​

An overlay, such as a moving average, draws on the price pane against the price axis. It differs from the Z-Score in two places:

constructor(options?: IMyOverlayOptions) {
super(MY_OVERLAY, PaneId.DEFAULT, options); // the price pane
}

public createAxes(): AxisBase2D[] {
return []; // share the price axis
}

StudyBase API for Custom Studies​

StudyBase implements IStudy. These are the members a custom study implements, overrides and uses:

MemberKindWhat it is for
createInputOutputIds, createDataSeries, createAxes, createRenderableSeries, onDataChangedAbstractThe five methods every study implements
onInitCompleteOverrideRuns once the study has drawn its first data
getRequiredWarmupOverrideCandles needed before the first value. Default 0
configureDescriptor, onDescriptorApplied, supportsLiveApplyOverrideSettings, and applying them in place
onThemeChangedOverrideRecolour what isn't a setting
getLegendColorOverrideThe colour of an output's value in the legend. Default: the line's stroke
getInputSourceId, createOutputSourceIdHelperInput and output columns
addReferenceAnnotationHelperGuide lines that are removed with the study
nanPad, valueAt, strokeForDataSeriesHelperPadding output to align with candles; reading a value; finding a series' stroke
wasmContext, dataManager, parentFinanceChart, parentSurface, themeContextSet when the study attaches: use them from createDataSeries onwards

Their TSDoc is in one place, with the class hierarchy, on Technical Indicators and Studies:

Descriptor Classes​

NumberDescriptor, TextDescriptor, ColorDescriptor, SelectDescriptor, StrokeTypeDescriptor and GradientDescriptor, with their TSDoc
class BaseDescriptor implements IuiDescriptor {
name: string;
type: string;
label: string;
default: string | number;
constructor(uiDescriptor: IuiDescriptor);
}
class TextDescriptor extends BaseDescriptor {
constructor(name: string, label: string, defaultValue: string);
}
class NumberDescriptor extends BaseDescriptor {
constructor(name: string, label: string, defaultValue: number);
}
class ColorDescriptor extends BaseDescriptor {
constructor(name: string, label: string, defaultValue: string);
}
type ISelectDescriptorOption = {
value: string;
label: string;
};
class SelectDescriptor extends BaseDescriptor {
options: ISelectDescriptorOption[];
constructor(name: string, label: string, defaultValue: string, options: ISelectDescriptorOption[]);
}
/**
* Stroke-style descriptor — renders via the form's dedicated `STROKE_TYPE`
* case which provides solid / dashed / dotted options out of the box, so
* concrete studies don't need to repeat the option list.
*/
class StrokeTypeDescriptor extends BaseDescriptor {
constructor(name: string, label: string, defaultValue: string);
}
/** One stop of a colour ramp: a colour, and where it sits along the ramp as a percentage (0–100). */
interface IGradientStopValue {
color: string;
atPct: number;
}
/**
* A colour ramp, edited as one control: the form draws the ramp as a bar with a handle per stop —
* drag a handle to move its stop, select it to change its colour.
*
* Carried as one string, `#rrggbbaa@pct;…`, because a descriptor value is one string. A control
* per stop colour and another per position put twelve fields in the form for six stops.
*/
class GradientDescriptor extends BaseDescriptor {
constructor(name: string, label: string, stops: readonly IGradientStopValue[]);
}
function formatGradient(stops: readonly IGradientStopValue[]): string;
/** Reads a ramp back; `undefined` unless every stop is `colour@number`. */
function parseGradient(value: unknown): IGradientStopValue[] | undefined;

/**
* Map a stroke-style token (`"solid"`, `"dashed"`, `"dotted"`) onto the
* `strokeDashArray` shape SciChart expects. `undefined` for solid lets the
* renderer skip the dash pattern entirely.
*
* Extracted from the inline copies that previously lived in `RSIStudy`,
* `SMAStudy`, and `LineStudy` (the comment in `SMAStudy` flagged this as the
* first candidate for extraction once 3+ studies shared it — the 15 new
* indicator studies push it well past that threshold).
*/
const STROKE_STYLE_TO_DASH_ARRAY: Record<string, number[] | undefined>;
/**
* Inverse of {@link STROKE_STYLE_TO_DASH_ARRAY} — used by `configureDescriptor`
* to initialise the stroke-style descriptor's default from whatever
* `renderOptions.strokeDashArray` was passed in. Anything unknown falls back
* to `"solid"`.
*/
function dashArrayToStrokeStyle(dashArray: number[] | undefined): string;

Data Updates: EDataUpdateType and IDataManager​

A study reads its inputs from the chart's data manager, and is told of each change as an EDataUpdateType. Data Manager and Data Feeds describes both, with their TSDoc: