ChartSynq

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:

the shape of every script
(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:

everything d carries
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. Entry i of a plot is drawn at bar i. A series that starts at zero length and gets pushed to once per bar comes out right; one built by filter does 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 null or 0 for 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.

What this code draws: The smallest indicator there is
draws on the price chart
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.

What this code draws: The same thing in its own pane
draws in its own pane
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:

the whole result object
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:

every field a plot understands
{
  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.

What this code draws: style: 'line' - the default
draws on the price chart
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.

What this code draws: style: 'area' - filled to zero
draws in its own pane
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.

What this code draws: style: 'hist' - bars from zero
draws in its own pane
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.

What this code draws: style: 'histud' - bars coloured by the CANDLE
draws in its own pane
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.

What this code draws: style: 'candle' - draw bars, not lines
draws in its own pane
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.

What this code draws: style: 'dots' - one marker per bar
draws on the price chart
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.

What this code draws: One line, many colours
draws on the price chart
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.

What this code draws: label: tag the current value on the scale
draws in its own pane
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.

What this code draws: Reference levels
draws in its own pane
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.

the seven kinds
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 -1 or "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.

What this code draws: Every kind of input, in one script
draws in its own pane
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.

draws in its own pane
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:

a label
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.

What this code draws: Labels on bars
draws on the price chart
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.

What this code draws: A table of numbers
draws in its own pane
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.

the three primitives
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.

What this code draws: Boxes in chart coordinates
draws on the price chart
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.

What this code draws: A volume profile in the right margin
draws on the price chart
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)→ array

Exponential moving average. Seeded with the first len values' mean, NaN before that.

lib.sma(src, len)→ array

Simple moving average.

lib.wma(src, len)→ array

Linearly weighted moving average - the newest bar counts len times, the oldest once.

lib.dema(src, len)→ array

Double EMA: 2*ema - ema(ema). Turns faster than an EMA of the same length.

lib.rma(src, len)→ array

Wilder'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..100

Wilder'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)→ array

Commodity channel index, on the mean deviation of the typical price.

lib.willr(h, l, c, len)→ array, -100..0

Williams %R. Note the sign: 0 is the top of the range, -100 the bottom.

lib.roc(src, len)→ array, percent

Rate of change over len bars, as a percentage.

lib.change(src)→ array

This bar minus the previous one. NaN on the first bar.

Range, trend and stops

lib.atr(h, l, c, len)→ array

Average 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)→ array

Highest value of the last len bars, this one included.

lib.lowest(src, len)→ array

Lowest value of the last len bars, this one included.

Volume

lib.vwap(t, h, l, c, v, sessionSec)→ array

Volume-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)→ array

On-balance volume.

lib.mfi(h, l, c, v, len)→ array, 0..100

Money flow index - RSI weighted by volume.

lib.cmf(h, l, c, v, len)→ array, -1..1

Chaikin money flow.

Structure: tops, bottoms and levels

lib.peaks(src, k)→ array of bar INDICES

Bars 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 INDICES

The same, downward.

lib.joinAt(src, idx)→ array

A 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)→ array

A 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)→ array

The 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).

the streaming definition
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.

What this code draws: Form B: one bar at a time
draws in its own pane
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.

the four actions
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 map in 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).

What this code draws: EMA
draws on the price chart
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}.

What this code draws: Bollinger Bands
draws on the price chart
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.

What this code draws: RSI
draws in its own pane
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

What this code draws: MACD
draws in its own pane
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

What this code draws: Stochastic
draws in its own pane
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.

What this code draws: ATR
draws in its own pane
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.

What this code draws: VWAP
draws on the price chart
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.

What this code draws: Tick Volume
draws in its own pane
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.

What this code draws: Candle Range (mpips)
draws in its own pane
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}.

What this code draws: SuperTrend
draws on the price chart
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

What this code draws: Parabolic SAR
draws on the price chart
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.

What this code draws: Ichimoku
draws on the price chart
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.

What this code draws: Daily floor pivots
draws on the price chart
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.

What this code draws: Heikin-Ashi
draws on the price chart
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.