---
title: "Series functions"
description: "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…"
order: 51
section: "functions"
---

<!-- source: docs/indicators/functions/series-functions.md; generated by packages/cli/scripts/gen-indicator-docs.ts, do not edit -->

# Series functions

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, how many bars since a condition
was true, and is this value real or missing. In kScript (legacy) each was
a builtin over a series with history. An Indicator sees one bar at a time
([Execution model](../core-concepts/execution-model.md)), so each is a
small class in `src/sdk/ta.ts` 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 and matches the kScript engine bar
for bar; the full catalog is on the [TA library](ta-library.md) page.

## Price-source helpers

`hl2`, `hlc3`, `ohlc4`, and `hlcc4` are arithmetic on inputs. Declare the
fields you read and write the formula:

| Helper | Formula |
| --- | --- |
| `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: kScript's `hl2()` with the source omitted
read the chart's OHLC, while an Indicator names every field as an
`input(...)` and reads it in `state()`.

```typescript
import { input, line, ohlcv, output, overlay } from "./sdk/declare";
import { in_close, in_high, in_low, in_open } from "./gen/inputs";
import { emitRow, out_hl2, out_hlc3, out_hlcc4, out_ohlc4 } from "./gen/outputs";

input("close", ohlcv.close);
input("open", ohlcv.open);
input("high", ohlcv.high);
input("low", ohlcv.low);
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" });

let hl2: f64 = NaN;
let hlc3: f64 = NaN;
let ohlc4: f64 = NaN;
let hlcc4: f64 = NaN;

export function init(): void {}

export function state(): i32 {
  const open = in_open();
  const high = in_high();
  const low = in_low();
  const close = in_close();
  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;
  return 1;
}

export function finalize(): void {
  out_hl2(hl2);
  out_hlc3(hlc3);
  out_ohlc4(ohlc4);
  out_hlcc4(hlcc4);
  emitRow();
}

export function reset(): void {
  hl2 = NaN;
  hlc3 = NaN;
  ohlc4 = NaN;
  hlcc4 = NaN;
}
```

## Crossovers and signals

`Cross` ships in `src/sdk/ta.ts`: `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 replaces the three kScript builtins:

| kScript | Indicator |
| --- | --- |
| `crossover(a, b)` | `cross.update(a, b) == 1` |
| `crossunder(a, b)` | `cross.update(a, b) == -1` |
| `cross(a, b)` | `cross.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](plotting.md)):

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

## Trend, extremes, and counting

| kScript | Indicator form | Returns |
| --- | --- | --- |
| `rising(source, period)` | `new 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 |
| `falling(source, period)` | `new 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 |
| `change(source, n)` | `new Change(n)`, `.update(x)` | `x` now minus `x` `n` bars ago (`n` defaults to 1); `NaN` for the first `n` bars |
| `highest(source, period)` | `new Highest(period)`, `.update(x)`, field `bars` | the highest value in the window; `bars` is the `highestbars` offset for the same window |
| `lowest(source, period)` | `new Lowest(period)`, `.update(x)`, field `bars` | the lowest value in the window; `bars` is the `lowestbars` offset |
| `highestbars(source, period)` | `new HighestBars(period)`, `.update(x)`, field `value` | how 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 |
| `lowestbars(source, period)` | `new LowestBars(period)`, `.update(x)`, field `value` | the same offset for the window's low |
| `barssince(condition)` | `new BarsSince()`, `.update(cond)` | bars elapsed since `cond` was last true (`0` on that bar), `NaN` until it has been true once |
| `valuewhen(condition, source, occurrence)` | `new ValueWhen(occurrence)`, `.update(cond, x)` | `x` on the `occurrence`-th most recent bar where `cond` was true (`0` = the latest, the current bar counts) |
| `percentile(source, period, pct)` | `new Percentile(period, pct)`, `.update(x)` | the nearest-rank `pct`-th percentile over the window |
| `median(source, period)` | `new Median(period)`, `.update(x)` | the middle value (the mean of the two middle values on an even window) |
| `correlation(s1, s2, period)` | `new Correlation(period)`, `.update(a, b)` | the rolling Pearson correlation, -1..1 |

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. kScript's
`highest(source=trade, period=20, priceIndex=2)` picked the `high` column
with an index; an Indicator feeds the class the series it should scan,
so `new Highest(20)` over `in_high()` is the 20-bar high and a `Lowest(20)`
over `in_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 `init()` from a param and update each once
per bar:

```typescript
import { Highest, Lowest, Median, Percentile, Rising } from "./sdk/ta";

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 state(): 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 rule of the kScript engine:
`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.

## 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 `state()` 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, the columns kScript read by default.

```typescript
import { Fixnan, PivotHigh, PivotLow } from "./sdk/ta";

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

// In state(): a value on the confirmation bar, NaN between.
// const confirmed = pivotHigh.update(in_high());
// const level = lastHigh.update(confirmed); // the kScript fixnan(pivothigh) idiom
```

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

## Handling missing values

Warm-up is `NaN`, and any arithmetic touching `NaN` stays `NaN`, exactly
as `na` propagated. The helpers are two lines each:

| kScript | Indicator |
| --- | --- |
| `isna(x)`, `isnan(x)` | `isNaN(x)` |
| `isnum(x)` | `isFinite(x)` (finite and not `NaN`) |
| `nz(x, replacement)` | `nz(x, replacement)` below |
| `fixnan(source)` | `new Fixnan()`, `.update(x)`: carries the last finite value forward, `NaN` until the first one |

```typescript
import { Fixnan } from "./sdk/ta";

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

const fixnan = new Fixnan();
// In state(): 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 watch them flip; the rest run over real bars. The
classes are the shipped ones, imported from `./sdk/ta`.

```typescript
import { input, line, lower, ohlcv, output, param } from "./sdk/declare";
import { in_close, in_high, in_low, in_open } from "./gen/inputs";
import {
  emitRow,
  out_barssince,
  out_change,
  out_correlation,
  out_cross,
  out_crossover,
  out_crossunder,
  out_falling,
  out_fixnan,
  out_highest,
  out_highestbars,
  out_isna,
  out_isnum,
  out_lowest,
  out_lowestbars,
  out_median,
  out_nz,
  out_percentile,
  out_pivot_high,
  out_pivot_low,
  out_rising,
  out_valuewhen,
} from "./gen/outputs";
import { p_fast, p_slow, p_window } from "./gen/params";
import {
  BarsSince,
  Change,
  Correlation,
  Cross,
  Falling,
  Fixnan,
  Highest,
  Lowest,
  Median,
  Percentile,
  PivotHigh,
  PivotLow,
  Rising,
  Sma,
  ValueWhen,
} 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" });
input("close", ohlcv.close);
input("open", ohlcv.open);
input("high", ohlcv.high);
input("low", ohlcv.low);
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;
let crossed: i32 = 0;
let risingValue: f64 = NaN;
let fallingValue: f64 = NaN;
let since: f64 = NaN;
let changeValue: f64 = NaN;
let highValue: f64 = NaN;
let lowValue: f64 = NaN;
let whenValue: f64 = NaN;
let pctValue: f64 = NaN;
let medianValue: f64 = NaN;
let corrValue: f64 = NaN;
let sparse: f64 = NaN;
let fixed: f64 = NaN;
let openValue: f64 = NaN;
let pivotHighValue: f64 = NaN;
let pivotLowValue: f64 = NaN;

export function init(): 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);
}

export function state(): i32 {
  const close = in_close();
  openValue = in_open();
  const f = fast.update(close);
  const s = slow.update(close);
  crossed = cross.update(f, s);
  risingValue = rising.update(close);
  fallingValue = falling.update(close);
  since = barsSince.update(!isNaN(f) && close > f ? 1.0 : 0.0);
  changeValue = change.update(close);
  highValue = highs.update(in_high());
  lowValue = lows.update(in_low());
  whenValue = valueWhen.update(crossed == 1 ? 1.0 : 0.0, close);
  pctValue = percentile.update(close);
  medianValue = median.update(close);
  corrValue = correlation.update(openValue, close);
  sparse = barIndex < 10 ? NaN : close;
  fixed = fixnan.update(sparse);
  pivotHighValue = stableHigh.update(pivotHigh.update(in_high()));
  pivotLowValue = stableLow.update(pivotLow.update(in_low()));
  barIndex += 1;
  return 1;
}

export function finalize(): void {
  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);
  emitRow();
}

export function reset(): void {
  fast.reset();
  slow.reset();
  cross.reset();
  rising.reset();
  falling.reset();
  barsSince.reset();
  change.reset();
  highs.reset();
  lows.reset();
  valueWhen.reset();
  percentile.reset();
  median.reset();
  correlation.reset();
  fixnan.reset();
  pivotHigh.reset();
  pivotLow.reset();
  stableHigh.reset();
  stableLow.reset();
  barIndex = 0;
  crossed = 0;
  risingValue = NaN;
  fallingValue = NaN;
  since = NaN;
  changeValue = NaN;
  highValue = NaN;
  lowValue = NaN;
  whenValue = NaN;
  pctValue = NaN;
  medianValue = NaN;
  corrValue = NaN;
  sparse = NaN;
  fixed = NaN;
  openValue = NaN;
  pivotHighValue = NaN;
  pivotLowValue = NaN;
}
```

`barIndex` is state the module counts itself: there is no `barIndex`
global, and `reset()` puts it back to `0` because the host replays the
forming bar through a fresh evaluation of the history.

## A Donchian breakout

The kScript "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.

```typescript
import { input, line, none, ohlcv, output, overlay, param, shape } from "./sdk/declare";
import { in_close, in_high, in_low } from "./gen/inputs";
import { emitRow, out_breakout, out_breakout_mark, out_hi, out_lo } from "./gen/outputs";
import { p_period } from "./gen/params";
import { Highest, Lowest } from "./sdk/ta";

param("period", 20, { min: 2, max: 400, description: "Channel lookback" });
input("close", ohlcv.close);
input("high", ohlcv.high);
input("low", ohlcv.low);
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 hi: f64 = NaN;
let lo: f64 = NaN;
let prevHi: f64 = NaN;
let close: f64 = NaN;
let breakout: f64 = 0.0;

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

export function state(): i32 {
  close = in_close();
  // Test against the channel as it stood BEFORE this bar, so the bar cannot break its own high.
  breakout = !isNaN(prevHi) && close > prevHi ? 1.0 : 0.0;
  hi = highs.update(in_high());
  lo = lows.update(in_low());
  prevHi = hi;
  return isNaN(hi) ? 0 : 1;
}

export function finalize(): void {
  out_hi(hi);
  out_lo(lo);
  out_breakout_mark(close);
  out_breakout(breakout);
  emitRow();
}

export function reset(): void {
  highs.reset();
  lows.reset();
  hi = NaN;
  lo = NaN;
  prevHi = NaN;
  close = NaN;
  breakout = 0.0;
}
```

## 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.
