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. Each zone is a typed struct in a persisted collection, drawn on the chart as a box, and the box deletes itself the moment price mitigates the zone. A live table reports how many are still active. It is a tour of the whole v3 stack working together: structs, collections that survive across bars, self-managing drawings, and a dashboard.
//@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);
}How it works
State shaped like the problem. The Zone type bundles everything a zone needs to know about itself: its top and bottom price, whether it is demand or supply, the bar it was bornBar, and a handle to its own box drawing. Instead of four parallel arrays you maintain one collection of self-describing objects. The isZoneMitigated function reads naturally because a zone carries its own orientation: a demand zone breaks when price falls below its bottom, a supply zone when price rises above its top.
A collection that survives. persist zones = [] is the heart of the script. A normal variable resets every bar, but a persisted array keeps its contents across the whole run, so zones born hundreds of bars ago are still in the list now. This is what lets the script accumulate and manage a living set of zones rather than recomputing from scratch each bar.
Birth. A new zone is created at a confirmed pivot. highest and lowest over the lookback window find swing extremes, and the pivot conditions check the prior bar against them while requiring the current bar to turn back. When a pivot fires and the list has room (zones.length < maxZones), the script measures the zone's bounds, draws a box.new() for it, and pushes a fresh Zone holding that box's handle. The box is drawn with a faint fill and a solid border and extends a fixed number of intervals into the future so it is visible ahead of price.
Death is self-managed. Every bar, the script walks the zones and asks each one whether price has mitigated it. A mitigated zone calls z.handle.delete() to remove its own box from the chart and is dropped from the rebuilt survivors list. Survivors are kept. Then zones = survivors commits the pruned set. Because each zone owns its drawing handle, cleanup is local: the zone that dies is the one that erases its rectangle. No bookkeeping of which drawing belonged to which zone, no leaks.
The dashboard. On the last bar a small plotTable reports the active count, so you get a live read on how many zones are currently in play without counting boxes by eye.
Render note. Zones are drawn with the
boxdrawing channel. If your chart renders box drawings, you see the shaded supply and demand rectangles appear at pivots and vanish on mitigation. The active-zone table reports the same state in text, so the count is readable even where boxes are not.
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." - Clutter control.
maxZonescaps how many can be active at once. Once full, new pivots are ignored until older zones get mitigated and free up a slot. Raise it if you want a denser map, lower it for a cleaner chart. - Mitigation rule. Right now a single close beyond the zone mitigates it. For a stricter definition, change
isZoneMitigatedto require the close to pass fully through to the far edge, or to require two consecutive closes beyond it. - 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, or the body only for tighter ones, by changing which priceszTop/zBotread. - Box length. Each box extends
currentInterval * 40into the future. Increase the multiplier to project zones further ahead, decrease it to keep them compact. - Colors.
supCol(demand) andresCol(supply) set both the box fill and border. The faint fill is derived withopacity(), so changing the base color restyles the whole zone. - Alert on touch. Add an
alert()whend.closeenters an active zone to be notified when price reaches one of your levels.
Concepts used
- User-defined types for the
Zonestruct and its fields - Collections for the array of zones,
push, and iteration - Execution model for the
persistcollection that survives across bars - Drawing objects for
box.new()and the self-managing.delete() - User functions for the
isZoneMitigatedhelper - Plotting for the live
plotTabledashboard