Execution model

An Indicator is a function the host calls once per bar, oldest bar first, with state that lives between calls in module-level variables. This page is the…

An Indicator is a function the host calls once per bar, oldest bar first, with state that lives between calls in module-level variables. This page is the mental model behind every recipe: what runs when, why the first bars of a line are empty, what "no lookahead" means when you write the code, what replaces the bar-state globals of kScript (legacy), and which host runs the file (your browser, your machine, or the platform's hosted alerts engine).

The bar loop

kScript ran your whole script once per bar, oldest first, walking forward to the newest. An Indicator runs state() once per bar the same way, and the difference is what survives between calls: a kScript re-ran its declarations and re-read its persist state each bar; an Indicator keeps module-level variables alive across the whole walk and calls four functions in a fixed order.

FunctionCalledReadsWrites
init()once, before the first barparams via p_<name>()nothing; size your averages and buffers here
state()once per bar, oldest firstthis bar's inputs via in_<name>()your module-level state; returns 1 (row ready) or 0 (abstain)
finalize()once per bar whose state() returned 1your stateevery output via out_<name>(value), string slots, drawing handles, then emitRow() last
reset()when the host replays the forming barnothingevery module-level variable back to its starting value, .reset() on every TA object

The host walks the loaded history in order. For each bar it calls state(); when that returns 1 it calls finalize() and reads the values written before emitRow(). A bar whose state() returned 0 has no row: nothing is drawn there, no metric value exists for it, and finalize() is not called.

barIndex, isFirst, isLastBar

Two of the bar-state globals have no counterpart: barIndex is a module-level counter you increment in state(), and isFirst is that counter at 0 (one-time setup that needs no bar belongs in init()). isLastBar is bar.isLast() from ./gen/draw: true exactly when the bar being evaluated is the newest bar the host holds for this run, false on every earlier bar. Run-level renderers and declared drawings still evaluate the newest ready row on their own without it; the signal is for a handle drawing that should exist on the newest bar only, or reach past it. core-variables.md has the counter idioms in a compiled example.

The signal is a function of the run's window, so a replay from bar 0 over the same window reproduces it:

  • A full run answers true on its last row only.
  • On the live chart it answers true on the forming bar, on every tick.
  • When that bar closes, the host re-runs it ONCE more as a closed bar, with the signal false, before the new bar is evaluated with it true. The re-run's outputs, strings, and handle drawings replace the row's. This is what keeps a chart that has been open all day identical to a fresh load: nothing the forming bar drew under bar.isLast() survives its close unless the closed bar draws it too.

An average with a dotted projection five bars ahead, drawn on the newest bar only. The projection's far end lies past the loaded range, which a handle's absolute time coordinates allow (Drawing objects):

import { handles, input, line, ohlcv, output, overlay, param, time } from "./sdk/declare";
import { bar, draw } from "./gen/draw";
import { in_close, in_t } from "./gen/inputs";
import { emitRow, out_sma } from "./gen/outputs";
import { p_period } from "./gen/params";
import { Sma } from "./sdk/ta";

param("period", 20, { min: 1, max: 200 });
input("close", ohlcv.close);
input("t", time.bar_open_sec);
output("sma", line, overlay, { color: "#38bdf8", width: 2 });
handles.line({ color: "#38bdf8", width: 1, lineStyle: "dotted" });

const projection = draw.line(0);
let sma = new Sma(20);
let value: f64 = NaN;
let t: f64 = NaN;
let prevT: f64 = NaN;

export function init(): void {
  sma = new Sma(i32(p_period()));
}

export function state(): i32 {
  prevT = t;
  t = in_t();
  value = sma.update(in_close());
  return isNaN(value) || isNaN(prevT) ? 0 : 1;
}

export function finalize(): void {
  out_sma(value);
  // Only the newest bar carries the projection: five bars ahead is past the loaded
  // range, and an absolute x makes that legal.
  if (bar.isLast()) projection.set(t, value, t + 5.0 * (t - prevT), value);
  emitRow();
}

export function reset(): void {
  sma.reset();
  value = NaN;
  t = NaN;
  prevT = NaN;
}

Over a full run the line is created on the last row and nowhere else; when that bar closes on the live chart, the closed-bar re-run draws no line, and the new forming bar creates it again one bar to the right.

Everything travels by name

Params, inputs, and outputs reach the code through generated accessors (./gen/params, ./gen/inputs, ./gen/outputs), one function per declared name. Underneath, the host passes values positionally, in declaration order. The accessors are regenerated from the declarations before every build, so reordering or renaming a declaration never rebinds a value silently: the compiler names the import that no longer exists. Raw positional reads (getFloat(0)) are refused by the build for the same reason (faq/common-errors.md).

Warm-up: NaN until the window fills

Every TA class in ./sdk/ta returns NaN until it has seen enough bars: Sma, Stdev, and Zscore need period values, Ema seeds itself with a simple average of the first period values, Rsi is warm after period + 1 samples, Roc after period + 1. Cross.update() returns 0 on any bar where either side is NaN.

You have two ways to handle a value that is not ready yet, and both are correct:

  • Abstain the whole row: return 0 from state(). Use it when nothing on the row is meaningful yet. The template does this (return isNaN(value) ? 0 : 1).
  • Write NaN to one output: the chart draws nothing for that output on that bar while the other outputs still draw. Use it when one line warms slower than another, or when a value legitimately has no answer (a session that has not completed yet, a ratio with a zero denominator).

Anything else written on a warming bar is drawn as if it were true. A bar-to-bar change with a smoothed line shows both policies at once: the first bar has no previous close (abstain), then the change exists while its average does not (abstain again until the average is warm), then both draw:

import { input, line, lower, ohlcv, output, param } from "./sdk/declare";
import { in_close } from "./gen/inputs";
import { emitRow, out_change_pct, out_smoothed } from "./gen/outputs";
import { p_period } from "./gen/params";
import { Ema } from "./sdk/ta";

param("period", 10, { min: 2, max: 200, description: "EMA length over the bar-to-bar change" });
input("close", ohlcv.close);
output("change_pct", line, lower, { unit: "%", color: "#94a3b8" });
output("smoothed", line, lower, { unit: "%", color: "#38bdf8", width: 2 });

let ema = new Ema(10);
let prevClose: f64 = NaN;
let change: f64 = NaN;
let smoothed: f64 = NaN;

export function init(): void {
  ema = new Ema(i32(p_period()));
}

export function state(): i32 {
  const close = in_close();
  // Yesterday's close is whatever we kept from the previous call: there is no close[1] to read.
  change = isNaN(prevClose) || prevClose == 0.0 ? NaN : ((close - prevClose) / prevClose) * 100.0;
  prevClose = close;
  if (isNaN(change)) return 0;
  smoothed = ema.update(change);
  // The EMA is NaN until `period` changes have been folded in; those bars abstain.
  return isNaN(smoothed) ? 0 : 1;
}

export function finalize(): void {
  out_change_pct(change);
  out_smoothed(smoothed);
  emitRow();
}

export function reset(): void {
  ema.reset();
  prevClose = NaN;
  change = NaN;
  smoothed = NaN;
}

History indexing

A kScript read a previous bar with series[n] on any timeseries. An Indicator has no history operator: state() sees exactly one bar, and indexing a number (close[1]) is a compile error (Index signature is missing in type 'f64'). Keep yesterday's value in a module-level variable when you see it (prevClose above), keep a window in a StaticArray<f64> ring buffer, and let a TA class keep the window a statistic needs.

No lookahead

A value can therefore only depend on bars at or before its own, and a mark that appears in history would have appeared live on the same bar. This is the property kScript calls no repaint, and an Indicator cannot break it by accident: there is nothing to peek at.

Higher timeframes follow from the same rule. On the chart every input is the chart's own interval, so a 4h view is built inside the module: bucket bars by time.bar_open_sec, and fold a bucket into its average only once the next bucket has started (multi-timeframe.md; the cookbook's regime filter does this). On your machine an interval pin reads a coarser feed instead, and the host contributes a coarse candle only as of its close, so a forming 4h candle never leaks into the 1h rows under it.

The forming bar is the one exception to "one call per bar": it is re-evaluated on every tick. On the chart the host keeps the compiled module alive, snapshots its state and its drawing handles after the last closed bar, and replays the revised forming bar into both, so a tick never stacks a second copy of what the forming bar drew; on your machine om metric series returns the forming bar as its newest row. This is why reset() must clear every variable: a field it forgets is a stale value the replayed bar will read (repainting.md).

You need a primary input to reach the loop

kScript's bar sequence came from the sources it loaded, so a script with no source never entered the loop. An Indicator's grid comes from its first input: the market and interval of inputs[0] define the rows every other input aligns to, and state() runs once per row of that grid. A time source cannot be that first input (there is no feed behind it), and a package whose only inputs are pinned to fixed markets computes the same value for every selector symbol, which mislabels screens and legends: keep the primary selector-following (functions/script-definition.md). This applies even when the computation does not obviously use price: a session clock still declares input("close", ohlcv.close) first, so the host knows which bars to walk.

Where an Indicator runs

HostHow it gets thereWhat every input readsWhat is supported
Your browserRun in the editor; a New engine row; an Indicators-tab addthe chart's own market and interval, for every input; symbol, exchange, and interval pins are not honored on the chart yetohlcv, time, trades (volume with a side), oi, liquidations, funding (rate_close only, as a percent), implied_volatility and skew (tenors ONE_W, ONE_M, THREE_M), and the celled book and volume_profile classes; odds, token_supply, and metric composition refuse by name
Your machineom wrun install ./dir, or om install @scope/name after a publishthe market you ask for; symbol + exchange pins read fixed reference markets and interval pins read coarser feeds as of their closewatch conditions on the metric id, om metric get / om metric series, screens, backtests (screens and backtests refuse celled packages)
Hosted alertsan alert armed from the chart on a published Indicatorthe chart's own candles onlywhere the site has it switched on (the chart says "Indicator alerts are not enabled yet" while it is off); refused for a pinned market or interval, a source other than candles, a celled class, or a runtime version it does not support. kScript indicators run on the same hosted engine

The rule to remember when a chart and your machine disagree: the chart reads its own market for every input, your machine honors pins. A package that pins another venue or a coarser interval is a package for your machine (data-sources.md lists every source and knob).

Numbers only

Every output is a 64-bit float, and NaN means "nothing here". A decision is an output too: write 1 or 0 and let the sheet turn it into a look (color_by picks a palette entry per bar, shape_where gates a mark, when gates a box or a segment). Text reaches the chart only through string slots and renderers (functions/plotting.md); params are numbers, so a color picker or a free-text setting has no declaration form (style knobs exist only in hand-written sheets, functions/styling.md). There is no print(): emit a data-only output and read it in the legend, or with om metric series on your machine.

Memory and speed

The module runs once per loaded bar, then once per tick. Allocate in init() or at module scope (a StaticArray<f64> sized from a param's max), never per bar: the module has no garbage collector, an array that grows on every call is the one pattern that makes a long history slow, and it survives across the forming bar's replays. Loops bounded by a param with a declared max stay cheap; the compiled module itself is a few kilobytes. kScript stopped a script that took more than 500 milliseconds on a bar; an Indicator's host enforces an execution timeout on the evaluation the same way, refusing it by name rather than hanging the chart.