Reference

JavaScript indicators

Write an indicator in plain JavaScript: the three exports, ta.* helpers, sandbox limits, and the NaN warm-up rule.

You can write an indicator in JavaScript instead of Pine. It runs in an isolated sandbox on our servers, computes against the same bars a backtest uses, and, unlike a Pine indicator, is not pinned to the symbol or timeframe you wrote it on. The same saved indicator recomputes for whatever a run happens to use.

The same capability exists in Python, with the same contract, the same ta.* helpers computing the same numbers, and the same limits. Pick whichever you would rather write in.

Everything on this page is enforced by the compiler, so when you break a rule you get a message with a line number rather than a wrong number.

The shape of an indicator#

Exactly three exports. Nothing else is required, and nothing else is special.

javascript
export const INPUTS = {
  length: 14,
  overbought: 70,
}

export const PLOTS = [
  { name: 'rsi', kind: 'line' },
  { name: 'hot', kind: 'signal' },
]

export function calc(candles, opts) {
  const rsi = ta.rsi(candles.close, opts.length)
  return {
    rsi,
    hot: rsi.map((v) => (Number.isFinite(v) && v > opts.overbought ? 1 : 0)),
  }
}
  • INPUTS: editable defaults. Numbers and true/false only; each becomes a control in the settings panel. Up to 24. An input can also describe itself, its range, step, label and section.
  • PLOTS: what you draw. Each name becomes a chart series and something you can reference in a query, so names must be unique. Up to 12.
  • calc(candles, opts): returns one array per declared plot, each exactly as long as the bars. opts is your INPUTS with any user overrides already applied.

Describing an input#

An input can be a bare default, or an object that says more about it:

javascript
export const INPUTS = {
  length: 14,                                                   // just a default
  mult:   { default: 2, min: 0.1, max: 10, step: 0.1,           // …or a description
            label: 'ATR Multiplier', tooltip: 'Stop distance.' },
}

Both forms mean the same thing to calc, opts.mult is the number 2 either way. What changes is the control the settings panel builds.

Why bother. Without a description the panel has only the default to go on, and a default cannot tell it everything. length: 14 and mult: 2 are both whole numbers, but one is a bar count and the other is a band width, and a band width you cannot set to 2.5 is broken. Writing 2.0 does not help: Python and JavaScript both hand the panel a plain 2.

FieldWhat it does
defaultRequired. The value the indicator runs at until someone changes it.
integerWhole numbers only. Without it, a number input accepts fractions.
min / maxThe range the control clamps to.
stepHow much the +/− buttons move. Defaults to 1 for a whole-number default, else 0.1.
labelWhat the user reads instead of the variable name.
tooltipOne or two sentences, shown on the ⓘ beside the control.
groupSection heading. Inputs sharing a group render under one header.
inlineInputs sharing an inline key pack onto one row.
options / optionLabelsA dropdown. Values stay numeric; the labels are what the user reads.

Grouping and dropdowns:

javascript
export const INPUTS = {
  fast: { default: 12, integer: true, group: 'Lengths', inline: 'ma' },
  slow: { default: 26, integer: true, group: 'Lengths', inline: 'ma' },
  mode: { default: 0, options: [0, 1, 2], optionLabels: ['EMA', 'SMA', 'WMA'], group: 'Method' },
}

fast and slow sit on one row under a LENGTHS heading; mode is a dropdown showing the three names, and opts.mode is 0, 1 or 2.

What is checked when you save. A default outside its own min/max, a min above its max, a step of zero, a dropdown default that is not one of the options, an integer input with a fractional bound, a range on a true/false input, or a misspelled field, each is refused with a line number rather than silently ignored.

Overrides are held to what you declared. A value outside the range is clamped to it; one that is not a listed option falls back to the default. That applies in the chart and in a backtest alike, so the two cannot run your indicator at different values.

An input with no description behaves exactly as it always has: a number field with no bounds, a step matched to the default's shape, and the variable name title-cased as its label.

candles is columnar, not a list of bars#

You get arrays, one entry per bar, oldest first:

javascript
candles.time      // epoch milliseconds
candles.open      candles.high      candles.low      candles.close      candles.volume
candles.hl2       // (high + low) / 2
candles.hlc3      // (high + low + close) / 3
candles.ohlc4     // (open + high + low + close) / 4
candles.bar_index // 0, 1, 2, …
candles.length    // number of bars

This is the same shape the ta.* helpers take and return, so candles.close goes straight into ta.ema(...) with nothing to convert at either end.

Warm-up is NaN, not null#

This is the one rule that catches everyone, and it fails silently, you get a confident-looking number instead of an error.

A helper has no value until it has enough bars. ta.ema(close, 26) has nothing to say for its first 25 bars, and it marks those bars NaN.

A null check does not exclude warm-up

v !== null is true for NaN, so it lets warm-up straight through. Test a helper's output with Number.isFinite(v).

javascript
// WRONG: reports a confident downtrend for the first 26 bars, before either EMA exists.
trend: fast.map((f, i) => (f !== null && slow[i] !== null ? (f > slow[i] ? 1 : -1) : null))

// RIGHT
trend: fast.map((f, i) => {
  const s = slow[i]
  if (!Number.isFinite(f) || !Number.isFinite(s)) return null
  return f > s ? 1 : -1
})

The rule reverses on the way out: what you return may use null for "no value on this bar", and any NaN or Infinity you return is converted to null for you, so a stray divide-by-zero cannot reach the chart as a broken number.

Plot kinds#

kinduse it fordrawn as
linea continuous valuea line on the chart
signal"this fired on this bar", 0 or 1a marker
statea small integer regime, e.g. -1 / 0 / +1a stepped series
colorone colour per bar, painting another layernothing on its own; see below

Drawing#

Your indicator does not have to be grey lines. Everything Pine can draw, you can declare, and each export below is the equivalent of the Pine call named beside it.

javascript
export const META    = { overlay: true, precision: 2 }        // indicator(overlay=…)
export const PLOTS   = [
  { name: 'hist', style: 'histogram', colors: 'histColor' },   // plot(style=, color=)
  { name: 'histColor', kind: 'color' },                        // the per-bar colour channel
]
export const LEVELS  = [{ value: 70, color: 'red', linestyle: 'dashed', label: 'OB' }]  // hline()
export const FILLS   = [{ from: 'upper', to: 'lower', color: 'blue', transp: 92 }]      // fill()
export const SHAPES  = [{ plot: 'buy', shape: 'triangleup', location: 'belowbar', text: 'BUY' }]
export const ARROWS  = [{ plot: 'netFlow', colorUp: 'green', colorDown: 'red' }]        // plotarrow()
export const BGCOLOR = 'regimeColor'   // bgcolor()
export const BARCOLOR = 'trendColor'   // barcolor()
export const CANDLES = [{ open: 'o', high: 'h', low: 'l', close: 'c' }]                 // plotcandle()

META.overlay is the field worth setting first. It is the difference between a moving average drawn on the price and one drawn in an empty pane underneath it, and it decides whether the preview chart puts your indicator on the price's scale.

A plot may declare style (line, stepline, histogram, columns, area, circles, cross), color, transp, colors, linewidth, linestyle, overlay, display, offset, precision, histbase and joinNulls.

Arrows say how much#

SHAPES marks when something happened; every marker is the same size. ARROWS says how much: the named plot's sign picks the direction, and its magnitude scales the arrow's length against the largest absolute value in the series.

javascript
export const PLOTS  = [{ name: 'netFlow', kind: 'line' }]
export const ARROWS = [{ plot: 'netFlow', colorUp: 'green', colorDown: 'red', minHeight: 4, maxHeight: 40 }]

It needs a line plot, a 0/1 signal has no magnitude to scale by, and that is refused rather than drawn at one uniform height.

Per-bar colour#

A kind: 'color' plot returns one colour string per bar: '#26a69a', '#26a69a80', 'rgb(38,166,154)', or a name like 'red', or null for "leave this bar alone". It never becomes a data column and can never be used in a strategy condition, because "above 50" means nothing for #26a69a. It exists only to paint whatever names it:

javascript
export const PLOTS = [
  { name: 'rsi', kind: 'line' },
  { name: 'zone', kind: 'color' },
]
export const BGCOLOR = 'zone'

export function calc(candles, opts) {
  const rsi = ta.rsi(candles.close, opts.length)
  return {
    rsi,
    zone: rsi.map(v => !Number.isFinite(v) ? null : v > 70 ? '#ef535020' : v < 30 ? '#26a69a20' : null),
  }
}

A colour channel that nothing points at is refused at compile time, it would cost a full series per bar and draw nothing.

The editor shows you the price#

Compile draws your indicator against the real candles it was computed from. An overlay study shares the price's scale, so you can see whether your band actually tracks the price; an oscillator keeps its own range with the price shown separately for context, so a 0-100 series is not flattened against the asset's price.

There are no packages#

An indicator is a single self-contained file. There is no npm, and no way to reach the outside world:

javascript
import _ from 'lodash'        // ✗ Cannot import "lodash"
const fs = require('fs')      // ✗ require is not defined
await fetch('https://…')      // ✗ fetch is not defined
process.env.SECRET            // ✗ process is not defined
Math.random()                 // ✗ throws
Date.now()                    // pinned to the last bar, not the wall clock

That is not a limitation we plan to lift, because it is what makes a backtest reproducible: a run today and the same run in a year must produce identical numbers. A floating dependency or a network call would break that, and a package you did not audit would be running against your account.

What to do instead:

  • Use ta.*: 65 helpers cover most of what a package would give you.
  • Paste the function in. You have 128KB. A pure JavaScript helper works exactly as it would anywhere else:
    javascript
    function median(values) {
      const sorted = [...values].sort((a, b) => a - b)
      return sorted[sorted.length >> 1]
    }
  • Import your own indicators: see below.

What you can import: your own indicators#

An indicator may import another one already saved on your account, by name with an @ prefix. This is how you build on a baseline, and how you keep shared maths in one place instead of pasting it into every file.

javascript
// Reuse another indicator's OUTPUT
import { calc as base, INPUTS as baseInputs } from '@my_baseline'

// …or just a helper it exports
import { median } from '@my_utils'

export const INPUTS = { length: 20 }
export const PLOTS = [{ name: 'smoothed', kind: 'line' }]

export function calc(candles, opts) {
  const b = base(candles, { ...baseInputs, length: opts.length })
  return { smoothed: ta.sma(b.rsi, 5) }
}

Imports are resolved before your code runs, so a problem is reported up front with the name or the chain that caused it:

  • a name you have not saved → You have no JavaScript indicator named "x".
  • two indicators importing each other → Circular import: a → b → c → a.
  • more than 5 levels deep, or more than 16 indicators

Deleting an indicator that others import is refused, and the response names the ones that depend on it, so you can edit those first.

Limits#

Run time3 seconds
Memory64 MB
Code size128 KB
Plots12
Inputs24
Imported indicators16, up to 5 levels deep

A timeout or a memory cap is reported as its own kind of failure: your code is valid, it just could not finish inside the budget.

The loop: validate, preview, save#

Validate

Checks syntax, the module shape and that every @import resolves. Loads no market data and never calls calc, so it answers in milliseconds. A green result means "this is a valid indicator", not "this works".

Compile & Preview

Runs calc against real bars for the symbol, timeframe and range in the toolbar, and plots the result. This is the step that finds a runtime error, a timeout, or a series of the wrong length.

Save

Compiles first and saves only if it compiles, a saved indicator that does not run is a landmine that would fail inside a backtest, far from the editor where you could fix it.

Drafts autosave as you type, and History in the editor toolbar restores earlier ones.

Using it in a query#

Exactly like any other custom indicator, reference the saved name with an @-tag:

text
Buy BTC when @my_osc crosses above 0, 4H last 180 days

A multi-plot indicator exposes each plot name as its own series. See Create a custom indicator for the full query side.

Errors you will meet#

messagewhat it means
Your indicator does not export PLOTSDeclare what you draw, even if it is one line.
x is null on every barUsually a warm-up guard that is never satisfied; check for !== null where you meant Number.isFinite.
x is declared in PLOTS, so calc must return itEvery declared plot needs an array back, null-filled during warm-up rather than omitted.
Every series must line up 1:1 with the barsFill warm-up bars instead of skipping them.
Cannot import "lodash"There are no packages; see above.
Your indicator went too deepAlmost always a function calling itself with no stopping condition.