What it does
Supply and demand zones mark areas where price paused briefly and then left in a hurry. The pause, a few small "base" candles, is drawn as a zone. A base followed by a strong rally becomes a demand zone and a base followed by a strong drop becomes a supply zone. Each zone is then tracked as fresh (not revisited), tested (price came back to it) or broken (price closed through it).
How it works
- A departure candle is a closed candle whose body is at least the set multiple of ATR (14). A bullish departure can create demand and a bearish one can create supply.
- The script counts the consecutive small candles right before it: each must have a body of no more than the base multiple of ATR, up to the maximum base length. At least one base candle is needed.
- Demand zone: from the lowest low of the base up to the highest body top of the base. Supply zone: from the lowest body bottom of the base up to the highest high. The departure candle must close beyond the zone.
- Optionally, a zone that overlaps an existing active zone of the same type is skipped to avoid stacking.
- On every closed candle, active zones are updated:
- Tested: price comes back into the zone after having been away. The test count goes up and the label changes, for example "Demand (tested 1x)".
- Broken: a candle closes beyond the far side of the zone (below a demand zone or above a supply zone).
- Retired: once the test limit is reached and price leaves again.
Broken and retired zones stop extending and turn grey, or are deleted. All lists are capped.
How to use it
- Fresh zones are the ones most supply and demand traders focus on, on the idea that orders left behind have not yet been filled. Each test is often seen as weakening a zone.
- Higher timeframes give fewer, larger zones. A common workflow is to mark H4 or daily zones and look for entries on a lower timeframe as price reaches them.
- Zones are areas, not exact prices. Many traders place stops beyond the far edge and use the Position Size Calculator to keep risk fixed.
- For the general idea of price levels, see the lesson on support and resistance.
Alerts: new demand zone, new supply zone, demand tested, supply tested, demand broken, supply broken.
Settings
| Input | Default | What it changes |
|---|---|---|
| ATR length | 14 | ATR used by the size rules |
| Departure candle: min body (x ATR) | 1.5 | Strength of the move away |
| Base candle: max body (x ATR) | 0.5 | What counts as a pause |
| Max base candles | 3 | Base length |
| Skip zones overlapping an active zone | On | Avoids stacked zones |
| Retire zone after this many tests | 3 | Lifetime of a zone |
| Keep broken zones (greyed) | On | Grey history |
| Max active zones per side | 10 | Cap on live zones |
Limitations
Zone rules are a simplification: different traders draw zones differently, and this script's thresholds will miss some zones you would mark by hand and include others you would not. A strong departure is only known after it closes, so zones appear one candle after the move. Zones can break without any reaction, especially around news. Results depend on ATR, so a sudden volatility change shifts what counts as a base or a departure.
Pine Script source
In TradingView open Pine Editor, paste the code, click Save and then Add to chart.
FXP_SupplyDemandZones.pine
188 lines
// © FXPIPS ONLINE — https://fxpips.online/tradingview-indicators/supply-demand-zones/
// License: MIT. Educational tool — not financial advice. Trading involves substantial risk.
//@version=6
// Finds base candles (small bodies) that come right before a strong departure candle
// and draws them as demand (before a rally) or supply (before a drop) zones. Each zone is
// tracked as fresh, tested or broken. Everything is evaluated on closed candles.
indicator("FXP Supply and Demand Zones", shorttitle = "FXP S/D Zones", overlay = true, max_boxes_count = 500)
// ── Inputs ─────────────────────────────────────────────────────────────
grpDet = "Detection"
atrLen = input.int(14, "ATR length", minval = 1, group = grpDet)
departMult = input.float(1.5, "Departure candle: min body (x ATR)", minval = 0.5, step = 0.1, group = grpDet)
baseMult = input.float(0.5, "Base candle: max body (x ATR)", minval = 0.1, step = 0.05, group = grpDet)
maxBase = input.int(3, "Max base candles", minval = 1, maxval = 6, group = grpDet)
skipOver = input.bool(true, "Skip zones overlapping an active zone", group = grpDet)
grpSt = "States"
maxTests = input.int(3, "Retire zone after this many tests", minval = 1, maxval = 10, group = grpSt)
keepBroken = input.bool(true, "Keep broken zones (greyed)", group = grpSt)
grpDisp = "Display"
maxZones = input.int(10, "Max active zones per side", minval = 1, maxval = 50, group = grpDisp)
maxBroken = input.int(10, "Max broken zones kept", minval = 0, maxval = 100, group = grpDisp)
colDemand = input.color(#26a69a, "Demand", group = grpDisp)
colSupply = input.color(#ef5350, "Supply", group = grpDisp)
colBroken = input.color(color.gray, "Broken / retired", group = grpDisp)
// ── Types and storage ──────────────────────────────────────────────────
type Zone
box bx
float top
float bottom
bool isDemand
int tests
bool away
var array<Zone> demand = array.new<Zone>()
var array<Zone> supply = array.new<Zone>()
var array<Zone> broken = array.new<Zone>()
zoneText(Zone z) =>
string kind = z.isDemand ? "Demand" : "Supply"
z.tests == 0 ? kind + " (fresh)" : kind + " (tested " + str.tostring(z.tests) + "x)"
zoneColor(Zone z) =>
color base = z.isDemand ? colDemand : colSupply
z.tests == 0 ? color.new(base, 78) : color.new(base, 90)
retireZone(Zone z) =>
if keepBroken and maxBroken > 0
z.bx.set_right(bar_index)
z.bx.set_bgcolor(color.new(colBroken, 90))
z.bx.set_border_color(color.new(colBroken, 70))
z.bx.set_text_color(colBroken)
broken.push(z)
while broken.size() > maxBroken
Zone old = broken.shift()
old.bx.delete()
else
z.bx.delete()
true
bool conf = barstate.isconfirmed
float atr = ta.atr(atrLen)
// ── Update existing zones ──────────────────────────────────────────────
bool demandTested = false
bool supplyTested = false
bool demandBroken = false
bool supplyBroken = false
if demand.size() > 0
for i = demand.size() - 1 to 0
Zone z = demand.get(i)
if conf
if close < z.bottom
demandBroken := true
z.bx.set_text("Demand (broken)")
demand.remove(i)
retireZone(z)
continue
if low <= z.top
if z.away
z.tests := z.tests + 1
z.away := false
demandTested := true
z.bx.set_text(zoneText(z))
z.bx.set_bgcolor(zoneColor(z))
else
z.away := true
if z.tests >= maxTests and z.away
z.bx.set_text("Demand (retired)")
demand.remove(i)
retireZone(z)
continue
z.bx.set_right(bar_index)
if supply.size() > 0
for i = supply.size() - 1 to 0
Zone z = supply.get(i)
if conf
if close > z.top
supplyBroken := true
z.bx.set_text("Supply (broken)")
supply.remove(i)
retireZone(z)
continue
if high >= z.bottom
if z.away
z.tests := z.tests + 1
z.away := false
supplyTested := true
z.bx.set_text(zoneText(z))
z.bx.set_bgcolor(zoneColor(z))
else
z.away := true
if z.tests >= maxTests and z.away
z.bx.set_text("Supply (retired)")
supply.remove(i)
retireZone(z)
continue
z.bx.set_right(bar_index)
// ── Detect new zones ───────────────────────────────────────────────────
float bodyNow = math.abs(close - open)
bool departUp = conf and close > open and bodyNow >= departMult * nz(atr)
bool departDn = conf and close < open and bodyNow >= departMult * nz(atr)
// count consecutive base candles right before the departure candle
int baseCount = 0
if departUp or departDn
for i = 1 to maxBase
if math.abs(close[i] - open[i]) <= baseMult * nz(atr)
baseCount += 1
else
break
overlaps(array<Zone> zones, float t, float b) =>
bool hit = false
if zones.size() > 0
for j = 0 to zones.size() - 1
Zone z = zones.get(j)
if t >= z.bottom and b <= z.top
hit := true
hit
bool newDemand = false
bool newSupply = false
if baseCount > 0
float bodyTop = math.max(open[1], close[1])
float bodyBot = math.min(open[1], close[1])
float hiWick = high[1]
float loWick = low[1]
for i = 1 to baseCount
bodyTop := math.max(bodyTop, math.max(open[i], close[i]))
bodyBot := math.min(bodyBot, math.min(open[i], close[i]))
hiWick := math.max(hiWick, high[i])
loWick := math.min(loWick, low[i])
if departUp
float t = bodyTop
float b = loWick
if t > b and close > t and not (skipOver and overlaps(demand, t, b))
box bx = box.new(bar_index - baseCount, t, bar_index, b, border_color = color.new(colDemand, 50), bgcolor = color.new(colDemand, 78), text = "Demand (fresh)", text_color = colDemand, text_size = size.small, text_halign = text.align_right)
demand.push(Zone.new(bx, t, b, true, 0, true))
newDemand := true
while demand.size() > maxZones
Zone old = demand.shift()
old.bx.delete()
if departDn
float t = hiWick
float b = bodyBot
if t > b and close < b and not (skipOver and overlaps(supply, t, b))
box bx = box.new(bar_index - baseCount, t, bar_index, b, border_color = color.new(colSupply, 50), bgcolor = color.new(colSupply, 78), text = "Supply (fresh)", text_color = colSupply, text_size = size.small, text_halign = text.align_right)
supply.push(Zone.new(bx, t, b, false, 0, true))
newSupply := true
while supply.size() > maxZones
Zone old = supply.shift()
old.bx.delete()
// ── Alerts ─────────────────────────────────────────────────────────────
alertcondition(newDemand, "New demand zone", "{{ticker}} {{interval}}: new demand zone")
alertcondition(newSupply, "New supply zone", "{{ticker}} {{interval}}: new supply zone")
alertcondition(demandTested, "Demand zone tested", "{{ticker}} {{interval}}: price returned to a demand zone at {{close}}")
alertcondition(supplyTested, "Supply zone tested", "{{ticker}} {{interval}}: price returned to a supply zone at {{close}}")
alertcondition(demandBroken, "Demand zone broken", "{{ticker}} {{interval}}: demand zone broken at {{close}}")
alertcondition(supplyBroken, "Supply zone broken", "{{ticker}} {{interval}}: supply zone broken at {{close}}")

