---
title: "Zone Tracker"
description: "Supply and demand zones drawn as boxes that switch themselves off when price mitigates them, with a live count of the zones still active. The kScript (legacy)…"
order: 62
section: "cookbook"
---

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

# Zone Tracker

Supply and demand zones drawn as boxes that switch themselves off when price mitigates them, with a live count of the zones still active. The kScript (legacy) recipe and the Indicator it became, side by side.

Supply and demand zones are price areas where a strong move began. They stay interesting until price trades back through them, at which point they are mitigated and no longer matter. Tracking them by hand means juggling a list of rectangles and remembering to remove each one when it gets invalidated. This recipe does it for you: it finds zones at confirmed pivots, draws each one as a box, and switches the box off the moment price mitigates the zone. It is a tour of the Indicator drawing model working against a kScript that leaned on everything an Indicator does differently: typed structs, a persisted collection, and drawings that delete themselves.

## The kScript (legacy) recipe

```javascript
//@version=2
// ============================================================================
//  SUPPLY/DEMAND ZONE TRACKER  (v3 only)
//  Pure v3 stack in ~50 lines: typed structs + top-level funcs hold zone state
//  across bars, timestamp-based drawing handles paint live boxes that DELETE THEMSELVES when
//  price mitigates the zone, and a table reports the survivors. v2 had no
//  types, no persistent collections, no drawings, no tables.
// ============================================================================
define(title="Zone Tracker (structs + drawings)", position="onchart", axis=false);

var lookback = input(name="lookback", type="number", defaultValue=20, label="Pivot Lookback", constraints={min: 5, max: 100, step: 1});
var maxZones = input(name="maxZones", type="number", defaultValue=6, label="Max Active Zones", constraints={min: 1, max: 12, step: 1});
var supCol   = input(name="supCol", type="color", defaultValue="#22d3a5", label="Demand");
var resCol   = input(name="resCol", type="color", defaultValue="#ff5b7f", label="Supply");

timeseries d = ohlcv(symbol=currentSymbol, exchange=currentExchange);

// A data-only type: state shaped like the problem.
type Zone {
  top: number,
  bottom: number,
  isDemand: boolean,
  bornBar: number,
  handle: any
}

func isZoneMitigated(z, price) {
  if (z.isDemand) {
    return price < z.bottom;
  }
  return price > z.top;
}

persist zones = [];

// New pivot -> new zone with its own live box drawing.
timeseries hi = highest(d.high, lookback);
timeseries lo = lowest(d.low, lookback);
var isPivotHigh = isnum(hi[1]) && d.high[1] >= hi[1] && d.high[0] < d.high[1];
var isPivotLow  = isnum(lo[1]) && d.low[1] <= lo[1] && d.low[0] > d.low[1];

if (isPivotHigh && zones.length < maxZones) {
  var zTop = d.high[1];
  var zBot = math.max(d.open[1], d.close[1]);
  var b = box.new(d.time[1], zTop, d.time[0] + currentInterval * 40, zBot, { color: opacity(resCol, 18), borderColor: resCol });
  zones.push(Zone.new(top=zTop, bottom=zBot, isDemand=false, bornBar=barIndex, handle=b));
}
if (isPivotLow && zones.length < maxZones) {
  var zTop2 = math.min(d.open[1], d.close[1]);
  var zBot2 = d.low[1];
  var b2 = box.new(d.time[1], zTop2, d.time[0] + currentInterval * 40, zBot2, { color: opacity(supCol, 18), borderColor: supCol });
  zones.push(Zone.new(top=zTop2, bottom=zBot2, isDemand=true, bornBar=barIndex, handle=b2));
}

// Mitigated zones remove their own drawing and leave the collection.
var survivors = [];
for (var i = 0; i < zones.length; i = i + 1) {
  var z = zones[i];
  if (isZoneMitigated(z, d.close[0])) {
    z.handle.delete();
  } else {
    survivors.push(z);
  }
}
zones = survivors;

// Live dashboard on the last bar.
if (isLastBar) {
  var rows = [["Zone Tracker", ""], ["Active zones", "".concat(zones.length)]];
  plotTable(data=rows, position="top_right", headerRow=true, backgroundColor="#0d1117", textColor="#e6edf3", fontSize=11);
}
```

## The Indicator

```typescript
import { box, input, none, ohlcv, output, overlay, param } from "./sdk/declare";
import { in_close, in_high, in_low, in_open } from "./gen/inputs";
import {
  emitRow,
  out_active_zones,
  out_demand_bottom,
  out_demand_live,
  out_demand_top,
  out_supply_bottom,
  out_supply_live,
  out_supply_top,
} from "./gen/outputs";
import { p_lookback } from "./gen/params";

param("lookback", 20, { min: 5, max: 100, description: "Bars a swing must dominate to count as a pivot" });
input("open", ohlcv.open);
input("high", ohlcv.high);
input("low", ohlcv.low);
input("close", ohlcv.close);
const demandTop = output("demand_top", none);
const demandBottom = output("demand_bottom", none);
const demandLive = output("demand_live", none);
const supplyTop = output("supply_top", none);
const supplyBottom = output("supply_bottom", none);
const supplyLive = output("supply_live", none);
output("active_zones", none, overlay, { description: "Zones alive on this bar, 0 to 2" });
// One bar wide (from 0 to 0) on every bar the zone is alive: the bars tile into a band that starts
// at the pivot and stops on the bar that mitigates it.
box("demand_zone", { top: demandTop, bottom: demandBottom, when: demandLive, color: "#22d3a5", opacity: 0.18, borderWidth: 0 });
box("supply_zone", { top: supplyTop, bottom: supplyBottom, when: supplyLive, color: "#ff5b7f", opacity: 0.18, borderWidth: 0 });

const MAX_LOOKBACK = 100;
const highs = new StaticArray<f64>(MAX_LOOKBACK);
const lows = new StaticArray<f64>(MAX_LOOKBACK);
let n: i32 = 20;
let cursor: i32 = 0;
let count: i32 = 0;
let windowHigh: f64 = NaN;
let windowLow: f64 = NaN;
let prevOpen: f64 = NaN;
let prevHigh: f64 = NaN;
let prevLow: f64 = NaN;
let prevClose: f64 = NaN;
let dTop: f64 = NaN;
let dBottom: f64 = NaN;
let dLive: f64 = 0.0;
let sTop: f64 = NaN;
let sBottom: f64 = NaN;
let sLive: f64 = 0.0;

export function init(): void {
  n = i32(p_lookback());
}

export function state(): i32 {
  const open = in_open();
  const high = in_high();
  const low = in_low();
  const close = in_close();
  // A pivot is confirmed one bar after it prints: the previous bar led its window and this bar turned back.
  const pivotHigh = !isNaN(windowHigh) && prevHigh >= windowHigh && high < prevHigh;
  const pivotLow = !isNaN(windowLow) && prevLow <= windowLow && low > prevLow;
  // One live zone per side: a new pivot is ignored while the side's zone is still alive.
  if (pivotHigh && sLive == 0.0) {
    sTop = prevHigh;
    sBottom = Math.max(prevOpen, prevClose);
    sLive = 1.0;
  }
  if (pivotLow && dLive == 0.0) {
    dTop = Math.min(prevOpen, prevClose);
    dBottom = prevLow;
    dLive = 1.0;
  }
  // Mitigation: a close through the far edge retires the zone.
  if (dLive == 1.0 && close < dBottom) dLive = 0.0;
  if (sLive == 1.0 && close > sTop) sLive = 0.0;
  // Push this bar into the window and recompute the window extremes.
  highs[cursor] = high;
  lows[cursor] = low;
  cursor = (cursor + 1) % n;
  if (count < n) count += 1;
  windowHigh = NaN;
  windowLow = NaN;
  if (count == n) {
    let h = -Infinity;
    let l = Infinity;
    for (let i = 0; i < n; i++) {
      if (highs[i] > h) h = highs[i];
      if (lows[i] < l) l = lows[i];
    }
    windowHigh = h;
    windowLow = l;
  }
  prevOpen = open;
  prevHigh = high;
  prevLow = low;
  prevClose = close;
  return 1;
}

export function finalize(): void {
  out_demand_top(dTop);
  out_demand_bottom(dBottom);
  out_demand_live(dLive);
  out_supply_top(sTop);
  out_supply_bottom(sBottom);
  out_supply_live(sLive);
  out_active_zones(dLive + sLive);
  emitRow();
}

export function reset(): void {
  cursor = 0;
  count = 0;
  windowHigh = NaN;
  windowLow = NaN;
  prevOpen = NaN;
  prevHigh = NaN;
  prevLow = NaN;
  prevClose = NaN;
  dTop = NaN;
  dBottom = NaN;
  dLive = 0.0;
  sTop = NaN;
  sBottom = NaN;
  sLive = 0.0;
}
```

## How it works

**State shaped like the problem.** Each side has three numbers: the zone's top, its bottom, and whether it is alive. They are outputs, so the sheet can draw from them, and they are module-level variables, so they survive from bar to bar. The kScript bundled the same facts into a `Zone` struct; the Indicator spreads them over named outputs because the box has to read them by name.

**Birth.** `highest` and `lowest` over the lookback window are a ring buffer of highs and lows plus a scan: the two `StaticArray<f64>` buffers are allocated once, at module start, sized from the param's `max`, and `cursor` walks them. A pivot is confirmed one bar after it prints: the previous bar led its window and the current bar turned back. When a pivot fires and that side has no live zone, the zone takes its bounds from the pivot bar's body and wick and its `live` flag flips to `1`.

**Death is a gate turning off.** Every bar checks whether the close went through the zone's far edge. A mitigated zone sets `live` to `0`, and the box's `when` gate stops drawing from that bar on. Nothing is deleted: the bars the zone was alive on keep their slices, which is exactly the history a kScript box erased when it called `delete()`.

**The box.** `box("demand_zone", ...)` with `from` and `to` at `0` draws one bar-wide slice on every bar where `demand_live` is `1`; the slices tile into a band that starts at the pivot and ends on the mitigation bar. `borderWidth: 0` keeps the slices seamless. The coordinates are output handles bound to consts, which is how a box names an output.

**The dashboard.** `active_zones` is `demand_live + supply_live`, a data-only count on every bar. The kScript printed it once, in a table; here it is a metric you can read back or watch.

## What changed in the port

- `persist zones = []` and `box.new(...).delete()` became a fixed set of declared boxes gated per bar. There is no handle to a drawn shape and no collection of them; what a bar draws is decided on that bar ([Drawing objects](../functions/drawing-objects.md)). That is why this port keeps one zone per side, the kScript with `maxZones = 1` per side. More zones are more declared boxes, up to 16 per sheet.
- A mitigated zone stays visible on the bars it was alive on. If you want a zone to vanish from history the way `delete()` did, kScript (legacy) is the engine for that script.
- The `Zone` type and `isZoneMitigated` became module-level variables and two `if` lines. An AssemblyScript `class` would carry the same fields ([User-defined types](../core-concepts/user-defined-types.md)); the outputs are the reason the flat form is simpler here.
- The active-zone table became `active_zones`, a data-only count. It is a metric, so a watch can fire when it changes.

## Customize it

- **Zone frequency.** `lookback` controls how significant a swing must be to spawn a zone. A small value (10) marks many minor pivots, a large one (50) keeps only major swing points. This is the main dial between "lots of zones" and "only the big ones".
- **Mitigation rule.** Right now a single close beyond the zone mitigates it. For a stricter definition, require the close to pass fully through to the far edge, or two consecutive closes, by adding a counter beside each `live` flag.
- **Zone thickness.** The bounds come from the pivot bar's body (`open`/`close`) and wick (`high`/`low`). Use the full candle range for thicker zones by reading `prevHigh` and `prevLow` for both edges, or the body only for tighter ones.
- **More zones.** Duplicate the three outputs and the box per extra slot and fill the first free slot at each pivot; 16 boxes per sheet is the ceiling.
- **Alert on a mitigation.** Publish, install, and watch `active_zones` crossing below `1`: it fires on the bar a zone dies.

```bash
om watch create "Zone died" --condition '{"metric":"wrun/@you/zone-tracker/active_zones","selector":{"exchange":"BINANCE_FUTURES","symbol":"BTCUSDT","interval":"HOUR"},"op":"crosses_below","value":1}'
```

## Concepts used

- [Collections](../core-concepts/collections.md) for the `StaticArray` ring buffers sized from a param's `max`
- [Series functions](../functions/series-functions.md) for `highest` and `lowest` as a window scan
- [Drawing objects](../functions/drawing-objects.md) for boxes with a `when` gate and why nothing is deleted
- [User-defined types](../core-concepts/user-defined-types.md) for the `class` a struct becomes when you want one
- [Core variables](../core-concepts/core-variables.md) for the module-level state that `reset()` restores
