DocumentationIndicators

Indicators

There are two ways to put an indicator on a chart, and they suit different jobs.

chart.addIndicator(id) is the managed path: the chart creates the series, picks the pane, draws reference levels, recomputes on every data change, and cleans up on removal. The 102 built-ins live in the lazy openalgo-charts/indicators tier, about 27 KB Brotli.

import { createChart } from 'openalgo-charts';
import 'openalgo-charts/indicators';   // registers all 102 built-ins
 
const chart = createChart(el);
chart.addSeries('candlestick').setData(bars);
 
chart.addIndicator('bollinger');                       // overlays the price pane
const macd = chart.addIndicator('macd', { fastPeriod: 8 });  // gets its own pane
macd.setSettings({ fastPeriod: 12 });
macd.remove();

The raw functions (ema, rsi, atr, supertrend) stay in the base bundle for when you want to compute a value and plot it yourself. They use Wilder semantics to match openalgo.ta; the *Series helpers return Bar[] with NaN in warmup slots, which the line renderer skips as whitespace.

import {
  ema, emaSeries,            // EMA: raw values, and a plottable line series
  rsi, rsiSeries,            // RSI
  supertrend, supertrendSeries,
  atr, trueRange,            // ATR (raw values) + true range
} from 'openalgo-charts';

The built-in catalog

live
Rendering live chart…
View example code
const chart = lib.createChart(el);
const bars = lib.generateBars(1700000000, 220, 3600);
chart.addSeries('candlestick').setData(bars);
chart.addIndicator('bollinger');
chart.addIndicator('macd');
chart.timeScale.fitContent(bars.length);
return chart;

102 descriptors ship in the tier, split across the four categories a picker reads off descriptor.category: Trend (36), Momentum (29), Volatility (22), and Volume (15).

onchart indicators overlay the price pane; pane indicators create their own. Override with chart.addIndicator('rsi', {}, { paneIndex: 2 }).

Key settings below are the tunable inputs with their defaults. Every descriptor also carries per-plot style inputs (colour, width, line style, opacity) that are not listed here; read them off indicatorStyleInputs(descriptor) rather than hardcoding them. Ids are exact: addIndicator throws on one it does not know, so guard a user-supplied id with hasIndicator(id).

Trend (36)

IndicatoridPlacementKey settings
ADX / DMIadxpaneperiod=14, adxPeriod=14
AlphaTrendalphatrendonchartcoeff=1, AP=14, source=close, showsignalsk=true, novolumedata=false
Arnaud Legoux Moving Averagealmaonchartlength=9, offset=0.85, sigma=6
Aroonaroonpanelength=14
Aroon Oscillatoraroon-oscillatorpanelength=14
Chande Kroll Stopchande-kroll-stoponchartp=10, x=1, q=9
Chandelier Exitchandelier-exitonchartlength=22, atrLength=22, atrMultiplier=3
Consolidation and Breakoutconsolidation-breakoutonchartmarkbreakout=true, colorinside=true
Double EMAdemaonchartlength=9, source=close
EMAemaonchartlength=9, source=close
HalfTrendhalftrendonchartamplitude=2, channelDeviation=2, atrPeriod=100, showChannels=true, showSignals=true, showLabels=true
Hull Moving Averagehmaonchartlength=9, source=close
Hull Suitehull-suiteonchartmode=Hma, length=55, lengthMult=1, source=close, switchColor=true, candleCol=false, visualSwitch=true
Ichimoku CloudichimokuonchartconversionPeriod=9, basePeriod=26, laggingSpanPeriod=52, displacement=26
Kaufman’s Adaptive Moving AveragekamaoncharterLength=10, fastLength=2, slowLength=30, source=close
Least Squares Moving Averagelsmaonchartlength=25, offset=0, source=close
Linear Regression Slopelinreg-slopepaneperiods=14
MA Crossma-crossonchartshortLength=9, longLength=26
CPR with Floor PivotcpronchartpivotMode=auto, showDaily=true, showWeekly=false, showMonthly=false, displaypivots=true, displaysupport=true, displayresistance=true, displaycpr=true, displayS1R1=false
McGinley Dynamicmcginley-dynamiconchartlength=14
Medianmedianonchartsource=hl2, length=3, atrLength=14, atrMult=2
Moving Average Ribbonma-ribbononchartfour groups showMaN=true, maNType=SMA, maNSource=close, maNLength (20 / 50 / 100 / 200) for N = 1..4
Parabolic SARparabolic-saronchartstart=0.02, increment=0.02, maximum=0.2
SeasonalityseasonalitypanestartYear=2015, cutoffPercent=10, tablePosition=Center, tableWidth=100, tableHeight=95, showAvg=true, showStDev=true, showPos=true, ignoredMonths='YYYY-MM, YYYY-MM'
SMAsmaonchartlength=9, source=close
Smoothed Moving Averagesmmaonchartlength=7, source=close
Supertrendsupertrendonchartperiod=10, multiplier=3
T3 Averaget3onchartlength=5, factor=0.7, source=close, highlightMovements=true
Time Weighted Average Pricetwaponchartanchor=session, source=ohlc4, offset=0
Trend Strength Indextrend-strength-indexpanelength=14
Triple EMAtemaonchartlength=9
Volatility Stopvolatility-stoponchartlength=20, source=close, factor=2
Vortex Indicatorvortexpanelength=14
Williams AlligatoralligatoronchartjawLength=21, teethLength=13, lipsLength=8, jawOffset=8, teethOffset=5, lipsOffset=3
Williams Fractalswilliams-fractalsonchartperiods=2, showUp=true, showDown=true
WMAwmaonchartlength=9, source=close

Momentum (29)

IndicatoridPlacementKey settings
Awesome Oscillatorawesome-oscillatorpanecolours only (5 and 34 are the definition, not a preference)
Balance of Powerbalance-of-powerpanecolours only
CCIccipaneperiod=20, constant=0.015, maType=SMA, maLength=20, bbMult=2
Chande Momentum Oscillatorchande-momentumpanelength=9, source=close
Connors RSIconnors-rsipanelenrsi=3, lenupdown=2, lenroc=100
Coppock Curvecoppock-curvepanewmaLength=10, longRoCLength=14, shortRoCLength=11
Detrended Price Oscillatordpopaneperiod=21, isCentered=false
Fisher Transformfisher-transformpanelength=9
Know Sure Thingknow-sure-thingpaneroclen1=10, roclen2=15, roclen3=20, roclen4=30, smalen1=10, smalen2=10, smalen3=10, smalen4=15, siglen=9
MACDmacdpanefastPeriod=12, slowPeriod=26, signalPeriod=9, source=close
Momentummomentumpanelen=10, source=close
Money Flow Indexmfipaneperiod=14
Percentage Price Oscillatorppopanesource=close, fastLength=12, slowLength=26, signalLength=9, oscType=EMA, sigType=EMA
Pring’s Special Kspecial-kpanesource=close, length1=100, length2=100
Rate Of Changerocpanelength=9, source=close
Relative Vigor Indexrelative-vigor-indexpanelength=10, offset=0
RSIrsipanelength=14, source=close, overbought=70, oversold=30
RSI Divergence Indicatorrsi-divergencepanelength=14, source=close, lbR=5, lbL=5, rangeUpper=60, rangeLower=5, plotBull=true, plotHiddenBull=false, plotBear=true, plotHiddenBear=false
SMI Ergodic Indicatorsmi-ergodic-indicatorpanelonglen=20, shortlen=5, siglen=5
SMI Ergodic Oscillatorsmi-ergodic-oscillatorpanelonglen=20, shortlen=5, siglen=5
StochasticstochasticpanekPeriod=14, kSmoothing=1, dPeriod=3
Stochastic Momentum IndexsmipanelengthK=10, lengthD=3, lengthEMA=3
Stochastic RSIstochastic-rsipanesmoothK=3, smoothD=3, lengthRSI=14, lengthStoch=14, source=close
TRIXtrixpanelength=18
True Strength Indextsipanelong=25, short=13, signal=13
Ultimate Oscillatorultimate-oscillatorpanelength1=7, length2=14, length3=28
WaveTrend Prowavetrendpanesource=hlc3, n1=10, n2=21, sigLen=4, obLevel1=60, obLevel2=53, osLevel1=-60, osLevel2=-53, filterZone=true, useInner=true, showMom=true, showRegDiv=true, showHidDiv=false, lbL=3, lbR=3, rangeUpper=60, rangeLower=5
Williams Percent Rangewilliams-percent-rpanelength=14, source=close
Woodies CCIwoodies-ccipanecciTurboLength=6, cci14Length=14

Volatility (22)

IndicatoridPlacementKey settings
ATRatrpaneperiod=14
Average Daily Rangeaverage-daily-rangepanelength=14
BBTrendbb-trendpaneshortLength=20, longLength=50, stdDevMult=2
Bollinger Bandsbollingeronchartlength=20, stdDev=2, source=close
Bollinger Bands %bbollinger-percent-bpanelength=20, source=close, mult=2
Bollinger BandWidthbollinger-bandwidthpanelength=20, source=close, mult=2, expansionLength=125, contractionLength=125
Chaikin Volatilitychaikin-volatilitypaneperiods=10, rocLookback=10
Chop Zonechop-zonepanecolours only (nine zone colours)
Choppiness Indexchoppiness-indexpanelength=14, offset=0
Donchian Channelsdonchianonchartlength=20, offset=0
Envelopeenvelopeonchartlength=20, percent=10, source=close, exponential=false
Historical Volatilityhistorical-volatilitypanelength=10, per=1
Keltner Channelskeltner-channelonchartlength=20, mult=2, source=close, exp=true, bandsStyle=Average True Range, atrlength=10
Mass Indexmass-indexpanelength=10
Moving Average Channelma-channelonchartupperLength=20, lowerLength=20, upperOffset=0, lowerOffset=0
Range Analysisrange-analysispaneshowAverage=false, avgLength=3
Relative Volatility Indexrelative-volatility-indexpanelength=10, offset=0, maType=SMA, maLength=14, bbMult=2
Standard Deviationstandard-deviationpaneperiods=5, deviations=1
Standard Errorstandard-errorpanelength=14
Standard Error Bandsstandard-error-bandsonchartperiods=21, errors=2, method=Simple, averagePeriods=3
Ulcer Indexulcer-indexpanesource=close, length=14
William VIX FIXwilliams-vix-fixpanepd=22, bbl=20, mult=2, lb=50, ph=0.85, pl=1.01, hp=false, sd=false

Volume (15)

IndicatoridPlacementKey settings
Accumulation/Distributionadlpanecolours only
Chaikin Money Flowchaikin-money-flowpanelength=20
Chaikin Oscillatorchaikin-oscillatorpaneshort=3, long=10
Ease of Movementease-of-movementpanelength=14, divisor=10000
Elder Force Indexelder-force-indexpanelength=13
Klinger Oscillatorklinger-oscillatorpanecolours only
Negative Volume IndexnvipanemaLength=255
Net Volumenet-volumepanecolours only
On-Balance VolumeobvpanemaType=None, maLength=9, bbMult=2
Percentage Volume OscillatorpvopanefastLength=12, slowLength=26, signalLength=9, oscType=EMA, sigType=EMA
Positive Volume IndexpvipanemaLength=255
Price Volume Trendpvtpanecolours only
Volumevolumepanecolours only
Volume Weighted Moving Averagevwmaonchartlength=20, source=close, offset=0
VWAPvwaponchartanchor=session, source=hlc3, offset=0, calcMode=stdev, showBand1=true, bandMult1=1, showBand2=false, bandMult2=2, showBand3=false, bandMult3=3

Adding the same indicator more than once gives each instance its own colours: three EMAs in one default blue are indistinguishable on the chart and in the legend alike. Only colour keys you leave unset are filled, so chart.addIndicator('ema', { color: '#123456' }) always wins, and the first instance keeps whatever the descriptor chose.

VWAP

vwap is a volume-weighted average with a resettable anchor and three optional standard-deviation band pairs. Seven plots in all: the average plus an upper and lower band for each of three multipliers, of which only the first pair is on by default.

SettingDefaultNotes
anchorsessionsession, week, month, quarter, year, or continuous.
sourcehlc3Any IndicatorSource.
offset0Shift the whole study left or right, in bars (-500..500).
calcModestdevstdev for standard-deviation bands, percent for a fixed percentage of the average.
showBand1 / bandMult1true / 1Band pair 1, drawn by default.
showBand2 / bandMult2false / 2Band pair 2.
showBand3 / bandMult3false / 3Band pair 3.

A hidden band still declares its plot and returns a null column, so turning one on is a setSettings call. Each pair also declares a fill, giving three nested shaded envelopes when all three are on. The average draws in #2962ff; each pair has its own colour key (band1Color, band2Color, band3Color) shared by its upper line, lower line, and fill.

live
Rendering live chart…
VWAP with two standard-deviation band pairs, each shading the region it encloses.
View example code
const chart = lib.createChart(el);
const bars = lib.generateBars(1700000000, 220, 3600);
chart.addSeries('candlestick').setData(bars);

chart.addIndicator('vwap', { anchor: 'continuous', showBand2: true });

chart.timeScale.fitContent(bars.length);
return chart;

Every anchor resets on the chart’s calendar, which is Asia/Kolkata until you set timezone to something else, so a period test never straddles the UTC midnight seam and a US symbol can be anchored on New York’s days rather than India’s. chart.setTimezone(...) recomputes the study, so the bands move with the axis. week is Monday-based; continuous never resets, giving a running VWAP over all loaded data.

HalfTrend

A trend level that deliberately does not move with price. Two state machines run at once: trend is what is drawn, and a flip is armed before it fires. While a down-flip is armed the indicator tracks the running maximum of the amplitude-bar low, and the flip only happens when the mean high drops under that maximum and the bar closes below the previous bar’s low. Needing both a mean crossing and a close beyond the prior extreme is what keeps the line flat through noise. On a flip the new level starts from where the other side ended, which is why it steps instead of jumping to price.

Every visual layer is independently toggleable: showChannels for the half-ATR bands, showSignals for the flip dots, showLabels for the Buy/Sell plates.

live
Rendering live chart…
HalfTrend: stepped level, half-ATR channel, trend-tinted ribbon, and Buy/Sell labels.
View example code
const chart = lib.createChart(el);
const bars = lib.generateBars(1700000000, 200, 3600);
chart.addSeries('candlestick').setData(bars);

chart.addIndicator('halftrend', { amplitude: 2, channelDeviation: 2 });

chart.timeScale.fitContent(bars.length);
return chart;

Signal markers

A descriptor can return bar-anchored markers as well as plots. A plot is a column of prices drawn as a line or a histogram; a signal is a discrete event with a name, and no plot expresses that. markers runs after every calc, so it reads the values it just produced:

registerIndicator({
  id: 'my-cross',
  name: 'My Cross',
  placement: 'onchart',
  inputs: [{ key: 'showLabels', type: 'boolean', label: 'Show Labels', default: true }],
  plots: [{ key: 'signal', type: 'line', title: 'Signal', colorKey: 'color' }],
  calc: (bars, s) => ({ signal: /* ... */ [] }),
  markers: ({ bars, values, settings }) => {
    if (settings.showLabels === false) return [];   // returning [] clears the layer
    return values.signal.flatMap((v, i) =>
      v === null ? [] : [{
        time: bars[i].time, position: 'atPrice' as const, price: v,
        shape: 'labelUp' as const, size: 'small' as const,
        color: '#2962ff', text: 'Buy',
      }]);
  },
});

The runtime creates the marker layer lazily on the first plot’s series, so an indicator without the hook costs nothing. Marker time values must match a real bar time or SeriesMarkers drops them silently. Hiding the indicator clears its markers too.

text may carry \n. A labelUp / labelDown plate stacks the rows and sizes itself to the widest one, staying centred on its anchor so the tail still points at the bar; a free text marker writes its rows outward from the anchor, so a block above a bar grows upward instead of back over the candle.

Five built-ins use the hook:

IndicatorWhat it marks
halftrendA Buy or Sell plate at each trend flip, suppressed by showLabels: false.
williams-fractalsAn up or down triangle at each confirmed fractal, filtered by showUp / showDown.
rsi-divergenceBull, Bear, H Bull, and H Bear labels on the RSI line, one per enabled plot* input.
alphatrendA Buy or Sell plate where the level crosses its own two-bar lag.
wavetrendA tinted dot at each signal-line cross, plus R and H plates for regular and hidden divergences, filtered by showRegDiv / showHidDiv.

See markers for the shape catalogue.

Tables

An indicator whose output is a matrix rather than a column of prices has nowhere to put it: a plot is one price per bar, and a monthly return heatmap is neither. table is an optional hook beside markers, returning rows of cells and the options to draw them with. Like markers, it runs after every calc, so it reads the values it just produced:

registerIndicator({
  id: 'my-scoreboard',
  name: 'My Scoreboard',
  placement: 'pane',
  inputs: [],
  // A pane needs at least one plot to exist. An all-null column draws nothing
  // and contributes nothing to autoscale, which is what a table-only indicator
  // wants: the pane gets no price axis at all.
  plots: [{ key: 'placeholder', type: 'line', title: 'Scoreboard' }],
  calc: (bars) => ({ placeholder: new Array(bars.length).fill(null) }),
  table: ({ bars, values, settings }) => ({
    rows: [
      [{ text: 'Metric', bold: true }, { text: 'Value', bold: true }],
      [{ text: 'Bars' }, { text: String(bars.length) }],
      [{ text: 'Last' }, { text: bars.at(-1).close.toFixed(2), bgColor: '#089981' }],
    ],
    options: { position: 'top-right', cellWidth: [80, 60], cellHeight: 18 },
  }),
});

Returning null draws nothing, which is how an indicator says it has too little data to tabulate. A header row on its own reads as a broken table rather than an empty one.

TableCell

FieldTypeDescription
textstringCell contents. An empty string draws the fill without any label.
bgColorstring?Cell fill. Transparent when omitted, so the pane shows through.
textColorstring?Derived from bgColor for contrast when omitted.
align'left' | 'center' | 'right'Default center.
fontSizenumber?Overrides the table’s fontSize for this cell, for a heading row.
boldboolean?

ChartTableOptions

OptionDefaultDescription
position'bottom-right'One of nine keywords: top/middle/bottom crossed with left/center/right.
margin8Gap from the pane edge, media px.
cellWidth64Column width in media px. A per-column array sizes each one separately.
cellHeight18Row height in media px.
widthPercentunsetTotal width as a percentage of the plot. Column proportions are preserved, so a per-column array still controls the relative widths. 0 or unset sizes from cellWidth.
heightPercentunsetTotal height as a percentage of the plot. 0 or unset sizes from cellHeight.
rowWeightsunsetRelative row heights, one per row, defaulting to 1. A separator row wants something like 0.3, or a stretched table makes the rule between two sections as tall as the sections.
fontSize11Shrinks automatically when a stretched row is shorter than this.
borderColorunsetGrid line colour. Omit to draw no grid.
borderWidth1
backgroundunsetBackdrop behind the whole grid, drawn before the cells.
idunsetHit-test id, so a host can route clicks the way it does for other primitives.

The table is a screen-space primitive: it is pinned to the pane, not to bars, so it stays put while the chart pans underneath and survives a zoom untouched. It takes no part in autoscale.

One built-in uses the hook: seasonality, whose entire output is the grid. Its tableWidth and tableHeight inputs default to 100% and 95%, so it fills the pane.

ChartTable is also usable directly as a primitive without an indicator. See primitives.

Drawings

A marker pins a glyph to a bar and a level runs a horizontal line across the pane. Neither draws geometry between two arbitrary points, and a plot cannot either, since a plot is one value per bar: a pivot-to-pivot trendline, a supply zone, a measured-move projection have nowhere to live. draws is the hook for those. Like markers and table it runs after every calc, and the list it returns replaces the previous one wholesale, so returning [] clears the layer.

Anchors are times and prices, never bar indices. Paging older history in at the left edge shifts every logical index, and a trendline pinned to an index would slide off its pivots the moment it did.

import { registerIndicator } from 'openalgo-charts';
import { pivotHigh, nulls } from 'openalgo-charts/indicators';
 
registerIndicator({
  id: 'pivot-ray',
  name: 'Pivot Ray',
  category: 'Trend',
  placement: 'onchart',
  inputs: [
    { key: 'left',  type: 'number', label: 'Left bars',  default: 5, min: 1, max: 50 },
    { key: 'right', type: 'number', label: 'Right bars', default: 5, min: 1, max: 50 },
    { key: 'color', type: 'color',  label: 'Color',      default: '#f5a524' },
  ],
  // The output is geometry, not a value per bar, so the only plot is an all-null
  // placeholder: it draws nothing and contributes nothing to autoscale.
  plots: [{ key: 'placeholder', type: 'line', title: 'Pivot Ray' }],
  calc: (bars, s) => ({
    placeholder: bars.map(() => null),
    pivots: nulls(pivotHigh(bars.map((b) => b.high), Number(s.left), Number(s.right))),
  }),
  draws: ({ bars, values, settings }) => {
    // The two most recent confirmed pivot highs. A pivot is confirmed `right`
    // bars after it happens, so the anchor is the bar it describes rather than
    // the bar that reported it.
    const back = Number(settings.right);
    const at: number[] = [];
    for (let i = values.pivots.length - 1; i >= 0 && at.length < 2; i--) {
      if (values.pivots[i] !== null) at.push(i - back);
    }
    if (at.length < 2) return [];
    const [recent, older] = at;
    const color = String(settings.color);
    return [
      {
        kind: 'line',
        from: { time: bars[older].time,  price: bars[older].high },
        to:   { time: bars[recent].time, price: bars[recent].high },
        color, lineWidth: 2, extendRight: true,
      },
      {
        kind: 'label',
        at: { time: bars[recent].time, price: bars[recent].high },
        text: `Pivot\n${bars[recent].high.toFixed(2)}`,
        color, align: 'left',
      },
    ];
  },
});

Four kinds. Every colour defaults to the theme’s axis ink, and every anchor is a DrawAnchor, which is { time, price }.

kind: 'line'

FieldTypeDescription
from / toDrawAnchorThe two ends.
colorstring?
lineWidthnumber?Media px, default 1.
lineStyle'solid' | 'dashed' | 'dotted'?
extendLeft / extendRightboolean?Continue past that anchor to the pane edge, along the segment’s own slope: a ray keeps its angle rather than flattening. A vertical line spans the pane on either flag.

kind: 'box'

FieldTypeDescription
from / toDrawAnchorOpposite corners, in either order.
colorstring?Border, and the caption plate’s fill.
fillColorstring?Omit to draw an outline only.
opacitynumber?Fill alpha, 0..1, default 0.12.
lineWidthnumber?Media px, default 1.
textstring?Caption on a plate at the centre of the box; \n splits lines.
textColorstring?Derived from the plate colour for contrast when omitted.

kind: 'label'

FieldTypeDescription
atDrawAnchorWhere the plate sits.
textstring\n splits lines; the plate sizes to the widest.
colorstring?Plate fill.
textColorstring?Contrast-derived when omitted.
align'left' | 'center' | 'right'?Which edge of the plate sits on the anchor. Default center.

kind: 'polyline'

FieldTypeDescription
pointsDrawAnchor[]Two or more; a shorter list draws nothing.
colorstring?
lineWidthnumber?Media px, default 1.
closedboolean?Close the path back to the first point (a triangle, a wedge).
fillColorstring?Omit for an unfilled path.
opacitynumber?Fill alpha, 0..1, default 0.12.
// A supply zone, and a closed wedge over the same span.
const zone: IndicatorDrawing = {
  kind: 'box',
  from: { time: t0, price: hi }, to: { time: t1, price: lo },
  color: '#ef5350', fillColor: '#ef5350', opacity: 0.1,
  text: `Supply\n${hi.toFixed(2)}`,
};
const wedge: IndicatorDrawing = {
  kind: 'polyline',
  points: [{ time: t0, price: hi }, { time: t1, price: mid }, { time: t0, price: lo }],
  closed: true, color: '#4f8cff', fillColor: '#4f8cff',
};

lineStyle belongs to line alone; boxes and polylines stroke solid.

What the layer does with what you return:

  • It draws over the series and under the crosshair: these are annotations on the data.
  • It takes no part in autoscale. A projection reaching far above the data would otherwise squash the study it annotates into a band a few pixels tall. The corollary is that a shape drawn outside the visible price range is simply not on screen.
  • Shapes entirely off-pane are culled before any path work, so a descriptor that marks every pivot in 50k bars pays for the visible ones.
  • A time that falls between two bars lands between them rather than snapping to the nearer one, and a time past the last bar extrapolates at the last interval’s spacing. That is what lets a projection reach into the empty space on the right, to a time no bar occupies yet.
  • The layer is created lazily, on the first non-empty list, so a descriptor without the hook (or one that has not drawn anything yet) costs nothing. Hiding the indicator hides the drawings with it; removing the indicator detaches them.

IndicatorDrawings is exported, so the same layer works as a plain primitive with no indicator behind it:

import { IndicatorDrawings } from 'openalgo-charts';
 
const layer = new IndicatorDrawings();
chart.addPrimitive(layer, 0);       // pane index
layer.setItems([{ kind: 'line', from: a, to: b, color: '#4f8cff' }]);
layer.setVisible(false);

Trading sessions

An indicator that accumulates within a trading day (VWAP, TWAP, daily pivots) has to know where one day ends. A calendar midnight is the wrong answer for every exchange but the one whose timezone you picked: 00:00 IST is 18:30 UTC, which is the middle of a New York session. Anchoring there restarts a VWAP every afternoon, and builds a “daily” range out of one session’s tail plus the next session’s head across the overnight gap.

Setting the chart’s timezone fixes which calendar is consulted, and on a US symbol you should. It does not remove the need to read the session, though: a calendar day is still not a trading day, and the bars know better than either.

The session is visible in the bars themselves, as the widest recurring gap, so read it back rather than assuming:

import { sessionStartFlags, calendarPeriodFlags, DEFAULT_TIMEZONE } from 'openalgo-charts';
 
calc: (bars, settings) => {
  // The chart puts its zone on the settings blob under the reserved `timezone`
  // key, so the calendar fallback follows the axis without threading a global.
  const zone = typeof settings.timezone === 'string' ? settings.timezone : DEFAULT_TIMEZONE;
  const times = bars.map((b) => b.time);
  const newSession = sessionStartFlags(times, zone); // first bar of each trading day
  let sum = 0;
  const out = bars.map((b, i) => {
    if (newSession[i]) sum = 0;
    sum += b.close;
    return sum;
  });
  return { running: out };
}
ExportReturnsUse
sessionStartIndices(times)number[] | nullThe bar indices that open a session, or null when the series shows no readable break.
sessionStartFlags(times, zone?)boolean[]One flag per bar. Falls back to calendar days in zone (default Asia/Kolkata) when sessions are unreadable.
calendarPeriodFlags(times, isNew)boolean[]A week, month or year boundary, tested on session opens so a session is never cut in half.

sessionStartIndices returns null, and the flag helpers fall back to a calendar day, in exactly the cases where there is nothing to read: bars already a day or coarser, a market that never closes, or a feed whose only gaps are weekends. An intraday lunch break is shorter than the four-hour floor, so it is not mistaken for a close.

A window you state rather than read

The helpers above read a session back from the bars, which is right when what you mean is “the trading day”. It is the wrong tool when you mean a particular window inside one: an opening range, the cash session inside an extended one, or one exchange’s hours drawn on another exchange’s chart. The bars cannot tell you which part of a continuous session you meant, so you state it:

import { sessionFlags, parseSessionSpec, inSessionAt, DEFAULT_TIMEZONE } from 'openalgo-charts';
 
calc: (bars, settings, store, ctx) => {
  const zone = ctx?.timezone ?? DEFAULT_TIMEZONE;
  // The first hour, on whichever calendar the axis is labelled in.
  const opening = sessionFlags(bars.map((b) => b.time), '0915-1015', zone);
  let hi = -Infinity, lo = Infinity;
  return {
    orHigh: bars.map((b, i) => {
      if (i > 0 && opening[i] && !opening[i - 1]) { hi = -Infinity; lo = Infinity; }
      if (opening[i]) { hi = Math.max(hi, b.high); lo = Math.min(lo, b.low); }
      return Number.isFinite(hi) ? hi : null;
    }),
  };
}
ExportReturnsUse
parseSessionSpec(spec)SessionSpec | nullParse '0915-1015', or '0930-1600:23456' with a day filter. null on anything it does not recognise, never a throw.
inSessionAt(utcSeconds, spec, zone?)booleanOne instant against a parsed spec.
sessionFlags(times, spec, zone?)boolean[]One flag per bar. Takes the raw string or a parsed spec.

The grammar is HHMM-HHMM, optionally followed by : and the days the window runs on, 1 = Sunday through 7 = Saturday, so '0930-1600:23456' is Monday to Friday and an absent filter means every day. Whitespace around the parts is ignored.

  • The window is half-open: the start minute is inside it, the end minute is not. A bar stamped exactly 10:15 belongs to what comes after a 0915-1015 window, which is what an opening-range comparison needs.
  • An end at or before the start runs past midnight, so '2330-0030' is a one-hour overnight window, and '0000-0000' is the whole day.
  • The day filter names the day the window opens on, not the day the bar falls on. For a window inside one day those are the same; for one that crosses midnight it keeps the session whole instead of lopping its tail off at 00:00.
  • zone defaults to Asia/Kolkata like every other zoned helper. Pass the chart’s, which a calc reads from ctx.timezone or from the reserved settings.timezone key.
  • An unparseable string marks nothing: sessionFlags returns an all-false array rather than throwing, because the spec is usually something a user is halfway through typing. Call parseSessionSpec yourself when you need to tell a bad spec from an empty window.

The IndicatorApi handle

addIndicator returns a handle:

MemberTypeDescription
idstringUnique instance id (several instances of one indicator can coexist).
indicatorIdstringThe descriptor id, e.g. 'macd'.
namestringDisplay name.
paneIndexnumberPane the indicator drew into.
settings()IndicatorSettingsCurrent settings, as a copy.
setSettings(patch)voidMerge a patch, recompute, and restyle.
series(plotKey)SeriesApi | undefinedThe series behind one plot, for direct styling.
values()IndicatorValuesLatest computed columns.
remove()voidRemove every series and level it created.

chart.indicators() lists the live instances; chart.removeIndicator(id) removes one by id. Removing an indicator also prunes the pane it emptied, so a host UI cannot strand a blank pane under the chart.

Inspecting the registry

The registry is readable, so a host can build its indicator UI from what is actually registered rather than a hardcoded list, including anything you registered yourself. The yfinance example builds its whole picker this way, grouped by category.

Every export below comes from the package root, openalgo-charts, not from the indicator tier: the registry is part of the base engine, and the tier only fills it.

ExportSignatureUse
registeredIndicators()-> IndicatorDescriptor[]Everything registered, in registration order. Read id, name, category, inputs, plots.
hasIndicator(id)(string) -> booleanGuard before addIndicator when the id is user-supplied, because addIndicator throws on an unknown one.
indicatorDefaults(descriptor)(IndicatorDescriptor) -> IndicatorSettingsDefault settings built from the descriptor’s inputs, for pre-filling a form. Takes the descriptor, not an id.
indicatorStyleInputs(descriptor)(IndicatorDescriptor) -> IndicatorInput[]Per-plot style inputs (colour, width, line style, opacity), so a settings dialog can be generated instead of written per indicator.
plotStyleKeys(plot)(IndicatorPlot) -> { color, width, lineStyle, opacity, type }The settings keys belonging to one plot, for reading or patching its style. type overrides the chart type it draws as.
INDICATOR_LINE_STYLESreadonly { label, value }[]The line-style choices a generated form should offer.
INDICATOR_PLOT_STYLESreadonly { label, value }[]Chart types a plot can be re-rendered as (line, line with markers, step, area, histogram, columns).
INDICATOR_SOURCESreadonly { label, value }[]The price-source choices for a type: 'source' input.

The same plot-style list also arrives on the generated <plotKey>:type input, so a form built from indicatorStyleInputs(descriptor) already has it without a second import:

const typeInput = indicatorStyleInputs(getIndicator('rsi'))
  .find((i) => i.key === 'rsi:type');
typeInput.options;   // same list as INDICATOR_PLOT_STYLES
import {
  registeredIndicators, hasIndicator, indicatorDefaults,
} from 'openalgo-charts';
import 'openalgo-charts/indicators';   // registers the 102 built-ins
 
// Group the catalog for a menu, with no per-indicator code.
const byCategory = new Map<string, string[]>();
for (const d of registeredIndicators()) {
  const list = byCategory.get(d.category) ?? [];
  list.push(d.name);
  byCategory.set(d.category, list);
}
 
// Settings are the second argument, and the defaults come off the descriptor.
const descriptor = registeredIndicators().find((d) => d.id === wanted);
if (descriptor !== undefined) {
  chart.addIndicator(descriptor.id, indicatorDefaults(descriptor));
}

Generating a settings form is the same shape: walk descriptor.inputs for the values and indicatorStyleInputs(descriptor) for the per-plot styling, and render each by its type:

for (const input of [...descriptor.inputs, ...indicatorStyleInputs(descriptor)]) {
  // input.type: 'number' | 'source' | 'color' | 'select' | 'boolean' | 'text'
  renderField(input.key, input.type, input.label, instance.settings()[input.key]);
}

The catalog is empty until the tier is imported. registeredIndicators() reflects what has been registered so far, so read it after your import 'openalgo-charts/indicators'.

Writing your own

An indicator is data, not code in the core: the chart never switches on an id. Each plot names a registered chart type, so your indicator draws through the same renderers as any other series and adds no drawing code.

import { registerIndicator, sourceValues } from 'openalgo-charts';
 
registerIndicator({
  id: 'momentum',
  name: 'Momentum',
  category: 'Momentum',
  placement: 'pane',
  inputs: [
    { key: 'length', type: 'number', label: 'Length', default: 10, min: 1, max: 500 },
    { key: 'source', type: 'source', label: 'Source', default: 'close' },
    { key: 'color',  type: 'color',  label: 'Color',  default: '#4f8cff' },
  ],
  plots: [{ key: 'mom', type: 'line', title: 'Momentum', colorKey: 'color' }],
  calc: (bars, s) => {
    const v = sourceValues(bars, s.source);
    const n = s.length;
    return { mom: v.map((x, i) => (i >= n ? x - v[i - n] : null)) };
  },
  levels: () => [{ price: 0, color: '#5a6b8c', dashed: true }],
});
 
chart.addIndicator('momentum', { length: 14 });

Shaded bands between plots

A pair of lines is not the same picture as a filled region, so a descriptor can declare fills: the Ichimoku cloud, a Bollinger channel. 28 of the 102 built-ins declare one or more, 38 fills in total.

plots: [
  { key: 'spanA', type: 'line', title: 'Senkou Span A', colorKey: 'spanAColor' },
  { key: 'spanB', type: 'line', title: 'Senkou Span B', colorKey: 'spanBColor' },
],
fills: [{
  between: ['spanA', 'spanB'],
  colorUpKey: 'cloudUpColor',     // settings keys, so the band is restyleable
  colorDownKey: 'cloudDownColor',
  opacity: 0.14,
}],

Two colours, because which side leads is itself the signal. The band draws behind the plots, contributes nothing to autoscale, splits at the exact crossing rather than the nearest bar, and breaks across a gap in either plot instead of bridging it. Pass colorUp / colorDown literals instead of the *Key forms for a fixed band.

Filling against a column that is never plotted

between names calc output columns, not declared plots, and that is the part worth internalising: calc may return more columns than there are plots, and a fill can use any of them. An edge that exists only to bound a shaded region does not have to be a line on the chart.

Three built-ins lean on this:

  • rsi returns upperLevel and lowerLevel alongside rsi, both constant at the overbought / oversold settings, and fills between them. The overbought/oversold background is therefore a fill between two columns that are never drawn, so changing overbought moves the shading with no extra plot to restyle. stochastic shades the same way.
  • aroon-oscillator returns a zero column and fills ['osc', 'zero'], so the band flips colour with the sign of the oscillator. A colorBy would recolour the stroke, but it is the shaded region between the line and zero that actually reads on a chart.
  • supertrend returns a hidden bodyMid column, (open + close) / 2 per bar, and declares one fill from it to up and another to down. That shades the whole distance between price and the trailing stop instead of drawing a second line through the candles. Only one side is live at a time, so the other fill resolves to nothing and the shading recolours at the flip along with the stop.
calc: (bars, s) => {
  const osc = /* ... */ [];
  return {
    osc,
    // No plot names this column. Match the warmup nulls or the band runs back
    // past where the line starts.
    zero: osc.map((v) => (v === null ? null : 0)),
  };
},
fills: [{ between: ['osc', 'zero'], colorUpKey: 'upColor', colorDownKey: 'downColor' }],

A column with no plot costs one array and nothing on screen, so this is the cheap way to draw a region rather than a pair of lines.

Colouring a plot per bar

One colour for a whole series cannot express a study whose meaning changes bar to bar, so a plot can name a colorBy:

{
  key: 'histogram', type: 'histogram', title: 'Histogram',
  colorBy: ({ value, index, values, settings }) =>
    value >= 0 ? '#26a69a' : '#ef5350',
}

Return undefined to fall back to the plot’s own colour. It reaches the renderer as Bar.color, so every Family-A plot type honours it: histogram, column, line, step, area, candles and OHLC bars. values is the full calc result, so a rule can read columns no plot draws: MACD compares against the previous bar, and William VIX FIX reads its band even when the band is hidden.

calc must return an array for every plot key, the same length as bars, plus any extra columns a fill or a colorBy needs. Use null for warmup slots: the line renderer breaks across them and autoscale skips them, so a gap draws as nothing rather than a spike to zero.

Reference levels

levels draws horizontal lines across the indicator’s pane: RSI 70/30, a zero line, yesterday’s high. It runs after every calc and is handed the bars and the computed values as well as the settings, so a level can be derived from the data rather than only from a number the user typed.

levels: (ctx) => {
  const bars = ctx.bars ?? [];
  const last = bars.at(-1);
  return [
    { price: Number(ctx.oversold), title: 'Oversold' },          // from a setting
    ...(last === undefined ? [] : [
      { price: last.high, title: 'Day high', lineStyle: 'dotted' as const },
    ]),
  ];
},

The context is a settings bag with three members added to it. That is why ctx.oversold reads a declared input directly, and why every descriptor written against the original levels(settings) signature keeps working unchanged:

MemberTypeDescription
(the settings keys)Every setting, spread onto the context.
settingsIndicatorSettingsThe same values as one object, for a helper that takes a bag.
barsBar[]The bars the last calc ran over.
valuesIndicatorValuesWhat that calc returned.

settings, bars and values are reserved keys here, the way timezone is reserved in the settings a calc receives: an input declared under one of those names is shadowed. The three are typed optional so a caller holding only a settings bag can still call levels directly, but the runtime always passes all three. Default them anyway (ctx.bars ?? []).

The returned list is compared against the previous one and the price lines are only rebuilt when something actually changed, so a constant 70/30 pair does not detach and reattach a line per level on every tick.

IndicatorLevel

FieldTypeDescription
pricenumberWhere the line sits.
colorstring?Default #8892a6.
titlestring?Right-axis tag text.
lineWidthnumber?Media px, default 1.
lineStyle'solid' | 'dashed' | 'dotted'?
dashedboolean?The original two-state switch. lineStyle wins when both are set; with neither set a level draws dashed, which is how every built-in already drew.

One plot on the price pane

An oscillator sometimes has one column that belongs on the candles: a trailing stop, a band the price is measured against. Splitting the study in two would make the user configure the same inputs twice, so a single plot can force itself onto the price pane instead:

placement: 'pane',
plots: [
  { key: 'osc',  type: 'line', title: 'Oscillator' },
  { key: 'stop', type: 'line', title: 'Stop', overlay: true },   // drawn on price
],

It is still one instance: one legend row, one settings form, one remove(). overlay is ignored for an onchart descriptor, which is already on the price pane. Fills, levels, drawings and the table stay on the indicator’s own pane; markers ride the first plot’s series, so marking the first plot overlay takes them to the price pane with it.

A plot drawn as candles

A single column cannot express a bar. A smoothed Heikin-Ashi overlay, a higher-timeframe candle, a synthetic spread instrument are all four numbers per bar, so a plot can name four calc columns instead of one:

plots: [{
  key: 'ha',
  type: 'candlestick',                 // or 'hollow-candle', 'bar', 'high-low'
  title: 'Heikin-Ashi',
  ohlc: { open: 'haOpen', high: 'haHigh', low: 'haLow', close: 'haClose' },
}],
calc: (bars) => ({
  haOpen: [...], haHigh: [...], haLow: [...], haClose: [...],   // four ordinary columns
}),

The four columns live in the same IndicatorValues every other descriptor returns, so calc keeps one shape and every helper written against it keeps working. A second return type would have forked the contract for all 102 built-ins.

  • key stays the series identity, the thing instance.series('ha') resolves and the thing the style settings are keyed on. The legend reading falls back to the close column.
  • All four columns must exist and be exactly bars.length long. A wrong name throws out of chart.addIndicator, because the first calc runs while the instance is being constructed, so you get an error rather than an empty pane.
  • colorBy still applies and is judged on the close value, reaching body, border and wick together the way Bar.color does.
  • A user overriding the plot’s chart type through its generated <plotKey>:type setting degrades quietly: line, area and histogram read the close column.

Shading the pane by regime

A regime study answers “which state is the market in right now”, and that is a property of the whole bar, not a price. Drawn as a plot it would need a value to sit at and would drag the pane’s autoscale around with it; drawn as a column behind the data it reads at a glance and costs the scale nothing.

background: ({ bars, values }) => values.trend.map((v) =>
  v === null ? null : v > 0 ? 'rgba(38,166,154,0.10)' : 'rgba(239,83,80,0.10)'),

One entry per bar, null for a bar that should not be shaded. It runs after every calc alongside markers, table and draws, and the returned list replaces the previous one wholesale, so returning [] clears the layer.

  • The shading contributes nothing to autoscale, and it is drawn in the indicator’s own pane.
  • Band edges are bar midpoints, so consecutive bars sharing a colour coalesce into one fill and two neighbouring runs abut exactly rather than overlapping by a pixel. Everything outside the visible range is dropped before any painting, so a two-state regime over 50k bars is a handful of fills per frame, not one per bar.
  • Pass a translucent rgba(). The layer sits below the series but above the grid, so an opaque colour hides the grid lines inside its band.
  • The colours are anchored to the first bar’s time, so a page of history arriving at the left edge does not slide the shading off the bars it describes.
  • The layer is created lazily on the first non-empty result, and hidden rather than detached when the indicator is hidden.

IndicatorBackground is exported, so the same layer works as a plain primitive with no indicator behind it, the way IndicatorDrawings does:

import { IndicatorBackground } from 'openalgo-charts';
 
const shade = new IndicatorBackground();
chart.addPrimitive(shade, 0);          // pane index
shade.setColors(colors, bars);         // `bars` supplies the time anchor
shade.setVisible(false);

Recolouring the price candles

colorBy paints the indicator’s own series. barColors paints the price bars, which is a different statement: a trend filter, a volatility regime or a higher-timeframe bias is a claim about the candles themselves, and drawing it as a second series beside them says something weaker.

barColors: ({ bars, values }) => values.bias.map((v) =>
  v === null ? null : v > 0 ? '#26a69a' : '#ef5350'),

One entry per price bar, null to leave that bar its own colour. Hiding the indicator withdraws the colours, and so does removing it.

The engine never writes into the bar objects you handed setData: it clones only the bars whose colour actually changes and republishes those, so the overlay cannot reach back into your array or outlive the indicator. A pass where nothing changed writes nothing at all, which is the common case on a live tick.

⚠️

Only one indicator’s colours can be on the candles at a time. The most recent publisher wins, which is deterministic rather than arbitrary: publishers run in addIndicator order, so the same instance wins every frame. Two known gaps follow from keeping this cheap:

  • Removing the winner while a second publisher is still live drops the bars back to their own colours until that publisher’s next recompute.
  • The bars’ own colours are snapshotted the first time an index is touched, and the snapshot is dropped when the first bar’s time changes. Prepending older history changes it, so the snapshot is retaken from bars that already carry the overlay. That is invisible while the indicator is live, and shows up as a tinted left-hand region if you then remove it.

The calculation context

calc and calcTail take one more optional argument: what the calculation cannot read off the bars themselves. It is optional and trailing, so every descriptor written against calc(bars, settings, store) keeps its exact signature and its exact behaviour.

calc: (bars, settings, store, ctx) => {
  const signal = compute(bars, settings);
  // Do not signal off a bar that is still moving. Default to acting, so a
  // caller that passes no context behaves the way it always did.
  const settled = ctx?.barState.isConfirmed ?? true;
  if (!settled) signal[signal.length - 1] = null;
  return { signal };
},
calcTail: (bars, settings, from, previous, store, ctx) => { ... },

IndicatorCalcContext

MemberTypeDescription
barState.isNewbooleanThe most recent update appended a bar rather than replacing one. False on a full history load, because there was no update to append.
barState.isConfirmedbooleanThe last bar’s own span has elapsed on the chart clock.
barState.isRealtimebooleanA live feed is driving updates rather than a one-off load. Sticky: set the first time a tail-only change lands, and never cleared.
barState.lastIndexnumberbars.length - 1, and -1 when there are no bars.
symbolstring?The instrument, when the host knows one.
intervalstring?The timeframe ('5m', '1d'), on the same terms.
timezonestringThe chart’s IANA zone, the calendar its axis is labelled in.
now()numberChart wall clock in UTC seconds, the clock the countdown row reads.

isConfirmed is inferred from the last bar’s gap against the chart clock, because that is the only interval signal the engine has: it is handed bars and never a timeframe. A session break or a holiday widens that gap, so read it as “this bar’s own span has elapsed”, not as “the exchange has closed”.

symbol and interval are undefined under chart.addIndicator, for the same reason they are on the attach context: the core is handed bars and never an instrument, so it answers “I do not know” rather than inventing a name. A host that owns the symbol picker supplies both through its own IndicatorHost. When you do have interval, the interval introspection helpers turn it into a count and a unit rather than a string match against the handful of codes you happened to test on.

Alerts

A crossover of an indicator’s own columns is something only that indicator knows how to name, so the condition is declared on the descriptor and the runtime watches it:

alerts: [{
  id: 'cross-up',                       // stable within the descriptor
  title: 'MACD crossed up',
  message: 'MACD histogram turned positive',   // defaults to `title`
  when: ({ values, index }) => {
    const h = values.histogram;
    return index > 0 && (h[index - 1] ?? 0) <= 0 && (h[index] ?? 0) > 0;
  },
}],

A trigger emits indicator:alert on the chart’s own bus:

chart.on('indicator:alert', ({ indicatorId, instanceId, alertId, title, time }) => {
  notify(`${title} at ${new Date(time * 1000).toLocaleTimeString()}`);
});

when is handed { bars, values, settings, index } and judges one bar, so a rule that compares against the previous bar reads index - 1 itself.

⚠️

Alerts fire only on a tail-only change, the same gate calcTail uses and for the same reason. Any other pass reseeds the watermark silently, so adding the indicator to a loaded chart, changing a setting, paging history in, or switching symbol announces nothing. An indicator dropped onto two years of bars must not fire every crossover in them at once.

The watermark is a bar time, not a count, so a page of older bars arriving at the left edge cannot re-fire the chart.

For a signal that arrives from outside the calculation entirely (a subscription your attach opened), use the imperative half instead: ctx.emit(event, payload) on the attach context puts anything you like on the same bus.

Descriptor reference

FieldTypeDescription
idstringRegistry key.
namestringDisplay name.
categorystring?Grouping for a picker UI.
placement'onchart' | 'pane'Overlay the price pane, or take a new one.
inputsIndicatorInput[]Tunable settings; type is what a settings UI renders.
plotsIndicatorPlot[]One per drawn series. type is any registered chart type; overlay: true puts that one plot on the price pane; ohlc names four columns so the plot draws as bars.
fillsIndicatorFillSpec[]?Optional shaded bands. between names two calc columns, which need not be plots.
calc(bars, settings, store, ctx?) => IndicatorValuesFull recompute. ctx is the optional calculation context.
calcTail(bars, settings, fromIndex, previous, store, ctx?) => IndicatorValues | nullOptional incremental path.
markers({ bars, values, settings }) => SeriesMarker[]Optional bar-anchored signal markers, run after every calc.
table({ bars, values, settings }) => \{ rows, options \} | nullOptional corner-pinned summary grid, run after every calc.
draws({ bars, values, settings }) => IndicatorDrawing[]Optional free-standing shapes anchored to time and price, run after every calc.
background({ bars, values, settings }) => (string | null)[]Optional per-bar shading behind the indicator’s own pane, run after every calc.
barColors({ bars, values, settings }) => (string | null)[]Optional recolouring of the price candles, run after every calc.
alertsIndicatorAlertSpec[]?Optional conditions the runtime watches, emitted as indicator:alert.
levels(ctx) => IndicatorLevel[]Optional horizontal reference lines. ctx is the settings plus bars and values, so a level can be data-derived.
range(settings) => \{ min, max \} | nullOptional fixed pane range (RSI 0..100).
attach(ctx) => (() => void) | voidOptional lifecycle for external data (Tier 2).

Input types are number, boolean, color, text, select, and source. A source input picks a price series; INDICATOR_SOURCES is the canonical option list for a UI.

⚠️

calcTail matters for live charts. Without it every incoming tick costs a full recompute, a few hundred microseconds for one indicator over 50k bars, but O(n) per tick per indicator. Implement it for anything meant to run in a busy live pane: return values for [fromIndex, bars.length) and the runtime splices them onto the previous result, or return null to fall back to a full calc.

The runtime only offers you the tail path when the previous result still describes every bar before it: the first bar’s time must be unchanged, and the last bar must be either that same bar replaced or one appended after it. A symbol change or a page of history arriving at the left edge falls back to a full calc instead of splicing onto a history that no longer exists.

Calculation helpers

The calculation helpers the 102 built-ins are made of are exported, so your own descriptor can be written against the same primitives instead of re-deriving them. Each matches the published formula of the ta.* function of the same name, which is what makes a ported study agree with its source rather than being approximately right.

They live in the indicator tier, not the base bundle:

import { registerIndicator } from 'openalgo-charts';
import { linreg, stdev, nulls } from 'openalgo-charts/indicators';

They take flat arrays rather than bars (readonly number[], or booleans for barsSince), return an array of the same length, and write NaN into warmup slots. nulls(values) converts that to the null a calc result wants; sourceValues(bars, source) (from the package root) is how you get the input array out of the bars in the first place.

HelperSignatureNotes
sma(values, period)First value at index period - 1. A non-finite input does not poison the running sum.
wma(values, period)Linear weights; the most recent bar carries weight period.
rma(values, period)Wilder smoothing, seeded with the SMA. The basis of RSI, ATR and ADX.
smaSeededEma(values, period)EMA seeded with the SMA of the first period values, NaN before that. The base bundle’s ema seeds from values[0] and emits from index 0 to match openalgo.ta, so the two disagree for roughly the first period bars and converge after. Use this one to reproduce a reference plot.
stdev(values, period)Rolling population standard deviation.
dev(values, period)Mean absolute deviation from the SMA, not a standard deviation. CCI’s 0.015 constant is calibrated against this one.
highest / lowest(values, period)Rolling maximum / minimum.
highestBars / lowestBars(values, period)The offset to the extreme bar: 0 for the current bar, -(period - 1) for the oldest. Ties resolve to the more recent bar. Aroon is built entirely out of these.
change(values, n = 1)values[i] - values[i - n].
roc(values, n)100 * (values[i] - values[i - n]) / values[i - n].
rollingSum(values, period)Rolling sum over the window.
cumulative(values)Running total from the first bar; non-finite terms count as zero.
alma(values, period, offset, sigma)Gaussian-weighted average, offset 0..1 and sigma > 0.
vwma(values, volumes, period)sma(value * volume) / sma(volume).
linreg(values, period, offset = 0)The least-squares line over the window, evaluated offset bars back from its right-hand end. That offset is how LSMA shifts its plot without refitting.
swma(values)The fixed four-bar symmetric average, 1/2/2/1 over 6.
stoch(source, high, low, period)Three independent series, which is why it does not take bars: Stochastic RSI passes the RSI in for all three.
cci(values, period)(value - sma) / (0.015 * dev).
correlation(a, b, period)Pearson correlation over the window.
percentRank(values, period)Percentage of the previous period values at or below the current one. The current bar is the subject rather than part of the window, so the first answer lands at index period.
percentileNearestRank(values, period, percentage)Nearest-rank, so the result is an actual member of the window rather than an interpolation between two. Visible on a window as short as 3.
pivotHigh / pivotLow(values, left, right)Confirmed right bars late: the answer lands on the confirming bar and refers to the value right bars back. Strict on both sides, so a tie is not a pivot.
barsSince(cond: boolean[])Bars elapsed since cond was last true, NaN before the first.
valueWhen(cond, source, occurrence)source at the n-th most recent true, counting the current bar. 0 is the latest.
nulls(values)NaN to null, for the calc return.

ema, rsi, atr, trueRange and supertrend are not here: they ship in the base bundle and the tier imports them from it, so they come from openalgo-charts.

Tier 2: indicators with their own data

Some series aren’t derived from the chart’s OHLCV at all: open interest, cumulative volume delta, PCR, an external analytics feed. createTier2Indicator wraps a fetch / subscribe lifecycle into an ordinary descriptor, so the runtime, settings, panes, and removal all work identically. There is no second runtime.

import { registerIndicator } from 'openalgo-charts';
import { createTier2Indicator } from 'openalgo-charts/indicators';
 
registerIndicator(createTier2Indicator({
  id: 'open-interest',
  name: 'Open Interest',
  placement: 'pane',
  inputs: [{ key: 'symbol', type: 'text', label: 'Symbol', default: 'NIFTY' }],
  plots: [{ key: 'oi', type: 'line', title: 'OI' }],
  refetchOn: ['symbol'],                       // these settings invalidate the data
  fetch: async ({ settings, from, to }) => {
    const rows = await loadOpenInterest(settings.symbol, from, to);
    return rows.map((r) => ({ time: r.time, values: { oi: r.oi } }));
  },
  subscribe: (ctx, push) => streamOi(ctx.settings.symbol, (r) =>
    push({ time: r.time, values: { oi: r.oi } })),
}));

Alignment rule. External points carry their own timestamps, which rarely match bar times. Each bar takes the most recent point at or before it, last-known-value, never interpolated and never forward-looking. Bars before the first point are null.

refetchOn names the settings that identify the external dataset. Changing one clears the previous dataset immediately and starts a new fetch, so old values cannot appear under a new symbol while its history loads or after that load fails. Include every data-affecting setting, such as symbol, exchange or resolution, in this list.

A style change that leaves the data key unchanged reuses loaded data or the pending request. Live points received while history is loading are merged into the result and win at matching timestamps. A failed fetch keeps only live points belonging to the current key; retryData() or a context/settings change can retry the history request. Late results and callbacks from an earlier attachment are ignored after a settings change or removal. An empty successful history result is still loaded data and does not trigger another fetch on a style change. Omitting refetchOn uses no settings-derived key; chart context still identifies the dataset.

The attach context

createTier2Indicator is a wrapper over the descriptor’s own attach hook, which any descriptor can implement directly. It is called once per instance and again after every settings change, and it returns a teardown function. The context it is handed:

MemberTypeDescription
settings()IndicatorSettingsCurrent settings. Read at call time, so do not capture the result.
bars()Bar[]The chart’s current source bars.
requestRecompute()voidRun calc again and repaint. Call it when external data arrives.
storeIndicatorStorePer-instance scratch, the same object calc receives. Survives a settings change, so a descriptor that caches there can no-op when nothing data-affecting moved.
symbol?()string | undefinedThe instrument on screen, when the host knows it.
interval?()string | undefinedThe timeframe, on the same terms.
timezone?()stringThe chart’s IANA zone, the one its axis is labelled in.
now?()numberChart wall clock in UTC seconds: the same clock the countdown reads, so an indicator deciding whether the last bar is still forming agrees with the axis about it.
paneIndex?()numberThe pane this instance drew into. Moves when panes are reordered, so read it rather than storing it.
addPrimitive?(p) / removePrimitive?(p)voidAttach an arbitrary primitive to this indicator’s pane, and detach it again. Anything you attach is yours to remove in the teardown.
emit?(event, payload)voidPut an event on the chart’s own bus, the one chart.on(name, cb) listens to. The imperative half of alerts: use it when the signal arrives from outside the calculation rather than from the bars.

Everything after store is optional so a minimal context can be hand-built (a unit test exercising one descriptor’s lifecycle), but the runtime always supplies them.

symbol and interval are undefined under chart.addIndicator. The engine core is handed bars and never an instrument, so it answers “I do not know” rather than inventing a name. A terminal that owns the symbol picker supplies both through its own IndicatorHost, which is how a Tier-2 indicator fetches the series matching what is on screen.

Supertrend

supertrendSeries(bars, period, multiplier) returns { up, down } so you can color the uptrend and downtrend legs differently (direction -1 = up, +1 = down).

This is the raw function. The managed chart.addIndicator('supertrend') draws the same two legs and additionally shades the gap between the active stop and the candle bodies, using the hidden bodyMid column described under filling against a column that is never plotted.

live
Rendering live chart…
View example code
const chart = lib.createChart(el);
const bars = lib.generateBars(1700000000, 220, 3600);
chart.addSeries('candlestick').setData(bars);

const st = lib.supertrendSeries(bars, 10, 3);
chart.addSeries('line', { style: { color: '#26a69a', lineWidth: 2 } }).setData(st.up);
chart.addSeries('line', { style: { color: '#ef5350', lineWidth: 2 } }).setData(st.down);

chart.timeScale.fitContent(bars.length);
return chart;

Return type: SupertrendPoint

supertrend(bars, period?, multiplier?) returns SupertrendPoint[]. Each element carries:

FieldTypeMeaning
valuenumberThe band price level. NaN during ATR warmup (first period - 1 bars).
direction-1 or 1-1 = uptrend: band is support below price (bullish). 1 = downtrend: band is resistance above price (bearish).

supertrendSeries converts that array into { up: Bar[], down: Bar[] }. The up series carries value only while direction === -1; the down series only while direction === 1. Non-active slots hold NaN so the renderer breaks the line at each trend flip, producing two separately colored segments. SupertrendPoint is a named export; see Types for the full type catalogue.

RSI

Plot RSI in its own pane with 70/30 guide lines:

live
Rendering live chart…
View example code
const chart = lib.createChart(el);
const bars = lib.generateBars(1700000000, 220, 3600);
chart.addSeries('candlestick').setData(bars);

chart.addSeries('line', { paneIndex: 1, style: { color: '#e0b020', lineWidth: 2 } })
   .setData(lib.rsiSeries(bars, 14));
chart.addPriceLine({ price: 70, color: '#ef5350', lineWidth: 1, dashed: true, id: 'r70' }, 1);
chart.addPriceLine({ price: 30, color: '#26a69a', lineWidth: 1, dashed: true, id: 'r30' }, 1);

chart.timeScale.fitContent(bars.length);
return chart;

EMA

emaSeries(bars, period) returns a line you overlay on price (see Series & Styling). Stack several periods for a ribbon.

Need an indicator that is not built in? Compute it however you like - these helpers just return Bar[] (set close to the indicator value). Plot the result as a line series. For an entirely new on-chart drawing, write a primitive.

ATR

The ATR family consists of two functions that build on each other. Both accept flat readonly number[] arrays rather than Bar objects, so they work on any OHLC source.

trueRange

trueRange(
  high: readonly number[],
  low: readonly number[],
  close: readonly number[],
): number[]

Returns the raw true range per bar with no smoothing. The first bar is high[0] - low[0] because there is no previous close. Every subsequent bar is the maximum of:

  • high[i] - low[i]
  • Math.abs(high[i] - close[i - 1])
  • Math.abs(low[i] - close[i - 1])

Every output slot is finite; there is no warmup period.

atr

atr(
  high: readonly number[],
  low: readonly number[],
  close: readonly number[],
  period?: number,   // default 14
): number[]

Smooths trueRange output using Wilder’s formula. The first output lands at index period - 1 (a plain average of the first period true ranges); from there each value is (prev * (period - 1) + tr[i]) / period. Slots before period - 1 are NaN, which the line renderer skips as whitespace.

import { atr, trueRange } from 'openalgo-charts';
 
const rawTR   = trueRange(highs, lows, closes);   // finite for every bar, no warmup
const atrVals = atr(highs, lows, closes, 14);     // NaN for bars 0-12, then Wilder ATR

atr is consumed internally by supertrend. Call it directly when you need a volatility measure for position sizing or stop distances, without computing the full band.

Managed external data (2.1.6)

createTier2Indicator now receives ctx.dataContext (optional symbol, exchange, interval) and ctx.signal. Forward the signal to your fetch. Set identity through chart.setDataContext(...); the widget does this for its own feed. Clear the old primary series before changing context, then install the new source bars.

supports(ctx) may return false when classified trades, open interest or another required dataset is unavailable. Do not synthesize those values from OHLCV. Context changes cancel obsolete work and refetch. Prepended source history extends the requested range; history-only studies refresh a growing tail, while live studies keep their subscription. Replay alignment never selects a future point.

indicator.dataStatus() returns IndicatorDataStatus | null. The states are loading, ready, empty, unsupported and error, with optional error detail. Use subscribeDataStatus(listener) and release its returned unsubscribe function. retryData() retries the current source. The widget renders these states and Retry automatically. indicator:data-status emits { id, indicatorId, status } on the chart event bus. Existing synchronous indicators return null status.