
QuaScript Documentation
Language guide, engine API, 601 builtins and the preset library · v3.0.0-phase1
Volatility
Adaptive ATR Bands
analysis · presets/analysis/volatility/adaptive_atr_bands.qs
Keltner-style bands whose width adapts to where volatility stands in its own history. The ATR multiplier slides from the tight setting (volatility in its lowest percentiles) to the wide one (highest), so bands hug price in quiet markets and give room in wild ones. Three layers (inner, middle, outer) are drawn as GPU gradients; touches of the outer layer against the basis slope are marked as exhaustion, closes beyond it with the slope as expansion.
analysis "Adaptive ATR Bands" {
overlay = true
render = webgpu
short_title = "AAB"
}
/// Keltner-style bands whose width adapts to where volatility stands in its own history. The ATR
/// multiplier slides from the tight setting (volatility in its lowest percentiles) to the wide one
/// (highest), so bands hug price in quiet markets and give room in wild ones. Three layers (inner,
/// middle, outer) are drawn as GPU gradients; touches of the outer layer against the basis slope
/// are marked as exhaustion, closes beyond it with the slope as expansion.
len = param.int(34, title = "Basis length", min = 2, max = 500)
lo_k = param.float(1.2, title = "Tight multiplier", min = 0.2, max = 10, step = 0.1)
hi_k = param.float(3.0, title = "Wide multiplier", min = 0.5, max = 15, step = 0.1)
p_len = param.int(250, title = "Percentile window", min = 20, max = 5000)
basis = ta.ema(close, len)
a = ta.atr(len)
rank = stat.percent_rank(a, p_len) / 100
k = lo_k + (hi_k - lo_k) * math.coalesce(rank, 0.5)
pb = render.line(basis, "Basis", color = basis > basis[1] ? color.sky.300 : color.amber.300, width = 1.6)
pu3 = render.line(basis + k * a, "Outer upper", color = color.translucent(color.rose.300, 50), width = 1)
pu1 = render.line(basis + k * a / 3, "Inner upper", color = color.transparent, width = 0)
pl1 = render.line(basis - k * a / 3, "Inner lower", color = color.transparent, width = 0)
pl3 = render.line(basis - k * a, "Outer lower", color = color.translucent(color.emerald.300, 50), width = 1)
render.band(pu3, pu1, texture = "gradient", direction = "edges", upper_color = color.rose.500, lower_color = color.indigo.950, opacity = 0.25, title = "Upper layer")
render.band(pu1, pl1, color = color.slate.500, opacity = 0.04, title = "Core")
render.band(pl1, pl3, texture = "gradient", direction = "edges", upper_color = color.indigo.950, lower_color = color.emerald.500, opacity = 0.25, title = "Lower layer")
rising = basis > basis[1]
exp_up = close > basis + k * a and rising
exp_dn = close < basis - k * a and not rising
exh_up = high > basis + k * a and close < basis + k * a and not rising
exh_dn = low < basis - k * a and close > basis - k * a and rising
render.mark(exp_up and not exp_up[1], shape = shape.arrow_up, at = anchor.above_bar, color = color.sky.300, size = 0.6, title = "Expansion up")
render.mark(exp_dn and not exp_dn[1], shape = shape.arrow_down, at = anchor.below_bar, color = color.amber.300, size = 0.6, title = "Expansion down")
render.mark(exh_up, shape = shape.x, at = anchor.above_bar, color = color.rose.300, size = 0.5, title = "Exhaustion high")
render.mark(exh_dn, shape = shape.x, at = anchor.below_bar, color = color.emerald.300, size = 0.5, title = "Exhaustion low")ATR Trailing Fan
analysis · presets/analysis/volatility/atr_trail_fan.qs
Four ATR trailing stops (1x .. 4x) that ratchet with the trend and fan out from price. The fan is filled with layered translucent bands, each stop flips side when price closes through it, the number of stops on the bullish side colors the candles and the widest stop flip is marked as the trend change.
analysis "ATR Trailing Fan" {
overlay = true
}
/// Four ATR trailing stops (1x .. 4x) that ratchet with the trend and fan out from price. The
/// fan is filled with layered translucent bands, each stop flips side when price closes through
/// it, the number of stops on the bullish side colors the candles and the widest stop flip is
/// marked as the trend change.
len = param.int(14, title = "ATR length", min = 2, max = 200)
m1 = param.float(1.0, title = "First multiple", min = 0.25, max = 5, step = 0.25)
gap = param.float(1.0, title = "Multiple step", min = 0.25, max = 5, step = 0.25)
a = ta.atr(len)
fn trail(m: float, a: float) -> (float, float) {
persist stop = na
persist dir = 1
up = close - m * a
dn = close + m * a
if math.is_na(stop) {
stop := up
} else if dir > 0 {
stop := math.max(stop, up)
if close < stop {
dir := -1
stop := dn
}
} else {
stop := math.min(stop, dn)
if close > stop {
dir := 1
stop := up
}
}
return (stop, dir)
}
(s1, d1) = trail(m1, a)
(s2, d2) = trail(m1 + gap, a)
(s3, d3) = trail(m1 + 2 * gap, a)
(s4, d4) = trail(m1 + 3 * gap, a)
votes = (d1 > 0 ? 1 : 0) + (d2 > 0 ? 1 : 0) + (d3 > 0 ? 1 : 0) + (d4 > 0 ? 1 : 0)
pc = render.line(close, "Close", color = color.transparent, width = 0)
p1 = render.line(s1, "Stop 1", color = d1 > 0 ? color.emerald.300 : color.rose.300, width = 1)
p2 = render.line(s2, "Stop 2", color = d2 > 0 ? color.emerald.400 : color.rose.400, width = 1)
p3 = render.line(s3, "Stop 3", color = d3 > 0 ? color.emerald.500 : color.rose.500, width = 1.5)
p4 = render.line(s4, "Stop 4", color = d4 > 0 ? color.emerald.600 : color.rose.600, width = 2.5)
render.band(pc, p1, color = color.teal.400, opacity = 0.06, title = "Fan 1")
render.band(p1, p2, color = color.teal.400, opacity = 0.08, title = "Fan 2")
render.band(p2, p3, color = color.teal.500, opacity = 0.1, title = "Fan 3")
render.band(p3, p4, color = color.teal.600, opacity = 0.12, title = "Fan 4")
render.candle_color(color.gradient(votes, 0, 4, color.rose.500, color.emerald.400))
render.mark(d4 > 0 and d4[1] < 0, shape = shape.tag_up, at = anchor.below_bar, color = color.emerald.500, text = "Trend up")
render.mark(d4 < 0 and d4[1] > 0, shape = shape.tag_down, at = anchor.above_bar, color = color.rose.500, text = "Trend down")
alert.rule(d4 > 0 and d4[1] < 0, "Wide stop flipped up", "Price closed above the widest trailing stop")
alert.rule(d4 < 0 and d4[1] > 0, "Wide stop flipped down", "Price closed below the widest trailing stop")Untitled
analysis · presets/analysis/volatility/atr.qs
Average True Range (ATR) Volatility with Surge Marks
analysis "Untitled" {
render = canvas
overlay = false
}
// Average True Range (ATR) Volatility with Surge Marks
len = param.float(14, "length", min=1, max=100)
val = ta.atr(len)
atr_ma = ta.sma(val, 20)
p_atr = render.line(val, "ATR", color=color.amber.500, width=2)
p_ma = render.line(atr_ma, "ATR 20 SMA", color=color.slate.500, width=1)
render.band(p_atr, p_ma, color=color.amber.500, opacity=0.10, title="Volatility Expansion")
render.mark(val > atr_ma * 1.5, shape=shape.star, text="VOLATILITY SURGE", color=color.yellow.500, at=anchor.above_bar)
alert.rule(val > atr_ma * 1.5, "ATR Surge", "ATR is 50% above its 20-period moving average")Untitled
analysis · presets/analysis/volatility/bollinger_bands.qs
Bollinger Bands with Cloud Fill, Squeeze Diamond & Breakout Triangles
analysis "Untitled" {
render = webgl
overlay = true
}
// Bollinger Bands with Cloud Fill, Squeeze Diamond & Breakout Triangles
len = param.float(20, "length", min=2, max=200)
mult = param.float(2.0, "multiplier", min=0.5, max=5.0, step=0.5)
basis = ta.sma(close, len)
dev = mult * stat.stdev(close, len)
upper = basis + dev
lower = basis - dev
width = (upper - lower) / basis
p_upper = render.line(upper, "Upper Band", color=color.blue.500, width=1)
p_basis = render.line(basis, "20 SMA Basis", color=color.amber.500, width=1)
p_lower = render.line(lower, "Lower Band", color=color.blue.500, width=1)
render.band(p_upper, p_lower, color=color.blue.500, opacity=0.12, title="Bollinger Cloud")
is_squeeze = width < ta.lowest(width, 100) * 1.05
render.mark(is_squeeze, shape=shape.diamond, text="SQUEEZE", color=color.yellow.500, at=anchor.below_bar)
render.mark(close > upper, shape=shape.triangle_up, text="BREAKOUT", color=color.emerald.500, at=anchor.above_bar)
render.mark(close < lower, shape=shape.triangle_down, text="BREAKDOWN", color=color.rose.500, at=anchor.below_bar)
alert.rule(close > upper, "BB Upper Breakout", "Price broke above upper Bollinger Band")
alert.rule(close < lower, "BB Lower Breakdown", "Price broke below lower Bollinger Band")Chandelier Exit
analysis · presets/analysis/volatility/chandelier_exit.qs
ATR trailing exits hung from the highest high / lowest low. The active side is drawn as a step line; a flip marks a reversal of the exit regime.
analysis "Chandelier Exit" {
overlay = true
}
/// ATR trailing exits hung from the highest high / lowest low. The active side is drawn as a step
/// line; a flip marks a reversal of the exit regime.
len = param.int(22, title = "ATR length", min = 1, max = 200)
mult = param.float(3.0, title = "ATR multiplier", min = 0.5, max = 10, step = 0.1)
(long_stop, short_stop) = ta.chandelier(len, mult)
persist dir = 1
if close > short_stop[1] { dir := 1 }
if close < long_stop[1] { dir := -1 }
flip = dir != dir[1]
render.step(dir > 0 ? long_stop : na, "Long exit", color = color.emerald.500, width = 2)
render.step(dir < 0 ? short_stop : na, "Short exit", color = color.rose.500, width = 2)
render.mark(flip and dir > 0, shape = shape.circle, at = anchor.below_bar, color = color.emerald.400, text = "Long")
render.mark(flip and dir < 0, shape = shape.circle, at = anchor.above_bar, color = color.rose.400, text = "Short")
alert.rule(flip, "Chandelier flip", "The exit regime reversed")Volatility Compression Breakout
analysis · presets/analysis/volatility/compression_breakout.qs
Compression is measured two ways and ranked against its own history: the percentile of Bollinger width and the percentile of the recent average range relative to ATR over a longer window. Their mean is the compression score (0 = widest, 100 = tightest), drawn as a gradient area. Thrust is the normalised slope of a short regression of price, in ATR per bar, drawn as a gradient histogram. A release mark fires when the score falls out of the compressed zone with thrust beyond a threshold, giving the side of the breakout.
analysis "Volatility Compression Breakout" {
overlay = false
}
/// Compression is measured two ways and ranked against its own history: the percentile of
/// Bollinger width and the percentile of the recent average range relative to ATR over a longer
/// window. Their mean is the compression score (0 = widest, 100 = tightest), drawn as a
/// gradient area. Thrust is the normalised slope of a short regression of price, in ATR per
/// bar, drawn as a gradient histogram. A release mark fires when the score falls out of the
/// compressed zone with thrust beyond a threshold, giving the side of the breakout.
len = param.int(20, title = "Width length", min = 5, max = 200, group = "Compression")
rank_len = param.int(250, title = "Rank window", min = 50, max = 2000, group = "Compression")
tight = param.float(80, title = "Compressed above", min = 50, max = 99, group = "Compression")
th_len = param.int(12, title = "Thrust length", min = 3, max = 100, group = "Thrust")
th_min = param.float(0.08, title = "Release thrust (ATR / bar)", min = 0, max = 1, step = 0.01, group = "Thrust")
bbw = ta.bollinger_width(close, len, 2)
range_ratio = ta.sma(ta.true_range(), 5) / math.max(ta.atr(len * 5), 1e-12)
score = 100 - (stat.percent_rank(bbw, rank_len) + stat.percent_rank(range_ratio, rank_len)) / 2
(_, slope, _) = stat.linreg(close, th_len, 0)
thrust = slope / math.max(ta.atr(len), 1e-12)
compressed = score >= tight
st = math.clamp(score / 100, 0, 1)
render.area(score, "Compression", color = color.gradient(st, 0, 1, color.slate.700, color.violet.400), pane = 1)
render.level(tight, "Compressed", color = color.violet.800, pane = 1)
tt = math.clamp(math.abs(thrust) / (th_min * 3), 0, 1)
render.histogram(thrust, "Thrust", color = thrust >= 0 ? color.gradient(tt, 0, 1, color.slate.700, color.cyan.300) : color.gradient(tt, 0, 1, color.slate.700, color.pink.400))
render.level(th_min, "Release up", color = color.cyan.900)
render.level(-th_min, "Release down", color = color.pink.900)
render.level(0, "Zero", color = color.slate.600, stroke = style.dotted)
render.dots(0, "Compressed state", color = compressed ? color.violet.400 : color.transparent, shape = shape.diamond, size = 2)
released = compressed[1] and not compressed
release_up = released and thrust > th_min
release_dn = released and thrust < -th_min
render.mark(release_up, shape = shape.arrow_up, at = anchor.bottom, color = color.cyan.300, text = "Release")
render.mark(release_dn, shape = shape.arrow_down, at = anchor.top, color = color.pink.400, text = "Release")
alert.rule(release_up or release_dn, "Compression release", "Volatility left the compressed zone with directional thrust")Volatility Estimator Suite
analysis · presets/analysis/volatility/estimator_suite.qs
Five range-based volatility estimators side by side (close-to-close, Parkinson, Garman-Klass, Rogers-Satchell, Yang-Zhang), all annualised, with a GARCH(1,1) forecast and its long-run level. The ribbon between the lowest and highest estimate shows estimator disagreement; the background warns when GARCH is far above its long-run level.
analysis "Volatility Estimator Suite" {
pane = hpane
height = 0.26
}
/// Five range-based volatility estimators side by side (close-to-close, Parkinson, Garman-Klass,
/// Rogers-Satchell, Yang-Zhang), all annualised, with a GARCH(1,1) forecast and its long-run
/// level. The ribbon between the lowest and highest estimate shows estimator disagreement; the
/// background warns when GARCH is far above its long-run level.
len = param.int(20, title = "Length", min = 5, max = 300)
annual = param.int(252, title = "Bars per year", min = 1, max = 100000)
cc = hft.realized_vol(close, len, annual)
pk = hft.parkinson(len, annual)
gk = hft.garman_klass(len, annual)
rs = hft.rogers_satchell(len, annual)
yz = hft.yang_zhang(len, annual)
(gv, lr) = hft.garch(close, 0.05, 0.9, annual)
lo = math.min(math.min(cc, pk), math.min(math.min(gk, rs), yz))
hi = math.max(math.max(cc, pk), math.max(math.max(gk, rs), yz))
p_hi = render.line(hi, "Highest estimate", color = color.transparent, width = 0)
p_lo = render.line(lo, "Lowest estimate", color = color.transparent, width = 0)
render.band(p_hi, p_lo, texture = "gradient", upper_color = color.fuchsia.500, lower_color = color.sky.500, opacity = 0.18, title = "Disagreement")
render.line(cc, "Close-to-close", color = color.slate.300, width = 1)
render.line(pk, "Parkinson", color = color.sky.400, width = 1.5)
render.line(gk, "Garman-Klass", color = color.violet.400, width = 1.5)
render.line(rs, "Rogers-Satchell", color = color.amber.400, width = 1.5)
render.line(yz, "Yang-Zhang", color = color.emerald.400, width = 2)
render.line(gv, "GARCH forecast", color = color.rose.400, width = 2.5)
render.line(lr, "GARCH long run", color = color.translucent(color.rose.300, 40), width = 1, stroke = style.dashed)
stress = gv > lr * 1.5
render.background(stress ? color.translucent(color.rose.500, 88) : na)
render.mark(stress and not stress[1], shape = shape.diamond, at = anchor.top, color = color.rose.400, title = "Volatility stress")
alert.rule(stress and not stress[1], "Volatility stress", "GARCH forecast is 50% above its long-run level")Hawkes Volatility Clusters
analysis · presets/analysis/volatility/hawkes_clusters.qs
Volatility clusters: large moves excite more large moves. Two self-exciting Hawkes intensities count up-shocks and down-shocks (returns beyond a multiple of ATR) and decay between them. Their difference is drawn as a mirrored area, total intensity as a heat histogram, and the pane warns while a cluster is building.
analysis "Hawkes Volatility Clusters" {
pane = hpane
height = 0.22
}
/// Volatility clusters: large moves excite more large moves. Two self-exciting Hawkes intensities
/// count up-shocks and down-shocks (returns beyond a multiple of ATR) and decay between them.
/// Their difference is drawn as a mirrored area, total intensity as a heat histogram, and the
/// pane warns while a cluster is building.
mult = param.float(1.2, title = "Shock size (ATR)", min = 0.3, max = 5, step = 0.1)
decay = param.float(0.15, title = "Decay per bar", min = 0.01, max = 1, step = 0.01)
a = ta.atr(50)
ch = close - close[1]
up_shock = ch > mult * a[1]
dn_shock = ch < -mult * a[1]
hu = hft.hawkes(up_shock, decay)
hd = hft.hawkes(dn_shock, decay)
total = hu + hd
z = stat.zscore(total, 300)
render.histogram(total, "Cluster intensity", color = color.gradient(z, -1, 3, color.slate.700, color.yellow.300))
render.area(hu, "Up-shock intensity", base = 0, color = color.translucent(color.emerald.400, 55))
render.area(-hd, "Down-shock intensity", base = 0, color = color.translucent(color.rose.400, 55))
render.spline(hu - hd, "Shock balance", color = color.white, width = 1.5)
render.level(0, "Zero", color = color.slate.600)
building = z > 2 and total > total[1]
render.background(building ? color.translucent(color.yellow.500, 88) : na)
render.mark(up_shock, shape = shape.triangle_up, at = anchor.bottom, color = color.emerald.400, size = 0.5, title = "Up shock")
render.mark(dn_shock, shape = shape.triangle_down, at = anchor.top, color = color.rose.400, size = 0.5, title = "Down shock")
alert.rule(building and not building[1], "Volatility cluster", "Shock intensity two sigma above normal and rising")Jump-Robust Volatility Bands
analysis · presets/analysis/volatility/jump_robust_bands.qs
Bands built from bipower variation instead of the standard deviation, so isolated jumps do not blow the bands open the way they do with Bollinger Bands. The share of variance caused by jumps is shown by shading the bands orange; bars with a jump are marked, and the classic two-sigma band is drawn faintly for comparison.
analysis "Jump-Robust Volatility Bands" {
overlay = true
}
/// Bands built from bipower variation instead of the standard deviation, so isolated jumps do not
/// blow the bands open the way they do with Bollinger Bands. The share of variance caused by jumps
/// is shown by shading the bands orange; bars with a jump are marked, and the classic two-sigma
/// band is drawn faintly for comparison.
len = param.int(30, title = "Length", min = 5, max = 300)
mult = param.float(2.0, title = "Multiplier", min = 0.5, max = 5, step = 0.1)
basis = ta.ema(close, len)
(rv, bv, jump) = hft.bipower(close, len)
// bipower variation is robust to jumps, realised variance is not: their ratio removes the jump
// part from the price deviation
sd_plain = stat.stdev(close, len)
sd_robust = rv > 0 ? sd_plain * math.sqrt(math.clamp(bv / rv, 0, 1)) : sd_plain
u = render.line(basis + mult * sd_robust, "Robust upper", color = jump > 0.3 ? color.orange.400 : color.sky.400, width = 2)
d = render.line(basis - mult * sd_robust, "Robust lower", color = jump > 0.3 ? color.orange.400 : color.sky.400, width = 2)
render.band(u, d, texture = "gradient", upper_color = color.sky.500, lower_color = color.indigo.500, opacity = 0.12, title = "Robust band")
render.line(basis, "Basis", color = color.slate.300, width = 1.5)
render.line(basis + mult * sd_plain, "Classic upper", color = color.translucent(color.slate.400, 60), width = 1, stroke = style.dotted)
render.line(basis - mult * sd_plain, "Classic lower", color = color.translucent(color.slate.400, 60), width = 1, stroke = style.dotted)
is_jump = jump > 0.3 and jump > jump[1]
render.mark(is_jump, shape = shape.diamond, at = anchor.above_bar, color = color.orange.400, size = 0.6, title = "Jump")
render.mark(ta.cross_up(close, basis + mult * sd_robust), shape = shape.arrow_up, at = anchor.below_bar, color = color.sky.300, title = "Robust breakout")
render.mark(ta.cross_down(close, basis - mult * sd_robust), shape = shape.arrow_down, at = anchor.above_bar, color = color.indigo.300, title = "Robust breakdown")
alert.rule(is_jump, "Price jump", "A jump dominates realised variance")Range Volatility Bands
analysis · presets/analysis/volatility/parkinson_cone_bands.qs
Bands from three range-based volatility estimators (Parkinson, Garman-Klass, Rogers-Satchell) projected over a horizon from the close. Their disagreement is drawn as the band thickness: when they diverge (drift or gaps distort one of them), the shaded zone widens.
analysis "Range Volatility Bands" {
overlay = true
render = webgpu
short_title = "RVB"
}
/// Bands from three range-based volatility estimators (Parkinson, Garman-Klass, Rogers-Satchell)
/// projected over a horizon from the close. Their disagreement is drawn as the band thickness: when
/// they diverge (drift or gaps distort one of them), the shaded zone widens.
len = param.int(20, title = "Window", min = 5, max = 500)
hz = param.int(10, title = "Horizon (bars)", min = 1, max = 200)
p = hft.parkinson(len, 0) / 100
g = hft.garman_klass(len, 0) / 100
r = hft.rogers_satchell(len, 0) / 100
lo_s = math.min(p, math.min(g, r))
hi_s = math.max(p, math.max(g, r))
c = ta.ema(close, 5)
f = math.sqrt(hz)
pu2 = render.line(c * math.exp(2 * hi_s * f), "Upper (max estimator)", color = color.translucent(color.rose.300, 40), width = 1)
pu1 = render.line(c * math.exp(2 * lo_s * f), "Upper (min estimator)", color = color.translucent(color.rose.300, 70), width = 1)
render.line(c, "Centre", color = color.amber.300, width = 1.2)
pd1 = render.line(c * math.exp(-2 * lo_s * f), "Lower (min estimator)", color = color.translucent(color.emerald.300, 70), width = 1)
pd2 = render.line(c * math.exp(-2 * hi_s * f), "Lower (max estimator)", color = color.translucent(color.emerald.300, 40), width = 1)
render.band(pu2, pu1, color = color.rose.500, opacity = 0.2, title = "Upper disagreement")
render.band(pd1, pd2, color = color.emerald.500, opacity = 0.2, title = "Lower disagreement")Volatility Plasma Bands
analysis · presets/analysis/volatility/plasma_bands.qs
Five nested volatility envelopes around a Hull basis, filled with stacked translucent bands so the core glows and the edges fade. The basis is colored by its volatility z-score, dots mark a compression (band width in its lowest decile) and arrows mark the expansion that follows.
analysis "Volatility Plasma Bands" {
overlay = true
}
/// Five nested volatility envelopes around a Hull basis, filled with stacked translucent bands so
/// the core glows and the edges fade. The basis is colored by its volatility z-score, dots mark a
/// compression (band width in its lowest decile) and arrows mark the expansion that follows.
src = param.source(close, title = "Source")
len = param.int(34, title = "Basis length", min = 5, max = 300)
vlen = param.int(20, title = "Volatility length", min = 5, max = 300)
gap = param.float(0.6, title = "Layer spacing (sigma)", min = 0.1, max = 2, step = 0.05)
basis = ta.hull(src, len)
sd = stat.stdev(src, vlen)
width = sd / math.max(math.abs(basis), 1e-12)
wz = stat.zscore(width, 200)
squeeze = width <= stat.percentile(width, 200, 10)
expand = squeeze[1] and not squeeze and width > width[1]
for k in [1, 2, 3, 4, 5] {
up = render.line(basis + k * gap * sd, str.format("Upper {}", k), color = color.translucent(color.violet.400, 30 + k * 12), width = k == 5 ? 1.5 : 0.8)
dn = render.line(basis - k * gap * sd, str.format("Lower {}", k), color = color.translucent(color.violet.400, 30 + k * 12), width = k == 5 ? 1.5 : 0.8)
render.band(up, dn, color = color.violet.500, opacity = 0.05, title = str.format("Layer {}", k))
}
core = wz >= 0 ? color.gradient(wz, 0, 2.5, color.violet.200, color.fuchsia.400) : color.gradient(wz, -2.5, 0, color.cyan.300, color.violet.200)
render.line(basis, "Basis", color = core, width = 3)
render.dots(squeeze ? basis : na, "Compression", color = color.amber.300, shape = shape.circle, size = 2)
render.mark(expand and close > basis, shape = shape.arrow_up, at = anchor.below_bar, color = color.cyan.300, text = "Expansion")
render.mark(expand and close < basis, shape = shape.arrow_down, at = anchor.above_bar, color = color.fuchsia.400, text = "Expansion")
alert.rule(expand, "Volatility expansion", "Band width left its lowest decile")Volatility Term Structure
analysis · presets/analysis/volatility/vol_term_structure.qs
Realised volatility over five horizons (5 .. 80 bars) drawn as a rainbow, plus the ratio of short to long volatility as a histogram. A ratio above one is backwardation (stress: short-term volatility leads), below one is contango (calm). The background follows the term structure and marks show inversions.
analysis "Volatility Term Structure" {
pane = hpane
height = 0.25
}
/// Realised volatility over five horizons (5 .. 80 bars) drawn as a rainbow, plus the ratio of
/// short to long volatility as a histogram. A ratio above one is backwardation (stress: short-term
/// volatility leads), below one is contango (calm). The background follows the term structure and
/// marks show inversions.
annual = param.int(252, title = "Bars per year", min = 1, max = 100000)
short = param.int(5, title = "Shortest horizon", min = 2, max = 50)
v1 = ta.historical_vol(close, short, annual)
v2 = ta.historical_vol(close, short * 2, annual)
v3 = ta.historical_vol(close, short * 4, annual)
v4 = ta.historical_vol(close, short * 8, annual)
v5 = ta.historical_vol(close, short * 16, annual)
ratio = v1 / math.max(v5, 1e-12)
norm = v5 > 0 ? 1 / v5 : na
render.line(v1 * norm, "Vol short", color = color.rose.400, width = 1.5)
render.line(v2 * norm, "Vol 2x", color = color.orange.400, width = 1.5)
render.line(v3 * norm, "Vol 4x", color = color.amber.300, width = 1.5)
render.line(v4 * norm, "Vol 8x", color = color.lime.300, width = 1.5)
render.line(1, "Vol long", color = color.sky.400, width = 2)
render.histogram(ratio - 1, "Term spread", base = 0, color = ratio >= 1 ? color.translucent(color.rose.500, 40) : color.translucent(color.sky.500, 40))
render.level(0, "Flat", color = color.slate.600, stroke = style.dotted)
inverted = v1 > v2 and v2 > v3 and v3 > v4 and v4 > v5
calm = v1 < v2 and v2 < v3 and v3 < v4 and v4 < v5
render.background(inverted ? color.translucent(color.rose.500, 88) : calm ? color.translucent(color.sky.500, 92) : na)
render.mark(inverted and not inverted[1], shape = shape.diamond, at = anchor.top, color = color.rose.400, title = "Inversion")
alert.rule(inverted and not inverted[1], "Volatility inversion", "Short-term volatility leads every longer horizon")Volatility Cone
analysis · presets/analysis/volatility/volatility_cone.qs
Annualised close-to-close volatility for three windows compared with the 10th / 50th / 90th percentile of its own history. Volatility above the 90th percentile shades the pane.
analysis "Volatility Cone" {
overlay = false
}
/// Annualised close-to-close volatility for three windows compared with the 10th / 50th / 90th
/// percentile of its own history. Volatility above the 90th percentile shades the pane.
annual = param.int(252, title = "Periods per year", min = 1, max = 100000)
hist = param.int(500, title = "Percentile lookback", min = 50, max = 5000)
hv10 = ta.historical_vol(close, 10, annual)
hv20 = ta.historical_vol(close, 20, annual)
hv60 = ta.historical_vol(close, 60, annual)
p10 = stat.percentile(hv20, hist, 10)
p50 = stat.percentile(hv20, hist, 50)
p90 = stat.percentile(hv20, hist, 90)
top = render.line(p90, "90th pct", color = color.rose.700, width = 1, stroke = style.dashed)
render.line(p50, "Median", color = color.slate.500, width = 1, stroke = style.dotted)
bot = render.line(p10, "10th pct", color = color.teal.700, width = 1, stroke = style.dashed)
render.band(top, bot, color = color.slate.500, opacity = 0.06, title = "Normal range")
render.line(hv10, "HV 10", color = color.sky.300, width = 1)
render.line(hv20, "HV 20", color = color.amber.400, width = 2)
render.line(hv60, "HV 60", color = color.violet.400, width = 1)
render.background(hv20 > p90 ? color.translucent(color.rose.500, 88) : hv20 < p10 ? color.translucent(color.teal.500, 88) : na)
alert.rule(ta.cross_up(hv20, p90), "Volatility spike", "20-bar volatility above its 90th percentile")Yang-Zhang Bands
analysis · presets/analysis/volatility/yang_zhang_bands.qs
Bands built from the Yang-Zhang volatility estimator, which uses open, high, low and close and handles overnight gaps, so the bands are tighter and more honest than close-to-close bands on gappy instruments. Bands are drawn at one and two expected one-bar moves scaled to the chosen horizon around an EMA basis.
analysis "Yang-Zhang Bands" {
overlay = true
render = webgpu
short_title = "YZB"
}
/// Bands built from the Yang-Zhang volatility estimator, which uses open, high, low and close and
/// handles overnight gaps, so the bands are tighter and more honest than close-to-close bands on
/// gappy instruments. Bands are drawn at one and two expected one-bar moves scaled to the chosen
/// horizon around an EMA basis.
len = param.int(20, title = "Estimator window", min = 5, max = 500)
hz = param.int(5, title = "Horizon (bars)", min = 1, max = 100)
base = param.int(20, title = "Basis length", min = 2, max = 500)
sig = hft.yang_zhang(len, 0) / 100
b = ta.ema(close, base)
m1 = b * sig * math.sqrt(hz)
p2u = render.line(b + 2 * m1, "+2 move", color = color.translucent(color.rose.300, 40), width = 1)
p1u = render.line(b + m1, "+1 move", color = color.transparent, width = 0)
pb = render.line(b, "Basis", color = color.amber.300, width = 1.5)
p1d = render.line(b - m1, "-1 move", color = color.transparent, width = 0)
p2d = render.line(b - 2 * m1, "-2 move", color = color.translucent(color.emerald.300, 40), width = 1)
render.band(p2u, p1u, texture = "gradient", direction = "edges", upper_color = color.rose.500, lower_color = color.slate.900, opacity = 0.2, title = "Upper tail")
render.band(p1u, p1d, color = color.amber.500, opacity = 0.04, title = "Expected range")
render.band(p1d, p2d, texture = "gradient", direction = "edges", upper_color = color.slate.900, lower_color = color.emerald.500, opacity = 0.2, title = "Lower tail")