Swing levels
Turn confirmed swing highs and lows into support and resistance rays that run until price breaks them, then freeze as broken levels.
Old swing highs and lows are where stops collect and where traders expect reactions. This script keeps them on the chart for you. Every confirmed swing high becomes a resistance ray and every swing low a support ray, starting at the swing and running to the right. When price closes through a level, the ray is replaced by a fixed segment that ends at the break, drawn faded and dotted so you can still see where the level was.
Only a set number of active levels per side is kept, so the chart stays readable, and an alert tells you when a level breaks.
script "Swing levels"
input (
left = input.int(10, title: "Swing bars left", min: 1, max: 50)
right = input.int(5, title: "Swing bars right", min: 1, max: 50)
maxLevels = input.int(8, title: "Active levels per side", min: 1, max: 40)
useWicks = input.bool(false, title: "Break on wick instead of close")
keepBroken = input.bool(true, title: "Keep broken levels")
resColor = input.color(color.red, title: "Resistance")
supColor = input.color(color.green, title: "Support")
)
data chart = subscribe(data.ohlcv)
type Level {
id: int
price: float
start: time
}
type Break {
kind: string
level: float
}
state (
highs = Level[](maxLevels)
lows = Level[](maxLevels)
nextId = 0
lines = entities.linePool(max: 300)
tags = entities.labelPool(max: 100)
)
alert levelBreak = alert(title: "Swing level broken", onClose: true, payload: Break)
// A broken level: a faded segment up to the break, or nothing.
fn retire(lv: Level, t: time, tone: color) {
tags.delete(lv.id)
if keepBroken {
lines.get(lv.id).set(lv.start, lv.price, t, lv.price, color: color.withAlpha(tone, 110), style: linestyle.dotted)
} else {
lines.delete(lv.id)
}
}
// A new level: a ray from the swing to the right edge, with its price.
fn draw(lv: Level, t: time, tone: color, prefix: string) {
lines.get(lv.id).set(lv.start, lv.price, t, lv.price, width: 2.0, color: tone, extend: extend.right)
tags.get(lv.id).set(lv.start, lv.price, str.format("{0} {1:.2}", prefix, lv.price), color: tone)
}
on chart.close {
let ph = ta.pivotHigh(chart.high, left, right)
let pl = ta.pivotLow(chart.low, left, right)
let t = chart.time
let swingTime = chart.time[right]
let up = useWicks ? chart.high : chart.close
let down = useWicks ? chart.low : chart.close
if t != null && up != null && down != null {
// 1. Retire resistance levels that price has broken.
let keepHighs = Level[](maxLevels)
for lv in highs {
if up > lv.price {
retire(lv, t, resColor)
levelBreak.trigger(Break { kind: "resistance", level: lv.price })
} else {
keepHighs.push(lv)
}
}
highs = keepHighs
// 2. The same for support.
let keepLows = Level[](maxLevels)
for lv in lows {
if down < lv.price {
retire(lv, t, supColor)
levelBreak.trigger(Break { kind: "support", level: lv.price })
} else {
keepLows.push(lv)
}
}
lows = keepLows
// 3. Add newly confirmed swings, dropping the oldest when full.
if ph != null && swingTime != null {
if highs.isFull {
let old = highs.shift()
if old != null {
lines.delete(old.id)
tags.delete(old.id)
}
}
nextId += 1
let lv = Level { id: nextId, price: ph, start: swingTime }
highs.push(lv)
draw(lv, t, resColor, "R")
}
if pl != null && swingTime != null {
if lows.isFull {
let old = lows.shift()
if old != null {
lines.delete(old.id)
tags.delete(old.id)
}
}
nextId += 1
let lv = Level { id: nextId, price: pl, start: swingTime }
lows.push(lv)
draw(lv, t, supColor, "S")
}
}
}How it works
Confirmed swings only
ta.pivotHigh(chart.high, left, right) returns the swing price once the bar right bars ago is the highest of its neighbourhood. The swing’s time is therefore read with the same offset, chart.time[right], so the ray starts at the actual swing candle even though it is drawn right bars later. Both pivot calls sit at the top of the handler, outside the null check, so they see every bar.
Rays for live levels, segments for history
A line with extend: extend.right continues to the right edge of the chart. Drawing it once, from the swing to the current bar at the same price, is enough; it needs no update on later bars.
When a level breaks, retire redraws the same pool entry as a faded, dotted segment from the swing to the break bar, or deletes it when broken levels are not kept. Each level’s id is its key in both pools, so creation, update and removal always agree.
Keeping the active list
Active levels are stored as Level records in two arrays with capacity maxLevels. Removing elements from an array while looping over it is easy to get wrong, so the script walks the array, handles broken levels, and pushes survivors into a local array, which then replaces the state array:
highs = keepHighsNew swings are appended at the end. When the array is full, shift() removes the oldest level, which is also the one furthest back in time, and its drawings are deleted with it. Pushing into a full array would stop the script, so the isFull check comes first.
If several levels break on the same bar, the alert still fires only once for that bar, because an alert fires at most once per bar.
Functions that draw
retire and draw read the pools and inputs directly; functions see the script’s state and setup values. Broken segments stay until the line pool’s 300 entries are used up; then the oldest is recycled.
Adapting it
- Higher-timeframe swings. Subscribe to
timeframe: 4h, run the pivot logic inon htf.close, and draw from the chart handler. See market structure for that pattern. - Liquidity sweeps instead of breaks. Require a wick through the level and a close back on the original side. That is a sweep of the stops above or below, not a break.
- Touch counter. Add a
touches: intfield toLevel, increment it when the bar’s range reaches the level without breaking it, and show the count in the label. - Volume filter. Only keep swings whose bar had above-average volume, using
ta.sma(chart.volume, 20)evaluated on every bar.
Related
- CVD divergence compares the same swings against delta.
- User-defined types, functions
tareference,entitiesreference