Keltner Channel Squeeze EA MQL5: Code & Strategy

Build a Keltner Channel squeeze EA in MQL5 with complete code, entry/exit logic, optimization tips, and honest risk warnings.

keltner-channel-squeeze-ea-mql5-code-strategy

Most volatility strategies fail because traders confuse a wide range with an impending move. A Keltner Channel squeeze flips that logic: it's not the big swing that matters, but the quiet compression that precedes it. When the bands pinch tight, the market is coiling. The breakout that follows often carries real momentum, and that's what we want to catch in an Expert Advisor.

I've spent years trading and coding EAs in MetaTrader 5, and the Keltner Channel squeeze is one of the few setups I still trust after heavy optimization. It's not a holy grail—nothing is—but it gives you a clear, mechanical edge that translates well into MQL5 code. In this post, I'll show you exactly how to detect the squeeze, code the entry and exit rules, and avoid the common traps that eat into backtest profits.

What Is the Keltner Channel Squeeze?

The Keltner Channel is a volatility envelope built around an exponential moving average (EMA). The upper band is the EMA plus a multiple of the Average True Range (ATR), and the lower band is the EMA minus that same multiple. Unlike Bollinger Bands, which use standard deviation, Keltner Channels use ATR, making them more responsive to actual price gaps and less sensitive to sudden spikes in variance.

The squeeze happens when the channel width contracts to a historically low level. That compression means volatility is drying up. Price is moving sideways, ranges are shrinking, and the market is building energy. When it finally breaks out of that tight range, the move tends to be sharp because pent-up energy releases all at once.

Here's the key distinction most traders miss: you're not trading the squeeze itself, you're trading the expansion after it. The squeeze is just a trigger condition. It tells you to stand ready, not to fire blindly.

Why ATR-Based Bands for a Volatility Squeeze EA?

I prefer ATR over standard deviation for squeeze detection for one practical reason: ATR handles gaps realistically. A stock or forex pair that gaps overnight will distort a Bollinger Band calculation for several bars, but ATR just registers the gap as a large true range and moves on. That makes your squeeze detection more stable across different market sessions and asset classes.

There's also a psychological component. ATR is measured in price terms, so you can directly relate the channel width to your stop-loss distance and position size. If the channel is 30 pips wide on EURUSD, you know a reasonable stop is somewhere in that ballpark. That immediacy is useful when you're coding risk management into the same EA.

One more thing worth mentioning: ATR doesn't assume a normal distribution of price returns, unlike standard deviation. Financial markets are fat-tailed—extreme moves happen more often than a Gaussian model suggests. ATR, being based on actual high-low ranges, captures that reality better. For a squeeze strategy that's explicitly hunting for explosive moves, that's not a minor detail.

Detecting the Squeeze: The Core Logic

The squeeze condition itself is simple: the channel width falls below a threshold. But the threshold needs to be dynamic, not a fixed pip value, because volatility regimes change. A 20-pip channel might be tight on Monday and wide on Friday. So we compare the current channel width to its own recent history.

Here's the logic I use in production EAs:

  1. Calculate the Keltner Channel width as (Upper Band - Lower Band) / EMA. Dividing by price normalizes it across instruments.
  2. Track the rolling average of that normalized width over the last N bars, say 100.
  3. Define a squeeze as the current width being less than a percentage of that average, typically 60-70%.

That percentage is your squeeze sensitivity. Lower values mean you only trade extreme compressions, which are rarer but potentially more explosive. Higher values catch more squeezes but include weaker breakouts. I usually start with 0.6 and adjust based on the instrument's typical behavior.

One thing to watch: the lookback period matters as much as the percentage. Too short a lookback (like 20 bars) and you'll get false alarms in choppy markets. Too long (like 200 bars) and the average becomes stale, missing regime shifts. I find 80-120 bars works well on H1 and H4 timeframes.

There's a subtlety here that most articles gloss over: the normalization step. If you just use raw channel width in pips, the strategy becomes useless across different instruments. A 50-pip width is tight for GBPJPY but extremely wide for EURCHF. Dividing by the EMA price level converts everything to a percentage, so the same InpSqueezePct value works reasonably on forex pairs, indices, and even gold. That's a huge practical advantage when you're deploying the same EA across a portfolio of symbols.

MQL5 Implementation: Coding the Keltner Channel Squeeze EA

Now let's get into the code. I'll assume you have a basic understanding of MQL5 structure—OnInit, OnTick, and how to access indicator handles. If you're new to this, the Strategy Tester is your friend; you'll be spending a lot of time there.

Setting Up the Indicator Handles

MQL5 doesn't have a built-in Keltner Channel indicator, so we build it from an EMA and an ATR. We'll need two handles: one for the EMA and one for the ATR. Here's the initialization code:

//--- Indicator handles
int emaHandle;
int atrHandle;

//--- Input parameters
input int InpEMAPeriod = 20;        // EMA Period
input double InpATRMult = 2.0;      // ATR Multiplier
input int InpATRPeriod = 14;        // ATR Period
input int InpSqueezeLookback = 100; // Squeeze Lookback Bars
input double InpSqueezePct = 0.6;   // Squeeze Threshold (%)

int OnInit()
  {
   emaHandle = iMA(_Symbol, _Period, InpEMAPeriod, 0, MODE_EMA, PRICE_CLOSE);
   atrHandle = iATR(_Symbol, _Period, InpATRPeriod);

   if(emaHandle == INVALID_HANDLE || atrHandle == INVALID_HANDLE)
     {
      Print("Failed to create indicator handles. Error: ", GetLastError());
      return(INIT_FAILED);
     }
   return(INIT_SUCCEEDED);
  }

Notice I'm using iMA with MODE_EMA—that's the exponential moving average. The ATR is straightforward with iATR. Both handles are created in OnInit, which is the correct place. Creating them in OnTick would be wasteful and could cause performance issues, especially on slower VPS setups where every millisecond of tick processing matters.

Also note the input parameter naming convention. Prefixing with Inp is a habit I picked up years ago—it makes it instantly clear which variables are user-facing inputs versus internal state. When you come back to a project after six months, that clarity saves you a lot of head-scratching.

Calculating the Channel Width and Squeeze Condition

In OnTick, we need to copy the latest values from both handles and calculate the channel. Here's the core function:

bool IsSqueeze()
  {
   double ema[], atr[];
   ArraySetAsSeries(ema, true);
   ArraySetAsSeries(atr, true);

   //--- Copy enough bars for lookback
   if(CopyBuffer(emaHandle, 0, 0, InpSqueezeLookback + 2, ema) < InpSqueezeLookback + 2) return(false);
   if(CopyBuffer(atrHandle, 0, 0, InpSqueezeLookback + 2, atr) < InpSqueezeLookback + 2) return(false);

   //--- Current channel width (normalized)
   double upper = ema[0] + InpATRMult * atr[0];
   double lower = ema[0] - InpATRMult * atr[0];
   double currentWidth = (upper - lower) / ema[0];

   //--- Average width over lookback
   double sumWidth = 0.0;
   for(int i = 1; i <= InpSqueezeLookback; i++)
     {
      double up = ema[i] + InpATRMult * atr[i];
      double dn = ema[i] - InpATRMult * atr[i];
      sumWidth += (up - dn) / ema[i];
     }
   double avgWidth = sumWidth / InpSqueezeLookback;

   //--- Squeeze condition
   return(currentWidth < avgWidth * InpSqueezePct);
  }

Note the ArraySetAsSeries call—this makes index 0 the most recent bar, which is what we want. The loop starts at i=1 to skip the current bar when calculating the average. That's important: you don't want the current bar's width polluting the baseline you're comparing against. The current bar is still forming; its high and low are not final, so including it would introduce a bias. Skipping it is a small detail that makes the logic cleaner.

One thing I should flag: the CopyBuffer call requests InpSqueezeLookback + 2 bars. Why the extra two? Because we need the current bar (index 0) plus the lookback bars (indices 1 through InpSqueezeLookback), and MQL5's copy functions can sometimes lag one bar behind if the indicator hasn't fully calculated. The extra buffer absorbs that lag. If you request exactly the number you need and the indicator is one bar behind, you'll silently get stale data and wonder why your signals look wrong.

Entry and Exit Rules

The squeeze is just the trigger. The actual entry comes when price breaks out of the channel. Here's how I code it:

//--- Entry logic in OnTick()
if(!IsSqueeze())
  {
   //--- No squeeze, no trade
   return;
  }

//--- Wait for breakout of the channel
double ema[], atr[];
ArraySetAsSeries(ema, true);
ArraySetAsSeries(atr, true);
CopyBuffer(emaHandle, 0, 0, 2, ema);
CopyBuffer(atrHandle, 0, 0, 2, atr);

double upper = ema[0] + InpATRMult * atr[0];
double lower = ema[0] - InpATRMult * atr[0];

//--- Long entry: close breaks above upper band
if(Close[1] > upper && Close[2] <= upper + _Point)
  {
   //--- Open long
  }

//--- Short entry: close breaks below lower band
if(Close[1] < lower && Close[2] >= lower - _Point)
  {
   //--- Open short
  }

I'm using the previous bar's close (Close[1]) to confirm the breakout, not the current tick. This filters out false breakouts that happen on a single tick's spike. The condition Close[2] <= upper ensures the prior bar was inside the channel, so this is a genuine breakout, not just a continuation.

Notice I'm not using the current forming bar at all for entry confirmation. That's deliberate. The current bar's close is not final—it could be anything by the time the bar closes. Acting on an intra-bar breakout means you're reacting to noise. By waiting for the bar to close beyond the band, you get a cleaner signal at the cost of entering a few pips later. In my experience, that small give-up is worth the reduction in false entries.

For exits, I prefer a trailing stop based on ATR. Here's a simple version:

//--- Trailing stop
double atrVal = atr[0];
double trailDist = InpATRMult * atrVal * InpTrailFactor;

if(PositionSelect(_Symbol))
  {
   double stopLoss = PositionGetDouble(POSITION_PRICE_OPEN) + 
                     (PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY ? -trailDist : trailDist);
   //--- Adjust stop if price has moved favorably
   double newStop = (PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY) ?
                    MathMax(stopLoss, PositionGetDouble(POSITION_SL)) :
                    MathMin(stopLoss, PositionGetDouble(POSITION_SL));
   //--- Apply the new stop
   CTrade trade;
   trade.PositionModify(_Symbol, newStop, PositionGetDouble(POSITION_TP));
  }

This trails the stop by a multiple of the ATR. The key insight: as volatility expands after the squeeze, the trailing distance grows, giving the trade room to breathe. If you use a fixed pip stop, you'll get stopped out too early on the very moves you're trying to catch.

There's a refinement worth adding: only start trailing after price has moved a certain distance in your favor. If you trail from the very first tick, you'll often get stopped out at breakeven on a minor pullback that's just normal market noise. I typically set an activation threshold—say, 1.5 times the initial ATR distance—before the trailing logic kicks in. That small change cuts the whipsaw rate noticeably.

Input Parameters: What Each One Does

Before you start optimizing, you need to understand what each input actually controls. Here's a breakdown of the parameters from the code above, plus a few extras I always include:

ParameterTypeDefaultDescription
InpEMAPeriodint20EMA period for the channel center. Shorter values react faster but produce more false signals.
InpATRMultdouble2.0Multiplier for ATR to set band distance. Higher values widen the channel, making squeezes rarer.
InpATRPeriod

Want to build your own version?

Recreate similar entry logic, risk rules, and filters in TradingBotMaker—no MQL coding. Start free.

Community

Clap for the article and open comments only when you want to read them.

0 claps0 comments

Related articles