
QuaScript Documentation
Language guide, engine API, 601 builtins and the preset library · v3.0.0-phase1
Statistics
Hurst Regime
analysis · presets/analysis/stats/hurst_regime.qs
Hurst exponent (rescaled range), fractal dimension and lag-1 autocorrelation of returns: three views of persistence. H > 0.5 trends, H < 0.5 mean-reverts.
analysis "Hurst Regime" {
overlay = false
}
/// Hurst exponent (rescaled range), fractal dimension and lag-1 autocorrelation of returns: three
/// views of persistence. H > 0.5 trends, H < 0.5 mean-reverts.
len = param.int(100, title = "Window", min = 20, max = 1000)
ret = ta.change(close) / close[1]
h = stat.hurst(close, len)
fd = stat.fractal_dim(close, len)
ac = stat.autocorr(ret, len, 1)
render.line(h, "Hurst", color = h > 0.55 ? color.emerald.400 : h < 0.45 ? color.rose.400 : color.slate.400, width = 2)
render.line(2 - fd, "2 - fractal dim", color = color.violet.400, width = 1)
render.histogram(0.5 + ac, "0.5 + autocorr", base = 0.5, color = ac >= 0 ? color.translucent(color.emerald.500, 60) : color.translucent(color.rose.500, 60))
render.level(0.5, "Random walk", color = color.slate.500, stroke = style.dashed)
render.level(0.55, "Trend", color = color.emerald.800, stroke = style.dotted)
render.level(0.45, "Revert", color = color.rose.800, stroke = style.dotted)Kalman Spread Z-Score
analysis · presets/analysis/stats/kalman_spread.qs
A dynamic hedge between price and its own slow trend, estimated with a Kalman filter: the time-varying beta, the residual spread and its z-score. The z-score is drawn as a gradient area with stretched zones; marks show the spread snapping back toward zero from an extreme.
analysis "Kalman Spread Z-Score" {
pane = hpane
height = 0.24
}
/// A dynamic hedge between price and its own slow trend, estimated with a Kalman filter: the
/// time-varying beta, the residual spread and its z-score. The z-score is drawn as a gradient
/// area with stretched zones; marks show the spread snapping back toward zero from an extreme.
trend_len = param.int(50, title = "Trend length", min = 5, max = 500)
delta = param.float(0.0001, title = "Adaptation", min = 0.000001, max = 0.01, step = 0.00001)
entry = param.float(1.5, title = "Entry z", min = 0.5, max = 5, step = 0.1)
(beta, alpha, spread, z) = hft.kalman_beta(close, ta.ema(close, trend_len), delta, 0.001)
render.area(z, "Spread z-score", base = 0, color = z >= 0 ? color.translucent(color.fuchsia.400, 55) : color.translucent(color.cyan.400, 55))
render.line(ta.ema(z, 5), "Smoothed z", color = color.white, width = 1.5)
render.line((beta - 1) * 20, "Beta deviation (x20)", color = color.translucent(color.amber.300, 30), width = 1, stroke = style.dashed)
top = render.level(3, "Top", color = color.transparent)
hi = render.level(entry, "Rich", color = color.fuchsia.700, stroke = style.dashed)
lo = render.level(-entry, "Cheap", color = color.cyan.700, stroke = style.dashed)
bot = render.level(-3, "Bottom", color = color.transparent)
render.band(top, hi, color = color.fuchsia.500, opacity = 0.08, title = "Rich zone")
render.band(lo, bot, color = color.cyan.500, opacity = 0.08, title = "Cheap zone")
render.level(0, "Fair", color = color.slate.600)
snap_dn = z[1] > entry and z < entry
snap_up = z[1] < -entry and z > -entry
render.mark(snap_dn, shape = shape.triangle_down, at = anchor.top, color = color.fuchsia.400, title = "Rich snap-back")
render.mark(snap_up, shape = shape.triangle_up, at = anchor.bottom, color = color.cyan.400, title = "Cheap snap-back")
alert.rule(snap_dn or snap_up, "Spread snap-back", "The Kalman spread left an extreme")Regression Channel
analysis · presets/analysis/stats/linreg_channel.qs
Least-squares regression line of the last N bars, redrawn on the last bar as one segment with parallel channel lines at the maximum residual and +-2 standard errors. R-squared and slope are shown in a table; a developing regression value is plotted on every bar.
analysis "Regression Channel" {
overlay = true
max_objects = 10
}
/// Least-squares regression line of the last N bars, redrawn on the last bar as one segment with
/// parallel channel lines at the maximum residual and +-2 standard errors. R-squared and slope
/// are shown in a table; a developing regression value is plotted on every bar.
len = param.int(100, title = "Window", min = 10, max = 2000)
(value, slope, intercept) = stat.linreg(close, len, 0)
r2 = stat.r_squared(close, len)
resid_sd = stat.stdev(close - value, len)
render.line(value, "Developing regression", color = color.sky.400, width = 1, stroke = style.dotted)
persist mid_id = 0.0
persist up_id = 0.0
persist dn_id = 0.0
persist tbl = draw.table.create(anchor.bottom_right, 3, 2)
if bar.is_last {
x0 = bar.index - len + 1
y0 = value - slope * (len - 1)
if mid_id == 0 {
mid_id := draw.segment.create(x0, y0, bar.index, value, color = color.sky.400, width = 2, ray = ray.right)
up_id := draw.segment.create(x0, y0 + 2 * resid_sd, bar.index, value + 2 * resid_sd, color = color.rose.400, ray = ray.right)
dn_id := draw.segment.create(x0, y0 - 2 * resid_sd, bar.index, value - 2 * resid_sd, color = color.teal.400, ray = ray.right)
} else {
draw.segment.update(mid_id, x0, y0, bar.index, value)
draw.segment.update(up_id, x0, y0 + 2 * resid_sd, bar.index, value + 2 * resid_sd)
draw.segment.update(dn_id, x0, y0 - 2 * resid_sd, bar.index, value - 2 * resid_sd)
}
draw.table.cell(tbl, 0, 0, "Slope / bar")
draw.table.cell(tbl, 0, 1, str.format("{:.4f}", slope))
draw.table.cell(tbl, 1, 0, "R squared")
draw.table.cell(tbl, 1, 1, str.format("{:.3f}", r2), bg_color = color.gradient(r2, 0, 1, color.slate.800, color.emerald.700))
draw.table.cell(tbl, 2, 0, "Std error")
draw.table.cell(tbl, 2, 1, str.format("{:.2f}", resid_sd))
}Local Linear Kernel Smoother
analysis · presets/analysis/stats/local_linear_smoother.qs
A local linear regression of price on time with Gaussian weights, fitted at the newest bar only (no future bars, so history never changes). Unlike a kernel-weighted average, the local linear fit removes the lag of a trending series at the right edge. The fitted slope colors the line; bands are drawn at a multiple of the weighted residual standard deviation of the same fit, with gradient fills. Touches of the outer band against the slope are marked as stretched, and slope sign changes as turns. Method: Cleveland (1979), Fan and Gijbels (1996).
analysis "Local Linear Kernel Smoother" {
overlay = true
}
/// A local linear regression of price on time with Gaussian weights, fitted at the newest bar
/// only (no future bars, so history never changes). Unlike a kernel-weighted average, the local
/// linear fit removes the lag of a trending series at the right edge. The fitted slope colors
/// the line; bands are drawn at a multiple of the weighted residual standard deviation of the
/// same fit, with gradient fills. Touches of the outer band against the slope are marked as
/// stretched, and slope sign changes as turns. Method: Cleveland (1979), Fan and Gijbels (1996).
src = param.source(close, title = "Source")
look = param.int(80, title = "Window bars", min = 10, max = 400)
bw = param.float(14, title = "Bandwidth (bars)", min = 2, max = 200, step = 0.5)
k_in = param.float(1.0, title = "Inner band (sigma)", min = 0.25, max = 4, step = 0.05)
k_out = param.float(2.2, title = "Outer band (sigma)", min = 0.5, max = 6, step = 0.05)
// weighted least squares at x = 0 (the newest bar), x = -i for i bars back
sw = 0.0
swx = 0.0
swxx = 0.0
swy = 0.0
swxy = 0.0
for i in 0..look {
y = src[i]
if not math.is_na(y) {
w = math.exp(-0.5 * (i * i) / (bw * bw))
x = -i
sw := sw + w
swx := swx + w * x
swxx := swxx + w * x * x
swy := swy + w * y
swxy := swxy + w * x * y
}
}
det = sw * swxx - swx * swx
fit = det != 0 ? (swxx * swy - swx * swxy) / det : na
slope = det != 0 ? (sw * swxy - swx * swy) / det : na
// weighted residual spread of the same local fit
sr = 0.0
for i in 0..look {
y = src[i]
if not math.is_na(y) and not math.is_na(fit) {
w = math.exp(-0.5 * (i * i) / (bw * bw))
e = y - (fit - slope * i)
sr := sr + w * e * e
}
}
sigma = sw > 0 ? math.sqrt(sr / sw) : na
up = slope > 0
p_fit = render.line(fit, "Local fit", color = up ? color.sky.300 : color.amber.300, width = 3)
p_hi = render.line(fit + k_out * sigma, "Upper outer", color = color.translucent(color.amber.300, 45), width = 1)
p_hi2 = render.line(fit + k_in * sigma, "Upper inner", color = color.transparent, width = 0)
p_lo2 = render.line(fit - k_in * sigma, "Lower inner", color = color.transparent, width = 0)
p_lo = render.line(fit - k_out * sigma, "Lower outer", color = color.translucent(color.sky.300, 45), width = 1)
render.band(p_hi, p_hi2, texture = "gradient", upper_color = color.amber.500, lower_color = color.slate.900, opacity = 0.32, title = "Upper stretch")
render.band(p_hi2, p_lo2, color = up ? color.sky.500 : color.amber.500, opacity = 0.06, title = "Fair range")
render.band(p_lo2, p_lo, texture = "gradient", upper_color = color.slate.900, lower_color = color.sky.500, opacity = 0.32, title = "Lower stretch")
stretched_up = high > fit + k_out * sigma and not up
stretched_dn = low < fit - k_out * sigma and up
turn_up = up and not up[1]
turn_dn = not up and up[1]
render.mark(stretched_up, shape = shape.circle, at = anchor.above_bar, color = color.amber.300, size = 0.7, title = "Stretched high")
render.mark(stretched_dn, shape = shape.circle, at = anchor.below_bar, color = color.sky.300, size = 0.7, title = "Stretched low")
render.mark(turn_up, shape = shape.triangle_up, at = anchor.below_bar, color = color.sky.300, text = "Turn")
render.mark(turn_dn, shape = shape.triangle_down, at = anchor.above_bar, color = color.amber.300, text = "Turn")
alert.rule(turn_up or turn_dn, "Smoother turn", "The local linear slope changed sign")
alert.rule(stretched_up or stretched_dn, "Stretched against trend", "Price reached the outer band against the fitted slope")Ornstein-Uhlenbeck Reversion
analysis · presets/analysis/stats/ou_reversion_bands.qs
Fits an Ornstein-Uhlenbeck process to price: the long-run mean, the half-life of reversion and the z-score of the distance from the mean. Bands at one, two and three sigma are gradient filled, the mean is shaded by how fast the market reverts, and reversion entries are marked when an extreme z-score turns back with a short half-life.
analysis "Ornstein-Uhlenbeck Reversion" {
overlay = true
}
/// Fits an Ornstein-Uhlenbeck process to price: the long-run mean, the half-life of reversion and
/// the z-score of the distance from the mean. Bands at one, two and three sigma are gradient
/// filled, the mean is shaded by how fast the market reverts, and reversion entries are marked
/// when an extreme z-score turns back with a short half-life.
len = param.int(120, title = "Fit length", min = 30, max = 1000)
hl_max = param.float(40, title = "Max half-life (bars)", min = 2, max = 500, step = 1)
(mu_fit, hl, z_fit) = hft.ou(close, len)
// a fit that drifts away from price (no reversion in the window) falls back to the rolling mean
base = ta.sma(close, len)
ok = not math.is_na(mu_fit) and math.abs(mu_fit - base) < 3 * ta.atr(len)
mu = ok ? mu_fit : base
sd = stat.stdev(close - mu, len)
z = ok ? z_fit : (close - mu) / math.max(sd, 1e-12)
fast = ok and hl > 0 and hl < hl_max
tone = fast ? color.gradient(hl, 0, hl_max, color.cyan.300, color.violet.400) : color.slate.400
render.line(mu, "OU mean", color = tone, width = 3)
u1 = render.line(mu + sd, "+1 sigma", color = color.translucent(color.violet.300, 60), width = 1)
d1 = render.line(mu - sd, "-1 sigma", color = color.translucent(color.violet.300, 60), width = 1)
u2 = render.line(mu + 2 * sd, "+2 sigma", color = color.translucent(color.fuchsia.300, 45), width = 1)
d2 = render.line(mu - 2 * sd, "-2 sigma", color = color.translucent(color.cyan.300, 45), width = 1)
u3 = render.line(mu + 3 * sd, "+3 sigma", color = color.translucent(color.fuchsia.400, 30), width = 1.5)
d3 = render.line(mu - 3 * sd, "-3 sigma", color = color.translucent(color.cyan.400, 30), width = 1.5)
render.band(u1, d1, color = color.violet.500, opacity = 0.06, title = "Fair value")
render.band(u3, u2, texture = "gradient", upper_color = color.fuchsia.500, lower_color = color.violet.900, opacity = 0.25, title = "Rich")
render.band(d2, d3, texture = "gradient", upper_color = color.violet.900, lower_color = color.cyan.500, opacity = 0.25, title = "Cheap")
buy = fast and z[1] < -2 and z > z[1]
sell = fast and z[1] > 2 and z < z[1]
render.mark(buy, shape = shape.tag_up, at = anchor.below_bar, color = color.cyan.500, text = "Revert")
render.mark(sell, shape = shape.tag_down, at = anchor.above_bar, color = color.fuchsia.500, text = "Revert")
render.candle_color(z > 2 ? color.fuchsia.400 : z < -2 ? color.cyan.400 : na)
alert.rule(buy, "Reversion up", "Price turned up from two sigma below the OU mean")
alert.rule(sell, "Reversion down", "Price turned down from two sigma above the OU mean")Regime Entropy Map
analysis · presets/analysis/stats/regime_entropy_map.qs
Classifies the market into four regimes from the Hurst exponent, the Shannon entropy of returns and Kaufman efficiency: trending, mean-reverting, random and chaotic. The regime score is a colored column histogram, the three inputs are drawn as lines, the pane background takes the regime color and a table explains the current reading.
analysis "Regime Entropy Map" {
pane = hpane
height = 0.25
}
/// Classifies the market into four regimes from the Hurst exponent, the Shannon entropy of
/// returns and Kaufman efficiency: trending, mean-reverting, random and chaotic. The regime score
/// is a colored column histogram, the three inputs are drawn as lines, the pane background takes
/// the regime color and a table explains the current reading.
len = param.int(100, title = "Hurst length", min = 30, max = 500)
elen = param.int(50, title = "Entropy length", min = 10, max = 300)
bins = param.int(10, title = "Entropy bins", min = 4, max = 30)
ret = math.log(close / close[1])
h = stat.hurst(close, len)
ent = stat.entropy(ret, elen, bins) / math.log(bins)
er = math.abs(close - close[elen]) / math.max(stat.sum(math.abs(close - close[1]), elen), 1e-12)
// +1 trending .. -1 mean-reverting; chaotic when entropy is near its maximum
score = math.clamp((h - 0.5) * 4 + (er - 0.3) * 2, -1, 1)
chaotic = ent > 0.92 and er < 0.15
regime = chaotic ? 3 : score > 0.25 ? 1 : score < -0.25 ? 2 : 0
rc = match regime {
case 1 { color.emerald.500 }
case 2 { color.violet.500 }
case 3 { color.orange.500 }
else { color.slate.500 }
}
render.histogram(score, "Regime score", color = color.translucent(rc, 25))
render.line(h * 2 - 1, "Hurst (scaled)", color = color.sky.300, width = 1.5)
render.line(ent * 2 - 1, "Entropy (scaled)", color = color.orange.300, width = 1, stroke = style.dashed)
render.spline(er * 2 - 1, "Efficiency (scaled)", color = color.lime.300, width = 1)
render.level(0, "Random walk", color = color.slate.600, stroke = style.dotted)
render.background(color.translucent(rc, 90))
render.mark(regime == 1 and regime[1] != 1, shape = shape.circle, at = anchor.top, color = color.emerald.400, size = 0.6, title = "Trend regime")
render.mark(regime == 2 and regime[1] != 2, shape = shape.circle, at = anchor.bottom, color = color.violet.400, size = 0.6, title = "Reversion regime")
persist t = draw.table.create(anchor.top_left, 4, 2)
if bar.is_last {
name = match regime {
case 1 { "Trending" }
case 2 { "Mean-reverting" }
case 3 { "Chaotic" }
else { "Random" }
}
draw.table.cell(t, 0, 0, "Regime", text_color = color.slate.400)
draw.table.cell(t, 0, 1, name, bg_color = rc, text_color = color.readable(rc))
draw.table.cell(t, 1, 0, "Hurst", text_color = color.slate.400)
draw.table.cell(t, 1, 1, str.format("{:.2f}", h))
draw.table.cell(t, 2, 0, "Entropy", text_color = color.slate.400)
draw.table.cell(t, 2, 1, str.format("{:.0f}%", ent * 100))
draw.table.cell(t, 3, 0, "Efficiency", text_color = color.slate.400)
draw.table.cell(t, 3, 1, str.format("{:.0f}%", er * 100))
}
alert.rule(regime == 1 and regime[1] != 1, "Trend regime", "Hurst and efficiency point to a persistent trend")
alert.rule(regime == 3 and regime[1] != 3, "Chaotic regime", "Returns are close to maximum entropy")Regression Fan
analysis · presets/analysis/stats/regression_fan.qs
Linear-regression channels over three horizons drawn from the newest bar back: each channel is a centre line with two-sigma edges, projected forward, labelled with its slope and R². The rolling regression value is plotted for the middle horizon so history stays readable.
analysis "Regression Fan" {
overlay = true
max_objects = 60
}
/// Linear-regression channels over three horizons drawn from the newest bar back: each channel
/// is a centre line with two-sigma edges, projected forward, labelled with its slope and R². The
/// rolling regression value is plotted for the middle horizon so history stays readable.
short = param.int(50, title = "Short horizon", min = 10, max = 500)
mid = param.int(120, title = "Middle horizon", min = 20, max = 1000)
long = param.int(250, title = "Long horizon", min = 30, max = 2000)
ext = param.int(15, title = "Projection bars", min = 0, max = 100)
(v1, s1, i1) = stat.linreg(close, short, 0)
(v2, s2, i2) = stat.linreg(close, mid, 0)
(v3, s3, i3) = stat.linreg(close, long, 0)
d1 = stat.stdev(close - v1, short)
d2 = stat.stdev(close - v2, mid)
d3 = stat.stdev(close - v3, long)
r1 = stat.r_squared(close, short)
r2 = stat.r_squared(close, mid)
r3 = stat.r_squared(close, long)
render.line(v2, "Regression value", color = color.translucent(color.sky.300, 40), width = 1)
persist ids = list.create(float)
persist tags = list.create(float)
fn channel(n: int, v: float, slope: float, sd: float, r2v: float, tone: color, ids: list<float>, tags: list<float>, ext: int) {
x0 = bar.index - n + 1
y0 = v - slope * (n - 1)
x1 = bar.index + ext
y1 = v + slope * ext
list.push(ids, draw.segment.create(x0, y0, x1, y1, color = tone, width = 2))
list.push(ids, draw.segment.create(x0, y0 + 2 * sd, x1, y1 + 2 * sd, color = color.translucent(tone, 40), stroke = style.dashed))
list.push(ids, draw.segment.create(x0, y0 - 2 * sd, x1, y1 - 2 * sd, color = color.translucent(tone, 40), stroke = style.dashed))
list.push(tags, draw.tag.create(x1, y1, str.format("{} bars slope {:.3f} R² {:.2f}", n, slope, r2v), color = color.translucent(tone, 20), shape = shape.tag, size = 0.8))
}
if bar.is_last {
// the live bar re-runs: replace the previous channels
for id in ids { draw.segment.delete(id) }
for id in tags { draw.tag.delete(id) }
list.clear(ids)
list.clear(tags)
channel(long, v3, s3, d3, r3, color.violet.400, ids, tags, ext)
channel(mid, v2, s2, d2, r2, color.sky.400, ids, tags, ext)
channel(short, v1, s1, d1, r1, color.amber.400, ids, tags, ext)
}Structural Break Detector
analysis · presets/analysis/stats/structural_breaks.qs
Detects regime changes with three statistics: a CUSUM test on price (breaks in the mean), the variance ratio (above one trends, below one mean-reverts) and bipower variation (jumps that continuous volatility does not explain). Breaks are drawn as time marks across the pane and jump bars are highlighted.
analysis "Structural Break Detector" {
pane = hpane
height = 0.22
}
/// Detects regime changes with three statistics: a CUSUM test on price (breaks in the mean), the
/// variance ratio (above one trends, below one mean-reverts) and bipower variation (jumps that
/// continuous volatility does not explain). Breaks are drawn as time marks across the pane and
/// jump bars are highlighted.
len = param.int(50, title = "Window", min = 10, max = 500)
mult = param.float(4.0, title = "CUSUM threshold (sigma)", min = 0.5, max = 6, step = 0.1)
brk = hft.cusum(close, 0, len, mult)
vr = hft.variance_ratio(close, len * 2, 4)
(rv, bv, jump) = hft.bipower(close, len)
render.histogram(vr - 1, "Variance ratio - 1", color = vr >= 1 ? color.translucent(color.emerald.500, 35) : color.translucent(color.violet.500, 35))
render.line(jump * 10, "Jump share (x10)", color = color.orange.400, width = 1.5)
render.spline(ta.ema(vr - 1, 10), "Trend persistence", color = color.sky.300, width = 2)
render.level(0, "Random walk", color = color.slate.600, stroke = style.dotted)
render.time_mark(brk != 0, title = "Structural break", color = color.amber.400, width = 1, stroke = style.dashed)
render.mark(brk > 0, shape = shape.triangle_up, at = anchor.bottom, color = color.emerald.400, title = "Break up")
render.mark(brk < 0, shape = shape.triangle_down, at = anchor.top, color = color.rose.400, title = "Break down")
render.background(jump > 0.3 ? color.translucent(color.orange.500, 85) : na)
alert.rule(brk != 0, "Structural break", "CUSUM detected a shift in the mean")Variance Ratio Regime
analysis · presets/analysis/stats/variance_ratio_regime.qs
Lo-MacKinlay variance ratios for holding periods 2, 4 and 8. A ratio above one means returns trend (they add up faster than a random walk), below one means they mean-revert. The three lines and their average decide the regime shaded in the background, useful as a switch between trend and reversion tactics.
analysis "Variance Ratio Regime" {
pane = hpane
height = 0.2
render = webgpu
}
/// Lo-MacKinlay variance ratios for holding periods 2, 4 and 8. A ratio above one means returns
/// trend (they add up faster than a random walk), below one means they mean-revert. The three
/// lines and their average decide the regime shaded in the background, useful as a switch between
/// trend and reversion tactics.
len = param.int(200, title = "Window", min = 30, max = 5000)
v2 = hft.variance_ratio(close, len, 2)
v4 = hft.variance_ratio(close, len, 4)
v8 = hft.variance_ratio(close, len, 8)
avg = (v2 + v4 + v8) / 3
render.line(v2, "VR(2)", color = color.translucent(color.sky.300, 30), width = 1)
render.line(v4, "VR(4)", color = color.translucent(color.violet.300, 30), width = 1)
render.line(v8, "VR(8)", color = color.translucent(color.fuchsia.300, 30), width = 1)
render.line(avg, "Average", color = color.white, width = 2)
render.level(1, "Random walk", color = color.translucent(color.slate.400, 50))
render.background(avg > 1.1 ? color.translucent(color.emerald.500, 88) : avg < 0.9 ? color.translucent(color.amber.500, 88) : na)Z-Score Bands
analysis · presets/analysis/stats/zscore_bands.qs · uses viz/palette
Mean-reversion envelope: the rolling mean plus 1 / 2 / 3 standard deviations, candles colored by the current z-score, and marks when price snaps back inside the 2-sigma band.
analysis "Z-Score Bands" {
overlay = true
}
/// Mean-reversion envelope: the rolling mean plus 1 / 2 / 3 standard deviations, candles colored
/// by the current z-score, and marks when price snaps back inside the 2-sigma band.
use "viz/palette" as pal
len = param.int(50, title = "Window", min = 5, max = 1000)
m = ta.sma(close, len)
sd = stat.stdev(close, len)
z = sd > 0 ? (close - m) / sd : 0
render.line(m, "Mean", color = color.slate.300, width = 1)
for k in [1, 2, 3] {
render.line(m + k * sd, str.format("+{} sd", k), color = k == 3 ? color.rose.500 : color.slate.600, width = 1, stroke = k == 1 ? style.dotted : style.solid)
render.line(m - k * sd, str.format("-{} sd", k), color = k == 3 ? color.teal.500 : color.slate.600, width = 1, stroke = k == 1 ? style.dotted : style.solid)
}
render.candle_color(pal.signed_heat(z, 3))
render.mark(ta.cross_down(z, 2), shape = shape.triangle_down, at = anchor.above_bar, color = pal.BEAR, text = "Revert")
render.mark(ta.cross_up(z, -2), shape = shape.triangle_up, at = anchor.below_bar, color = pal.BULL, text = "Revert")Z-Score Heatmap
analysis · presets/analysis/stats/zscore_heatmap.qs
The z-score of price against its mean over twelve lookbacks, one heatmap row each: blue when price is cheap, red when rich. Reading a column from short (bottom) to long (top) separates a short-term stretch from a long-term extreme. The top row is the average.
analysis "Z-Score Heatmap" {
pane = hpane
height = 0.24
}
/// The z-score of price against its mean over twelve lookbacks, one heatmap row each: blue when
/// price is cheap, red when rich. Reading a column from short (bottom) to long (top) separates a
/// short-term stretch from a long-term extreme. The top row is the average.
gap = param.int(20, title = "Lookback step", min = 2, max = 100)
first = param.int(10, title = "Shortest lookback", min = 5, max = 200)
total = 0.0
for k in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] {
z = stat.zscore(close, first + k * gap)
total := total + z
c = z >= 0 ? color.gradient(z, 0, 2.5, color.slate.800, color.rose.400) : color.gradient(z, -2.5, 0, color.sky.400, color.slate.800)
render.line(k, str.format("Row {}", k + 1), color = math.abs(z) > 3 ? color.yellow.300 : c, width = 10)
}
avg = total / 12
avg_c = avg >= 0 ? color.gradient(avg, 0, 2, color.slate.700, color.rose.500) : color.gradient(avg, -2, 0, color.sky.500, color.slate.700)
render.line(13, "Average z", color = avg_c, width = 13)
render.mark(avg > 2 and avg[1] <= 2, shape = shape.diamond, at = anchor.top, color = color.rose.400, title = "Rich on every horizon")
render.mark(avg < -2 and avg[1] >= -2, shape = shape.diamond, at = anchor.bottom, color = color.sky.400, title = "Cheap on every horizon")
alert.rule(avg > 2 and avg[1] <= 2, "Rich", "Average z-score across twelve lookbacks above 2")
alert.rule(avg < -2 and avg[1] >= -2, "Cheap", "Average z-score across twelve lookbacks below -2")