Skip to content

Creating custom indicators for a Stock Chart

This tutorial will show how we can create custom indicators for a stock chart.

To begin creating a custom indicator, we need to define a new class that extends a built-in Indicator class.

Our class definition at the very least should contain three things:

  • _afterNew() method - it is used to set everything up, such as creating indicator series.
  • prepareData() method - whenever data needs to be updated, e.g. on first load or target stock series data updates.
  • An instance of one of the XYSeries, e.g. LineSeries, which needs to be set on indicator’s series property.
class MyIndicator extends am5stock.Indicator {
declare className: string = "MyIndicator";
declare series: am5xy.LineSeries;
public _afterNew() {
// Setting up indicator elements
// ...
}
public prepareData() {
// Setting up data
// ...
}
}

To test our new indicator, we will invoke it programmatically.

let myIndicator = stockChart.indicators.push(MyIndicator.new(root, {
stockChart: stockChart,
stockSeries: stockChart.get("stockSeries"),
legend: valueLegend
}));

Since the new indicator class we created is not bundled with amCharts, the serializer/parser routines cannot process it.

This means that indicators created using custom class will not be saved and restored when using Stock Chart’s serialization techniques.

To fix that we need to register our class:

am5stock.registerClass("MyIndicator", MyIndicator);

Whenever indicator is instantiated, it will run its _afterNew() method, which is supposed to set everything up.

At the very least it should create a series and set it to own series property.

class MyIndicator extends am5stock.Indicator {
declare series: am5xy.LineSeries;
public _afterNew() {
// Setting up indicator elements
const stockSeries = this.get("stockSeries");
const chart = stockSeries.chart;
if (chart) {
const series = chart.series.push(am5xy.LineSeries.new(this._root, {
valueXField: "valueX",
valueYField: "valueY1",
openValueYField: "valueY2",
groupDataDisabled: true,
xAxis: stockSeries.get("xAxis"),
yAxis: stockSeries.get("yAxis"),
themeTags: ["indicator"],
name: "My indicator"
}));
this.series = series;
}
// Don't forget inherited stuff
super._afterNew();
}
public prepareData() {
// Setting up data
// ...
}
}

We now have a series set up, but for it to be plotted, we need to populate its data.

We’ll use indicator’s prepareData() method, to grab source data from target series, and create our own derivative data set.

In most cases, we will use stock chart’s main series as a source of the data.

class MyIndicator extends am5stock.Indicator {
declare series: am5xy.LineSeries;
public _afterNew() {
// Setting up indicator elements
// ...
// Don't forget inherited stuff
super._afterNew();
}
public prepareData() {
// Setting up data
const stockSeries = this.get("stockSeries");
const dataItems = stockSeries.dataItems;
let data = this._getDataArray(dataItems);
let margin = 100;
am5.array.each(data, (item, i) => {
let baseValue = dataItems[i].get("valueY", 0);
item.valueY1 = baseValue + Math.round(Math.random() * margin);
item.valueY2 = baseValue - Math.round(Math.random() * margin);
});
this.series.data.setAll(data);
}
}

If we want our indicator to appear in legend, we need to pass in legend instance, when instantiating it.

let myIndicator = stockChart.indicators.push(MyIndicator.new(root, {
stockChart: stockChart,
stockSeries: stockChart.get("stockSeries"),
legend: valueLegend
}));

To make indicator create a legend item for itself, all we need to do is call its _handleLegend() method in _afterNew(), passing in the indicator series as a parameter.

class MyIndicator extends am5stock.Indicator {
declare series: am5xy.LineSeries;
public _afterNew() {
// Setting up indicator elements
// ...
// Create a legend item
this._handleLegend(series);
// Don't forget inherited stuff
super._afterNew();
}
public prepareData() {
// Setting up data
// ...
}
}

A legend will show only indicator’s (series) name by default.

We can use indicator series’ legendLabelText and legendValueText to specify what we need to see there.

class MyIndicator extends am5stock.Indicator {
declare series: am5xy.LineSeries;
public _afterNew() {
// Setting up indicator elements
const stockSeries = this.get("stockSeries");
const chart = stockSeries.chart;
if (chart) {
const series = chart.series.push(am5xy.LineSeries.new(this._root, {
valueXField: "valueX",
valueYField: "valueY1",
openValueYField: "valueY2",
groupDataDisabled: true,
xAxis: stockSeries.get("xAxis"),
yAxis: stockSeries.get("yAxis"),
themeTags: ["indicator"],
name: "My indicator",
legendLabelText: "{name}",
legendValueText: "high: [bold]{valueY}[/] - low: [bold]{openValueY}[/]",
legendRangeValueText: ""
}));
this.series = series;
// Create a legend item
this._handleLegend(series);
}
// Don't forget inherited stuff
super._afterNew();
}
public prepareData() {
// Setting up data
// ...
}
}

Most indicators can be configured using modal popup.

For example, users might want to check colors, functionality, or other factors.

We can define those for custom indicators as well. Let’s look at how it works.

Before we can implement setting editor, we need to set up custom settings for our indicator.

It usually involves these tasks:

  • Defining settings interface (strongly typed languages only, e.g. TypeScript).
  • Setting defaults.
  • Handling changes.

Skip on to “Setting defaults”, if you are using vanilla JavaScript.

Just so compiler knows we are adding new settings, we need to implement a settings interface, as well as declare its use via classe’s _settings:

// Defining custom settings
export interface MyIndicatorSettings extends am5stock.IIndicatorSettings {
margin?: number;
seriesStyle?: "Solid" | "Dashed";
showFill?: boolean;
}
class MyIndicator extends am5stock.Indicator {
// Declaring custom settings use
declare public _settings: MyIndicatorSettings;
declare series: am5xy.LineSeries;
public _afterNew() {
// Setting up indicator elements
// ...
}
public prepareData() {
// Setting up data
// ...
}
}

We will use indicator’s _setDefault() method for, well, setting default values for the custom settings:

class MyIndicator extends am5stock.Indicator {
// Declaring custom settings use
declare public _settings: MyIndicatorSettings;
declare series: am5xy.LineSeries;
public _afterNew() {
// Set defaults
this._setDefault("name", "My Indicator");
this._setDefault("margin", 100);
this._setDefault("seriesColor", am5.color(0x045153));
this._setDefault("seriesStyle", "Solid");
this._setDefault("showFill", true);
// Setting up indicator elements
// ...
// Don't forget inherited stuff
super._afterNew();
}
public prepareData() {
// Setting up data
// ...
}
}

Indicator needs to be set up to handle all changes to its custom settings - both through API and via settings modal.

For that we will need to employ another method: _beforeChanged().

class MyIndicator extends am5stock.Indicator {
// Declaring custom settings use
declare public _settings: MyIndicatorSettings;
declare series: am5xy.LineSeries;
public _afterNew() {
// Setting up indicator settings
// ...
}
public _beforeChanged() {
if (this.isDirty("margin")) {
this.markDataDirty();
}
if (this.isDirty("seriesStyle")) {
const style = this.get("seriesStyle");
if (style == "Dashed") {
this.series.strokes.template.set("strokeDasharray", [4, 4]);
}
else {
this.series.strokes.template.remove("strokeDasharray");
}
}
if (this.isDirty("showFill")) {
this.series.fills.template.set("visible", this.get("showFill"));
}
// Don't forget inherited stuff
super._beforeChanged();
}
public prepareData() {
// Setting up data
// ...
}
}

A few notes about the above code:

  • isDirty() method is used to check whether value of the specific setting changed. No need to update anything if there was no change.
  • We don’t need to worry about seriesColor, because it’s a setting for parent Indicator class, and is handled by its own change handling mechanism.

To enable settings modal, we need to set indicator’s _editableSettings property, which is an array of IIndicatorEditableSetting objects.

Each object has the following properties:

Key Type Comment
key String A key (name) of the indicator setting.
name String A setting name to display next to input field in modal.
type "color" | "number" | "dropdown" | "checkbox" | "text" Type of the input field.
minValue Number Minimum numeric value allowable in the field (for type: "number"). Values below it are clamped.
maxValue Number Maximum numeric value allowable in the field (for type: "number"). Values above it are clamped.
step Number Step for a numeric input. A step of 1 also marks the setting as an integer, so typed decimals are rounded to whole numbers.
scale Number Display multiplier for the modal only. The UI shows/edits stored value × scale and divides it back on save. Does not affect the stored/serialized value or the API.
options Array An array of options to choose from if type is set to "dropdown"

Let’s set up am interface for editing the three custom settings we added (margin, seriesStyle, showFill) plus the seriesColor which is an inherited setting from Indicator.

class MyIndicator extends am5stock.Indicator {
// Declaring custom settings use
declare public _settings: MyIndicatorSettings;
public _editableSettings: am5stock.IIndicatorEditableSetting[] = [{
key: "margin",
name: "Margin",
type: "number"
}, {
key: "seriesColor",
name: "Color",
type: "color"
}, {
key: "seriesStyle",
name: "Line Style",
type: "dropdown",
options: ["Solid", "Dashed"]
}, {
key: "showFill",
name: "Show fill",
type: "checkbox"
}];
declare series: am5xy.LineSeries;
public _afterNew() {
// Setting up indicator settings
// ...
}
public _beforeChanged() {
// Handling changes
// ...
}
public prepareData() {
// Setting up data
// ...
}
}

When indicator instance is destroyed, it cleans after itself, including disposing its series (one stored in the series property).

If indicator did not create any other objects, like additional series, we’re all set.

If, however, indicator has created other elements, that need to be destroyed together with the indicator, we will need to define a _dispose() method, which does that.

class MyIndicator extends am5stock.Indicator {
public series: am5xy.LineSeries;
public anotherSeries: am5xy.LineSeries;
public _afterNew() {
// Setting up indicator elements
// ...
}
public prepareData() {
// Setting up data
// ...
}
public _dispose() {
// this.series will be disposed automatically by parent class
this.anotherSeries.dispose();
super._dispose();
}
}

To enable users to add our new indicator via UI, we need to add it to the Indicator control.

To do that we need to use indicators setting, which is an array of names of built-in indicators, or objects implementing IIndicator interface.

We’ll be using the latter to add our custom indicator.

// Create indicator control
let indicatorControl = am5stock.IndicatorControl.new(root, {
stockChart: stockChart,
legend: valueLegend
});
// Get current indicators
let indicators = indicatorControl.get("indicators", []);
// Add custom indicator to the top of the list
indicators.unshift({
id: "myIndicator",
name: "My indicator",
callback: () => {
const myIndicator = stockChart.indicators.push(MyIndicator.new(root, {
stockChart: stockChart,
stockSeries: stockChart.get("stockSeries"),
legend: valueLegend
}));
return myIndicator;
}
});
// Set indicator list back
indicatorControl.set("indicators", indicators);
// Use indicator control in the toolbar
let toolbar = am5stock.StockToolbar.new(root, {
container: document.getElementById("chartcontrols")!,
stockChart: stockChart,
controls: [
indicatorControl,
am5stock.ResetControl.new(root, {
stockChart: stockChart
}),
am5stock.SettingsControl.new(root, {
stockChart: stockChart
})
]
});

The object, identifying custom indicator consists of these properties:

  • id - a unique ID of the indicator. It must not be the same as built-in indicators.
  • name - a name that will be shown in the dropdown.
  • callback - a function that will be called when user selects this indicator from the list. It must return an indicator object.
// Defining custom settings
export interface MyIndicatorSettings extends am5stock.IIndicatorSettings {
margin?: number;
seriesStyle?: "Solid" | "Dashed";
showFill?: boolean;
}
// Define indicator class
class MyIndicator extends am5stock.Indicator {
// Declaring custom settings use
declare public _settings: MyIndicatorSettings;
public _editableSettings: am5stock.IIndicatorEditableSetting[] = [{
key: "margin",
name: "Margin",
type: "number"
}, {
key: "seriesColor",
name: "Color",
type: "color"
}, {
key: "seriesStyle",
name: "Line Style",
type: "dropdown",
options: ["Solid", "Dashed"]
}, {
key: "showFill",
name: "Show fill",
type: "checkbox"
}];
declare series: am5xy.SmoothedXLineSeries;
public _afterNew() {
// Set default indicator name
this._setDefault("name", "My Indicator");
this._setDefault("margin", 100);
this._setDefault("seriesColor", am5.color(0x045153));
this._setDefault("seriesStyle", "Solid");
this._setDefault("showFill", true);
// Setting up indicator elements
const stockSeries = this.get("stockSeries");
const chart = stockSeries.chart;
if (chart) {
const series = chart.series.push(am5xy.SmoothedXLineSeries.new(this._root, {
valueXField: "valueX",
valueYField: "valueY1",
openValueYField: "valueY2",
groupDataDisabled: true,
calculateAggregates: true,
xAxis: stockSeries.get("xAxis"),
yAxis: stockSeries.get("yAxis"),
themeTags: ["indicator"],
name: "My indicator",
legendLabelText: "{name}",
legendValueText: "high: [bold]{valueY}[/] - low: [bold]{openValueY}[/]",
legendRangeValueText: "",
stroke: this.get("seriesColor"),
fill: this.get("seriesColor")
}));
series.fills.template.setAll({
fillOpacity: 0.3,
visible: true
});
this.series = series;
this._handleLegend(series);
}
// Don't forget inherited stuff
super._afterNew();
}
public _beforeChanged() {
if (this.isDirty("margin")) {
this.markDataDirty();
}
if (this.isDirty("seriesStyle")) {
const style = this.get("seriesStyle");
if (style == "Dashed") {
this.series.strokes.template.set("strokeDasharray", [4, 4]);
}
else {
this.series.strokes.template.remove("strokeDasharray");
}
}
if (this.isDirty("showFill")) {
this.series.fills.template.set("visible", this.get("showFill"));
}
// Don't forget inherited stuff
super._beforeChanged();
}
public prepareData() {
// Setting up data
const stockSeries = this.get("stockSeries");
const dataItems = stockSeries.dataItems;
let data = this._getDataArray(dataItems);
const margin = this.get("margin", 0);
am5.array.each(data, function(item, i) {
let baseValue = dataItems[i].get("valueY", 0);
item.valueY1 = baseValue + Math.round(Math.random() * margin);
item.valueY2 = baseValue - Math.round(Math.random() * margin);
});
this.series.data.setAll(data);
}
}
// Add custom indicator
let myIndicator = stockChart.indicators.push(MyIndicator.new(root, {
stockChart: stockChart,
stockSeries: stockChart.get("stockSeries"),
legend: valueLegend
}));
// Create indicator control
let indicatorControl = am5stock.IndicatorControl.new(root, {
stockChart: stockChart,
legend: valueLegend
});
// Get current indicators
let indicators = indicatorControl.get("indicators", []);
// Add custom indicator to the top of the list
indicators.unshift({
id: "myIndicator",
name: "My indicator",
callback: function() {
const myIndicator = stockChart.indicators.push(MyIndicator.new(root, {
stockChart: stockChart,
stockSeries: stockChart.get("stockSeries"),
legend: valueLegend
}));
return myIndicator;
}
});
// Set indicator list back
indicatorControl.set("indicators", indicators);
// Stock toolbar
let toolbar = am5stock.StockToolbar.new(root, {
container: document.getElementById("chartcontrols")!,
stockChart: stockChart,
controls: [
indicatorControl,
am5stock.DateRangeSelector.new(root, {
stockChart: stockChart
}),
am5stock.PeriodSelector.new(root, {
stockChart: stockChart
}),
am5stock.DrawingControl.new(root, {
stockChart: stockChart
}),
am5stock.ResetControl.new(root, {
stockChart: stockChart
}),
am5stock.SettingsControl.new(root, {
stockChart: stockChart
})
]
});
Open in CodePen

An indicator can be made to create and open in its own panel.

The process of defining such indicator is similar to regular indicators, with three differences:

  • It needs to extend ChartIndicator class (instead of Indicator).
  • It needs to have a _createSeries() method. We need a separate method for creating series, as we can’t create them in _afterNew() like regular indicators, because new panel is not ready at that point, yet.
  • For indicator series, we need to use indicator panel’s own axes accessible via its xAxis and yAxis properties.

Here’s how updated code would look like:

// Defining custom settings
export interface MyIndicatorSettings extends am5stock.IChartIndicatorSettings {
margin?: number;
seriesStyle?: "Solid" | "Dashed";
showFill?: boolean;
}
// Define indicator class
class MyIndicator extends am5stock.ChartIndicator {
// Declaring custom settings use
declare public _settings: MyIndicatorSettings;
public _editableSettings: am5stock.IIndicatorEditableSetting[] = [{
key: "margin",
name: "Margin",
type: "number"
}, {
key: "seriesColor",
name: "Color",
type: "color"
}, {
key: "seriesStyle",
name: "Line Style",
type: "dropdown",
options: ["Solid", "Dashed"]
}, {
key: "showFill",
name: "Show fill",
type: "checkbox"
}];
declare series: am5xy.SmoothedXLineSeries;
public _createSeries(): am5xy.SmoothedXLineSeries {
const stockSeries = this.get("stockSeries");
const series = this.panel.series.push(am5xy.SmoothedXLineSeries.new(this._root, {
valueXField: "valueX",
valueYField: "valueY1",
openValueYField: "valueY2",
groupDataDisabled: true,
calculateAggregates: true,
xAxis: this.xAxis,
yAxis: this.yAxis,
themeTags: ["indicator"],
name: "My indicator",
legendLabelText: "{name}",
legendValueText: "high: [bold]{valueY}[/] - low: [bold]{openValueY}[/]",
legendRangeValueText: "",
stroke: this.get("seriesColor"),
fill: this.get("seriesColor")
}));
series.fills.template.setAll({
fillOpacity: 0.3,
visible: true
});
return series;
}
public _afterNew() {
// Set default indicator name
this._setDefault("name", "My Indicator");
this._setDefault("margin", 100);
this._setDefault("seriesColor", am5.color(0x045153));
this._setDefault("seriesStyle", "Solid");
this._setDefault("showFill", true);
// Don't forget inherited stuff
super._afterNew();
}
public _beforeChanged() {
if (this.isDirty("margin")) {
this.markDataDirty();
}
if (this.isDirty("seriesStyle")) {
const style = this.get("seriesStyle");
if (style == "Dashed") {
this.series.strokes.template.set("strokeDasharray", [4, 4]);
}
else {
this.series.strokes.template.remove("strokeDasharray");
}
}
if (this.isDirty("showFill")) {
this.series.fills.template.set("visible", this.get("showFill"));
}
// Don't forget inherited stuff
super._beforeChanged();
}
public prepareData() {
// Setting up data
const stockSeries = this.get("stockSeries");
const dataItems = stockSeries.dataItems;
let data = this._getDataArray(dataItems);
const margin = this.get("margin", 0);
am5.array.each(data, function(item, i) {
let baseValue = dataItems[i].get("valueY", 0);
item.valueY1 = baseValue + Math.round(Math.random() * margin);
item.valueY2 = baseValue - Math.round(Math.random() * margin);
});
this.series.data.setAll(data);
}
}
// Add custom indicator
let myIndicator = stockChart.indicators.push(MyIndicator.new(root, {
stockChart: stockChart,
stockSeries: stockChart.get("stockSeries"),
legend: valueLegend
}));
// Create indicator control
let indicatorControl = am5stock.IndicatorControl.new(root, {
stockChart: stockChart,
legend: valueLegend
});
// Get current indicators
let indicators = indicatorControl.get("indicators", []);
// Add custom indicator to the top of the list
indicators.unshift({
id: "myIndicator",
name: "My indicator",
callback: function() {
const myIndicator = stockChart.indicators.push(MyIndicator.new(root, {
stockChart: stockChart,
stockSeries: stockChart.get("stockSeries"),
legend: valueLegend
}));
return myIndicator;
}
});
// Set indicator list back
indicatorControl.set("indicators", indicators);
// Stock toolbar
let toolbar = am5stock.StockToolbar.new(root, {
container: document.getElementById("chartcontrols")!,
stockChart: stockChart,
controls: [
indicatorControl,
am5stock.DateRangeSelector.new(root, {
stockChart: stockChart
}),
am5stock.PeriodSelector.new(root, {
stockChart: stockChart
}),
am5stock.DrawingControl.new(root, {
stockChart: stockChart
}),
am5stock.ResetControl.new(root, {
stockChart: stockChart
}),
am5stock.SettingsControl.new(root, {
stockChart: stockChart
})
]
});
Open in CodePen