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 |
|
Definition |
|
Study |
|
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 |
| Once per bar, and again on every tick of the forming bar |
Indicator with |
| Once over a closed window of bars. Exporting both |
Definition |
| Once per bar, in the entry file only |
Study |
| Once per event from a declared definition, then once at the end. |
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-levelletorvar. Studies have noctx.accum: read earlier events withctx.collected(...)insideonEvent.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, andw.forever()for a running total. Lint fails withwindow/warmup-requiredwhen it is missing, and withstate/never-forgetswhen a running total declares a count. See Warmup and memory.onBarruns again on every tick of the forming bar, and the chart rolls back what the previous tick wrote toctx.accumfirst. Anything that must happen only on a closed bar returns early whenctx.barConfirmed === false.A timeframe is a string literal (
"15m"),"chart", or aninput.timeframehandle. Never passctx.params.<id>totimeframe(): lint rejects it withtf/no-ctx-at-module-scope.No
Date.now(),new Date(), network or async code. Read time fromctx.time.Pin every dependency to a real version:
indicators.declare({ slug, as, version })andflows.declare({ slug, as, version }).
