How to Code a Bollinger Bands EA in MQL5 (Mean-Reversion Example + Real Backtest)
Bollinger Bands are one of the most common building blocks for a mean-reversion strategy: price
tends to snap back toward the middle band after it stretches to an extreme. That idea is easy to
draw on a chart and surprisingly easy to get *wrong* in code. This guide walks through a complete,
compiling MQL5 Expert Advisor that trades it — and then shows a real MetaTrader 5 backtest of the
exact source, so you can see what the rules actually do rather than what they promise.
The strategy in one sentence
When a candle closes back above the lower band after closing below it, go long. When a candle
closes back below the upper band after closing above it, go short. Exit when price returns to
the middle (base) line. That "close back inside" filter matters — entering the moment price merely
touches a band is how you end up buying straight into a trend that keeps running.
The iBands handle (and the buffer order people miss)
In MQL5 you create an indicator once with a handle, then read its values with CopyBuffer. The
signature is:
int iBands(string symbol, ENUM_TIMEFRAMES period, int bands_period,
int bands_shift, double deviation, ENUM_APPLIED_PRICE applied_price);
The single detail that trips people up is the buffer numbering. For Bollinger Bands it is not
upper/middle/lower in visual order — it is:
- buffer
0= base line (the middle SMA) - buffer
1= upper band - buffer
2= lower band
Get that wrong and your "buy below the band" logic silently reads the middle line. Here is the setup:
#include <Trade/Trade.mqh>
input int InpBandsPeriod = 20;
input double InpDeviation = 2.0;
input double InpLots = 0.10;
input int InpStopLossPts = 600;
input int InpMagic = 770020;
CTrade c_Trade;
int h_Bands = INVALID_HANDLE;
double g_upper[], g_lower[], g_base[], g_close[];
int OnInit()
{
h_Bands = iBands(_Symbol, _Period, InpBandsPeriod, 0, InpDeviation, PRICE_CLOSE);
if(h_Bands == INVALID_HANDLE) { Print("iBands failed, err=", GetLastError()); return(INIT_FAILED); }
ArraySetAsSeries(g_upper, true); ArraySetAsSeries(g_lower, true);
ArraySetAsSeries(g_base, true); ArraySetAsSeries(g_close, true);
c_Trade.SetExpertMagicNumber(InpMagic);
c_Trade.SetTypeFillingBySymbol(_Symbol);
return(INIT_SUCCEEDED);
}
ArraySetAsSeries(..., true) makes index [0] the current (still-forming) bar, [1] the last
closed bar, [2] the one before it — so signal logic reads left-to-right in time like you'd expect.
Act on closed bars only
A mean-reversion rule that fires mid-bar will re-trigger on every tick until the bar closes,
firing a burst of orders. The fix is to do the work once per new bar:
bool IsNewBar()
{
datetime t = (datetime)SeriesInfoInteger(_Symbol, _Period, SERIES_LASTBAR_DATE);
if(t == g_lastBar) return(false);
g_lastBar = t;
return(true);
}
The entry and exit logic
Read three values of each buffer ([0] forming, [1] and [2] closed), compare the two closed
bars, and act:
void OnTick()
{
if(!IsNewBar()) return;
if(CopyBuffer(h_Bands, 1, 0, 3, g_upper) < 3) return; // upper
if(CopyBuffer(h_Bands, 2, 0, 3, g_lower) < 3) return; // lower
if(CopyBuffer(h_Bands, 0, 0, 3, g_base) < 3) return; // middle
if(CopyClose(_Symbol, _Period, 0, 3, g_close) < 3) return;
double closePrev = g_close[2], closeNow = g_close[1];
if(HasPosition())
{
long type = PositionGetInteger(POSITION_TYPE);
if(type == POSITION_TYPE_BUY && closeNow >= g_base[1]) c_Trade.PositionClose(_Symbol);
if(type == POSITION_TYPE_SELL && closeNow <= g_base[1]) c_Trade.PositionClose(_Symbol);
return;
}
double ask = SymbolInfoDouble(_Symbol, SYMBOL_ASK);
double bid = SymbolInfoDouble(_Symbol, SYMBOL_BID);
if(closePrev < g_lower[2] && closeNow > g_lower[1]) // closed back above lower band
{
double sl = InpStopLossPts > 0 ? NormalizeDouble(ask - InpStopLossPts * _Point, _Digits) : 0;
c_Trade.Buy(InpLots, _Symbol, ask, sl, 0);
}
else if(closePrev > g_upper[2] && closeNow < g_upper[1]) // closed back below upper band
{
double sl = InpStopLossPts > 0 ? NormalizeDouble(bid + InpStopLossPts * _Point, _Digits) : 0;
c_Trade.Sell(InpLots, _Symbol, bid, sl, 0);
}
}
A few things worth calling out. HasPosition() checks PositionsTotal() filtered by symbol and
magic number, so the EA holds at most one position and won't fight a trade opened by something else.
CTrade handles the order request plumbing, and SetTypeFillingBySymbol picks a filling mode the
broker actually accepts — a frequent cause of "order send failed" on otherwise-correct code. The
exit here is the middle band; a fixed stop-loss in points is the only hard risk cap, and there is no
take-profit because the whole premise is "ride it back to the mean," not to a fixed target.
A real backtest of this exact code
Rules that look clean can still behave differently than you assume, so here is the genuine
MetaTrader 5 Strategy Tester result for the source above — no tuning, default 20/2.0 bands, 0.10
lots, 600-point stop:
- Symbol / timeframe: EURUSD, H1
- Period: 2024.01.01 – 2024.07.01 (real-tick model)
- Net profit: +$69 on a $10,000 deposit
- Win rate: 62%
- Profit factor: 1.06
- Max drawdown: 2.8%
- Trades: 142
That is a textbook example of *why you backtest before you trust a strategy*: a 62% win rate sounds
great, but a profit factor barely above 1.0 means the average loss nearly cancels the average win —
the edge is thin and would likely be eaten by wider spreads or a different period. It's a starting
point to iterate on (different symbol, band period, a trend filter), not a finished system. Past
performance does not guarantee future results.
Let the tool write and backtest it for you
If you'd rather describe the strategy than hand-write the handle bookkeeping, that's exactly what
Ranar Algo does: you type the rules in plain English, it generates the MQL5, compile-checks it
against real MetaEditor, and runs the same MetaTrader 5 Strategy Tester backtest you saw above —
returning the net profit, win rate, profit factor, drawdown and trade count. You keep your IP and
can download the full .mq5 source; nothing is locked to us. It's still improving and feedback is
welcome. It won't invent an edge that isn't there — but it removes the boilerplate between an idea
and an honest backtest, which is usually where a good strategy dies of a silent bug.
Takeaways
iBandsbuffers are 0 = base, 1 = upper, 2 = lower — not visual order.- Set arrays as series and act on the last closed bar, gated by a new-bar check.
- Require a *close back inside* the band, not a touch, to avoid entering into a trend.
- Always backtest the exact code: a high win rate with a ~1.0 profit factor is a warning, not a win.