---
title: "Volume Spike Detector"
description: "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…"
order: 56
section: "cookbook"
---

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

# Volume Spike Detector

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

```javascript
//@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

```typescript
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](../core-concepts/execution-model.md)).

**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 own `none` output.
- `hline` is an output that never changes; its value is the param, written every bar.
- The `step` constraint has no counterpart; settings take any value inside `min`..`max`. `label` became `description`.
- Every output is a metric. `wrun/@you/volume-spike/z` and `wrun/@you/volume-spike/is_spike` are 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.** `threshold` is the main knob. Drop it toward `1.5` to catch milder bursts, raise it toward `4` to keep only genuine anomalies. The red line moves with it.
- **Memory.** `lookback` sets 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 second `shape` output gated by it.
- **Color and size.** The circle and line colors are hex strings on the declarations. `width` on the line output resizes it; `render.shape` picks a different mark kind than the host's default ([Drawing primitives](../functions/drawing-primitives.md)).
- **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; `--cooldown` keeps a noisy hour from firing on every tick:

```bash
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 1h
```

## Concepts used

- [Series functions](../functions/series-functions.md) and [TA library](../functions/ta-library.md) for `Zscore` over a trailing window
- [Typed inputs](../functions/typed-inputs.md) for the `lookback` and `threshold` params
- [na and scalar types](../core-concepts/na-and-scalar-types.md) for the `NaN` warm-up rule
- [Plotting](../functions/plotting.md) and [Drawing primitives](../functions/drawing-primitives.md) for the `line` outputs and the gated `shape`
