Indicator · Pine Script v6 · On the chart

Kalman Percentile Bands

by NQ-stats-MX

Kalman Percentile Bands plots a volatility-adaptive Kalman mean along with three empirical percentile bands on each side of the price chart.

View on GitHub

GitHub stars15on the repository
Pine versionv6current
Lines of code194
Settings40inputs
LicenceMIT
Last update2 weeks ago12 Oct 2026

Add It to Your TradingView Chart

  1. 1Copy the scriptPress Copy script above, or the copy button on the code below.
  2. 2Open the Pine EditorOpen a TradingView chart and choose Pine Editor at the bottom of the screen.
  3. 3Paste over the templateCreate a new indicator, select all of the template text and paste. Press Save and give it a name.
  4. 4Add it to the chartPress Add to chart, then adjust the settings with the gear icon next to its name.

Source Code

194 lines of Pine Script v6 from kalmanpercentilebands.pine, published under MIT. Keep the author's credit and licence lines when you reuse it.

kalmanpercentilebands.pine
//@version=6
indicator("Kalman Percentile Bands", shorttitle="KPB", overlay=true, max_labels_count=50)

// ============================================================================
// Kalman mean + 3 empirical percentile bands on each side.
//
//  1) Mean: local-level Kalman filter. Optionally vol-adaptive: the gain rises
//     when short-term vol exceeds long-term vol, so the mean keeps up in fast
//     markets and stays calm in quiet ones.
//  2) Volatility: EWMA of 1-bar price changes. Deviation from the mean is
//     normalized by it:  z = (price - mean) / vol
//  3) Percentiles: the bands are empirical percentiles of z over a lookback,
//     scaled back by current vol:  band = mean + percentile(z) * vol
//     -> no normal-distribution assumption, fat tails and skew are respected,
//        and the bands still widen/narrow instantly with volatility.
// ============================================================================

// ---- Calculation inputs ------------------------------------------------------
gM = "Kalman mean"
src      = input.source(close, "Source", group=gM)
qr       = input.float(0.02, "Q/R sensitivity (higher = faster mean)", minval=0.0001, step=0.005, group=gM)
adaptQ   = input.bool(true, "Volatility-adaptive mean", group=gM, tooltip="Scales the Kalman process noise by (fast vol / slow vol)^2, clamped. The mean speeds up in high-vol bursts and slows down in quiet markets.")
vFastLen = input.int(20, "Fast vol length", minval=2, group=gM)
vSlowLen = input.int(200, "Slow vol length", minval=10, group=gM)

gP = "Volatility & percentiles"
volLen   = input.int(30, "Band volatility length (EWMA)", minval=2, group=gP, tooltip="Lower = bands react faster to volatility changes.")
lookback = input.int(1000, "Percentile lookback (bars)", minval=100, maxval=5000, group=gP, tooltip="Needs enough bars for the tails: the 99% band uses the extreme 0.5% on each side. Lower it if the script is slow.")
mode     = input.string("Central (two-sided)", "Percentile mode", options=["Central (two-sided)", "One-sided"], group=gP, tooltip="Central: 95 = the band pair contains 95% of observations (2.5% above, 2.5% below).\nOne-sided: 95 = 95th percentile up, 5th percentile down (5% beyond each band).")
lagBands = input.bool(true, "Bands ignore the current bar", group=gP, tooltip="Bands use the previous bar's percentiles and vol, so a spike can't widen its own band.")

// ---- Band inputs (percentile + styling) -----------------------------------------
gB1 = "Band 1"
show1 = input.bool(true, "Show", group=gB1, inline="1a")
p1    = input.float(80.0, "Percentile", minval=1.0, maxval=99.99, step=0.5, group=gB1, inline="1a")
cU1   = input.color(color.new(color.red, 60), "Upper", group=gB1, inline="1b")
cL1   = input.color(color.new(color.green, 60), "Lower", group=gB1, inline="1b")
w1    = input.int(1, "Width", minval=1, maxval=6, group=gB1, inline="1c")
s1    = input.string("Dotted", "Style", options=["Solid", "Dashed", "Dotted"], group=gB1, inline="1c")

gB2 = "Band 2"
show2 = input.bool(true, "Show", group=gB2, inline="2a")
p2    = input.float(95.0, "Percentile", minval=1.0, maxval=99.99, step=0.5, group=gB2, inline="2a")
cU2   = input.color(color.new(color.red, 30), "Upper", group=gB2, inline="2b")
cL2   = input.color(color.new(color.green, 30), "Lower", group=gB2, inline="2b")
w2    = input.int(1, "Width", minval=1, maxval=6, group=gB2, inline="2c")
s2    = input.string("Dashed", "Style", options=["Solid", "Dashed", "Dotted"], group=gB2, inline="2c")

gB3 = "Band 3"
show3 = input.bool(true, "Show", group=gB3, inline="3a")
p3    = input.float(99.0, "Percentile", minval=1.0, maxval=99.99, step=0.5, group=gB3, inline="3a")
cU3   = input.color(color.new(color.red, 0), "Upper", group=gB3, inline="3b")
cL3   = input.color(color.new(color.green, 0), "Lower", group=gB3, inline="3b")
w3    = input.int(2, "Width", minval=1, maxval=6, group=gB3, inline="3c")
s3    = input.string("Solid", "Style", options=["Solid", "Dashed", "Dotted"], group=gB3, inline="3c")

gMS = "Mean line"
showM = input.bool(true, "Show", group=gMS, inline="ma")
cM    = input.color(color.new(color.orange, 0), "Color", group=gMS, inline="ma")
wM    = input.int(2, "Width", minval=1, maxval=6, group=gMS, inline="mb")
sM    = input.string("Solid", "Style", options=["Solid", "Dashed", "Dotted"], group=gMS, inline="mb")

gF = "Zone fills"
showFill = input.bool(true, "Show zone fills", group=gF)
fU1 = input.color(color.new(color.red, 95), "Upper  mean–B1", group=gF, inline="fu1")
fU2 = input.color(color.new(color.red, 90), "B1–B2", group=gF, inline="fu1")
fU3 = input.color(color.new(color.red, 82), "B2–B3", group=gF, inline="fu1")
fL1 = input.color(color.new(color.green, 95), "Lower  mean–B1", group=gF, inline="fl1")
fL2 = input.color(color.new(color.green, 90), "B1–B2", group=gF, inline="fl1")
fL3 = input.color(color.new(color.green, 82), "B2–B3", group=gF, inline="fl1")

gBG = "Background highlight"
bgOn  = input.bool(false, "Highlight when close is beyond a band", group=gBG)
bgLvl = input.int(3, "Band", options=[1, 2, 3], group=gBG)
bgUp  = input.color(color.new(color.red, 85), "Above upper", group=gBG, inline="bg")
bgLo  = input.color(color.new(color.green, 85), "Below lower", group=gBG, inline="bg")

gL = "Labels & table"
showLbl = input.bool(true, "Band labels on last bar", group=gL)
lblOff  = input.int(3, "Label offset (bars)", minval=0, maxval=50, group=gL)
lblSize = input.string("Small", "Label size", options=["Tiny", "Small", "Normal"], group=gL)
showTbl = input.bool(true, "Show readout table", group=gL)
tblPos  = input.string("Top right", "Table position", options=["Top right", "Bottom right", "Top left", "Bottom left"], group=gL)

// ---- Volatility -------------------------------------------------------------------
d1    = nz(src - src[1])
vFast = math.sqrt(ta.ema(d1 * d1, vFastLen))
vSlow = math.sqrt(ta.ema(d1 * d1, vSlowLen))
vol   = math.sqrt(ta.ema(d1 * d1, volLen))

// ---- Kalman mean (local level, R = 1, Q = qr * vol-ratio^2) -----------------------
ratio = adaptQ and vSlow > 0 ? math.max(0.5, math.min(3.0, vFast / vSlow)) : 1.0
qEff  = qr * ratio * ratio

var float km = na
var float kP = 1.0
if na(km)
    km := src
else
    float pp = kP + qEff
    float kg = pp / (pp + 1.0)
    km := km + kg * (src - km)
    kP := (1.0 - kg) * pp

// ---- Normalized deviation and percentile bands ---------------------------------------
z = vol > 0 ? (src - km) / vol : na

central = mode == "Central (two-sided)"
pu1 = central ? 50.0 + p1 / 2.0 : p1
pl1 = central ? 50.0 - p1 / 2.0 : 100.0 - p1
pu2 = central ? 50.0 + p2 / 2.0 : p2
pl2 = central ? 50.0 - p2 / 2.0 : 100.0 - p2
pu3 = central ? 50.0 + p3 / 2.0 : p3
pl3 = central ? 50.0 - p3 / 2.0 : 100.0 - p3

qU1 = ta.percentile_linear_interpolation(z, lookback, pu1)
qL1 = ta.percentile_linear_interpolation(z, lookback, pl1)
qU2 = ta.percentile_linear_interpolation(z, lookback, pu2)
qL2 = ta.percentile_linear_interpolation(z, lookback, pl2)
qU3 = ta.percentile_linear_interpolation(z, lookback, pu3)
qL3 = ta.percentile_linear_interpolation(z, lookback, pl3)

k  = lagBands ? 1 : 0
vB = vol[k]
u1 = km + qU1[k] * vB
l1 = km + qL1[k] * vB
u2 = km + qU2[k] * vB
l2 = km + qL2[k] * vB
u3 = km + qU3[k] * vB
l3 = km + qL3[k] * vB

pRank = ta.percentrank(z, lookback)

// ---- Line styles ------------------------------------------------------------------------
lsM = sM == "Dashed" ? plot.linestyle_dashed : sM == "Dotted" ? plot.linestyle_dotted : plot.linestyle_solid
ls1 = s1 == "Dashed" ? plot.linestyle_dashed : s1 == "Dotted" ? plot.linestyle_dotted : plot.linestyle_solid
ls2 = s2 == "Dashed" ? plot.linestyle_dashed : s2 == "Dotted" ? plot.linestyle_dotted : plot.linestyle_solid
ls3 = s3 == "Dashed" ? plot.linestyle_dashed : s3 == "Dotted" ? plot.linestyle_dotted : plot.linestyle_solid

// ---- Plots ---------------------------------------------------------------------------------
pM  = plot(km, "Kalman mean", color=showM ? cM : na, linewidth=wM, linestyle=lsM)
pU1 = plot(show1 ? u1 : na, "Upper band 1", color=cU1, linewidth=w1, linestyle=ls1)
pL1 = plot(show1 ? l1 : na, "Lower band 1", color=cL1, linewidth=w1, linestyle=ls1)
pU2 = plot(show2 ? u2 : na, "Upper band 2", color=cU2, linewidth=w2, linestyle=ls2)
pL2 = plot(show2 ? l2 : na, "Lower band 2", color=cL2, linewidth=w2, linestyle=ls2)
pU3 = plot(show3 ? u3 : na, "Upper band 3", color=cU3, linewidth=w3, linestyle=ls3)
pL3 = plot(show3 ? l3 : na, "Lower band 3", color=cL3, linewidth=w3, linestyle=ls3)

fill(pM,  pU1, color=showFill ? fU1 : na, title="Fill upper mean–B1")
fill(pU1, pU2, color=showFill ? fU2 : na, title="Fill upper B1–B2")
fill(pU2, pU3, color=showFill ? fU3 : na, title="Fill upper B2–B3")
fill(pM,  pL1, color=showFill ? fL1 : na, title="Fill lower mean–B1")
fill(pL1, pL2, color=showFill ? fL2 : na, title="Fill lower B1–B2")
fill(pL2, pL3, color=showFill ? fL3 : na, title="Fill lower B2–B3")

bgU = bgLvl == 1 ? u1 : bgLvl == 2 ? u2 : u3
bgL = bgLvl == 1 ? l1 : bgLvl == 2 ? l2 : l3
bgcolor(bgOn and close > bgU ? bgUp : bgOn and close < bgL ? bgLo : na, title="Beyond-band highlight")

// ---- Labels on the last bar -----------------------------------------------------------------
lSize = lblSize == "Tiny" ? size.tiny : lblSize == "Normal" ? size.normal : size.small
var array<label> lbls = array.new<label>()

addLbl(float y, string t, color c) =>
    if not na(y)
        lbls.push(label.new(bar_index + lblOff, y, t + "  " + str.tostring(y, format.mintick), color=color.new(color.black, 100), style=label.style_label_left, textcolor=color.new(c, 0), size=lSize))

if barstate.islast
    for l in lbls
        l.delete()
    lbls.clear()
    if showLbl
        if show1
            addLbl(u1, str.tostring(p1) + "%", cU1)
            addLbl(l1, str.tostring(p1) + "%", cL1)
        if show2
            addLbl(u2, str.tostring(p2) + "%", cU2)
            addLbl(l2, str.tostring(p2) + "%", cL2)
        if show3
            addLbl(u3, str.tostring(p3) + "%", cU3)
            addLbl(l3, str.tostring(p3) + "%", cL3)
        if showM
            addLbl(km, "Mean", cM)

// ---- Readout table -------------------------------------------------------------------------------
tPos = tblPos == "Bottom right" ? position.bottom_right : tblPos == "Top left" ? position.top_left : tblPos == "Bottom left" ? position.bottom_left : position.top_right
var table tb = table.new(tPos, 2, 3, bgcolor=color.new(color.black, 30), border_width=1)
if showTbl and barstate.islast
    table.cell(tb, 0, 0, "Percentile rank", text_color=color.white, text_size=size.small)
    table.cell(tb, 1, 0, na(pRank) ? "-" : str.tostring(pRank, "#.0"), text_color=pRank >= 50 ? color.red : color.lime, text_size=size.small)
    table.cell(tb, 0, 1, "z (vol units)", text_color=color.white, text_size=size.small)
    table.cell(tb, 1, 1, na(z) ? "-" : str.tostring(z, "#.00"), text_color=color.white, text_size=size.small)
    table.cell(tb, 0, 2, "Mean speed (vol ratio)", text_color=color.white, text_size=size.small)
    table.cell(tb, 1, 2, str.tostring(ratio, "#.00") + "x", text_color=color.white, text_size=size.small)

Overview

Kalman Percentile Bands calculates a local-level Kalman filter as the fair value mean, normalizes price deviations by an EWMA of volatility, and derives bands from the actual distribution of those deviations over a lookback window rather than assuming normality. Two percentile modes are available: central two-sided or one-sided. Bands ignore the current bar optionally and style independently for color, width, and line style. Main settings control Kalman sensitivity, volatility lengths, percentile lookback, and band display. It draws directly on the price chart for mean-reversion signals on any symbol and timeframe.

Details

Published by

NQ-stats-MX on GitHub

Type

Indicator, Pine Script v6

Draws

On the price chart

Category

Mean reversion, Volatility & bands, Moving averages

Markets

Indices

Timeframes

M15-H1

Risk Notes

No repainting, lookahead or stacked entries were found in its code or README. That says nothing about how well it works: test it first.

Settings

The inputs you can change from the gear icon on the chart. Defaults are the author's.

SettingTypeDefaultVariable
Sourcesourceclosesrc
Q/R sensitivity (higher = faster mean)float0.02qr
Volatility-adaptive meanbooltrueadaptQ
Fast vol lengthint20vFastLen
Slow vol lengthint200vSlowLen
Band volatility length (EWMA)int30volLen
Percentile lookback (bars)int1000lookback
Percentile modestringCentral (two-sided)mode
Bands ignore the current barbooltruelagBands
Showbooltrueshow1
Percentilefloat80.0p1
Uppercolorcolor.new(color.red, 60)cU1
Show all 40 settings
SettingTypeDefaultVariable
Lowercolorcolor.new(color.green, 60)cL1
Widthint1w1
StylestringDotteds1
Showbooltrueshow2
Percentilefloat95.0p2
Uppercolorcolor.new(color.red, 30)cU2
Lowercolorcolor.new(color.green, 30)cL2
Widthint1w2
StylestringDasheds2
Showbooltrueshow3
Percentilefloat99.0p3
Uppercolorcolor.new(color.red, 0)cU3
Lowercolorcolor.new(color.green, 0)cL3
Widthint2w3
StylestringSolids3
ShowbooltrueshowM
Colorcolorcolor.new(color.orange, 0)cM
Widthint2wM
StylestringSolidsM
Show zone fillsbooltrueshowFill
Upper mean–B1colorcolor.new(color.red, 95)fU1
B1–B2colorcolor.new(color.red, 90)fU2
B2–B3colorcolor.new(color.red, 82)fU3
Lower mean–B1colorcolor.new(color.green, 95)fL1
B1–B2colorcolor.new(color.green, 90)fL2
B2–B3colorcolor.new(color.green, 82)fL3
Highlight when close is beyond a bandboolfalsebgOn
Bandint3bgLvl
Third-party code. ForexR did not write, test or endorse this script, and the summary above was generated automatically from its code and README. Indicators describe the past, and a strategy's backtest can look better than live trading, especially when a script repaints. Check it in the Strategy Tester and on a demo account before you rely on it.

Similar Pine Scripts

This indicator plots trade entry and exit signals on the price chart by combining Bollinger Bands with optional filter conditions. It calculates standard Bollinger Bands along with RSI, Aroon, and moving average filters. Reversal signals trigger when price bre

Indicator v4 Volatility & bandsOscillators & momentum
Updated 2 months ago · None stated GitHub repository of Bollinger Bands Winner IND [Alorse]
6 stars

This indicator classifies price action into four distinct market regimes: Trend, Expansion, Compression, or Mean Reversion. It determines states using a simplified Hurst exponent estimate, ADX slope thresholds, and realized volatility measurements. Based on th

Indicator v6 TrendVolatility & bands
Updated 8 weeks ago · MIT GitHub repository of Capital41 Regime Matrix v3
5 stars

Greeffer's Scalping Indicator overlays higher-timeframe exponential moving averages, Bollinger Bands, and Donchian channels on the price chart. It flags a long when price touches the lower Donchian and lower band while the faster averages are not stacked beari

Indicator v5 Moving averagesVolatility & bands
Updated 4 years ago · None stated GitHub repository of Greeffer's Scalping Indicator

This indicator builds a channel from a Hull moving average of the source and an ATR scaled by a band multiplier, then normalizes price inside that channel into a 0 to 100 oscillator. It smooths the result with a weighted moving average and plots the line in a

Indicator v6 Mean reversionVolatility & bands
Updated 3 months ago · None stated GitHub repository of Radiant Mean Reversion Channels [Pineify]

Hampel Filter

by everget
948 stars

The Hampel Filter indicator identifies and filters out price outliers to smooth price data directly on the main chart. It calculates the rolling median and Median Absolute Deviation (MAD) of the chosen source series over a specified length. When a data point e

Indicator v4 Moving averagesVolatility & bands
Updated 10 days ago · GPL-3.0 GitHub repository of Hampel Filter

More indicators

Trade Your Charts With a Regulated Broker

Compare brokers that connect to TradingView, with spreads, minimum deposits and leverage side by side.

Compare brokers