Why Donchian Channels Still Matter in 2025
I've coded a lot of breakout systems over the years — Keltner, Bollinger, even some weird adaptive stuff I'd rather forget. But when I need something that just works on trending markets, I keep coming back to Donchian Channels. They're brutally simple: take the highest high and lowest low over N periods, draw two lines, and trade the break. No smoothing, no weighting, no voodoo.
Most traders overlook the real power here. Donchian doesn't try to predict — it reacts. That lag is actually a feature, not a bug, because it keeps you in trends until they genuinely exhaust. Richard Donchian figured this out in the 1960s, and it's still the backbone of many institutional trend-following systems. I've seen hedge funds run variants of this on everything from currency futures to cocoa.
In this post, I'll walk you through building a complete Donchian Channel breakout EA in MQL5, with a dynamic trailing stop that adapts to volatility. You'll get the full code structure, input parameter tables, and the gotchas I've hit in live trading. If you're expecting a "copy-paste and get rich" script, you'll be disappointed. If you want something you can actually trust in the Strategy Tester and forward test, keep reading.
What Makes a Donchian Breakout Strategy Tick
The core logic is dead simple: price breaks above the upper channel = go long. Price breaks below the lower channel = go short. But the devil is in the details — specifically, how you confirm the breakout, where you place the stop, and how you trail it.
A typical Donchian Channel has three lines:
- Upper Channel — highest high of the last N bars
- Lower Channel — lowest low of the last N bars
- Middle Line — average of upper and lower (optional, often ignored)
Most traders set N to 20 for daily charts, but I've seen everything from 10 to 50 work depending on the instrument. The key insight: Donchian works best when the market has directional bias. In sideways chop, you'll get whipsawed to death. That's why we'll add a trend filter later.
One thing that trips up newcomers: Donchian Channels repaint by definition, because the highest high and lowest low shift as new bars form. This isn't a bug — it's how the channel adapts. But it means you should never base an entry on the current bar's channel values alone. Always check that the breakout happened on a completed bar, or at least use the previous bar's close as I do in the code below.
The Dynamic Trailing Stop Problem
Standard trailing stops are static — you set a fixed distance in points or pips, and the stop moves up as price moves in your favor. That's fine for ranging markets, but in strong trends, a fixed trailing stop is either too tight (gets you stopped out early) or too loose (gives back most of the profit). I've watched traders give back 80% of a 200-pip move because their 50-pip trail was way too narrow for that week's volatility.
Dynamic trailing means the stop distance adapts to recent volatility. When volatility expands, the trail widens; when it contracts, the trail tightens. I use the Average True Range (ATR) for this because it's simple, standard, and available in MQL5 out of the box. No external libraries, no custom indicators — just iATR().
Building the EA: MQL5 Implementation Step by Step
Let's get into the code. I'll assume you have basic MQL5 familiarity — you know how to create an Expert Advisor, compile it, and run it in the Strategy Tester. If not, MetaQuotes has decent documentation on the basics. Open MetaEditor, create a new Expert Advisor, and paste the snippets as we go. I'll structure this as a single-file EA for simplicity, but you can split it into include files if you're managing multiple strategies.
Step 1: Input Parameters
Every good EA starts with clean, well-documented inputs. Here's what we'll expose to the user. These go at the top of the file, outside any functions:
| Parameter | Type | Default | Description |
|---|---|---|---|
| DonchianPeriod | int | 20 | Number of bars for channel calculation |
| ATRPeriod | int | 14 | ATR period for dynamic trail calculation |
| TrailMultiplier | double | 2.0 | Multiplier for ATR to set trail distance |
| LotSize | double | 0.1 | Fixed lot size for each trade |
| TrendFilterMA | int | 50 | Period for SMA trend filter (0 = disabled) |
| MagicNumber | int | 123456 | Unique identifier to manage EA's own orders |
| InitialSLBuffer | double | 0.5 | ATR multiplier buffer below channel for initial stop |
Notice the InitialSLBuffer parameter — that's a lesson from live trading. Placing the stop exactly at the Donchian low gets you stopped out on minor wicks. Adding a 0.5 ATR buffer gives breathing room. You'll want to optimize this per symbol.
Step 2: The Core Breakout Logic
In the OnTick() function, we first check if we have an open position for the current symbol. If not, we look for breakout signals. Here's the essential code for detecting a long breakout. I use iHighest() and iLowest() rather than custom loops — they're faster and less error-prone:
//+------------------------------------------------------------------+
//| Check for long breakout signal |
//+------------------------------------------------------------------+
bool CheckLongBreakout()
{
// Find highest high and lowest low over the Donchian period, excluding current bar
int highBar = iHighest(_Symbol, PERIOD_CURRENT, MODE_HIGH, DonchianPeriod, 1);
int lowBar = iLowest(_Symbol, PERIOD_CURRENT, MODE_LOW, DonchianPeriod, 1);
double donchHigh = iHigh(_Symbol, PERIOD_CURRENT, highBar);
double donchLow = iLow(_Symbol, PERIOD_CURRENT, lowBar);
double currentClose = iClose(_Symbol, PERIOD_CURRENT, 0);
double prevClose = iClose(_Symbol, PERIOD_CURRENT, 1);
// Breakout: current close above upper channel, previous close below
if(currentClose > donchHigh && prevClose <= donchHigh)
{
// Optional trend filter: price above SMA
if(TrendFilterMA > 0)
{
double sma = iMA(_Symbol, PERIOD_CURRENT, TrendFilterMA, 0, MODE_SMA, PRICE_CLOSE, 0);
if(currentClose < sma) return false;
}
return true;
}
return false;
}Notice I check that the previous close was below the channel. This prevents re-entering after price has already been above the channel for several bars. It's a simple confirmation that reduces false signals. I also use index 1 for the range — that means we look back starting from the previous completed bar, ignoring the current one. This avoids repaint issues.
For the short breakout, you'd mirror this: check if current close is below the lower channel and previous close was above it, with the trend filter requiring price below the SMA.
Step 3: Dynamic Trailing Stop Implementation
This is where the EA earns its keep. Instead of a fixed pip distance, we calculate the ATR on every tick and adjust the stop accordingly. The stop moves only when price moves in our favor by more than the trail distance. Here's the full function — I call it from OnTick() after checking for new signals:
//+------------------------------------------------------------------+
//| Update trailing stop for all positions |
//+------------------------------------------------------------------+
void UpdateTrailingStop()
{
double atr = iATR(_Symbol, PERIOD_CURRENT, ATRPeriod, 0);
double trailDistance = atr * TrailMultiplier;
// Convert to points for SL modification
double trailPoints = trailDistance / _Point;
for(int i = PositionsTotal() - 1; i >= 0; i--)
{
if(PositionSelectByTicket(PositionGetTicket(i)))
{
if(PositionGetString(POSITION_SYMBOL) != _Symbol) continue;
if(PositionGetInteger(POSITION_MAGIC) != MagicNumber) continue;
double currentSL = PositionGetDouble(POSITION_SL);
double currentTP = PositionGetDouble(POSITION_TP);
double openPrice = PositionGetDouble(POSITION_PRICE_OPEN);
double currentPrice = (PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY) ?
SymbolInfoDouble(_Symbol, SYMBOL_BID) :
SymbolInfoDouble(_Symbol, SYMBOL_ASK);
if(PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY)
{
double newSL = currentPrice - trailPoints * _Point;
// Only move SL forward if it's an improvement and trade is in profit
if(newSL > currentSL && newSL > openPrice + trailPoints * _Point)
{
MqlTradeRequest request = {};
MqlTradeResult result = {};
request.action = TRADE_ACTION_SLTP;
request.position = PositionGetTicket(i);
request.sl = NormalizeDouble(newSL, _Digits);
request.tp = currentTP;
OrderSend(request, result);
// Check for errors
if(result.retcode != TRADE_RETCODE_DONE)
{
Print("Trail modify failed for ticket ", PositionGetTicket(i),
" error: ", result.retcode);
}
}
}
else if(PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_SELL)
{
double newSL = currentPrice + trailPoints * _Point;
if(newSL < currentSL || currentSL == 0)
{
MqlTradeRequest request = {};
MqlTradeResult result





