Best practices

View as MarkdownOpen the editor

Guidelines for writing wrun indicators that are fast, honest about warm-up, and safe to replay: where state lives, what belongs in onStart() and what in onBar(), why the generated accessors beat slot literals, and the handful of habits that keep a file fast and readable.

Code structure

Variable declarations

An indicator has three kinds of state, and each has one right home.

Module-level let for anything that survives between bars. This is where accumulators, the previous bar's close, running session highs, and the TA objects live. Give every one a type and a starting value (let cvd: f64 = 0.0, let prevClose: f64 = NaN).

Locals inside onBar() for this bar's arithmetic. A const typical = (high + low + close) / 3.0 that nothing needs next bar belongs in the function, not at module level. It is cheaper and it cannot go stale.

A StaticArray<f64> ring buffer for a window. A window you index into (the value five bars back) is a buffer you fill yourself: allocate it once, sized from the param's max, walk it with a cursor, and count how full it is. History from ./sdk/stats is that buffer ready-made, with ago(n) for the value n bars back (Stats, history and lists). The zone tracker and the previous-bar FAQ sample show the shape.

Allocate in onStart() or at module start, never in onBar()

onBar() runs once per loaded bar and again on the forming bar as live updates arrive. Anything allocated there is allocated thousands of times, an array that grows on every call is the one pattern that makes a long history slow, and the chart stops a module whose memory grows once the bars start ("The Indicator allocated memory after init() ... the sandbox forbids growth once the bars start."). Size buffers from the param's declared max, at module start, and let onStart() set the live length:

param("window", 100, { min: 10, max: 500, description: "Bars the rank is measured against" });
output("rank", line, lower, { unit: "%", description: "Share of the window's closes below this bar's close" });

// Allocate once, at module start, sized from the param's max: never inside onBar().
const MAX_WINDOW = 500;
const closes = new StaticArray<f64>(MAX_WINDOW);
let n: i32 = 100;
let cursor: i32 = 0;
let count: i32 = 0;

function onStart(): void {
  n = i32(p_window());
}

function onBar(): void {
  const close = bar.close();
  let below = 0;
  for (let i = 0; i < count; i++) if (closes[i] < close) below += 1;
  closes[cursor] = close;
  cursor = (cursor + 1) % n;
  if (count < n) count += 1;
  if (count < n) return;
  out_rank((100.0 * f64(below)) / f64(n));
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

The TA classes follow the same rule: new Sma(period) allocates its window in the constructor and update() never allocates, which is why they are constructed in onStart() and fed in onBar().

Nothing to reset

There is no reset step to write. The chart runs the module once over the loaded bars, and the forming bar does not depend on any reset: the chart replays that bar from a snapshot of the module's memory taken after the last closed bar, so a live value never double-counts. Give every module-level let its starting value where it is declared, build what depends on a param in onStart(), and let a count field say how much of a buffer is valid instead of clearing the buffer.

Accessors over slot literals

Params, inputs, and outputs reach the code by name: p_period(), bar.close(), in_funding(), out_sma(value), generated from the declarations on every check. Underneath, the host passes values positionally, and the raw calls (getFloat(0), setOutput(0, ...)) bind by position, so adding a declaration above them silently rebinds them. The editor refuses the raw form in your source (Raw slot literal 0 passed to getFloat(): raw positional slots rebind silently when the sheet changes) and names the accessor to use instead. The same rule keeps a rename honest: rename output("sma", ...) to output("average", ...) and the compiler points at every stale out_sma (Declarations and the sheet).

Technical indicators

Read the period once, cast it once

Params are f64; a period is i32. Read each param in onStart(), cast it there (new Sma(i32(p_period()))), and keep the class in a module-level variable. Reading p_period() in onBar() works but does the cast on every bar for nothing, and constructing a class in onBar() allocates on every bar.

Be honest about warm-up

A window that is not full has no value, and the honest output is NaN. Two ways to write it, both correct: return from onBar() before any write while nothing on the bar is meaningful (every output stays NaN, and the bar draws nothing), or write NaN to the one output that is still warming while the others draw (Execution model). What is never correct is a placeholder that draws as if it were true. The chart reads no warm-up count: every bar has a row, and what onBar() writes alone decides what is drawn.

Name what the module computes, not how it is drawn

A decision is an output too. Emit 1 or 0 from a data-only none output and let a declaration turn it into a look (shape_where, color_by, a box's when). That keeps the numeric surface reusable: the same gate that draws a mark can drive a declared alert(...), and you can read it by name at the Console prompt.

Plotting and visualization

Describe every declaration

label on a setting is its name in the settings dialog (description stands in when there is none, and hint is the words behind the row's glyph); on an input description documents the input in the sheet Run derives; on an output it is the note the Console prompt shows beside the output's name. Write them the way you would want to read them a month later: "Bars of history that define normal volume" beats "lookback". Give small-magnitude series (oscillators, percentages, counts) the lower panel and a unit (%, price, or a short label) so the axis formats itself.

One namespace for shape names

Outputs, boxes, segments, renderers, and drawings share one namespace. A box named range beside an output named range is refused at the sheet (box name 'range' is already taken by an output). Name shapes for what they draw (demand_zone, pdh_line) and outputs for what they compute (demand_top, pdh).

Debugging

Probe with an output

Log it or draw it. The debug log is the indicator's print(): a string slot named debug, written in onBar(), prints each bar's line in the editor's Console. For a number, declare the suspect intermediate as none (readable by name at the Console prompt, never drawn) or as a lower line to see its shape across every bar, then delete the declaration when you are done. Debugging is the full workflow.

Error prevention

NaN checks and safe division

NaN is the only "nothing here" value, and it propagates: NaN + 1 is NaN, and NaN > 0 is false. Test with isNaN(x) before a comparison that decides something, and guard every division by a value that can be zero. The two helpers most indicators need are two lines each:

param("len", 20, { min: 1, max: 200 });
output("ratio", line, lower, { description: "Volume over its average, 0 while the average warms" });
output("smooth", line, lower, { description: "Close average, last good value carried across gaps" });

// nz: a number, or the fallback when it is NaN.
function nz(value: f64, fallback: f64): f64 {
  return isNaN(value) ? fallback : value;
}

// Division that refuses to blow up: NaN when the denominator is zero or missing.
function safeDiv(numerator: f64, denominator: f64): f64 {
  return isNaN(denominator) || denominator == 0.0 ? NaN : numerator / denominator;
}

let avgVolume = new Sma(20);
let avgClose = new Sma(20);
let lastGood: f64 = NaN;

function onStart(): void {
  avgVolume = new Sma(i32(p_len()));
  avgClose = new Sma(i32(p_len()));
}

function onBar(): void {
  const volume = bar.volume();
  out_ratio(nz(safeDiv(volume, avgVolume.update(volume)), 0.0));
  // fixnan: keep the last non-NaN value instead of showing a gap.
  const smooth = avgClose.update(bar.close());
  if (!isNaN(smooth)) lastGood = smooth;
  out_smooth(lastGood);
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

Buffer bounds

A ring buffer never reads past what it has been given: keep a count beside the cursor and only scan count entries until the buffer is full. There is no barIndex to compare against and no negative index to worry about; the buffer's own bookkeeping is the bound. A History from ./sdk/stats keeps that bookkeeping for you: ago(n) reads NaN until it holds n + 1 values (Stats, history and lists).

missing on sparse primaries

A feed that only has rows when something happened (liquidations, for one) makes a poor primary input as-is: the grid has holes. Declare missing: "zero" (or "nan") on it and the grid densifies to one row per bar, with the fill on the quiet bars. On a secondary sparse input the same policies decide what the module sees on bars without an observation; the default carries the last value forward, which is right for a coarse candle and wrong for a volume you would double-count (Data sources).

Performance optimization

Avoid redundant calculations

Compute a value once and keep it in a local for every output that needs it; do not fold the same input into two classes when one class and a copy will do. Loops are fine when they are bounded by a param with a declared max; a loop whose bound comes from history length is the thing to avoid.

Keep the row cheap

onBar() should be arithmetic and writes. Building a string per bar is fine inside the string channel's shared buffer (sb_clear / sb_text / sb_f64 allocate nothing), but text built with + or toString() allocates on every bar, and memory that grows once the bars start stops the run; build every per-bar line with sb_*.

Canvas cost

A price canvas costs what it draws. A TPO costs rows x letters x the periods on screen: the chart folds every row from the candles and paints every letter as a block. The folding counts toward the "is slowing your chart" notice like your script's own time, and a live tick refolds only the forming period. On BTC at 15m the chart's 75-dollar rows make about 230 blocks a day; 1.5-dollar rows would make about 9,300.

Prefer "auto"

Leave row_height out. "auto" takes the row from what the chart serves for the market (the bucket width of your cells, else the market's TPO or volume profile catalog) times the chart's row table, so your canvas draws the rows the chart's own footprint, TPO or profile would: on BTC at 15m, a TPO gets about 25 to 90 rows a day, each tall enough for its letters (Row height).

When to declare a number

  • A market with no catalog, whose tick you know. "auto" falls back to a quarter of the median candle range there; a whole number of ticks (row_height: 0.25 on a market that trades in 0.05 steps) keeps every row on a price the market can print.
  • A deliberately coarse profile. Fewer, taller rows than auto, such as row_height: 250 for a BTC day profile read at a glance.

Size the number from the range: a period's high minus its low, divided by row_height, is the rows it holds, and under 512 the chart keeps your height.

The coarsening notice

tpo 'tpo': row_height 1 coarsened to 8 (512 rows per day is the chart's limit), a warning row in the Console and the warning on the legend chip, means a period held more than 512 rows at the height you declared, so the chart doubled it until the period fit (at most 6 times). The TPO still draws, at the coarser height, and the notice repeats on every run. To clear it, declare the height it names or a larger one, or remove row_height and let "auto" pick. On "auto" (row_height auto coarsened from 1.5 to 12) the market's own unit is too fine for how far its price moves in a period: declare a row_height of at least the height the notice names.

Code organization

Read top to bottom: declarations, state, helpers, then onStart() and onBar() in the order the chart calls them. A complete indicator laid out that way:

// 1. Declarations: settings and outputs, each with the description the settings dialog and the Console prompt show.
param("fast", 9, { min: 2, max: 100, description: "Fast EMA length" });
param("slow", 21, { min: 5, max: 400, description: "Slow EMA length" });
output("fast", line, overlay, { color: "#38bdf8", description: "Fast EMA" });
output("slow", line, overlay, { color: "#f59e0b", width: 2, description: "Slow EMA" });
output("entry", shape, overlay, { color: "#22c55e", shape_where: "is_entry", description: "Fast crossed above slow" });
output("is_entry", none);

// 2. State: everything that lives between bars, with its starting value.
let fast = new Ema(9);
let slow = new Ema(21);
let cross = new Cross();

// 3. Lifecycle: onStart reads settings, onBar folds the bar and writes the row.
function onStart(): void {
  fast = new Ema(i32(p_fast()));
  slow = new Ema(i32(p_slow()));
  cross = new Cross();
}

function onBar(): void {
  const close = bar.close();
  const fastValue = fast.update(close);
  const slowValue = slow.update(close);
  const crossed = cross.update(fastValue, slowValue);
  if (isNaN(slowValue)) return;
  out_fast(fastValue);
  out_slow(slowValue);
  out_entry(bar.low());
  out_is_entry(crossed == 1 ? 1.0 : 0.0);
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

Comment the why

The declarations already say what the indicator reads and writes, and the accessors keep the code readable, so comments earn their place explaining a decision: why a bucket folds in only on the next bucket's first bar, why a session that was already running stays NaN, why a pinned input reads NaN on a stock chart. The cookbook recipes are written that way.