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
//@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
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 = []andbox.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). That is why this port keeps one zone per side, the kScript withmaxZones = 1per 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
Zonetype andisZoneMitigatedbecame module-level variables and twoiflines. An AssemblyScriptclasswould carry the same fields (User-defined types); 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.
lookbackcontrols 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
liveflag. - 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 readingprevHighandprevLowfor 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_zonescrossing below1: it fires on the bar a zone dies.
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 for the
StaticArrayring buffers sized from a param'smax - Series functions for
highestandlowestas a window scan - Drawing objects for boxes with a
whengate and why nothing is deleted - User-defined types for the
classa struct becomes when you want one - Core variables for the module-level state that
reset()restores