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.
| Function | Called | Reads | Writes |
|---|---|---|---|
init() | once, before the first bar | params via p_<name>() | nothing; size your averages and buffers here |
state() | once per bar, oldest first | this bar's inputs via in_<name>() | your module-level state; returns 1 (row ready) or 0 (abstain) |
finalize() | once per bar whose state() returned 1 | your state | every output via out_<name>(value), string slots, drawing handles, then emitRow() last |
reset() | when the host replays the forming bar | nothing | every 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 0fromstate(). Use it when nothing on the row is meaningful yet. The template does this (return isNaN(value) ? 0 : 1). - Write
NaNto 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
| Host | How it gets there | What every input reads | What is supported |
|---|---|---|---|
| Your browser | Run in the editor; a New engine row; an Indicators-tab add | the chart's own market and interval, for every input; symbol, exchange, and interval pins are not honored on the chart yet | ohlcv, 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 machine | om wrun install ./dir, or om install @scope/name after a publish | the market you ask for; symbol + exchange pins read fixed reference markets and interval pins read coarser feeds as of their close | watch conditions on the metric id, om metric get / om metric series, screens, backtests (screens and backtests refuse celled packages) |
| Hosted alerts | an alert armed from the chart on a published Indicator | the chart's own candles only | where 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.