feat: add new indicators (Decay, Edecay, MinusDi, MinusDm, PlusDi, PlusDm, Maxindex, Minindex, Sarext) and update pine scripts, core libs, validation tests, and python bindings
2026-03-09 13:45:46 -07:00
|
|
|
|
// Licensed under the Apache License, Version 2.0
|
2026-02-20 18:44:56 -08:00
|
|
|
|
// © mihakralj
|
|
|
|
|
|
//@version=6
|
|
|
|
|
|
indicator("Squeeze Momentum (SQUEEZE)", "SQUEEZE", overlay=false)
|
|
|
|
|
|
|
|
|
|
|
|
//@function Calculates Squeeze Momentum — BB vs KC squeeze detection with LinReg momentum
|
|
|
|
|
|
//@param source Series to evaluate (typically close)
|
|
|
|
|
|
//@param period Lookback period for BB, KC, Donchian midline, and LinReg
|
|
|
|
|
|
//@param bbMult Bollinger Band standard deviation multiplier
|
|
|
|
|
|
//@param kcMult Keltner Channel ATR multiplier
|
|
|
|
|
|
//@returns [momentum, squeezeOn] momentum histogram and squeeze state (1=on, 0=off)
|
|
|
|
|
|
//@description Squeeze Momentum detects low-volatility compressions (BB inside KC)
|
|
|
|
|
|
// and measures directional momentum via linear regression:
|
|
|
|
|
|
// Bollinger Bands: SMA ± bbMult × StdDev(period)
|
|
|
|
|
|
// Keltner Channel: EMA ± kcMult × ATR(period) using Wilder RMA
|
|
|
|
|
|
// Squeeze On: BB_upper < KC_upper and BB_lower > KC_lower
|
|
|
|
|
|
// Midline: (Highest(high, period) + Lowest(low, period)) / 2
|
|
|
|
|
|
// Delta: close - (midline + SMA) / 2
|
|
|
|
|
|
// Momentum: LinReg(delta, period) evaluated at endpoint
|
|
|
|
|
|
// SMA and variance use O(1) circular buffer (§3 warmup).
|
|
|
|
|
|
// EMA and RMA use §2 exponential warmup compensators.
|
|
|
|
|
|
// Donchian highest/lowest use circular buffers with running max/min scan.
|
|
|
|
|
|
// LinReg uses incremental O(1) running sums (ΣY, ΣXY).
|
|
|
|
|
|
// Positive momentum = bullish, negative = bearish.
|
|
|
|
|
|
// Squeeze transitions (on→off) signal potential breakout moves.
|
|
|
|
|
|
squeeze(series float source, simple int period, simple float bbMult, simple float kcMult) =>
|
|
|
|
|
|
if period <= 0
|
|
|
|
|
|
runtime.error("Period must be greater than 0")
|
|
|
|
|
|
if bbMult <= 0.0
|
|
|
|
|
|
runtime.error("BB multiplier must be greater than 0")
|
|
|
|
|
|
if kcMult <= 0.0
|
|
|
|
|
|
runtime.error("KC multiplier must be greater than 0")
|
|
|
|
|
|
|
|
|
|
|
|
float EPSILON = 1e-10
|
|
|
|
|
|
|
|
|
|
|
|
// ===== SMA + Variance (circular buffer, §3 warmup) =====
|
|
|
|
|
|
var array<float> smaBuf = array.new_float(period, na)
|
|
|
|
|
|
var int smaHead = 0
|
|
|
|
|
|
var float smaSum = 0.0
|
|
|
|
|
|
var float smaSumSq = 0.0
|
|
|
|
|
|
var int smaCount = 0
|
|
|
|
|
|
|
|
|
|
|
|
float srcVal = nz(source)
|
|
|
|
|
|
float oldSma = array.get(smaBuf, smaHead)
|
|
|
|
|
|
if not na(oldSma)
|
|
|
|
|
|
smaSum -= oldSma
|
|
|
|
|
|
smaSumSq -= oldSma * oldSma
|
|
|
|
|
|
else
|
|
|
|
|
|
smaCount += 1
|
|
|
|
|
|
smaSum += srcVal
|
|
|
|
|
|
smaSumSq += srcVal * srcVal
|
|
|
|
|
|
array.set(smaBuf, smaHead, srcVal)
|
|
|
|
|
|
smaHead := (smaHead + 1) % period
|
|
|
|
|
|
|
|
|
|
|
|
int n = math.max(1, smaCount)
|
|
|
|
|
|
float smaVal = smaSum / float(n)
|
|
|
|
|
|
float variance = math.max(0.0, smaSumSq / float(n) - smaVal * smaVal)
|
|
|
|
|
|
float stddev = math.sqrt(variance)
|
|
|
|
|
|
|
|
|
|
|
|
float bbUpper = smaVal + bbMult * stddev
|
|
|
|
|
|
float bbLower = smaVal - bbMult * stddev
|
|
|
|
|
|
|
|
|
|
|
|
// ===== EMA for KC midline (§2 warmup) =====
|
|
|
|
|
|
var float rawEma = 0.0
|
|
|
|
|
|
var float eEma = 1.0
|
|
|
|
|
|
float emaAlpha = 2.0 / (float(period) + 1.0)
|
|
|
|
|
|
float emaBeta = 1.0 - emaAlpha
|
|
|
|
|
|
|
|
|
|
|
|
rawEma := rawEma * emaBeta + srcVal * emaAlpha
|
|
|
|
|
|
eEma *= emaBeta
|
|
|
|
|
|
float cEma = eEma > EPSILON ? 1.0 / (1.0 - eEma) : 1.0
|
|
|
|
|
|
float emaVal = rawEma * cEma
|
|
|
|
|
|
|
|
|
|
|
|
// ===== True Range + RMA for ATR (§2 warmup) =====
|
|
|
|
|
|
var float prevClose = na
|
|
|
|
|
|
float trueRange = high - low
|
|
|
|
|
|
if not na(prevClose)
|
|
|
|
|
|
trueRange := math.max(trueRange, math.max(math.abs(high - prevClose), math.abs(low - prevClose)))
|
|
|
|
|
|
prevClose := close
|
|
|
|
|
|
|
|
|
|
|
|
var float rawRma = 0.0
|
|
|
|
|
|
var float eRma = 1.0
|
|
|
|
|
|
float rmaAlpha = 1.0 / float(period)
|
|
|
|
|
|
float rmaBeta = 1.0 - rmaAlpha
|
|
|
|
|
|
|
|
|
|
|
|
rawRma := rawRma * rmaBeta + trueRange * rmaAlpha
|
|
|
|
|
|
eRma *= rmaBeta
|
|
|
|
|
|
float cRma = eRma > EPSILON ? 1.0 / (1.0 - eRma) : 1.0
|
|
|
|
|
|
float atr = rawRma * cRma
|
|
|
|
|
|
|
|
|
|
|
|
float kcUpper = emaVal + kcMult * atr
|
|
|
|
|
|
float kcLower = emaVal - kcMult * atr
|
|
|
|
|
|
|
|
|
|
|
|
// ===== Squeeze detection =====
|
|
|
|
|
|
float sqOn = bbUpper < kcUpper and bbLower > kcLower ? 1.0 : 0.0
|
|
|
|
|
|
|
|
|
|
|
|
// ===== Donchian midline: (highest + lowest) / 2 over period =====
|
|
|
|
|
|
var array<float> hiBuf = array.new_float(period, na)
|
|
|
|
|
|
var array<float> loBuf = array.new_float(period, na)
|
|
|
|
|
|
var int donHead = 0
|
|
|
|
|
|
|
|
|
|
|
|
array.set(hiBuf, donHead, high)
|
|
|
|
|
|
array.set(loBuf, donHead, low)
|
|
|
|
|
|
donHead := (donHead + 1) % period
|
|
|
|
|
|
|
|
|
|
|
|
float highest = high
|
|
|
|
|
|
float lowest = low
|
|
|
|
|
|
for i = 0 to period - 1
|
|
|
|
|
|
float h = array.get(hiBuf, i)
|
|
|
|
|
|
float l = array.get(loBuf, i)
|
|
|
|
|
|
if not na(h)
|
|
|
|
|
|
highest := math.max(highest, h)
|
|
|
|
|
|
if not na(l)
|
|
|
|
|
|
lowest := math.min(lowest, l)
|
|
|
|
|
|
|
|
|
|
|
|
float donMid = (highest + lowest) / 2.0
|
|
|
|
|
|
|
|
|
|
|
|
// ===== Delta: close - average of donchian midline and SMA =====
|
|
|
|
|
|
float delta = srcVal - (donMid + smaVal) / 2.0
|
|
|
|
|
|
|
|
|
|
|
|
// ===== LinReg of delta over period (O(1) incremental) =====
|
|
|
|
|
|
var array<float> lrBuf = array.new_float(period, na)
|
|
|
|
|
|
var int lrHead = 0
|
|
|
|
|
|
var float sumY = 0.0
|
|
|
|
|
|
var float sumXY = 0.0
|
|
|
|
|
|
var int lrCount = 0
|
|
|
|
|
|
|
|
|
|
|
|
float oldLr = array.get(lrBuf, lrHead)
|
|
|
|
|
|
if not na(oldLr)
|
|
|
|
|
|
int oldIdx = lrCount - period
|
|
|
|
|
|
sumY -= oldLr
|
|
|
|
|
|
sumXY -= float(oldIdx) * oldLr
|
|
|
|
|
|
else
|
|
|
|
|
|
// noop — count handles warmup
|
|
|
|
|
|
nop = 0
|
|
|
|
|
|
|
|
|
|
|
|
sumY += delta
|
|
|
|
|
|
sumXY += float(lrCount) * delta
|
|
|
|
|
|
array.set(lrBuf, lrHead, delta)
|
|
|
|
|
|
lrHead := (lrHead + 1) % period
|
|
|
|
|
|
|
|
|
|
|
|
lrCount += 1
|
|
|
|
|
|
|
|
|
|
|
|
int pn = math.min(lrCount, period)
|
|
|
|
|
|
int startIdx = lrCount - pn
|
|
|
|
|
|
float sumX = float(pn) * float(startIdx + startIdx + pn - 1) / 2.0
|
|
|
|
|
|
float sumX2 = 0.0
|
|
|
|
|
|
for i = 0 to pn - 1
|
|
|
|
|
|
float xi = float(startIdx + i)
|
|
|
|
|
|
sumX2 += xi * xi
|
|
|
|
|
|
|
|
|
|
|
|
float denomX = float(pn) * sumX2 - sumX * sumX
|
|
|
|
|
|
float slope = denomX == 0.0 ? 0.0 : (float(pn) * sumXY - sumX * sumY) / denomX
|
|
|
|
|
|
float intercept = (sumY - slope * sumX) / float(pn)
|
|
|
|
|
|
float momentum = slope * float(lrCount - 1) + intercept
|
|
|
|
|
|
|
|
|
|
|
|
[momentum, sqOn]
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- Main loop ----------
|
|
|
|
|
|
|
|
|
|
|
|
// Inputs
|
|
|
|
|
|
i_source = input.source(close, "Source")
|
|
|
|
|
|
i_period = input.int(20, "Period", minval=1)
|
|
|
|
|
|
i_bbMult = input.float(2.0, "BB Multiplier", minval=0.001, step=0.1)
|
|
|
|
|
|
i_kcMult = input.float(1.5, "KC Multiplier", minval=0.001, step=0.1)
|
|
|
|
|
|
|
|
|
|
|
|
// Calculation
|
|
|
|
|
|
[mom, sqOn] = squeeze(i_source, i_period, i_bbMult, i_kcMult)
|
|
|
|
|
|
|
|
|
|
|
|
// Plot
|
|
|
|
|
|
momColor = mom > 0 ? (mom > nz(mom[1]) ? color.lime : color.green) : (mom < nz(mom[1]) ? color.red : color.maroon)
|
|
|
|
|
|
plot(mom, "Momentum", color=momColor, linewidth=2, style=plot.style_histogram)
|
|
|
|
|
|
plot(sqOn == 1.0 ? 0 : na, "Squeeze On", color=color.red, linewidth=4, style=plot.style_circles)
|
|
|
|
|
|
plot(sqOn == 0.0 ? 0 : na, "Squeeze Off", color=color.lime, linewidth=4, style=plot.style_circles)
|
|
|
|
|
|
hline(0, "Zero", color=color.gray, linestyle=hline.style_dotted)
|