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.
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 wrun drawing model: state in module-level variables, boxes that read their bounds from outputs, and a gate that switches a drawing off.
The wrun indicator
param("lookback", 20, { min: 5, max: 100, description: "Bars a swing must dominate to count as a pivot" });
input("open", ohlcv.open); // the first input is the bar grid: the chart's own candles
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;
function onStart(): void {
n = i32(p_lookback());
}
function onBar(): void {
const open = bar.open();
const high = bar.high();
const low = bar.low();
const close = bar.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;
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);
}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 indicator spreads them over named outputs because the box has to read them by name.
Birth. The window's highest high and lowest low come from 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.
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. Every bar carries it, readable at the editor's Console prompt.
Design notes
- The zones are a fixed set of declared boxes gated per bar: what a bar draws is decided on that bar (Drawing objects). That is why this indicator keeps one zone per side. More zones are more declared boxes, up to 16 per sheet. Box handles, which the file creates, moves and deletes by id, are the other form: Supply and demand zones keeps a ring of them per side.
- A mitigated zone stays visible on the bars it was alive on. For a zone that vanishes from the chart when it breaks, draw it as a box handle and delete the handle on the mitigation bar.
- Each zone's facts are module-level variables and its mitigation check is one
ifline. An AssemblyScriptclasswould carry the same fields (User-defined types); the outputs are the reason the flat form is simpler here.
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.
active_zonesis data-only, so the alert dialog does not offer it as a condition. Add a data-only gate written1on the bar a zone dies, bind it to aconst, and declarealert("zone_died", { when: ... }): once the indicator is published and on a chart, the signal is in the chart's alert dialog (Alerts).
Run it
- In the editor's Explorer, press New indicator and pick Blank indicator: the tab holds the
//@lang=wrun-tsline alone. - Paste the indicator block above under that line and press Run.
- At the editor's Console prompt, type
last 20 active_zonesto read how many zones were alive on each of the last 20 bars.
Concepts used
- Collections for the
StaticArrayring buffers sized from a param'smax - Series functions for
HighestandLowest, the one-call form of the window scan - Drawing objects for boxes with a
whengate and why nothing is deleted - User-defined types for the
classthat carries a zone's fields when you want one - Core variables for the module-level state that survives from bar to bar