Market structure
Build supply and demand zones from confirmed higher-timeframe swings, extend them on the chart, and react when the chart timeframe breaks out of them.
Higher-timeframe swings carry more weight than the ones on your trading chart. A four-hour swing high marks a place where sellers took control for hours, and a close above it on your 15-minute chart is a break of structure worth knowing about.
This script finds confirmed swing highs and lows on a higher timeframe and turns each into a zone: from the swing candle’s extreme to its body. Supply zones sit above price, demand zones below. The zones extend bar by bar on the chart timeframe until a chart bar closes beyond them. At that point the zone is frozen and faded, a breakout marker appears, the script’s structure bias flips, and an alert fires.
script "Market structure"
input (
zoneTf = input.timeframe(4h, title: "Zone timeframe")
left = input.int(3, title: "Swing bars left", min: 1, max: 20)
right = input.int(3, title: "Swing bars right", min: 1, max: 20)
maxZones = input.int(6, title: "Zones per side", min: 1, max: 30)
)
data (
chart = subscribe(data.ohlcv)
htf = subscribe(data.ohlcv, timeframe: zoneTf)
)
type Zone {
id: int
top: float
bottom: float
start: time
}
type BreakPayload {
direction: string
level: float
}
state (
supply = Zone[](maxZones)
demand = Zone[](maxZones)
nextId = 0
bias = 0
brokeUp = false
brokeDown = false
boxes = entities.boxPool(max: 200)
)
plot (
upBreak = plot.marker(title: "Break up", color: color.green, shape: shape.up, size: 6.0)
downBreak = plot.marker(title: "Break down", color: color.red, shape: shape.down, size: 6.0)
)
alert structureBreak = alert(title: "Structure break", onClose: true, payload: BreakPayload)
// Draw a zone up to `t`: solid while live, faded once broken.
fn paint(z: Zone, t: time, tone: color, text: string, live: bool) {
boxes.get(z.id).set(z.start, z.top, t, z.bottom, color: color.withAlpha(tone, live ? 45 : 15), borderWidth: 1.0, borderColor: color.withAlpha(tone, live ? 255 : 60), text: text)
}
// Zones come from confirmed swings on the zone timeframe only.
on htf.close {
let ph = ta.pivotHigh(htf.high, left, right)
let pl = ta.pivotLow(htf.low, left, right)
let t = htf.time[right]
let o = htf.open[right]
let c = htf.close[right]
if t != null && o != null && c != null {
if ph != null {
if supply.isFull {
let old = supply.shift()
if old != null {
boxes.delete(old.id)
}
}
nextId += 1
supply.push(Zone { id: nextId, top: ph, bottom: math.max(o, c), start: t })
}
if pl != null {
if demand.isFull {
let old = demand.shift()
if old != null {
boxes.delete(old.id)
}
}
nextId += 1
demand.push(Zone { id: nextId, top: math.min(o, c), bottom: pl, start: t })
}
}
}
// Extend live zones and detect breakouts on the chart timeframe.
on chart.close {
brokeUp = false
brokeDown = false
let c = chart.close
let t = chart.time
if c != null && t != null {
let keepSupply = Zone[](maxZones)
for z in supply {
if c > z.top {
brokeUp = true
paint(z, t, color.red, "Broken supply", false)
structureBreak.trigger(BreakPayload { direction: "up", level: z.top })
} else {
paint(z, t, color.red, "Supply", true)
keepSupply.push(z)
}
}
supply = keepSupply
let keepDemand = Zone[](maxZones)
for z in demand {
if c < z.bottom {
brokeDown = true
paint(z, t, color.green, "Broken demand", false)
structureBreak.trigger(BreakPayload { direction: "down", level: z.bottom })
} else {
paint(z, t, color.green, "Demand", true)
keepDemand.push(z)
}
}
demand = keepDemand
}
bias = brokeUp ? 1 : (brokeDown ? -1 : bias)
upBreak.plot(brokeUp ? chart.low : null)
downBreak.plot(brokeDown ? chart.high : null)
let label = match bias {
1 => "bullish"
-1 => "bearish"
_ => "undecided"
}
script.setTitle(str.format("Market structure: {0}", label))
}How it works
Two handlers, two jobs
The script splits its work by timeframe. on htf.close runs once per confirmed zone-timeframe bar and only creates zones. on chart.close runs on every chart bar and only extends zones and checks for breaks. The two communicate through the supply and demand arrays in state.
Because zones come from htf.close, a zone appears only after its higher-timeframe swing is confirmed, right bars later on that timeframe. A zone never appears and then disappears because a forming four-hour candle changed.
Zone geometry
The swing candle is right bars back on the zone timeframe, so its time, open and close are read with that offset:
let t = htf.time[right]
let o = htf.open[right]
let c = htf.close[right]A supply zone runs from the swing high down to the top of the candle’s body, math.max(o, c). A demand zone runs from the swing low up to the bottom of the body. Wick-to-body zones are a common choice because the wick is where the rejection happened.
Extending and freezing
Each chart bar redraws every live zone with the same pool key, its id, and the current bar time as its right edge. Setting a pool entry again moves it instead of creating a new one. When a close breaks a zone, it is drawn one last time, faded, ending at the break bar, and is not pushed into the survivor list. From then on nothing touches it again, so it stays on the chart as history.
The loop cost is small: at most 2 * maxZones boxes per bar. Broken zones stay until the pool’s 200 boxes are used up; then the oldest is recycled.
Flags in state
Locals are immutable, so the loop cannot set a local “broke up” flag. The script uses two state booleans instead and resets them at the start of each bar. After the loops they drive the markers and the structure bias, which keeps its previous value on bars without a break.
Adapting it
- Retests. Add a
touched: boolfield toZoneand trigger an alert the first time price trades back into a live zone without closing through it. - Order-flow confirmation. Require the breakout bar’s delta, from
subscribe(data.vd), to point in the breakout direction before you count it. See VD absorption. - Zone strength. Store the swing candle’s volume in the zone and scale the fill opacity with
color.gradient. - Several timeframes. Duplicate the HTF subscription and handler for
1D, and tag zones from each timeframe with a different colour.
Related
- Swing levels does the same on the chart timeframe with rays.
- Multiple timeframes, user-defined types
entitiesreference,tareference