Skip to main content

Building Indicators#

TracerAI can write an indicator for you from a plain-English description. When you want full control, you write one yourself in Tracer Script, a JavaScript dialect that runs against your chart. This page builds one from scratch, adds settings and live data, then covers the limits worth knowing. For the full list of functions and data sources, see the Scripting Reference.

How an indicator works#

An indicator is a function that runs once per candle. Tracer calls your onBar(i, ctx) for each bar i. You read the candle, compute a value, and plot it. Each plotted value lines up with its bar, so plotting across every bar draws a line on the chart.

The whole script re-runs from the top on every update, so treat it as a pure function of the candles rather than a long-running program. Anything you need to remember between bars goes in ctx.state, which is covered below.

Your first indicator#

An indicator has three parts: a header line that names it, an onBar function, and at least one plot. Here is the smallest one that works. It draws the close price back onto the chart.

// @indicator { name: "My First Indicator", overlay: true }
 
function onBar(i, ctx) {
  plot("close", candles[i].close, { color: "#4ea1ff" });
}
  • The header is a comment on line 1. overlay: true draws on the price pane, and false draws in a pane below the chart.
  • candles is always in scope. Each candles[i] has time, open, high, low, close, and volume.
  • plot(label, value, style) adds a point to the line named label.

To run it, open TracerAI, open the editor, paste the code, and click Attach. It shows up on your chart right away. See the TracerAI Overview for the editor and the attach flow.

TODO: screenshot of a simple custom indicator running on the chart (the result of attaching the example above).

Add settings#

Hard-coded numbers are fine to start, but you will want knobs. Declare them in a top-level getSettings() and Tracer builds the settings panel for you. Read the chosen values from ctx.settings. This version plots a moving average with an adjustable length and color.

// @indicator { name: "My EMA", overlay: true }
 
const closes = candles.map(c => c.close);
 
function getSettings() {
  return [
    { key: "length", type: "number", default: 20, min: 1, max: 400, step: 1, label: "Length" },
    { key: "color",  type: "color",  default: "#4ea1ff", label: "Line color" },
  ];
}
 
function onBar(i, ctx) {
  const value = ta.ema(closes, ctx.settings.length, i);
  if (value === null) { return; }
  plot("ema", value, { color: ctx.settings.color });
}
  • Setting types are number (with optional min, max, step), color, boolean, and select (which takes options: [{ value, label }]).
  • Build a plain array once at the top with candles.map(...) and pass it to the ta.* helpers. The candle data is read-only, so map it into your own array before transforming.
  • The ta.* helpers return null until they have enough bars to warm up. Always null-check before plotting, or you draw an invisible gap.
  • Pass your color setting on every plot call so restyling from the panel takes effect.

TODO: screenshot of the auto-generated settings panel for the indicator (the Length and Line color inputs).

Keep state across bars#

Because the script re-runs from the top each time, top-level variables are rebuilt on every run. The one thing that survives between bars is ctx.state. Set it up on the first bar, which you detect with ctx.isFirst.

There is one subtlety with the live candle. While a bar is still forming, Tracer re-runs onBar for that same bar on every tick, with the same ctx.state. A running total like state.sum += delta would double-count the live bar. Bank the closed total instead, and recompute the live bar from it.

function onBar(i, ctx) {
  if (ctx.isFirst) {
    ctx.state.base = 0;
    ctx.state.sum = 0;
    ctx.state.lastTime = null;
  }
 
  const c = candles[i];
  const delta = c.close >= c.open ? c.volume : -c.volume;
 
  if (c.time !== ctx.state.lastTime) {          // a new bar opened
    if (ctx.state.sum != null) { ctx.state.base = ctx.state.sum; }
    ctx.state.lastTime = c.time;
  }
  ctx.state.sum = ctx.state.base + delta;       // live bar recomputed from scratch
 
  plot("cvd", ctx.state.sum, { color: "#a78bfa" });
}

Treat ctx.state as a cache, not permanent storage. It is cleared whenever the script warms up again, which happens on any settings, symbol, or interval change. Your first-bar setup has to rebuild everything from the candles alone.

Pull in Tracer's data#

Your script can read Tracer's aggregated feeds, not just the chart's candles. Declare each one at the top level with source(kind, options) or a tracer.* accessor. They must be declared at the top, unconditionally, with literal options. A source() call inside onBar throws, and Tracer matches fetched data back to your declarations by their order, so a declaration hidden behind an if desyncs the data.

const oi = source("oi");
 
function onBar(i, ctx) {
  if (!oi.values.oiUsd) { return; }
  const v = oi.values.oiUsd[i];
  if (Number.isFinite(v)) { plot("oi", v); }
}

A few things to expect:

  • Missing or failed data comes back as an empty-but-shaped payload, never an error inside onBar. Check that a column exists before you read it.
  • Per-candle feeds like open interest and funding are lined up one-to-one with your candles, with NaN where there is no data, so NaN-check before doing math. Liquidation buckets are zero-filled, where 0 means none.
  • One unit gotcha: the from and to options you pass to source() are unix milliseconds, while every timestamp you read back (candle time, values.time) is unix seconds.

Draw more than lines#

Beyond lines, you can draw entities and tables. Entities are markers, lines, boxes, and labels placed at a time and price. Tables are small on-chart readouts. Entities are keyed, so re-emitting the same key updates that object in place, and handle.delete() removes one. Give each logical object a stable key, and delete signals that flip off so a stale marker does not linger. The full entity and table API, with every field, is in the Scripting Reference.

Limits and rules#

A handful of rules keep scripts safe and fast. You will run into these.

  • Plot null, never NaN or Infinity, for a gap. The validator rejects non-finite values. Wrap risky division in Number.isFinite before plotting.
  • Brace every loop body. A bare for or while without { } is rejected.
  • The candle and data arrays are read-only. Map them into your own arrays to transform.
  • Only the built-in helpers exist: candles, plot, entity, source, tracer, ta, math, and color, plus standard Math, Array, Number, and JSON. There is no fetch or network access, so all market data comes through source().
  • Index arrays with numbers. candles[i] and closes[i - 1] are fine. Only string keys like obj["name"] are blocked, which a normal indicator never needs.

Keep onBar cheap. Every ta.* call re-walks the series from the start, so calling several on a long chart adds up. When your output only depends on the final state, such as a level line or a table, gate that work behind ctx.isLast and skip the warmup bars.

Hard limits
  • Plots: 20 lines, 10 fills, 100,000 values per line.
  • Entities: 10,000 per script.
  • Tables: 5 tables, up to 50 rows by 12 columns, 300 cells each and 1,000 cells total. Cell text is truncated to 64 characters.
  • Compute budget: 5 seconds, 1,000,000 operations, 1,000,000 loop iterations, and a call depth of 64 per run. A script that blows the budget is stopped and restarts cold.
  • Data feeds clamp silently: open interest to 500 rows, funding to 1,000, liquidations to 50,000 events.

Exceeding any cap fails the whole output, not just the excess.

Learn more#