Best Practices

Guidelines for writing Indicators that are fast, honest about warm-up, and safe to replay: where state lives, what reset() owes the forming bar, why the…

Guidelines for writing Indicators that are fast, honest about warm-up, and safe to replay: where state lives, what reset() owes the forming bar, why the generated accessors beat slot literals, and the handful of habits that keep a port from a kScript (legacy) from drifting.

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 the kScript persist and the kScript static at once: accumulators, the previous bar's close, running session highs, the TA objects. Give every one a type and a starting value (let cvd: f64 = 0.0, let prevClose: f64 = NaN), and restore that starting value in reset().

Locals inside state() 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. The kScript timeseries you index into (sma_values[5]) 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. The zone tracker and the previous-bar FAQ sample show the shape.

Allocate in init() or at module start, never in state()

state() runs once per loaded bar and again on every tick of the forming bar. Anything allocated there is allocated thousands of times, and an array that grows on every call is the one pattern that makes a long history slow. Size buffers from the param's declared max, at module start, and let init() set the live length:

import { input, line, lower, ohlcv, output, param } from "./sdk/declare";
import { in_close } from "./gen/inputs";
import { emitRow, out_rank } from "./gen/outputs";
import { p_window } from "./gen/params";

param("window", 100, { min: 10, max: 500, description: "Bars the rank is measured against" });
input("close", ohlcv.close);
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 state().
const MAX_WINDOW = 500;
const closes = new StaticArray<f64>(MAX_WINDOW);
let n: i32 = 100;
let cursor: i32 = 0;
let count: i32 = 0;
let rank: f64 = NaN;

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

export function state(): i32 {
  const close = in_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 0;
  rank = (100.0 * f64(below)) / f64(n);
  return 1;
}

export function finalize(): void {
  out_rank(rank);
  emitRow();
}

// Every module-level variable goes back to its starting value: a field this forgets is a stale value
// the forming bar's replay will read.
export function reset(): void {
  cursor = 0;
  count = 0;
  rank = NaN;
}

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 init() and fed in state().

reset() every field

The forming bar is replayed on every tick from a snapshot of the module's state after the last closed bar, and the host calls reset() first. A variable reset() forgets is a stale value the replay reads, and the symptom is subtle: a line that lags by a tick, a gate that fires once and never clears, an accumulator that double-counts. The rule is mechanical: for every module-level let, one line in reset() restoring its starting value, and .reset() on every TA object. The buffer contents themselves need no clearing when a count field says how much of the buffer is valid; resetting count and cursor is enough.

Accessors over slot literals

Params, inputs, and outputs reach the code by name: p_period(), in_close(), out_sma(value), generated from the declarations before every build. 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 build refuses the raw form in your source (scaffold build blocked: raw positional slot literals silently rebind) 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 (Script definition).

Technical indicators

Read the period once, cast it once

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

Be honest about warm-up

A window that is not full has no value, and the honest outputs are NaN or no row at all. Two policies, both correct: return 0 from state() to abstain the whole row while nothing on it is meaningful, 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. In a hand-written sheet, warmup_bars is the same honesty as a hint to consumers: set it to the real number of bars the module needs, not 1.

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 the sheet 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 is the metric a watch fires on.

Plotting and visualization

Describe every declaration

description on a param is the settings dialog's label; on an input it is the sheet's documentation; on an output it is the legend and the metric catalog entry. 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

There is no print(). The debugger is an output: declare the suspect intermediate as none (readable in the legend and through om metric series, 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 ports need are two lines each:

import { input, line, lower, ohlcv, output, param } from "./sdk/declare";
import { in_close, in_volume } from "./gen/inputs";
import { emitRow, out_ratio, out_smooth } from "./gen/outputs";
import { p_len } from "./gen/params";
import { Sma } from "./sdk/ta";

param("len", 20, { min: 1, max: 200 });
input("close", ohlcv.close);
input("volume", ohlcv.volume);
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 ratio: f64 = 0.0;
let lastGood: f64 = NaN;

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

export function state(): i32 {
  const volume = in_volume();
  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(in_close());
  if (!isNaN(smooth)) lastGood = smooth;
  return 1;
}

export function finalize(): void {
  out_ratio(ratio);
  out_smooth(lastGood);
  emitRow();
}

export function reset(): void {
  avgVolume.reset();
  avgClose.reset();
  ratio = 0.0;
  lastGood = NaN;
}

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.

missing on sparse primaries

A feed that only has rows when something happened (liquidations, a coarse pin) 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 in state() and keep it in a module-level variable for finalize() to write; 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

finalize() should be writes and arithmetic. Building a string per bar is fine inside the string channel's shared buffer (sb_clear / sb_text / sb_f64 allocate nothing), but formatting through String concatenation allocates on every bar; reserve it for the newest-bar label you actually read.

Code organization

Read top to bottom: imports, declarations, state, helpers, then the four exports in the order the host calls them. A complete Indicator laid out that way:

// 1. Imports: the declaring words, the generated accessors, the TA classes.
import { input, line, none, ohlcv, output, overlay, param, shape } from "./sdk/declare";
import { in_close, in_low } from "./gen/inputs";
import { emitRow, out_entry, out_fast, out_is_entry, out_slow } from "./gen/outputs";
import { p_fast, p_slow } from "./gen/params";
import { Cross, Ema } from "./sdk/ta";

// 2. Declarations: settings, feeds, outputs, each with the description the dialog and legend show.
param("fast", 9, { min: 2, max: 100, description: "Fast EMA length" });
param("slow", 21, { min: 5, max: 400, description: "Slow EMA length" });
input("close", ohlcv.close);
input("low", ohlcv.low);
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);

// 3. State: everything that lives between bars, with its starting value.
let fast = new Ema(9);
let slow = new Ema(21);
let cross = new Cross();
let fastValue: f64 = NaN;
let slowValue: f64 = NaN;
let low: f64 = NaN;
let crossed: i32 = 0;

// 4. Lifecycle: init reads settings, state folds the bar, finalize writes the row, reset restores.
export function init(): void {
  fast = new Ema(i32(p_fast()));
  slow = new Ema(i32(p_slow()));
  cross = new Cross();
}

export function state(): i32 {
  const close = in_close();
  low = in_low();
  fastValue = fast.update(close);
  slowValue = slow.update(close);
  crossed = cross.update(fastValue, slowValue);
  return isNaN(slowValue) ? 0 : 1;
}

export function finalize(): void {
  out_fast(fastValue);
  out_slow(slowValue);
  out_entry(low);
  out_is_entry(crossed == 1 ? 1.0 : 0.0);
  emitRow();
}

export function reset(): void {
  fast.reset();
  slow.reset();
  cross.reset();
  fastValue = NaN;
  slowValue = NaN;
  low = NaN;
  crossed = 0;
}

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 pin is honored on your machine only. The cookbook recipes are written that way.

Keep the names when you port

When porting a kScript, keep its variable names as your declaration names: param("lookback", ...), input("close", ...), output("z", ...). The generated accessors then read like the original (p_lookback(), in_close(), out_z(z)), and comparing the two on the same market is a matter of reading side by side (From kScript).