← Engineering notes

Two Bollinger bands, same window, same standard deviations, different trades

Two widely-copied Bollinger helpers with identical parameters disagree for the whole warm-up because of one min_periods argument, and one popular entry rule is secretly a test of that disagreement.

· 10 min read

Ask two Python helpers for "Bollinger bands, 40 period, 2 standard deviations" and you can get two different sets of numbers back. Neither is broken. "Bollinger bands" names a shape — a moving average with a moving standard deviation stacked either side of it — and the shape says nothing about what to do before you have forty samples. That gap is filled by whichever helper you copied, and the two helpers most widely copied in this corpus fill it differently.

So our indicator vocabulary carries both. One name, one extra parameter:

@register("bollinger", outputs=("upper", "mid", "lower"))
def _bollinger(ns: Namespace, source: str = "close", window: int = 20, stds: float = 2.0,
               flavour: str = "qtpylib") -> dict[str, pd.Series]:
    src = _series(ns, source)
    window = int(window)
    if flavour == "qtpylib":
        mid = src.rolling(window=window, min_periods=1).mean()
        std = src.rolling(window=window, min_periods=1).std()
    elif flavour == "sma":
        mid = src.rolling(window=window).mean()
        std = src.rolling(window=window).std()
    else:
        raise IndicatorError(f"bollinger flavour must be 'qtpylib' or 'sma', got {flavour!r}")

    upper, lower = mid + std * float(stds), mid - std * float(stds)
    if flavour == "sma":
        mid, upper, lower = (s.fillna(0.0) for s in (mid, upper, lower))
    return {"upper": upper, "mid": mid, "lower": lower}

(konis_core/strategy/indicators.py, the bollinger registration.) The module's first rule is why both spellings survive: match the original bit for bit — "where two callers computed 'bollinger bands' differently we keep both spellings rather than picking a winner."

The entire difference is min_periods, and it lives entirely in the warm-up.

What min_periods actually does

pandas.Series.rolling(window=40) defaults to min_periods=40: the first 39 outputs are NaN because the window is not full. min_periods=1 says emit a value as soon as there is at least one observation in the window. Two consequences that matter:

  • rolling(...).mean() with min_periods=1 is defined at row 0 — it is just the first close.
  • rolling(...).std() is not defined at row 0 even with min_periods=1, because pandas computes the sample standard deviation (ddof=1) and one observation gives a zero denominator. So the qtpylib bands are NaN at row 0 and real from row 1 — the second bar.

One frame, both flavours

Real candles, tests/testdata/UNITTEST_BTC-5m.feather, 5,760 rows of 5-minute data. Parameters taken from the strategy that actually uses this: window: 40, stds: 2, source close.

The first two closes are 0.0994766 and 0.0996900.

At row 1, the qtpylib flavour has exactly two samples. For n = 2 the sample standard deviation collapses to a one-term expression:

Δ   = 0.0996900 − 0.0994766 = 0.0002134
x̄   = 0.0995833
σ   = sqrt( ((x₁−x̄)² + (x₂−x̄)²) / (2−1) ) = |Δ| / √2
    = 0.0002134 / 1.4142136 = 0.00015090

lower = 0.0995833 − 2(0.00015090) = 0.0992815
upper = 0.0995833 + 2(0.00015090) = 0.0998851
width = 4σ = 0.0006036          (0.606% of mid)

The sma flavour at row 1 has 2 of 40 observations, so mean and std are both NaN, upper/mid/lower are all NaN, and then fillna(0.0) turns each of them into exactly 0.0.

Row 39 is the first row the 40-window can fill, and the first row on which the two flavours agree:

σ     = 0.00176451
mid   = 0.096225
lower = 0.092696
upper = 0.099754
width = 0.0070581              (7.335% of mid)

From row 39 onward the two are identical, forever — min_periods only ever describes a full window's behaviour when the window is not yet full. Every difference between the flavours is contained in rows 0 through 38.

Where our own comment overstates it

The docstring on _bollinger describes the min_periods=1 warm-up as carrying "the wide values a one- or two-sample standard deviation produces". On this frame that is not what we measured. The row-1 band is 0.0006036 wide against a settled row-39 band of 0.0070581 — about one twelfth as wide, not wider.

Measuring the ratio at every bar (close.rolling(2).std() / close.rolling(40).std(), 5,721 defined rows):

statistic σ over 2 samples ÷ σ over 40 samples
min 0.0000
median 0.2510
mean 0.3593
max 3.6496

364 of those 5,721 rows (6.36%) have the two-sample σ larger than the forty-sample σ. 156 rows have two consecutive closes that are exactly equal, giving σ = 0 and a band of zero width sitting on the mid.

So the accurate statement is narrower than the one in our comment. A two-sample σ is |Δ|/√2 — a rescaling of the single most recent move. It is not a small estimate of dispersion or a large one; it is not an estimate of dispersion at all. Sometimes it is 3.6× the settled width, usually it is a quarter of it, and 156 times on this frame it is nothing. "Wide" was the wrong word; "arbitrary" is the right one. The comment is due a correction.

The sting: a condition that is not about price

Here is the BinHV45 entry, from user_data/strategies_v2/combined-binh-cluc.yaml:

indicators:
  bb40: { fn: bollinger, source: close, window: 40, stds: 2, flavour: sma }

columns:
  bbdelta:    abs(bb40.mid - bb40.lower)
  closedelta: abs(close - shift(close, 1))
  tail:       abs(close - low)

entry:
  long:
    - name: binhv45
      when: >
        shift(bb40.lower, 1) > 0
        and bbdelta > close * bbdelta_close
        and closedelta > close * closedelta_close
        and tail < bbdelta * tail_bbdelta
        and close < shift(bb40.lower, 1)
        and close <= shift(close, 1)

Read the first condition as a price test and it is a tautology. The lower band is mid − 2σ over a positive price series; on this frame it never comes near zero — its minimum over all 5,760 rows is 0.0817, against closes around 0.09. A condition that is true on every settled bar is not selecting anything.

It is a warm-up test. It asks "is the previous bar's lower band a real number yet", spelled in the only vocabulary available to a rule engine that sees a float column.

Swapping the flavour is therefore not a cosmetic change. Running the gate shift(lower, 1) > 0 both ways on the same frame:

flavour gate first true at rows where gates disagree
qtpylib (min_periods=1) row 2
sma (min_periods=40, NaN→0) row 40 38

That is 38 leading bars per pair per run where one document admits entries and the other does not, and it generalises: qtpylib opens at row 2 regardless of window, sma opens at row window, so the disagreement is window − 2 bars. At window: 20 — the bb20 most of the ported documents use — it is 18.

Run the whole six-clause rule over the frame, changing nothing but the flavour: sma fires on 4 bars, qtpylib on 5, and the extra one is inside rows 0–39. Same rule text, same parameter card, one more entry bar.

That is exactly the difference the V5 variant of this document makes on purpose, and it says so in its own comment:

  # qtpylib, NOT the `sma` flavour V4 and earlier use. qtpylib computes with
  # min_periods=1, so the bands carry values from the second bar instead of
  # zeros until bar 40. The `shift(bb40.lower, 1) > 0` guard below is therefore
  # satisfied almost immediately here and only after warm-up there, so V5 can
  # take entries in its first 40 bars that V4 cannot. Same rule text, different
  # trades -- this is the single most important difference between the variants.
  bb40: { fn: bollinger, source: close, window: 40, stds: 2, flavour: qtpylib }

A correction to the obvious reading

The natural next sentence is "and the gate only works because fillna(0.0) makes the warm-up zeros instead of NaN." Our docstring says that too. We measured it, and it is not true of the gate.

NaN > 0 evaluates to False in pandas, exactly as 0.0 > 0 does. Running a third variant — the sma flavour with the fillna(0.0) removed — the gate opens at row 40 and the full rule fires on the same 4 bars. For this condition the zeros are doing nothing that the NaNs were not already doing.

Where the zeros are load-bearing is the other direction of comparison, because NaN is false against every operator while 0.0 is a number:

close > lower   zeros: 5449 true   NaN: 5410 true   differ on 39 rows
close < lower   zeros:  311 true   NaN:  311 true   differ on  0 rows

The 39 rows are precisely the warm-up. Any rule spelled close > band — a breakout mirror, a "price above the lower band" filter — opens on every warm-up bar under the zeros and stays shut under the NaNs. A rule spelled close < band behaves identically either way. So the fillna(0.0) is not a general safety net; it is a change in truth value that depends on which side of the comparison the band is on, and the BinHV45 gate happens to sit on the side where it makes no difference.

Where this bites and where it doesn't

run() in konis_core/strategy/runtime.py does not trim the warm-up; it returns a signal row for every bar in the frame it was given, and says so — the caller must supply at least startup_candles bars "before the first bar it cares about; short of that the leading indicator values are warm-up, not signal."

In live evaluation that caller is the signal worker, which asks for strategy.startup_candles + WARMUP_SLACK bars — 50 + 100 = 150 for this document. The bar being decided sits about 110 rows past the warm-up, so the flavour cannot change today's answer.

It changes the answer wherever a frame starts at row 0: a backtest over a window that begins at the start of the data, the left edge of a chart or authoring preview, and the first run on a pair that has barely more history than startup_candles.

Reproduce it

import pandas as pd

c = pd.read_feather("tests/testdata/UNITTEST_BTC-5m.feather")["close"]
W, S = 40, 2.0

mq = c.rolling(W, min_periods=1).mean(); sq = c.rolling(W, min_periods=1).std()
lq = mq - S * sq                                   # qtpylib flavour

ms = c.rolling(W).mean(); ss = c.rolling(W).std()
ls = (ms - S * ss).fillna(0.0)                     # sma flavour

print(lq.head(3).tolist())                         # [nan, 0.0992815, 0.0992030]
print(ls.head(3).tolist())                         # [0.0, 0.0, 0.0]
print(int(((lq.shift(1) > 0) != (ls.shift(1) > 0)).sum()))   # 38
print((lq[39], ls[39]))                            # identical from here on

The point about cards

A strategy card that reads Bollinger — window 40, stds 2 is showing you every parameter and still not showing you the function. Two documents can carry that identical card and disagree on 38 bars per pair per run, and one of them will take entries the other cannot.

This is why flavour is carried literally in the indicator cache key rather than resolved like a column reference — the cache's own note is that "a flavour like \"qtpylib\"" is not a name in the namespace and must not let two computations meet. There is a test whose entire job is to assert that the two spellings never share a cache entry.

An indicator name is a label on a family of functions. The parameters pick a member of the family only if everyone already agrees on the definition, and here they do not — not out of carelessness, but because the original authors copied different helpers and the difference never showed up in the part of the series anybody looks at.

KONIS is quantitative trading infrastructure. Backtest declarative strategies against years of exchange data, run what survives on a managed runtime, and read the market through analytics while it runs.

Build and backtest free — no card

Technical software and market analytics, not financial, investment or trading advice. Nothing here is a recommendation to buy or sell anything. Backtested results are historical and do not indicate future results. Trading carries substantial risk, including total loss of capital.