Debugging

A practical workflow for finding out why an Indicator is wrong: emit what you cannot see, read the Problems lane, isolate one output, read the numbers on your…

A practical workflow for finding out why an Indicator is wrong: emit what you cannot see, read the Problems lane, isolate one output, read the numbers on your machine, compare against the kScript (legacy) original on one market, then check the usual suspects. Work it in order.

Your Indicator compiles and runs but the signal is wrong, or it draws nothing, or a line is mysteriously flat. There is no print() and no console, but there is a fast, reliable workflow, and it starts from the one fact that makes Indicators easy to debug: every value the module computes can be an output, and every output is readable as numbers.

1. Emit what you cannot see

The fastest debugger is an output. You cannot step through the bar loop, but you can declare any intermediate value as an output and look at it on every bar. Two forms:

  • A lower line shows the shape of a value over every bar. Seeing it usually tells you immediately whether it is doing what you think: pinned at zero, flatlining, a spike where there should be none.
  • A none output is a probe: computed and written every bar, readable in the legend and as a metric, never drawn. It keeps the picture clean while you check a number.
import { input, line, lower, none, ohlcv, output, param } from "./sdk/declare";
import { in_close, in_high, in_low } from "./gen/inputs";
import { emitRow, out_range_pct, out_raw_spread, out_smooth } from "./gen/outputs";
import { p_len } from "./gen/params";
import { Ema } from "./sdk/ta";

param("len", 14, { min: 2, max: 200 });
input("close", ohlcv.close);
input("high", ohlcv.high);
input("low", ohlcv.low);
// The value under suspicion, drawn in its own pane so its shape is visible on every bar.
output("range_pct", line, lower, { unit: "%", color: "#ef4444", description: "high minus low as a percent of close" });
output("smooth", line, lower, { unit: "%", color: "#f59e0b", description: "EMA of the range" });
// A probe: computed and readable in the legend and as a metric, never drawn. Delete it when done.
output("raw_spread", none, lower, { description: "debug: high minus low before the percent" });

let ema = new Ema(14);
let spread: f64 = NaN;
let rangePct: f64 = NaN;
let smooth: f64 = NaN;

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

export function state(): i32 {
  const close = in_close();
  spread = in_high() - in_low();
  rangePct = close > 0.0 ? (spread / close) * 100.0 : NaN;
  smooth = isNaN(rangePct) ? NaN : ema.update(rangePct);
  return isNaN(rangePct) ? 0 : 1;
}

export function finalize(): void {
  out_range_pct(rangePct);
  out_smooth(smooth);
  out_raw_spread(spread);
  emitRow();
}

export function reset(): void {
  ema.reset();
  spread = NaN;
  rangePct = NaN;
  smooth = NaN;
}

When you need the exact number on the chart rather than the shape, a string slot and a render.label print it on the newest bar, the way a kScript plotText under isLastBar did. It switches the file to the second runtime contract, which the build does for you:

import { input, line, lower, none, ohlcv, output, render, string, time } from "./sdk/declare";
import { in_bar_t, in_close, in_high, in_low } from "./gen/inputs";
import { emitRow, out_range_pct, out_tag_x } from "./gen/outputs";
import { sb_clear, sb_f64, sb_text, str_readout_sb } from "./gen/strings";

input("close", ohlcv.close);
input("high", ohlcv.high);
input("low", ohlcv.low);
input("bar_t", time.bar_open_sec);
output("range_pct", line, lower, { unit: "%", color: "#ef4444" });
output("tag_x", none, lower, { description: "This bar's open time, the label's x" });
string("readout", { max_bytes: 32 });
// One label, on the newest bar that wrote the slot: the exact number, read off the chart.
render.label("range_readout", { x: "tag_x", y: "range_pct", text: "readout", color: "#f59e0b", size: 12 });

let rangePct: f64 = NaN;
let barTime: f64 = NaN;

export function init(): void {}

export function state(): i32 {
  const close = in_close();
  barTime = in_bar_t();
  rangePct = close > 0.0 ? ((in_high() - in_low()) / close) * 100.0 : NaN;
  return isNaN(rangePct) ? 0 : 1;
}

export function finalize(): void {
  out_range_pct(rangePct);
  out_tag_x(barTime);
  sb_clear();
  sb_text("range% = ");
  sb_f64(rangePct, 2);
  str_readout_sb();
  emitRow();
}

export function reset(): void {
  rangePct = NaN;
  barTime = NaN;
}

This works for any expression. Lift the part you doubt into a module-level variable, declare an output for it, and read the answer off the chart instead of guessing. Delete the probe when you are done; every output is a metric, and a debug metric in a published package is noise.

2. Read the Problems lane

If Run stops before the chart changes, the message is in the Problems lane, tagged with the stage that produced it, and one problem hides the ones behind it:

StageWhat it checkedTypical message
declarationsthe param / input / output / shape statements, read from the textoption 'top' takes an output handle, not a string literal
lintraw positional slot literals in your sourceraw positional slot literals silently rebind
metadatathe derived sheet against the schemaoutputs.0.color_by: color_by needs 'colors' with at least 2 entries
compilethe AssemblyScript compilerERROR AS200: Conversion from type 'f64' to 'i32' requires an explicit cast.
validatethe built module against the four-export contractWRUN export 'finalize' has signature () -> f64

Fix the first one, Run again. On your machine om wrun build prints the same messages and exits non-zero; every one of them is listed with its fix in Common errors.

A run that reaches the chart and draws nothing is usually not an error at all. It is state() returning 0 on every bar, an output declared none, or a source the chart's market does not serve (the chart says which, by name). Step 5 covers those.

3. Isolate

When several outputs are wrong at once, stop reasoning about all of them. Reduce the module to one output: write NaN to the others in finalize() (a NaN draws nothing), or switch their plot to none, and keep only the suspect series drawn. Once that one is correct, bring the others back one at a time. This is the cheapest way to find which input poisoned a calculation downstream, because a wrong value in an Indicator has exactly one place it can come from: the bar's inputs, the state carried from the previous bar, or the arithmetic between them.

4. Read it as numbers on your machine

The chart shows shapes; the daemon shows numbers. Install the draft and read the output bar by bar, oldest first, newest last (the still-forming bar):

om wrun install ./debug-probe --replace
om metric series --metric wrun/@you/debug-probe/range_pct --symbol BTCUSDT --exchange BINANCE_FUTURES --interval 1h --bars 60
om metric series --metric wrun/@you/debug-probe/raw_spread --symbol BTCUSDT --exchange BINANCE_FUTURES --interval 1h --bars 60 --format json

A none output is a metric like any other, so a probe is readable here without ever drawing. Text mode renders a sparkline; --format json gives the [barOpenSec, value] pairs to diff against a spreadsheet or the original script's export.

5. Compare against the kScript original on one market

When the Indicator is a port, the original is the oracle. Open both on the same market and interval and read them side by side: on the chart, add the kScript (legacy) row with Use kScript engine beside your draft; from the terminal, om open launches a published kScript on a chart in your browser. Compare from the first bar where both are warm, not from bar zero: the shipped TA classes warm up differently from the kScript builtins (Rsi is Wilder-smoothed and NaN until its window fills; Ema seeds from a simple average), so the first period bars differ by design and everything after them should not.

om open @scope/their-indicator

If the two agree on closed bars and disagree only on the newest one, the difference is the forming-bar replay, and the fix is almost always a field missing from reset().

6. The usual suspects

Most "wrong Indicator" bugs are one of a handful of patterns. Scan this list against your symptom:

  • Line starts blank, then appears. Warm-up. Anything with a period is NaN until it has enough bars, and a state() that returns 0 abstains those rows. That leading gap is expected; if the line never appears, the chart has fewer bars loaded than the window needs.
  • Flat line. A module-level variable that is assigned in init() and never updated in state(), or an accumulator that reset() restores on every replay of the forming bar. Check that the value is written every bar and that reset() restores the starting value, not the current one.
  • Everything is NaN. A secondary input under missing: "nan" on bars without an observation, an arithmetic chain fed by one NaN input, or a division by zero. Probe each input as a none output and find the first NaN.
  • Wrong pane. A percent or a count drawn overlay hugs the price axis floor. Declare it lower.
  • A mark on every bar. A shape output without shape_where. Add the gate output.
  • Stale values after a tick. A field reset() forgot. Reassign every module-level variable and .reset() every TA object.
  • Nothing at all, no error. state() never returns 1 (a class whose period exceeds the loaded history), the only rendered output is declared none, or the chart's market does not serve the source (the chart names it: trades volume needs side 'BUY' or 'SELL', source class 'odds' is not served by the browser lane yet).
  • Different on the chart and on your machine. A pin. The chart reads its own market and interval for every input; your machine honors symbol, exchange, and interval pins. A higher timeframe on the chart is a bucket fold inside the module (Multi-timeframe).
  • A watch never fires. The condition names the wrong output, the wrong venue spelling, or an edge operator without an interval in the selector. om watch stats <id> reads condition_text and last_error back; om watch repair <id> names the repair.

Walk these top to bottom. The fix for each is one line, and the probe from step 1 usually tells you which one you are looking at.

See also

  • Common errors for every build and runtime message with its fix.
  • Execution model for warm-up, the forming-bar replay, and where an Indicator runs.
  • Repainting for why a stale field in reset() repaints and a bucket fold does not.