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
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)
| Indicator | id | Placement | Key settings |
|---|---|---|---|
| ADX / DMI | adx | pane | period=14, adxPeriod=14 |
| AlphaTrend | alphatrend | onchart | coeff=1, AP=14, source=close, showsignalsk=true, novolumedata=false |
| Arnaud Legoux Moving Average | alma | onchart | length=9, offset=0.85, sigma=6 |
| Aroon | aroon | pane | length=14 |
| Aroon Oscillator | aroon-oscillator | pane | length=14 |
| Chande Kroll Stop | chande-kroll-stop | onchart | p=10, x=1, q=9 |
| Chandelier Exit | chandelier-exit | onchart | length=22, atrLength=22, atrMultiplier=3 |
| Consolidation and Breakout | consolidation-breakout | onchart | markbreakout=true, colorinside=true |
| Double EMA | dema | onchart | length=9, source=close |
| EMA | ema | onchart | length=9, source=close |
| HalfTrend | halftrend | onchart | amplitude=2, channelDeviation=2, atrPeriod=100, showChannels=true, showSignals=true, showLabels=true |
| Hull Moving Average | hma | onchart | length=9, source=close |
| Hull Suite | hull-suite | onchart | mode=Hma, length=55, lengthMult=1, source=close, switchColor=true, candleCol=false, visualSwitch=true |
| Ichimoku Cloud | ichimoku | onchart | conversionPeriod=9, basePeriod=26, laggingSpanPeriod=52, displacement=26 |
| Kaufman’s Adaptive Moving Average | kama | onchart | erLength=10, fastLength=2, slowLength=30, source=close |
| Least Squares Moving Average | lsma | onchart | length=25, offset=0, source=close |
| Linear Regression Slope | linreg-slope | pane | periods=14 |
| MA Cross | ma-cross | onchart | shortLength=9, longLength=26 |
| CPR with Floor Pivot | cpr | onchart | pivotMode=auto, showDaily=true, showWeekly=false, showMonthly=false, displaypivots=true, displaysupport=true, displayresistance=true, displaycpr=true, displayS1R1=false |
| McGinley Dynamic | mcginley-dynamic | onchart | length=14 |
| Median | median | onchart | source=hl2, length=3, atrLength=14, atrMult=2 |
| Moving Average Ribbon | ma-ribbon | onchart | four groups showMaN=true, maNType=SMA, maNSource=close, maNLength (20 / 50 / 100 / 200) for N = 1..4 |
| Parabolic SAR | parabolic-sar | onchart | start=0.02, increment=0.02, maximum=0.2 |
| Seasonality | seasonality | pane | startYear=2015, cutoffPercent=10, tablePosition=Center, tableWidth=100, tableHeight=95, showAvg=true, showStDev=true, showPos=true, ignoredMonths='YYYY-MM, YYYY-MM' |
| SMA | sma | onchart | length=9, source=close |
| Smoothed Moving Average | smma | onchart | length=7, source=close |
| Supertrend | supertrend | onchart | period=10, multiplier=3 |
| T3 Average | t3 | onchart | length=5, factor=0.7, source=close, highlightMovements=true |
| Time Weighted Average Price | twap | onchart | anchor=session, source=ohlc4, offset=0 |
| Trend Strength Index | trend-strength-index | pane | length=14 |
| Triple EMA | tema | onchart | length=9 |
| Volatility Stop | volatility-stop | onchart | length=20, source=close, factor=2 |
| Vortex Indicator | vortex | pane | length=14 |
| Williams Alligator | alligator | onchart | jawLength=21, teethLength=13, lipsLength=8, jawOffset=8, teethOffset=5, lipsOffset=3 |
| Williams Fractals | williams-fractals | onchart | periods=2, showUp=true, showDown=true |
| WMA | wma | onchart | length=9, source=close |
Momentum (29)
| Indicator | id | Placement | Key settings |
|---|---|---|---|
| Awesome Oscillator | awesome-oscillator | pane | colours only (5 and 34 are the definition, not a preference) |
| Balance of Power | balance-of-power | pane | colours only |
| CCI | cci | pane | period=20, constant=0.015, maType=SMA, maLength=20, bbMult=2 |
| Chande Momentum Oscillator | chande-momentum | pane | length=9, source=close |
| Connors RSI | connors-rsi | pane | lenrsi=3, lenupdown=2, lenroc=100 |
| Coppock Curve | coppock-curve | pane | wmaLength=10, longRoCLength=14, shortRoCLength=11 |
| Detrended Price Oscillator | dpo | pane | period=21, isCentered=false |
| Fisher Transform | fisher-transform | pane | length=9 |
| Know Sure Thing | know-sure-thing | pane | roclen1=10, roclen2=15, roclen3=20, roclen4=30, smalen1=10, smalen2=10, smalen3=10, smalen4=15, siglen=9 |
| MACD | macd | pane | fastPeriod=12, slowPeriod=26, signalPeriod=9, source=close |
| Momentum | momentum | pane | len=10, source=close |
| Money Flow Index | mfi | pane | period=14 |
| Percentage Price Oscillator | ppo | pane | source=close, fastLength=12, slowLength=26, signalLength=9, oscType=EMA, sigType=EMA |
| Pring’s Special K | special-k | pane | source=close, length1=100, length2=100 |
| Rate Of Change | roc | pane | length=9, source=close |
| Relative Vigor Index | relative-vigor-index | pane | length=10, offset=0 |
| RSI | rsi | pane | length=14, source=close, overbought=70, oversold=30 |
| RSI Divergence Indicator | rsi-divergence | pane | length=14, source=close, lbR=5, lbL=5, rangeUpper=60, rangeLower=5, plotBull=true, plotHiddenBull=false, plotBear=true, plotHiddenBear=false |
| SMI Ergodic Indicator | smi-ergodic-indicator | pane | longlen=20, shortlen=5, siglen=5 |
| SMI Ergodic Oscillator | smi-ergodic-oscillator | pane | longlen=20, shortlen=5, siglen=5 |
| Stochastic | stochastic | pane | kPeriod=14, kSmoothing=1, dPeriod=3 |
| Stochastic Momentum Index | smi | pane | lengthK=10, lengthD=3, lengthEMA=3 |
| Stochastic RSI | stochastic-rsi | pane | smoothK=3, smoothD=3, lengthRSI=14, lengthStoch=14, source=close |
| TRIX | trix | pane | length=18 |
| True Strength Index | tsi | pane | long=25, short=13, signal=13 |
| Ultimate Oscillator | ultimate-oscillator | pane | length1=7, length2=14, length3=28 |
| WaveTrend Pro | wavetrend | pane | source=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 Range | williams-percent-r | pane | length=14, source=close |
| Woodies CCI | woodies-cci | pane | cciTurboLength=6, cci14Length=14 |
Volatility (22)
| Indicator | id | Placement | Key settings |
|---|---|---|---|
| ATR | atr | pane | period=14 |
| Average Daily Range | average-daily-range | pane | length=14 |
| BBTrend | bb-trend | pane | shortLength=20, longLength=50, stdDevMult=2 |
| Bollinger Bands | bollinger | onchart | length=20, stdDev=2, source=close |
| Bollinger Bands %b | bollinger-percent-b | pane | length=20, source=close, mult=2 |
| Bollinger BandWidth | bollinger-bandwidth | pane | length=20, source=close, mult=2, expansionLength=125, contractionLength=125 |
| Chaikin Volatility | chaikin-volatility | pane | periods=10, rocLookback=10 |
| Chop Zone | chop-zone | pane | colours only (nine zone colours) |
| Choppiness Index | choppiness-index | pane | length=14, offset=0 |
| Donchian Channels | donchian | onchart | length=20, offset=0 |
| Envelope | envelope | onchart | length=20, percent=10, source=close, exponential=false |
| Historical Volatility | historical-volatility | pane | length=10, per=1 |
| Keltner Channels | keltner-channel | onchart | length=20, mult=2, source=close, exp=true, bandsStyle=Average True Range, atrlength=10 |
| Mass Index | mass-index | pane | length=10 |
| Moving Average Channel | ma-channel | onchart | upperLength=20, lowerLength=20, upperOffset=0, lowerOffset=0 |
| Range Analysis | range-analysis | pane | showAverage=false, avgLength=3 |
| Relative Volatility Index | relative-volatility-index | pane | length=10, offset=0, maType=SMA, maLength=14, bbMult=2 |
| Standard Deviation | standard-deviation | pane | periods=5, deviations=1 |
| Standard Error | standard-error | pane | length=14 |
| Standard Error Bands | standard-error-bands | onchart | periods=21, errors=2, method=Simple, averagePeriods=3 |
| Ulcer Index | ulcer-index | pane | source=close, length=14 |
| William VIX FIX | williams-vix-fix | pane | pd=22, bbl=20, mult=2, lb=50, ph=0.85, pl=1.01, hp=false, sd=false |
Volume (15)
| Indicator | id | Placement | Key settings |
|---|---|---|---|
| Accumulation/Distribution | adl | pane | colours only |
| Chaikin Money Flow | chaikin-money-flow | pane | length=20 |
| Chaikin Oscillator | chaikin-oscillator | pane | short=3, long=10 |
| Ease of Movement | ease-of-movement | pane | length=14, divisor=10000 |
| Elder Force Index | elder-force-index | pane | length=13 |
| Klinger Oscillator | klinger-oscillator | pane | colours only |
| Negative Volume Index | nvi | pane | maLength=255 |
| Net Volume | net-volume | pane | colours only |
| On-Balance Volume | obv | pane | maType=None, maLength=9, bbMult=2 |
| Percentage Volume Oscillator | pvo | pane | fastLength=12, slowLength=26, signalLength=9, oscType=EMA, sigType=EMA |
| Positive Volume Index | pvi | pane | maLength=255 |
| Price Volume Trend | pvt | pane | colours only |
| Volume | volume | pane | colours only |
| Volume Weighted Moving Average | vwma | onchart | length=20, source=close, offset=0 |
| VWAP | vwap | onchart | anchor=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.
| Setting | Default | Notes |
|---|---|---|
anchor | session | session, week, month, quarter, year, or continuous. |
source | hlc3 | Any IndicatorSource. |
offset | 0 | Shift the whole study left or right, in bars (-500..500). |
calcMode | stdev | stdev for standard-deviation bands, percent for a fixed percentage of the average. |
showBand1 / bandMult1 | true / 1 | Band pair 1, drawn by default. |
showBand2 / bandMult2 | false / 2 | Band pair 2. |
showBand3 / bandMult3 | false / 3 | Band 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.
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.
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:
| Indicator | What it marks |
|---|---|
halftrend | A Buy or Sell plate at each trend flip, suppressed by showLabels: false. |
williams-fractals | An up or down triangle at each confirmed fractal, filtered by showUp / showDown. |
rsi-divergence | Bull, Bear, H Bull, and H Bear labels on the RSI line, one per enabled plot* input. |
alphatrend | A Buy or Sell plate where the level crosses its own two-bar lag. |
wavetrend | A 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
| Field | Type | Description |
|---|---|---|
text | string | Cell contents. An empty string draws the fill without any label. |
bgColor | string? | Cell fill. Transparent when omitted, so the pane shows through. |
textColor | string? | Derived from bgColor for contrast when omitted. |
align | 'left' | 'center' | 'right' | Default center. |
fontSize | number? | Overrides the table’s fontSize for this cell, for a heading row. |
bold | boolean? |
ChartTableOptions
| Option | Default | Description |
|---|---|---|
position | 'bottom-right' | One of nine keywords: top/middle/bottom crossed with left/center/right. |
margin | 8 | Gap from the pane edge, media px. |
cellWidth | 64 | Column width in media px. A per-column array sizes each one separately. |
cellHeight | 18 | Row height in media px. |
widthPercent | unset | Total 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. |
heightPercent | unset | Total height as a percentage of the plot. 0 or unset sizes from cellHeight. |
rowWeights | unset | Relative 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. |
fontSize | 11 | Shrinks automatically when a stretched row is shorter than this. |
borderColor | unset | Grid line colour. Omit to draw no grid. |
borderWidth | 1 | |
background | unset | Backdrop behind the whole grid, drawn before the cells. |
id | unset | Hit-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'
| Field | Type | Description |
|---|---|---|
from / to | DrawAnchor | The two ends. |
color | string? | |
lineWidth | number? | Media px, default 1. |
lineStyle | 'solid' | 'dashed' | 'dotted'? | |
extendLeft / extendRight | boolean? | 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'
| Field | Type | Description |
|---|---|---|
from / to | DrawAnchor | Opposite corners, in either order. |
color | string? | Border, and the caption plate’s fill. |
fillColor | string? | Omit to draw an outline only. |
opacity | number? | Fill alpha, 0..1, default 0.12. |
lineWidth | number? | Media px, default 1. |
text | string? | Caption on a plate at the centre of the box; \n splits lines. |
textColor | string? | Derived from the plate colour for contrast when omitted. |
kind: 'label'
| Field | Type | Description |
|---|---|---|
at | DrawAnchor | Where the plate sits. |
text | string | \n splits lines; the plate sizes to the widest. |
color | string? | Plate fill. |
textColor | string? | Contrast-derived when omitted. |
align | 'left' | 'center' | 'right'? | Which edge of the plate sits on the anchor. Default center. |
kind: 'polyline'
| Field | Type | Description |
|---|---|---|
points | DrawAnchor[] | Two or more; a shorter list draws nothing. |
color | string? | |
lineWidth | number? | Media px, default 1. |
closed | boolean? | Close the path back to the first point (a triangle, a wedge). |
fillColor | string? | Omit for an unfilled path. |
opacity | number? | 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 };
}| Export | Returns | Use |
|---|---|---|
sessionStartIndices(times) | number[] | null | The 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;
}),
};
}| Export | Returns | Use |
|---|---|---|
parseSessionSpec(spec) | SessionSpec | null | Parse '0915-1015', or '0930-1600:23456' with a day filter. null on anything it does not recognise, never a throw. |
inSessionAt(utcSeconds, spec, zone?) | boolean | One 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-1015window, 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.
zonedefaults toAsia/Kolkatalike every other zoned helper. Pass the chart’s, which acalcreads fromctx.timezoneor from the reservedsettings.timezonekey.- An unparseable string marks nothing:
sessionFlagsreturns an all-false array rather than throwing, because the spec is usually something a user is halfway through typing. CallparseSessionSpecyourself when you need to tell a bad spec from an empty window.
The IndicatorApi handle
addIndicator returns a handle:
| Member | Type | Description |
|---|---|---|
id | string | Unique instance id (several instances of one indicator can coexist). |
indicatorId | string | The descriptor id, e.g. 'macd'. |
name | string | Display name. |
paneIndex | number | Pane the indicator drew into. |
settings() | IndicatorSettings | Current settings, as a copy. |
setSettings(patch) | void | Merge a patch, recompute, and restyle. |
series(plotKey) | SeriesApi | undefined | The series behind one plot, for direct styling. |
values() | IndicatorValues | Latest computed columns. |
remove() | void | Remove 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.
| Export | Signature | Use |
|---|---|---|
registeredIndicators() | -> IndicatorDescriptor[] | Everything registered, in registration order. Read id, name, category, inputs, plots. |
hasIndicator(id) | (string) -> boolean | Guard before addIndicator when the id is user-supplied, because addIndicator throws on an unknown one. |
indicatorDefaults(descriptor) | (IndicatorDescriptor) -> IndicatorSettings | Default 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_STYLES | readonly { label, value }[] | The line-style choices a generated form should offer. |
INDICATOR_PLOT_STYLES | readonly { label, value }[] | Chart types a plot can be re-rendered as (line, line with markers, step, area, histogram, columns). |
INDICATOR_SOURCES | readonly { 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_STYLESimport {
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:
rsireturnsupperLevelandlowerLevelalongsidersi, both constant at theoverbought/oversoldsettings, and fills between them. The overbought/oversold background is therefore a fill between two columns that are never drawn, so changingoverboughtmoves the shading with no extra plot to restyle.stochasticshades the same way.aroon-oscillatorreturns azerocolumn and fills['osc', 'zero'], so the band flips colour with the sign of the oscillator. AcolorBywould recolour the stroke, but it is the shaded region between the line and zero that actually reads on a chart.supertrendreturns a hiddenbodyMidcolumn,(open + close) / 2per bar, and declares one fill from it toupand another todown. 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:
| Member | Type | Description |
|---|---|---|
| (the settings keys) | Every setting, spread onto the context. | |
settings | IndicatorSettings | The same values as one object, for a helper that takes a bag. |
bars | Bar[] | The bars the last calc ran over. |
values | IndicatorValues | What 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
| Field | Type | Description |
|---|---|---|
price | number | Where the line sits. |
color | string? | Default #8892a6. |
title | string? | Right-axis tag text. |
lineWidth | number? | Media px, default 1. |
lineStyle | 'solid' | 'dashed' | 'dotted'? | |
dashed | boolean? | 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.
keystays the series identity, the thinginstance.series('ha')resolves and the thing the style settings are keyed on. The legend reading falls back to theclosecolumn.- All four columns must exist and be exactly
bars.lengthlong. A wrong name throws out ofchart.addIndicator, because the firstcalcruns while the instance is being constructed, so you get an error rather than an empty pane. colorBystill applies and is judged on the close value, reaching body, border and wick together the wayBar.colordoes.- A user overriding the plot’s chart type through its generated
<plotKey>:typesetting degrades quietly: line, area and histogram read theclosecolumn.
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
| Member | Type | Description |
|---|---|---|
barState.isNew | boolean | The most recent update appended a bar rather than replacing one. False on a full history load, because there was no update to append. |
barState.isConfirmed | boolean | The last bar’s own span has elapsed on the chart clock. |
barState.isRealtime | boolean | A 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.lastIndex | number | bars.length - 1, and -1 when there are no bars. |
symbol | string? | The instrument, when the host knows one. |
interval | string? | The timeframe ('5m', '1d'), on the same terms. |
timezone | string | The chart’s IANA zone, the calendar its axis is labelled in. |
now() | number | Chart 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
| Field | Type | Description |
|---|---|---|
id | string | Registry key. |
name | string | Display name. |
category | string? | Grouping for a picker UI. |
placement | 'onchart' | 'pane' | Overlay the price pane, or take a new one. |
inputs | IndicatorInput[] | Tunable settings; type is what a settings UI renders. |
plots | IndicatorPlot[] | 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. |
fills | IndicatorFillSpec[]? | Optional shaded bands. between names two calc columns, which need not be plots. |
calc | (bars, settings, store, ctx?) => IndicatorValues | Full recompute. ctx is the optional calculation context. |
calcTail | (bars, settings, fromIndex, previous, store, ctx?) => IndicatorValues | null | Optional incremental path. |
markers | ({ bars, values, settings }) => SeriesMarker[] | Optional bar-anchored signal markers, run after every calc. |
table | ({ bars, values, settings }) => \{ rows, options \} | null | Optional 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. |
alerts | IndicatorAlertSpec[]? | 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 \} | null | Optional fixed pane range (RSI 0..100). |
attach | (ctx) => (() => void) | void | Optional 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.
| Helper | Signature | Notes |
|---|---|---|
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:
| Member | Type | Description |
|---|---|---|
settings() | IndicatorSettings | Current settings. Read at call time, so do not capture the result. |
bars() | Bar[] | The chart’s current source bars. |
requestRecompute() | void | Run calc again and repaint. Call it when external data arrives. |
store | IndicatorStore | Per-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 | undefined | The instrument on screen, when the host knows it. |
interval?() | string | undefined | The timeframe, on the same terms. |
timezone?() | string | The chart’s IANA zone, the one its axis is labelled in. |
now?() | number | Chart 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?() | number | The pane this instance drew into. Moves when panes are reordered, so read it rather than storing it. |
addPrimitive?(p) / removePrimitive?(p) | void | Attach 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) | void | Put 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.
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:
| Field | Type | Meaning |
|---|---|---|
value | number | The 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:
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 ATRatr 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.