Bollinger Band mean reversion
A study that arms on a close outside a Bollinger Band and orders only on a later close back inside. Educational only. A tag is not treated as an order.
Educational study of a published rule. It is not a signal, a recommendation, or a forecast. The series is the ECB daily US dollar/euro reference rate from 2 January 2020 to 31 December 2024, one rate per TARGET business day, copied into open, high, low and close. Source: ECB statistics. The study charges a spread of 0.01 percent of the rate, a fraction of 0.0001, once, against the trader. Commission is zero. Swap and slippage are not modelled. The script does not print a return. Past performance does not predict future results.
Idea
Bollinger Bands draw a simple average and a pair of curves at a multiple of the standard deviation of the same window. The defaults used here are the ones John Bollinger states as the original defaults: 20 observations, and 2 standard deviations. He also states that the calculation uses the population standard deviation. In code that means the variance divides by N, the length of the window, and not by N − 1. The sample divisor makes the bands a little wider. This study uses N. The fixture checks the upper band of 1, 2, 3 at period 3 and width 2, which is 2 + 2 * sqrt(2/3).
The bands answer a descriptive question: is this observation high or low relative to the recent window? They are not, on Bollinger’s own rules, an order.
What the code does with a tag
Bollinger’s published rules say that a tag of the upper band is not in and of itself a sell, and a tag of the lower band is not in and of itself a buy. They also say that closes outside the bands are initially continuation signals, not reversal signals, and that in a trend price can walk along a band. This study is explicit about disagreeing with the use of a tag as an order, and about still being a fade.
A close strictly outside the lower band arms a long. It does not buy. The buy is a later close that is no longer outside the lower band, a close that has come back inside. The short is the mirror at the upper band. The observation that enters is not allowed to exit on the same close, so a gap from outside the band to beyond the average still opens the position. A later close at or through the middle band leaves it. One position at a time. No reversal on the exit bar.
That is a teaching state machine. It is not a system published by Bollinger, and the page would be wrong if it pretended otherwise. The interesting part of the code is the arm, which is there so a tag cannot become an order by accident.
Fills, the sample, and the other language
The arm and the entry are judged on a completed observation. The fill is the next one. The spread fraction is 0.0001 of the rate, charged once against the trader, with commission at zero. Swap and slippage are not modelled. At a rate of 1.10 the fraction moves the fill by 0.00011. That is the formula, not a spread taken from a broker.
The series is the ECB daily US dollar per euro reference rate. Each row is one number, so the bands are bands around a reference rate, not around a traded high-low range. Python sends the signal to backtesting.py and does not print a return. C# implements the same bands, the same arm, and the same next-observation fill. The runs are required to share a signal digest. A prefix rebuild fails if the band at i moves when later rows are deleted.
A flat series never leaves zero deviation, never closes strictly outside, and never arms. The fixture checks that too.
The MQL5 sketch calls iBands. The MQL5 reference numbers buffer 0 as the base line, buffer 1 as the upper band and buffer 2 as the lower band. The sketch uses those numbers. It does not reimplement the divisor, it was not compiled, and it was not compared with the fixture. A terminal whose bands divide by N − 1 will not match this study even when the period and the width match.
Weaknesses
Fading a close back inside the band is a bet that the excursion is over. Bollinger’s rule 8 is the warning against that bet: a close outside is, in his account, initially a continuation. A trend that walks the band will arm this study and then either stay outside, so no order is sent, or dip back inside and get faded while the trend continues. The exit at the middle band can then be a long way from the entry. There is no other stop.
The population divisor is a real source of silent disagreement between programs. So is a window that includes a future row, and a fill at the same close that was compared with the band. This file divides by N, uses a trailing window, and fills next. The lengths 20 and 2 were not searched on the sample. Searching them on these five years would fit the only series the repository has.
The signal count is the number of position changes the state machine produced. It is not a score, and it is not comparable with the library’s count of closed round trips. Neither is a forecast.
On a demo account
Write down 20, 2, population deviation, and the arm, before opening a platform. In particular, write down that a tag is not the order. Use a demo account and the smallest size. Read the indicator’s standard-deviation setting. If the platform cannot select the population divisor, the line on the chart is not this study.
Compare three numbers in a log: your band, the platform’s band, and the fill. Act only after the bar has closed. Do not drop the width from 2 to 1.5 because a demo fade was early. That change is a different rule, chosen from a few tickets. A demo that follows this study is demonstrating a fade that the author of the bands does not treat as a signal. It is not a suggestion to run it with money. Past fills do not predict later ones.
Implementations
The decision uses only data up to that observation. The fill is the next observation. Python and C# are run against the ECB file and must produce the same signal digest. The MQL5 file is illustrative: it was not compiled or run in MetaTrader, and a chart has high and low prices that this sample does not. The code is released under the MIT licence. The ECB file is not.
Python
#!/usr/bin/env python3
# Copyright (c) 2026 fxtradingguide.com
# SPDX-License-Identifier: MIT
"""Bollinger Band mean-reversion study.
The middle band is a simple average. The width is a multiple of the population
standard deviation of that same window (divide by N, not N - 1). A close
outside a band arms the rule and is not itself an order. The order is the
later close that comes back inside. A long is left on a later close at or
through the middle band, and a short is the mirror. The entry bar is not
exited on the same close. The fill is the next observation. Nothing here is
a forecast or a recommendation.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pandas as pd
from backtesting import Backtest
ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT / "strategies" / "shared" / "python"))
from fxstudy.data import assert_sample, load_observations
from fxstudy.library import SignalStrategy
from fxstudy.report import (
MARGIN,
SPREAD_FRACTION,
STARTING_CASH,
assert_causal,
write_report,
)
STUDY = "bollinger-mean-reversion"
PERIOD = 20
WIDTH = 2.0
def bands(
close: list[float], period: int, width: float
) -> tuple[list[float | None], list[float | None], list[float | None]]:
if period <= 1 or width <= 0:
raise SystemExit("band period and width must be positive")
middle: list[float | None] = [None] * len(close)
upper: list[float | None] = [None] * len(close)
lower: list[float | None] = [None] * len(close)
for index in range(period - 1, len(close)):
window = close[index - period + 1 : index + 1]
mean = sum(window) / period
variance = sum((price - mean) ** 2 for price in window) / period
deviation = variance**0.5
middle[index] = mean
upper[index] = mean + width * deviation
lower[index] = mean - width * deviation
return middle, upper, lower
def signals(close: list[float], period: int = PERIOD, width: float = WIDTH) -> list[int | None]:
middle, upper, lower = bands(close, period, width)
position = 0
armed_long = False
armed_short = False
desired: list[int | None] = []
for index, price in enumerate(close):
if middle[index] is None or upper[index] is None or lower[index] is None:
desired.append(None)
continue
outside_lower = price < lower[index]
outside_upper = price > upper[index]
entered_this_bar = False
if position == 0 and armed_long and not outside_lower:
position = 1
entered_this_bar = True
armed_long = False
elif position == 0 and armed_short and not outside_upper:
position = -1
entered_this_bar = True
armed_short = False
if not entered_this_bar and position == 1 and price >= middle[index]:
position = 0
elif not entered_this_bar and position == -1 and price <= middle[index]:
position = 0
if outside_lower:
armed_long = True
armed_short = False
elif outside_upper:
armed_short = True
armed_long = False
desired.append(position)
return desired
def _assert_population_deviation() -> None:
# Mean of 1, 2, 3 is 2. Population variance is 2/3. Upper band at 2 widths
# is 2 + 2 * sqrt(2/3). The sample divisor (N - 1) would be wider.
_, upper, _ = bands([1, 2, 3], 3, 2)
expected = 2.0 + 2.0 * ((2.0 / 3.0) ** 0.5)
if upper[2] is None or abs(upper[2] - expected) > 1e-12:
raise SystemExit(f"population deviation fixture failed: {upper[2]}")
flat = signals([1.0] * 30, period=5, width=2)
if any(value not in (None, 0) for value in flat) or flat[-1] != 0:
raise SystemExit("a flat series should not arm a band trade")
def library_trade_count(dates: list[str], rates: list[float], desired: list[int | None]) -> int:
frame = pd.DataFrame(
{
"Open": rates,
"High": rates,
"Low": rates,
"Close": rates,
"Signal": [float("nan") if value is None else float(value) for value in desired],
},
index=pd.to_datetime(dates),
)
backtest = Backtest(
frame,
SignalStrategy,
cash=STARTING_CASH,
spread=SPREAD_FRACTION,
commission=0.0,
margin=MARGIN,
trade_on_close=False,
hedging=False,
exclusive_orders=True,
)
return int(backtest.run()["# Trades"])
def main() -> None:
_assert_population_deviation()
observations = load_observations()
assert_sample(observations)
rates = [item.rate for item in observations]
assert_causal(len(rates), lambda end: signals(rates[:end]))
desired = signals(rates)
trades = library_trade_count([item.date for item in observations], rates, desired)
write_report(STUDY, "python", observations, desired, str(trades))
if __name__ == "__main__":
main()
C#
// Copyright (c) 2026 fxtradingguide.com
// SPDX-License-Identifier: MIT
using FxTradingGuide.Studies;
namespace FxTradingGuide.Strategies.BollingerMeanReversion;
/// <summary>
/// Population standard deviation (divide by N). A close outside a band arms
/// the rule. The order is the later close that comes back inside.
/// </summary>
public static class BollingerMeanReversion
{
public const int Period = 20;
public const double Width = 2.0;
public static (double?[] Middle, double?[] Upper, double?[] Lower) Bands(
IReadOnlyList<double> close,
int period,
double width)
{
if (period <= 1)
{
throw new ArgumentOutOfRangeException(nameof(period));
}
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width);
var middle = new double?[close.Count];
var upper = new double?[close.Count];
var lower = new double?[close.Count];
for (var index = period - 1; index < close.Count; index++)
{
double sum = 0;
var start = index - period + 1;
for (var cursor = start; cursor <= index; cursor++)
{
sum += close[cursor];
}
var mean = sum / period;
double squares = 0;
for (var cursor = start; cursor <= index; cursor++)
{
var deviation = close[cursor] - mean;
squares += deviation * deviation;
}
var deviationWidth = Math.Sqrt(squares / period);
middle[index] = mean;
upper[index] = mean + (width * deviationWidth);
lower[index] = mean - (width * deviationWidth);
}
return (middle, upper, lower);
}
public static int?[] Signals(IReadOnlyList<double> close, int period = Period, double width = Width)
{
var (middle, upper, lower) = Bands(close, period, width);
var position = 0;
var armedLong = false;
var armedShort = false;
var desired = new int?[close.Count];
for (var index = 0; index < close.Count; index++)
{
if (middle[index] is null || upper[index] is null || lower[index] is null)
{
desired[index] = null;
continue;
}
var price = close[index];
var outsideLower = price < lower[index];
var outsideUpper = price > upper[index];
var enteredThisBar = false;
if (position == 0 && armedLong && !outsideLower)
{
position = 1;
enteredThisBar = true;
armedLong = false;
}
else if (position == 0 && armedShort && !outsideUpper)
{
position = -1;
enteredThisBar = true;
armedShort = false;
}
if (!enteredThisBar && position == 1 && price >= middle[index])
{
position = 0;
}
else if (!enteredThisBar && position == -1 && price <= middle[index])
{
position = 0;
}
if (outsideLower)
{
armedLong = true;
armedShort = false;
}
else if (outsideUpper)
{
armedShort = true;
armedLong = false;
}
desired[index] = position;
}
return desired;
}
public static void AssertFixture()
{
var (_, upper, _) = Bands(new[] { 1d, 2d, 3d }, 3, 2d);
var expected = 2.0 + (2.0 * Math.Sqrt(2.0 / 3.0));
if (upper[2] is not double got || Math.Abs(got - expected) > 1e-12)
{
throw new InvalidOperationException("population deviation fixture failed");
}
var flat = Signals(Enumerable.Repeat(1d, 30).ToArray(), period: 5, width: 2d);
if (flat[^1] != 0 || flat.Any(value => value is not (null or 0)))
{
throw new InvalidOperationException("a flat series should not arm a band trade");
}
}
}
public static class Program
{
public static void Main()
{
BollingerMeanReversion.AssertFixture();
var observations = SampleSeries.Load();
var rates = observations.Select(item => item.Rate).ToArray();
StudyReport.AssertCausal(rates.Length, end =>
{
var slice = new double[end];
Array.Copy(rates, slice, end);
return BollingerMeanReversion.Signals(slice);
});
StudyReport.Write("bollinger-mean-reversion", observations, BollingerMeanReversion.Signals(rates));
}
}
MQL5 — Illustrative. This file was not compiled or run in MetaTrader. It calls iBands and does not reimplement the standard-deviation divisor, so it was not checked against the Python fixture.
//+------------------------------------------------------------------+
//| BollingerMeanReversion.mq5 |
//| Illustrative only. This file was not compiled or run. |
//| Copyright (c) 2026 fxtradingguide.com |
//| SPDX-License-Identifier: MIT |
//| https://fxtradingguide.com/strategies/bollinger-mean-reversion/ |
//+------------------------------------------------------------------+
#property copyright "Copyright (c) 2026 fxtradingguide.com"
#property link "https://fxtradingguide.com/strategies/bollinger-mean-reversion/"
#property version "1.00"
#property description "Educational band study. A tag of the band is not an order."
#property description "iBands buffer 0 is the middle, 1 the upper, 2 the lower."
#property description "The terminal's standard-deviation divisor is not reimplemented."
#property description "Orders are refused unless the account is a demo account."
#include <Trade/Trade.mqh>
input int BandPeriod = 20;
input double Width = 2.0;
input int Lookback = 400;
input double Lots = 0.01;
CTrade trade;
int bandHandle = INVALID_HANDLE;
datetime lastClosedBar = 0;
int OnInit()
{
if(AccountInfoInteger(ACCOUNT_TRADE_MODE) != ACCOUNT_TRADE_MODE_DEMO)
{
Print("Refusing to run: this study sends orders only on a demo account.");
return INIT_FAILED;
}
if(BandPeriod <= 1 || Width <= 0.0 || Lots <= 0.0)
return INIT_PARAMETERS_INCORRECT;
// Shift 0. The MQL5 reference numbers the buffers
// 0 BASE_LINE, 1 UPPER_BAND, 2 LOWER_BAND.
bandHandle = iBands(_Symbol, PERIOD_CURRENT, BandPeriod, 0, Width, PRICE_CLOSE);
if(bandHandle == INVALID_HANDLE)
{
Print("iBands failed. Error ", GetLastError());
return INIT_FAILED;
}
return INIT_SUCCEEDED;
}
void OnDeinit(const int reason)
{
if(bandHandle != INVALID_HANDLE)
IndicatorRelease(bandHandle);
}
void OnTick()
{
datetime closed = iTime(_Symbol, PERIOD_CURRENT, 1);
if(closed == 0 || closed == lastClosedBar)
return;
lastClosedBar = closed;
int target = 0;
if(!DesiredPosition(target))
return;
AlignDemoPosition(target);
}
bool CopyBand(const int buffer, const int count, double &values[])
{
ArraySetAsSeries(values, false);
return CopyBuffer(bandHandle, buffer, 1, count, values) == count;
}
bool DesiredPosition(int &target)
{
int available = BarsCalculated(bandHandle);
int count = MathMin(Lookback, available - 1);
if(count < BandPeriod + 2)
return false;
double middle[];
double upper[];
double lower[];
double close[];
if(!CopyBand(0, count, middle) || !CopyBand(1, count, upper) || !CopyBand(2, count, lower))
return false;
ArraySetAsSeries(close, false);
if(CopyClose(_Symbol, PERIOD_CURRENT, 1, count, close) != count)
return false;
int position = 0;
bool armedLong = false;
bool armedShort = false;
for(int i = 0; i < count; i++)
{
bool outsideLower = close[i] < lower[i];
bool outsideUpper = close[i] > upper[i];
bool enteredThisBar = false;
if(position == 0 && armedLong && !outsideLower)
{
position = 1;
enteredThisBar = true;
armedLong = false;
}
else if(position == 0 && armedShort && !outsideUpper)
{
position = -1;
enteredThisBar = true;
armedShort = false;
}
if(!enteredThisBar && position == 1 && close[i] >= middle[i])
position = 0;
else if(!enteredThisBar && position == -1 && close[i] <= middle[i])
position = 0;
if(outsideLower)
{
armedLong = true;
armedShort = false;
}
else if(outsideUpper)
{
armedShort = true;
armedLong = false;
}
}
target = position;
return true;
}
int OpenDirection()
{
if(!PositionSelect(_Symbol))
return 0;
long type = PositionGetInteger(POSITION_TYPE);
if(type == POSITION_TYPE_BUY)
return 1;
if(type == POSITION_TYPE_SELL)
return -1;
return 0;
}
void AlignDemoPosition(const int target)
{
int current = OpenDirection();
if(current == target)
return;
if(current != 0 && !trade.PositionClose(_Symbol))
{
Print("Demo close was not accepted: ", trade.ResultRetcodeDescription());
return;
}
if(target == 1)
trade.Buy(Lots);
else if(target == -1)
trade.Sell(Lots);
}
The order adapter and the shared report live next to the rule, so the page above is the decision and the links below are the fill.
Glossary
These are the directory’s definitions for words this study uses. They are not part of the rule, and they are not a recommendation.
Software catalogue
The Python file imports backtesting.py. MetaTrader 5 itself is in the platform directory below. The catalogue also lists the MetaTrader 5 Python package and the MQL5 Code Base. This study does not import that package, and the MQL5 file is not a Code Base entry.
Published broker costs
The spread in this study is one assumed fraction of the reference rate, applied on every fill. Swap is not modelled, and slippage is not measured. Firms publish their own spread, commission and financing figures, and those figures are not this assumption. The broker comparison sets the published figures side by side, with the source and the last-checked date. It does not score or rank firms, and it is not a recommendation.
Related guides
Quoting units such as pips, lots and leverage are explained in How FX trading works. What MetaTrader 4, MetaTrader 5, cTrader and TradingView are each for is in the platform guide. Neither page is a recommendation, and neither changes this study.
Sources and last review
Last reviewed 10 November 2026.
- John Bollinger, Bollinger Bands, including the defaults of 20 periods and 2 standard deviations and the statement that the population calculation is the one used.
- John Bollinger, Bollinger Bands rules. A tag of a band is not itself a signal, and a close outside a band is initially a continuation rather than a reversal.
- MQL5 reference for iBands. Buffer 0 is the base line, buffer 1 the upper band, buffer 2 the lower band. Cited for the illustrative file only.
- ECB Data Portal, euro foreign exchange reference rates, including the daily US dollar/euro series EXR.D.USD.EUR.SP00.A
- ECB, policy on reuse of ESCB statistics
- backtesting.py 0.6, Backtest constructor: spread, commission, and trade_on_close
The code on this page is released under the MIT licence. The ECB series is not covered by that licence. Source: ECB statistics.