ChartnautDocs

Script anatomy

Every Chartnaut script has two parts: top-level declarations that say what exists (settings, outputs, events, results, dependencies), then hooks that run over bars or events.

Declarations run once when the script loads. Chartnaut derives the script's manifest from them on save: the settings dialog, the outputs, the dependency pins, the timeframes it reads. You never edit that manifest by hand. Hooks do the per-bar or per-event work. Shared helpers (bar data, drawing, dependencies) are documented under Shared runtime.

Declarations

Call these at the top level of the file, never inside a function.

Script

Declarations

Indicator

meta, dialog, input.*, output.*, pane, scale, layout, indicators.declare, timeframe, warmup

Definition

meta, input.*, events.declare, snapshots.declare, outputs.declare, output.layer, imports.use, indicators.declare, timeframe, warmup

Study

meta, flows.declare, indicators.declare, dimensions.declare, results.declare

A runtime call that names something you never declared fails lint or typecheck, or draws nothing. Declare first, then call.

Hooks

Script

Hook

When it runs

Indicator

onBar(ctx)

Once per bar, and again on every tick of the forming bar

Indicator with meta({ mode: "once" })

onRun(ctx)

Once over a closed window of bars. Exporting both onBar and onRun is a compile error

Definition

export function onBar(ctx)

Once per bar, in the entry file only

Study

export function onEvent(ctx), then export function onFinish(ctx)

Once per event from a declared definition, then once at the end. onFinish is optional

A timeframe handle can carry its own handler, timeframe("1d").onBar((ctx) => ...), which runs once per closed bucket of that timeframe. It keeps state and never draws. See Timeframe handles and runOn.

Minimal templates

Indicator

meta({ shortName: "EMA", kind: "overlay" });
dialog({ title: "EMA" });
const length = input.number({ id: "length", label: "Length", default: 20, min: 1 });
warmup((w) => w.ema(length));
output.line({ id: "ema", color: "#2196f3" });
layout(
  tab({ id: "main", title: "Main" },
    section({ id: "p", title: "Parameters" }, length))
);

function onBar(ctx) {
  const k = 2 / (ctx.params.length + 1);
  const ema = ctx.accum("ema", null, (prev) =>
    prev == null ? ctx.close : prev + k * (ctx.close - prev)
  );
  ctx.plot("ema", ema);
}

Definition

meta({ name: "Tick Signal", kind: "flow", slug: "tick-signal" });
events.declare({
  id: "tick",
  intent: "alert",
  payload: { reason: { type: "string", description: "Why this fired" } },
  paint: "pin",
});
outputs.declare({ id: "close_line", kind: "line", name: "Close" });

export function onBar(ctx) {
  ctx.plot("close_line", ctx.close);
  if (ctx.close > ctx.open) {
    ctx.emit("tick", { reason: "bullish_bar" }, {
      label: "Bull bar",
      price: ctx.close,
    });
  }
}

Study

meta({ name: "Tick count", kind: "study" });
flows.declare({ slug: "tick-signal", as: "sig", version: 1 });
results.declare({
  id: "rows",
  kind: "table",
  title: "Rows",
  description: "One row per Tick Signal event, with its time.",
});

export function scope(ctx) {
  if (ctx.event.flow !== "sig") return null;
  return {};
}

export function onEvent(ctx, scope) {
  ctx.collect("rows", { time: ctx.event.time });
}

export function onFinish(ctx) {
  ctx.publish("rows", ctx.collected("rows"));
}

version in flows.declare is the definition version the study reads. Set it to the definition's current version: lint fails with flows/missing-version without one. scope(ctx) runs first for each event and returns its group, {} when the study declares no dimensions, or null to leave the event out; onEvent runs only for the events it keeps. Lint fails with study/scope-required without it. See scope. Every result carries a description, which Forward Insight cards show.

Rules that apply everywhere

  • Keep state across bars in ctx.accum, never in a module-level let or var. Studies have no ctx.accum: read earlier events with ctx.collected(...) inside onEvent.

  • A script that keeps state declares how far back that state remembers, with a top-level warmup(...) call after its inputs: w.ema(length) for an EMA, w.wilder(length) for RSI, ATR or ADX, w.day() for state that resets each day, and w.forever() for a running total. Lint fails with window/warmup-required when it is missing, and with state/never-forgets when a running total declares a count. See Warmup and memory.

  • onBar runs again on every tick of the forming bar, and the chart rolls back what the previous tick wrote to ctx.accum first. Anything that must happen only on a closed bar returns early when ctx.barConfirmed === false.

  • A timeframe is a string literal ("15m"), "chart", or an input.timeframe handle. Never pass ctx.params.<id> to timeframe(): lint rejects it with tf/no-ctx-at-module-scope.

  • No Date.now(), new Date(), network or async code. Read time from ctx.time.

  • Pin every dependency to a real version: indicators.declare({ slug, as, version }) and flows.declare({ slug, as, version }).