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

basis = selected MA(source, basisLength)
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.

Synthetic five-slot history: [0.01, 0.02, 0.02, 0.04, unavailable]
Current widthQualifying / valid prior valuesRank
0.0050 / 40
0.023 / 475
0.054 / 4100

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

  1. 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.
  2. 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.
  3. 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.

Pine Script v6
// 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)
BBWP chart preview with a colored percentile series and white smoothing line

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.

  1. Record the source, basis method, basis length, valid current width, prior valid sample count and tie rule.
  2. Check the four-value example above, a constant positive price, a zero basis and insufficient history before testing alerts.
  3. 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.
  4. 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.
  5. 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.
  6. 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 adaptation

Check 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.

BBWP questions

This implementation counts valid prior normalized widths that are less than or equal to the current width, divides by the valid prior sample count and multiplies by 100. It measures rank in a selected history, not the size, direction or probability of the next price move.

The exact statement for this code is at least as wide as 95% of valid prior widths, because ties are counted with <=. The current bar is excluded from the reference sample. The denominator may be smaller than the configured lookback during warm-up or missing data.

Yes. If the price is a positive constant, width is zero. Once valid zero widths exist in the reference window, every tied value is counted and the rank is 100. The red high-rank background in this case does not demonstrate expanding volatility.

If current width is unavailable but some prior widths are valid, the retained rank loop counts samples while comparisons with current na do not pass. It can output zero. That is a data-validity limitation, not evidence of a narrow current band. Verify current width before interpreting an extreme rank.

No. BBWP compares width with its own history. BB-inside-Keltner compares both current boundaries of two channels, including their different centers. Neither condition implies the other.

No. The example plots a background at BBWP <= 2 or >= 98 and reference lines at 2 and 98. It does not call alert or alertcondition. An alert workflow requires a separately defined condition and a user-created TradingView alert.

They are this example and core configuration defaults: basis length 7, prior-bar lookback 100 and smoothing length 5, both using SMA. No optimum or best market is established. Different reference windows change what the rank means.

The default SMA is an average of valid ranks, so it stays within their range. Alternative smoothing methods such as HMA can overshoot. The underlying rank and its smoothed output are different series.