You have seen the charts. A clean shaded rectangle sits above price, and a few weeks later price walks up to it and turns around. You want that rectangle on your own charts, drawn automatically, and you want it to disappear from the "active" list once price has clearly blown through it. Then you open the Pine Editor and realise the job is not one plot() call. It needs swing detection, a way to size a box, somewhere to keep every box you have drawn, and rules for when a box is dead.
This guide builds a supply and demand zone indicator in Pine Script v6 from the ground up. By the end you will have a script that finds swing points, draws zones sized by volatility, extends them to the right, retires them when price closes through them, and fires an alert the first time price returns. Along the way you will see why the pivot functions arrive late, how to store drawing objects safely, and how to stay under TradingView's box limit.
What a supply or demand zone actually is in code
Discretionary traders describe a supply zone as an area where selling pressure previously overwhelmed buyers, so price left it quickly. A demand zone is the mirror image. None of that is something a script can measure directly. What a script can measure is price behaviour: a swing high that price fell away from, or a swing low that price rose away from.
So in code, a zone is a rectangle with a left edge, a top, a bottom, and a right edge that keeps growing until something ends it. The interesting decisions are all about where the rectangle comes from and when it ends. Your answers to those two questions define your version of the indicator. The version in this post is one reasonable set of answers, not the definition of the concept.
It is worth being clear about what the result is. The indicator marks places where price reacted in the past, and it flags when price comes back to them. It describes history. It does not tell you what price will do next, and nothing in this post should be read that way.
Finding the swing points with ta.pivothigh and ta.pivotlow
The raw material for a zone is a confirmed swing. Pine gives you ta.pivothigh() and ta.pivotlow(), which each take a source, a number of bars to the left, and a number of bars to the right. A bar is a pivot high if its high is the highest of the bars on both sides within those windows.
The catch is in the right-hand window. Pine cannot know that the bar five bars ago was a swing high until five more bars have printed without beating it. So the function reports the pivot on the bar where it becomes confirmed, and the swing itself sits pivotLen bars back. If you draw at bar_index you are drawing in the wrong place. The detailed version of this is in ta.pivothigh and ta.pivotlow: Why Your Pivot Signals Arrive Late, and the short version is the script below, which marks each swing high where it really happened using a negative offset.
//@version=6
indicator("Pivot delay demo", overlay = true)
pivotLen = input.int(5, "Pivot length", minval = 1)
ph = ta.pivothigh(high, pivotLen, pivotLen)
plotshape(not na(ph), "Confirmed pivot high",
style = shape.triangledown,
location = location.abovebar,
color = color.red,
offset = -pivotLen)Load it on a chart and watch the live edge. The triangle for a new swing only appears after five more bars have closed, and then it is painted five bars to the left. That delay is not a bug and it cannot be removed. It is the price of not repainting. A pivot that was reported earlier would have to be taken back whenever the next bar made a higher high.
Sizing the box with ATR instead of guessing
A pivot gives you one price, but a zone needs a top and a bottom. There are several defensible ways to get the second edge. You can use the body of the pivot candle, the high to the open, or a fixed percentage. Each of these breaks on some instruments. A body-based zone is paper thin on a doji, and a percentage zone is far too tight on a volatile market and far too loose on a quiet one.
Tying the thickness to volatility avoids most of that. ta.atr() returns the average true range, so multiplying it by an input gives a zone height that scales with the instrument and the timeframe. For a swing high the zone runs from the high down by that height. For a swing low it runs from the low up. A multiple around 0.5 to 1.0 is a sensible starting range, and since it is an input you can tighten or widen it per chart without touching the code.
There is one more subtlety. You are drawing the zone on the confirmation bar, but the swing happened pivotLen bars earlier, so the ATR that describes the swing is atrVal[pivotLen], not the current value. Using the current value is a small mistake that quietly makes zones inconsistent between charts.
Storing zones with a user-defined type and an array
Once you draw a handful of boxes you need to keep track of them. Every frame you want to extend the live ones, test whether any have broken, and eventually delete the oldest. A loose collection of var box variables does not scale, and parallel arrays of boxes and flags are easy to desync.
Pine v6 gives you a cleaner option. A user-defined type lets you bundle the box with the facts about it, and an array<Zone> holds as many as you want. Because objects are passed by reference, a function or a loop can change a zone and the change is visible everywhere. If arrays are new to you, Pine Script arrays, matrices, and maps covers the collection types in more depth.
The type for this indicator carries four things: the box itself, whether it is a supply zone, whether it is still active, and whether price has already returned to it once. The last flag is what lets the alert fire on the first return only.
Retiring a zone when price closes through it
Zones that never end are not useful. After a few hundred bars the chart fills with rectangles that price crossed long ago. The rule used here is simple: a supply zone is broken when a bar closes above its top, and a demand zone is broken when a bar closes below its bottom. Using the close rather than the high is deliberate. A wick that pokes through and gets rejected is often the exact behaviour you want to keep the zone for, while a close beyond the edge is a stronger statement.
When a zone breaks, the script stops extending it and greys it out instead of deleting it. That keeps the history visible so you can see how earlier zones behaved, and it keeps the loop trivial because nothing is removed while you iterate.
Staying under the box limit and calculating on confirmed bars
Two practical constraints shape the final structure. First, TradingView caps the number of boxes per script. You raise the cap with max_boxes_count in the indicator() declaration, up to 500, but it is still finite and when you exceed it the oldest boxes vanish silently. A better approach is to set your own limit with an input, and to delete the oldest zone yourself when the array grows past it. The general behaviour of these caps is explained in Pine Script Drawing Objects: Lines, Boxes, and Labels That Do Not Hit the Limit.
Second, everything that creates or breaks a zone should happen on a confirmed bar. On a live bar, the close changes with every tick and the right-hand side of a pivot is still being decided. If you create or retire zones on those ticks you get flicker and, worse, zones that depend on prices that never ended up closing there. Wrapping the logic in if barstate.isconfirmed makes the live bar behave like a historical one. barstate.isconfirmed in Pine Script goes through the trade-offs, including the one-bar delay it introduces.
The complete indicator
Here is everything together. It also adds a departure filter: a swing only becomes a zone if price moved away from it by at least a set multiple of ATR within the confirmation window. That filter is what separates a quiet pause from a real reaction, and it removes a large share of the clutter on lower timeframes.
//@version=6
indicator("Supply and Demand Zones", overlay = true, max_boxes_count = 100)
type Zone
box body
bool isSupply
bool active = true
bool touched = false
pivotLen = input.int(5, "Pivot length", minval = 2)
atrLen = input.int(14, "ATR length", minval = 1)
thickness = input.float(0.6, "Zone thickness (ATR multiple)", minval = 0.1, step = 0.1)
departure = input.float(1.0, "Minimum departure (ATR multiple)", minval = 0.0, step = 0.1)
maxZones = input.int(30, "Maximum zones kept", minval = 2, maxval = 90)
supplyFill = color.new(color.red, 80)
demandFill = color.new(color.green, 80)
deadFill = color.new(color.gray, 90)
var array<Zone> zones = array.new<Zone>()
atrVal = ta.atr(atrLen)
ph = ta.pivothigh(high, pivotLen, pivotLen)
pl = ta.pivotlow(low, pivotLen, pivotLen)
lowestL = ta.lowest(low, pivotLen)
highestH = ta.highest(high, pivotLen)
zoneSize = atrVal[pivotLen] * thickness
newZone(bool isSupply, float level, float height) =>
top = isSupply ? level : level + height
bottom = isSupply ? level - height : level
fillCol = isSupply ? supplyFill : demandFill
edge = isSupply ? color.red : color.green
b = box.new(bar_index - pivotLen, top, bar_index, bottom,
border_color = edge, bgcolor = fillCol)
Zone.new(b, isSupply)
if barstate.isconfirmed
for z in zones
if z.active
box.set_right(z.body, bar_index)
zTop = box.get_top(z.body)
zBottom = box.get_bottom(z.body)
broken = z.isSupply ? close > zTop : close < zBottom
overlap = high >= zBottom and low <= zTop
if broken
z.active := false
box.set_bgcolor(z.body, deadFill)
box.set_border_color(z.body, color.gray)
else if overlap and not z.touched
z.touched := true
alert((z.isSupply ? "Supply" : "Demand") + " zone touched on " + syminfo.ticker,
alert.freq_once_per_bar_close)
if not na(zoneSize)
if not na(ph) and ph - lowestL >= departure * atrVal
array.push(zones, newZone(true, ph, zoneSize))
if not na(pl) and highestH - pl >= departure * atrVal
array.push(zones, newZone(false, pl, zoneSize))
while array.size(zones) > maxZones
oldest = array.shift(zones)
box.delete(oldest.body)A few details are worth walking through, because each one is a place a first attempt usually goes wrong.
The loop runs before the new zones are added. If it ran after, a zone created on this bar could be flagged as touched by the same bar that created it, since the current bar can overlap a price from five bars ago. Doing the maintenance first means a new zone only starts being tested on the next confirmed bar.
The loop uses for z in zones rather than counting an index down from array.size(zones) - 1. When the array is empty that counted loop would run from -1 to 0 and ask for an element that does not exist. The for-in form simply does nothing on an empty array, and since the script never deletes inside the loop there is no index to keep in sync.
The departure test uses lowestL and highestH, which cover the last pivotLen bars, exactly the right-hand window of the pivot. For a swing high, ph - lowestL is how far price fell after it. For a swing low, highestH - pl is how far price rose after it. Both are compared against the current ATR scaled by the input.
Finally, Zone.new(b, isSupply) fills the two flag fields with their defaults, which is why the type declares active = true and touched = false. Adding a new field with a default later will not break any call that already exists.
Tuning it without breaking it
Start by loading the script on a timeframe you know well and compare the zones with the swings you would have marked by hand. If you see too many, raise the departure multiple before touching the pivot length. If zones keep appearing in the middle of ranges, raise the pivot length so only larger swings qualify. If the boxes look too thick to be useful, lower the thickness multiple. Change one input at a time, because they interact.
Expect disagreement with other people's versions of the indicator. Some start the zone at the open of the last down candle before an impulse, others use the full candle range, and others merge overlapping zones into one. Those are design choices, not corrections. The related order block indicator build takes the candle-based approach, and comparing the two is a good way to see how the same idea can be coded differently.
Describing this to an AI so it does not drift
If you would rather not write the script by hand, the quality of the result depends almost entirely on the description. A vague request such as "draw supply and demand zones" leaves every one of the decisions above to chance. A specific request settles them in advance.
Pine Script v6 indicator, overlay. Detect swing highs and lows with ta.pivothigh and ta.pivotlow, pivot length 5 as an input. Create a supply zone from each swing high and a demand zone from each swing low, with thickness equal to 0.6 times the ATR at the swing. Only create the zone if price moved away by at least 1 ATR within the confirmation window. Extend zones to the right, grey them out when a bar closes through them, keep at most 30, and send an alert the first time price re-enters a live zone. Do everything on confirmed bars.
PineScripter's AI editor takes a description like that and writes Pine Script v6. The AI sees the compile errors from TradingView's checks and fixes them itself, so you are not pasting errors back and forth. The follow-up is where it saves the most time. Asking it to add a rule, for example to merge zones that overlap by more than half, changes only the affected lines instead of regenerating the script, and the Code Changes view shows you each edit line by line so you can check it against what you asked for. You still paste the finished script into TradingView, and you should read it before you trust it.
Common mistakes to check for
Drawing at bar_index instead of bar_index - pivotLen puts every zone five bars too far right. Forgetting the confirmed-bar guard produces zones that appear and disappear on the live bar. Using high instead of close for the break test retires zones that were merely wicked. Forgetting max_boxes_count, or leaving it at its default while keeping dozens of zones, deletes your oldest zones without a warning. And keeping a very high zone cap makes the chart unreadable long before it makes it more useful. When a script behaves oddly and no error is shown, how to debug Pine Script when there is no error message walks through a method for finding out why.
Where to take it next
Once the base indicator works you can extend it in several directions. You can add a higher timeframe filter, carefully, using the approach in how request.security works so the higher timeframe data does not repaint. You can colour zones by how many times they were touched. You can add a table with the count of active zones. Each of those is a small, well-defined change, which is exactly the kind of request that works well with line-level editing. The pricing page lists the plans if you want to try that workflow.
Disclaimer: PineScripter is a coding tool for Pine Script development. It does not provide financial advice and does not guarantee trading profits. Always backtest strategies thoroughly and understand the risks before live trading.
Frequently asked questions
Why do my zones appear several bars after the swing?
ta.pivothigh and ta.pivotlow need pivotLen bars on the right to confirm a swing, so the function reports it pivotLen bars late. Draw the zone at bar_index - pivotLen so it sits on the real swing.
Why use the close instead of the high to break a zone?
A wick that pokes through a zone and is rejected is often the behaviour the zone is meant to capture. Requiring a close beyond the edge treats only a bar that settles outside the zone as a break.
How many zones can a Pine Script indicator draw?
A script can draw up to 500 boxes, set with max_boxes_count in the indicator declaration. Past the cap, the oldest boxes are removed, so it is better to enforce your own smaller limit and delete the oldest zone yourself.
Does this indicator predict where price will go?
No. It marks places where price reacted in the past and flags when price returns to them. It describes history only and is not a forecast or advice.
Describe your indicator in plain English and get Pine Script that compiles →
Disclaimer: PineScripter is a coding tool for Pine Script development. It does not provide financial advice and does not guarantee trading profits. Always backtest strategies thoroughly and understand the risks before live trading.