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:
| kScript | Indicator | Use 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 : x | replace a missing value with a fallback |
fixnan(series) | a module-level held updated only when the new value is finite | hold the last good value forward over gaps |
NaN == na | isNaN(x); never x == NaN | NaN 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.0isNaNfor the first 20 bars. Written to its output, those bars are a gap on the grey line; the row itself is still ready (state()returns1), 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;isfixnan: a bar with no liquidations readsNaNundermissing: "nan", and the held value steps only when a real reading arrives. Before the first readingheldisNaN, 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:
| Form | Example | Where |
|---|---|---|
| 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.