Flag bars whose volume blows past its trailing average, scored as a z-score against a rolling mean and standard deviation. The kScript (legacy) recipe and the Indicator it became, side by side.
This recipe spots unusual volume. Instead of a fixed "alert above 1M" threshold that means nothing across different symbols, it measures how far the current bar's volume sits above its own recent average, in standard deviations. A reading of 3 means "three sigma above normal" whatever the symbol or timeframe. It is the cleanest possible introduction to the Indicator loop: read one input, fold it into one class, write a few outputs.
The kScript (legacy) recipe
//@version=2
define(title="Volume Spike Detector", position="offchart", axis=true)
var lookback = input(name="lookback", type="number", defaultValue=50, label="Lookback", constraints={min: 10, max: 300, step: 10})
var threshold = input(name="threshold", type="slider", defaultValue=2.5, label="Z-Score Threshold", constraints={min: 1, max: 6, step: 0.1})
timeseries d = ohlcv(symbol=currentSymbol, exchange=currentExchange)
// Z-score of volume against its trailing mean and standard deviation.
timeseries vMean = sma(source=d.volume, period=lookback)
timeseries vStd = stddev(d.volume, lookback)
timeseries z = (isnum(vStd[0]) && vStd[0] > 0) ? (d.volume[0] - vMean[0]) / vStd[0] : 0
plotLine(value=z, colors=["#64748b"], width=1, label=["Volume z-score"], desc=["how many standard deviations volume sits above its trailing mean"])
hline(value=threshold, color="#ef4444", width=1)
// Flag bars whose volume z-score clears the threshold.
var isSpike = isnum(z[0]) && z[0] >= threshold
if (isSpike) {
plotShape(value=z[0], shape="circle", width=6, colors=["#ef4444"], fill=true, label=["Spike"], desc=["volume anomaly above the threshold"])
}The Indicator
import { input, line, lower, none, ohlcv, output, param, shape } from "./sdk/declare";
import { in_volume } from "./gen/inputs";
import { emitRow, out_is_spike, out_spike, out_threshold, out_z } from "./gen/outputs";
import { p_lookback, p_threshold } from "./gen/params";
import { Zscore } from "./sdk/ta";
param("lookback", 50, { min: 10, max: 300, description: "Bars of history that define normal volume" });
param("threshold", 2.5, { min: 1, max: 6, description: "Z-score that counts as a spike" });
input("volume", ohlcv.volume);
output("z", line, lower, { color: "#64748b", width: 1, description: "Volume z-score" });
output("threshold", line, lower, { color: "#ef4444", width: 1 });
output("spike", shape, lower, { color: "#ef4444", shape_where: "is_spike" });
output("is_spike", none);
let zscore = new Zscore(50);
let threshold: f64 = 2.5;
let z: f64 = NaN;
export function init(): void {
zscore = new Zscore(i32(p_lookback()));
threshold = p_threshold();
}
export function state(): i32 {
z = zscore.update(in_volume());
return isNaN(z) ? 0 : 1;
}
export function finalize(): void {
out_z(z);
out_threshold(threshold);
out_spike(z);
out_is_spike(z >= threshold ? 1.0 : 0.0);
emitRow();
}
export function reset(): void {
zscore.reset();
z = NaN;
}How it works
The data. One input("volume", ohlcv.volume) gives the module the chart's own volume, one value per bar, read in state() through the generated in_volume() accessor. That is the only feed the detector needs. There is no timeseries to declare and no d.volume[0] to index: state() sees this bar's value and nothing else.
The score. A z-score answers "how surprising is this number?" You need two reference points: where volume usually sits, and how much it normally wobbles. Zscore from ./sdk/ta is both reference points in one class: update(x) folds one bar into its window and returns (x - mean) / stdev over that window, or 0 when the window is flat. Because it is a ratio, it is comparable across any symbol or timeframe. A whale print on BTC and a thin altcoin both light up at the same number.
The warm-up guard. Until lookback bars have loaded, the class returns NaN, and state() returns 0, which abstains the whole row: the pane stays empty there instead of drawing noise. The kScript needed an isnum ternary to get a quiet line; the Indicator gets the same result by not emitting a row at all (Execution model).
The output. z plots as a line in its own pane (lower). threshold is a line too, written to the same value on every bar, so you can see at a glance how close any bar is to firing: it is the hline. spike is a shape output whose value is the z-score (where the mark sits) and whose shape_where gate is the data-only is_spike output: the mark draws only on bars where the gate is 1. Decisions are numbers here, and the sheet turns them into looks.
What changed in the port
- The
if (isSpike) plotShape(...)block became two outputs: the mark's position (spike) and the decision that gates it (is_spike). An output cannot gate itself, so the gate is its ownnoneoutput. hlineis an output that never changes; its value is the param, written every bar.- The
stepconstraint has no counterpart; settings take any value insidemin..max.labelbecamedescription. - Every output is a metric.
wrun/@you/volume-spike/zandwrun/@you/volume-spike/is_spikeare ids a watch, a screen, or a series read can use once the package is on your machine, which the kScript's plotted series never were.
Customize it
- Sensitivity.
thresholdis the main knob. Drop it toward1.5to catch milder bursts, raise it toward4to keep only genuine anomalies. The red line moves with it. - Memory.
lookbacksets how much history "normal" is measured against. A short window (20) reacts to recent conditions and treats a busy session as the new baseline; a long window (200) compares against a calmer, broader average and flags more. - Two-sided. As written, only high-volume bars fire because the gate is
z >= threshold. To catch unusually quiet bars too, add a second gate output (z <= -threshold) and a secondshapeoutput gated by it. - Color and size. The circle and line colors are hex strings on the declarations.
widthon the line output resizes it;render.shapepicks a different mark kind than the host's default (Drawing primitives). - Turn it into an alert. Publish it, install it on your machine, and put a watch on the z-score crossing the threshold. The condition names the metric id, the market, and the interval;
--cooldownkeeps a noisy hour from firing on every tick:
om wrun install ./volume-spike --replace
om watch create "Volume spike" --condition '{"metric":"wrun/@you/volume-spike/z","selector":{"exchange":"BINANCE_FUTURES","symbol":"BTCUSDT","interval":"HOUR"},"op":"crosses_above","value":3}' --cooldown 1hConcepts used
- Series functions and TA library for
Zscoreover a trailing window - Typed inputs for the
lookbackandthresholdparams - na and scalar types for the
NaNwarm-up rule - Plotting and Drawing primitives for the
lineoutputs and the gatedshape