How to Code an ATR Trailing Stop in MQL5 (Full EA Example + Real Backtest)

Published 2026-10-13 · Ranar Algo

A fixed stop loss of, say, 300 points means very different things on a quiet Asian session and on a news spike. An ATR trailing stop solves that: the stop distance is a multiple of the Average True Range, so it widens when the market is volatile and tightens when it is calm, and it only ever moves in your favour. It is one of the most common things traders ask for in an Expert Advisor, and one of the easiest to get subtly wrong in MQL5.

This guide walks through a complete, compiling EA: a simple EMA crossover entry, an ATR-based initial stop, and an ATR trailing stop that ratchets on every new closed bar. The full source compiled with 0 errors in MetaEditor, and the results at the end come from running that exact file in the MT5 Strategy Tester.

Step 1: Create the ATR handle once, in OnInit

In MQL5, indicator functions return a handle, not a value. Create it once in OnInit() and release it in OnDeinit(). The signature is int iATR(string symbol, ENUM_TIMEFRAMES period, int ma_period):

h_Atr = iATR(_Symbol, _Period, InpATRPeriod);
if(h_Atr == INVALID_HANDLE) return(INIT_FAILED);
ArraySetAsSeries(g_atr, true);

A frequent bug in AI-generated or MQL4-ported code is calling iATR(...) on every tick and treating the return value as a price. In MQL5 that number is a handle ID, so the "stop distance" ends up being something like 10 points regardless of volatility.

Step 2: Read ATR from the last closed bar

Use CopyBuffer() with buffer 0 (ATR has a single buffer). Start at position 1 so you read the last *closed* bar, not the forming one, which still changes tick by tick:

if(CopyBuffer(h_Atr, 0, 1, 1, g_atr) < 1) return;
double atr = g_atr[0];   // ATR of the last closed bar, in price units

ATR is already in price units (for EURUSD something like 0.0012), so the stop distance is simply atr * InpATRMult. No conversion to points is needed until you compare it with broker limits.

Step 3: Only act once per bar

Trailing on every tick generates a flood of modify requests, and some brokers will throttle or reject them. Gate the logic with a new-bar check:

bool IsNewBar()
  {
   datetime t = (datetime)SeriesInfoInteger(_Symbol, _Period, SERIES_LASTBAR_DATE);
   if(t == g_lastBar) return(false);
   g_lastBar = t;
   return(true);
  }

Step 4: The trailing function

This is the core. Three rules make it correct:

void TrailStop(double atr)
  {
   double dist     = atr * InpATRMult;
   double minDist  = SymbolInfoInteger(_Symbol, SYMBOL_TRADE_STOPS_LEVEL) * _Point;
   double bid      = SymbolInfoDouble(_Symbol, SYMBOL_BID);
   double ask      = SymbolInfoDouble(_Symbol, SYMBOL_ASK);
   ulong  ticket   = (ulong)PositionGetInteger(POSITION_TICKET);
   long   type     = PositionGetInteger(POSITION_TYPE);
   double curSL    = PositionGetDouble(POSITION_SL);
   double curTP    = PositionGetDouble(POSITION_TP);

   if(type == POSITION_TYPE_BUY)
     {
      double newSL = NormalizeDouble(bid - dist, _Digits);
      if(bid - newSL < minDist) return;
      if(curSL == 0.0 || newSL > curSL + _Point)
         c_Trade.PositionModify(ticket, newSL, curTP);
     }
   else
     {
      double newSL = NormalizeDouble(ask + dist, _Digits);
      if(newSL - ask < minDist) return;
      if(curSL == 0.0 || newSL < curSL - _Point)
         c_Trade.PositionModify(ticket, newSL, curTP);
     }
  }

CTrade::PositionModify(ticket, sl, tp) takes the position ticket plus the new SL and TP. Pass the existing TP back in, or you will silently remove it. On a hedging account always modify by ticket: the symbol overload modifies the position with the lowest ticket, which may not be yours.

Step 5: Entry with an ATR initial stop

The entry is a plain 20/50 EMA crossover on closed bars, with the initial stop set the same ATR distance away, so the trailing logic takes over smoothly:

if(crossUp)
   c_Trade.Buy(InpLots, _Symbol, ask, NormalizeDouble(ask - atr * InpATRMult, _Digits), 0.0);
else if(crossDn)
   c_Trade.Sell(InpLots, _Symbol, bid, NormalizeDouble(bid + atr * InpATRMult, _Digits), 0.0);

Before any trade, set c_Trade.SetExpertMagicNumber(InpMagic) and c_Trade.SetTypeFillingBySymbol(_Symbol) in OnInit(). The second call avoids the classic "unsupported filling mode" rejection when a broker only allows FOK or IOC. The EA also closes the position on an opposite crossover, so a trade exits either via the trailing stop or via a signal reversal, whichever comes first.

A real backtest of this exact code

Settings: 20/50 EMA, ATR(14) × 2.5, fixed 0.10 lots, no take profit, $10,000 deposit, no optimisation.

That is a losing result, and it is the honest one. A low win rate is expected with a trailing stop, because the idea is many small losses and a few long runners. On EURUSD in 2024, which ranged for much of the year, the runners did not pay for the whipsaws. The trailing stop kept drawdown small (4.6%) and did its job of capping risk; the weak part is the EMA entry. Sensible next experiments are a trend filter (only trade in the direction of a higher-timeframe EMA), a wider multiplier (3.0–3.5), or a trending instrument. Past performance does not guarantee future results.

Skip the boilerplate: describe it in plain English

If you would rather write "enter on a 20/50 EMA cross, trail the stop at 2.5 × ATR(14)" than manage handles, buffers and stop levels by hand, that is what Ranar Algo does. You describe the strategy in plain English, it generates the MQL5, compiles it in real MetaEditor, and runs an MT5 Strategy Tester backtest, returning the same metrics shown above. You keep your IP and can download the full .mq5 source. It is still improving and feedback is welcome.

Takeaways

Build & backtest your own EA free →

Ranar Algo turns a plain-English strategy into a compiled MQL5 Expert Advisor with a real MetaTrader 5 backtest. You keep your IP. Past performance does not guarantee future results.