Writing your own indicator
ChartSynq 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, ta, input) => {
your code here
return { plots: [ ... ] };
}
d— the bars of the window you are on.ta— 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 ta 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.
Averages and smoothing
lib.ema(src, len)→ arrayExponential moving average. Seeded with the first len values' mean, NaN before that.
lib.sma(src, len)→ arraySimple moving average.
lib.wma(src, len)→ arrayLinearly weighted moving average - the newest bar counts len times, the oldest once.
lib.dema(src, len)→ arrayDouble EMA: 2*ema - ema(ema). Turns faster than an EMA of the same length.
lib.rma(src, len)→ arrayWilder's smoothing - an EMA with k = 1/len. ADX and RSI are DEFINED on this; ema(2n-1) is only an approximation of it.
Oscillators
lib.rsi(src, len)→ array, 0..100Wilder's relative strength index.
lib.macd(src, fast, slow, signal)→ {macd, signal, hist}hist is macd minus signal, which is the series you draw as a histogram.
lib.stoch(h, l, c, kLen, dLen, smooth)→ {k, d}Stochastic oscillator, both lines already smoothed.
lib.cci(h, l, c, len)→ arrayCommodity channel index, on the mean deviation of the typical price.
lib.willr(h, l, c, len)→ array, -100..0Williams %R. Note the sign: 0 is the top of the range, -100 the bottom.
lib.roc(src, len)→ array, percentRate of change over len bars, as a percentage.
lib.change(src)→ arrayThis bar minus the previous one. NaN on the first bar.
Range, trend and stops
lib.atr(h, l, c, len)→ arrayAverage true range, Wilder's - the same number MT5 prints.
lib.bb(src, len, mult)→ {up, mid, lo}Bollinger bands: a simple moving average plus and minus mult standard deviations.
lib.adx(h, l, c, len)→ {adx, pdi, ndi}Wilder's directional movement. adx measures how much trend there is, not which way.
lib.psar(h, l, step, maxAf)→ {sar, side}Parabolic SAR. side is +1 while the stop is below price, -1 while it is above.
lib.supertrend(h, l, c, atrLen, mult)→ {line, dir}ATR bands that may only tighten while price stays on their side, so the line ratchets. dir is +1 up, -1 down.
lib.highest(src, len)→ arrayHighest value of the last len bars, this one included.
lib.lowest(src, len)→ arrayLowest value of the last len bars, this one included.
Volume
lib.vwap(t, h, l, c, v, sessionSec)→ arrayVolume-weighted average price, reset every sessionSec of BAR TIME (86400 = daily). On FX the weight is tick volume, because brokers publish no real volume.
lib.obv(c, v)→ arrayOn-balance volume.
lib.mfi(h, l, c, v, len)→ array, 0..100Money flow index - RSI weighted by volume.
lib.cmf(h, l, c, v, len)→ array, -1..1Chaikin money flow.
Structure: tops, bottoms and levels
lib.peaks(src, k)→ array of bar INDICESBars higher than the k bars either side. A flat top is reported once, at the bar it began on.
lib.troughs(src, k)→ array of bar INDICESThe same, downward.
lib.joinAt(src, idx)→ arrayA series that runs in straight segments through the given bars and is NaN outside them - so ta.joinAt(r, ta.peaks(r, 3)) is a line drawn across the tops of r.
lib.pivothigh(src, left, right)→ arrayA confirmed pivot high, reported right bars LATE - at the bar where it became certain, which is the earliest a live chart could know it.
lib.pivotlow(src, left, right)→ arrayThe same, downward.
lib.ichimoku(h, l, c, tenkanLen, kijunLen, senkouLen)→ {tenkan, kijun, senkouA, senkouB, chikou}Already displaced the way Hosoda defined it: the senkou lines are pushed FORWARD kijun bars, chikou is drawn kijun bars BACK.
lib.pivots(t, h, l, c)→ {p, r1, r2, r3, s1, s2, s3}Classic floor pivots from the previous DAY's high, low and close, held flat through the day and NaN at each boundary so consecutive days do not join into a staircase.
Anything returning an object gives you named series
— const m = lib.macd(d.c, 12, 26, 9) then
m.macd, m.signal, m.hist. Anything
returning an array gives you one value per bar, NaN where it has not warmed
up.
12. The streaming form
Everything so far recomputes the whole history every time the chart moves. For almost every indicator that is the right trade: it is simple, it cannot drift, and it is fast enough. When it stops being fast enough there is a second shape.
Return { stream: { ... } } instead of { plots }
and the app runs your script one bar at a time, committing every bar
older than lag once and recomputing only the tail. The cost per
new bar becomes O(lag) rather than O(all bars).
return { stream: {
lag: 1, // how many bars at the end can still change.
// 1 = only the forming bar.
init: (p) => ({}), // your state, once per run
newOut: () => ({ Line: [] }), // the arrays step() will fill
step: (st, d, i, out, labels) => { }, // called once per bar
assemble:(st, out, labels, p, d) => ({ pane:'sub', plots:[ ... ] })
}};
step is where the work happens: read bar i, update
st, push one entry per output array. assemble runs
afterwards and returns exactly the same result object the simple form
returns. The tail is recomputed on a copy of the committed state,
so a forming bar can change its mind without corrupting the history behind
it.
Form B: one bar at a time
The streaming form commits every bar older than lag once and only recomputes the tail, so the cost per new bar is O(lag) instead of O(all bars). Use it when the whole-array form starts to feel heavy: long histories, nested loops, or state that carries forward. step() is called once per bar and pushes into out; assemble() turns out into the same result object the simple form returns.
const len = input.int('Length', 20, { min: 1, max: 500 });
return { stream: {
lag: 1, // only the forming bar can change
init: (p) => ({ k: 2 / (len + 1), e: NaN, cnt: 0, sum: 0 }),
newOut: () => ({ Ema: [], Dist: [] }),
step: (st, d, i, out, labels) => {
const v = d.c[i];
if (st.cnt < len) { // seed on the first len bars
st.sum += v; st.cnt++;
if (st.cnt === len) st.e = st.sum / len;
} else {
st.e = v * st.k + st.e * (1 - st.k);
}
out.Ema.push(st.cnt < len ? NaN : st.e);
out.Dist.push(st.cnt < len ? NaN : (v - st.e) * 10000);
},
assemble: (st, out, labels, p, d) => ({
pane: 'sub',
plots: [{ name: 'Distance to EMA ' + len + ' (pips)',
data: out.Dist, style: 'area',
color: '#d7dde5',
pos: 'rgba(47,184,164,.28)', neg: 'rgba(240,84,79,.28)' }],
hlines: [0]
})
}};
Write the simple form first. Get it right, watch what Test says it costs, and convert it only if that number bothers you — streaming state is the one place in this language where a bug can be invisible for a hundred bars and then wrong for the rest.
13. Placing trades
A script can ask for a trade. It cannot take one.
Everything below is a request, handed back with your plots. The page decides whether to act on it, and by default the answer is no.
return {
plots: [ ... ],
orders: [
{ act: 'buy', i: n, lots: 0.1, sl: 20, tp: 0, maxOpen: 1 },
{ act: 'sell', i: n, lots: 0.1 },
{ act: 'close', i: n, side: 'buy' },
{ act: 'closePct', i: n, side: 'both', pct: 50 }
]
};
i is the bar the order belongs to, and is required.
sl and tp are distances in pips, not prices; 0
means none. maxOpen refuses the order when that many are
already open on that side, and defaults to 1 — so
the plainest possible script cannot pyramid by accident. Pass 0 for no
limit. side is 'buy', 'sell' or
'both'.
Three things stand between your script and the account
The person running it has to arm it. Every indicator has its own switch, in its properties, and it starts off. A script cannot turn itself on, and cannot tell whether it is on. This is what makes installing somebody else's indicator safe: it arrives switched off, whatever its author had switched on.
Replay only. In live mode nothing is placed, at all.
Only the last bar acts, and only once. Your script is re-run over the whole series constantly — every redraw, every new bar, every resize. Only an order sitting on the final bar of that run is ever acted on, and only once for that bar. So you can return your entire history of signals, and should: it is the same list you drew labels from, and everything but the live edge is ignored.
The bar's time is stamped onto the order by the sandbox, not by you. An
order whose i is not a real bar never fires.
What it cannot do
Your script runs in a worker, on its own schedule, while the replay runs on the page. At speed the worker falls behind and bars go past unseen, so signals are missed. The count sits next to the switch — “12 placed, 3 bars missed” — because a missed signal looks exactly like a signal that was never given.
If a strategy must not miss a bar, it belongs in Settings › Rules, which runs inside the bar loop. Many people will want both: the script to open, the rules to manage and close.
Twenty orders cross per result and eight are placed per result, hard. Beyond twenty the list is trimmed to the newest, so the live edge always survives the trim.
14. Limits, errors and speed
When something is wrong
A script that throws does not break the chart. The pane keeps its space and prints the error next to the indicator's name, and the same message is waiting in the editor. Press ▶ Test for the fuller version, including the line number when the browser gives us one.
The six-second rule
A script that has not finished after six seconds is stopped, and the exact source that hung is refused until you edit it — otherwise every redraw would hang the worker again. In practice this only ever fires on a loop that never ends.
What makes a script slow
- A loop inside a loop over every bar. A twenty-thousand-bar history with a 200-bar inner window is four million iterations per redraw. Keep a running total instead, or move to the streaming form.
- Rebuilding big arrays with
mapin a loop. One pass that fills a pre-sized array is dramatically cheaper than twenty passes that each allocate one. - Drawing thousands of primitives. Every box is drawn on every frame. A profile of 24 bins is free; one of 2400 is not.
What crosses back, and what does not
The result is rebuilt field by named field on the way out, so anything not named in this manual is dropped. The three that people look for:
overlay— the trick that lets the built-in Tick Volume ride the bottom of the price chart on a hidden scale. Not available; a custom histogram gets a pane.range— a fixed pane scale. Not available; use two levels (chapter 7).- Anything holding a function, a class or a reference. Only numbers, strings, booleans and arrays of those survive the crossing.
Every string is bounded too: names and label text are cut at 60 characters,
a corner readout at 120, colours at 32. Nothing is silently rounded —
numbers cross as numbers, and null in a data series becomes
NaN, because isFinite(null) is true and a null
that reached the renderer used to blank the whole window.
15. The shipped indicators, as source
The fastest way to learn a language is to read something that works. Every script below is a built-in indicator — the same maths the picker offers, written out as a custom script. Paste one in, press Save, and change it into the version you actually wanted.
Where a built-in reaches something a script cannot, that is called out on the example rather than quietly papered over.
EMA
The shipped EMA, whole and unedited. Everything in this chapter is the real code behind a built-in indicator, rewritten only where the built-in reaches something a script cannot (those places are called out).
const len = input.int('len', 20, { min: 1, max: 1000 });
const src = input.source('src', 'close');
const col = input.color('color', '#e8a33d');
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;
return { pane: 'chart',
plots: [{ name: 'EMA' + len, data: lib.ema(s, len), color: col, width: 1.4 }] };
Bollinger Bands
Three plots off one helper. lib.bb returns {up, mid, lo}.
const len = input.int('len', 20, { min: 2, max: 1000 });
const mult = input.number('mult', 2, { min: 0.1, step: 0.1 });
const col = input.color('color', '#5ab0f0');
const b = lib.bb(d.c, len, mult);
return { pane: 'chart', plots: [
{ name: 'up', data: b.up, color: col, width: 1 },
{ name: 'mid', data: b.mid, color: col, width: 1, dash: [4, 3] },
{ name: 'lo', data: b.lo, color: col, width: 1 }
]};
RSI
The built-in declares range: [0, 100] so its pane never rescales. A script cannot: range is not carried across the sandbox boundary. Two hlines at 0 and 100 do the same job, because levels count in the pane's auto-scale.
const len = input.int('len', 14, { min: 2, max: 1000 });
const col = input.color('color', '#c678dd');
return { pane: 'sub',
plots: [{ name: 'RSI' + len, data: lib.rsi(d.c, len), color: col, width: 1.3 }],
hlines: [0, 30, 50, 70, 100] };
MACD
const fast = input.int('fast', 12, { min: 1, max: 500 });
const slow = input.int('slow', 26, { min: 1, max: 500 });
const sig = input.int('signal', 9, { min: 1, max: 500 });
const m = lib.macd(d.c, fast, slow, sig);
return { pane: 'sub', plots: [
{ name: 'hist', data: m.hist, style: 'hist' },
{ name: 'macd', data: m.macd, color: '#5ab0f0', width: 1.2 },
{ name: 'sig', data: m.signal, color: '#e8a33d', width: 1.2 }
], hlines: [0] };
Stochastic
const kLen = input.int('k', 14, { min: 1, max: 500 });
const dLen = input.int('d', 3, { min: 1, max: 500 });
const smooth = input.int('smooth', 3, { min: 1, max: 500 });
const s = lib.stoch(d.h, d.l, d.c, kLen, dLen, smooth);
return { pane: 'sub', plots: [
{ name: '%K', data: s.k, color: '#5ab0f0', width: 1.2 },
{ name: '%D', data: s.d, color: '#e8a33d', width: 1.2 }
], hlines: [0, 20, 80, 100] };
ATR
lib.atr is Wilder's, via an EMA of 2n-1 - the same number MT5 prints.
const len = input.int('len', 14, { min: 1, max: 1000 });
const col = input.color('color', '#e8a33d');
return { pane: 'sub',
plots: [{ name: 'ATR' + len, data: lib.atr(d.h, d.l, d.c, len),
color: col, width: 1.3, label: true }] };
VWAP
Session VWAP, resetting at each day boundary of the BAR TIMES. On FX the weight is tick volume, because brokers publish no real volume - which is what MT5 does too.
const session = input.select('session', 'day', ['day', 'week']);
const col = input.color('color', '#e8a33d');
const secs = session === 'week' ? 604800 : 86400;
return { pane: 'chart',
plots: [{ name: 'VWAP', data: lib.vwap(d.t, d.h, d.l, d.c, d.v, secs),
color: col, width: 1.5 }] };
Tick Volume
The built-in rides the bottom slice of the price chart on a hidden scale (overlay: true). A script cannot: overlay is not carried across the sandbox boundary, so a custom volume gets a pane of its own. Everything else is identical.
return { pane: 'sub',
plots: [{ name: 'Vol', data: d.v, style: 'histud' }] };
Candle Range (mpips)
The whole height of the bar in mpips - a tenth of a pip - so a five-pip M1 candle reads 50. The built-in reads the instrument's point size from the feed and takes its threshold per timeframe; a script knows neither, so both become inputs. d._tf is the window's timeframe in seconds, which is how a script can still tell M1 from H1.
const point = input.number('Point size', 0.00001, { min: 1e-8 });
const pipPts = input.int('Points per pip', 10, { min: 1, max: 100 });
const level = input.number('Line (mpips)', 50, { min: 0 });
const col = input.color('Line colour', '#e8a33d');
const mpip = point * pipPts / 10; // a tenth of a pip
const out = d.c.map((_, i) => (d.h[i] - d.l[i]) / mpip);
return { pane: 'sub',
plots: [{ name: 'Range', data: out, style: 'histud' }],
hlines: level > 0 ? [{ v: level, color: col, dash: [4, 3], label: true }] : [] };
The threshold is a per-timeframe field on the built-in, because a big candle is not one number: 50 on M1 and 50 on H1 are not the same statement. This preview is H1, where 50 mpips is a very small bar.
SuperTrend
The band-carrying rule is what makes it SuperTrend: a band may only tighten while price stays on its side, so the line ratchets. lib.supertrend returns {line, dir}.
const len = input.int('atr length', 10, { min: 1, max: 500 });
const mult = input.number('multiplier', 3, { min: 0.1, step: 0.1 });
const s = lib.supertrend(d.h, d.l, d.c, len, mult);
const col = s.dir.map(v => v > 0 ? '#2fb8a4' : '#f0544f');
return { pane: 'chart',
plots: [{ name: 'SuperTrend', data: s.line, colors: col,
color: '#7f8b99', width: 1.6 }] };
Parabolic SAR
const step = input.number('step', 0.02, { min: 0.001, step: 0.001 });
const maxAf = input.number('max', 0.2, { min: 0.01, step: 0.01 });
const ps = lib.psar(d.h, d.l, step, maxAf);
return { pane: 'chart',
plots: [{ name: 'PSAR', data: ps.sar, style: 'dots',
colors: ps.side.map(s => s > 0 ? '#2fb8a4' : '#f0544f') }] };
Ichimoku
Five series, displaced the way Hosoda defined them: the two senkou lines are pushed FORWARD kijun bars, chikou is today's close drawn kijun bars BACK. The cloud floats past the last bar, so ask for the room with rightBars.
const tk = input.int('tenkan', 9, { min: 1, max: 500 });
const kj = input.int('kijun', 26, { min: 1, max: 500 });
const sp = input.int('senkou', 52, { min: 1, max: 500 });
const k = lib.ichimoku(d.h, d.l, d.c, tk, kj, sp);
return { pane: 'chart', rightBars: kj + 2, plots: [
{ name: 'tenkan', data: k.tenkan, color: '#5ab0f0', width: 1.1 },
{ name: 'kijun', data: k.kijun, color: '#f0544f', width: 1.1 },
{ name: 'senkouA', data: k.senkouA, color: '#2fb8a4', width: 1 },
{ name: 'senkouB', data: k.senkouB, color: '#e8a33d', width: 1 },
{ name: 'chikou', data: k.chikou, color: '#9aa5b1', width: 1, dash: [3, 3] }
]};
Daily floor pivots
Classic pivots from the PREVIOUS day's high, low and close, held flat for the day. The first bar of each day is deliberately NaN, so consecutive days plot as separate flat segments instead of one staircase.
const p = lib.pivots(d.t, d.h, d.l, d.c);
return { pane: 'chart', plots: [
{ name: 'R2', data: p.r2, color: 'rgba(240,84,79,.5)', width: 1, dash: [3, 3] },
{ name: 'R1', data: p.r1, color: '#f0544f', width: 1 },
{ name: 'P', data: p.p, color: '#e8a33d', width: 1.3 },
{ name: 'S1', data: p.s1, color: '#2fb8a4', width: 1 },
{ name: 'S2', data: p.s2, color: 'rgba(47,184,164,.5)', width: 1, dash: [3, 3] }
]};
Heikin-Ashi
A bar TRANSFORM, not a study: every HA candle sits on exactly the bar it came from, at the same prices, so it lands on the price pane 1:1 with the real candles. Turn the real ones off with the window's candle button and the window IS a Heikin-Ashi chart. What HA is not is a price you can trade: its open and close are averages, so an HA close never happened.
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++) {
C[i] = (d.o[i] + d.h[i] + d.l[i] + d.c[i]) / 4;
O[i] = i === 0 ? (d.o[i] + d.c[i]) / 2 : (O[i - 1] + C[i - 1]) / 2;
H[i] = Math.max(d.h[i], O[i], C[i]);
L[i] = Math.min(d.l[i], O[i], C[i]);
col[i] = C[i] >= O[i] ? '#2fb8a4' : '#f0544f';
}
return { pane: 'chart',
plots: [{ name: 'HA', style: 'candle',
o: O, h: H, l: L, data: C, colors: col }] };
16. Asking an AI to write one
This page is written for a person: it explains, it shows pictures, it leaves out the tedious parts. A model asked to write an indicator needs the opposite — every field, every limit, every thing that does not work, with nothing implied by a screenshot.
So there is a second copy of all of this, written for that:
The API, written for an AI Plain text
Give an assistant that page — the plain-text link is the one to paste — and ask for what you want in your own words. Then bring the answer back here, press ▶ Test, and read what it says. Test is the part that is not optional: a model can write something that compiles, runs, and is confidently wrong about the maths, and the only person who can catch that is you.