
QuaScript Documentation
Language guide, engine API, 601 builtins and the preset library · v3.0.0-phase1
WebGPU Studio
Autocorrelation Lag Map
analysis · presets/analysis/gpu/autocorrelation_lag_map.qs · uses viz/premium
Autocorrelation of one-bar returns at lags 1 .. 24, as a heatmap. Values inside the 95 % significance band (plus or minus 2 / sqrt(window)) stay dark, so only statistically real structure lights up: violet for persistence at that lag, lime for reversal. Reading: a lime lag-1 row = the next bar tends to give back this bar's move (fade spikes); violet low lags = short-term continuation; a bright row at lag L = a repeating L-bar rhythm. Signals: lag-1 sign flips outside the band. Method: sample autocorrelation, Bartlett band.
analysis "Autocorrelation Lag Map" {
pane = hpane
height = 0.28
render = webgpu
short_title = "ACM"
precision = 2
}
/// Autocorrelation of one-bar returns at lags 1 .. 24, as a heatmap. Values inside the 95 %
/// significance band (plus or minus 2 / sqrt(window)) stay dark, so only statistically real
/// structure lights up: violet for persistence at that lag, lime for reversal.
/// Reading: a lime lag-1 row = the next bar tends to give back this bar's move (fade spikes);
/// violet low lags = short-term continuation; a bright row at lag L = a repeating L-bar rhythm.
/// Signals: lag-1 sign flips outside the band. Method: sample autocorrelation, Bartlett band.
use "viz/premium" as pr
len = param.int(120, title = "Window", min = 30, max = 2000, group = "Map")
ret = math.log(close / close[1])
sig = 2 / math.sqrt(len)
real = 0
edge = render.line(-0.5, "Edge 0", color = color.transparent, width = 0)
for k in 0..=23 {
ac = stat.autocorr(ret, len, k + 1)
ex = math.abs(ac) > sig ? ac - math.sign(ac) * sig : 0
if math.abs(ac) > sig { real := real + 1 }
top = render.line(k + 0.5, str.format("Edge {}", k + 1), color = color.transparent, width = 0)
render.band(top, edge, color = pr.diverging(ex, 0.25, color.violet.400, color.lime.400), opacity = 1, title = str.format("Lag {}", k + 1))
edge := top
}
ac1 = stat.autocorr(ret, len, 1)
render.line(11.5 + math.clamp(ac1 / 0.4, -1, 1) * 11.5, "Lag-1 balance", color = color.translucent(color.white, 25), width = 1.5)
flip_rev = ac1 < -sig and ac1[1] >= -sig
flip_mom = ac1 > sig and ac1[1] <= sig
render.mark(flip_rev, shape = shape.triangle_down, at = anchor.bottom, color = color.lime.300, size = 0.8, title = "Reversal onset")
render.mark(flip_mom, shape = shape.triangle_up, at = anchor.top, color = color.violet.300, size = 0.8, title = "Persistence onset")
alert.rule(flip_rev or flip_mom, "Lag-1 regime change", "Lag-1 return autocorrelation left the significance band")
persist t = draw.table.create(anchor.top_right, 5, 2)
if bar.is_last {
pr.heading(t, 2, "Autocorrelation Lag Map", "WebGPU Studio · Bartlett significance")
pr.row(t, 2, "Lag-1 autocorrelation", str.format("{:+.3f}", ac1), ac1 > sig ? color.violet.300 : ac1 < -sig ? color.lime.300 : color.slate.300)
pr.row(t, 3, "Significant lags", str.format("{} / 24", real), color.white)
pr.row(t, 4, "Band", str.format("+/- {:.3f}", sig), pr.LABEL)
}Bollinger Position Field
analysis · presets/analysis/gpu/bollinger_position_field.qs
%B of Bollinger Bands on twenty-four lengths: where the close sits between the 2-sigma bands of each window. Rows above 1 or below 0 (outside the bands) saturate; the line tracks how many windows are outside, a breadth of statistical stretch.
analysis "Bollinger Position Field" {
pane = hpane
height = 0.24
render = webgpu
}
/// %B of Bollinger Bands on twenty-four lengths: where the close sits between the 2-sigma bands of
/// each window. Rows above 1 or below 0 (outside the bands) saturate; the line tracks how many
/// windows are outside, a breadth of statistical stretch.
first = param.int(10, title = "Shortest length", min = 3, max = 500)
gap = param.int(5, title = "Length step", min = 1, max = 200)
edge = render.line(-0.5, "Edge 0", color = color.transparent, width = 0)
outside = 0
for k in 0..=23 {
pb = ta.bollinger_pct(close, first + k * gap, 2.0)
if pb > 1 or pb < 0 { outside := outside + 1 }
c = math.is_na(pb) ? na : pb >= 0.5 ? color.gradient(pb, 0.5, 1.1, color.slate.900, color.rose.300) : color.gradient(pb, -0.1, 0.5, color.teal.300, color.slate.900)
top = render.line(k + 0.5, str.format("Edge {}", k + 1), color = color.transparent, width = 0)
render.band(top, edge, color = c, opacity = 1, title = str.format("Row {}", k + 1))
edge := top
}
render.line(outside - 0.5, "Windows outside", color = color.translucent(color.white, 15), width = 1.6)Channel Position Field
analysis · presets/analysis/gpu/channel_position_field.qs
Where price sits inside its regression channel, on twenty-four horizons at once. Each row is the residual of the close from the least-squares line of that horizon, in residual sigmas: bright gold = at the top of the channel, bright cyan = at the bottom, dark = on the line. When every horizon is stretched the same way the move is exhausted on all scales; when short rows are stretched against long rows, a pullback inside a larger trend is under way. The line is the average position; marks show full-spectrum extremes turning back.
analysis "Channel Position Field" {
pane = hpane
height = 0.26
render = webgpu
}
/// Where price sits inside its regression channel, on twenty-four horizons at once. Each row is the
/// residual of the close from the least-squares line of that horizon, in residual sigmas: bright
/// gold = at the top of the channel, bright cyan = at the bottom, dark = on the line.
/// When every horizon is stretched the same way the move is exhausted on all scales; when short
/// rows are stretched against long rows, a pullback inside a larger trend is under way.
/// The line is the average position; marks show full-spectrum extremes turning back.
first = param.int(10, title = "Shortest horizon", min = 4, max = 200, group = "Field")
gap = param.int(10, title = "Horizon step", min = 1, max = 100, group = "Field")
ext = param.float(1.8, title = "Extreme (sigma)", min = 0.5, max = 4, step = 0.1, group = "Signals")
fn tone(z: float) -> color {
t = math.clamp(math.abs(z) / 2.5, 0, 1)
return math.is_na(z) ? na : z >= 0 ? color.gradient(t, 0, 1, color.slate.900, color.amber.300) : color.gradient(t, 0, 1, color.slate.900, color.cyan.300)
}
edge = render.line(-0.5, "Edge 0", color = color.transparent, width = 0)
total = 0.0
for k in 0..=23 {
len = first + k * gap
(v, s, _) = stat.linreg(close, len, 0)
sd = stat.stdev(close - v, len)
z = (close - v) / math.max(sd, 1e-12)
total := total + z
top = render.line(k + 0.5, str.format("Edge {}", k + 1), color = color.transparent, width = 0)
render.band(top, edge, color = tone(z), opacity = 1, title = str.format("Row {}", k + 1))
edge := top
}
avg = total / 24
p = render.line(11.5 + math.clamp(avg, -3, 3) * 3.8, "Average position", color = color.translucent(color.white, 15), width = 1.6)
top_out = avg < ext and avg[1] >= ext
bot_out = avg > -ext and avg[1] <= -ext
render.mark(top_out, shape = shape.triangle_down, at = anchor.top, color = color.amber.300, title = "Leaving the channel tops")
render.mark(bot_out, shape = shape.triangle_up, at = anchor.bottom, color = color.cyan.300, title = "Leaving the channel bottoms")
alert.rule(top_out or bot_out, "Spectrum extreme turns", "Price turned back from an extreme on every horizon")Cornish-Fisher Risk Bands
analysis · presets/analysis/gpu/cornish_fisher_risk_bands.qs · uses viz/premium
Value-at-risk bands that respect fat tails and skew. The 95 % and 99 % quantiles of the next bar's return are expanded with the Cornish-Fisher correction for the rolling skewness and excess kurtosis, then turned into price levels around the previous close. The outer bands take their colour from tail heaviness, bar by bar, so heavier tails glow hotter. Reading: the gap between the 95 % and 99 % bands is the tail risk a normal model misses; place stops beyond the 99 % band and size positions from the 95 % distance. Asymmetric bands show skew (a longer lower band = crash risk). Signals: tail breaches. Method: Cornish and Fisher (1938), Zangari (1996) modified VaR.
analysis "Cornish-Fisher Risk Bands" {
overlay = true
render = webgpu
short_title = "CFR"
}
/// Value-at-risk bands that respect fat tails and skew. The 95 % and 99 % quantiles of the next
/// bar's return are expanded with the Cornish-Fisher correction for the rolling skewness and
/// excess kurtosis, then turned into price levels around the previous close. The outer bands take
/// their colour from tail heaviness, bar by bar, so heavier tails glow hotter.
/// Reading: the gap between the 95 % and 99 % bands is the tail risk a normal model misses; place
/// stops beyond the 99 % band and size positions from the 95 % distance. Asymmetric bands show
/// skew (a longer lower band = crash risk). Signals: tail breaches.
/// Method: Cornish and Fisher (1938), Zangari (1996) modified VaR.
use "viz/premium" as pr
len = param.int(250, title = "Return window", min = 30, max = 3000, group = "Distribution")
hor = param.int(1, title = "Horizon bars", min = 1, max = 50, group = "Distribution")
r = math.log(close / close[1])
mu = ta.sma(r, len)
sd = stat.stdev(r, len)
S = math.clamp(stat.skew(r, len), -3, 3)
K = math.clamp(stat.kurtosis(r, len), -2, 20)
fn cf(z: float, s: float, k: float) -> float {
return z + (z * z - 1) * s / 6 + (z * z * z - 3 * z) * k / 24 - (2 * z * z * z - 5 * z) * s * s / 36
}
h = math.sqrt(hor)
zu95 = cf(1.645, S, K)
zu99 = cf(2.326, S, K)
zd95 = cf(-1.645, S, K)
zd99 = cf(-2.326, S, K)
// levels for this bar come from the distribution known at the previous close (no lookahead)
u95 = close[1] * math.exp(mu[1] * hor + zu95[1] * sd[1] * h)
u99 = close[1] * math.exp(mu[1] * hor + zu99[1] * sd[1] * h)
d95 = close[1] * math.exp(mu[1] * hor + zd95[1] * sd[1] * h)
d99 = close[1] * math.exp(mu[1] * hor + zd99[1] * sd[1] * h)
tail = color.ramp(K[1], 0, 8, "inferno")
p_u99 = render.line(u99, "Upper 99 %", color = color.translucent(color.orange.300, 30), width = 1)
p_u95 = render.line(u95, "Upper 95 %", color = color.translucent(color.amber.200, 50), width = 1)
p_d95 = render.line(d95, "Lower 95 %", color = color.translucent(color.sky.200, 50), width = 1)
p_d99 = render.line(d99, "Lower 99 %", color = color.translucent(color.sky.300, 30), width = 1)
render.band(p_u99, p_u95, upper_color = tail, lower_color = color.translucent(color.slate.900, 80), opacity = 0.55, title = "Upper tail")
render.band(p_d95, p_d99, upper_color = color.translucent(color.slate.900, 80), lower_color = tail, opacity = 0.55, title = "Lower tail")
render.band(p_u95, p_d95, color = color.indigo.400, opacity = 0.04, title = "Core range")
breach_up = high > u99
breach_dn = low < d99
render.mark(breach_up, shape = shape.triangle_down, at = anchor.above_bar, color = color.orange.300, size = 0.7, title = "Upper tail breach")
render.mark(breach_dn, shape = shape.triangle_up, at = anchor.below_bar, color = color.sky.300, size = 0.7, title = "Lower tail breach")
alert.rule(breach_up or breach_dn, "Tail breach", "Price traded beyond the 99 % Cornish-Fisher band")
persist t = draw.table.create(anchor.top_right, 6, 2)
if bar.is_last {
pr.heading(t, 2, "Cornish-Fisher Risk Bands", "WebGPU Studio · modified VaR")
pr.row(t, 2, "Skewness", str.format("{:+.2f}", S), S < -0.3 ? pr.BEAR : color.white)
pr.row(t, 3, "Excess kurtosis", str.format("{:.2f}", K), K > 3 ? pr.WARN : color.white)
pr.row(t, 4, "VaR 99 % (down)", str.format("{:.2%}", 1 - d99 / close[1]), pr.BEAR)
pr.row(t, 5, "VaR 99 % (up)", str.format("{:.2%}", u99 / close[1] - 1), pr.BULL)
}Price-Time Correlation Field
analysis · presets/analysis/gpu/correlation_horizon_field.qs
The correlation between price and time (Pearson r of the close with the bar index) on twenty-four windows: +1 is a perfectly straight rise, -1 a perfectly straight fall. It reads like trend quality but keeps the sign continuous, so turns show as a gradient travelling across horizons.
analysis "Price-Time Correlation Field" {
pane = hpane
height = 0.24
render = webgpu
}
/// The correlation between price and time (Pearson r of the close with the bar index) on twenty-four
/// windows: +1 is a perfectly straight rise, -1 a perfectly straight fall. It reads like trend
/// quality but keeps the sign continuous, so turns show as a gradient travelling across horizons.
first = param.int(8, title = "Shortest window", min = 3, max = 500)
gap = param.int(8, title = "Window step", min = 1, max = 200)
idx = bar.index
edge = render.line(-0.5, "Edge 0", color = color.transparent, width = 0)
s = 0.0
for k in 0..=23 {
r = stat.corr(close, idx, first + k * gap)
s := s + r
t = math.clamp(math.abs(r), 0, 1)
c = math.is_na(r) ? na : r >= 0 ? color.gradient(t, 0.2, 1, color.slate.900, color.emerald.300) : color.gradient(t, 0.2, 1, color.slate.900, color.pink.400)
top = render.line(k + 0.5, str.format("Edge {}", k + 1), color = color.transparent, width = 0)
render.band(top, edge, color = c, opacity = 1, title = str.format("Row {}", k + 1))
edge := top
}
render.line(11.5 + s / 24 * 11.5, "Average r", color = color.translucent(color.white, 15), width = 1.6)Cycle Phase Scope
analysis · presets/analysis/gpu/cycle_phase_scope.qs
Price split into three cycles with band-pass filters made of two-pole super smoothers: a short cycle, a swing cycle and a primary cycle. Each wave is normalised by its own amplitude, so all three share one scale and a +/-1 reading is a typical crest or trough. The waves are filled to zero in the color of their phase (rising or falling), and the composite of the three is the thick line. When all three cycles bottom or top together the timing is at its best: those confluences are marked, and the dashboard names the dominant cycle and the phase of each one.
analysis "Cycle Phase Scope" {
pane = hpane
height = 0.28
render = webgpu
}
/// Price split into three cycles with band-pass filters made of two-pole super smoothers: a short
/// cycle, a swing cycle and a primary cycle. Each wave is normalised by its own amplitude, so all
/// three share one scale and a +/-1 reading is a typical crest or trough. The waves are filled to
/// zero in the color of their phase (rising or falling), and the composite of the three is the
/// thick line.
/// When all three cycles bottom or top together the timing is at its best: those confluences are
/// marked, and the dashboard names the dominant cycle and the phase of each one.
src = param.source(close, title = "Source")
p_fast = param.int(10, title = "Short cycle", min = 3, max = 100, group = "Cycles")
p_mid = param.int(30, title = "Swing cycle", min = 5, max = 300, group = "Cycles")
p_slow = param.int(90, title = "Primary cycle", min = 10, max = 1000, group = "Cycles")
amp_len = param.int(200, title = "Amplitude window", min = 20, max = 2000, group = "Cycles")
lp_a = ta.super_smoother(src, p_fast / 2)
lp_b = ta.super_smoother(src, p_fast)
lp_c = ta.super_smoother(src, p_mid)
lp_d = ta.super_smoother(src, p_slow)
lp_e = ta.super_smoother(src, p_slow * 2)
raw_s = lp_a - lp_b
raw_m = lp_b - lp_c
raw_p = lp_d - lp_e
cs = raw_s / math.max(stat.stdev(raw_s, amp_len) * 1.4, 1e-12)
cm = raw_m / math.max(stat.stdev(raw_m, amp_len) * 1.4, 1e-12)
cp = raw_p / math.max(stat.stdev(raw_p, amp_len) * 1.4, 1e-12)
comp = (cs + cm * 1.5 + cp * 2) / 4.5
zero = render.line(0, "Zero", color = color.translucent(color.slate.400, 55), width = 0.6)
ws = render.spline(cs, "Short cycle", color = color.translucent(color.sky.300, 40), width = 1)
wm = render.spline(cm, "Swing cycle", color = color.translucent(color.violet.300, 20), width = 1.4)
wp = render.spline(cp, "Primary cycle", color = color.translucent(color.amber.300, 10), width = 1.8)
render.band(wp, zero, color = cp >= cp[1] ? color.translucent(color.amber.400, 82) : color.translucent(color.orange.700, 86), opacity = 1, title = "Primary phase")
render.band(wm, zero, color = cm >= cm[1] ? color.translucent(color.violet.400, 84) : color.translucent(color.purple.800, 88), opacity = 1, title = "Swing phase")
render.band(ws, zero, color = cs >= cs[1] ? color.translucent(color.sky.400, 88) : color.translucent(color.blue.800, 90), opacity = 1, title = "Short phase")
render.line(comp, "Composite", color = comp >= comp[1] ? color.lime.300 : color.rose.400, width = 2.6)
render.level(1, "Crest", color = color.translucent(color.slate.400, 50), stroke = style.dotted)
render.level(-1, "Trough", color = color.translucent(color.slate.400, 50), stroke = style.dotted)
turn_up = (cs > cs[1] and cs[1] <= cs[2]) or (cm > cm[1] and cm[1] <= cm[2])
turn_dn = (cs < cs[1] and cs[1] >= cs[2]) or (cm < cm[1] and cm[1] >= cm[2])
trough = turn_up and cm < -0.3 and cp < 0 and cp >= cp[1] and comp < -0.3
crest = turn_dn and cm > 0.3 and cp > 0 and cp <= cp[1] and comp > 0.3
render.mark(trough and not trough[1], shape = shape.triangle_up, at = anchor.bottom, color = color.lime.300, title = "Cycle trough confluence")
render.mark(crest and not crest[1], shape = shape.triangle_down, at = anchor.top, color = color.rose.400, title = "Cycle crest confluence")
fn phase(x: float) -> text {
return x >= x[1] ? (x < 0 ? "Rising from trough" : "Rising to crest") : (x > 0 ? "Falling from crest" : "Falling to trough")
}
amp_s = stat.stdev(raw_s, amp_len)
amp_m = stat.stdev(raw_m, amp_len)
amp_p = stat.stdev(raw_p, amp_len)
persist tb = draw.table.create(anchor.top_right, 4, 2)
if bar.is_last {
draw.table.cell(tb, 0, 0, "Dominant", text_color = color.slate.400)
draw.table.cell(tb, 0, 1, amp_p >= amp_m and amp_p >= amp_s ? "Primary" : amp_m >= amp_s ? "Swing" : "Short")
draw.table.cell(tb, 1, 0, "Short", text_color = color.sky.300)
draw.table.cell(tb, 1, 1, phase(cs))
draw.table.cell(tb, 2, 0, "Swing", text_color = color.violet.300)
draw.table.cell(tb, 2, 1, phase(cm))
draw.table.cell(tb, 3, 0, "Primary", text_color = color.amber.300)
draw.table.cell(tb, 3, 1, phase(cp))
}
alert.rule(trough and not trough[1], "Cycle trough", "Short and swing cycles turned up below zero with the primary cycle rising")
alert.rule(crest and not crest[1], "Cycle crest", "Short and swing cycles turned down above zero with the primary cycle falling")Drawdown Depth Field
analysis · presets/analysis/gpu/drawdown_depth_field.qs · uses viz/premium
Drawdown from the rolling high on 24 lookbacks, as a heatmap, with the underwater curve of the longest lookback as a gradient area on top. Each row is the distance below the highest high of its lookback in ATR units, so the field reads the same on any instrument. Reading: dark rows are at their highs; a hot bottom with a dark top = a shallow pullback inside a larger advance (buyable); heat climbing to the long rows = a real correction. The underwater strip shows depth and how long it has lasted. Signals: deep drawdown and recovery confirmation (a new long-lookback high after a deep drawdown).
analysis "Drawdown Depth Field" {
pane = hpane
height = 0.3
render = webgpu
short_title = "DDF"
precision = 1
}
/// Drawdown from the rolling high on 24 lookbacks, as a heatmap, with the underwater curve of the
/// longest lookback as a gradient area on top. Each row is the distance below the highest high of
/// its lookback in ATR units, so the field reads the same on any instrument.
/// Reading: dark rows are at their highs; a hot bottom with a dark top = a shallow pullback inside
/// a larger advance (buyable); heat climbing to the long rows = a real correction. The underwater
/// strip shows depth and how long it has lasted. Signals: deep drawdown and recovery confirmation
/// (a new long-lookback high after a deep drawdown).
use "viz/premium" as pr
l_min = param.int(10, title = "Shortest lookback", min = 3, max = 200, group = "Field")
l_step = param.int(10, title = "Lookback step", min = 2, max = 100, group = "Field")
deep = param.float(4, title = "Deep drawdown (ATR)", min = 0.5, max = 20, step = 0.5, group = "Signals")
a = math.max(ta.atr(50), 1e-12)
edge = render.line(-0.5, "Edge 0", color = color.transparent, width = 0)
for k in 0..=23 {
dd = (ta.highest(high, l_min + k * l_step) - close) / a
top = render.line(k + 0.5, str.format("Edge {}", k + 1), color = color.transparent, width = 0)
render.band(top, edge, color = color.ramp(dd, 0, deep * 1.5, "magma"), opacity = 1, title = str.format("Lookback {}", k + 1))
edge := top
}
long_l = l_min + 23 * l_step
hh_long = ta.highest(high, long_l)
dd_long = (hh_long - close) / a
age = ta.highest_offset(high, long_l)
depth = math.clamp(dd_long / (deep * 2), 0, 1)
u_top = render.line(28.1, "Underwater top", color = color.transparent, width = 0)
u_bot = render.line(28.1 - depth * 3.8, "Underwater", color = color.sky.300, width = 1.2)
render.band(u_top, u_bot, upper_color = color.translucent(color.sky.900, 30), lower_color = color.ramp(depth, 0, 1, "plasma"), opacity = 0.95, title = "Underwater area")
persist was_deep = false
if dd_long > deep { was_deep := true }
recovered = was_deep and high >= hh_long[1]
if recovered { was_deep := false }
render.mark(dd_long > deep and dd_long[1] <= deep, shape = shape.triangle_down, at = anchor.top, color = color.rose.300, size = 0.8, title = "Deep drawdown")
render.mark(recovered, shape = shape.star, at = anchor.top, color = color.emerald.300, size = 0.9, title = "Recovery confirmed")
alert.rule(recovered, "Recovery confirmed", "A new long-lookback high after a deep drawdown")
persist t = draw.table.create(anchor.top_right, 5, 2)
if bar.is_last {
pr.heading(t, 2, "Drawdown Depth Field", "WebGPU Studio · underwater analysis")
pr.row(t, 2, "Depth (long lookback)", str.format("{:.1f} ATR", dd_long), dd_long > deep ? pr.BEAR : color.white)
pr.row(t, 3, "Bars since high", str.format("{}", age), color.white)
pr.row(t, 4, "State", dd_long < 0.5 ? "At highs" : dd_long > deep ? "Correction" : "Pullback", color.white)
}Drawup Field
analysis · presets/analysis/gpu/drawup_field.qs
The rally from the lowest low on twenty-four lookbacks, in multiples of ATR. Bright rows mean price has travelled far off that window's low; dark rows mean it is still near it. The mirror of the drawdown field: together they show whether a market is extended from its lows or pressing them, on every scale. The line is the median extension.
analysis "Drawup Field" {
pane = hpane
height = 0.24
render = webgpu
}
/// The rally from the lowest low on twenty-four lookbacks, in multiples of ATR. Bright rows mean
/// price has travelled far off that window's low; dark rows mean it is still near it. The mirror of
/// the drawdown field: together they show whether a market is extended from its lows or pressing
/// them, on every scale. The line is the median extension.
first = param.int(10, title = "Shortest lookback", min = 2, max = 500, group = "Field")
gap = param.int(10, title = "Lookback step", min = 1, max = 500, group = "Field")
a = ta.atr(50)
edge = render.line(-0.5, "Edge 0", color = color.transparent, width = 0)
mid_ext = 0.0
for k in 0..=23 {
len = first + k * gap
ext = (close - ta.lowest(low, len)) / math.max(a, 1e-12)
if k == 12 { mid_ext := ext }
c = math.is_na(ext) ? na : color.gradient(ext, 0, 12, color.slate.900, color.lime.300)
top = render.line(k + 0.5, str.format("Edge {}", k + 1), color = color.transparent, width = 0)
render.band(top, edge, color = c, opacity = 1, title = str.format("Row {}", k + 1))
edge := top
}
render.line(math.min(mid_ext, 23), "Median-window extension (ATR)", color = color.translucent(color.white, 15), width = 1.6)Efficiency Ratio Field
analysis · presets/analysis/gpu/efficiency_ratio_field.qs
Kaufman's efficiency ratio on twenty-four horizons: net move divided by the path travelled. 1 means price went straight, 0 means it went nowhere however much it moved. Each row is signed by the direction of the net move, so efficient rallies glow lime and efficient declines magenta, while chop stays dark on every horizon. The line is the volume-weighted average efficiency; the background marks bars where every horizon above the median is efficient in one direction, the cleanest trend state there is.
analysis "Efficiency Ratio Field" {
pane = hpane
height = 0.24
render = webgpu
}
/// Kaufman's efficiency ratio on twenty-four horizons: net move divided by the path travelled.
/// 1 means price went straight, 0 means it went nowhere however much it moved. Each row is signed
/// by the direction of the net move, so efficient rallies glow lime and efficient declines
/// magenta, while chop stays dark on every horizon.
/// The line is the volume-weighted average efficiency; the background marks bars where every
/// horizon above the median is efficient in one direction, the cleanest trend state there is.
first = param.int(5, title = "Shortest horizon", min = 2, max = 100, group = "Field")
gap = param.int(5, title = "Horizon step", min = 1, max = 100, group = "Field")
edge = render.line(-0.5, "Edge 0", color = color.transparent, width = 0)
acc = 0.0
long_up = 0
long_dn = 0
for k in 0..=23 {
len = first + k * gap
er = hft.efficiency(close, len)
sgn = math.sign(close - close[len])
q = er * sgn
acc := acc + q
if k >= 12 and q > 0.3 { long_up := long_up + 1 }
if k >= 12 and q < -0.3 { long_dn := long_dn + 1 }
t = math.clamp(math.abs(q) * 1.6, 0, 1)
c = math.is_na(q) ? na : q >= 0 ? color.gradient(t, 0, 1, color.slate.900, color.lime.300) : color.gradient(t, 0, 1, color.slate.900, color.fuchsia.400)
top = render.line(k + 0.5, str.format("Edge {}", k + 1), color = color.transparent, width = 0)
render.band(top, edge, color = c, opacity = 1, title = str.format("Row {}", k + 1))
edge := top
}
rv = volume / math.max(ta.sma(volume, 50), 1e-12)
w = ta.ema(acc / 24 * math.min(rv, 3), 3)
render.line(11.5 + math.clamp(w * 18, -11.5, 11.5), "Weighted efficiency", color = color.translucent(color.white, 15), width = 1.6)
clean_up = long_up == 12
clean_dn = long_dn == 12
render.background(clean_up ? color.translucent(color.lime.500, 88) : clean_dn ? color.translucent(color.fuchsia.500, 88) : na)
alert.rule(clean_up and not clean_up[1], "Efficient up-trend", "Every long horizon moves efficiently up")
alert.rule(clean_dn and not clean_dn[1], "Efficient down-trend", "Every long horizon moves efficiently down")Entropy Regime Cloud
analysis · presets/analysis/gpu/entropy_regime_cloud.qs · uses viz/premium
A trend cloud whose colour and width come from the Shannon entropy of recent returns. Ordered markets (low entropy: returns cluster, moves repeat) glow bright and the cloud tightens around the adaptive mean; random markets (entropy near its maximum) fade to a wide, dark haze. Reading: trade continuation when the cloud is bright and sloping, stand aside or fade edges when it is dark and wide. The edge colours change bar by bar, drawn as edge-anchored gradients. Signals: "Order" when entropy drops below the order level with a sloping mean (trend start), "Disorder" when it returns to noise. Method: Shannon (1948) entropy of the return histogram.
analysis "Entropy Regime Cloud" {
overlay = true
render = webgpu
short_title = "ERC"
}
/// A trend cloud whose colour and width come from the Shannon entropy of recent returns. Ordered
/// markets (low entropy: returns cluster, moves repeat) glow bright and the cloud tightens around
/// the adaptive mean; random markets (entropy near its maximum) fade to a wide, dark haze.
/// Reading: trade continuation when the cloud is bright and sloping, stand aside or fade edges when
/// it is dark and wide. The edge colours change bar by bar, drawn as edge-anchored gradients.
/// Signals: "Order" when entropy drops below the order level with a sloping mean (trend start),
/// "Disorder" when it returns to noise. Method: Shannon (1948) entropy of the return histogram.
use "viz/premium" as pr
len = param.int(60, title = "Entropy window", min = 20, max = 1000, group = "Entropy")
bins = param.int(10, title = "Histogram bins", min = 4, max = 40, group = "Entropy")
mean_l = param.int(34, title = "Mean length", min = 5, max = 400, group = "Cloud")
width = param.float(1.6, title = "Cloud width (ATR)", min = 0.3, max = 6, step = 0.1, group = "Cloud")
order_lv = param.float(0.84, title = "Order level", min = 0.3, max = 0.98, step = 0.01, group = "Signals")
r = math.log(close / close[1])
e = stat.entropy(r, len, bins)
c = ta.kaufman(close, mean_l)
a = ta.atr(mean_l)
w = width * a * (0.55 + math.clamp((e - order_lv + 0.1) / 0.2, 0, 1) * 0.9)
up = c > c[1]
glow = math.clamp((order_lv + 0.12 - e) / 0.24, 0, 1)
tone = up ? color.gradient(glow, 0, 1, color.slate.700, color.cyan.300) : color.gradient(glow, 0, 1, color.slate.700, color.fuchsia.400)
p_u = render.line(c + w, "Cloud top", color = color.translucent(color.slate.400, 60), width = 1)
p_c = render.line(c, "Adaptive mean", color = tone, width = 2.5)
p_l = render.line(c - w, "Cloud bottom", color = color.translucent(color.slate.400, 60), width = 1)
render.band(p_u, p_c, upper_color = color.translucent(tone, 92), lower_color = tone, opacity = 0.42, title = "Upper cloud")
render.band(p_c, p_l, upper_color = tone, lower_color = color.translucent(tone, 92), opacity = 0.42, title = "Lower cloud")
ordered = e < order_lv
render.mark(ordered and not ordered[1] and up, shape = shape.triangle_up, at = anchor.below_bar, color = color.cyan.300, text = "Order", title = "Ordered up-trend start")
render.mark(ordered and not ordered[1] and not up, shape = shape.triangle_down, at = anchor.above_bar, color = color.fuchsia.300, text = "Order", title = "Ordered down-trend start")
render.mark(not ordered and ordered[1], shape = shape.x, at = anchor.above_bar, color = color.slate.400, size = 0.6, title = "Disorder")
alert.rule(ordered and not ordered[1], "Entropy order", "Return entropy dropped below the order level")
persist t = draw.table.create(anchor.top_right, 5, 2)
if bar.is_last {
pr.heading(t, 2, "Entropy Regime Cloud", "WebGPU Studio · Shannon entropy")
pr.row(t, 2, "Entropy", str.format("{:.3f}", e), ordered ? color.cyan.300 : color.slate.300)
pr.row(t, 3, "Regime", ordered ? (up ? "Ordered up-trend" : "Ordered down-trend") : "Random", color.white)
pr.row(t, 4, "Mean slope", up ? "Rising" : "Falling", up ? pr.BULL : pr.BEAR)
}Event Intensity Heat
analysis · presets/analysis/gpu/event_intensity_heat.qs · uses viz/premium
Self-exciting intensity of large moves, separately for up and down shocks. A shock is a bar whose return exceeds a multiple of the rolling volatility; each shock raises its intensity, which then decays, so clusters of shocks build heat. Up intensity grows above zero, down intensity below it, as gradient histograms; the area behind them is the total. Reading: rising down intensity = selling pressure is feeding on itself (stops, liquidations); a decaying total after a cluster = calm returning, when mean-reversion entries work again. Signals: cluster starts and "calm returning". Method: Hawkes (1971) self-exciting process.
analysis "Event Intensity Heat" {
pane = hpane
height = 0.26
render = webgpu
short_title = "EIH"
precision = 2
}
/// Self-exciting intensity of large moves, separately for up and down shocks. A shock is a bar
/// whose return exceeds a multiple of the rolling volatility; each shock raises its intensity,
/// which then decays, so clusters of shocks build heat. Up intensity grows above zero, down
/// intensity below it, as gradient histograms; the area behind them is the total.
/// Reading: rising down intensity = selling pressure is feeding on itself (stops, liquidations);
/// a decaying total after a cluster = calm returning, when mean-reversion entries work again.
/// Signals: cluster starts and "calm returning". Method: Hawkes (1971) self-exciting process.
use "viz/premium" as pr
len = param.int(100, title = "Volatility window", min = 20, max = 2000, group = "Shocks")
k_sd = param.float(2.0, title = "Shock size (sigma)", min = 1, max = 5, step = 0.1, group = "Shocks")
decay = param.float(0.12, title = "Decay per bar", min = 0.01, max = 0.8, step = 0.01, group = "Process")
jump = param.float(1.0, title = "Jump per shock", min = 0.1, max = 5, step = 0.1, group = "Process")
hot = param.float(1.5, title = "Cluster level", min = 0.2, max = 10, step = 0.1, group = "Signals")
r = math.log(close / close[1])
sd_now = stat.stdev(r, len)
sd = sd_now[1]
up_ev = r > k_sd * sd
dn_ev = r < -k_sd * sd
lam_u = hft.hawkes(up_ev, decay, jump)
lam_d = hft.hawkes(dn_ev, decay, jump)
tot = lam_u + lam_d
render.area(tot, "Total intensity", color = color.translucent(color.indigo.400, 75))
render.area(-tot, "Total intensity mirror", color = color.translucent(color.indigo.400, 75))
render.histogram(lam_u, "Up intensity", color = color.ramp(lam_u, 0, hot * 1.5, "viridis"))
render.histogram(-lam_d, "Down intensity", color = color.ramp(lam_d, 0, hot * 1.5, "magma"))
render.level(hot, "Cluster up", color = color.translucent(color.emerald.700, 30))
render.level(-hot, "Cluster down", color = color.translucent(color.rose.700, 30))
render.level(0, "Zero", color = color.slate.600, stroke = style.dotted)
cl_u = lam_u > hot and lam_u[1] <= hot
cl_d = lam_d > hot and lam_d[1] <= hot
persist was_hot = false
if tot > hot { was_hot := true }
calm = was_hot and tot < hot * 0.25
if calm { was_hot := false }
render.mark(cl_u, shape = shape.triangle_up, at = anchor.top, color = color.emerald.300, size = 0.8, title = "Up-shock cluster")
render.mark(cl_d, shape = shape.triangle_down, at = anchor.bottom, color = color.rose.300, size = 0.8, title = "Down-shock cluster")
render.mark(calm, shape = shape.circle, at = anchor.top, color = color.sky.300, size = 0.6, title = "Calm returning")
alert.rule(cl_d, "Down-shock cluster", "Large down moves are clustering")
alert.rule(calm, "Calm returning", "Shock intensity decayed after a cluster")
persist t = draw.table.create(anchor.top_right, 5, 2)
if bar.is_last {
pr.heading(t, 2, "Event Intensity Heat", "WebGPU Studio · Hawkes process")
pr.row(t, 2, "Up intensity", str.format("{:.2f}", lam_u), lam_u > hot ? pr.BULL : color.white)
pr.row(t, 3, "Down intensity", str.format("{:.2f}", lam_d), lam_d > hot ? pr.BEAR : color.white)
pr.row(t, 4, "State", tot > hot ? "Shock cluster" : "Calm", tot > hot ? pr.WARN : color.slate.300)
}Fractal Dimension Terrain
analysis · presets/analysis/gpu/fractal_dimension_terrain.qs · uses viz/premium
The Sevcik fractal dimension of price on 20 windows, drawn as terrain: deep blue where price moves like a smooth line (low dimension, a clean trend), sand and brown in between, white where the path is jagged (high dimension, chop; a random walk sits near 1.35). Short windows at the bottom. Reading: blue climbing from the short windows to the long ones = a trend being born; white spreading downwards = the trend is dissolving into a range. Signals: trend birth when the consensus drops below the trend level, chop when it rises above the chop level. Method: Sevcik (1998) fractal dimension of the normalised price curve.
analysis "Fractal Dimension Terrain" {
pane = hpane
height = 0.28
render = webgpu
short_title = "FDT"
precision = 2
}
/// The Sevcik fractal dimension of price on 20 windows, drawn as terrain: deep blue where price
/// moves like a smooth line (low dimension, a clean trend), sand and brown in between, white where
/// the path is jagged (high dimension, chop; a random walk sits near 1.35). Short windows at the bottom.
/// Reading: blue climbing from the short windows to the long ones = a trend being born; white
/// spreading downwards = the trend is dissolving into a range. Signals: trend birth when the
/// consensus drops below the trend level, chop when it rises above the chop level.
/// Method: Sevcik (1998) fractal dimension of the normalised price curve.
use "viz/premium" as pr
w_min = param.int(16, title = "Shortest window", min = 8, max = 200, group = "Terrain")
w_step = param.int(6, title = "Window step", min = 2, max = 50, group = "Terrain")
trend_lv = param.float(1.26, title = "Trend level", min = 1.05, max = 1.5, step = 0.01, group = "Signals")
chop_lv = param.float(1.42, title = "Chop level", min = 1.3, max = 1.95, step = 0.01, group = "Signals")
total = 0.0
edge = render.line(-0.5, "Edge 0", color = color.transparent, width = 0)
for k in 0..=19 {
fd = stat.fractal_dim(close, w_min + k * w_step)
total := total + math.coalesce(fd, 1.5)
top = render.line(k + 0.5, str.format("Edge {}", k + 1), color = color.transparent, width = 0)
render.band(top, edge, color = color.ramp(fd, 1.12, 1.58, "terrain"), opacity = 1, title = str.format("Window {}", k + 1))
edge := top
}
avg = total / 20
cap_lo = render.line(20.1, "Consensus low", color = color.transparent, width = 0)
cap_hi = render.line(22.1, "Consensus high", color = color.transparent, width = 0)
render.band(cap_hi, cap_lo, upper_color = color.ramp(avg, 1.12, 1.58, "terrain"), lower_color = color.slate.950, opacity = 1, title = "Consensus")
trending = avg < trend_lv
choppy = avg > chop_lv
render.mark(trending and not trending[1], shape = shape.triangle_up, at = anchor.top, color = color.sky.300, size = 0.8, title = "Trend birth")
render.mark(choppy and not choppy[1], shape = shape.x, at = anchor.top, color = color.slate.200, size = 0.7, title = "Chop")
alert.rule(trending and not trending[1], "Trend birth", "Fractal dimension consensus fell below the trend level")
persist t = draw.table.create(anchor.top_right, 4, 2)
if bar.is_last {
pr.heading(t, 2, "Fractal Dimension Terrain", "WebGPU Studio · Sevcik dimension")
pr.row(t, 2, "Consensus dimension", str.format("{:.3f}", avg), trending ? color.sky.300 : choppy ? color.slate.200 : color.amber.200)
pr.row(t, 3, "Path", trending ? "Smooth trend" : choppy ? "Chop" : "Mixed", color.white)
}GARCH Forecast Cone
analysis · presets/analysis/gpu/garch_forecast_cone.qs · uses viz/premium
A GARCH(1,1) volatility forecast turned into price ranges. On history, the bands are the one-bar-ahead 1, 2 and 3 sigma ranges around the previous close, filled with edge-anchored gradients that fade outwards. At the newest bar a forward cone shows the expected range for the next bars; its width follows the GARCH term structure (volatility decays towards its long-run level), not a flat square-root-of-time rule. Reading: a close outside the 2-sigma band is a genuine volatility event, outside 3 sigma a shock; a narrow cone with a high long-run level means volatility is likely to expand. Signals: 2- and 3-sigma breaches. Method: Bollerslev (1986), variance targeting.
analysis "GARCH Forecast Cone" {
overlay = true
render = webgpu
short_title = "GFC"
max_objects = 40
}
/// A GARCH(1,1) volatility forecast turned into price ranges. On history, the bands are the
/// one-bar-ahead 1, 2 and 3 sigma ranges around the previous close, filled with edge-anchored
/// gradients that fade outwards. At the newest bar a forward cone shows the expected range for the
/// next bars; its width follows the GARCH term structure (volatility decays towards its long-run
/// level), not a flat square-root-of-time rule.
/// Reading: a close outside the 2-sigma band is a genuine volatility event, outside 3 sigma a
/// shock; a narrow cone with a high long-run level means volatility is likely to expand.
/// Signals: 2- and 3-sigma breaches. Method: Bollerslev (1986), variance targeting.
use "viz/premium" as pr
alpha = param.float(0.06, title = "Alpha (shock weight)", min = 0.01, max = 0.3, step = 0.01, group = "GARCH")
beta = param.float(0.92, title = "Beta (persistence)", min = 0.5, max = 0.99, step = 0.01, group = "GARCH")
horizon = param.int(20, title = "Cone bars", min = 5, max = 120, group = "Cone")
(vol, long_run) = hft.garch(close, alpha, beta, 0)
sig = vol / 100
lr = long_run / 100
base = close[1]
s1 = base * sig[1]
p3u = render.line(base + 3 * s1, "+3 sigma", color = color.translucent(color.rose.400, 55), width = 1)
p2u = render.line(base + 2 * s1, "+2 sigma", color = color.translucent(color.orange.300, 40), width = 1)
p1u = render.line(base + s1, "+1 sigma", color = color.transparent, width = 0)
p1d = render.line(base - s1, "-1 sigma", color = color.transparent, width = 0)
p2d = render.line(base - 2 * s1, "-2 sigma", color = color.translucent(color.sky.300, 40), width = 1)
p3d = render.line(base - 3 * s1, "-3 sigma", color = color.translucent(color.violet.400, 55), width = 1)
render.band(p3u, p2u, texture = "gradient", direction = "edges", upper_color = color.rose.500, lower_color = color.orange.400, opacity = 0.22, title = "Shock zone up")
render.band(p2u, p1u, texture = "gradient", direction = "edges", upper_color = color.orange.400, lower_color = color.indigo.500, opacity = 0.14, title = "Event zone up")
render.band(p1u, p1d, color = color.indigo.400, opacity = 0.05, title = "Expected range")
render.band(p1d, p2d, texture = "gradient", direction = "edges", upper_color = color.indigo.500, lower_color = color.sky.400, opacity = 0.14, title = "Event zone down")
render.band(p2d, p3d, texture = "gradient", direction = "edges", upper_color = color.sky.400, lower_color = color.violet.500, opacity = 0.22, title = "Shock zone down")
breach2 = math.abs(close - base) > 2 * s1 and math.abs(close - base) <= 3 * s1
breach3 = math.abs(close - base) > 3 * s1
render.mark(breach2 and close > base, shape = shape.circle, at = anchor.above_bar, color = color.orange.300, size = 0.6, title = "2-sigma breach up")
render.mark(breach2 and close < base, shape = shape.circle, at = anchor.below_bar, color = color.sky.300, size = 0.6, title = "2-sigma breach down")
render.mark(breach3, shape = shape.diamond, at = anchor.above_bar, color = color.rose.400, text = "Shock", title = "3-sigma shock")
alert.rule(breach3, "Volatility shock", "The close moved more than three GARCH sigmas")
// forward cone from the newest bar: cumulative forecast variance with mean reversion
persist cone = list.create(float)
persist t = draw.table.create(anchor.top_right, 6, 2)
if bar.is_last and not math.is_na(sig) {
for id in cone { draw.path.delete(id) }
list.clear(cone)
persist_k = alpha + beta
for m in 0..=2 {
mult = 3 - m
xs = list.create(float)
ys = list.create(float)
cum = 0.0
for h in 1..=horizon {
v = lr * lr + math.pow(persist_k, h - 1) * (sig * sig - lr * lr)
cum := cum + v
list.push(xs, bar.index + h)
list.push(ys, close * math.exp(mult * math.sqrt(cum)))
}
for h in 0..horizon {
hh = horizon - h
vv = 0.0
for j in 1..=hh { vv := vv + lr * lr + math.pow(persist_k, j - 1) * (sig * sig - lr * lr) }
list.push(xs, bar.index + hh)
list.push(ys, close * math.exp(-mult * math.sqrt(vv)))
}
list.push(xs, bar.index)
list.push(ys, close)
tone = mult == 3 ? color.translucent(color.violet.500, 82) : mult == 2 ? color.translucent(color.indigo.400, 72) : color.translucent(color.sky.300, 60)
list.push(cone, draw.path.create(xs, ys, color = color.translucent(color.slate.300, 70), width = 1, closed = true, fill = tone))
}
pr.heading(t, 2, "GARCH Forecast Cone", "WebGPU Studio · GARCH(1,1)")
pr.row(t, 2, "Next-bar sigma", str.format("{:.2%}", sig), color.white)
pr.row(t, 3, "Long-run sigma", str.format("{:.2%}", lr), color.slate.300)
pr.row(t, 4, "Outlook", sig < lr ? "Volatility expanding" : "Volatility contracting", sig < lr ? pr.WARN : pr.BULL)
pr.row(t, 5, str.format("{}-bar 2-sigma range", horizon), str.format("+/- {:.2%}", 2 * sig * math.sqrt(horizon)), color.white)
}Mean-Reversion Half-Life Map
analysis · presets/analysis/gpu/half_life_map.qs · uses viz/premium
The Ornstein-Uhlenbeck half-life of price deviations on 16 windows, as a heatmap. Each row fits an AR(1) to price over its window and converts the decay into the number of bars a deviation needs to halve. Bright rows revert fast (a tradable mean), dark rows barely revert (trend). A z-score strip on top shows how stretched price is from the fitted mean of the middle window. Reading: bright rows with a hot z-score strip = a stretched price likely to snap back within the half-life; dark everywhere = do not fade, the series is trending. Signals: "Reversion setup" when the half-life is short and |z| is beyond the entry level. Method: Uhlenbeck and Ornstein (1930); AR(1) half-life, ln 2 / -ln b.
analysis "Mean-Reversion Half-Life Map" {
pane = hpane
height = 0.28
render = webgpu
short_title = "HLM"
precision = 1
}
/// The Ornstein-Uhlenbeck half-life of price deviations on 16 windows, as a heatmap. Each row fits
/// an AR(1) to price over its window and converts the decay into the number of bars a deviation
/// needs to halve. Bright rows revert fast (a tradable mean), dark rows barely revert (trend).
/// A z-score strip on top shows how stretched price is from the fitted mean of the middle window.
/// Reading: bright rows with a hot z-score strip = a stretched price likely to snap back within
/// the half-life; dark everywhere = do not fade, the series is trending.
/// Signals: "Reversion setup" when the half-life is short and |z| is beyond the entry level.
/// Method: Uhlenbeck and Ornstein (1930); AR(1) half-life, ln 2 / -ln b.
use "viz/premium" as pr
w_min = param.int(30, title = "Shortest window", min = 10, max = 500, group = "Map")
w_step = param.int(15, title = "Window step", min = 2, max = 100, group = "Map")
hl_max = param.float(40, title = "Tradable half-life (bars)", min = 2, max = 500, group = "Signals")
z_in = param.float(2.0, title = "Entry z-score", min = 0.5, max = 4, step = 0.1, group = "Signals")
fast = 0
edge = render.line(-0.5, "Edge 0", color = color.transparent, width = 0)
for k in 0..=15 {
(m, hl, z) = hft.ou(close, w_min + k * w_step)
if hl < hl_max { fast := fast + 1 }
top = render.line(k + 0.5, str.format("Edge {}", k + 1), color = color.transparent, width = 0)
render.band(top, edge, color = color.ramp(math.log(math.max(hl, 1)), math.log(hl_max * 4), 0, "viridis"), opacity = 1, title = str.format("Window {}", k + 1))
edge := top
}
(m_mid, hl_mid, z_mid) = hft.ou(close, w_min + 7 * w_step)
cap_lo = render.line(16.1, "Z strip low", color = color.transparent, width = 0)
cap_hi = render.line(18.1, "Z strip high", color = color.transparent, width = 0)
render.band(cap_hi, cap_lo, upper_color = pr.diverging(z_mid, 3, color.rose.400, color.emerald.400), lower_color = color.slate.950, opacity = 1, title = "Z-score strip")
setup_dn = hl_mid < hl_max and z_mid > z_in and z_mid[1] <= z_in
setup_up = hl_mid < hl_max and z_mid < -z_in and z_mid[1] >= -z_in
render.mark(setup_dn, shape = shape.triangle_down, at = anchor.top, color = color.rose.300, size = 0.8, title = "Reversion setup short")
render.mark(setup_up, shape = shape.triangle_up, at = anchor.top, color = color.emerald.300, size = 0.8, title = "Reversion setup long")
alert.rule(setup_up or setup_dn, "Reversion setup", "Short half-life and a stretched z-score")
persist t = draw.table.create(anchor.top_right, 6, 2)
if bar.is_last {
pr.heading(t, 2, "Mean-Reversion Half-Life Map", "WebGPU Studio · Ornstein-Uhlenbeck")
pr.row(t, 2, "Half-life (mid window)", math.is_na(hl_mid) ? "none" : str.format("{:.1f} bars", hl_mid), hl_mid < hl_max ? pr.BULL : color.slate.300)
pr.row(t, 3, "Z-score", str.format("{:+.2f}", z_mid), math.abs(z_mid) > z_in ? pr.WARN : color.white)
pr.row(t, 4, "Fair value", str.format("{:.2f}", m_mid), color.white)
pr.row(t, 5, "Fast windows", str.format("{} / 16", fast), color.white)
}Horizon Breadth Thrust
analysis · presets/analysis/gpu/horizon_breadth.qs
Market breadth for a single instrument: forty moving averages from 5 to 200 bars act as forty voters. Breadth is the share of them the price is above, and participation is the share that are rising. Breadth near 100% means every horizon agrees on up; near 0% every horizon agrees on down; mid-range readings are transition. A breadth thrust (from washed-out to nearly unanimous within a few bars) is one of the most reliable trend-start signals, and a breadth washout is its mirror. Both are marked, with the overbought / oversold zones drawn behind.
analysis "Horizon Breadth Thrust" {
pane = hpane
height = 0.26
render = webgpu
}
/// Market breadth for a single instrument: forty moving averages from 5 to 200 bars act as forty
/// voters. Breadth is the share of them the price is above, and participation is the share that
/// are rising. Breadth near 100% means every horizon agrees on up; near 0% every horizon agrees
/// on down; mid-range readings are transition.
/// A breadth thrust (from washed-out to nearly unanimous within a few bars) is one of the most
/// reliable trend-start signals, and a breadth washout is its mirror. Both are marked, with the
/// overbought / oversold zones drawn behind.
src = param.source(close, title = "Source")
first = param.int(5, title = "Shortest average", min = 2, max = 50, group = "Voters")
gap = param.int(5, title = "Average step", min = 1, max = 50, group = "Voters")
smooth = param.int(3, title = "Smoothing", min = 1, max = 20, group = "Voters")
lo_th = param.float(25, title = "Washed out below (%)", min = 1, max = 49, group = "Thrust")
hi_th = param.float(75, title = "Unanimous above (%)", min = 51, max = 99, group = "Thrust")
window = param.int(12, title = "Thrust window (bars)", min = 2, max = 60, group = "Thrust")
above = 0
rising = 0
for k in 0..=39 {
m = ta.sma(src, first + k * gap)
if src > m { above := above + 1 }
if m > m[1] { rising := rising + 1 }
}
breadth = ta.ema(above / 40 * 100, smooth)
partic = ta.ema(rising / 40 * 100, smooth)
top_z = render.line(100, "Top", color = color.transparent, width = 0)
hi_z = render.line(hi_th, "Unanimous", color = color.translucent(color.emerald.500, 55), width = 0.8, stroke = style.dashed)
mid = render.line(50, "Mid", color = color.translucent(color.slate.400, 50), width = 0.6)
lo_z = render.line(lo_th, "Washed out", color = color.translucent(color.rose.500, 55), width = 0.8, stroke = style.dashed)
bot_z = render.line(0, "Bottom", color = color.transparent, width = 0)
render.band(top_z, hi_z, color = color.emerald.500, opacity = 0.06, title = "Unanimous zone")
render.band(lo_z, bot_z, color = color.rose.500, opacity = 0.06, title = "Washout zone")
b = render.line(breadth, "Breadth", color = color.gradient(breadth, 0, 100, color.rose.400, color.emerald.300), width = 2.6)
render.line(partic, "Participation", color = color.translucent(color.sky.300, 30), width = 1.2, stroke = style.dotted)
render.band(b, mid, color = breadth >= 50 ? color.translucent(color.emerald.400, math.clamp(92 - (breadth - 50) * 0.8, 55, 92)) : color.translucent(color.rose.500, math.clamp(92 - (50 - breadth) * 0.8, 55, 92)), opacity = 1, title = "Breadth cloud")
render.histogram(50 + (breadth - breadth[5]) / 2, "Breadth impulse", base = 50, color = breadth >= breadth[5] ? color.translucent(color.teal.400, 55) : color.translucent(color.pink.500, 55), width = 1)
thrust = breadth >= hi_th and ta.lowest(breadth, window) <= lo_th
washout = breadth <= lo_th and ta.highest(breadth, window) >= hi_th
thrust_new = thrust and not thrust[1]
washout_new = washout and not washout[1]
render.mark(thrust_new, shape = shape.arrow_up, at = anchor.bottom, color = color.emerald.300, text = "Thrust", title = "Breadth thrust")
render.mark(washout_new, shape = shape.arrow_down, at = anchor.top, color = color.rose.400, text = "Washout", title = "Breadth washout")
render.mark(ta.cross_up(breadth, 50) and partic > 50, shape = shape.circle, at = anchor.bottom, color = color.teal.300, size = 0.5, title = "Breadth turns up")
render.mark(ta.cross_down(breadth, 50) and partic < 50, shape = shape.circle, at = anchor.top, color = color.pink.300, size = 0.5, title = "Breadth turns down")
since_thrust = ta.bars_since(thrust_new)
persist t = draw.table.create(anchor.top_right, 3, 2)
if bar.is_last {
draw.table.cell(t, 0, 0, "Breadth", text_color = color.slate.400)
draw.table.cell(t, 0, 1, str.format("{:.0f}%", breadth), bg_color = color.gradient(breadth, 0, 100, color.rose.700, color.emerald.700))
draw.table.cell(t, 1, 0, "Participation", text_color = color.slate.400)
draw.table.cell(t, 1, 1, str.format("{:.0f}%", partic))
draw.table.cell(t, 2, 0, "Last thrust", text_color = color.slate.400)
draw.table.cell(t, 2, 1, math.is_na(since_thrust) ? "none" : str.format("{} bars ago", since_thrust))
}
alert.rule(thrust_new, "Breadth thrust", "Breadth went from washed out to nearly unanimous within the thrust window")
alert.rule(washout_new, "Breadth washout", "Breadth collapsed from nearly unanimous to washed out within the thrust window")Hull Slope Field
analysis · presets/analysis/gpu/hull_slope_field.qs
The slope of twenty Hull moving averages, in ATR per bar. Hull averages react quickly, so turns show up early and travel from the short rows to the long ones. Cyan = rising, magenta = falling, intensity by speed. The line counts rising rows minus falling rows.
analysis "Hull Slope Field" {
pane = hpane
height = 0.24
render = webgpu
}
/// The slope of twenty Hull moving averages, in ATR per bar. Hull averages react quickly, so turns
/// show up early and travel from the short rows to the long ones. Cyan = rising, magenta =
/// falling, intensity by speed. The line counts rising rows minus falling rows.
first = param.int(6, title = "Shortest length", min = 4, max = 500, group = "Field")
gap = param.int(6, title = "Length step", min = 1, max = 500, group = "Field")
a = ta.atr(50)
edge = render.line(-0.5, "Edge 0", color = color.transparent, width = 0)
net = 0
for k in 0..=19 {
len = first + k * gap
h = ta.hull(close, len)
sl = (h - h[1]) / math.max(a, 1e-12)
net := net + (sl > 0 ? 1 : -1)
t = math.clamp(math.abs(sl) / 0.3, 0, 1)
c = math.is_na(sl) ? na : sl >= 0 ? color.gradient(t, 0, 1, color.slate.900, color.cyan.300) : color.gradient(t, 0, 1, color.slate.900, color.fuchsia.400)
top = render.line(k + 0.5, str.format("Edge {}", k + 1), color = color.transparent, width = 0)
render.band(top, edge, color = c, opacity = 1, title = str.format("Row {}", k + 1))
edge := top
}
render.line(9.5 + net / 2, "Net rising", color = color.translucent(color.white, 15), width = 1.6)
render.mark(net == 20 and net[1] < 20, shape = shape.triangle_up, at = anchor.bottom, color = color.cyan.300, title = "Every Hull rising")
render.mark(net == -20 and net[1] > -20, shape = shape.triangle_down, at = anchor.top, color = color.fuchsia.400, title = "Every Hull falling")Hurst Persistence Field
analysis · presets/analysis/gpu/hurst_persistence_field.qs · uses viz/premium
The rescaled-range Hurst exponent on 24 windows, as a heatmap. Warm rows persist (H above 0.5: moves tend to continue at that horizon), cool rows mean-revert (H below 0.5), dark rows behave like a random walk. Short windows sit at the bottom, long windows at the top. Reading: warm across the field = trend-following conditions at every horizon; cool at the bottom and warm at the top = fade intraday swings inside a larger trend. The top strip is the consensus. Signals: regime flips when the consensus crosses the persistence bands. Method: Hurst (1951) rescaled range on log returns, Mandelbrot and Wallis (1969).
analysis "Hurst Persistence Field" {
pane = hpane
height = 0.3
render = webgpu
short_title = "HPF"
precision = 2
}
/// The rescaled-range Hurst exponent on 24 windows, as a heatmap. Warm rows persist (H above 0.5:
/// moves tend to continue at that horizon), cool rows mean-revert (H below 0.5), dark rows behave
/// like a random walk. Short windows sit at the bottom, long windows at the top.
/// Reading: warm across the field = trend-following conditions at every horizon; cool at the bottom
/// and warm at the top = fade intraday swings inside a larger trend. The top strip is the
/// consensus. Signals: regime flips when the consensus crosses the persistence bands.
/// Method: Hurst (1951) rescaled range on log returns, Mandelbrot and Wallis (1969).
use "viz/premium" as pr
w_min = param.int(32, title = "Shortest window", min = 16, max = 200, group = "Field")
w_step = param.int(8, title = "Window step", min = 2, max = 50, group = "Field")
band = param.float(0.05, title = "Random-walk band", min = 0.01, max = 0.2, step = 0.01, group = "Signals")
ret = math.log(close / close[1])
total = 0.0
warm = 0
edge = render.line(-0.5, "Edge 0", color = color.transparent, width = 0)
for k in 0..=23 {
h = stat.hurst(ret, w_min + k * w_step)
total := total + math.coalesce(h, 0.5)
if h > 0.5 + band { warm := warm + 1 }
top = render.line(k + 0.5, str.format("Edge {}", k + 1), color = color.transparent, width = 0)
render.band(top, edge, color = pr.diverging(h - 0.5, 0.2, color.orange.400, color.sky.400), opacity = 1, title = str.format("Window {}", k + 1))
edge := top
}
avg = total / 24
cap_lo = render.line(24.1, "Consensus low", color = color.transparent, width = 0)
cap_hi = render.line(26.1, "Consensus high", color = color.transparent, width = 0)
render.band(cap_hi, cap_lo, color = pr.diverging(avg - 0.5, 0.12, color.orange.300, color.sky.300), opacity = 1, title = "Consensus")
render.line(11.5 + math.clamp((avg - 0.5) / 0.2, -1, 1) * 11.5, "Persistence balance", color = color.translucent(color.white, 25), width = 1.5)
persistent = avg > 0.5 + band
reverting = avg < 0.5 - band
render.mark(persistent and not persistent[1], shape = shape.triangle_up, at = anchor.top, color = color.orange.300, size = 0.8, title = "Persistent regime")
render.mark(reverting and not reverting[1], shape = shape.triangle_down, at = anchor.bottom, color = color.sky.300, size = 0.8, title = "Reverting regime")
alert.rule(persistent and not persistent[1], "Persistent regime", "Hurst consensus moved above the random-walk band")
alert.rule(reverting and not reverting[1], "Reverting regime", "Hurst consensus moved below the random-walk band")
persist t = draw.table.create(anchor.top_right, 5, 2)
if bar.is_last {
pr.heading(t, 2, "Hurst Persistence Field", "WebGPU Studio · rescaled range")
pr.row(t, 2, "Consensus H", str.format("{:.3f}", avg), persistent ? color.orange.300 : reverting ? color.sky.300 : color.slate.300)
pr.row(t, 3, "Persistent windows", str.format("{} / 24", warm), color.white)
pr.row(t, 4, "Regime", persistent ? "Trend-following" : reverting ? "Mean-reverting" : "Random walk", color.white)
}Kalman Regime Channel
analysis · presets/analysis/gpu/kalman_regime_channel.qs
A Kalman-filtered fair value with 1, 2 and 3 sigma channels whose color is the market regime. The regime comes from the efficiency ratio (how straight the path is) and the Hurst exponent (persistence of the moves): trending up, trending down, mean-reverting or undecided. The same 2 sigma touch means opposite things in those regimes, so the signals change with it: in a trend a close beyond 2 sigma is a breakout, in a mean-reverting market it is a fade back towards fair value.
analysis "Kalman Regime Channel" {
overlay = true
render = webgpu
max_objects = 120
}
/// A Kalman-filtered fair value with 1, 2 and 3 sigma channels whose color is the market regime.
/// The regime comes from the efficiency ratio (how straight the path is) and the Hurst exponent
/// (persistence of the moves): trending up, trending down, mean-reverting or undecided. The same 2 sigma touch
/// means opposite things in those regimes, so the signals change with it: in a trend a close
/// beyond 2 sigma is a breakout, in a mean-reverting market it is a fade back towards fair value.
src = param.source(close, title = "Source")
q_noise = param.float(0.01, title = "Process noise", min = 0.0001, max = 1, step = 0.001, group = "Filter")
r_noise = param.float(0.5, title = "Measurement noise", min = 0.01, max = 10, step = 0.01, group = "Filter")
sig_len = param.int(60, title = "Residual window", min = 10, max = 500, group = "Filter")
h_len = param.int(120, title = "Hurst window", min = 32, max = 1000, group = "Regime")
e_len = param.int(30, title = "Efficiency window", min = 5, max = 300, group = "Regime")
e_trend = param.float(0.35, title = "Trending above efficiency", min = 0.05, max = 0.9, step = 0.01, group = "Regime")
h_mr = param.float(0.45, title = "Mean-reverting below Hurst", min = 0.1, max = 0.6, step = 0.01, group = "Regime")
color_bars = param.bool(true, title = "Color candles", group = "Style")
fair = ta.kalman(src, q_noise, r_noise)
sd = math.max(stat.stdev(src - fair, sig_len), 1e-12)
z = (src - fair) / sd
hurst = stat.hurst(src, h_len)
eff = hft.efficiency(src, e_len)
slope = fair - fair[5]
// 1 trend up, -1 trend down, 2 mean-reverting, 0 undecided
raw = eff >= e_trend ? (slope >= 0 ? 1 : -1) : hurst <= h_mr and eff < e_trend * 0.5 ? 2 : 0
// a regime holds until another one is confirmed for three bars (no flicker through "undecided")
persist regime = 0
persist pending = 0
persist count = 0
if raw != 0 and raw != regime {
count := raw == pending ? count + 1 : 1
pending := raw
if count >= 3 { regime := raw }
} else {
count := 0
}
tone = regime == 1 ? color.emerald.400 : regime == -1 ? color.rose.500 : regime == 2 ? color.violet.400 : color.slate.400
c = render.line(fair, "Fair value", color = tone, width = 2.4)
u1 = render.line(fair + sd, "Upper 1 sigma", color = color.translucent(tone, 70), width = 0.7)
l1 = render.line(fair - sd, "Lower 1 sigma", color = color.translucent(tone, 70), width = 0.7)
u2 = render.line(fair + 2 * sd, "Upper 2 sigma", color = color.translucent(tone, 45), width = 1)
l2 = render.line(fair - 2 * sd, "Lower 2 sigma", color = color.translucent(tone, 45), width = 1)
u3 = render.line(fair + 3 * sd, "Upper 3 sigma", color = color.translucent(tone, 65), width = 0.8, stroke = style.dotted)
l3 = render.line(fair - 3 * sd, "Lower 3 sigma", color = color.translucent(tone, 65), width = 0.8, stroke = style.dotted)
render.band(u1, l1, color = color.translucent(tone, 78), opacity = 1, title = "Core channel")
render.band(u2, u1, color = color.translucent(tone, 86), opacity = 1, title = "Upper channel")
render.band(l1, l2, color = color.translucent(tone, 86), opacity = 1, title = "Lower channel")
render.band(u3, u2, color = color.translucent(tone, 93), opacity = 1, title = "Upper fringe")
render.band(l2, l3, color = color.translucent(tone, 93), opacity = 1, title = "Lower fringe")
breakout_up = regime == 1 and z > 2 and z[1] <= 2
breakout_dn = regime == -1 and z < -2 and z[1] >= -2
fade_dn = regime == 2 and z[1] >= 2 and z < 2
fade_up = regime == 2 and z[1] <= -2 and z > -2
render.mark(breakout_up, shape = shape.triangle_up, at = anchor.below_bar, color = color.emerald.300, title = "Trend breakout up")
render.mark(breakout_dn, shape = shape.triangle_down, at = anchor.above_bar, color = color.rose.400, title = "Trend breakout down")
render.mark(fade_dn, shape = shape.circle, at = anchor.above_bar, color = color.violet.300, size = 0.8, title = "Fade short")
render.mark(fade_up, shape = shape.circle, at = anchor.below_bar, color = color.violet.300, size = 0.8, title = "Fade long")
if regime != regime[1] and regime[1] != 0 {
draw.tag.create(bar.index, regime == -1 ? high + sd * 0.5 : low - sd * 0.5, regime == 1 ? "Trend up" : regime == -1 ? "Trend down" : "Mean-revert", color = tone, shape = regime == -1 ? shape.tag_down : shape.tag_up)
}
render.candle_color(color_bars ? color.translucent(tone, math.clamp(60 - math.abs(z) * 20, 0, 60)) : na)
persist t = draw.table.create(anchor.top_right, 4, 2)
if bar.is_last {
draw.table.cell(t, 0, 0, "Regime", text_color = color.slate.400)
draw.table.cell(t, 0, 1, regime == 1 ? "Trend up" : regime == -1 ? "Trend down" : regime == 2 ? "Mean-reverting" : "Undecided", bg_color = color.translucent(tone, 30), text_color = color.white)
draw.table.cell(t, 1, 0, "Hurst", text_color = color.slate.400)
draw.table.cell(t, 1, 1, str.format("{:.2f}", hurst))
draw.table.cell(t, 2, 0, "Efficiency", text_color = color.slate.400)
draw.table.cell(t, 2, 1, str.format("{:.0%}", eff))
draw.table.cell(t, 3, 0, "Position", text_color = color.slate.400)
draw.table.cell(t, 3, 1, str.format("{:+.2f} sigma", z))
}
alert.rule(breakout_up or breakout_dn, "Regime breakout", "A close beyond 2 sigma in the direction of a persistent trend")
alert.rule(fade_up or fade_dn, "Mean-reversion fade", "Price fell back inside 2 sigma in a mean-reverting regime")Liquidity Pressure Waves
analysis · presets/analysis/gpu/liquidity_pressure.qs
Where each bar closed inside its range, weighted by its volume, splits the bar into buying and selling pressure. The net pressure is shown on three horizons: per-bar columns (shaded by relative volume), a fast wave and a slow tide, each normalised by the volume of the same horizon so the whole pane reads in percent of volume. The cloud between the wave and the tide takes the side that is winning. Absorption marks bars with heavy volume, a small range and pressure against the bar's own direction: someone large is soaking up the other side. Divergences compare the tide with price.
analysis "Liquidity Pressure Waves" {
pane = hpane
height = 0.28
render = webgpu
}
/// Where each bar closed inside its range, weighted by its volume, splits the bar into buying and
/// selling pressure. The net pressure is shown on three horizons: per-bar columns (shaded by
/// relative volume), a fast wave and a slow tide, each normalised by the volume of the same
/// horizon so the whole pane reads in percent of volume. The cloud between the wave and the tide takes
/// the side that is winning.
/// Absorption marks bars with heavy volume, a small range and pressure against the bar's own
/// direction: someone large is soaking up the other side. Divergences compare the tide with price.
fast_len = param.int(8, title = "Wave length", min = 2, max = 100, group = "Horizons")
slow_len = param.int(34, title = "Tide length", min = 5, max = 400, group = "Horizons")
rv_len = param.int(50, title = "Relative volume window", min = 5, max = 500, group = "Absorption")
abs_rv = param.float(1.8, title = "Absorption volume (x average)", min = 1, max = 10, step = 0.1, group = "Absorption")
abs_rng = param.float(0.7, title = "Absorption range (x ATR)", min = 0.1, max = 2, step = 0.05, group = "Absorption")
rng = math.max(high - low, 1e-12)
clv = ((close - low) - (high - close)) / rng
buy = volume * (1 + clv) / 2
sell = volume * (1 - clv) / 2
vol_fast = math.max(ta.ema(volume, fast_len), 1e-12)
vol_slow = math.max(ta.ema(volume, slow_len), 1e-12)
bar_net = clv * 100 // (buy - sell) in percent of the bar's own volume
wave = ta.ema(buy - sell, fast_len) / vol_fast * 100
tide = ta.ema(buy - sell, slow_len) / vol_slow * 100
rv = volume / math.max(ta.sma(volume, rv_len), 1e-12)
col = bar_net >= 0 ? color.gradient(rv, 0.5, 3, color.teal.900, color.teal.300) : color.gradient(rv, 0.5, 3, color.rose.900, color.rose.400)
render.histogram(bar_net, "Bar pressure", color = color.translucent(col, math.clamp(97 - rv * 25, 45, 95)), width = 1)
zero = render.line(0, "Zero", color = color.translucent(color.slate.500, 60), width = 0.6)
w = render.spline(wave, "Wave", color = wave >= 0 ? color.cyan.300 : color.pink.400, width = 2)
t = render.line(tide, "Tide", color = tide >= 0 ? color.emerald.400 : color.rose.500, width = 2.6)
render.band(w, t, color = wave >= tide ? color.translucent(color.cyan.400, 70) : color.translucent(color.pink.500, 70), opacity = 1, title = "Pressure cloud")
render.band(t, zero, color = tide >= 0 ? color.translucent(color.emerald.500, 88) : color.translucent(color.rose.500, 88), opacity = 1, title = "Tide fill")
a = ta.atr(14)
absorb_buy = rv >= abs_rv and rng <= abs_rng * a and close < open and clv > 0.2
absorb_sell = rv >= abs_rv and rng <= abs_rng * a and close > open and clv < -0.2
render.mark(absorb_buy, shape = shape.circle, at = anchor.bottom, color = color.amber.300, size = 0.8, title = "Buy absorption")
render.mark(absorb_sell, shape = shape.circle, at = anchor.top, color = color.orange.400, size = 0.8, title = "Sell absorption")
(bull_div, bear_div) = ta.divergence(tide, 5, 5, 80, false)
render.mark(bull_div, shape = shape.triangle_up, at = anchor.bottom, color = color.emerald.300, title = "Bullish divergence")
render.mark(bear_div, shape = shape.triangle_down, at = anchor.top, color = color.rose.400, title = "Bearish divergence")
alert.rule(absorb_buy or absorb_sell, "Absorption", "Heavy volume, small range and pressure against the bar")
alert.rule(bull_div or bear_div, "Pressure divergence", "The tide diverges from price at a confirmed pivot")Liquidity-Weighted Trend Fan
analysis · presets/analysis/gpu/liquidity_weighted_fan.qs · uses viz/premium
Sixteen moving averages weighted by liquid flow, drawn as a fan. Each bar's weight is its volume per unit of range (volume / true range), so bars where size traded without moving price count most and thin, gappy bars count least. Lines are coloured along a smooth ramp from the fastest to the slowest; the ribbons between them take their colour from the fan's alignment, bar by bar. Reading: an ordered, bright fan = a trend carried by liquid flow; a fan that diverges from the plain average fan (shown as the white guide) = drift on thin volume that tends to retrace. Signals: full alignment and thin-drift warnings.
analysis "Liquidity-Weighted Trend Fan" {
overlay = true
render = webgpu
short_title = "LWF"
}
/// Sixteen moving averages weighted by liquid flow, drawn as a fan. Each bar's weight is its volume
/// per unit of range (volume / true range), so bars where size traded without moving price count
/// most and thin, gappy bars count least. Lines are coloured along a smooth ramp from the fastest to
/// the slowest; the ribbons between them take their colour from the fan's alignment, bar by bar.
/// Reading: an ordered, bright fan = a trend carried by liquid flow; a fan that diverges from the
/// plain average fan (shown as the white guide) = drift on thin volume that tends to retrace.
/// Signals: full alignment and thin-drift warnings.
use "viz/premium" as pr
l_min = param.int(8, title = "Fastest length", min = 2, max = 200, group = "Fan")
l_step = param.int(8, title = "Length step", min = 1, max = 100, group = "Fan")
thin = param.float(0.6, title = "Thin-drift gap (ATR)", min = 0.1, max = 5, step = 0.1, group = "Signals")
wv = volume / math.max(ta.true_range(), close * 1e-6)
up = 0
dn = 0
persist last_v = array.create(16, 0.0)
lw0 = ta.sma(close * wv, l_min) / math.max(ta.sma(wv, l_min), 1e-12)
array.set(last_v, 0, lw0)
prev = render.line(lw0, "Liquid average 1", color = color.ramp(0, 0, 15, "plasma"), width = 2)
for k in 1..=15 {
len = l_min + k * l_step
lw = ta.sma(close * wv, len) / math.max(ta.sma(wv, len), 1e-12)
if lw < array.get(last_v, k - 1) { up := up + 1 }
if lw > array.get(last_v, k - 1) { dn := dn + 1 }
array.set(last_v, k, lw)
p = render.line(lw, str.format("Liquid average {}", k + 1), color = color.ramp(k, 0, 15, "plasma"), width = k == 15 ? 2 : 1)
align = (up - dn) / 15
render.band(prev, p, color = pr.diverging(align, 1, color.cyan.400, color.rose.400), opacity = 0.10, title = str.format("Ribbon {}", k))
prev := p
}
plain = ta.sma(close, l_min + 8 * l_step)
lw_mid = ta.sma(close * wv, l_min + 8 * l_step) / math.max(ta.sma(wv, l_min + 8 * l_step), 1e-12)
render.line(plain, "Plain average guide", color = color.translucent(color.white, 45), width = 1, stroke = style.dashed)
gap = (plain - lw_mid) / math.max(ta.atr(50), 1e-12)
aligned_up = up == 15
aligned_dn = dn == 15
drift = math.abs(gap) > thin
render.mark(aligned_up and not aligned_up[1], shape = shape.triangle_up, at = anchor.below_bar, color = color.cyan.300, text = "Aligned", title = "Fan aligned up")
render.mark(aligned_dn and not aligned_dn[1], shape = shape.triangle_down, at = anchor.above_bar, color = color.rose.300, text = "Aligned", title = "Fan aligned down")
render.mark(drift and not drift[1], shape = shape.circle, at = anchor.above_bar, color = color.amber.300, size = 0.6, title = "Thin drift")
alert.rule(aligned_up or aligned_dn, "Liquid trend", "All sixteen liquidity-weighted averages are in order")
persist t = draw.table.create(anchor.top_right, 5, 2)
if bar.is_last {
pr.heading(t, 2, "Liquidity-Weighted Trend Fan", "WebGPU Studio · volume per unit range")
pr.row(t, 2, "Alignment", str.format("{} up / {} down", up, dn), up > dn ? pr.BULL : pr.BEAR)
pr.row(t, 3, "Plain vs liquid gap", str.format("{:+.2f} ATR", gap), drift ? pr.WARN : color.white)
pr.row(t, 4, "Trend support", drift ? "Thin drift" : "Liquid flow", drift ? pr.WARN : pr.BULL)
}MACD Spectrum
analysis · presets/analysis/gpu/macd_spectrum.qs · uses viz/premium
Sixteen MACD histograms from fast to slow, each divided by ATR so the rows are comparable. Green rows = the fast average is accelerating away above the slow one at that speed, red = below. Momentum turns travel from the top rows (fast) to the bottom rows (slow); when the fast rows flip while the slow rows still hold, a pullback is under way inside the slower trend.
analysis "MACD Spectrum" {
pane = hpane
height = 0.26
render = webgpu
}
/// Sixteen MACD histograms from fast to slow, each divided by ATR so the rows are comparable.
/// Green rows = the fast average is accelerating away above the slow one at that speed, red =
/// below. Momentum turns travel from the top rows (fast) to the bottom rows (slow); when the fast
/// rows flip while the slow rows still hold, a pullback is under way inside the slower trend.
use "viz/premium" as pr
a = ta.atr(50)
edge = render.line(-0.5, "Edge 0", color = color.transparent, width = 0)
score = 0.0
k = 0
for f in [3, 4, 5, 6, 8, 10, 12, 14, 17, 20, 24, 28, 34, 40, 48, 56] {
(m, s, h) = ta.macd(close, f, f * 2 + 2, 9)
hn = h / math.max(a, 1e-12)
score := score + math.sign(h)
top = render.line(15.5 - k + 1, str.format("Edge f{}", f), color = color.transparent, width = 0)
render.band(top, render.line(15.5 - k, str.format("Base f{}", f), color = color.transparent, width = 0), color = pr.diverging(hn, 0.3, color.emerald.300, color.red.400), opacity = 1, title = str.format("Fast {}", f))
k += 1
}
render.line(7.5 + score / 16 * 7.5, "Agreement", color = color.translucent(color.white, 15), width = 1.6)
full_up = score == 16
full_dn = score == -16
render.mark(full_up and not full_up[1], shape = shape.triangle_up, at = anchor.bottom, color = color.emerald.300, title = "All speeds up")
render.mark(full_dn and not full_dn[1], shape = shape.triangle_down, at = anchor.top, color = color.red.400, title = "All speeds down")
alert.rule((full_up and not full_up[1]) or (full_dn and not full_dn[1]), "MACD spectrum aligned", "Every MACD speed agrees")Momentum Field
analysis · presets/analysis/gpu/momentum_field.qs
Thirty-two momentum horizons as a seamless heatmap. Each row is the move over its lookback in units of the noise expected over that many bars (net change / (one-bar sigma * sqrt(length))), so every row is on the same scale: around +/-1 is ordinary, beyond +/-2 is a statistically unusual push. Fast horizons sit at the bottom, so a turn can be watched climbing the field from the short horizons to the long ones. The top strip is the field's consensus. Rows are filled bands with a per-bar color, so the heatmap has no gaps at any pane height.
analysis "Momentum Field" {
pane = hpane
height = 0.3
render = webgpu
}
/// Thirty-two momentum horizons as a seamless heatmap. Each row is the move over its lookback in
/// units of the noise expected over that many bars (net change / (one-bar sigma * sqrt(length))),
/// so every row is on the same scale: around +/-1 is ordinary, beyond +/-2 is a statistically
/// unusual push. Fast horizons sit at the bottom, so a turn can be watched climbing the field
/// from the short horizons to the long ones. The top strip is the field's consensus.
/// Rows are filled bands with a per-bar color, so the heatmap has no gaps at any pane height.
src = param.source(close, title = "Source")
first = param.int(2, title = "Shortest horizon", min = 1, max = 50, group = "Field")
gap = param.int(3, title = "Horizon step", min = 1, max = 20, group = "Field")
sig_len = param.int(100, title = "Noise window", min = 20, max = 2000, group = "Field")
scale = param.float(2.5, title = "Color scale (sigma)", min = 0.5, max = 6, step = 0.1, group = "Style")
sigma1 = math.max(stat.stdev(ta.change(src, 1), sig_len), 1e-12)
fn heat(z: float, s: float) -> color {
t = math.clamp(math.abs(z) / s, 0, 1)
return math.is_na(z) ? na : z >= 0 ? color.gradient(t, 0, 1, color.slate.900, color.lime.300) : color.gradient(t, 0, 1, color.slate.900, color.fuchsia.400)
}
total = 0.0
pos = 0
neg = 0
extreme = 0
edge = render.line(-0.5, "Edge 0", color = color.transparent, width = 0)
for k in 0..=31 {
len = first + k * gap
z = ta.change(src, len) / (sigma1 * math.sqrt(len))
total := total + z
if z > 0 { pos := pos + 1 }
if z < 0 { neg := neg + 1 }
if math.abs(z) >= 2 { extreme := extreme + 1 }
top = render.line(k + 0.5, str.format("Edge {}", k + 1), color = color.transparent, width = 0)
render.band(top, edge, color = heat(z, scale), opacity = 1, title = str.format("Row {}", k + 1))
edge := top
}
avg = total / 32
cap_lo = render.line(32.1, "Consensus low", color = color.transparent, width = 0)
cap_hi = render.line(34.1, "Consensus high", color = color.transparent, width = 0)
render.band(cap_hi, cap_lo, color = heat(avg * 1.5, scale), opacity = 1, title = "Consensus")
render.line(15.5 + math.clamp(avg / scale, -1, 1) * 15.5, "Field balance", color = color.translucent(color.white, 25), width = 1.5)
ignite_up = pos >= 29 and pos[1] < 29
ignite_dn = neg >= 29 and neg[1] < 29
render.mark(ignite_up, shape = shape.triangle_up, at = anchor.bottom, color = color.lime.300, title = "Field ignition up")
render.mark(ignite_dn, shape = shape.triangle_down, at = anchor.top, color = color.fuchsia.400, title = "Field ignition down")
render.mark(extreme >= 12 and extreme[1] < 12, shape = shape.diamond, at = anchor.top, color = color.amber.300, size = 0.7, title = "Statistical extreme")
persist t = draw.table.create(anchor.top_right, 3, 2)
if bar.is_last {
draw.table.cell(t, 0, 0, "Consensus", text_color = color.slate.400)
draw.table.cell(t, 0, 1, str.format("{:+.2f} sigma", avg), bg_color = heat(avg * 1.5, scale), text_color = color.white)
draw.table.cell(t, 1, 0, "Rising horizons", text_color = color.slate.400)
draw.table.cell(t, 1, 1, str.format("{} / 32", pos))
draw.table.cell(t, 2, 0, "Beyond 2 sigma", text_color = color.slate.400)
draw.table.cell(t, 2, 1, str.format("{}", extreme))
}
alert.rule(ignite_up, "Field ignition up", "At least 29 of 32 momentum horizons turned positive")
alert.rule(ignite_dn, "Field ignition down", "At least 29 of 32 momentum horizons turned negative")Momentum Term Structure
analysis · presets/analysis/gpu/momentum_term_structure.qs · uses viz/premium
Momentum across 24 horizons at once. Each row is the return over a horizon (from base bars up to 24 steps longer) divided by the volatility expected over that horizon, so every row is a t-statistic: 0 is noise, +/-2 is a strong move for that horizon. Rows are drawn as a heatmap on a cool-warm ramp, and the white trace is the term-structure slope (short-horizon strength minus long-horizon strength), scaled onto the row axis. Reading: a warm column from top to bottom is a trend every horizon agrees on. Warm long rows with cool short rows are a pullback inside the trend, the classic entry. Short rows warming while long rows are still cool are an early reversal. Signals: full alignment up / down, pullback inside an up / down term structure.
analysis "Momentum Term Structure" {
pane = hpane
height = 0.3
render = webgpu
short_title = "MTS"
precision = 2
}
/// Momentum across 24 horizons at once. Each row is the return over a horizon (from `base` bars up
/// to 24 steps longer) divided by the volatility expected over that horizon, so every row is a
/// t-statistic: 0 is noise, +/-2 is a strong move for that horizon. Rows are drawn as a heatmap on a
/// cool-warm ramp, and the white trace is the term-structure slope (short-horizon strength minus
/// long-horizon strength), scaled onto the row axis.
/// Reading: a warm column from top to bottom is a trend every horizon agrees on. Warm long rows
/// with cool short rows are a pullback inside the trend, the classic entry. Short rows warming
/// while long rows are still cool are an early reversal. Signals: full alignment up / down,
/// pullback inside an up / down term structure.
use "viz/premium" as pr
base = param.int(2, title = "Shortest horizon", min = 1, max = 50, group = "Horizons")
stride = param.int(4, title = "Horizon step", min = 1, max = 50, group = "Horizons")
vol_n = param.int(200, title = "Volatility window", min = 20, max = 5000, group = "Horizons")
scale = param.float(2.5, title = "Color scale (t)", min = 0.5, max = 6, step = 0.1, group = "Style")
agree = param.int(20, title = "Rows to align", min = 12, max = 24, group = "Signals")
r1 = math.log(close / close[1])
sig = stat.stdev(r1, vol_n)
pos = 0
neg = 0
short_sum = 0.0
long_sum = 0.0
edge = render.line(-0.5, "Edge 0", color = color.transparent, width = 0)
for k in 0..=23 {
h = base + k * stride
tk = math.log(close / close[h]) / math.max(sig * math.sqrt(h), 1e-12)
tz = math.coalesce(tk, 0)
if tz > 0.5 { pos := pos + 1 }
if tz < -0.5 { neg := neg + 1 }
if k < 6 { short_sum := short_sum + tz }
if k >= 18 { long_sum := long_sum + tz }
top = render.line(k + 0.5, str.format("Edge {}", k + 1), color = color.transparent, width = 0)
render.band(top, edge, color = color.ramp(math.clamp(tk, -scale, scale), -scale, scale, "coolwarm"), opacity = 1, title = str.format("Horizon {}", k + 1))
edge := top
}
st = short_sum / 6
lt = long_sum / 6
slope = st - lt
trace = 11.5 + math.clamp(slope / scale, -1, 1) * 11.5
render.spline(trace, "Term slope", color = color.translucent(color.white, 10), width = 1.5)
render.level(11.5, "Flat term structure", color = color.translucent(color.white, 75), stroke = style.dotted)
all_up = pos >= agree
all_dn = neg >= agree
pull_up = lt > 0.8 and st < -0.25 and st > st[1] and st[1] <= st[2]
pull_dn = lt < -0.8 and st > 0.25 and st < st[1] and st[1] >= st[2]
render.mark(all_up and not all_up[1], shape = shape.triangle_up, at = anchor.top, color = color.orange.300, size = 0.8, title = "Aligned up")
render.mark(all_dn and not all_dn[1], shape = shape.triangle_down, at = anchor.bottom, color = color.sky.300, size = 0.8, title = "Aligned down")
render.mark(pull_up, shape = shape.circle, at = anchor.bottom, color = color.emerald.300, size = 0.6, title = "Pullback in uptrend")
render.mark(pull_dn, shape = shape.circle, at = anchor.top, color = color.rose.300, size = 0.6, title = "Pullback in downtrend")
alert.rule(all_up and not all_up[1], "Aligned up", "Most horizons show significant upside momentum")
alert.rule(all_dn and not all_dn[1], "Aligned down", "Most horizons show significant downside momentum")
alert.rule(pull_up, "Pullback in uptrend", "Long horizons up, short horizons turning up from a dip")
alert.rule(pull_dn, "Pullback in downtrend", "Long horizons down, short horizons turning down from a bounce")
persist t = draw.table.create(anchor.top_right, 6, 2)
if bar.is_last {
shape_txt = lt > 1 and st > 1 ? "Aligned up" : lt < -1 and st < -1 ? "Aligned down" : lt > 1 and st < 0 ? "Pullback in uptrend" : lt < -1 and st > 0 ? "Bounce in downtrend" : st > 1 ? "Early turn up" : st < -1 ? "Early turn down" : "No edge"
pr.heading(t, 2, "Momentum Term Structure", "WebGPU Studio · 24 horizons, volatility-scaled")
pr.row(t, 2, "Short horizons (t)", str.format("{:+.2f}", st), st > 0 ? pr.BULL : pr.BEAR)
pr.row(t, 3, "Long horizons (t)", str.format("{:+.2f}", lt), lt > 0 ? pr.BULL : pr.BEAR)
pr.row(t, 4, "Rows up / down", str.format("{} / {}", pos, neg), color.white)
pr.row(t, 5, "Shape", shape_txt, color.white)
}Pressure Wave
analysis · presets/analysis/gpu/pressure_wave.qs · uses viz/premium
Buying and selling pressure as a glowing wave. Each bar's buy and sell volume (exchange order flow when the chart has it, otherwise a bulk-volume estimate) gives a signed pressure between -1 and +1; a fast and a slow volume-weighted average of it form the wave. The area under the fast wave is filled with a per-bar gradient on a profit-and-loss ramp, the slow wave is the tide line, and the histogram is their difference. Reading: a wave above the tide and rising = buyers pressing; a wave that rolls over while price still makes highs is a pressure divergence, the early warning of a top (mirror for bottoms). Signals: tide crosses, bullish and bearish pressure divergences at confirmed swings.
analysis "Pressure Wave" {
pane = hpane
height = 0.26
render = webgpu
short_title = "PWV"
precision = 2
}
/// Buying and selling pressure as a glowing wave. Each bar's buy and sell volume (exchange order
/// flow when the chart has it, otherwise a bulk-volume estimate) gives a signed pressure between
/// -1 and +1; a fast and a slow volume-weighted average of it form the wave. The area under the
/// fast wave is filled with a per-bar gradient on a profit-and-loss ramp, the slow wave is the
/// tide line, and the histogram is their difference.
/// Reading: a wave above the tide and rising = buyers pressing; a wave that rolls over while price
/// still makes highs is a pressure divergence, the early warning of a top (mirror for bottoms).
/// Signals: tide crosses, bullish and bearish pressure divergences at confirmed swings.
use "viz/premium" as pr
fast = param.int(8, title = "Wave length", min = 2, max = 200, group = "Wave")
slow = param.int(34, title = "Tide length", min = 5, max = 500, group = "Wave")
swing = param.int(5, title = "Swing bars", min = 2, max = 30, group = "Divergence")
lookbk = param.int(60, title = "Divergence reach (bars)", min = 10, max = 500, group = "Divergence")
(buy, sell, _) = hft.flow(fast)
signed = (buy - sell)
wave = ta.ema(signed, fast) / math.max(ta.ema(volume, fast), 1e-12)
tide = ta.ema(signed, slow) / math.max(ta.ema(volume, slow), 1e-12)
spread = wave - tide
zero = render.line(0, "Zero", color = color.translucent(color.white, 80), width = 1)
w = render.line(wave, "Wave", color = color.ramp(math.clamp(wave, -0.6, 0.6), -0.6, 0.6, "pnl"), width = 2)
render.band(w, zero, color = color.ramp(math.clamp(wave, -0.6, 0.6), -0.6, 0.6, "pnl", 45), opacity = 1, title = "Pressure")
render.line(tide, "Tide", color = color.translucent(color.white, 25), width = 1.25, stroke = style.dashed)
render.histogram(spread, "Wave minus tide", color = pr.diverging(spread, 0.25, color.lime.400, color.pink.400), width = 2)
// divergences at confirmed swings of price against the wave at the same bars
ph = ta.pivot_high(swing, swing)
pl = ta.pivot_low(swing, swing)
wave_at = wave[swing]
persist ph_p = na
persist ph_w = na
persist ph_x = 0
persist pl_p = na
persist pl_w = na
persist pl_x = 0
bear_div = false
bull_div = false
if not math.is_na(ph) {
if not math.is_na(ph_p) and bar.index - ph_x <= lookbk and ph > ph_p and wave_at < ph_w and ph_w > 0 { bear_div := true }
ph_p := ph
ph_w := wave_at
ph_x := bar.index
}
if not math.is_na(pl) {
if not math.is_na(pl_p) and bar.index - pl_x <= lookbk and pl < pl_p and wave_at > pl_w and pl_w < 0 { bull_div := true }
pl_p := pl
pl_w := wave_at
pl_x := bar.index
}
x_up = ta.cross_up(wave, tide) and wave < 0
x_dn = ta.cross_down(wave, tide) and wave > 0
render.mark(bull_div, shape = shape.triangle_up, at = anchor.bottom, color = color.emerald.300, text = "Div", title = "Bullish pressure divergence")
render.mark(bear_div, shape = shape.triangle_down, at = anchor.top, color = color.rose.300, text = "Div", title = "Bearish pressure divergence")
render.mark(x_up, shape = shape.circle, at = anchor.bottom, color = color.lime.300, size = 0.45, title = "Wave over tide")
render.mark(x_dn, shape = shape.circle, at = anchor.top, color = color.pink.300, size = 0.45, title = "Wave under tide")
alert.rule(bull_div, "Bullish pressure divergence", "Lower price low with a higher pressure low")
alert.rule(bear_div, "Bearish pressure divergence", "Higher price high with a lower pressure high")
since_bull = ta.bars_since(bull_div)
since_bear = ta.bars_since(bear_div)
persist t = draw.table.create(anchor.top_right, 6, 2)
if bar.is_last {
pr.heading(t, 2, "Pressure Wave", "WebGPU Studio · signed volume pressure")
pr.row(t, 2, "Wave", str.format("{:+.2f}", wave), wave > 0 ? pr.BULL : pr.BEAR)
pr.row(t, 3, "Tide", str.format("{:+.2f}", tide), tide > 0 ? pr.BULL : pr.BEAR)
pr.row(t, 4, "Momentum", spread > 0 ? "Buyers pressing" : "Sellers pressing", spread > 0 ? pr.BULL : pr.BEAR)
pr.row(t, 5, "Last divergence", math.is_na(since_bull) and math.is_na(since_bear) ? "None" : math.coalesce(since_bull, 1000000) < math.coalesce(since_bear, 1000000) ? str.format("Bullish, {} bars ago", since_bull) : str.format("Bearish, {} bars ago", since_bear), pr.WARN)
}Price Impact Field
analysis · presets/analysis/gpu/price_impact_field.qs · uses viz/premium
How much price moves per unit of traded volume, on 12 windows of two estimators: Kyle's lambda (bottom half, the regression slope of price change on signed volume) and Amihud illiquidity (top half, absolute return per unit of traded value). Every row is ranked against its own history, so bright means "thinner than usual for this market", whatever its size. Reading: a field lighting up = liquidity is draining, moves need less volume, breakouts and stop runs travel further; a dark field = deep liquidity, moves get absorbed. The glowing line is the average rank. Signals: impact spikes and liquidity returning. Method: Kyle (1985) lambda, Amihud (2002) illiquidity, percentile ranks.
analysis "Price Impact Field" {
pane = hpane
height = 0.3
render = webgpu
short_title = "PIF"
precision = 0
}
/// How much price moves per unit of traded volume, on 12 windows of two estimators: Kyle's lambda
/// (bottom half, the regression slope of price change on signed volume) and Amihud illiquidity
/// (top half, absolute return per unit of traded value). Every row is ranked against its own
/// history, so bright means "thinner than usual for this market", whatever its size.
/// Reading: a field lighting up = liquidity is draining, moves need less volume, breakouts and
/// stop runs travel further; a dark field = deep liquidity, moves get absorbed. The glowing line is
/// the average rank. Signals: impact spikes and liquidity returning.
/// Method: Kyle (1985) lambda, Amihud (2002) illiquidity, percentile ranks.
use "viz/premium" as pr
w_min = param.int(10, title = "Shortest window", min = 5, max = 200, group = "Field")
w_step = param.int(10, title = "Window step", min = 2, max = 100, group = "Field")
rank = param.int(300, title = "Rank history", min = 50, max = 5000, group = "Field")
spike = param.float(85, title = "Impact spike rank", min = 50, max = 99, group = "Signals")
total = 0.0
edge = render.line(-0.5, "Edge 0", color = color.transparent, width = 0)
for k in 0..=5 {
lam = stat.percent_rank(hft.kyle_lambda(w_min + k * w_step), rank)
total := total + math.coalesce(lam, 50)
top = render.line(k + 0.5, str.format("Edge {}", k + 1), color = color.transparent, width = 0)
render.band(top, edge, color = color.ramp(lam, 0, 100, "inferno"), opacity = 1, title = str.format("Kyle {}", k + 1))
edge := top
}
for k in 0..=5 {
ami = stat.percent_rank(hft.amihud(w_min + k * w_step), rank)
total := total + math.coalesce(ami, 50)
top = render.line(k + 6.5, str.format("Edge {}", k + 7), color = color.transparent, width = 0)
render.band(top, edge, color = color.ramp(ami, 0, 100, "inferno"), opacity = 1, title = str.format("Amihud {}", k + 1))
edge := top
}
avg = total / 12
y = -0.5 + 12 * avg / 100
render.line(y, "Impact glow", color = color.translucent(color.amber.300, 70), width = 5)
render.line(y, "Average impact rank", color = color.amber.100, width = 1.5)
thin = avg > spike
render.mark(thin and not thin[1], shape = shape.diamond, at = anchor.top, color = color.amber.300, size = 0.8, title = "Impact spike")
render.mark(avg < 40 and avg[1] >= 40, shape = shape.circle, at = anchor.bottom, color = color.sky.300, size = 0.6, title = "Liquidity returning")
alert.rule(thin and not thin[1], "Impact spike", "Price impact per unit of volume is unusually high")
persist t = draw.table.create(anchor.top_right, 5, 2)
if bar.is_last {
pr.heading(t, 2, "Price Impact Field", "WebGPU Studio · Kyle lambda + Amihud")
pr.row(t, 2, "Average impact rank", str.format("{:.0f} / 100", avg), thin ? pr.WARN : color.white)
pr.row(t, 3, "Liquidity", avg > spike ? "Thin" : avg < 40 ? "Deep" : "Normal", thin ? pr.WARN : pr.BULL)
pr.row(t, 4, "Breakout follow-through", avg > 60 ? "Likely" : "Absorbed", color.white)
}Price-Volume Correlation Strip
analysis · presets/analysis/gpu/price_volume_correlation.qs · uses viz/premium
Whether volume confirms price, on 12 windows. The bottom six rows correlate signed returns with volume (do up bars carry the volume?), the top six correlate the size of moves with volume (do big bars need big volume?). Diverging colours: emerald for positive correlation, rose for negative, dark for none. Reading: emerald at the bottom = buyers lead with volume (healthy advance); rose at the bottom = volume comes in on down bars (distribution); top rows going dark = moves happen without participation (thin, fragile trend). Signals: confirmation breaks and participation returning. Method: rolling Pearson correlation of log returns and log volume.
analysis "Price-Volume Correlation Strip" {
pane = hpane
height = 0.26
render = webgpu
short_title = "PVC"
precision = 2
}
/// Whether volume confirms price, on 12 windows. The bottom six rows correlate signed returns with
/// volume (do up bars carry the volume?), the top six correlate the size of moves with volume (do
/// big bars need big volume?). Diverging colours: emerald for positive correlation, rose for
/// negative, dark for none.
/// Reading: emerald at the bottom = buyers lead with volume (healthy advance); rose at the bottom =
/// volume comes in on down bars (distribution); top rows going dark = moves happen without
/// participation (thin, fragile trend). Signals: confirmation breaks and participation returning.
/// Method: rolling Pearson correlation of log returns and log volume.
use "viz/premium" as pr
w_min = param.int(20, title = "Shortest window", min = 10, max = 500, group = "Strip")
w_step = param.int(20, title = "Window step", min = 5, max = 200, group = "Strip")
brk = param.float(0.25, title = "Break threshold", min = 0.05, max = 0.8, step = 0.05, group = "Signals")
r = math.log(close / close[1])
lv = math.log(math.max(volume, 1))
sgn_total = 0.0
size_total = 0.0
edge = render.line(-0.5, "Edge 0", color = color.transparent, width = 0)
for k in 0..=5 {
cs = stat.corr(r, lv, w_min + k * w_step)
sgn_total := sgn_total + math.coalesce(cs, 0)
top = render.line(k + 0.5, str.format("Edge {}", k + 1), color = color.transparent, width = 0)
render.band(top, edge, color = pr.diverging(cs, 0.5, color.emerald.400, color.rose.400), opacity = 1, title = str.format("Direction {}", k + 1))
edge := top
}
for k in 0..=5 {
cz = stat.corr(math.abs(r), lv, w_min + k * w_step)
size_total := size_total + math.coalesce(cz, 0)
top = render.line(k + 6.5, str.format("Edge {}", k + 7), color = color.transparent, width = 0)
render.band(top, edge, color = pr.diverging(cz, 0.6, color.emerald.400, color.rose.400), opacity = 1, title = str.format("Participation {}", k + 1))
edge := top
}
dir_c = sgn_total / 6
part_c = size_total / 6
render.line(5.5 + math.clamp(dir_c / 0.5, -1, 1) * 6, "Confirmation balance", color = color.translucent(color.white, 25), width = 1.5)
dist = dir_c < -brk and dir_c[1] >= -brk
thin = part_c < 0.05 and part_c[1] >= 0.05
back = part_c > brk and part_c[1] <= brk
render.mark(dist, shape = shape.triangle_down, at = anchor.top, color = color.rose.300, size = 0.8, title = "Distribution")
render.mark(thin, shape = shape.x, at = anchor.top, color = color.amber.300, size = 0.7, title = "No participation")
render.mark(back, shape = shape.circle, at = anchor.bottom, color = color.emerald.300, size = 0.6, title = "Participation returning")
alert.rule(dist, "Volume on down bars", "Signed return-volume correlation turned negative")
persist t = draw.table.create(anchor.top_right, 5, 2)
if bar.is_last {
pr.heading(t, 2, "Price-Volume Correlation", "WebGPU Studio · return / volume")
pr.row(t, 2, "Direction correlation", str.format("{:+.2f}", dir_c), dir_c > 0 ? pr.BULL : pr.BEAR)
pr.row(t, 3, "Participation correlation", str.format("{:+.2f}", part_c), part_c > brk ? pr.BULL : pr.WARN)
pr.row(t, 4, "Read", dir_c < -brk ? "Distribution" : part_c < 0.05 ? "Thin moves" : "Confirmed", color.white)
}Range Expansion Field
analysis · presets/analysis/gpu/range_expansion_field.qs
The newest bar's true range against the average true range of twenty windows. Rows light up when the bar is large relative to that window: one bright column is a shock bar, a band of warm columns a sustained expansion. Up bars glow orange, down bars blue.
analysis "Range Expansion Field" {
pane = hpane
height = 0.22
render = webgpu
}
/// The newest bar's true range against the average true range of twenty windows. Rows light up
/// when the bar is large relative to that window: one bright column is a shock bar, a band of warm
/// columns a sustained expansion. Up bars glow orange, down bars blue.
first = param.int(5, title = "Shortest window", min = 2, max = 500)
gap = param.int(10, title = "Window step", min = 1, max = 500)
tr = ta.true_range()
dir = close >= open
edge = render.line(-0.5, "Edge 0", color = color.transparent, width = 0)
for k in 0..=19 {
r = tr / math.max(ta.sma(tr, first + k * gap), 1e-12)
c = math.is_na(r) ? na : dir ? color.gradient(r, 1, 3, color.slate.900, color.orange.300) : color.gradient(r, 1, 3, color.slate.900, color.sky.300)
top = render.line(k + 0.5, str.format("Edge {}", k + 1), color = color.transparent, width = 0)
render.band(top, edge, color = c, opacity = 1, title = str.format("Row {}", k + 1))
edge := top
}Regime Probability Ribbon
analysis · presets/analysis/gpu/regime_probability_ribbon.qs · uses viz/premium
Bull, neutral and bear probabilities as a stacked 100 % ribbon. Three factors are combined: trend (the slope of a regression in ATR per bar), trend quality (R squared of that regression) and order (one minus the Shannon entropy of returns). A softmax turns the scores into probabilities that always add up to 100; each layer is an edge-anchored gradient. Reading: a thick emerald layer = a confident up regime; a growing neutral (slate) layer = the trend is losing quality before it reverses; the dashboard turns the probabilities into an exposure hint (bull minus bear). Signals: regime changes once the new regime passes the confidence level.
analysis "Regime Probability Ribbon" {
pane = hpane
height = 0.24
render = webgpu
short_title = "RPR"
precision = 0
}
/// Bull, neutral and bear probabilities as a stacked 100 % ribbon. Three factors are combined: trend
/// (the slope of a regression in ATR per bar), trend quality (R squared of that regression) and
/// order (one minus the Shannon entropy of returns). A softmax turns the scores into probabilities
/// that always add up to 100; each layer is an edge-anchored gradient.
/// Reading: a thick emerald layer = a confident up regime; a growing neutral (slate) layer = the trend
/// is losing quality before it reverses; the dashboard turns the probabilities into an exposure
/// hint (bull minus bear). Signals: regime changes once the new regime passes the confidence level.
use "viz/premium" as pr
len = param.int(50, title = "Regression window", min = 10, max = 500, group = "Factors")
ent = param.int(60, title = "Entropy window", min = 20, max = 1000, group = "Factors")
temp = param.float(1.0, title = "Softmax temperature", min = 0.2, max = 5, step = 0.1, group = "Model")
conf = param.float(60, title = "Confidence (%)", min = 40, max = 95, group = "Signals")
(_, slope, _) = stat.linreg(close, len, 0)
s = slope / math.max(ta.atr(len), 1e-12) * math.sqrt(len)
q = stat.r_squared(close, len)
o = 1 - stat.entropy(math.log(close / close[1]), ent, 10)
strength = math.coalesce(q, 0) * (1 + 3 * math.coalesce(o, 0))
bull_s = (s * strength) / temp
bear_s = (-s * strength) / temp
neut_s = (1 - math.coalesce(q, 0)) * 1.5 / temp
mx = math.max(math.max(bull_s, bear_s), neut_s)
eb = math.exp(bull_s - mx)
en = math.exp(neut_s - mx)
er = math.exp(bear_s - mx)
pb = 100 * eb / (eb + en + er)
pn = 100 * en / (eb + en + er)
prb = 100 - pb - pn
y0 = render.line(0, "Base", color = color.transparent, width = 0)
y1 = render.line(prb, "Bear share", color = color.translucent(color.rose.300, 30), width = 1)
y2 = render.line(prb + pn, "Neutral share", color = color.translucent(color.slate.300, 30), width = 1)
y3 = render.line(100, "Top", color = color.transparent, width = 0)
render.band(y1, y0, texture = "gradient", direction = "edges", upper_color = color.rose.400, lower_color = color.rose.900, opacity = 0.9, title = "Bear")
render.band(y2, y1, texture = "gradient", direction = "edges", upper_color = color.slate.500, lower_color = color.slate.800, opacity = 0.9, title = "Neutral")
render.band(y3, y2, texture = "gradient", direction = "edges", upper_color = color.emerald.900, lower_color = color.emerald.400, opacity = 0.9, title = "Bull")
render.level(conf, "Confidence", color = color.translucent(color.white, 70), stroke = style.dotted)
regime = pb > conf ? 1 : prb > conf ? -1 : 0
render.mark(regime == 1 and regime[1] != 1, shape = shape.triangle_up, at = anchor.top, color = color.emerald.300, size = 0.8, title = "Bull regime")
render.mark(regime == -1 and regime[1] != -1, shape = shape.triangle_down, at = anchor.bottom, color = color.rose.300, size = 0.8, title = "Bear regime")
alert.rule(regime != regime[1] and regime != 0, "Regime change", "A regime passed the confidence level")
persist t = draw.table.create(anchor.top_right, 6, 2)
if bar.is_last {
pr.heading(t, 2, "Regime Probability Ribbon", "WebGPU Studio · softmax of three factors")
pr.row(t, 2, "Bull", str.format("{:.0f} %", pb), pr.BULL)
pr.row(t, 3, "Neutral", str.format("{:.0f} %", pn), color.slate.300)
pr.row(t, 4, "Bear", str.format("{:.0f} %", prb), pr.BEAR)
pr.row(t, 5, "Exposure hint", str.format("{:+.0f} %", pb - prb), pb > prb ? pr.BULL : pr.BEAR)
}Regression Slope Spectrum
analysis · presets/analysis/gpu/regression_slope_spectrum.qs · uses viz/premium
The statistical significance of the trend on twenty-four horizons. Each row is the t-statistic of a least-squares slope over that horizon: the slope divided by its standard error. |t| above 2 means the trend is unlikely to be noise. Green rows are significant up-trends, red rows significant down-trends, dark rows are horizons without a trend. Reading up the field shows where a trend is born (short rows light first) and where it holds; the line is the share of horizons with a significant trend, signed by direction.
analysis "Regression Slope Spectrum" {
pane = hpane
height = 0.26
render = webgpu
}
/// The statistical significance of the trend on twenty-four horizons. Each row is the t-statistic
/// of a least-squares slope over that horizon: the slope divided by its standard error. |t| above
/// 2 means the trend is unlikely to be noise. Green rows are significant up-trends, red rows
/// significant down-trends, dark rows are horizons without a trend.
/// Reading up the field shows where a trend is born (short rows light first) and where it holds;
/// the line is the share of horizons with a significant trend, signed by direction.
use "viz/premium" as pr
first = param.int(10, title = "Shortest horizon", min = 4, max = 200, group = "Field")
gap = param.int(8, title = "Horizon step", min = 1, max = 100, group = "Field")
t_sig = param.float(2.0, title = "Significant |t|", min = 1, max = 6, step = 0.1, group = "Field")
edge = render.line(-0.5, "Edge 0", color = color.transparent, width = 0)
share = 0.0
for k in 0..=23 {
len = first + k * gap
(v, s, _) = stat.linreg(close, len, 0)
sd = stat.stdev(close - v, len)
// standard error of the slope: residual sigma / sqrt(sum (x - mean x)^2)
sxx = len * (len * len - 1) / 12
tt = s / math.max(sd / math.sqrt(sxx), 1e-12)
share := share + (tt > t_sig ? 1 : tt < -t_sig ? -1 : 0)
top = render.line(k + 0.5, str.format("Edge {}", k + 1), color = color.transparent, width = 0)
render.band(top, edge, color = pr.diverging(tt, 3 * t_sig, color.emerald.300, color.red.400), opacity = 1, title = str.format("Row {}", k + 1))
edge := top
}
share := share / 24
render.line(11.5 + share * 11.5, "Signed significance", color = color.translucent(color.white, 15), width = 1.6)
born_up = share > 0.25 and share[1] <= 0.25
born_dn = share < -0.25 and share[1] >= -0.25
render.mark(born_up, shape = shape.triangle_up, at = anchor.bottom, color = color.emerald.300, title = "Trend becomes significant (up)")
render.mark(born_dn, shape = shape.triangle_down, at = anchor.top, color = color.red.400, title = "Trend becomes significant (down)")
alert.rule(born_up or born_dn, "Significant trend", "A quarter of the horizons now show a significant slope")Return Shape Field
analysis · presets/analysis/gpu/return_shape_field.qs
The shape of the return distribution on twenty-four windows. Each row is the skewness of log returns over that window: green = right-skewed (rare large gains, typical of squeezes), red = left-skewed (rare large losses, typical of complacent rallies). The line is the excess kurtosis of the middle window: fat tails ahead of the crowd. Marks show the whole spectrum flipping to negative skew, the classic warning before air pockets.
analysis "Return Shape Field" {
pane = hpane
height = 0.26
render = webgpu
}
/// The shape of the return distribution on twenty-four windows. Each row is the skewness of log
/// returns over that window: green = right-skewed (rare large gains, typical of squeezes), red =
/// left-skewed (rare large losses, typical of complacent rallies). The line is the excess kurtosis
/// of the middle window: fat tails ahead of the crowd. Marks show the whole spectrum flipping to
/// negative skew, the classic warning before air pockets.
first = param.int(20, title = "Shortest window", min = 10, max = 500, group = "Field")
gap = param.int(10, title = "Window step", min = 1, max = 200, group = "Field")
r = math.log(close / close[1])
edge = render.line(-0.5, "Edge 0", color = color.transparent, width = 0)
neg = 0
for k in 0..=23 {
len = first + k * gap
sk = stat.skew(r, len)
if sk < 0 { neg := neg + 1 }
t = math.clamp(math.abs(sk) / 1.5, 0, 1)
c = math.is_na(sk) ? na : sk >= 0 ? color.gradient(t, 0, 1, color.slate.900, color.lime.300) : color.gradient(t, 0, 1, color.slate.900, color.red.400)
top = render.line(k + 0.5, str.format("Edge {}", k + 1), color = color.transparent, width = 0)
render.band(top, edge, color = c, opacity = 1, title = str.format("Row {}", k + 1))
edge := top
}
ku = stat.kurtosis(r, first + 12 * gap)
render.line(math.clamp(ku, 0, 12) * 2, "Excess kurtosis x2", color = color.amber.300, width = 1.6)
all_neg = neg >= 22
render.mark(all_neg and not all_neg[1], shape = shape.triangle_down, at = anchor.top, color = color.red.400, title = "Negative skew everywhere")
alert.rule(all_neg and not all_neg[1], "Negative skew", "Returns are left-skewed on almost every window")Risk Ladder Trail
analysis · presets/analysis/gpu/risk_ladder_trail.qs
A complete trade plan on the chart. An ATR supertrend decides the side and trails the stop; every flip opens a new leg whose risk (entry to the initial stop) defines R. From there the ladder draws the 1R, 2R and 3R targets, fills the risk zone (entry to stop) and the reward zone (entry to 3R), and tags each target the first time it is reached. The dashboard reports the live leg: side, entry, stop, the size of R and the open R multiple, so position size and expectancy are always one glance away.
analysis "Risk Ladder Trail" {
overlay = true
render = webgpu
max_objects = 300
}
/// A complete trade plan on the chart. An ATR supertrend decides the side and trails the stop;
/// every flip opens a new leg whose risk (entry to the initial stop) defines R. From there the
/// ladder draws the 1R, 2R and 3R targets, fills the risk zone (entry to stop) and the reward zone
/// (entry to 3R), and tags each target the first time it is reached.
/// The dashboard reports the live leg: side, entry, stop, the size of R and the open R multiple,
/// so position size and expectancy are always one glance away.
factor = param.float(3, title = "ATR multiple", min = 0.5, max = 10, step = 0.1, group = "Trail")
atr_len = param.int(10, title = "ATR length", min = 2, max = 200, group = "Trail")
t1 = param.float(1, title = "Target 1 (R)", min = 0.25, max = 10, step = 0.25, group = "Targets")
t2 = param.float(2, title = "Target 2 (R)", min = 0.25, max = 10, step = 0.25, group = "Targets")
t3 = param.float(3, title = "Target 3 (R)", min = 0.25, max = 20, step = 0.25, group = "Targets")
(trail, dir) = ta.supertrend(factor, atr_len)
flip = dir != dir[1]
persist entry = na
persist leg_risk = na
persist hit1 = false
persist hit2 = false
persist hit3 = false
persist legs = 0
persist wins = 0
if flip and not math.is_na(dir[1]) {
if not math.is_na(entry) and hit1 { wins := wins + 1 }
legs := legs + 1
entry := close
leg_risk := math.max(math.abs(close - trail), 1e-12)
hit1 := false
hit2 := false
hit3 := false
}
leg_side = dir > 0 ? 1 : -1
tg1 = entry + leg_side * t1 * leg_risk
tg2 = entry + leg_side * t2 * leg_risk
tg3 = entry + leg_side * t3 * leg_risk
long_c = color.emerald.400
short_c = color.rose.400
tone = leg_side > 0 ? long_c : short_c
// the levels break on the flip bar, so one leg's ladder never joins the next one's
gap = flip ? na : 1.0
s = render.line(trail * gap, "Trailing stop", color = tone, width = 2.2)
e = render.line(entry * gap, "Entry", color = color.translucent(color.slate.200, 25), width = 1.2, stroke = style.dashed)
r1 = render.line(tg1 * gap, "Target 1", color = color.translucent(tone, 45), width = 1)
r2 = render.line(tg2 * gap, "Target 2", color = color.translucent(tone, 35), width = 1)
r3 = render.line(tg3 * gap, "Target 3", color = color.translucent(tone, 20), width = 1.4)
render.band(e, s, color = color.translucent(color.orange.500, 86), opacity = 1, title = "Risk zone")
render.band(r1, e, color = leg_side > 0 ? color.translucent(long_c, 90) : color.translucent(short_c, 90), opacity = 1, title = "Reward 1R")
render.band(r2, r1, color = leg_side > 0 ? color.translucent(long_c, 93) : color.translucent(short_c, 93), opacity = 1, title = "Reward 2R")
render.band(r3, r2, color = leg_side > 0 ? color.translucent(long_c, 95) : color.translucent(short_c, 95), opacity = 1, title = "Reward 3R")
if not math.is_na(entry) and not flip {
if not hit1 and (leg_side > 0 ? high >= tg1 : low <= tg1) {
hit1 := true
draw.tag.create(bar.index, tg1, str.format("{:.2g}R", t1), color = tone, shape = leg_side > 0 ? shape.tag_down : shape.tag_up)
}
if not hit2 and (leg_side > 0 ? high >= tg2 : low <= tg2) {
hit2 := true
draw.tag.create(bar.index, tg2, str.format("{:.2g}R", t2), color = tone, shape = leg_side > 0 ? shape.tag_down : shape.tag_up)
}
if not hit3 and (leg_side > 0 ? high >= tg3 : low <= tg3) {
hit3 := true
draw.tag.create(bar.index, tg3, str.format("{:.2g}R", t3), color = tone, shape = leg_side > 0 ? shape.tag_down : shape.tag_up)
}
}
render.mark(flip and dir > 0, shape = shape.triangle_up, at = anchor.below_bar, color = long_c, title = "Long leg")
render.mark(flip and dir < 0, shape = shape.triangle_down, at = anchor.above_bar, color = short_c, title = "Short leg")
open_r = (close - entry) * leg_side / leg_risk
persist tb = draw.table.create(anchor.top_right, 5, 2)
if bar.is_last {
draw.table.cell(tb, 0, 0, "Side", text_color = color.slate.400)
draw.table.cell(tb, 0, 1, leg_side > 0 ? "Long" : "Short", bg_color = color.translucent(tone, 30), text_color = color.white)
draw.table.cell(tb, 1, 0, "Entry / stop", text_color = color.slate.400)
draw.table.cell(tb, 1, 1, str.format("{:.2f} / {:.2f}", entry, trail))
draw.table.cell(tb, 2, 0, "1R", text_color = color.slate.400)
draw.table.cell(tb, 2, 1, str.format("{:.2f} ({:.2f}%)", leg_risk, leg_risk / entry * 100))
draw.table.cell(tb, 3, 0, "Open", text_color = color.slate.400)
draw.table.cell(tb, 3, 1, str.format("{:+.2f}R", open_r), bg_color = open_r >= 0 ? color.gradient(open_r, 0, 3, color.slate.700, color.emerald.700) : color.gradient(open_r, -1, 0, color.rose.700, color.slate.700))
draw.table.cell(tb, 4, 0, "Legs reaching target 1", text_color = color.slate.400)
draw.table.cell(tb, 4, 1, str.format("{} / {}", wins, math.max(legs - 1, 0)))
}
alert.rule(flip, "New leg", "The trail flipped; a new leg_risk ladder starts at this close")RSI Length Field
analysis · presets/analysis/gpu/rsi_length_field.qs
RSI on twenty-four lengths from 3 to 72 with the 50 line as the dark centre. Overbought rows glow amber, oversold rows violet; momentum exhaustion on every length lights the whole column.
analysis "RSI Length Field" {
pane = hpane
height = 0.24
render = webgpu
}
/// RSI on twenty-four lengths from 3 to 72 with the 50 line as the dark centre. Overbought rows
/// glow amber, oversold rows violet; momentum exhaustion on every length lights the whole column.
first = param.int(3, title = "Shortest length", min = 2, max = 100)
gap = param.int(3, title = "Length step", min = 1, max = 50)
edge = render.line(-0.5, "Edge 0", color = color.transparent, width = 0)
ob = 0
os = 0
for k in 0..=23 {
r = ta.rsi(close, first + k * gap)
if r > 70 { ob := ob + 1 }
if r < 30 { os := os + 1 }
c = math.is_na(r) ? na : r >= 50 ? color.gradient(r, 50, 85, color.slate.900, color.amber.300) : color.gradient(r, 15, 50, color.violet.400, color.slate.900)
top = render.line(k + 0.5, str.format("Edge {}", k + 1), color = color.transparent, width = 0)
render.band(top, edge, color = c, opacity = 1, title = str.format("Row {}", k + 1))
edge := top
}
render.line(11.5 + (ob - os) / 2, "Net extremes", color = color.translucent(color.white, 15), width = 1.6)
render.mark(ob >= 16 and ob[1] < 16, shape = shape.triangle_down, at = anchor.top, color = color.amber.300, title = "Overbought on most lengths")
render.mark(os >= 16 and os[1] < 16, shape = shape.triangle_up, at = anchor.bottom, color = color.violet.300, title = "Oversold on most lengths")Spectral Power Waterfall
analysis · presets/analysis/gpu/spectral_waterfall.qs · uses viz/premium
Cycle power at 24 cycle lengths on every bar, as a waterfall heatmap. Each row is the share of spectral power at one period (6 .. 52 bars) relative to the strongest period of that bar, from a complex demodulation of detrended price, so a bright row is a cycle the market is repeating. Reading: one bright row = a clean dominant cycle (time entries with its troughs); power spread over many rows = no cycle (trend or noise days); a bright band drifting up or down = the cycle is lengthening or shortening. The white trace marks the dominant period. Signals: "Cycle lock" when one period holds more than the lock share of power, "Cycle lost" when it drops away. Method: complex demodulation (Bloomfield, 1976): price times a unit phasor of each period, smoothed over the window.
analysis "Spectral Power Waterfall" {
pane = hpane
height = 0.32
render = webgpu
short_title = "SPW"
precision = 1
}
/// Cycle power at 24 cycle lengths on every bar, as a waterfall heatmap. Each row is the share of
/// spectral power at one period (6 .. 52 bars) relative to the strongest period of that bar, from a
/// complex demodulation of detrended price, so a bright row is a cycle the market is repeating.
/// Reading: one bright row = a clean dominant cycle (time entries with its troughs); power spread
/// over many rows = no cycle (trend or noise days); a bright band drifting up or down = the cycle
/// is lengthening or shortening. The white trace marks the dominant period.
/// Signals: "Cycle lock" when one period holds more than the lock share of power, "Cycle lost"
/// when it drops away. Method: complex demodulation (Bloomfield, 1976): price times a unit phasor of each period, smoothed over the window.
use "viz/premium" as pr
src = param.source(close, title = "Source")
win = param.int(64, title = "Smoothing bars", min = 16, max = 400, group = "Spectrum")
p_min = param.int(6, title = "Shortest period", min = 4, max = 40, group = "Spectrum")
p_step = param.int(2, title = "Period step", min = 1, max = 10, group = "Spectrum")
lock = param.float(0.12, title = "Lock share", min = 0.05, max = 0.5, step = 0.01, group = "Signals")
palette = param.choice("magma", title = "Palette", options = ["magma", "inferno", "viridis", "turbo", "plasma"], group = "Style")
x = src - ta.sma(src, win)
t_idx = bar.index
persist pw = array.create(24, 0.0)
total = 0.0
best = 0.0
best_k = 0
// complex demodulation: x times a unit phasor of each period, smoothed; |.|^2 is the power there
for k in 0..=23 {
per = p_min + k * p_step
ang = 2 * math.pi * t_idx / per
re = ta.ema(x * math.cos(ang), win)
im = ta.ema(x * math.sin(ang), win)
p = math.coalesce(re * re + im * im, 0)
array.set(pw, k, p)
total := total + p
if p > best {
best := p
best_k := k
}
}
edge = render.line(-0.5, "Edge 0", color = color.transparent, width = 0)
for k in 0..=23 {
rel = best > 0 ? math.pow(array.get(pw, k) / best, 1.6) : na
top = render.line(k + 0.5, str.format("Edge {}", k + 1), color = color.transparent, width = 0)
render.band(top, edge, color = color.ramp(rel, 0, 1, palette), opacity = 1, title = str.format("Period {}", k + 1))
edge := top
}
dom_share = total > 0 ? best / total : na
dom_period = p_min + best_k * p_step
render.line(total > 0 ? best_k : na, "Dominant period", color = color.translucent(color.white, 15), width = 1.5)
locked = dom_share >= lock
render.mark(locked and not locked[1], shape = shape.diamond, at = anchor.top, color = color.amber.300, size = 0.8, title = "Cycle lock")
render.mark(not locked and locked[1], shape = shape.x, at = anchor.top, color = color.slate.400, size = 0.7, title = "Cycle lost")
alert.rule(locked and not locked[1], "Cycle lock", "One cycle length now dominates the spectrum")
persist t = draw.table.create(anchor.top_right, 5, 2)
if bar.is_last {
pr.heading(t, 2, "Spectral Power Waterfall", "WebGPU Studio · complex demodulation")
pr.row(t, 2, "Dominant period", str.format("{} bars", dom_period), color.white)
pr.row(t, 3, "Power share", str.format("{:.0%}", dom_share), locked ? pr.WARN : color.slate.300)
pr.row(t, 4, "State", locked ? "Cycle lock" : "No dominant cycle", locked ? pr.BULL : pr.LABEL)
}Stochastic Field
analysis · presets/analysis/gpu/stochastic_field.qs
Stochastic %K on twenty-four lookbacks: where the close sits in the high-low range of each window. Gold rows = near the top of that range, blue rows = near the bottom. A column that is gold everywhere is a market pressing highs on every scale; the line is the average %K.
analysis "Stochastic Field" {
pane = hpane
height = 0.26
render = webgpu
}
/// Stochastic %K on twenty-four lookbacks: where the close sits in the high-low range of each
/// window. Gold rows = near the top of that range, blue rows = near the bottom. A column that is
/// gold everywhere is a market pressing highs on every scale; the line is the average %K.
first = param.int(5, title = "Shortest lookback", min = 2, max = 200, group = "Field")
gap = param.int(5, title = "Lookback step", min = 1, max = 200, group = "Field")
edge = render.line(-0.5, "Edge 0", color = color.transparent, width = 0)
total = 0.0
for k in 0..=23 {
len = first + k * gap
hh = ta.highest(high, len)
ll = ta.lowest(low, len)
kv = (close - ll) / math.max(hh - ll, 1e-12) * 100
total := total + kv
c = math.is_na(kv) ? na : kv >= 50 ? color.gradient(kv, 50, 100, color.slate.900, color.amber.300) : color.gradient(kv, 0, 50, color.blue.400, color.slate.900)
top = render.line(k + 0.5, str.format("Edge {}", k + 1), color = color.transparent, width = 0)
render.band(top, edge, color = c, opacity = 1, title = str.format("Row {}", k + 1))
edge := top
}
avg = total / 24
render.line(avg / 100 * 23 - 0.5, "Average %K", color = color.translucent(color.white, 15), width = 1.6)
render.mark(avg > 85 and avg[1] <= 85, shape = shape.triangle_down, at = anchor.top, color = color.amber.300, title = "Highs on every scale")
render.mark(avg < 15 and avg[1] >= 15, shape = shape.triangle_up, at = anchor.bottom, color = color.blue.300, title = "Lows on every scale")Trend Age Field
analysis · presets/analysis/gpu/trend_age_field.qs
How long price has stayed on one side of sixteen EMAs, from fast to slow. Each row's brightness is the age of the current side (bars since the last cross, relative to the EMA length): young trends are dim, mature ones bright; green above the average, red below. Old trends on the slow rows with fresh crosses on the fast rows mark pullbacks; fresh crosses everywhere mark turns.
analysis "Trend Age Field" {
pane = hpane
height = 0.24
render = webgpu
}
/// How long price has stayed on one side of sixteen EMAs, from fast to slow. Each row's brightness
/// is the age of the current side (bars since the last cross, relative to the EMA length): young
/// trends are dim, mature ones bright; green above the average, red below. Old trends on the slow
/// rows with fresh crosses on the fast rows mark pullbacks; fresh crosses everywhere mark turns.
edge = render.line(-0.5, "Edge 0", color = color.transparent, width = 0)
k = 0
for len in [5, 8, 10, 13, 16, 21, 26, 34, 42, 55, 68, 89, 110, 144, 180, 233] {
e = ta.ema(close, len)
above = close > e
age = ta.bars_since(above != above[1])
t = math.clamp(age / len, 0, 1)
c = math.is_na(e) ? na : above ? color.gradient(t, 0, 1, color.slate.900, color.emerald.300) : color.gradient(t, 0, 1, color.slate.900, color.red.400)
top = render.line(k + 0.5, str.format("EMA {} edge", len), color = color.transparent, width = 0)
render.band(top, edge, color = c, opacity = 1, title = str.format("EMA {}", len))
edge := top
k += 1
}Trend Fan Spectrum
analysis · presets/analysis/gpu/trend_fan_spectrum.qs
Twenty-four EMAs on a geometric ladder of lengths (5 .. ~250) drawn as one fan. Every line is colored by its own slope in ATR units, so the fan shows at a glance which horizons are accelerating, which are rolling over and where the turn is travelling. Neighbouring lines are filled, so the fan reads as one surface that opens in trends and folds in ranges. Alignment (the share of lines stacked in trend order) colors the candles; marks show the bars where the whole fan becomes aligned, and the dashboard reports alignment, fan width and the fastest / slowest slopes.
analysis "Trend Fan Spectrum" {
overlay = true
render = webgpu
max_objects = 100
}
/// Twenty-four EMAs on a geometric ladder of lengths (5 .. ~250) drawn as one fan. Every line is
/// colored by its own slope in ATR units, so the fan shows at a glance which horizons are
/// accelerating, which are rolling over and where the turn is travelling. Neighbouring lines are
/// filled, so the fan reads as one surface that opens in trends and folds in ranges.
/// Alignment (the share of lines stacked in trend order) colors the candles; marks show the bars
/// where the whole fan becomes aligned, and the dashboard reports alignment, fan width and the
/// fastest / slowest slopes.
src = param.source(close, title = "Source")
first = param.int(5, title = "Shortest length", min = 2, max = 50, group = "Fan")
ratio = param.float(1.185, title = "Length ratio", min = 1.05, max = 1.5, step = 0.005, group = "Fan")
atr_len = param.int(21, title = "ATR length", min = 5, max = 200, group = "Fan")
color_bars = param.bool(true, title = "Color candles", group = "Style")
show_fill = param.bool(true, title = "Fill the fan", group = "Style")
a = math.max(ta.atr(atr_len), 1e-12)
up_stack = 0
dn_stack = 0
rising = 0
prev = na
prev_plot = na
fast_slope = 0.0
slow_slope = 0.0
fast_line = 0.0
slow_line = 0.0
for k in 0..=23 {
len = math.max(2, math.round(first * math.pow(ratio, k)))
e = ta.ema(src, len)
slope = math.clamp((e - e[2]) / (2 * a), -0.6, 0.6)
if slope > 0 { rising := rising + 1 }
if k > 0 {
if prev > e { up_stack := up_stack + 1 }
if prev < e { dn_stack := dn_stack + 1 }
}
tint = slope >= 0 ? color.gradient(slope, 0, 0.6, color.slate.500, color.cyan.300) : color.gradient(slope, -0.6, 0, color.fuchsia.400, color.slate.500)
fade = 15 + k * 2.5
p = render.line(e, str.format("EMA {}", k + 1), color = color.translucent(tint, k == 0 or k == 23 ? 0 : fade), width = k == 0 or k == 23 ? 2.2 : 1)
if k > 0 and show_fill {
render.band(prev_plot, p, color = slope >= 0 ? color.cyan.500 : color.fuchsia.500, opacity = 0.035, title = str.format("Fan {}", k))
}
if k == 0 { fast_slope := slope
fast_line := e }
if k == 23 { slow_slope := slope
slow_line := e }
prev := e
prev_plot := p
}
// 23 neighbour pairs: +1 for every pair stacked in bull order, -1 for bear order
align = (up_stack - dn_stack) / 23
width_atr = math.abs(fast_line - slow_line) / a
bull_all = up_stack == 23
bear_all = dn_stack == 23
render.candle_color(color_bars ? color.gradient(align, -1, 1, color.fuchsia.500, color.cyan.400) : na)
render.mark(bull_all and not bull_all[1], shape = shape.triangle_up, at = anchor.below_bar, color = color.cyan.300, title = "Fan aligned up")
render.mark(bear_all and not bear_all[1], shape = shape.triangle_down, at = anchor.above_bar, color = color.fuchsia.400, title = "Fan aligned down")
render.mark(math.abs(align) < 0.2 and math.abs(align[1]) >= 0.2 and width_atr < 1, shape = shape.diamond, at = anchor.above_bar, color = color.amber.300, size = 0.6, title = "Fan folded")
persist t = draw.table.create(anchor.top_right, 4, 2)
if bar.is_last {
draw.table.cell(t, 0, 0, "Alignment", text_color = color.slate.400)
draw.table.cell(t, 0, 1, str.format("{:+.0f}%", align * 100), bg_color = color.gradient(align, -1, 1, color.fuchsia.700, color.cyan.700))
draw.table.cell(t, 1, 0, "Fan width", text_color = color.slate.400)
draw.table.cell(t, 1, 1, str.format("{:.1f} ATR", width_atr))
draw.table.cell(t, 2, 0, "Rising lines", text_color = color.slate.400)
draw.table.cell(t, 2, 1, str.format("{} / 24", rising))
draw.table.cell(t, 3, 0, "Fast / slow slope", text_color = color.slate.400)
draw.table.cell(t, 3, 1, str.format("{:+.2f} / {:+.2f}", fast_slope, slow_slope))
}
alert.rule(bull_all and not bull_all[1], "Fan aligned up", "All 24 EMAs are stacked in bullish order")
alert.rule(bear_all and not bear_all[1], "Fan aligned down", "All 24 EMAs are stacked in bearish order")Trend Quality Field
analysis · presets/analysis/gpu/trend_quality_field.qs
Trend quality on twenty-four horizons. Each row is the R² of a linear regression of price over that horizon, signed by the regression slope: bright green is a clean, straight up-trend, bright red a clean down-trend, and dark cells are noise whatever the net move. Reading up the field tells whether a move is a short burst or a trend that holds on every horizon. The line is the average signed quality; marks show where every horizon agrees on a clean trend.
analysis "Trend Quality Field" {
pane = hpane
height = 0.26
render = webgpu
}
/// Trend quality on twenty-four horizons. Each row is the R² of a linear regression of price over
/// that horizon, signed by the regression slope: bright green is a clean, straight up-trend,
/// bright red a clean down-trend, and dark cells are noise whatever the net move. Reading up the
/// field tells whether a move is a short burst or a trend that holds on every horizon.
/// The line is the average signed quality; marks show where every horizon agrees on a clean trend.
src = param.source(close, title = "Source")
first = param.int(8, title = "Shortest horizon", min = 3, max = 100, group = "Field")
gap = param.int(6, title = "Horizon step", min = 1, max = 50, group = "Field")
fn heat(q: float) -> color {
t = math.clamp(math.abs(q), 0, 1)
return math.is_na(q) ? na : q >= 0 ? color.gradient(t, 0.1, 0.9, color.slate.900, color.emerald.300) : color.gradient(t, 0.1, 0.9, color.slate.900, color.red.400)
}
edge = render.line(-0.5, "Edge 0", color = color.transparent, width = 0)
total = 0.0
clean_up = 0
clean_dn = 0
for k in 0..=23 {
len = first + k * gap
r2 = stat.r_squared(src, len)
(fit, slope, icpt) = stat.linreg(src, len)
q = (slope >= 0 ? 1 : -1) * r2
total := total + q
if q > 0.6 { clean_up := clean_up + 1 }
if q < -0.6 { clean_dn := clean_dn + 1 }
top = render.line(k + 0.5, str.format("Edge {}", k + 1), color = color.transparent, width = 0)
render.band(top, edge, color = heat(q), opacity = 1, title = str.format("Row {}", k + 1))
edge := top
}
avg = total / 24
render.line(11.5 + avg * 11.5, "Average quality", color = color.translucent(color.white, 20), width = 1.6)
all_up = clean_up >= 20
all_dn = clean_dn >= 20
render.mark(all_up and not all_up[1], shape = shape.triangle_up, at = anchor.bottom, color = color.emerald.300, title = "Clean up-trend")
render.mark(all_dn and not all_dn[1], shape = shape.triangle_down, at = anchor.top, color = color.red.400, title = "Clean down-trend")
persist t = draw.table.create(anchor.top_right, 2, 2)
if bar.is_last {
draw.table.cell(t, 0, 0, "Average quality", text_color = color.slate.400)
draw.table.cell(t, 0, 1, str.format("{:+.2f}", avg), bg_color = heat(avg))
draw.table.cell(t, 1, 0, "Clean horizons", text_color = color.slate.400)
draw.table.cell(t, 1, 1, str.format("{} up / {} down", clean_up, clean_dn))
}
alert.rule(all_up and not all_up[1], "Clean up-trend", "At least 20 of 24 horizons show a straight rising regression")
alert.rule(all_dn and not all_dn[1], "Clean down-trend", "At least 20 of 24 horizons show a straight falling regression")Variance Ratio Spectrum
analysis · presets/analysis/gpu/variance_ratio_spectrum.qs · uses viz/premium
The variance ratio for holding periods q = 2 .. 25 bars, as a heatmap. VR(q) is the variance of q-bar returns over q times the variance of one-bar returns: 1 for a random walk, above 1 when moves extend (momentum pays at that holding period), below 1 when they reverse. Reading: amber rows = hold winners for that many bars; teal rows = fade moves over that horizon; dark = no edge. The strip on top shows the consensus. Signals: "Momentum window" and "Reversal window" when most holding periods agree. Method: Lo and MacKinlay (1988).
analysis "Variance Ratio Spectrum" {
pane = hpane
height = 0.3
render = webgpu
short_title = "VRS"
precision = 2
}
/// The variance ratio for holding periods q = 2 .. 25 bars, as a heatmap. VR(q) is the variance of
/// q-bar returns over q times the variance of one-bar returns: 1 for a random walk, above 1 when
/// moves extend (momentum pays at that holding period), below 1 when they reverse.
/// Reading: amber rows = hold winners for that many bars; teal rows = fade moves over that
/// horizon; dark = no edge. The strip on top shows the consensus. Signals: "Momentum window" and
/// "Reversal window" when most holding periods agree. Method: Lo and MacKinlay (1988).
use "viz/premium" as pr
len = param.int(150, title = "Estimation window", min = 50, max = 2000, group = "Spectrum")
scale = param.float(0.35, title = "Color scale (log VR)", min = 0.05, max = 1.5, step = 0.05, group = "Style")
agree = param.int(16, title = "Agreement rows", min = 8, max = 24, group = "Signals")
up = 0
dn = 0
total = 0.0
edge = render.line(-0.5, "Edge 0", color = color.transparent, width = 0)
for k in 0..=23 {
vr = hft.variance_ratio(close, len, k + 2)
lv = math.log(math.max(vr, 1e-6))
total := total + math.coalesce(lv, 0)
if lv > scale * 0.25 { up := up + 1 }
if lv < -scale * 0.25 { dn := dn + 1 }
top = render.line(k + 0.5, str.format("Edge {}", k + 1), color = color.transparent, width = 0)
render.band(top, edge, color = pr.diverging(lv, scale, color.amber.400, color.teal.400), opacity = 1, title = str.format("q = {}", k + 2))
edge := top
}
avg = total / 24
cap_lo = render.line(24.1, "Consensus low", color = color.transparent, width = 0)
cap_hi = render.line(26.1, "Consensus high", color = color.transparent, width = 0)
render.band(cap_hi, cap_lo, upper_color = pr.diverging(avg, scale * 0.6, color.amber.300, color.teal.300), lower_color = color.slate.950, opacity = 1, title = "Consensus")
mom = up >= agree
rev = dn >= agree
render.mark(mom and not mom[1], shape = shape.diamond, at = anchor.top, color = color.amber.300, size = 0.8, title = "Momentum window")
render.mark(rev and not rev[1], shape = shape.diamond, at = anchor.bottom, color = color.teal.300, size = 0.8, title = "Reversal window")
alert.rule(mom and not mom[1], "Momentum window", "Most holding periods show variance ratios above one")
alert.rule(rev and not rev[1], "Reversal window", "Most holding periods show variance ratios below one")
persist t = draw.table.create(anchor.top_right, 5, 2)
if bar.is_last {
pr.heading(t, 2, "Variance Ratio Spectrum", "WebGPU Studio · Lo-MacKinlay")
pr.row(t, 2, "Mean log VR", str.format("{:+.3f}", avg), avg > 0 ? color.amber.300 : color.teal.300)
pr.row(t, 3, "Momentum / reversal rows", str.format("{} / {}", up, dn), color.white)
pr.row(t, 4, "Edge", mom ? "Hold winners" : rev ? "Fade moves" : "None", color.white)
}Alpha-Beta Velocity Ribbon
analysis · presets/analysis/gpu/velocity_ribbon.qs · uses viz/premium
A two-state tracking filter (alpha-beta, the steady-state form of a constant-velocity Kalman filter) follows price level and price velocity together. The level line is wrapped in two envelopes of the tracking error (one and two standard deviations), and the ribbon is coloured every bar by the velocity in ATR per bar on a cool-warm ramp: deep blue falling fast, white still, hot red rising fast. Because the filter predicts with its velocity, it turns earlier than a moving average of similar smoothness. Reading: a warm ribbon with price inside the inner envelope is a healthy trend; price outside the outer envelope is a shock the tracker has not absorbed yet. Velocity crossing zero is the turn; a velocity that keeps its sign while acceleration flips is a trend losing power. Signals: velocity turns up / down, shock outside the outer envelope, deceleration warnings.
analysis "Alpha-Beta Velocity Ribbon" {
overlay = true
render = webgpu
short_title = "ABV"
precision = 2
}
/// A two-state tracking filter (alpha-beta, the steady-state form of a constant-velocity Kalman
/// filter) follows price level and price velocity together. The level line is wrapped in two
/// envelopes of the tracking error (one and two standard deviations), and the ribbon is coloured
/// every bar by the velocity in ATR per bar on a cool-warm ramp: deep blue falling fast, white
/// still, hot red rising fast. Because the filter predicts with its velocity, it turns earlier
/// than a moving average of similar smoothness.
/// Reading: a warm ribbon with price inside the inner envelope is a healthy trend; price outside
/// the outer envelope is a shock the tracker has not absorbed yet. Velocity crossing zero is the
/// turn; a velocity that keeps its sign while acceleration flips is a trend losing power.
/// Signals: velocity turns up / down, shock outside the outer envelope, deceleration warnings.
use "viz/premium" as pr
alpha = param.float(0.18, title = "Alpha (level gain)", min = 0.01, max = 0.9, step = 0.01, group = "Tracker")
beta = param.float(0.012, title = "Beta (velocity gain)", min = 0.0005, max = 0.5, step = 0.0005, group = "Tracker")
err_n = param.int(50, title = "Error window", min = 5, max = 1000, group = "Envelope")
vscale = param.float(0.25, title = "Velocity colour scale (ATR / bar)", min = 0.02, max = 2, step = 0.01, group = "Style")
persist lvl = na
persist vel = 0.0
pred = math.is_na(lvl) ? close : lvl + vel
resid = close - pred
lvl := pred + alpha * resid
vel := vel + beta * resid
err = stat.stdev(resid, err_n)
a = ta.atr(14)
v_atr = vel / math.max(a, 1e-12)
acc = v_atr - v_atr[1]
acc_s = ta.ema(acc, 5)
tone = color.ramp(math.clamp(v_atr, -vscale, vscale), -vscale, vscale, "coolwarm")
u2 = render.line(lvl + 2 * err, "Outer upper", color = color.transparent, width = 0)
u1 = render.line(lvl + err, "Inner upper", color = color.transparent, width = 0)
d1 = render.line(lvl - err, "Inner lower", color = color.transparent, width = 0)
d2 = render.line(lvl - 2 * err, "Outer lower", color = color.transparent, width = 0)
render.band(u2, u1, color = color.ramp(math.clamp(v_atr, -vscale, vscale), -vscale, vscale, "coolwarm", 82), opacity = 1, title = "Outer envelope above")
render.band(u1, d1, color = color.ramp(math.clamp(v_atr, -vscale, vscale), -vscale, vscale, "coolwarm", 58), opacity = 1, title = "Inner envelope")
render.band(d1, d2, color = color.ramp(math.clamp(v_atr, -vscale, vscale), -vscale, vscale, "coolwarm", 82), opacity = 1, title = "Outer envelope below")
render.line(lvl, "Tracked level", color = tone, width = 2.25)
turn_up = v_atr > 0 and v_atr[1] <= 0
turn_dn = v_atr < 0 and v_atr[1] >= 0
shock = math.abs(close - lvl) > 2 * err and math.abs(close[1] - lvl[1]) <= 2 * err[1]
decel_up = v_atr > vscale * 0.3 and acc_s < 0 and acc_s[1] >= 0
decel_dn = v_atr < -vscale * 0.3 and acc_s > 0 and acc_s[1] <= 0
render.mark(turn_up, shape = shape.triangle_up, at = anchor.below_bar, color = color.red.300, text = "v+", title = "Velocity turns up")
render.mark(turn_dn, shape = shape.triangle_down, at = anchor.above_bar, color = color.blue.300, text = "v-", title = "Velocity turns down")
render.mark(shock, shape = shape.x, at = anchor.above_bar, color = color.yellow.200, size = 0.5, title = "Shock")
render.mark(decel_up or decel_dn, shape = shape.circle, at = anchor.above_bar, color = color.translucent(color.white, 30), size = 0.35, title = "Deceleration")
alert.rule(turn_up, "Velocity turns up", "The tracked velocity crossed above zero")
alert.rule(turn_dn, "Velocity turns down", "The tracked velocity crossed below zero")
alert.rule(shock, "Shock", "Close moved outside the two-sigma tracking envelope")
persist t = draw.table.create(anchor.top_right, 6, 2)
if bar.is_last {
pr.heading(t, 2, "Alpha-Beta Velocity Ribbon", "WebGPU Studio · level and velocity tracker")
pr.row(t, 2, "Velocity", str.format("{:+.3f} ATR / bar", v_atr), v_atr > 0 ? pr.BULL : pr.BEAR)
pr.row(t, 3, "Acceleration", acc_s > 0 ? "Speeding up" : "Slowing", acc_s > 0 ? pr.BULL : pr.BEAR)
pr.row(t, 4, "Tracking error", str.format("{:.2f}", err), color.white)
pr.row(t, 5, "Price vs level", str.format("{:+.2f} sigma", (close - lvl) / math.max(err, 1e-12)), math.abs(close - lvl) > 2 * err ? pr.WARN : color.white)
}Volatility Aurora
analysis · presets/analysis/gpu/volatility_aurora.qs · uses viz/premium
Six curtains of volatility around a two-pole smoothed price. Each curtain is one ATR multiple wider than the one inside it, and every curtain is coloured each bar by where today's volatility ranks against its own history: deep violet when the market is unusually quiet, through magenta and orange to pale gold when volatility is extreme. The outer curtains fade, so the cloud reads as a glow that tightens and flares. Reading: a thin violet aurora is stored energy; the first bar that closes outside the second curtain while the colour warms is an ignition, usually in the direction of the close. Gold curtains mean risk is expensive: widen stops or reduce size. Signals: compression (rank below the quiet level), ignition up / down out of a recent compression, and extreme volatility.
analysis "Volatility Aurora" {
overlay = true
render = webgpu
short_title = "AUR"
precision = 2
}
/// Six curtains of volatility around a two-pole smoothed price. Each curtain is one ATR multiple
/// wider than the one inside it, and every curtain is coloured each bar by where today's
/// volatility ranks against its own history: deep violet when the market is unusually quiet,
/// through magenta and orange to pale gold when volatility is extreme. The outer curtains fade,
/// so the cloud reads as a glow that tightens and flares.
/// Reading: a thin violet aurora is stored energy; the first bar that closes outside the second
/// curtain while the colour warms is an ignition, usually in the direction of the close. Gold
/// curtains mean risk is expensive: widen stops or reduce size. Signals: compression (rank below
/// the quiet level), ignition up / down out of a recent compression, and extreme volatility.
use "viz/premium" as pr
len = param.int(34, title = "Centre smoothing", min = 5, max = 400, group = "Aurora")
atr_n = param.int(20, title = "ATR length", min = 2, max = 400, group = "Aurora")
width = param.float(0.6, title = "Curtain spacing (ATR)", min = 0.1, max = 3, step = 0.05, group = "Aurora")
rank_n = param.int(250, title = "Volatility rank window", min = 30, max = 5000, group = "Regime")
quiet = param.float(15, title = "Quiet rank (%)", min = 1, max = 50, group = "Regime")
hot = param.float(90, title = "Extreme rank (%)", min = 50, max = 99, group = "Regime")
memory = param.int(20, title = "Compression memory (bars)", min = 2, max = 200, group = "Signals")
mid = ta.super_smoother(close, len)
a = ta.atr(atr_n)
nv = a / close
rank = math.coalesce(stat.percent_rank(nv, rank_n), 50)
render.line(mid, "Centre", color = color.translucent(color.white, 35), width = 1.5)
up_in = render.line(mid, "Upper 0", color = color.transparent, width = 0)
dn_in = render.line(mid, "Lower 0", color = color.transparent, width = 0)
for k in 1..=6 {
up_out = render.line(mid + k * width * a, str.format("Upper {}", k), color = color.transparent, width = 0)
dn_out = render.line(mid - k * width * a, str.format("Lower {}", k), color = color.transparent, width = 0)
glow = color.ramp(rank, 0, 100, "magma", 38 + k * 9)
render.band(up_out, up_in, color = glow, opacity = 1, title = str.format("Curtain above {}", k))
render.band(dn_in, dn_out, color = glow, opacity = 1, title = str.format("Curtain below {}", k))
up_in := up_out
dn_in := dn_out
}
compressed = rank < quiet
since_quiet = ta.bars_since(compressed)
recent = not math.is_na(since_quiet) and since_quiet <= memory
up2 = mid + 2 * width * a
dn2 = mid - 2 * width * a
ign_up = recent and close > up2 and close[1] <= up2[1] and rank > rank[1]
ign_dn = recent and close < dn2 and close[1] >= dn2[1] and rank > rank[1]
extreme = rank > hot
since_ext = ta.bars_since(extreme)
new_ext = extreme and math.coalesce(since_ext[1], 1000000) > memory
render.mark(compressed and not compressed[1], shape = shape.circle, at = anchor.below_bar, color = color.violet.400, size = 0.5, title = "Compression")
render.mark(ign_up, shape = shape.triangle_up, at = anchor.below_bar, color = color.amber.300, text = "Ignite", title = "Ignition up")
render.mark(ign_dn, shape = shape.triangle_down, at = anchor.above_bar, color = color.orange.400, text = "Ignite", title = "Ignition down")
render.mark(new_ext, shape = shape.diamond, at = anchor.above_bar, color = color.yellow.200, size = 0.6, title = "Extreme volatility")
alert.rule(ign_up, "Ignition up", "Close left a recent compression through the upper curtain")
alert.rule(ign_dn, "Ignition down", "Close left a recent compression through the lower curtain")
alert.rule(new_ext, "Extreme volatility", "Volatility rank passed the extreme level")
persist t = draw.table.create(anchor.top_right, 6, 2)
if bar.is_last {
regime = rank < quiet ? "Compressed" : rank < 50 ? "Calm" : rank < hot ? "Active" : "Extreme"
tone = rank < quiet ? color.violet.300 : rank < 50 ? color.fuchsia.300 : rank < hot ? color.orange.300 : color.yellow.200
pr.heading(t, 2, "Volatility Aurora", "WebGPU Studio · ranked volatility curtains")
pr.row(t, 2, "Volatility rank", str.format("{:.0f} %", rank), tone)
pr.row(t, 3, "Regime", regime, tone)
pr.row(t, 4, "ATR / price", str.format("{:.2%}", nv), color.white)
pr.row(t, 5, "Price vs centre", str.format("{:+.2f} ATR", (close - mid) / math.max(a, 1e-12)), close > mid ? pr.BULL : pr.BEAR)
}Volatility Estimator Surface
analysis · presets/analysis/gpu/volatility_estimator_surface.qs · uses viz/premium
Five annualised volatility estimators on one surface: close-to-close, Parkinson (high-low), Garman-Klass (OHLC), Rogers-Satchell (drift-free) and Yang-Zhang (gaps plus intraday). The estimators are sorted every bar and the layers between them are filled with an edge-anchored gradient whose colour shows how far they disagree. Reading: a thin, dark surface = estimators agree and the volatility reading is reliable; a wide, hot surface = they disagree, which means overnight gaps (close-to-close and Yang-Zhang above the range estimators) or intraday stress (range estimators above). Signals: disagreement spikes and vol-of-vol bursts. Method: Parkinson (1980), Garman and Klass (1980), Rogers and Satchell (1991), Yang and Zhang (2000).
analysis "Volatility Estimator Surface" {
pane = hpane
height = 0.28
render = webgpu
short_title = "VES"
precision = 1
}
/// Five annualised volatility estimators on one surface: close-to-close, Parkinson (high-low),
/// Garman-Klass (OHLC), Rogers-Satchell (drift-free) and Yang-Zhang (gaps plus intraday). The
/// estimators are sorted every bar and the layers between them are filled with an edge-anchored
/// gradient whose colour shows how far they disagree.
/// Reading: a thin, dark surface = estimators agree and the volatility reading is reliable; a wide,
/// hot surface = they disagree, which means overnight gaps (close-to-close and Yang-Zhang above the
/// range estimators) or intraday stress (range estimators above). Signals: disagreement spikes and
/// vol-of-vol bursts. Method: Parkinson (1980), Garman and Klass (1980), Rogers and Satchell (1991),
/// Yang and Zhang (2000).
use "viz/premium" as pr
len = param.int(20, title = "Estimator window", min = 5, max = 500, group = "Estimators")
annual = param.int(252, title = "Bars per year", min = 1, max = 500000, group = "Estimators")
spike = param.float(0.35, title = "Disagreement spike", min = 0.05, max = 2, step = 0.05, group = "Signals")
vov_lv = param.float(0.11, title = "Vol-of-vol burst", min = 0.05, max = 2, step = 0.05, group = "Signals")
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)
lo = math.min(math.min(math.min(cc, pk), math.min(gk, rs)), yz)
hi = math.max(math.max(math.max(cc, pk), math.max(gk, rs)), yz)
mid = (cc + pk + gk + rs + yz) / 5
spread = mid > 0 ? (hi - lo) / mid : na
vov = stat.stdev(yz, len * 3) / math.max(ta.sma(yz, len * 3), 1e-12)
heat = color.ramp(spread, 0, spike * 1.5, "inferno")
p_hi = render.line(hi, "Highest estimate", color = color.translucent(color.orange.300, 30), width = 1)
p_mid = render.line(mid, "Mean estimate", color = color.white, width = 2)
p_lo = render.line(lo, "Lowest estimate", color = color.translucent(color.sky.300, 30), width = 1)
render.band(p_hi, p_mid, upper_color = heat, lower_color = color.translucent(color.indigo.900, 40), opacity = 0.9, title = "Upper surface")
render.band(p_mid, p_lo, upper_color = color.translucent(color.indigo.900, 40), lower_color = heat, opacity = 0.9, title = "Lower surface")
render.line(cc, "Close-to-close", color = color.translucent(color.slate.300, 40), width = 1, stroke = style.dotted)
render.line(yz, "Yang-Zhang", color = color.violet.300, width = 1)
gappy = (cc + yz) / 2 > (pk + gk + rs) / 3
stress = spread > spike and spread[1] <= spike
burst = vov > vov_lv and vov[1] <= vov_lv
render.mark(stress and gappy, shape = shape.diamond, at = anchor.top, color = color.amber.300, size = 0.8, title = "Gap disagreement")
render.mark(stress and not gappy, shape = shape.diamond, at = anchor.top, color = color.rose.300, size = 0.8, title = "Intraday stress")
render.mark(burst, shape = shape.circle, at = anchor.bottom, color = color.violet.300, size = 0.6, title = "Vol-of-vol burst")
alert.rule(stress, "Estimator disagreement", "Volatility estimators diverged: check for gaps or intraday stress")
persist t = draw.table.create(anchor.top_right, 6, 2)
if bar.is_last {
pr.heading(t, 2, "Volatility Estimator Surface", "WebGPU Studio · five OHLC estimators")
pr.row(t, 2, "Mean estimate", str.format("{:.1f} %", mid), color.white)
pr.row(t, 3, "Disagreement", str.format("{:.0%}", spread), spread > spike ? color.rose.300 : color.slate.300)
pr.row(t, 4, "Source", gappy ? "Gaps / overnight" : "Intraday range", color.white)
pr.row(t, 5, "Vol of vol", str.format("{:.2f}", vov), vov > vov_lv ? color.violet.300 : color.slate.300)
}Volatility Ratio Field
analysis · presets/analysis/gpu/volatility_ratio_field.qs
Short against long volatility on twenty horizons: each row is ATR(n) / ATR(4n). Orange rows = short-term volatility above its longer baseline (expansion at that scale), indigo rows = contraction. Expansion that starts in the fast rows and spreads down marks a volatility breakout in progress; contraction across all rows is a coiled market.
analysis "Volatility Ratio Field" {
pane = hpane
height = 0.24
render = webgpu
}
/// Short against long volatility on twenty horizons: each row is ATR(n) / ATR(4n). Orange rows =
/// short-term volatility above its longer baseline (expansion at that scale), indigo rows =
/// contraction. Expansion that starts in the fast rows and spreads down marks a volatility
/// breakout in progress; contraction across all rows is a coiled market.
first = param.int(3, title = "Shortest n", min = 2, max = 100, group = "Field")
gap = param.int(3, title = "n step", min = 1, max = 100, group = "Field")
edge = render.line(-0.5, "Edge 0", color = color.transparent, width = 0)
coil = 0
for k in 0..=19 {
n = first + k * gap
r = ta.atr(n) / math.max(ta.atr(n * 4), 1e-12)
if r < 0.8 { coil := coil + 1 }
c = math.is_na(r) ? na : r >= 1 ? color.gradient(r, 1, 1.8, color.slate.900, color.orange.400) : color.gradient(r, 0.5, 1, color.indigo.400, color.slate.900)
top = render.line(k + 0.5, str.format("Edge {}", k + 1), color = color.transparent, width = 0)
render.band(top, edge, color = c, opacity = 1, title = str.format("Row {}", k + 1))
edge := top
}
render.line(coil, "Contracted rows", color = color.translucent(color.white, 20), width = 1.4)
coiled = coil >= 16
render.mark(coiled and not coiled[1], shape = shape.diamond, at = anchor.bottom, color = color.indigo.300, title = "Coiled on every scale")
alert.rule(coiled and not coiled[1], "Volatility coil", "Short volatility is below its baseline on almost every horizon")Volatility Term Heatmap
analysis · presets/analysis/gpu/volatility_term_heatmap.qs
Realised volatility on twenty-four horizons (4 .. ~300 bars), each shown as its percentile against its own history: cool rows are quiet relative to that horizon's past, hot rows are loud. Short horizons sit at the bottom. A hot bottom under a cool top is a fresh shock; a cool bottom under a hot top is a market calming after a storm; the whole field going cold is the compression that usually comes before the next expansion. The line is the term slope (short-horizon percentile minus long-horizon percentile).
analysis "Volatility Term Heatmap" {
pane = hpane
height = 0.26
render = webgpu
}
/// Realised volatility on twenty-four horizons (4 .. ~300 bars), each shown as its percentile
/// against its own history: cool rows are quiet relative to that horizon's past, hot rows are
/// loud. Short horizons sit at the bottom. A hot bottom under a cool top is a fresh shock; a
/// cool bottom under a hot top is a market calming after a storm; the whole field going cold is
/// the compression that usually comes before the next expansion.
/// The line is the term slope (short-horizon percentile minus long-horizon percentile).
src = param.source(close, title = "Source")
first = param.int(4, title = "Shortest horizon", min = 2, max = 50, group = "Field")
ratio = param.float(1.2, title = "Horizon ratio", min = 1.05, max = 1.5, step = 0.01, group = "Field")
history = param.int(250, title = "Percentile history", min = 50, max = 5000, group = "Field")
ret = math.log(src / src[1])
fn heat(p: float) -> color {
return math.is_na(p) ? na : p >= 50 ? color.gradient(p, 50, 100, color.slate.800, color.orange.400) : color.gradient(p, 0, 50, color.blue.500, color.slate.800)
}
edge = render.line(-0.5, "Edge 0", color = color.transparent, width = 0)
short_p = 0.0
long_p = 0.0
cold = 0
for k in 0..=23 {
len = math.max(2, math.round(first * math.pow(ratio, k)))
rv = stat.stdev(ret, len)
p = stat.percent_rank(rv, history)
if k < 6 { short_p := short_p + p / 6 }
if k >= 18 { long_p := long_p + p / 6 }
if p < 20 { cold := cold + 1 }
top = render.line(k + 0.5, str.format("Edge {}", k + 1), color = color.transparent, width = 0)
render.band(top, edge, color = heat(p), opacity = 1, title = str.format("Row {}", k + 1))
edge := top
}
slope = short_p - long_p
render.line(11.5 + slope / 100 * 11.5, "Term slope", color = slope >= 0 ? color.amber.200 : color.sky.200, width = 1.6)
squeeze = cold >= 20
render.mark(squeeze and not squeeze[1], shape = shape.diamond, at = anchor.top, color = color.sky.300, title = "Field compression")
shock = short_p >= 85 and long_p <= 50 and not (short_p[1] >= 85 and long_p[1] <= 50)
render.mark(shock, shape = shape.triangle_down, at = anchor.top, color = color.orange.400, title = "Fresh shock")
persist t = draw.table.create(anchor.top_right, 3, 2)
if bar.is_last {
draw.table.cell(t, 0, 0, "Short horizons", text_color = color.slate.400)
draw.table.cell(t, 0, 1, str.format("{:.0f} pct", short_p), bg_color = heat(short_p))
draw.table.cell(t, 1, 0, "Long horizons", text_color = color.slate.400)
draw.table.cell(t, 1, 1, str.format("{:.0f} pct", long_p), bg_color = heat(long_p))
draw.table.cell(t, 2, 0, "Term slope", text_color = color.slate.400)
draw.table.cell(t, 2, 1, slope >= 0 ? str.format("{:+.0f} (backwardation)", slope) : str.format("{:+.0f} (contango)", slope))
}
alert.rule(squeeze and not squeeze[1], "Volatility compression", "At least 20 of 24 horizons are below their 20th percentile")
alert.rule(shock, "Volatility shock", "Short-horizon volatility jumped while the long horizons are calm")Volatility Topography
analysis · presets/analysis/gpu/volatility_topography.qs
Ten sigma shells above and ten below an adaptive basis, drawn as a contour map of volatility. The shell the price occupies right now lights up, warm above the basis and cool below, so the chart reads like terrain: price climbing to the 2-3 sigma ridges, or resting in the valley at the basis. Sigma is measured on the residual around the basis, so the shells widen in volatile tape and pinch in quiet tape. Marks: a stretch beyond the alert shell, and the snap back inside the inner shells that often follows. The dashboard shows the z-score, the sigma percentile and the distance to the basis.
analysis "Volatility Topography" {
overlay = true
render = webgpu
max_objects = 60
}
/// Ten sigma shells above and ten below an adaptive basis, drawn as a contour map of volatility.
/// The shell the price occupies right now lights up, warm above the basis and cool below, so the
/// chart reads like terrain: price climbing to the 2-3 sigma ridges, or resting in the valley at
/// the basis. Sigma is measured on the residual around the basis, so the shells widen in
/// volatile tape and pinch in quiet tape.
/// Marks: a stretch beyond the alert shell, and the snap back inside the inner shells that often
/// follows. The dashboard shows the z-score, the sigma percentile and the distance to the basis.
src = param.source(close, title = "Source")
len = param.int(50, title = "Basis length", min = 5, max = 500, group = "Basis")
vol_len = param.int(50, title = "Sigma window", min = 10, max = 500, group = "Basis")
spacing = param.float(0.3, title = "Shell spacing (sigma)", min = 0.05, max = 1, step = 0.05, group = "Shells")
stretch = param.float(2.4, title = "Stretch alert (sigma)", min = 0.5, max = 4, step = 0.1, group = "Signals")
color_bars = param.bool(true, title = "Color candles", group = "Style")
basis = ta.jurik(ta.ema(src, len), math.max(2, len / 4), 0, 2)
sd = math.max(stat.stdev(src - basis, vol_len), 1e-12)
z = (src - basis) / sd
up_prev = render.line(basis, "Basis", color = z >= 0 ? color.amber.200 : color.sky.200, width = 1.6)
dn_prev = up_prev
for k in 1..=10 {
d = k * spacing
lo = d - spacing
up = render.line(basis + d * sd, str.format("Upper shell {}", k), color = color.translucent(color.orange.300, 55 + k * 3.5), width = k % 5 == 0 ? 1 : 0.6)
dn = render.line(basis - d * sd, str.format("Lower shell {}", k), color = color.translucent(color.cyan.300, 55 + k * 3.5), width = k % 5 == 0 ? 1 : 0.6)
in_up = z > lo and z <= d
in_dn = z < -lo and z >= -d
warm = color.gradient(k, 1, 10, color.amber.400, color.rose.500)
cool = color.gradient(k, 1, 10, color.cyan.400, color.indigo.500)
render.band(up, up_prev, color = color.translucent(warm, in_up ? 45 : 93 - k * 0.6), opacity = 1, title = str.format("Upper terrain {}", k))
render.band(dn_prev, dn, color = color.translucent(cool, in_dn ? 45 : 93 - k * 0.6), opacity = 1, title = str.format("Lower terrain {}", k))
up_prev := up
dn_prev := dn
}
hot = z >= stretch and z[1] < stretch
cold = z <= -stretch and z[1] > -stretch
back_dn = ta.bars_since(z >= stretch) <= 15 and z < 1 and z[1] >= 1
back_up = ta.bars_since(z <= -stretch) <= 15 and z > -1 and z[1] <= -1
render.mark(hot, shape = shape.diamond, at = anchor.above_bar, color = color.rose.400, title = "Stretched up")
render.mark(cold, shape = shape.diamond, at = anchor.below_bar, color = color.cyan.300, title = "Stretched down")
render.mark(back_dn, shape = shape.arrow_down, at = anchor.above_bar, color = color.amber.300, title = "Snap back down")
render.mark(back_up, shape = shape.arrow_up, at = anchor.below_bar, color = color.sky.300, title = "Snap back up")
render.candle_color(color_bars ? (z >= 0 ? color.gradient(z, 0, 3, color.amber.200, color.rose.500) : color.gradient(z, -3, 0, color.indigo.400, color.sky.200)) : na)
width_pct = stat.percent_rank(sd / math.max(math.abs(basis), 1e-12), 500)
persist t = draw.table.create(anchor.top_right, 4, 2)
if bar.is_last {
draw.table.cell(t, 0, 0, "Z-score", text_color = color.slate.400)
draw.table.cell(t, 0, 1, str.format("{:+.2f}", z), bg_color = z >= 0 ? color.gradient(z, 0, 3, color.slate.700, color.rose.600) : color.gradient(z, -3, 0, color.indigo.600, color.slate.700))
draw.table.cell(t, 1, 0, "Shell", text_color = color.slate.400)
draw.table.cell(t, 1, 1, str.format("{} {}", math.min(10, math.ceil(math.abs(z) / spacing)), z >= 0 ? "above" : "below"))
draw.table.cell(t, 2, 0, "Sigma percentile", text_color = color.slate.400)
draw.table.cell(t, 2, 1, str.format("{:.0f}%", width_pct), bg_color = color.gradient(width_pct, 0, 100, color.slate.700, color.amber.600))
draw.table.cell(t, 3, 0, "To basis", text_color = color.slate.400)
draw.table.cell(t, 3, 1, str.format("{:+.2f}%", (basis - src) / src * 100))
}
alert.rule(hot or cold, "Volatility stretch", "Price moved beyond the stretch shell")
alert.rule(back_dn or back_up, "Snap back", "Price returned inside one sigma after a stretch")Volume Surge Field
analysis · presets/analysis/gpu/volume_surge_field.qs
Participation on twenty-four horizons. Each row is the z-score of the log volume of the newest bar against that horizon's history, signed by the bar's direction: an up-bar with unusual volume against every horizon glows cyan, a down-bar orange. A surge that lights only the short rows is a local burst; one that lights the long rows is a regime change in participation.
analysis "Volume Surge Field" {
pane = hpane
height = 0.24
render = webgpu
}
/// Participation on twenty-four horizons. Each row is the z-score of the log volume of the newest
/// bar against that horizon's history, signed by the bar's direction: an up-bar with unusual volume
/// against every horizon glows cyan, a down-bar orange. A surge that lights only the short rows is a
/// local burst; one that lights the long rows is a regime change in participation.
first = param.int(10, title = "Shortest horizon", min = 5, max = 500, group = "Field")
gap = param.int(10, title = "Horizon step", min = 1, max = 500, group = "Field")
lv = math.log(math.max(volume, 1))
dir = close >= open ? 1 : -1
edge = render.line(-0.5, "Edge 0", color = color.transparent, width = 0)
hot = 0
for k in 0..=23 {
len = first + k * gap
z = stat.zscore(lv, len)
if z > 1.5 { hot := hot + 1 }
t = math.clamp(z / 3, 0, 1)
c = math.is_na(z) ? na : dir > 0 ? color.gradient(t, 0, 1, color.slate.900, color.cyan.300) : color.gradient(t, 0, 1, color.slate.900, color.orange.400)
top = render.line(k + 0.5, str.format("Edge {}", k + 1), color = color.transparent, width = 0)
render.band(top, edge, color = c, opacity = 1, title = str.format("Row {}", k + 1))
edge := top
}
render.line(hot, "Hot horizons", color = color.translucent(color.white, 20), width = 1.4)
regime = hot >= 18
render.mark(regime and dir > 0, shape = shape.triangle_up, at = anchor.bottom, color = color.cyan.300, title = "Participation surge (up)")
render.mark(regime and dir < 0, shape = shape.triangle_down, at = anchor.top, color = color.orange.400, title = "Participation surge (down)")
alert.rule(regime, "Participation surge", "Volume is unusual against almost every horizon")VWMA Distance Field
analysis · presets/analysis/gpu/vwma_distance_field.qs
Distance of the close from twenty-four volume-weighted averages, in ATR. A VWMA is where the traded volume of the window is centred, so each row reads whether the buyers (above) or the sellers (below) of that horizon are in profit. Sky rows = above, pink rows = below.
analysis "VWMA Distance Field" {
pane = hpane
height = 0.24
render = webgpu
}
/// Distance of the close from twenty-four volume-weighted averages, in ATR. A VWMA is where the
/// traded volume of the window is centred, so each row reads whether the buyers (above) or the
/// sellers (below) of that horizon are in profit. Sky rows = above, pink rows = below.
first = param.int(5, title = "Shortest length", min = 2, max = 500, group = "Field")
gap = param.int(8, title = "Length step", min = 1, max = 500, group = "Field")
a = ta.atr(50)
edge = render.line(-0.5, "Edge 0", color = color.transparent, width = 0)
above = 0
for k in 0..=23 {
len = first + k * gap
d = (close - ta.vwma(close, len)) / math.max(a, 1e-12)
if d > 0 { above := above + 1 }
t = math.clamp(math.abs(d) / 4, 0, 1)
c = math.is_na(d) ? na : d >= 0 ? color.gradient(t, 0, 1, color.slate.900, color.sky.300) : color.gradient(t, 0, 1, color.slate.900, color.pink.400)
top = render.line(k + 0.5, str.format("Edge {}", k + 1), color = color.transparent, width = 0)
render.band(top, edge, color = c, opacity = 1, title = str.format("Row {}", k + 1))
edge := top
}
render.line(above - 0.5, "Horizons in profit", color = color.translucent(color.white, 15), width = 1.6)