Volatility ranking · Ties · Reference samples
BBWP Pine Script: a percentile needs a precise comparison rule
Bollinger Band Width Percentile ranks a normalized width against earlier widths. A high rank says how the current reading compares with that selected history. It does not establish that width is currently increasing, that price will break upward or that a particular strategy will win. This implementation counts ties, skips missing historical widths and can use a partial reference window.
Count valid prior widths, including ties
width = 2 × population deviation(source, basisLength) / basis
rank = 100 × count(prior valid width ≤ current width) / count(prior valid width)
The loop visits offsets 1 through lookback. It excludes the current bar and ignores unavailable prior widths. A reading of 95 therefore means that 95% of the valid comparison widths are less than or equal to the current width. It does not necessarily mean 95 out of 100 observations, because the denominator can be smaller.
| Current width | Qualifying / valid prior values | Rank |
|---|---|---|
| 0.005 | 0 / 4 | 0 |
| 0.02 | 3 / 4 | 75 |
| 0.05 | 4 / 4 | 100 |
A strict-less-than definition would give 25, not 75, for current width 0.02. Neither is interchangeable with a midpoint tie rank. When comparing implementations, match the comparison operator, current-bar inclusion and denominator before comparing colors.
The width in this helper is 2σ/basis. A standard two-deviation Bollinger envelope has full normalized width 4σ/basis. Multiplying every valid width in the comparison by the same positive constant preserves the ordering and ties, so this factor difference does not change the percentile. It does change the reported raw width. Do not compare those raw values without reconciling the convention.
Three cases that can defeat a quick color reading
- All widths tied at zero. A constant positive price gives zero deviation and zero width. Once prior valid zero widths exist, every tie passes and BBWP is 100. The red background is a high-rank condition, not proof of volatility expansion.
- A partial history. With only one valid prior width, the rank can only be 0 or 100. With four valid samples, steps are 25 points. Default lookback 100 does not guarantee 100 available samples.
- Current width unavailable. The retained function checks prior validity but does not require current width to be valid before ranking. If current width is unavailable and prior valid samples exist, comparisons fail and the output can be zero. This zero must not be treated as a measured compression signal.
The default seven-observation basis first becomes valid on the seventh complete source observation. The eighth can obtain a rank from one prior valid width. A complete 100-prior-width sample first becomes possible on observation 107, assuming no missing data and a valid basis throughout. The copied script does not wait for that full sample.
A zero basis makes width unavailable. A negative basis produces a negative normalized value, so the normal interpretation of wider positive price envelopes no longer applies. Keep positive-basis validity separate from the percentile formula.
Rank is not a rate of expansion
A rank of 90 can remain high while absolute width decreases. The reference history is also rolling: an old observation leaving the window can move rank even if current width is unchanged. For example, prior widths [0.01, 0.02, 0.04] and current 0.02 give 66.67%; on the next bar, prior widths [0.02, 0.04, 0.02] with unchanged current 0.02 still give 66.67%. One bar later, prior widths [0.04, 0.02, 0.02] still give 66.67%; when 0.04 leaves and the prior window is all 0.02, rank reaches 100 without any new expansion.
The white default SMA(5) smooths ranks, not raw widths. A rising white line therefore does not establish increasing standard deviation. Its 50 reference is a rank reference, not the historical arithmetic mean of widths. The unsmoothed rank stays within 0–100 when computed from valid comparisons; an alternative smoother such as HMA can overshoot those bounds.
Keep Bollinger %B separate: it locates price within the bands. BBP is Bull Bear Power, a different indicator. A BB-inside-Keltner squeeze compares two channel boundaries; a percentile threshold does not establish that containment.
Pine Script and its current boundary behavior
The rank loop retains Pineify's current tie and partial-history behavior, including the missing-current limitation above. The deviation calculation runs on every bar before the denominator check. The low reference line is corrected to 2 to match the background threshold; it is not 97.
// This source code is subject to the terms of the Mozilla Public License 2.0 at https://mozilla.org/MPL/2.0/
// © Pineify
//@version=6
indicator(title="Bollinger Band Width Percentile", overlay=false, max_labels_count=500)
p_comm_time_range_to_unix_time(string time_range, int date_time = time, string timezone = syminfo.timezone) =>
int start_unix_time = na
int end_unix_time = na
int start_time_hour = na
int start_time_minute = na
int end_time_hour = na
int end_time_minute = na
if str.length(time_range) == 11
start_time_hour := math.floor(str.tonumber(str.substring(time_range, 0, 2)))
start_time_minute := math.floor(str.tonumber(str.substring(time_range, 3, 5)))
end_time_hour := math.floor(str.tonumber(str.substring(time_range, 6, 8)))
end_time_minute := math.floor(str.tonumber(str.substring(time_range, 9, 11)))
else if str.length(time_range) == 9
start_time_hour := math.floor(str.tonumber(str.substring(time_range, 0, 2)))
start_time_minute := math.floor(str.tonumber(str.substring(time_range, 2, 4)))
end_time_hour := math.floor(str.tonumber(str.substring(time_range, 5, 7)))
end_time_minute := math.floor(str.tonumber(str.substring(time_range, 7, 9)))
start_unix_time := timestamp(timezone, year(date_time, timezone), month(date_time, timezone), dayofmonth(date_time, timezone), start_time_hour, start_time_minute, 0)
end_unix_time := timestamp(timezone, year(date_time, timezone), month(date_time, timezone), dayofmonth(date_time, timezone), end_time_hour, end_time_minute, 0)
[start_unix_time, end_unix_time]
p_custom_sources() =>
[open, high, low, close, volume]
p_ta_Var_Func(src, length) =>
valpha = 2 / (length + 1)
vud1 = src > src[1] ? src - src[1] : 0
vdd1 = src < src[1] ? src[1] - src : 0
vUD = math.sum(vud1, 9)
vDD = math.sum(vdd1, 9)
vCMO = nz((vUD - vDD) / (vUD + vDD))
VAR = 0.0
VAR := nz(valpha * math.abs(vCMO) * src) + (1 - valpha * math.abs(vCMO)) * nz(VAR[1])
VAR
p_ta_Wwma_Func(src, length) =>
wwalpha = 1 / length
WWMA = 0.0
WWMA := wwalpha * src + (1 - wwalpha) * nz(WWMA[1])
WWMA
p_ta_Zlema_Func(src, length) =>
zxLag = length / 2 == math.round(length / 2) ? length / 2 : (length - 1) / 2
zxEMAData = src + src - src[zxLag]
ZLEMA = ta.ema(zxEMAData, length)
ZLEMA
p_ta_Tsf_Func(src, length) =>
lrc = ta.linreg(src, length, 0)
lrc1 = ta.linreg(src, length, 1)
lrs = lrc - lrc1
TSF = ta.linreg(src, length, 0) + lrs
TSF
p_ta_ma(series float source, simple int length, simple string ma_type) =>
switch ma_type
"SMA" => ta.sma(source, length)
"EMA" => ta.ema(source, length)
"RMA" => ta.rma(source, length)
"HMA" => ta.hma(source, length)
"WMA" => ta.wma(source, length)
"TMA" => ta.sma(ta.sma(source, math.ceil(length / 2)), math.floor(length / 2) + 1)
"VAR" => p_ta_Var_Func(source, length)
"WWMA" => p_ta_Wwma_Func(source, length)
"ZLEMA" => p_ta_Zlema_Func(source, length)
"TSF" => p_ta_Tsf_Func(source, length)
"VWMA" => ta.vwma(source, length)
p_ta_bbwp(series float source, simple string basisType, simple int basisLength, simple int lookback, simple string maType, simple int maLength) =>
basis = p_ta_ma(source, basisLength, basisType)
deviation = ta.stdev(source, basisLength)
bbw = basis != 0 ? 2 * deviation / basis : na
rankCount = 0
sampleCount = 0
for i = 1 to lookback
if not na(bbw[i])
sampleCount += 1
if bbw[i] <= bbw
rankCount += 1
bbwp = sampleCount > 0 ? rankCount * 100.0 / sampleCount : na
ma = p_ta_ma(bbwp, maLength, maType)
[bbwp, ma]
[p_ind_1_bbwp, p_ind_1_ma] = p_ta_bbwp(close, "SMA", 7, 100, "SMA", 5)
hline(100, title="BBWP - Scale High", color=color.rgb(255, 0, 0, 0), linestyle=hline.style_solid, linewidth=1)
hline(50, title="BBWP - Mid-Line", color=color.rgb(166, 166, 166, 0), linestyle=hline.style_dashed, linewidth=1)
hline(0, title="BBWP - Scale Low", color=color.rgb(0, 0, 255, 0), linestyle=hline.style_solid, linewidth=1)
bgcolor(p_ind_1_bbwp <= 2 ? color.new(color.rgb(0, 0, 255, 0), 30) : p_ind_1_bbwp >= 98 ? color.new(color.rgb(255, 0, 0, 0), 30) : na, title="BBWP - Alerts")
p_ind_1_bbwp_color = p_ind_1_bbwp <= 25 ? color.from_gradient(p_ind_1_bbwp, 0, 25, color.rgb(0, 0, 255, 0), color.rgb(0, 255, 255, 0)) : p_ind_1_bbwp <= 50 ? color.from_gradient(p_ind_1_bbwp, 25, 50, color.rgb(0, 255, 255, 0), color.rgb(0, 255, 0, 0)) : p_ind_1_bbwp <= 75 ? color.from_gradient(p_ind_1_bbwp, 50, 75, color.rgb(0, 255, 0, 0), color.rgb(255, 255, 0, 0)) : color.from_gradient(p_ind_1_bbwp, 75, 100, color.rgb(255, 255, 0, 0), color.rgb(255, 0, 0, 0))
plot(p_ind_1_bbwp, title="BBWP - BBWP", color=p_ind_1_bbwp_color, linewidth=2, display=display.all)
plot(p_ind_1_ma, title="BBWP - MA", color=color.rgb(255, 255, 255, 0), linewidth=2, display=display.all)
plot(98, title="BBWP - Extreme High Alert Level", color=color.rgb(255, 0, 0, 0), linewidth=1)
plot(2, title="BBWP - Extreme Low Alert Level", color=color.rgb(0, 0, 255, 0), linewidth=1)
The fixed call uses close, SMA basis length 7, 100 prior bar slots, and SMA smoothing length 5. It has no input declarations. The background uses inclusive thresholds: blue at or below 2 and red at or above 98. Those are plotted conditions, despite the drawing title containing Alerts; the source contains no alert or alertcondition calls.
The gradient runs blue to cyan over 0–25, cyan to green over 25–50, green to yellow over 50–75, and yellow to red over 75–100. Colors encode rank only. Plots can change while the current bar develops; the script has no closed-bar gate.
Publication context and a reproducible audit
The_Caretaker's BBWP publication is dated May 6, 2021 and describes ranking width over a lookback. Its written definition uses less-than wording; this Pineify loop explicitly uses less-than-or-equal. The retained source is not presented as a verbatim copy or an assertion of identical tie handling. John Bollinger's BandWidth rules provide background for the underlying normalized envelope width.
- Record the source, basis method, basis length, valid current width, prior valid sample count and tie rule.
- Check the four-value example above, a constant positive price, a zero basis and insufficient history before testing alerts.
- Specify whether an adaptation must wait for the full lookback and return unavailable when current width is invalid. Expose sample count and raw width for inspection.
- Define an event separately from a state. A move from rank 8 to 12 crosses a threshold of 10; remaining at 12 for another bar is only an above-threshold state.
- If adding a price breakout, specify a prior completed high/low window, entry timing, exits and costs. An exit below 75 cannot occur by crossing from above unless rank first reaches above that level; specify a fallback exit as well.
- Compare settings on dates excluded from selection. Rank is neither implied volatility nor a forecast of the next return, and equal ranks on different assets can correspond to very different raw widths.
One hundred four-hour bars cover 400 hours of bar duration in an uninterrupted feed, not a fixed calendar span across markets with weekends and sessions. Keep those trading calendars visible when choosing a lookback. No measured signal rate or optimal timeframe is established here.
Specify a BBWP adaptationCheck account access and usage for the selected product. A requested adaptation still needs compilation and numerical verification; a code generator does not establish the validity of a trading hypothesis.