ChartnautDocs

First indicator guide

This walkthrough builds an overlay indicator by hand: an EMA with a length and a colour setting, and an arrow on every bar that closes up through it.

When you finish, you will know how meta, inputs, warmup, output.* and onBar fit together. To have the agent write it for you instead, follow Build your own indicator.

Indicator Code tab with live chart preview
The indicator builder: the script beside a live chart preview.

Steps

  1. Open Tools → Indicators and click New.

  2. Click Build it yourself under the suggestions and give the indicator a Name.

  3. In the Code tab, replace the script with the one below and click Save.

  4. Open the Chart tab. Step through a few days with the arrows beside the date.

On a chart, the indicator's settings dialog shows Length and Color in a Parameters section, the layout the script declares.

A save that does not compile is still kept. The chart goes on showing the last version that did, and Errors shows what stopped it. Build a custom indicator covers the builder itself.

Full script

meta({ shortName: "EMA Cross", kind: "overlay" });
dialog({ title: "EMA Cross" });

const length = input.number({
  id: "length",
  label: "Length",
  default: 20,
  min: 1,
});
const color = input.color({
  id: "color",
  label: "Color",
  default: "#2196f3",
});
warmup((w) => w.ema(length));

const emaLine = output.line({ id: "ema", color: "#2196f3", colorFrom: color });
output.markers({ id: "cross_up", on: emaLine, color: "#26a69a" });

layout(
  tab({ id: "main", title: "Main" },
    section({ id: "params", title: "Parameters" }, length, color))
);

function onBar(ctx) {
  const k = 2 / (ctx.params.length + 1);

  const st = ctx.accum(
    "st",
    () => ({ time: null, ema: null, close: null, prevEma: null, prevClose: null }),
    (s) => {
      if (s.time !== ctx.time) {
        // A new bar: the values held so far belong to the previous bar.
        s.prevEma = s.ema;
        s.prevClose = s.close;
        s.time = ctx.time;
      }
      s.ema = s.prevEma == null ? ctx.close : s.prevEma + k * (ctx.close - s.prevEma);
      s.close = ctx.close;
      return s;
    }
  );

  ctx.plot("ema", st.ema);

  if (st.prevEma != null && st.prevClose <= st.prevEma && ctx.close > st.ema) {
    ctx.mark("cross_up", { shape: "arrowUp", position: "belowBar", text: "Cross" });
  }
}

What each part does

Piece

Job

meta({ kind: "overlay" })

Draws on the price pane. "pane" draws in a pane of its own below price

input.number / input.color

Settings the trader can change. Read them as ctx.params.length and ctx.params.color

warmup((w) => w.ema(length))

How many bars before the visible range the EMA needs to settle: w.ema is 30 times the length

output.line / output.markers

The series the chart may draw. colorFrom ties the line colour to the setting; on: emaLine puts the arrows on the line

layout

The settings dialog's tabs and sections

ctx.accum

Memory across bars: the EMA and the previous bar's close and EMA

ctx.plot / ctx.mark

Values for the declared outputs, bar by bar

onBar runs again on every tick of the bar that is still forming, and ctx.accum keeps what the last run wrote. That is why the script stores the previous bar's values and recomputes the current bar from them, instead of stepping the EMA forward on every call.

Common mistakes

  • Plotting an id you never declared with output.*.

  • Keeping the EMA in a module-level let instead of ctx.accum.

  • Leaving out warmup. A script that keeps state fails lint with window/warmup-required.

  • Passing price to a marker with position: "aboveBar", "belowBar" or "inBar". Only "atPriceTop", "atPriceMiddle" and "atPriceBottom" take a price, and they require one.

  • Setting kind: "pane" when you meant an overlay on price.

Settings inputs and dialog and Drawing on the chart cover the rest of what an indicator can declare and draw.