Writing your own indicator
Enchartiv indicators are JavaScript. You are handed the bars of the window you are on, you work out some numbers, and you say what should be drawn. There is no build step, no plugin to install and nothing to compile: you type, press Test, and it is on the chart.
Every picture on this page is the actual output of the code printed beneath it, produced by running that code through the same sandbox your browser uses. Prices shown are the demo instrument's — synthetic data, nobody's live feed.
1. What this is
An indicator is a function body. Not a file, not a class, not a
registration call — just the inside of a function, which is handed
three things and must return a result:
(d, lib, input) => {
your code here
return { plots: [ ... ] };
}
d— the bars of the window you are on.lib— a toolbox of thirty of the usual calculations.input— how you declare the settings your script can be adjusted by.
You do not write the function line, or the closing brace. The editor holds
the body, and the app wraps it. Everything else is ordinary JavaScript:
const, for, Math, Array
and the rest all work as you would expect.
2. Where your code runs
Your script runs in a Web Worker — a separate thread with no
access to the page. It cannot see the document, cookies, your session, your
account or your trades, and fetch, XMLHttpRequest,
WebSocket, EventSource, importScripts,
indexedDB and caches are all removed before your
code is reached. An indicator cannot talk to the network, and cannot
load a library.
That is deliberate, and it is what makes it safe to run a script somebody else wrote. Custom code used to run on the page with the same privileges as the app itself; an indicator copied off a forum could have read your session or shipped your trade history somewhere. Now the only things that cross back out of the worker are plain numbers and strings, field by named field.
Two consequences worth knowing before you start:
- Only what is on the whitelist comes back. A result object with a field nobody has named in the app is not an error — the field is simply not there on the other side. Where a built-in indicator can do something a script cannot, this manual says so.
- It is asynchronous. Drawing does not wait for you. A job is posted, and the previous result keeps being drawn until the new one lands. On a fast script you will never notice; on a slow one the indicator trails the candles by a frame or two rather than freezing them.
3. Writing one, and saving it
In the app, click + Ind on a window and choose the My scripts tab, then + New. You get a name box, a short chip label (what the chart strip shows when the full name is too long), and the code.
Four buttons matter:
- ▶ Test compiles and runs your script against the bars in the active window and tells you what happened: the line a syntax error is on, the error a crash threw, or how many bars it ran on, how long it took and how many real values each plot produced. A plot shorter than the bar count is called out, because a short series is drawn against the wrong bars rather than stopping early.
- ⤢ Window opens the editor in its own resizable window — put it on a second screen next to the chart it draws on.
- Save stores it under its name. Saving under a name that already exists edits that script rather than making a second one with the same name.
- Add to chart puts it on the window you opened the dialog from.
The 📖 Manual button on the left of that row opens this page in a new tab, so it can sit beside the editor while you write.
A saved script is yours across every window and every layout. Once it declares inputs (chapter 8) the dialog opens on its properties rather than its code, the way a built-in does, and ✎ Edit code takes you back to the source.
4. The bars you are given
d holds six parallel arrays, all the same length, oldest bar
first:
d.t // bar open time, seconds since 1970 (UTC)
d.o // open
d.h // high
d.l // low
d.c // close
d.v // tick volume - the number of price changes in the bar,
// not traded size. FX brokers publish no real volume.
d._tf // this window's timeframe IN SECONDS: 60 = M1, 3600 = H1,
// 86400 = D1. How a script can tell one from another.
Bar i is d.o[i], d.h[i],
d.l[i], d.c[i]. The last bar is still
forming: its close is the current price and its high and low can still
move. Everything before it is finished.
Three things are worth saying plainly, because each has cost somebody an afternoon:
- Your output must be as long as
d.c. Entryiof a plot is drawn at bari. A series that starts at zero length and gets pushed to once per bar comes out right; one built byfilterdoes not, and will be drawn shifted. - Use NaN for "no value yet". A moving average has nothing to say
before it has warmed up, and NaN is how you say so: a NaN breaks the
line rather than dragging it to zero. Never use
nullor0for this. - The window's timeframe is the window's business. You are given the bars of whatever timeframe the window is on. A script cannot ask for another one; put it on the window whose bars you want.
The smallest indicator there is
Every script is a function body. It is handed the bars and must return a result object with at least one plot. This one plots the closing price back onto the chart it came from - useless, and the shortest thing that works.
return {
pane: 'chart',
plots: [{ name: 'Close', data: d.c, color: '#5ab0f0', width: 1.4 }]
};
The same thing in its own pane
Change pane to 'sub' and the result gets a pane of its own under the candles, with its own scale. Anything that is not a price - a rate of change, a count, a ratio - belongs there.
const roc = lib.roc(d.c, 20);
return {
pane: 'sub',
plots: [{ name: 'ROC 20', data: roc, style: 'area', color: '#5ab0f0' }],
hlines: [0]
};
5. What you hand back
One object. Every field is optional except plots, which must
be an array even when it is empty:
return {
pane: 'sub', // 'sub' = its own pane (default), 'chart' = on the candles
plots: [ ... ], // the series to draw - chapter 6
hlines: [ ... ], // horizontal reference levels - chapter 7
labels: [ ... ], // text pinned to a bar - chapter 9
table: { ... }, // a small grid of values - chapter 9
corner: { text: '' }, // one-line shorthand for a table
boxes: [ ... ], // rectangles in chart coords - chapter 10
lines: [ ... ], // free line segments
polys: [ ... ], // filled polygons
rightBars: 20, // keep this many empty bars past the last one
orders: [ ... ] // trades to place - chapter 13
};
pane: 'chart' draws on the price chart, in price
coordinates — use it for anything that is a price: moving
averages, bands, stops, levels. pane: 'sub' gives you a pane of
your own under the candles with its own scale — use it for anything
that is not: oscillators, counts, ratios, spreads.
The pane comes from the result itself, so switching it in code takes effect the next time the script runs — there is nothing else to change.
6. Plots: the six shapes
A plot is an object. name and data are the only
required fields:
{
name: 'RSI 14', // shown in the pane's name strip
data: [ ... ], // one number (or NaN) per bar
style: 'line', // line | area | hist | histud | candle | dots
color: '#5ab0f0',
colors: [ ... ], // OPTIONAL per-bar colour, entry i colours the
// segment ENDING at bar i; color is the fallback
width: 1.4,
dash: [4, 3], // [on, off] in pixels
label: true, // chip this plot's newest value on the scale
pos: 'rgba(...)', // area/hist: the fill above zero
neg: 'rgba(...)', // area/hist: the fill below zero
o: [], h: [], l: [] // candle only - data is then the close
}
style: 'line' - the default
A plot with no style is a line. width sets its thickness, dash makes it dashed ([on, off] in pixels). NaN breaks the line rather than dragging it to zero, which is why every lib helper returns NaN during its warm-up instead of a number it has not earned yet.
const fast = lib.ema(d.c, 20);
const slow = lib.ema(d.c, 50);
return {
pane: 'chart',
plots: [
{ name: 'EMA 20', data: fast, color: '#e8a33d', width: 1.4 },
{ name: 'EMA 50', data: slow, color: '#5ab0f0', width: 1.4, dash: [4, 3] }
]
};
style: 'area' - filled to zero
An area plot fills between the line and zero, in one colour above and another below. pos and neg name those two fills; the line itself still uses color. The fill splits exactly on the zero crossing, so the shape never bow-ties.
const m = lib.macd(d.c, 12, 26, 9);
return {
pane: 'sub',
plots: [{
name: 'MACD',
data: m.macd,
style: 'area',
color: '#d7dde5',
pos: 'rgba(47,184,164,.30)',
neg: 'rgba(240,84,79,.30)'
}],
hlines: [0]
};
style: 'hist' - bars from zero
A histogram draws one bar per value, from zero. Its colour comes from the SIGN of the value: pos for zero and above, neg for below.
const m = lib.macd(d.c, 12, 26, 9);
return {
pane: 'sub',
plots: [
{ name: 'Hist', data: m.hist, style: 'hist',
pos: 'rgba(47,184,164,.75)', neg: 'rgba(240,84,79,.75)' },
{ name: 'MACD', data: m.macd, color: '#5ab0f0', width: 1.2 },
{ name: 'Signal', data: m.signal, color: '#e8a33d', width: 1.2 }
],
hlines: [0]
};
style: 'histud' - bars coloured by the CANDLE
The same bars, but the colour comes from the price bar underneath - teal where that candle closed up, red where it closed down - and the bars grow from the bottom of the pane rather than from zero. This is what Tick Volume and Candle Range are drawn with. The colours are fixed: histud takes no colour of its own, because the whole point of it is that it agrees with the candles.
const n = d.c.length;
const body = new Array(n);
for (let i = 0; i < n; i++) body[i] = Math.abs(d.c[i] - d.o[i]);
return {
pane: 'sub',
plots: [{ name: 'Body', data: body, style: 'histud' }]
};
style: 'candle' - draw bars, not lines
A candle plot carries four series: o, h and l alongside data, which is the close. Use it where the SHAPE carries the meaning and a single line would throw it away. This one is price with its own 50-bar average taken out, so the pane shows how far each bar ran from fair value.
const base = lib.ema(d.c, 50);
const n = d.c.length;
const O = new Array(n), H = new Array(n), L = new Array(n), C = new Array(n);
const col = new Array(n);
for (let i = 0; i < n; i++) {
const b = base[i];
O[i] = d.o[i] - b; H[i] = d.h[i] - b;
L[i] = d.l[i] - b; C[i] = d.c[i] - b;
col[i] = d.c[i] >= d.o[i] ? '#2fb8a4' : '#f0544f';
}
return {
pane: 'sub',
plots: [{ name: 'Detrended', style: 'candle',
o: O, h: H, l: L, data: C, colors: col }],
hlines: [0]
};
style: 'dots' - one marker per bar
Dots are for a series that is a sequence of points rather than a path - Parabolic SAR is the classic. They are sized off the candle width, so they neither vanish zoomed out nor balloon zoomed in. Dots are a CHART-pane style: in a sub pane the same plot comes out as a line.
const ps = lib.psar(d.h, d.l, 0.02, 0.2);
const col = ps.side.map(s => s > 0 ? '#2fb8a4' : '#f0544f');
return {
pane: 'chart',
plots: [{ name: 'PSAR', data: ps.sar, style: 'dots', colors: col }]
};
One line, many colours
colors is an array as long as data: entry i colours the segment that ENDS at bar i, and color is the fallback wherever it is null. Before this existed the only way to colour a line by state was to draw it twice with each half NaN-ed out.
const len = 34;
const ma = lib.ema(d.c, len);
const col = ma.map((v, i) => {
if (!isFinite(v) || i === 0 || !isFinite(ma[i - 1])) return null;
return v >= ma[i - 1] ? '#2fb8a4' : '#f0544f';
});
return {
pane: 'chart',
plots: [{ name: 'EMA ' + len, data: ma, colors: col,
color: '#7f8b99', width: 2 }]
};
label: tag the current value on the scale
label: true on a plot prints its NEWEST value as a chip on the pane's own scale, in the plot's colour. Newest means strictly the last bar: if that value is NaN nothing is chipped, rather than a stale number being presented as today's.
const r = lib.rsi(d.c, 14);
return {
pane: 'sub',
plots: [{ name: 'RSI 14', data: r, color: '#c678dd', width: 1.4,
label: true }],
hlines: [30, 50, 70]
};
Two footnotes to the list. A plot with no
style is a line — that is the sixth, not a seventh. And
dots only draws as dots on the price chart: in a pane of its
own the same plot comes out as a line.
7. Levels
A level is a horizontal line across the pane. They are the cheapest thing in the language and the most useful: a zero line turns a signed series into something you can read at a glance.
Reference levels
hlines takes plain numbers, or objects for control: {v, color, dash, width, label}. label: true prints the value on the scale, label: 'text' prints your own words. Levels are counted in the pane's auto-scale, which is the one lever you have over it - a custom script cannot set a fixed range, so pinning 0 and 100 with two hlines is how an oscillator keeps a steady scale.
const r = lib.rsi(d.c, 14);
return {
pane: 'sub',
plots: [{ name: 'RSI 14', data: r, color: '#c678dd', width: 1.4 }],
hlines: [
0, // pins the scale
{ v: 30, color: '#2fb8a4', dash: [3, 4] },
{ v: 50, color: '#39434f' },
{ v: 70, color: '#f0544f', dash: [3, 4], label: 'hot' },
100 // pins the scale
]
};
Levels are counted in the pane's automatic scale, and for a custom script that is the only handle on it there is. A built-in indicator can declare a fixed range — RSI's 0 to 100 — but that setting is not one of the fields that crosses out of the sandbox. Two levels at the ends of the range you want do the same job. (Whoever is looking at the chart can always drag the pane's own axis, and that beats everything.)
8. Inputs, and the properties dialog
Declaring an input is what turns a piece of code into a real indicator: the properties dialog grows a control for it, and it can be retuned from the chart chip without opening the editor at all.
input.int('Length', 20, { min: 1, max: 500 }) // whole numbers
input.number('Mult', 2, { min: 0.1, step: 0.1 }) // any number
input.bool('Show band', true)
input.color('Line', '#5ab0f0')
input.text('Note', '')
input.select('Mode', 'fast', ['fast', 'slow'])
input.source('Source', 'close') // open|high|low|close|hl2|hlc3|ohlc4
Each call returns the value to use — the saved one if there is one, the default otherwise. So you use the return value and never read the saved settings yourself.
Three rules that are not obvious:
- The label makes the key. "EMA length (fast)" becomes
ema_length_fast. Rename a label and the old saved value no longer matches it — the input falls back to its default. - Declare the same label twice and it is offered once. Two boxes writing to one key is not a form anyone can use.
- Every value is clamped before you see it. Saved settings live in
a layout the user owns, and a length of
-1or"banana"reaching your loop would be your crash and their confusion. Below the minimum clamps to the minimum; nonsense falls back to the default; a colour that is not a colour, or an option that is not on your list, does the same.
Every kind of input, in one script
Declaring an input is what turns your code into a normal indicator: the properties dialog grows a control for it, and it can be retuned from the chip without opening the editor. Each call returns the saved value, or the default when nothing is saved - so you use the return value and never read params yourself.
const len = input.int('Length', 20, { min: 1, max: 500 });
const mult = input.number('Band width', 2, { min: 0.1, step: 0.1 });
const band = input.bool('Show band', true);
const line = input.color('Line', '#5ab0f0');
const src = input.source('Source', 'close'); // open/high/low/close/hl2/hlc3/ohlc4
const mode = input.select('Basis', 'ema', ['ema', 'sma']);
const note = input.text('Note', '');
const s = src === 'open' ? d.o : src === 'high' ? d.h
: src === 'low' ? d.l : src === 'hl2' ? d.h.map((v, i) => (v + d.l[i]) / 2)
: src === 'hlc3' ? d.h.map((v, i) => (v + d.l[i] + d.c[i]) / 3)
: d.c;
const basis = mode === 'sma' ? lib.sma(s, len) : lib.ema(s, len);
const a = lib.atr(d.h, d.l, d.c, len);
const plots = [{ name: 'Basis', data: basis, color: line, width: 1.4 }];
if (band) {
plots.push({ name: 'Up', data: basis.map((v, i) => v + mult * a[i]),
color: line, width: 1, dash: [3, 4] });
plots.push({ name: 'Dn', data: basis.map((v, i) => v - mult * a[i]),
color: line, width: 1, dash: [3, 4] });
}
return { pane: 'sub', plots: plots,
corner: note ? { text: note, color: '#7f8b99' } : null };
Inputs are discovered by running your script, not by reading it — it runs every time anyway, so a declaration is just a function call that reports itself. That is also why a script that has not run yet has no properties to show, and—the reason the mechanism is a function call at all: a script whose source is never shown can still declare what it can be tuned by.
A script is also told which timeframe it is
running on, so it can tune itself — a trigger that fits M5 is wrong on
the hourly. d.tf is the seconds, d.tfName the label;
compare the name for an exact match, or the seconds for a range.
Know which timeframe you are on
The script is told its timeframe: d.tf in SECONDS (60 M1, 300 M5, 900 M15, 1800 M30, 3600 H1, 14400 H4, 86400 D1, 604800 W1, 2592000 MN) and d.tfName as the label ('M5', 'H1', 'D1', 'W1', 'MN'...). Branch on either to tune a value per timeframe - trigger levels that make sense on M5 are wrong on the hourly. Compare the name for an exact match, or the seconds for a range.
let trigger;
if (d.tfName === 'M5') trigger = 0.00143;
else if (d.tfName === 'M15') trigger = 0.00235;
else if (d.tf >= 3600) trigger = 0.005; // H1 and higher
else trigger = 0.001;
return {
pane: 'sub',
plots: [{ name: 'trigger (' + d.tfName + ')',
data: d.c.map(() => trigger), color: '#e8a33d' }]
};
9. Labels, tables and readouts
A label is text pinned to a bar and a value, so it travels with the chart:
labels: [{
i: 240, // bar index
value: 1.0954, // where on the pane's scale
text: 'up',
style: 'up', // up | down | center | circle | none
size: 'small', // tiny | small | normal | large
color: '#2fb8a4', // the badge
textColor: '#08130f',
pill: true // rounded ends
}]
Labels on bars
A label is pinned to a bar index and a value: {i, value, text}. style picks the shape - 'up' sits under the bar with a pointer up, 'down' above it, 'center' on it, 'circle' is a dot and 'none' is bare text. There is no cap on how many you draw, so thin them out yourself if the chart is to stay readable.
const fast = lib.ema(d.c, 12), slow = lib.ema(d.c, 34);
const labels = [];
for (let i = 1; i < d.c.length; i++) {
const now = fast[i] - slow[i], was = fast[i - 1] - slow[i - 1];
if (!isFinite(now) || !isFinite(was)) continue;
if (was <= 0 && now > 0)
labels.push({ i: i, value: d.l[i], text: 'up',
style: 'up', color: '#2fb8a4', textColor: '#08130f' });
if (was >= 0 && now < 0)
labels.push({ i: i, value: d.h[i], text: 'dn',
style: 'down', color: '#f0544f', textColor: '#1a0908' });
}
return {
pane: 'chart',
plots: [
{ name: 'EMA 12', data: fast, color: '#e8a33d' },
{ name: 'EMA 34', data: slow, color: '#5ab0f0' }
],
labels: labels
};
A table of numbers
table draws a small grid anywhere in the pane. rows is an array of arrays; a cell is a string, or {text, color, bold} when it needs to carry a state. position takes top/middle/bottom crossed with left/center/right. corner: {text} is the one-line shorthand for the same machinery.
const n = d.c.length - 1;
const r = lib.rsi(d.c, 14);
const a = lib.atr(d.h, d.l, d.c, 14);
const adx = lib.adx(d.h, d.l, d.c, 14);
const f = (v, k) => isFinite(v) ? v.toFixed(k) : '-';
const state = adx.adx[n] > 25 ? { text: 'TRENDING', color: '#2fb8a4', bold: true }
: { text: 'chop', color: '#e8a33d' };
return {
pane: 'sub',
plots: [{ name: 'ADX 14', data: adx.adx, color: '#c678dd', width: 1.4 }],
hlines: [{ v: 25, color: '#39434f', dash: [3, 4] }],
table: {
position: 'top_right',
fontSize: 11,
rows: [
['RSI', { text: f(r[n], 1), color: r[n] > 70 ? '#f0544f' : '#c7d0da' }],
['ATR', f(a[n], 5)],
['ADX', f(adx.adx[n], 1)],
['State', state]
]
}
};
The pane prints its own name in the top-left corner, so a table put there shares the space with it. top_right is the habit for that reason.
There is no cap on the number of labels — dropping labels a script meant to draw would be a lie about the chart. Thinning them out so the chart stays readable is your job, not the renderer's.
10. Free drawing
Boxes, lines and polygons are drawn in chart coordinates:
x is a bar index and y is a value on the pane's
scale. They move with the chart rather than sitting on the glass, and
x may run past the last bar into the margin
rightBars asks for.
boxes: [{ x1: 10, y1: 1.09, x2: 40, y2: 1.10,
bg: 'rgba(91,142,214,.15)', border: '#5b8ed6', bw: 1,
text: '31p', tcolor: '#8b97a6', tsize: 'tiny',
halign: 'center', valign: 'top' }],
lines: [{ x1: 10, y1: 1.09, x2: 60, y2: 1.11,
color: '#e8a33d', width: 1, dash: [4, 4] }],
polys: [{ pts: [[10, 1.09], [40, 1.10], [40, 1.08]],
fill: 'rgba(47,184,164,.2)', line: '#2fb8a4',
width: 1, closed: true }],
rightBars: 26 // 0-400 empty bars kept past the last candle
Boxes in chart coordinates
boxes, lines and polys are drawn in CHART coordinates: x is a bar index and y is a price, so they move with the chart instead of sitting on the glass. This one marks each day's range and labels its height. Boxes and lines are capped at 2500 each per result.
const boxes = [];
let start = 0, hi = -Infinity, lo = Infinity;
const dayOf = i => Math.floor(d.t[i] / 86400);
const flush = (end) => {
if (end <= start || !isFinite(hi)) return;
const up = d.c[end] >= d.o[start];
boxes.push({
x1: start, y1: lo, x2: end, y2: hi,
bg: up ? 'rgba(47,184,164,.07)' : 'rgba(240,84,79,.07)',
border: up ? 'rgba(47,184,164,.45)' : 'rgba(240,84,79,.45)',
text: ((hi - lo) * 10000).toFixed(0) + 'p',
tcolor: '#8b97a6', tsize: 'tiny', valign: 'top'
});
};
for (let i = 0; i < d.c.length; i++) {
if (i && dayOf(i) !== dayOf(i - 1)) {
flush(i - 1);
start = i; hi = -Infinity; lo = Infinity;
}
if (d.h[i] > hi) hi = d.h[i];
if (d.l[i] < lo) lo = d.l[i];
}
flush(d.c.length - 1);
return { pane: 'chart', plots: [], boxes: boxes };
A volume profile in the right margin
rightBars asks the chart to keep that many empty bars past the last one, and x may run into them - which is what makes a profile, a forecast cone or a projection possible at all. This is the shape of script the drawing primitives were added for: no time series could express it.
const bins = input.int('Bins', 24, { min: 4, max: 120 });
const width = input.int('Width in bars', 26, { min: 4, max: 200 });
const look = input.int('Lookback', 240, { min: 20, max: 2000 });
const n = d.c.length, from = Math.max(0, n - look);
let hi = -Infinity, lo = Infinity;
for (let i = from; i < n; i++) { if (d.h[i] > hi) hi = d.h[i]; if (d.l[i] < lo) lo = d.l[i]; }
const step = (hi - lo) / bins;
if (!isFinite(step) || step <= 0) return { pane: 'chart', plots: [] };
const vol = new Array(bins).fill(0);
for (let i = from; i < n; i++) {
const k = Math.min(bins - 1, Math.max(0, Math.floor((d.c[i] - lo) / step)));
vol[k] += isFinite(d.v[i]) ? d.v[i] : 1;
}
let peak = 0, at = 0;
for (let k = 0; k < bins; k++) if (vol[k] > peak) { peak = vol[k]; at = k; }
const boxes = [];
for (let k = 0; k < bins; k++) {
const w = peak ? (vol[k] / peak) * width : 0;
if (w < 0.5) continue;
boxes.push({ x1: n - 1, y1: lo + k * step, x2: n - 1 + w, y2: lo + (k + 1) * step,
bg: k === at ? 'rgba(232,163,61,.45)' : 'rgba(91,142,214,.28)' });
}
return {
pane: 'chart',
plots: [],
rightBars: width + 2,
boxes: boxes,
lines: [{ x1: from, y1: lo + (at + 0.5) * step, x2: n - 1 + width,
y2: lo + (at + 0.5) * step, color: '#e8a33d', dash: [4, 4] }]
};
The caps are per result: 2500 boxes, 2500 lines, 200
polygons of up to 4400 points each, and rightBars is held
between 0 and 400. They are there so a runaway script cannot flood the
renderer — if you are near any of them, the thing you are drawing
probably wants to be a plot.
11. The lib toolbox
Thirty calculations, all written the textbook way — Wilder's originals where he defined them — so a number here matches MT5 or TradingView on the same bars. They take plain arrays, so you can feed them anything, including each other's output.