Execution model

View as MarkdownOpen the editor

A wrun 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, how to know the bar's index and whether it is the newest, and where an indicator runs: in your browser, on OpenMarket's servers, on OpenMarket's alerts engine, and in the Strategy Tester.

The bar loop

Bar by bar: onBar() runs once for every bar, oldest first, and each run adds one point to the line; the circled forming bar at the right runs again as new data arrives.onBar()oldest barforming bar

An indicator runs onBar() once per bar, oldest first, walking forward to the newest. Module-level variables stay alive across the whole walk, and the file defines two hooks with fixed roles (onStart() is optional):

FunctionCalledReadsWrites
onStart()once, before the first barparams via p_<name>()nothing; size your averages and buffers here
onBar()once per bar, oldest firstthis bar's candle via bar.<field>(), every declared input via in_<name>(), paramsyour module-level state; every output via out_<name>(value), string slots, drawing handles, strategy orders

The hooks are plain top-level functions with no arguments that return nothing: function onStart(): void { ... } and function onBar(): void { ... }. Anything else (an argument, a missing or different return type, an arrow) is refused on the hook's own line: onBar must be a function with no arguments that returns nothing: write function onBar(): void { ... } (the build calls it once per bar).

Params are readable from onStart() on, anywhere in the file (at module scope, before it, a p_<name>() reads NaN). Inputs and the bar's fields are readable in onBar() and the helpers it calls (in onStart() they read NaN). The readers return values cached for the bar, so reading one twice costs nothing.

The host walks the loaded history in order. On the chart that history is exactly the candles the chart has loaded, every source is fetched over the same window, and panning back runs the indicator again over the longer window. For each bar the host calls onBar(), then emits the bar's row with whatever onBar() wrote. Every bar has a row: an output onBar() did not write is NaN on that bar, and nothing is drawn there. Nothing in the file has to send the row: each out_<name>(value) keeps its value for the bar (the last write wins), and the row reaches the host in one step after onBar() returns, so an emitRow() of your own does nothing.

The bar's index, the first bar, the newest bar

There are no bar-state globals. The bar's index is a module-level counter you increment in onBar(), and the first bar is that counter at 0 (one-time setup that needs no bar belongs in onStart()). The newest bar is bar.isLast(): 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. Variables has the counter idioms in a compiled example, and the bar's own fields beside them.

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, each time the forming bar is evaluated again.
  • When that bar closes, the chart evaluates 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):

param("period", 20, { min: 1, max: 200 });
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 t: f64 = NaN;
let prevT: f64 = NaN;

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

function onBar(): void {
  prevT = t;
  t = bar.time();
  const value = sma.update(bar.close());
  if (isNaN(value) || isNaN(prevT)) return;
  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);
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

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.

How many bars the run holds

bar.count() is the number of bars the run holds while the bar is evaluated, the forming bar included. With your own bar counter it tells a bar whether a later bar exists, which a lagging line needs: a line drawn shift bars back can only show a bar's value once the bar shift bars later exists.

wrun
let index: i32 = 0;
function onBar(): void {
  const later = index + 1 < bar.count(); // a bar after this one exists in the run
  out_settled(index + shift < bar.count() ? 1.0 : 0.0); // the bar shift bars later exists
  index += 1;
}
  • A full run (a fresh load) answers the run's bar count on every bar, so every bar sees the whole window, including bars after it.
  • On the live chart the forming bar answers the bars held so far, and a new revision of the forming bar evaluates only that bar again.
  • When a new bar opens, the chart runs every bar again from the first one with the new count, so each bar's reading stays current and a chart open all day matches a fresh load over the same bars.

Reading bar.count() makes the sheet wrun-6 (Versions and contracts). It answers inside onBar() only; read in onStart() it stops the run by name (wrun_bar_count_phase).

Everything travels by name

Params, inputs, and outputs reach the code through generated readers and writers (p_<name>(), in_<name>(), out_<name>()), one function per declared name, and the chart's own candle through bar.<field>(). Underneath, the host passes values positionally, in declaration order. The editor regenerates the readers from the declarations on every compile, so reordering or renaming a declaration never rebinds a value silently: the compiler names the reader or writer that no longer exists. Raw positional reads (getFloat(0)) are refused by the editor's lint stage for the same reason (Common errors).

Warm-up: NaN until the window fills

Warm-up: an average of 8 bars is NaN on the first 7 bars, so nothing is drawn there; from the bar where the window is full, the line draws.the window is fullsmawarm-up: NaN, so nothing is drawn

Every TA class 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:

  • Write nothing on the bar: return from onBar() before any write, after the state updates the bar still owes (the update() that folds the bar in, the close you remember). The bar's row then carries NaN in every output, and the chart draws nothing there. Use it when nothing on the bar is meaningful yet.
  • 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). A warming TA value is already NaN, so writing it as it comes needs no test at all: the Moving Average starter does this (out_sma(sma.update(bar.close()))).

Anything else written on a warming bar is drawn as if it were true. The chart does not skip a warm-up count for you: it hands the module every loaded bar and onBar() decides what to write. A bar-to-bar change with a smoothed line returns early twice over: on the first bar there is no previous close, then the change exists while its average does not, and only once both are real does it write them:

param("period", 10, { min: 2, max: 200, description: "EMA length over the bar-to-bar change" });
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;

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

function onBar(): void {
  const close = bar.close();
  // Yesterday's close is whatever we kept from the previous call: there is no close[1] to read.
  const change = isNaN(prevClose) || prevClose == 0.0 ? NaN : ((close - prevClose) / prevClose) * 100.0;
  prevClose = close;
  if (isNaN(change)) return;
  const smoothed = ema.update(change);
  // The EMA is NaN until `period` changes have been folded in; those bars write nothing.
  if (isNaN(smoothed)) return;
  out_change_pct(change);
  out_smoothed(smoothed);
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

History indexing

An indicator has no history operator: onBar() 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 what no repaint means, and an indicator cannot break it by accident: there is nothing to peek at. The one deliberate exception is bar.count(): a value that reads it can change when a later bar arrives, which is why the chart runs every bar again when a bar opens (How many bars the run holds).

Higher timeframes follow from the same rule, in either of the chart's two forms (Multi-timeframe). Build the 4h view inside the module: bucket bars by bar.time(), and fold a bucket into its average only once the next bucket has started (the cookbook's regime filter does this). Or pin a secondary input to interval: "4h": the chart fetches the 4h candles as their own series and hands each one to a chart bar only as of that bar's 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": the chart evaluates it again as new data arrives, at most about once a second per indicator, coalescing the updates in between and never dropping the latest. The chart keeps the compiled module alive, snapshots its state after the last closed bar (its memory, every module-level variable, and its drawing handles), and restores that snapshot before each new evaluation of the forming bar, so an update never counts the bar twice or stacks a second copy of what the forming bar drew. What can still change a bar you have already seen is on Repainting.

Declare the chart's candles first

On the chart an indicator's rows are always the chart's own candles, and onBar() runs once per row. A file with no input line runs on them with no declaration at all: bar.close() and the other fields are its inputs, and the chart's close is its grid. A file whose first input line names another candle field (a lone input("high", ohlcv.high);) keeps that line, so the grid stays on that field instead of moving to the close; bar.high() and the other bar reads work either way, through the line or without one. Once a file declares an input, its first input line is the grid: it follows the chart's market and interval, so a market or interval pin on it is refused by name (a Polymarket odds input is the one source that may come first with a market of its own), and a time or celled source cannot be the first input (there is no feed behind the clock; a block needs a grid to be sliced by). So when the file declares anything else, declare the chart's own candles first even when the computation does not obviously use price: a file with a celled volume_profile input still declares input("close", ohlcv.close) before it, a forming view on a pinned input needs it, and a strategy's fills run against the chart's candles (with no input line a strategy gets close as its grid). The full rules, the reserved names and the order of the sheet are on Data sources.

Where an indicator runs

Where an indicator runs: in your browser when you press Run, in each reader's browser when published as Hidden or Open source, and on OpenMarket's servers when published as Never leaves OpenMarket; the locks mark the sandboxed browsers, and alerts run in OpenMarket's cloud.Your browserRun draws a draftthat only you see.Readers' browsersPublished as Hiddenor Open source.OpenMarket's serversPublished as Neverleaves OpenMarket.Alerts on a published indicator run in OpenMarket's cloud.
WhereHow it gets thereWhat it computes over
Your browserRun in the editor, or adding an indicator published with Compiled or Open source codethe chart's loaded bars and every source on Data sources, with pins served as Multi-timeframe and Multi-source describe
OpenMarket's serversadding an indicator published as Protected, or Run on > Cloud in the editor for your ownthe chart's candles; the module never downloads to the chart, which shows the result the servers stream
OpenMarket's alerts enginean alert set from the chart on a published indicatorthe chart's own market, with the settings the overlay carries
The Strategy Testeran indicator that declares strategy(...)the chart's own candles, in your browser

Your browser. Run compiles the file in your browser and runs the module in a sandboxed worker beside the chart. The computation never leaves the browser: the only network traffic is the data the declared sources need and a one-time download of the compiler. A draft from Run lasts for the session and is not saved with the layout; running again replaces it. A published indicator with Compiled or Open source code runs the same way, in each reader's browser: Compiled readers get a compiled module, never the source.

OpenMarket's servers. An indicator published with the Publish dialog's Code choice Protected runs on OpenMarket's servers only: they compute it over the chart's candles and stream the result, and the code never leaves them; its settings dialog is the file's own, and a source, timeframe or symbol setting keeps its declared default there for now. In the editor, Run on offers Cloud only for a published Protected indicator whose published version matches the code in the tab; otherwise it says "Publish first", "Publish the current version first", or "Runs in the browser. Only a Protected Indicator runs in the cloud." Where an indicator runs is fixed by its first published version (Publishing).

OpenMarket's alerts engine. An alert on an indicator runs in OpenMarket's cloud wherever the overlay itself computes: the alerts engine loads the published version the overlay carries and evaluates it on the chart's market and interval with the overlay's settings, over at most 600 bars of that interval. A draft cannot carry an alert ("Publish the Indicator before adding an alert."). The engine reads another market's candles and a coarser pinned timeframe, and refuses, by name, a pin it cannot serve ("This Indicator is pinned to a different interval than the chart.") or a data source alerts cannot evaluate yet (Alerts).

The Strategy Tester. An indicator that declares strategy(...) places its orders in onBar(), and the Strategy Tester runs it in your browser with its own simulated broker, filling orders against the chart's own candles (Strategies overview).

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 declaration 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). A file declares at least one output; without one the build stops with a file with onBar() needs at least one output(...) statement: declare what the Indicator draws, e.g. output("value", line, overlay), and write it in onBar() with out_value(...). Text reaches the chart only through string slots and renderers (Plotting). Params reach the module as numbers too: every setting is a row in the overlay's settings dialog, drawn as the control its kind names (param.int, param.bool, param.choice, param.color, ...; plain param(...) is a number field labelled by its description, else its name, and bounded by its min and max), read once before the first bar, and changing one reruns the compiled module over the loaded bars without compiling again (Setting kinds). There is no print(): declare a string slot named debug, write it with str_debug(...) in onBar(), and each non-empty line prints in the editor's Console with its bar's time (Debugging).

Memory and speed

The module runs once per loaded bar, then again each time the forming bar updates. Allocate in onStart() or at module scope (a StaticArray<f64> sized from a param's max), never per bar: the module has no garbage collector, and the chart's sandbox caps its memory at 4 MiB and refuses a run whose memory grows once the bars start ("The Indicator allocated memory after init() ...: the sandbox forbids growth once the bars start."). Loops bounded by a param with a declared max stay cheap; the compiled module itself is a few kilobytes. A run that does not finish within 20 seconds is stopped and refused by name ("The run did not finish within 20 s.") rather than hanging the chart, and the chart keeps the last result that drew.

The four-function form

Underneath, the chart has always called four exported functions, init(), state(), finalize() and reset(). A file with onBar() is built into them: the build adds the four around your hooks, with import lines for every kit name, reader and writer the file uses. For the Moving Average example (an onStart() that sizes the average, an onBar() that writes it) the added functions do this:

wrun
// What the build adds around the Moving Average's onStart() and onBar().
export function init(): void { /* reads period once */ onStart(); }
export function state(): i32 { /* reads this bar's close */ return 1; }
export function finalize(): void { /* sma starts the bar as NaN */ onBar(); /* then the row goes out */ }
export function reset(): void { /* nothing: the file has no onReset() */ }

A file that writes the four functions itself keeps building unchanged, with the same bytes as before; every file written before the hooks existed is one of these. The Moving Average written that way:

wrun
import { input, line, ohlcv, output, overlay, param } from "./sdk/declare";
import { in_close } 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);
output("sma", line, overlay);

let sma = new Sma(20);
let value: f64 = NaN;

export function init(): void { sma = new Sma(i32(p_period())); }
export function state(): i32 { value = sma.update(in_close()); return isNaN(value) ? 0 : 1; }
export function finalize(): void { out_sma(value); emitRow(); }
export function reset(): void { sma.reset(); value = NaN; }

What differs from a file with onBar():

  • Rows. state() may return 0 to emit no row at all for that bar: nothing is drawn, no output has a value there, and finalize() is not called. A file with onBar() emits a row on every bar and leaves what it did not write NaN. A file that needs a bar with no row at all is written with the four functions.
  • Phases. It reads in state() and writes in finalize(): params are readable in init() only, inputs in state() only, outputs, strings, frames, handles and orders writable in finalize() only, so a value computed in state() travels to finalize() in a module variable, and finalize() calls emitRow() last.
  • Imports. It needs its import lines: the declaring words from ./sdk/declare, its readers and writers from ./gen/params, ./gen/inputs, ./gen/outputs (and ./gen/strings, ./gen/draw, ./gen/strategy when declared), the classes from the kit modules. A file with onBar() has nothing to import.
  • Reset. reset() is its required fourth export, putting every module-level variable back to its starting value. A file with onBar() may define function onReset(): void { ... } for the same job, and the build's reset() calls it when the host resets the run; without one, nothing is reset. The chart in your browser never resets a run this way (it restores the snapshot of the closed bars instead), so a file without onReset() loses nothing there.

A file is one form or the other: exporting any of the four, even one, makes it the four-function form, which then declares everything itself, imports included. The reference rows for both are on Declarations and the sheet.