NaN and color

Working with missing values in an Indicator: NaN and the two-line helpers that replace the isna, nz, and fixnan builtins of kScript (legacy), how NaN behaves…

Working with missing values in an Indicator: NaN and the two-line helpers that replace the isna, nz, and fixnan builtins of kScript (legacy), how NaN behaves in arithmetic and on the chart, and how color works when it is a declaration rather than a value. kScript had na as a value and color as a first-class type with constructors; an Indicator has NaN inside f64 and colors as string literals on outputs, boxes, and segments.

Why NaN matters

NaN is an Indicator's "no value here." You meet it constantly: a TA class has not warmed up yet, a sparse source has no observation on this bar, a ratio has a zero denominator. The important rule is that NaN is contagious in arithmetic: any expression touching a NaN becomes NaN. Add 5 to a not-yet-warm SMA and you get NaN, and a NaN written to an output draws as a gap, not a zero. So the skill is detecting NaN and deciding what to do about it before it poisons a calculation or blanks a line.

The helpers, and what they are in an Indicator:

kScriptIndicatorUse it to
isna(x), isnan(x)isNaN(x)branch on whether a value is missing
isnum(x)isFinite(x) (!isNaN(x) when infinities are impossible)the inverse: is this a usable number
nz(x, fallback)isNaN(x) ? fallback : xreplace a missing value with a fallback
fixnan(series)a module-level held updated only when the new value is finitehold the last good value forward over gaps
NaN == naisNaN(x); never x == NaNNaN is not equal to anything, itself included

Comparisons with NaN are always false: NaN > 0.0, NaN < 0.0, and NaN == NaN all evaluate false. That is why a condition built over a warming input never fires by accident, and why isNaN is the only test that works.

Filling and forward-holding

nz is the everyday tool and it is one ternary: if x is NaN, take the fallback, otherwise take x. It is how you keep a plot continuous or keep arithmetic finite.

fixnan is the series-level cousin: wherever the series is NaN, substitute the most recent finite value, so a gappy series becomes a stepped, continuous one. In an Indicator that is a module-level variable you overwrite only when the new value is finite, and reset() clears it.

Both, plus the two ways a row can be missing, in one file. The propagated output is deliberately NaN for the first 20 bars: the SMA has not warmed up, and adding 5 to NaN stays NaN. filled patches those bars with the close, and held carries the last finite liquidation reading across quiet bars (missing: "nan" keeps the source honest; the holding is the module's decision).

import { box, input, line, liquidations, lower, ohlcv, output, overlay, param } from "./sdk/declare";
import { in_close, in_liqs } from "./gen/inputs";
import { emitRow, out_band_hi, out_band_lo, out_filled, out_held, out_propagated } from "./gen/outputs";
import { p_period } from "./gen/params";
import { Sma } from "./sdk/ta";

param("period", 20, { min: 1, max: 200, description: "SMA length" });
input("close", ohlcv.close);
input("liqs", liquidations.liquidations, { missing: "nan", description: "Liquidation volume, NaN on bars without any" });
output("propagated", line, overlay, { color: "#94a3b8", description: "SMA + 5: NaN until the window is warm, and NaN stays NaN" });
output("filled", line, overlay, { color: "rgb(37, 99, 235)", width: 2, description: "The same series with the close as a fallback while warming (nz)" });
output("held", line, lower, { color: "#7c3aed", description: "The last finite liquidation reading, held across quiet bars (fixnan)" });
const bandHi = output("band_hi", line, overlay, { color: "#2563eb66", description: "Filled series plus 1%" });
const bandLo = output("band_lo", line, overlay, { color: "#2563eb66", description: "Filled series minus 1%" });
// A box fill takes the opacity, so its color is hex, rgb(), or hsl(): a named color is refused here.
box("band", { top: bandHi, bottom: bandLo, color: "hsl(221, 83%, 53%)", opacity: 0.1, borderWidth: 0 });

let sma = new Sma(20);
let close: f64 = NaN;
let propagated: f64 = NaN;
let held: f64 = NaN; // the fixnan state: overwritten only by a finite value

function nz(x: f64, fallback: f64): f64 {
  return isNaN(x) ? fallback : x;
}

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

export function state(): i32 {
  close = in_close();
  propagated = sma.update(close) + 5.0; // NaN + 5 is NaN: the contagion
  const liqs = in_liqs();
  if (isFinite(liqs)) held = liqs; // fixnan: hold the last good value forward
  return 1;
}

export function finalize(): void {
  const filled = nz(propagated, close);
  out_propagated(propagated);
  out_filled(filled);
  out_held(held);
  out_band_hi(filled * 1.01);
  out_band_lo(filled * 0.99);
  emitRow();
}

export function reset(): void {
  sma.reset();
  close = NaN;
  propagated = NaN;
  held = NaN;
}

Walking the key lines:

  • propagated = sma.update(close) + 5.0 is NaN for the first 20 bars. Written to its output, those bars are a gap on the grey line; the row itself is still ready (state() returns 1), so the other outputs draw.
  • nz(propagated, close) patches the early bars with the close, so the blue line is continuous from bar 0 instead of starting blank at bar 20.
  • if (isFinite(liqs)) held = liqs; is fixnan: a bar with no liquidations reads NaN under missing: "nan", and the held value steps only when a real reading arrives. Before the first reading held is NaN, an honest gap rather than an invented zero.

What you'll see: a grey line that starts at bar 20 and a blue one that starts at bar 0, a faint band around the blue one, and a stepped purple line in the lower pane.

Abstaining is the other option

Writing NaN to one output leaves the row ready and the other outputs drawing. Returning 0 from state() abstains the whole row: nothing is drawn, no metric value exists for that bar, and finalize() is not called. Use NaN when one line warms slower than another; abstain when nothing on the row is meaningful yet (execution-model.md).

Colors are declared, not built

kScript built colors at runtime with color.rgb(r, g, b) and color.new(color, transp) and stored them in variables. An Indicator has no color type and no color values: a color is a string literal in a declaration, read once when the sheet is derived. The forms the chart understands:

FormExampleWhere
Six-digit hex"#2563eb"everywhere
Eight-digit hex (alpha in the last two digits)"#2563eb66"everywhere; the built-in way to say color.new(c, transp)
rgb() / rgba()"rgb(37, 99, 235)"everywhere
hsl() / hsla()"hsl(221, 83%, 53%)"everywhere
A named color"orange"outputs, segments, renderers, borders; refused on a box fill

Alpha has two spellings: an eight-digit hex (or rgba() / hsla()) bakes it into the color, and the opacity option (0..1) on an output, a box, or a range applies it on top. A box's fill takes its opacity, which is why a box color must be a form the host can rewrite with an alpha channel: hex, rgb(), or hsl(). A named color there is refused at validation with color must be a hex, rgb() or hsl() color (the fill takes the opacity). The named palette, and where each form is accepted, is color-constants.md.

A color that must change per bar is not a value either: it is a data-only output indexing a declared colors palette through color_by, floored, with a missing or out-of-range value falling back to entry 0 (functions/color-functions.md).

Channel values are validated

The sheet validator checks a box fill's form before the module runs, and the chart parses every other color string when it draws. A channel outside its range (rgb(300, 0, 0)) is not a build error, but it is not a color the chart can render either, so keep channels in 0..255 and hue, saturation, and lightness in their own ranges. The safe habit is six- or eight-digit hex everywhere: it validates on a box fill, it carries alpha, and it is what every worked example in this tree uses.