Series functions

View as MarkdownOpen the editor

Series functions answer the questions that come up constantly when writing an indicator: did two lines just cross, is a series rising, what is the highest high in the last 10 bars, what is the window total or z-score, how many bars since a condition was true, and is this value real or missing.

A wrun indicator sees one bar at a time (Execution model), so each is a small class in ./sdk/ta that keeps the history it needs and folds one bar per update() call, or a two-line function over NaN. Every class on this page ships in the kit; the full catalog is on the TA library page.

The one series question this page does not answer is source[n], the value a few bars back. That is History from ./sdk/stats: construct it in onStart(), push() once per bar, read ago(n), NaN until the window holds the bar. The same module does the list math (mean, stdev, slope, correlation, median, percentile) over a StaticArray<f64> without allocating, and ./sdk/ta-plus adds the running extremes, PercentRank, Range and Mode over a window; all on Extra indicators. The pivots below are also the raw material of the Market structure kit, whose Swings turns them into confirmed swing highs and lows.

Price-source helpers

hl2, hlc3, ohlc4, and hlcc4 are arithmetic on the bar's fields. Read the fields you need and write the formula:

HelperFormula
hl2(high + low) / 2.0
hlc3(high + low + close) / 3.0
ohlc4(open + high + low + close) / 4.0
hlcc4(high + low + close + close) / 4.0

There is no implicit source: a wrun indicator names every field it reads, bar.high() for the high, bar.close() for the close.

output("hl2", line, overlay, { color: "#2563eb", width: 1, description: "Bar midpoint" });
output("hlc3", line, overlay, { color: "#16a34a", width: 1, description: "Typical price" });
output("ohlc4", line, overlay, { color: "#f97316", width: 1, description: "Four-price average" });
output("hlcc4", line, overlay, { color: "#7c3aed", width: 1, description: "Close-weighted average" });

function onBar(): void {
  const open = bar.open();
  const high = bar.high();
  const low = bar.low();
  const close = bar.close();
  out_hl2((high + low) / 2.0);
  out_hlc3((high + low + close) / 3.0);
  out_ohlc4((open + high + low + close) / 4.0);
  out_hlcc4((high + low + close + close) / 4.0);
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

Crossovers and signals

Cross ships in ./sdk/ta: new Cross(), .update(a, b): i32 returns +1 on the bar a crosses above b, -1 on the bar it crosses below, and 0 otherwise, including the first bar and any bar where either side is NaN. The edge rule is the engine's: a crossover is a below b on the previous bar and a at or above b on this bar (touching counts on the current bar, not on the previous one), and a crossunder is the mirror image. One object answers all three questions:

You wantwrun form
a cross abovecross.update(a, b) == 1
a cross belowcross.update(a, b) == -1
a cross in either directioncross.update(a, b) != 0

Call update() exactly once per bar per pair: it remembers the previous pair, so a second call on the same bar would compare the bar to itself. Keep the result in a local and test it as many times as you like. A crossover against a constant (rsi over 70) is cross.update(value, 70.0).

The classic signal-line pattern is a Cross over the MACD line and its signal; the result is a 0/1 step you can draw, or a gate for a shape output that marks the price bar (Plotting):

wrun
const crossed = cross.update(macd.macd, macd.signal);
bullish = crossed == 1 ? 1.0 : 0.0;

Trend, extremes, and counting

You wantwrun formReturns
is the series risingnew Rising(period), .update(x)1 when x is strictly above every one of the previous period values, else 0; NaN for the first period bars
is the series fallingnew Falling(period), .update(x)1 when x is strictly below every one of the previous period values, else 0; NaN for the first period bars
the change over n barsnew Change(n), .update(x)x now minus x n bars ago (n defaults to 1); NaN for the first n bars
the window highnew Highest(period), .update(x), field barsthe highest value in the window; bars is the same offset HighestBars reports for the window
the window lownew Lowest(period), .update(x), field barsthe lowest value in the window; bars is the same offset LowestBars reports
how long ago the window high wasnew HighestBars(period), .update(x), field valuehow many bars ago the window's high sits, as 0 or a negative number (0 = this bar, -3 = three bars ago); value is the high itself
how long ago the window low wasnew LowestBars(period), .update(x), field valuethe same offset for the window's low
bars since a conditionnew BarsSince(), .update(cond)bars elapsed since cond was last true (0 on that bar), NaN until it has been true once
the value when a condition last heldnew ValueWhen(occurrence), .update(cond, x)x on the occurrence-th most recent bar where cond was true (0 = the latest, the current bar counts)
a percentile over a windownew Percentile(period, pct), .update(x)the nearest-rank pct-th percentile over the window
a median over a windownew Median(period), .update(x)the middle value (the mean of the two middle values on an even window)
a correlation over a windownew Correlation(period), .update(a, b)the rolling Pearson correlation, -1..1
a window totalnew Sum(period), .update(x)the sum of the last period values; no warm-up, bar 0 already returns the partial window
a z-score over a windownew Zscore(period), .update(x)(x - mean) / stdev over the window; 0 when the deviation is exactly 0

A condition is an f64: true means finite and not 0, so a 0/1 series or a comparison cast with ? 1.0 : 0.0 both work. You feed the class the series it should scan, so new Highest(20) over bar.high() is the 20-bar high and a Lowest(20) over bar.low() is the 20-bar low. Among equal values the newest bar wins the offset. The bars fields are handy for "is the high in the window recent?" logic, and BarsSince counts up from the last time a condition fired, the natural cooldown and recency check.

Construct the windowed ones in onStart() from a param and update each once per bar:

wrun
let highs = new Highest(20);
let lows = new Lowest(20);
let p80 = new Percentile(20, 80.0);
let median = new Median(20);
const rising = new Rising(3);

// In onBar(): the return value is the extreme, the field is its offset.
// const hi = highs.update(high);   // highs.bars: 0 = this bar, -3 = three bars ago
// const lo = lows.update(low);
// const up = rising.update(close); // 1, 0, or NaN for the first 3 bars

Every windowed class here follows the strict window rule: NaN until period bars exist, and NaN whenever any value inside the window is not finite. Percentile sorts the window and takes rank ceil(pct / 100 * period) (1-based, pct clamped to 0..100); Median sorts and takes the middle, averaging the two middle values on an even period; Correlation returns NaN when either side has no variance. None of them allocates after construction. Sum and Zscore are the two that skip a NaN bar instead of refusing the window (Zscore still divides its variance by period), and Sum has no warm-up at all: a 20-bar Sum draws from the first bar where a 20-bar Stdev draws from bar 19.

Pivot confirmation

PivotHigh and PivotLow are causal confirmation signals. A pivot only emits after rightbars later bars have closed, so the emitted value appears on the confirmation bar and lags the true pivot bar by rightbars. Nothing reads a future bar and no earlier bar repaints; an indicator could not do otherwise, because onBar() sees one bar. new PivotHigh(leftbars, rightbars) and new PivotLow(leftbars, rightbars) keep left + right + 1 bars and, once the window is full, test the candidate rightbars back: strictly higher than every other value in the window is a pivot high, strictly lower a pivot low (a tie never counts), and update(x) returns the candidate's value on the confirmation bar only and NaN everywhere else, including any window with a non-finite value. Feed PivotHigh the high and PivotLow the low.

wrun
const pivotHigh = new PivotHigh(2, 2);
const pivotLow = new PivotLow(2, 2);
const lastHigh = new Fixnan();

// In onBar(): a value on the confirmation bar, NaN between.
// const confirmed = pivotHigh.update(bar.high());
// const level = lastHigh.update(confirmed); // the last confirmed pivot, carried forward

A confirmed pivot is a sparse series: one value on the confirmation bar, NaN between. To carry it forward into a continuous line, run it through Fixnan below.

Handling missing values

Warm-up is NaN, and any arithmetic touching NaN stays NaN. isNaN and isFinite are AssemblyScript builtins on f64; there is no separate missing-value type, so a missing value is NaN everywhere and these two are the whole vocabulary. The helpers are two lines each:

You wantwrun form
is the value missingisNaN(x)
is the value a real numberisFinite(x) (finite and not NaN)
a replacement for a missing valuenz(x, replacement) below
the last real value carried forwardnew Fixnan(), .update(x): carries the last finite value forward, NaN until the first one
wrun
function nz(x: f64, replacement: f64): f64 {
  return isNaN(x) ? replacement : x;
}

const fixnan = new Fixnan();
// In onBar(): fixnan.update(sparse) repeats the last finite value across every NaN bar.

Every series function in one module

A sparse series (forced NaN for the first 10 bars) drives the missing value helpers so you can see them flip; the rest run over real bars. The classes are the shipped ones from ./sdk/ta.

param("fast", 5, { min: 1, max: 200 });
param("slow", 13, { min: 2, max: 400 });
param("window", 10, { min: 2, max: 200, description: "Window for the extremes, percentile, median, and correlation" });
output("crossover", line, lower, { color: "#2563eb", description: "1 on the bar the fast average crosses above the slow" });
output("crossunder", line, lower, { color: "#dc2626", description: "1 on the bar it crosses below" });
output("cross", line, lower, { color: "#7c3aed", description: "1 on either cross" });
output("rising", line, lower, { color: "#16a34a", description: "1 while the close is above its previous 3 values" });
output("falling", line, lower, { color: "#ea580c", description: "1 while the close is below its previous 3 values" });
output("barssince", line, lower, { color: "#0891b2", description: "Bars since the close was above the fast average" });
output("change", line, lower, { color: "#4b5563", description: "3-bar change" });
output("highest", line, lower, { color: "#0f766e", description: "Window highest high" });
output("lowest", line, lower, { color: "#be123c", description: "Window lowest low" });
output("highestbars", line, lower, { color: "#9333ea", description: "Offset of the window high (0 = this bar, negative = bars ago)" });
output("lowestbars", line, lower, { color: "#1d4ed8", description: "Offset of the window low" });
output("valuewhen", line, lower, { color: "#0e7490", description: "The close on the most recent bullish cross" });
output("percentile", line, lower, { color: "#b45309", description: "80th percentile of the close over the window" });
output("median", line, lower, { color: "#a16207", description: "Median close over the window" });
output("correlation", line, lower, { color: "#15803d", description: "Correlation between open and close over the window" });
output("nz", line, lower, { color: "#6d28d9", description: "nz over the sparse series, the open as the replacement" });
output("isna", line, lower, { color: "#0e7490", description: "1 while the sparse series is NaN" });
output("isnum", line, lower, { color: "#374151", description: "1 while the sparse series is a finite number" });
output("fixnan", line, lower, { color: "#3b82f6", description: "The sparse series with gaps carried forward" });
output("pivot_high", line, lower, { color: "#2563eb", description: "Confirmed 2/2 pivot highs, carried forward" });
output("pivot_low", line, lower, { color: "#dc2626", description: "Confirmed 2/2 pivot lows, carried forward" });

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

let fast = new Sma(5);
let slow = new Sma(13);
const cross = new Cross();
const rising = new Rising(3);
const falling = new Falling(3);
const barsSince = new BarsSince();
const change = new Change(3);
let highs = new Highest(10);
let lows = new Lowest(10);
const valueWhen = new ValueWhen(0);
let percentile = new Percentile(10, 80.0);
let median = new Median(10);
let correlation = new Correlation(10);
const fixnan = new Fixnan();
const pivotHigh = new PivotHigh(2, 2);
const pivotLow = new PivotLow(2, 2);
const stableHigh = new Fixnan();
const stableLow = new Fixnan();
let barIndex: i32 = 0;

function onStart(): void {
  fast = new Sma(i32(p_fast()));
  slow = new Sma(i32(p_slow()));
  const window = i32(p_window());
  highs = new Highest(window);
  lows = new Lowest(window);
  percentile = new Percentile(window, 80.0);
  median = new Median(window);
  correlation = new Correlation(window);
}

function onBar(): void {
  const close = bar.close();
  const openValue = bar.open();
  const f = fast.update(close);
  const s = slow.update(close);
  const crossed = cross.update(f, s);
  const risingValue = rising.update(close);
  const fallingValue = falling.update(close);
  const since = barsSince.update(!isNaN(f) && close > f ? 1.0 : 0.0);
  const changeValue = change.update(close);
  const highValue = highs.update(bar.high());
  const lowValue = lows.update(bar.low());
  const whenValue = valueWhen.update(crossed == 1 ? 1.0 : 0.0, close);
  const pctValue = percentile.update(close);
  const medianValue = median.update(close);
  const corrValue = correlation.update(openValue, close);
  const sparse = barIndex < 10 ? NaN : close;
  const fixed = fixnan.update(sparse);
  const pivotHighValue = stableHigh.update(pivotHigh.update(bar.high()));
  const pivotLowValue = stableLow.update(pivotLow.update(bar.low()));
  barIndex += 1;
  out_crossover(crossed == 1 ? 1.0 : 0.0);
  out_crossunder(crossed == -1 ? 1.0 : 0.0);
  out_cross(crossed != 0 ? 1.0 : 0.0);
  out_rising(risingValue);
  out_falling(fallingValue);
  out_barssince(since);
  out_change(changeValue);
  out_highest(highValue);
  out_lowest(lowValue);
  out_highestbars(highs.bars);
  out_lowestbars(lows.bars);
  out_valuewhen(whenValue);
  out_percentile(pctValue);
  out_median(medianValue);
  out_correlation(corrValue);
  out_nz(nz(sparse, openValue));
  out_isna(isNaN(sparse) ? 1.0 : 0.0);
  out_isnum(isFinite(sparse) ? 1.0 : 0.0);
  out_fixnan(fixed);
  out_pivot_high(pivotHighValue);
  out_pivot_low(pivotLowValue);
}

barIndex is state the module counts itself: there is no barIndex global, so it lives at module level with the rest of the state that spans bars.

A Donchian breakout

A 20-bar high and low channel as a Highest over the high and a Lowest over the low, with a gated mark on the bar that closes above the channel. The gate is a data-only output, and the shape output draws only where it is nonzero.

param("period", 20, { min: 2, max: 400, description: "Channel lookback" });
output("hi", line, overlay, { color: "#16a34a", width: 1, description: "Upper Donchian band" });
output("lo", line, overlay, { color: "#dc2626", width: 1, description: "Lower Donchian band" });
output("breakout_mark", shape, overlay, { color: "#16a34a", shape_where: "breakout", description: "The close on a breakout bar" });
output("breakout", none, overlay, { description: "1 when the close is above the previous bar's upper band" });

let highs = new Highest(20);
let lows = new Lowest(20);
let prevHi: f64 = NaN;

function onStart(): void {
  highs = new Highest(i32(p_period()));
  lows = new Lowest(i32(p_period()));
}

function onBar(): void {
  const close = bar.close();
  // Test against the channel as it stood BEFORE this bar, so the bar cannot break its own high.
  const breakout = !isNaN(prevHi) && close > prevHi ? 1.0 : 0.0;
  const hi = highs.update(bar.high());
  const lo = lows.update(bar.low());
  prevHi = hi;
  if (isNaN(hi)) return;
  out_hi(hi);
  out_lo(lo);
  out_breakout_mark(close);
  out_breakout(breakout);
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

Window statistics, a channel, and a cross

The window statistics (Highest, Lowest, Sum, Stdev), a Donchian channel, a Cross over two averages, and an isFinite gate in one module. Stdev and Donchian are explained with their families, on Trend and volatility and Moving averages.

param("period", 20, { min: 2, max: 400 });
output("low20", line, overlay, { color: "#dc2626", width: 1, description: "Lowest low over the period" });
output("high20", line, overlay, { color: "#16a34a", width: 1, description: "Highest high over the period" });
output("donchian_mid", line, overlay, { color: "#94a3b8", width: 1, description: "Donchian midpoint" });
output("volume_sum", line, lower, { color: "#0891b2", description: "Volume summed over the period" });
output("volatility", line, lower, { color: "#7c3aed", description: "Standard deviation of the close" });
output("bullish", none, lower, { description: "1 on the bar the fast average crosses above the slow" });
output("bearish", none, lower, { description: "1 on the bar it crosses below" });
output("any_cross", none, lower, { description: "1 on either" });
output("valid", none, lower, { description: "1 when every input on the bar is a finite number" });

let highs = new Highest(20);
let lows = new Lowest(20);
let volumeSum = new Sum(20);
let stdev = new Stdev(20);
let donchian = new Donchian(20);
let fast = new Sma(5);
let slow = new Sma(20);
const cross = new Cross();

function onStart(): void {
  const period = i32(p_period());
  highs = new Highest(period);
  lows = new Lowest(period);
  volumeSum = new Sum(period);
  stdev = new Stdev(period);
  donchian = new Donchian(period);
  fast = new Sma(5);
  slow = new Sma(period);
}

function onBar(): void {
  const close = bar.close();
  const high = bar.high();
  const low = bar.low();
  const volume = bar.volume();
  const valid = isFinite(close) && isFinite(high) && isFinite(low) && isFinite(volume) ? 1.0 : 0.0;
  const highValue = highs.update(high);
  const lowValue = lows.update(low);
  const sumValue = volumeSum.update(volume);
  const volatility = stdev.update(close);
  donchian.update(high, low);
  const crossed = cross.update(fast.update(close), slow.update(close));
  out_low20(lowValue);
  out_high20(highValue);
  out_donchian_mid(donchian.basis);
  out_volume_sum(sumValue);
  out_volatility(volatility);
  out_bullish(crossed == 1 ? 1.0 : 0.0);
  out_bearish(crossed == -1 ? 1.0 : 0.0);
  out_any_cross(crossed != 0 ? 1.0 : 0.0);
  out_valid(valid);
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 10 to Aug 18, 2026Real output from OpenMarket's engine

Warm-up and the missing-value window

Windowed functions cannot produce a value until they have seen enough bars: a 20-bar Percentile or Correlation returns NaN until 20 bars exist, just as a 20-bar Sma does. When the underlying series is itself NaN for a stretch, the warm-up shifts forward by the same amount, because the windowed classes refuse a window with a NaN inside it. Fixnan shows the recovery: it produces its first real value the moment the source has one, then holds it across every gap. Feed the sparse series from the tour into a 5-bar Percentile and the percentile output stays NaN through bar 14, not bar 4.

Practices that carry over

  • Lookback periods. Choose them for the interval you trade: short windows for scalping and lower timeframes, longer ones for swing context. Declare the range on the param (min, max) and size buffers from max so the module never allocates per bar.
  • One object per statistic. Compute a statistic once per bar and reuse the return value or field rather than constructing a second object over the same series.
  • Cross detection. Combine a cross with a trend read (Adx, a slope, a higher-timeframe fold) before treating it as a signal; in a chop the cross flips every few bars.
  • Donchian channels. Breakout logic in trends, support and resistance in ranges: the same two outputs, different rules on top.
  • Data validation. Check isFinite before a division, and write NaN rather than a made-up number when a value has no answer; a NaN draws nothing and never trips an alert.