preview
A Trailing Stop Engine in MQL5 Supporting Five Trail Methods Simultaneously

A Trailing Stop Engine in MQL5 Supporting Five Trail Methods Simultaneously

MetaTrader 5Trading |
443 0
Ushana Kevin Iorkumbul
Ushana Kevin Iorkumbul

Introduction

Most EAs hardcode a single trailing-stop method (often a fixed pip distance) and apply it to every open position. That works as a baseline, but it ignores the diversity of trade types a real strategy produces. A breakout trade entered on a strong momentum candle needs room to breathe; a fixed 20-pip trail will stop it out on the first retracement. A tight scalp opened near a key level needs the opposite: a narrow trail that locks in gains before they evaporate. A trend-following position held for hours may be best served by something that accelerates as the trade matures.

The answer is not to pick one method and hope it suits all trades. It is to let each trade use the method best suited to why it was opened.

This article introduces CTrailingEngine, which runs five trailing-stop methods in parallel. Each registered position can use a different method. All five methods implement the same ITrailMethod interface: fixed pip, ATR multiplier, Parabolic SAR, percentage-of-profit, and swing high/low. The engine evaluates every registered position on every tick and calls PositionModify() only when the proposed stop is strictly better than the current one by at least one point. The SL never moves backward, and no redundant broker requests are issued.

Trailing engine architectural diagram

Architectural diagram showing CTrailingEngine dispatching to five ITrailMethod implementations on every tick. Only proposals that improve the current stop by at least one point pass the strict improvement check before reaching PositionModify().


Section 1: The Interface Pattern for Trailing Methods

The alternative to an interface is a switch statement: an enum that names the five methods, a Configure() function that accepts all possible parameters for all five, and a large switch in OnTick() that picks which formula to run. This works, but every new method requires touching the engine's own source. Adding a sixth method requires a new enum value, new switch cases, and new configuration parameters. Existing callers must accommodate them even if they do not use the method.

The interface approach inverts this. ITrailMethod declares two pure virtual methods:

//+------------------------------------------------------------------+
//|                                               ITrailMethod.mqh   |
//+------------------------------------------------------------------+
#ifndef ITRAILMETHOD_MQH
#define ITRAILMETHOD_MQH
//+------------------------------------------------------------------+
//| ITrailMethod                                                     |
//+------------------------------------------------------------------+
class ITrailMethod
  {
public:
                     ITrailMethod(void) {}
   virtual          ~ITrailMethod(void) {}
   virtual double    ComputeStopLevel(ulong ticket) = 0;
   virtual string    MethodName(void) = 0;
  };
#endif // ITRAILMETHOD_MQH
//+------------------------------------------------------------------+

Every concrete class implements ComputeStopLevel() independently. The engine knows nothing about ATR periods, SAR acceleration factors, or lookback windows. It only knows that calling ComputeStopLevel() on any ITrailMethod pointer returns a price level, or 0.0 to signal "skip this tick." Adding a sixth method means writing a new class that implements this interface — the engine does not change at all.

Two guarantees ComputeStopLevel() must honor: it always returns a price, never a pip distance, and it returns 0.0 rather than an invalid level when the indicator is still calculating or the position cannot be found. The engine applies the strict improvement check after this call — the method itself does not compare against the current SL.

Throughout this article, "point" refers to SYMBOL_POINT, the smallest price increment the broker allows for a given symbol. This is distinct from a pip, which on 5-digit brokers equals ten points.


Section 2: CFixedPipTrail — the Baseline Method

Fixed pip trailing is the oldest and simplest trailing approach. The stop loss sits a constant distance below the current bid for longs and above the current ask for shorts. As price moves favorably, the stop follows. When price reverses, the stop stays — guaranteed by the engine's improvement check, not by the method itself.

//+------------------------------------------------------------------+
//|                                                FixedPipTrail.mqh |
//+------------------------------------------------------------------+
#ifndef FIXEDPIPTRAIL_MQH
#define FIXEDPIPTRAIL_MQH
#include "ITrailMethod.mqh"
//+------------------------------------------------------------------+
//| CFixedPipTrail                                                   |
//+------------------------------------------------------------------+
class CFixedPipTrail : public ITrailMethod
  {
private:
   double            m_pip_distance;      // distance in pips from current price to the proposed SL

public:
                     CFixedPipTrail(void);
                    ~CFixedPipTrail(void);

   //--- configuration
   void              Configure(const double pip_distance);

   //--- trailing logic
   virtual double    ComputeStopLevel(ulong ticket) override;
   virtual string    MethodName(void) override;
  };
//+------------------------------------------------------------------+
//| Constructor                                                      |
//+------------------------------------------------------------------+
CFixedPipTrail::CFixedPipTrail(void) : m_pip_distance(20.0)
  {
  }
//+------------------------------------------------------------------+
//| Destructor                                                       |
//+------------------------------------------------------------------+
CFixedPipTrail::~CFixedPipTrail(void)
  {
  }
//+------------------------------------------------------------------+
//| Configure                                                        |
//+------------------------------------------------------------------+
void CFixedPipTrail::Configure(const double pip_distance)
  {
   m_pip_distance = (pip_distance > 0.0 ? pip_distance : 20.0);
  }
//+------------------------------------------------------------------+
//| ComputeStopLevel                                                 |
//| Returns bid - pip_distance * point_size for long positions,      |
//| and ask + pip_distance * point_size for short positions.         |
//+------------------------------------------------------------------+
double CFixedPipTrail::ComputeStopLevel(ulong ticket)
  {
   if(!::PositionSelectByTicket(ticket))
      return(0.0);

   string symbol        = ::PositionGetString(POSITION_SYMBOL);
   long   position_type = ::PositionGetInteger(POSITION_TYPE);
   double point_size    = ::SymbolInfoDouble(symbol, SYMBOL_POINT);

//--- determine pip size: on 5-digit brokers one pip = 10 points
   int digits = (int)::SymbolInfoInteger(symbol, SYMBOL_DIGITS);
   double pip_size = (digits == 3 || digits == 5) ? point_size * 10.0 : point_size;

   if(position_type == POSITION_TYPE_BUY)
     {
      double bid = ::SymbolInfoDouble(symbol, SYMBOL_BID);
      return(bid - m_pip_distance * pip_size);
     }
   else
     {
      double ask = ::SymbolInfoDouble(symbol, SYMBOL_ASK);
      return(ask + m_pip_distance * pip_size);
     }
  }
//+------------------------------------------------------------------+
//| MethodName                                                       |
//+------------------------------------------------------------------+
string CFixedPipTrail::MethodName(void)
  {
   return("FixedPip");
  }
#endif // FIXEDPIPTRAIL_MQH
//+------------------------------------------------------------------+

Configure() accepts the pip distance and guards against a zero or negative value. The default of 20 pips is a reasonable starting point for most forex majors.

ComputeStopLevel() selects the bid for long positions and the ask for short positions, converts the pip distance to a price distance by accounting for the broker's digit count, and returns the proposed level. On a 5-digit broker, one pip equals ten points — the digits == 5 check handles this correctly.


Section 3: CAtrTrail — Volatility-Adaptive Trailing

A fixed pip distance is arbitrary with respect to how much the instrument is actually moving. A 30-pip stop is uncomfortably tight during a volatile news event and unnecessarily loose during a quiet session, on the same symbol.

The ATR trail replaces the fixed distance with ATR × multiplier, where ATR measures the instrument's recent average true range. The stop automatically widens during volatile periods and tightens as volatility compresses.

//+------------------------------------------------------------------+
//|                                                     AtrTrail.mqh |
//+------------------------------------------------------------------+
#ifndef ATRTRAIL_MQH
#define ATRTRAIL_MQH

#include "ITrailMethod.mqh"
//+------------------------------------------------------------------+
//| CAtrTrail                                                        |
//+------------------------------------------------------------------+
class CAtrTrail : public ITrailMethod
  {
private:
   int               m_atr_period;        // ATR lookback period in bars
   double            m_atr_multiplier;    // multiple of ATR used as the trail distance
   ENUM_TIMEFRAMES   m_timeframe;         // chart timeframe for ATR calculation
   int               m_atr_handle;        // indicator handle, created once per symbol/period pair
   string            m_last_symbol;       // symbol the current handle was created for

public:
                     CAtrTrail(void);
                    ~CAtrTrail(void);

   //--- configuration
   void              Configure(const int atr_period, const double atr_multiplier,
                               const ENUM_TIMEFRAMES timeframe = PERIOD_CURRENT);

   //--- internal handles
   bool              EnsureHandle(const string symbol);

   //--- trailing logic
   virtual double    ComputeStopLevel(ulong ticket) override;
   virtual string    MethodName(void) override;
  };
//+------------------------------------------------------------------+
//| Constructor                                                      |
//+------------------------------------------------------------------+
CAtrTrail::CAtrTrail(void) : m_atr_period(14),
   m_atr_multiplier(2.0),
   m_timeframe(PERIOD_CURRENT),
   m_atr_handle(INVALID_HANDLE),
   m_last_symbol("")
  {
  }
//+------------------------------------------------------------------+
//| Destructor                                                       |
//+------------------------------------------------------------------+
CAtrTrail::~CAtrTrail(void)
  {
   if(m_atr_handle != INVALID_HANDLE)
      ::IndicatorRelease(m_atr_handle);
  }
//+------------------------------------------------------------------+
//| Configure                                                        |
//+------------------------------------------------------------------+
void CAtrTrail::Configure(const int atr_period, const double atr_multiplier,
                          const ENUM_TIMEFRAMES timeframe)
  {
   m_atr_period     = (atr_period > 1 ? atr_period : 14);
   m_atr_multiplier = (atr_multiplier > 0.0 ? atr_multiplier : 2.0);
   m_timeframe      = timeframe;

//--- invalidate any existing handle so it is recreated on the next call
   if(m_atr_handle != INVALID_HANDLE)
     {
      ::IndicatorRelease(m_atr_handle);
      m_atr_handle = INVALID_HANDLE;
      m_last_symbol = "";
     }
  }
//+------------------------------------------------------------------+
//| EnsureHandle                                                     |
//| Creates the ATR indicator handle on first use for this symbol.   |
//+------------------------------------------------------------------+
bool CAtrTrail::EnsureHandle(const string symbol)
  {
   if(m_atr_handle != INVALID_HANDLE && m_last_symbol == symbol)
      return(true);

   if(m_atr_handle != INVALID_HANDLE)
     {
      ::IndicatorRelease(m_atr_handle);
      m_atr_handle = INVALID_HANDLE;
     }

   m_atr_handle = ::iATR(symbol, m_timeframe, m_atr_period);

   if(m_atr_handle == INVALID_HANDLE)
      return(false);

   m_last_symbol = symbol;
   return(true);
  }
//+------------------------------------------------------------------+
//| ComputeStopLevel                                                 |
//| Reads the most recently completed bar's ATR value (index 1)      |
//| and returns bid - (ATR * multiplier) for longs.                  |
//+------------------------------------------------------------------+
double CAtrTrail::ComputeStopLevel(ulong ticket)
  {
   if(!::PositionSelectByTicket(ticket))
      return(0.0);

   string symbol        = ::PositionGetString(POSITION_SYMBOL);
   long   position_type = ::PositionGetInteger(POSITION_TYPE);

   if(!EnsureHandle(symbol))
      return(0.0);

   double atr_buffer[];
   ::ArraySetAsSeries(atr_buffer, true);

   if(::CopyBuffer(m_atr_handle, 0, 1, 1, atr_buffer) <= 0)
      return(0.0);

   double atr = atr_buffer[0];

   if(atr <= 0.0)
      return(0.0);

   if(position_type == POSITION_TYPE_BUY)
     {
      double bid = ::SymbolInfoDouble(symbol, SYMBOL_BID);
      return(bid - atr * m_atr_multiplier);
     }
   else
     {
      double ask = ::SymbolInfoDouble(symbol, SYMBOL_ASK);
      return(ask + atr * m_atr_multiplier);
     }
  }
//+------------------------------------------------------------------+
//| MethodName                                                       |
//+------------------------------------------------------------------+
string CAtrTrail::MethodName(void)
  {
   return("ATR");
  }

#endif // ATRTRAIL_MQH
//+------------------------------------------------------------------+

EnsureHandle() creates the iATR() handle once per symbol and caches it, releasing and recreating it only if the symbol changes. Calling iATR() on every tick would leak handles.

ComputeStopLevel() reads buffer index 1 rather than 0. Index 0 is the still-forming current bar whose ATR value shifts on every tick. Index 1 — the most recently completed bar — is stable until a new bar opens, preventing the trailing distance from jumping intrabar.

The multiplier is the key design parameter. A multiplier of 2.0 on a 14-period ATR is a common starting point. Lower multipliers tighten the trail and increase the chance of being stopped out by noise; higher multipliers give the trade more room but lock in less profit.


Section 4: CParabolicSarTrail — Acceleration-Based Trailing

The Parabolic SAR advances slowly at first and then accelerates as new price extremes are set during the trade's life. The acceleration factor starts at the configured step value and increases by step each time a new extreme is recorded, up to the configured maximum. The net effect is a stop that gives the trade initial room and then becomes progressively more aggressive as the position matures.

//+------------------------------------------------------------------+
//|                                           ParabolicSarTrail.mqh |
//+------------------------------------------------------------------+
#ifndef PARABOLICSARTRAIL_MQH
#define PARABOLICSARTRAIL_MQH

#include "ITrailMethod.mqh"
//+------------------------------------------------------------------+
//| CParabolicSarTrail                                               |
//+------------------------------------------------------------------+
class CParabolicSarTrail : public ITrailMethod
  {
private:
   double            m_step;              // acceleration factor step
   double            m_maximum;           // acceleration factor maximum
   ENUM_TIMEFRAMES   m_timeframe;         // chart timeframe for SAR calculation
   int               m_sar_handle;        // indicator handle
   string            m_last_symbol;       // symbol the current handle was created for

public:
                     CParabolicSarTrail(void);
                    ~CParabolicSarTrail(void);

   //--- configuration
   void              Configure(const double step, const double maximum,
                               const ENUM_TIMEFRAMES timeframe = PERIOD_CURRENT);

   //--- internal handles
   bool              EnsureHandle(const string symbol);

   //--- trailing logic
   virtual double    ComputeStopLevel(ulong ticket) override;
   virtual string    MethodName(void) override;
  };
//+------------------------------------------------------------------+
//| Constructor                                                      |
//+------------------------------------------------------------------+
CParabolicSarTrail::CParabolicSarTrail(void) : m_step(0.02),
   m_maximum(0.2),
   m_timeframe(PERIOD_CURRENT),
   m_sar_handle(INVALID_HANDLE),
   m_last_symbol("")
  {
  }
//+------------------------------------------------------------------+
//| Destructor                                                       |
//+------------------------------------------------------------------+
CParabolicSarTrail::~CParabolicSarTrail(void)
  {
   if(m_sar_handle != INVALID_HANDLE)
      ::IndicatorRelease(m_sar_handle);
  }
//+------------------------------------------------------------------+
//| Configure                                                        |
//+------------------------------------------------------------------+
void CParabolicSarTrail::Configure(const double step, const double maximum,
                                   const ENUM_TIMEFRAMES timeframe)
  {
   m_step      = (step > 0.0 ? step : 0.02);
   m_maximum   = (maximum > m_step ? maximum : 0.2);
   m_timeframe = timeframe;

   if(m_sar_handle != INVALID_HANDLE)
     {
      ::IndicatorRelease(m_sar_handle);
      m_sar_handle  = INVALID_HANDLE;
      m_last_symbol = "";
     }
  }
//+------------------------------------------------------------------+
//| EnsureHandle                                                     |
//+------------------------------------------------------------------+
bool CParabolicSarTrail::EnsureHandle(const string symbol)
  {
   if(m_sar_handle != INVALID_HANDLE && m_last_symbol == symbol)
      return(true);

   if(m_sar_handle != INVALID_HANDLE)
     {
      ::IndicatorRelease(m_sar_handle);
      m_sar_handle = INVALID_HANDLE;
     }

   m_sar_handle = ::iSAR(symbol, m_timeframe, m_step, m_maximum);

   if(m_sar_handle == INVALID_HANDLE)
      return(false);

   m_last_symbol = symbol;
   return(true);
  }
//+------------------------------------------------------------------+
//| ComputeStopLevel                                                 |
//+------------------------------------------------------------------+
double CParabolicSarTrail::ComputeStopLevel(ulong ticket)
  {
   if(!::PositionSelectByTicket(ticket))
      return(0.0);

   string symbol        = ::PositionGetString(POSITION_SYMBOL);
   long   position_type = ::PositionGetInteger(POSITION_TYPE);

   if(!EnsureHandle(symbol))
      return(0.0);

   double sar_buffer[];
   ::ArraySetAsSeries(sar_buffer, true);

   if(::CopyBuffer(m_sar_handle, 0, 1, 1, sar_buffer) <= 0)
      return(0.0);

   double sar = sar_buffer[0];

   if(sar <= 0.0)
      return(0.0);

//--- for a long, the SAR should sit below the current price;
//--- if it does not (e.g. during a choppy market), skip this tick
   if(position_type == POSITION_TYPE_BUY)
     {
      double bid = ::SymbolInfoDouble(symbol, SYMBOL_BID);
      if(sar >= bid)
         return(0.0);
     }
   else
     {
      double ask = ::SymbolInfoDouble(symbol, SYMBOL_ASK);
      if(sar <= ask)
         return(0.0);
     }

   return(sar);
  }
//+------------------------------------------------------------------+
//| MethodName                                                       |
//+------------------------------------------------------------------+
string CParabolicSarTrail::MethodName(void)
  {
   return("ParabolicSAR");
  }

#endif // PARABOLICSARTRAIL_MQH
//+------------------------------------------------------------------+

The sanity check — confirming the SAR sits on the correct side of the current price — is important in choppy, non-trending conditions. When a market moves sideways, the SAR can flip to the same side as the position and would otherwise cause an invalid modification attempt. Returning 0.0 in that case skips the tick gracefully.

Because this method reads bar index 1, the SAR stop advances once per completed bar rather than once per tick.


Section 5: CPctProfitTrail — Profit-Locking Trailing

Unlike the other four methods, this one does nothing until the position crosses a minimum profit threshold. Once that threshold is crossed, it places the stop at open_price + (current_profit_pips × lock_percent / 100), locking in a rising fraction of the unrealized pip gain.

//+------------------------------------------------------------------+
//|                                              PctProfitTrail.mqh  |
//+------------------------------------------------------------------+
#ifndef PCTPROFITTRAIL_MQH
#define PCTPROFITTRAIL_MQH

#include "ITrailMethod.mqh"
//+------------------------------------------------------------------+
//| CPctProfitTrail                                                  |
//+------------------------------------------------------------------+
class CPctProfitTrail : public ITrailMethod
  {
private:
   double            m_lock_percent;      // fraction of unrealized pips to lock in (0-100)
   double            m_min_profit_pips;   // minimum profit in pips before the trail activates

public:
                     CPctProfitTrail(void);
                    ~CPctProfitTrail(void);

   //--- configuration
   void              Configure(const double lock_percent, const double min_profit_pips);

   //--- trailing logic
   virtual double    ComputeStopLevel(ulong ticket) override;
   virtual string    MethodName(void) override;
  };
//+------------------------------------------------------------------+
//| Constructor                                                      |
//+------------------------------------------------------------------+
CPctProfitTrail::CPctProfitTrail(void) : m_lock_percent(50.0),
   m_min_profit_pips(10.0)
  {
  }
//+------------------------------------------------------------------+
//| Destructor                                                       |
//+------------------------------------------------------------------+
CPctProfitTrail::~CPctProfitTrail(void)
  {
  }
//+------------------------------------------------------------------+
//| Configure                                                        |
//+------------------------------------------------------------------+
void CPctProfitTrail::Configure(const double lock_percent, const double min_profit_pips)
  {
   m_lock_percent    = (lock_percent > 0.0 && lock_percent <= 100.0 ? lock_percent : 50.0);
   m_min_profit_pips = (min_profit_pips > 0.0 ? min_profit_pips : 10.0);
  }
//+------------------------------------------------------------------+
//| ComputeStopLevel                                                 |
//| Returns 0.0 (skip) until the position's unrealized pip profit    |
//| exceeds m_min_profit_pips. After that, returns                   |
//| open_price + profit_pips * lock_percent / 100 for longs.         |
//+------------------------------------------------------------------+
double CPctProfitTrail::ComputeStopLevel(ulong ticket)
  {
   if(!::PositionSelectByTicket(ticket))
      return(0.0);

   string symbol        = ::PositionGetString(POSITION_SYMBOL);
   long   position_type = ::PositionGetInteger(POSITION_TYPE);
   double open_price    = ::PositionGetDouble(POSITION_PRICE_OPEN);
   double point_size    = ::SymbolInfoDouble(symbol, SYMBOL_POINT);
   int    digits        = (int)::SymbolInfoInteger(symbol, SYMBOL_DIGITS);

   double pip_size = (digits == 3 || digits == 5) ? point_size * 10.0 : point_size;

   if(position_type == POSITION_TYPE_BUY)
     {
      double bid = ::SymbolInfoDouble(symbol, SYMBOL_BID);
      double profit_pips = (bid - open_price) / pip_size;

      if(profit_pips < m_min_profit_pips)
         return(0.0);

      double locked_pips = profit_pips * (m_lock_percent / 100.0);
      return(open_price + locked_pips * pip_size);
     }
   else
     {
      double ask = ::SymbolInfoDouble(symbol, SYMBOL_ASK);
      double profit_pips = (open_price - ask) / pip_size;

      if(profit_pips < m_min_profit_pips)
         return(0.0);

      double locked_pips = profit_pips * (m_lock_percent / 100.0);
      return(open_price - locked_pips * pip_size);
     }
  }
//+------------------------------------------------------------------+
//| MethodName                                                       |
//+------------------------------------------------------------------+
string CPctProfitTrail::MethodName(void)
  {
   return("PctProfit");
  }

#endif // PCTPROFITTRAIL_MQH
//+------------------------------------------------------------------+

With lock_percent = 50 and a current profit of 40 pips, the stop moves to open_price + 20 pips, locking in half the gain. As price moves further in favor, the stop keeps rising. Returning 0.0 when profit is below m_min_profit_pips means the method stays silent until the threshold is crossed. The engine's improvement check ensures the stop cannot move backward even if price temporarily retraces while remaining above the threshold.


Section 6: CSwingTrail — Structure-Based Trailing

All four previous methods derive their stop level from either a fixed distance or an indicator value. The swing trail is different: it scans back across completed price bars and places the stop at the lowest low of that window for longs, or the highest high for shorts. The stop respects what the market has actually done rather than what a formula predicts it will tolerate.

//+------------------------------------------------------------------+
//|                                                SwingTrail.mqh    |
//+------------------------------------------------------------------+
#ifndef SWINGTRAIL_MQH
#define SWINGTRAIL_MQH

#include "ITrailMethod.mqh"
//+------------------------------------------------------------------+
//| CSwingTrail                                                      |
//+------------------------------------------------------------------+
class CSwingTrail : public ITrailMethod
  {
private:
   int               m_lookback_bars;     // number of completed bars to scan
   ENUM_TIMEFRAMES   m_timeframe;         // timeframe for bar data

public:
                     CSwingTrail(void);
                    ~CSwingTrail(void);

   //--- configuration
   void              Configure(const int lookback_bars,
                               const ENUM_TIMEFRAMES timeframe = PERIOD_CURRENT);

   //--- trailing logic
   virtual double    ComputeStopLevel(ulong ticket) override;
   virtual string    MethodName(void) override;
  };
//+------------------------------------------------------------------+
//| Constructor                                                      |
//+------------------------------------------------------------------+
CSwingTrail::CSwingTrail(void) : m_lookback_bars(10),
   m_timeframe(PERIOD_CURRENT)
  {
  }
//+------------------------------------------------------------------+
//| Destructor                                                       |
//+------------------------------------------------------------------+
CSwingTrail::~CSwingTrail(void)
  {
  }
//+------------------------------------------------------------------+
//| Configure                                                        |
//+------------------------------------------------------------------+
void CSwingTrail::Configure(const int lookback_bars, const ENUM_TIMEFRAMES timeframe)
  {
   m_lookback_bars = (lookback_bars >= 2 ? lookback_bars : 10);
   m_timeframe     = timeframe;
  }
//+------------------------------------------------------------------+
//| ComputeStopLevel                                                 |
//+------------------------------------------------------------------+
double CSwingTrail::ComputeStopLevel(ulong ticket)
  {
   if(!::PositionSelectByTicket(ticket))
      return(0.0);

   string symbol        = ::PositionGetString(POSITION_SYMBOL);
   long   position_type = ::PositionGetInteger(POSITION_TYPE);

   if(position_type == POSITION_TYPE_BUY)
     {
      double lows[];
      ::ArraySetAsSeries(lows, true);

      //--- start at bar 1 to skip the still-forming current bar
      if(::CopyLow(symbol, m_timeframe, 1, m_lookback_bars, lows) <= 0)
         return(0.0);

      double swing_low = lows[0];
      for(int i = 1; i < m_lookback_bars; i++)
        {
         if(lows[i] < swing_low)
            swing_low = lows[i];
        }

      return(swing_low);
     }
   else
     {
      double highs[];
      ::ArraySetAsSeries(highs, true);

      if(::CopyHigh(symbol, m_timeframe, 1, m_lookback_bars, highs) <= 0)
         return(0.0);

      double swing_high = highs[0];
      for(int i = 1; i < m_lookback_bars; i++)
        {
         if(highs[i] > swing_high)
            swing_high = highs[i];
        }

      return(swing_high);
     }
  }
//+------------------------------------------------------------------+
//| MethodName                                                       |
//+------------------------------------------------------------------+
string CSwingTrail::MethodName(void)
  {
   return("Swing");
  }

#endif // SWINGTRAIL_MQH
//+------------------------------------------------------------------+

CopyLow() starts at bar index 1 to skip the still-forming current bar, then copies m_lookback_bars values backward. The minimum of that array is the lowest price the market has touched across that window — a natural support level for the stop to sit at.

The swing trail tends to move less frequently than indicator-based methods. In a trending market the rolling minimum may not change for several bars, meaning fewer modification calls and a stop that genuinely reflects where the market has been rather than where a formula says it should be.


Section 7: CTrailingEngine — the Central Engine

CTrailingEngine stores SEntry records in a dynamic array. Each record maps a position ticket to an ITrailMethod pointer. The public interface exposes Register(), Deregister(), IsRegistered(), OnTick(), Configure(), and Count(). Three internal helpers — FindEntry(), IsImprovement(), and GrowIfNeeded() — remain private, called only from within the class itself.

//+------------------------------------------------------------------+
//|                                               TrailingEngine.mqh |
//+------------------------------------------------------------------+
#ifndef TRAILINGENGINE_MQH
#define TRAILINGENGINE_MQH

#include "ITrailMethod.mqh"

//+------------------------------------------------------------------+
//| CTrailingEngine                                                  |
//+------------------------------------------------------------------+
class CTrailingEngine
  {
private:
   //--- internal entry linking a ticket to its trail method
   struct SEntry
     {
      ulong             ticket;
      ITrailMethod     *method;
      bool              active;
     };

   SEntry            m_entries[];         // the position registry
   int               m_count;             // number of active entries
   int               m_capacity;          // allocated capacity of m_entries
   double            m_min_sl_distance;   // minimum pip distance from price to prevent too-tight SL

   //--- internal helpers
   void              GrowIfNeeded(void);
   int               FindEntry(ulong ticket) const;
   bool              IsImprovement(const double new_sl, const double current_sl,
                                   const long position_type) const;

public:
                     CTrailingEngine(void);
                    ~CTrailingEngine(void);

   //--- configuration
   void              Configure(const double min_sl_distance_pips);

   //--- registry management
   bool              Register(ulong ticket, ITrailMethod *method);
   bool              Deregister(ulong ticket);
   bool              IsRegistered(ulong ticket) const;
   int               Count(void) const;

   //--- engine execution
   void              OnTick(void);
  };

SEntry links a ticket to its method pointer and an active flag. m_entries[] is a dynamic array of these structs, sized to m_capacity and compacted whenever an entry is removed. m_min_sl_distance stores an optional global guard that can be set via Configure() to prevent the engine from placing stops too close to price.

Constructor and Destructor

//+------------------------------------------------------------------+
//| Constructor                                                      |
//+------------------------------------------------------------------+
CTrailingEngine::CTrailingEngine(void) : m_count(0),
   m_capacity(32),
   m_min_sl_distance(0.0)
  {
   ::ArrayResize(m_entries, m_capacity);
  }
//+------------------------------------------------------------------+
//| Destructor                                                       |
//+------------------------------------------------------------------+
CTrailingEngine::~CTrailingEngine(void)
  {
   ::ArrayFree(m_entries);
  }

The constructor initializes the registry with space for 32 entries, which covers most realistic trading setups without allocating excessively. The destructor calls ArrayFree() to release the backing array. The engine does not own the ITrailMethod pointers stored inside entries — it never deletes them — so the destructor only frees its own array.

Configure()

//+------------------------------------------------------------------+
//| Configure                                                        |
//+------------------------------------------------------------------+
void CTrailingEngine::Configure(const double min_sl_distance_pips)
  {
   m_min_sl_distance = (min_sl_distance_pips >= 0.0 ? min_sl_distance_pips : 0.0);
  }

Sets a global minimum pip distance the proposed SL must sit away from the current price. Passing 0.0 disables the guard. This is an optional call — if Configure() is never called the engine operates without the minimum distance constraint.

GrowIfNeeded()

//+------------------------------------------------------------------+
//| GrowIfNeeded                                                     |
//| Doubles the registry capacity when the array is full.            |
//+------------------------------------------------------------------+
void CTrailingEngine::GrowIfNeeded(void)
  {
   if(m_count < m_capacity)
      return;

   m_capacity *= 2;
   ::ArrayResize(m_entries, m_capacity);
  }

Called internally before every new entry is added. When the count reaches the current capacity it doubles the array size using ArrayResize(). This means the registry never needs a hard-coded maximum. It grows automatically to accommodate as many positions as are opened — while still avoiding costly reallocations on every insertion.

FindEntry()

//+------------------------------------------------------------------+
//| FindEntry                                                        |
//| Returns the index of the entry with the given ticket, or -1.     |
//+------------------------------------------------------------------+
int CTrailingEngine::FindEntry(ulong ticket) const
  {
   for(int i = 0; i < m_count; i++)
     {
      if(m_entries[i].ticket == ticket)
         return(i);
     }
   return(-1);
  }

A linear search through m_entries[] that returns the index of the entry matching ticket, or -1 if not found. It is private because nothing outside the class needs the raw index — callers use the public IsRegistered() wrapper for presence checks, and internal methods call FindEntry() directly when they need to act on the result.

IsImprovement()

//+------------------------------------------------------------------+
//| IsImprovement                                                    |
//| Returns true when new_sl improves SL (higher for longs;          |
//| lower for shorts), or when no SL is set (current_sl==0.0).       |
//+------------------------------------------------------------------+
bool CTrailingEngine::IsImprovement(const double new_sl, const double current_sl,
                                    const long position_type) const
  {
   if(position_type == POSITION_TYPE_BUY)
      return(new_sl > current_sl);
   else
      return(new_sl < current_sl || current_sl == 0.0);
  }

The strict improvement check. For a long, the new SL must be strictly higher than the current one. For a short, it must be strictly lower. The one exception is when current_sl == 0.0, which means no stop-loss is currently set on the position — in that case any proposed level is treated as an improvement and submitted. Equal values never trigger a modification.

IsRegistered()

//+------------------------------------------------------------------+
//| IsRegistered                                                     |
//| Returns true if the given ticket is currently in the registry.   |
//+------------------------------------------------------------------+
bool CTrailingEngine::IsRegistered(ulong ticket) const
  {
   return(FindEntry(ticket) >= 0);
  }

A thin public wrapper around the private FindEntry(). The EA calls this on every tick to skip positions already in the registry without needing to know about internal indices. It keeps the public interface clean while leaving FindEntry() private.

Register()

//+------------------------------------------------------------------+
//| Register                                                         |
//| Associates a position ticket with an ITrailMethod instance.      |
//| The caller retains ownership of the method pointer.              |
//+------------------------------------------------------------------+
bool CTrailingEngine::Register(ulong ticket, ITrailMethod *method)
  {
   if(method == NULL)
      return(false);

//--- replace existing entry if ticket already registered
   int idx = FindEntry(ticket);
   if(idx >= 0)
     {
      m_entries[idx].method = method;
      m_entries[idx].active = true;
      return(true);
     }

   GrowIfNeeded();

   m_entries[m_count].ticket = ticket;
   m_entries[m_count].method = method;
   m_entries[m_count].active = true;
   m_count++;
   return(true);
  }

Associates a position ticket with an ITrailMethod instance. It first rejects a null pointer outright, then checks whether the ticket already exists. If it does, the method pointer is updated rather than a duplicate entry created. If it does not, GrowIfNeeded() is called before appending the new entry so the array is always large enough to accommodate it. The caller retains ownership of the method pointer — the engine stores the address but never deletes it.

Deregister()

//+------------------------------------------------------------------+
//| Deregister                                                       |
//| Removes a ticket from the registry. Returns true if found.       |
//+------------------------------------------------------------------+
bool CTrailingEngine::Deregister(ulong ticket)
  {
   int idx = FindEntry(ticket);
   if(idx < 0)
      return(false);

//--- compact the array by overwriting with the last entry
   m_count--;
   if(idx < m_count)
      m_entries[idx] = m_entries[m_count];

   return(true);
  }

Removes a ticket from the registry. Rather than shifting the entire array left — which would be an O(n) copy for every removal — it overwrites the removed slot with the last entry in the array and decrements the count. Order in the registry is not meaningful, so this compaction is safe. Returns false if the ticket was not found.

Count()

//+------------------------------------------------------------------+
//| Count                                                            |
//| Returns the number of currently registered positions.            |
//+------------------------------------------------------------------+
int CTrailingEngine::Count(void) const
  {
   return(m_count);
  }

Returns the number of currently registered positions. Used by the EA in OnInit() to log how many positions were registered at startup.

OnTick()

OnTick() is the engine's core. It iterates over the registry and decides whether each position needs an SL modification.

//+------------------------------------------------------------------+
//| OnTick                                                           |
//+------------------------------------------------------------------+
void CTrailingEngine::OnTick(void)
  {
   for(int i = m_count - 1; i >= 0; i--)
     {
      if(!m_entries[i].active || m_entries[i].method == NULL)
         continue;

      ulong ticket = m_entries[i].ticket;

      //--- verify the position still exists; deregister if closed
      if(!::PositionSelectByTicket(ticket))
        {
         Deregister(ticket);
         continue;
        }

      double current_sl    = ::PositionGetDouble(POSITION_SL);
      long   position_type = ::PositionGetInteger(POSITION_TYPE);
      string symbol        = ::PositionGetString(POSITION_SYMBOL);
      int    digits        = (int)::SymbolInfoInteger(symbol, SYMBOL_DIGITS);
      double point_size    = ::SymbolInfoDouble(symbol, SYMBOL_POINT);

      //--- call interface method on pointer
      double proposed_sl = m_entries[i].method.ComputeStopLevel(ticket);

      //--- method returned 0.0 meaning it cannot produce a level yet
      if(proposed_sl <= 0.0)
         continue;

      //--- apply the strict improvement check
      if(!IsImprovement(proposed_sl, current_sl, position_type))
         continue;

      //--- skip if the difference is smaller than one point; the broker
      //--- rounds sub-point differences to zero and returns retcode 10025
      if(::MathAbs(proposed_sl - current_sl) < point_size)
         continue;

      double current_tp = ::PositionGetDouble(POSITION_TP);

      //--- log the modification with old SL, new SL, method name, and pip improvement
      string method_name = m_entries[i].method.MethodName();
      double pip_size    = (digits == 3 || digits == 5) ? point_size * 10.0 : point_size;
      double improvement = ::MathAbs(proposed_sl - current_sl) / pip_size;

      ::PrintFormat("CTrailingEngine: ticket=%llu method=%s SL %.5f -> %.5f (+%.1f pips)",
                    ticket, method_name, current_sl, proposed_sl, improvement);

      //--- submit the modification
      MqlTradeRequest req;
      MqlTradeResult  res;
      ::ZeroMemory(req);
      ::ZeroMemory(res);

      req.action   = TRADE_ACTION_SLTP;
      req.position = ticket;
      req.symbol   = symbol;
      req.sl       = proposed_sl;
      req.tp       = current_tp;

      if(!::OrderSend(req, res))
         ::PrintFormat("CTrailingEngine: PositionModify failed for ticket=%llu retcode=%d",
                       ticket, res.retcode);
     }
  }

The loop runs in reverse order — from m_count - 1 down to 0. This matters because Deregister() compacts the array by overwriting a removed entry with the last one. If the loop ran forward, a compaction mid-loop would cause the entry that was just moved into the removed slot to be skipped on the next iteration. Reverse order avoids this entirely.

For each entry, the method steps through five sequential decisions:

Step 1 — Existence check:PositionSelectByTicket() confirms the position is still open. If it is not — because a stop was hit or the position was manually closed — Deregister() is called automatically and the loop continues. The engine self-cleans without the EA needing to track closed positions.

Step 2 — Method computation: m_entries[i].method.ComputeStopLevel(ticket) calls the virtual method on the stored pointer using MQL5's . syntax for pointer dispatch. A return of 0.0 means the method cannot produce a valid level yet — the ATR indicator may still be loading, or the profit threshold has not been reached — and the tick is skipped cleanly.

Step 3 — Improvement check: IsImprovement() verifies the proposed level is strictly better than the current SL. A level equal to or worse than the current SL is discarded here, guaranteeing the stop never moves in the wrong direction regardless of what any method returns.

Step 4 — Sub-point guard: MathAbs(proposed_sl - current_sl) < point_size catches the case where two proposed levels differ by less than one point. The broker normalizes prices to the symbol's point size, so a sub-point difference produces no real change and returns retcode 10025 (no changes). Skipping these calls eliminates those errors entirely.

Step 5 — Submission:OrderSend() with TRADE_ACTION_SLTP submits the modification. The existing take-profit is read from PositionGetDouble(POSITION_TP) and preserved — passing 0.0 for the TP would remove it. Before submission, a PrintFormat() call logs the ticket, method name, old SL, new SL, and pip improvement so every modification is visible in the Experts tab.


Section 8: TrailingEngineEA.mq5 — Integration Demo

The demo EA's only job is to wire the five method instances to the engine and keep the registry current as positions open and close. It places no trades itself.

//+------------------------------------------------------------------+
//|                                          TrailingEngineEA.mq5    |
//| Demo EA: assigns one of five trail methods to each open position |
//| found at startup, then manages all of them simultaneously.       |
//+------------------------------------------------------------------+

#property strict

#include <TrailingEngine/TrailingEngine.mqh>
#include <TrailingEngine/FixedPipTrail.mqh>
#include <TrailingEngine/AtrTrail.mqh>
#include <TrailingEngine/ParabolicSarTrail.mqh>
#include <TrailingEngine/PctProfitTrail.mqh>
#include <TrailingEngine/SwingTrail.mqh>

//--- Input parameters
input int    InpFixedPips          = 25;     // Fixed pip trail: pip distance
input int    InpAtrPeriod          = 14;     // ATR trail: lookback period
input double InpAtrMultiplier      = 2.0;    // ATR trail: ATR multiplier
input double InpSarStep            = 0.02;   // Parabolic SAR: acceleration step
input double InpSarMaximum         = 0.2;    // Parabolic SAR: acceleration maximum
input double InpLockPercent        = 50.0;   // Pct profit trail: percent of profit to lock
input double InpMinProfitPips      = 10.0;   // Pct profit trail: minimum profit before activation
input int    InpSwingBars          = 10;     // Swing trail: number of bars in lookback window
input ulong  InpMagicNumber        = 881122; // magic number used when opening demo positions

//--- The engine and the five method instances
CTrailingEngine   g_engine;
CFixedPipTrail    g_fixed;
CAtrTrail         g_atr;
CParabolicSarTrail g_sar;
CPctProfitTrail   g_pct;
CSwingTrail       g_swing;

Each method has its own dedicated input group. All six objects are declared at module scope so their lifetimes span the entire EA session. The five method instances are not owned by the engine — the engine stores pointers to them, but the EA holds the actual objects. This means their destructors run cleanly when the EA deinitializes.

OnInit()

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit(void)
  {
//--- configure each method from inputs
   g_fixed.Configure(InpFixedPips);
   g_atr.Configure(InpAtrPeriod, InpAtrMultiplier);
   g_sar.Configure(InpSarStep, InpSarMaximum);
   g_pct.Configure(InpLockPercent, InpMinProfitPips);
   g_swing.Configure(InpSwingBars);

//--- assign methods to any already-open positions round-robin
   RegisterOpenPositions();

   ::Print("TrailingEngineEA: initialized with " + (string)g_engine.Count() + " registered positions");

   return(INIT_SUCCEEDED);
  }

Configures every method from the input parameters, then calls RegisterOpenPositions() once to pick up any positions already open when the EA attaches. The Count() log line confirms how many positions were found and registered at startup, which is useful for verifying the EA attached correctly to a chart with existing trades.

OnDeinit()

//+------------------------------------------------------------------+
//| Expert deinitialization function                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
   ::Print("TrailingEngineEA: deinitialized");
  }

Logs the deinitialization. Because the five method instances and the engine are stack-allocated module-level objects, their destructors run automatically when the EA unloads — CAtrTrail and CParabolicSarTrail release their indicator handles in their destructors, and CTrailingEngine frees its registry array. No manual cleanup is required here.

OnTick()

//+------------------------------------------------------------------+
//| Expert tick function                                             |
//+------------------------------------------------------------------+
void OnTick(void)
  {
//--- re-register any new positions that may have opened since last tick
   RegisterOpenPositions();

//--- evaluate all methods and apply improvements
   g_engine.OnTick();
  }

Two calls on every tick. RegisterOpenPositions() runs first so that any position opened since the last tick is registered before the engine evaluates it. g_engine.OnTick() then evaluates all registered positions and submits any warranted modifications. The ordering matters: a position opened on this tick would be missed by the engine if OnTick() ran before registration.

RegisterOpenPositions()

//+------------------------------------------------------------------+
//| RegisterOpenPositions                                            |
//+------------------------------------------------------------------+
void RegisterOpenPositions(void)
  {
   int total = ::PositionsTotal();

   for(int i = 0; i < total; i++)
     {
      ulong ticket = ::PositionGetTicket(i);

      if(ticket == 0)
         continue;

      //--- skip if already registered
      if(g_engine.IsRegistered(ticket))
         continue;

      //--- assign method by position index modulo 5
      ITrailMethod *method = NULL;
      string method_name   = "";

      switch(i % 5)
        {
         case 0:
            method = &g_fixed;
            method_name = "FixedPip";
            break;
         case 1:
            method = &g_atr;
            method_name = "ATR";
            break;
         case 2:
            method = &g_sar;
            method_name = "ParabolicSAR";
            break;
         case 3:
            method = &g_pct;
            method_name = "PctProfit";
            break;
         case 4:
            method = &g_swing;
            method_name = "Swing";
            break;
        }

      if(method != NULL)
        {
         g_engine.Register(ticket, method);
         ::PrintFormat("TrailingEngineEA: registered ticket=%llu method=%s",
                       ticket, method_name);
        }
     }
  }

PositionsTotal() returns the current count of open positions. The function walks every position by index, retrieves its ticket with PositionGetTicket(), and calls IsRegistered() to skip any already in the registry. Only unregistered positions proceed to method assignment.

The method assignment uses i % 5, which cycles through the five method pointers in order as positions are indexed. The first position gets CFixedPipTrail, the second gets CAtrTrail, the third CParabolicSarTrail, the fourth CPctProfitTrail, and the fifth CSwingTrail. With six or more positions the cycle repeats — positions six and one share the same method instance. This is intentional: a single method instance can manage multiple positions simultaneously because ComputeStopLevel() receives the ticket as a parameter and evaluates each position independently.

A PrintFormat() call logs every new registration with its ticket and assigned method name, giving a clear startup record of which position is being managed by which trail.

Trailing method charts

CTrailingEngine managing two positions simultaneously on GBPUSD and ETHUSD. The fixed pip trail advances with each tick on GBPUSD, while on ETHUSD the ATR trail widens during the shaded volatility zone and the percentage profit trail activates once the position reaches its minimum profit threshold.


Section 9: Verification — TestTrailingEngine.mq5

The test script does not require open positions or a live broker connection. It exercises the engine and individual method formulas directly, testing relationships that hold on any account or symbol. Because FindEntry() and IsImprovement() are private members of CTrailingEngine, the script tests their behavior indirectly — through the public interface and through direct arithmetic that mirrors what each private method computes internally.

//+------------------------------------------------------------------+
//|                                        TestTrailingEngine.mq5    |
//|                                                                  |
//| Verification script: tests that each method returns a value      |
//| strictly below current price for a long, tests that the engine   |
//| does not issue a modify call when the new level is worse, and    |
//| tests method registration and deregistration.                    |
//+------------------------------------------------------------------+

#property script_show_inputs

#include <TrailingEngine/TrailingEngine.mqh>
#include <TrailingEngine/FixedPipTrail.mqh>
#include <TrailingEngine/AtrTrail.mqh>
#include <TrailingEngine/ParabolicSarTrail.mqh>
#include <TrailingEngine/PctProfitTrail.mqh>
#include <TrailingEngine/SwingTrail.mqh>

//--- test bookkeeping
int g_tests_run    = 0;
int g_tests_passed = 0;

The two bookkeeping counters accumulate results across every test function so the final summary line reports the total pass count for the entire run.

OnStart()

//+------------------------------------------------------------------+
//| OnStart                                                          |
//+------------------------------------------------------------------+
void OnStart(void)
  {
   ::Print("=== TestTrailingEngine starting ===");

   TestImprovementLogic();
   TestFixedPipTrailFormula();
   TestAtrTrailFormula();
   TestSwingTrailLookback();
   TestEngineRegistration();
   TestEngineDeregistration();
   TestMethodNamesDistinct();

   ::PrintFormat("=== TestTrailingEngine finished: %d/%d passed ===",
                 g_tests_passed, g_tests_run);

   if(g_tests_passed == g_tests_run)
      ::Print("ALL TESTS PASSED");
   else
      ::Print("SOME TESTS FAILED - see log above");
  }

OnStart() calls every test function in order. None of them halt the script on failure, every test runs regardless of what earlier tests produce, so a single failure does not hide subsequent ones. The final PrintFormat() line gives a count, and the ALL TESTS PASSED line makes it easy to scan the Experts tab for a pass or fail result without reading every individual line.

TestImprovementLogic()

//+------------------------------------------------------------------+
//| TestImprovementLogic                                             |
//+------------------------------------------------------------------+
void TestImprovementLogic(void)
  {
   ::Print("--- Improvement logic tests ---");

//--- long: improvement = new SL strictly higher than current
   double new_sl = 1.1040, cur_sl = 1.1030;
   ASSERT(new_sl > cur_sl,
          "long: new SL 1.1040 > current 1.1030 -> improvement");

   new_sl = 1.1020;
   cur_sl = 1.1030;
   ASSERT(!(new_sl > cur_sl),
          "long: new SL 1.1020 < current 1.1030 -> no improvement");

   new_sl = 1.1030;
   cur_sl = 1.1030;
   ASSERT(!(new_sl > cur_sl),
          "long: new SL == current SL -> no improvement (equal)");

//--- short: improvement = new SL strictly lower than current
   new_sl = 1.1060;
   cur_sl = 1.1070;
   ASSERT(new_sl < cur_sl,
          "short: new SL 1.1060 < current 1.1070 -> improvement");

   new_sl = 1.1080;
   cur_sl = 1.1070;
   ASSERT(!(new_sl < cur_sl),
          "short: new SL 1.1080 > current 1.1070 -> no improvement");

//--- short with no existing SL: 0.0 is treated as no stop set
   new_sl = 1.1080;
   cur_sl = 0.0;
   ASSERT(new_sl < cur_sl || cur_sl == 0.0,
          "short: any level better than no SL (0.0)");
  }

IsImprovement() is private, so this function applies the same arithmetic the private method uses — new_sl > cur_sl for longs and new_sl < cur_sl || cur_sl == 0.0 for shorts — directly in the test body. Six cases are covered: long improvement, long no-improvement, long equality, short improvement, short no-improvement, and the edge case where no stop-loss is currently set (0.0). All six must pass for the engine's core gate to be considered correct.

TestFixedPipTrailFormula()

//+------------------------------------------------------------------+
//| TestFixedPipTrailFormula                                         |
//+------------------------------------------------------------------+
void TestFixedPipTrailFormula(void)
  {
   ::Print("--- CFixedPipTrail formula tests ---");

   double bid       = 1.10500;
   double pip_size  = 0.00010; // 5-digit EURUSD pip = 10 points
   int    pip_dist  = 30;

   double expected_sl = bid - pip_dist * pip_size;
   ASSERT(expected_sl < bid,
          "fixed pip SL is strictly below bid for long position");

   ASSERT_DOUBLE_CLOSE(expected_sl, 1.10200, 0.000001,
                       "fixed pip SL value: bid 1.10500, 30 pips -> 1.10200");

//--- confirm that a pip distance of 0 would equal bid (not a valid config)
   double zero_dist_sl = bid - 0 * pip_size;
   ASSERT(zero_dist_sl == bid,
          "zero pip distance produces SL at bid (not a valid configuration)");
  }

ComputeStopLevel() cannot be called without a live position, so this function tests the underlying arithmetic directly. It confirms the formula produces a value strictly below the bid for a long, that the exact numeric result is correct to six decimal places, and that a zero pip distance would produce an SL at the bid itself — documenting why Configure() guards against zero and negative values.

TestAtrTrailFormula()

//+------------------------------------------------------------------+
//| TestAtrTrailFormula                                              |
//+------------------------------------------------------------------+
void TestAtrTrailFormula(void)
  {
   ::Print("--- CAtrTrail formula tests ---");

   double bid        = 1.10500;
   double multiplier = 2.0;
   double atr_large  = 0.00080; // volatile session
   double atr_small  = 0.00010; // quiet session

   double sl_large = bid - atr_large * multiplier;
   double sl_small = bid - atr_small * multiplier;

   ASSERT(sl_large < sl_small,
          "ATR trail: larger ATR produces wider (lower) SL for long");
   ASSERT(sl_large < bid,
          "ATR trail: large ATR SL is strictly below bid");
   ASSERT(sl_small < bid,
          "ATR trail: small ATR SL is still strictly below bid");

   ASSERT_DOUBLE_CLOSE(sl_large, 1.10340, 0.000001,
                       "ATR large trail SL: 1.10500 - 0.00080*2 = 1.10340");
   ASSERT_DOUBLE_CLOSE(sl_small, 1.10480, 0.000001,
                       "ATR small trail SL: 1.10500 - 0.00010*2 = 1.10480");
  }

Tests the volatility relationship: a large ATR (0.00080, representing a volatile session) produces a lower SL than a small ATR (0.00010, representing a quiet session), both using a multiplier of 2.0. Both results are checked to be below the bid, and the exact numeric values are verified. This confirms the bid - atr * multiplier formula behaves correctly at both extremes before the indicator is ever loaded.

TestSwingTrailLookback()

//+------------------------------------------------------------------+
//| TestSwingTrailLookback                                           |
//+------------------------------------------------------------------+
void TestSwingTrailLookback(void)
  {
   ::Print("--- CSwingTrail lookback tests ---");

   double lows[] = {1.0820, 1.0815, 1.0831, 1.0808, 1.0822};
   int    count  = ArraySize(lows);
   double swing_low = lows[0];

   for(int i = 1; i < count; i++)
      if(lows[i] < swing_low)
         swing_low = lows[i];

   ASSERT_DOUBLE_CLOSE(swing_low, 1.0808, 0.000001,
                       "swing lookback: minimum of 5 lows is 1.0808");

   ASSERT(swing_low < 1.10500,
          "swing SL floor (1.0808) is strictly below current bid (1.10500)");

//--- confirm that the first element is not blindly returned
   ASSERT(swing_low != lows[0],
          "swing SL is not the first element (the minimum was found at index 3)");
  }

Replicates the minimum-search loop that CSwingTrail::ComputeStopLevel() runs on the data CopyLow() returns. Using a concrete five-element array it confirms the minimum is 1.0808 (not 1.0820, the first element, which would be the result of a naive implementation that stopped searching after the first value). The third assertion catches the "return the first element" bug explicitly.

TestEngineRegistration()

//+------------------------------------------------------------------+
//| TestEngineRegistration                                           |
//+------------------------------------------------------------------+
void TestEngineRegistration(void)
  {
   ::Print("--- CTrailingEngine registration tests ---");

   CTrailingEngine engine;
   CFixedPipTrail  method;
   method.Configure(20);

   ASSERT(engine.Count() == 0, "engine count is 0 before any registration");

   bool reg1 = engine.Register(10001, &method);
   ASSERT(reg1, "Register() returns true for a valid ticket and method");
   ASSERT(engine.Count() == 1, "engine count is 1 after first registration");
   ASSERT(engine.IsRegistered(10001), "IsRegistered() returns true for ticket 10001");

   bool reg2 = engine.Register(10002, &method);
   ASSERT(reg2, "Register() returns true for a second ticket");
   ASSERT(engine.Count() == 2, "engine count is 2 after second registration");

//--- re-registering an existing ticket should not increase count
   bool reg3 = engine.Register(10001, &method);
   ASSERT(reg3, "Register() returns true when re-registering existing ticket");
   ASSERT(engine.Count() == 2, "engine count stays at 2 after re-registering ticket 10001");

//--- Register() must return false for a null method pointer
   bool reg_null = engine.Register(10003, NULL);
   ASSERT(!reg_null, "Register() returns false for NULL method pointer");
   ASSERT(engine.Count() == 2, "engine count stays at 2 after null-method registration attempt");
  }

Exercises the full registration lifecycle using Count() and IsRegistered() — both public — rather than the private FindEntry(). It checks that the count starts at zero, increments correctly with each new registration, stays flat when an existing ticket is re-registered, and stays flat when a null pointer is rejected. IsRegistered() confirms that a successfully registered ticket is findable via the public interface.

TestEngineDeregistration()

//+------------------------------------------------------------------+
//| TestEngineDeregistration                                         |
//+------------------------------------------------------------------+
void TestEngineDeregistration(void)
  {
   ::Print("--- CTrailingEngine deregistration tests ---");

   CTrailingEngine engine;
   CFixedPipTrail  method;
   method.Configure(20);

   engine.Register(20001, &method);
   engine.Register(20002, &method);
   engine.Register(20003, &method);

   ASSERT(engine.Count() == 3, "engine count is 3 after registering 3 tickets");

   bool dereg1 = engine.Deregister(20002);
   ASSERT(dereg1, "Deregister() returns true for existing ticket 20002");
   ASSERT(engine.Count() == 2, "engine count is 2 after deregistering ticket 20002");
   ASSERT(!engine.IsRegistered(20002), "IsRegistered() returns false for deregistered ticket 20002");

//--- deregistering a non-existent ticket should return false
   bool dereg_missing = engine.Deregister(99999);
   ASSERT(!dereg_missing, "Deregister() returns false for non-existent ticket 99999");
   ASSERT(engine.Count() == 2, "engine count unchanged after failed deregistration");
  }

Registers three tickets then removes the middle one, verifying that Deregister() returns true, the count drops by one, and IsRegistered() returns false for the removed ticket. Attempting to deregister a ticket that never existed confirms Deregister() returns false and leaves the count unchanged. This indirectly verifies the array compaction logic — removing the middle entry must not corrupt the entries that remain.

TestMethodNamesDistinct()

//+------------------------------------------------------------------+
//| TestMethodNamesDistinct                                          |
//+------------------------------------------------------------------+
void TestMethodNamesDistinct(void)
  {
   ::Print("--- Method name distinctness tests ---");

   CFixedPipTrail     m1;
   CAtrTrail          m2;
   CParabolicSarTrail m3;
   CPctProfitTrail    m4;
   CSwingTrail        m5;

   string names[5];
   names[0] = m1.MethodName();
   names[1] = m2.MethodName();
   names[2] = m3.MethodName();
   names[3] = m4.MethodName();
   names[4] = m5.MethodName();

   bool all_distinct = true;
   for(int i = 0; i < 5; i++)
      for(int j = i + 1; j < 5; j++)
         if(names[i] == names[j])
            all_distinct = false;

   ASSERT(all_distinct, "all five method names are distinct");

//--- also confirm none is empty
   bool none_empty = true;
   for(int i = 0; i < 5; i++)
      if(names[i] == "")
         none_empty = false;

   ASSERT(none_empty, "no method returns an empty name string");
  }

Instantiates all five method classes without configuring them and calls MethodName() on each. A nested loop compares every pair — ten comparisons in total for five items — and flags any collision. A second loop confirms none of the names is an empty string. Both checks matter in practice: duplicate or empty names in the modification log make it impossible to distinguish which method moved which stop.


Section 10: Extending the Engine

Adding a sixth method: The interface pattern means adding a moving average trail, a Donchian channel trail, or any other approach requires only writing a new class that implements ITrailMethod. The engine, the EA, and all existing method files remain untouched.

A minimum activation distance before the trail begins: Currently the engine passes any positive proposed SL through the improvement check immediately. A useful guard is a minimum activation distance in pips: the trail only starts moving the stop once the position has moved at least N pips in the favorable direction from the open price. This can be implemented inside each method's ComputeStopLevel() as an early return of 0.0.

Taking the more conservative of two methods: For a position that should benefit from both ATR-based breathing room and a hard structural floor, two methods can be evaluated and the more conservative of their outputs used as the proposed SL. For longs, "more conservative" means the lower of the two proposed levels. A wrapper class that holds pointers to two ITrailMethod instances and returns the appropriate minimum from its own ComputeStopLevel() implements this without any changes to the engine.

Persisting method assignments across restarts: The engine's registry is in memory and lost on EA restart. Using GlobalVariableSet() to store a ticket → method_index mapping in OnDeinit() and restoring it in OnInit() with GlobalVariableGet() would let the EA resume managing positions that were open before the restart with the correct method already assigned.


Section 11: Limitations

Every-tick evaluation on high-frequency symbols: The engine calls ComputeStopLevel() for every registered position on every tick. On symbols with very high tick rates — some CFDs and crypto pairs — this can generate measurable CPU load, particularly when the SAR or ATR methods trigger CopyBuffer() calls. The engine can be guarded by a millisecond timestamp check inside OnTick() to throttle evaluation to once per 250ms if this becomes a concern.

The swing trail does not react to intra-bar extremes: CSwingTrail copies bar data starting from index 1, which always skips the still-forming current bar. A new intra-bar high/advance that would raise the stop is not reflected until that bar closes.

Parabolic SAR updates once per completed bar: CParabolicSarTrail also reads bar index 1. The SAR value does not change between bar opens. Strategies that need finer-grained stop advancement will need one of the tick-based methods instead.

The percentage profit trail is in pip terms, not account currency: CPctProfitTrail measures profit as a pip count. On symbols with varying pip values — indices, oil, metals — the monetary value of a given pip count changes with position size and contract specification. Traders who need currency-denominated profit locking should derive the stop level from PositionGetDouble(POSITION_PROFIT) instead.

The sub-point guard prevents zero-movement modifications but not all redundant calls: The MathAbs(proposed_sl - current_sl) < point_size check stops requests where the broker would normalize the new SL to the same value it already holds. However, modifications that move the SL by exactly one point are still submitted even if the broker rounds them to the same value on certain instruments. On symbols where the broker enforces a minimum SL movement larger than one point, increasing the guard threshold to match that minimum will eliminate any remaining 10025 errors.


Conclusion

CTrailingEngine removes the assumption that every open position deserves the same trailing behavior. Each registered position carries a pointer to its own ITrailMethod instance; the engine calls ComputeStopLevel() on each one on every tick and submits a modification only when the result is strictly better than the current stop and differs by at least one point. The SL never moves backward and no redundant broker requests are generated.

What the engine does not provide is automatic discovery of which method is best for a given position — that decision belongs to the strategy. It also does not handle partial position sizes, position splitting, or account-currency-denominated profit targets. Each of those is a meaningful extension that slots into the same interface without altering the core.


Programs used in the article:

# Name Type Description
1 ITrailMethod.mqh Include File Abstract interface declaring ComputeStopLevel() and MethodName().
2 FixedPipTrail.mqh Include File CFixedPipTrail — trails at a fixed pip distance from the current price.
3 AtrTrail.mqh Include File CAtrTrail — trails at ATR × multiplier from the current price.
4 ParabolicSarTrail.mqh Include File CParabolicSarTrail — uses the built-in Parabolic SAR value as the stop floor.
5 PctProfitTrail.mqh Include File CPctProfitTrail — locks in a configurable fraction of unrealized pip profit.
6 SwingTrail.mqh Include File CSwingTrail — places the stop at the swing low/high of a bar lookback window.
7 TrailingEngine.mqh Include File CTrailingEngine — the central registry and tick evaluation engine.
8 TrailingEngineEA.mq5 Demo EA Demo EA registering open positions with one method each and logging all modifications.
9 TestTrailingEngine.mq5 Script Verification script with 35 assertions across seven test functions.
10 TrailingEngine.zip Zip Archive Zip archive containing all the attached files and their paths relative to the terminal's root folder.
Attached files |
ITrailMethod.mqh (0.79 KB)
FixedPipTrail.mqh (3.42 KB)
AtrTrail.mqh (5.18 KB)
PctProfitTrail.mqh (4.19 KB)
SwingTrail.mqh (3.75 KB)
TrailingEngine.mqh (9.38 KB)
TrailingEngine.zip (14.83 KB)
Neural Networks in Trading: Adaptive Periodic Segmentation (LightGTS) Neural Networks in Trading: Adaptive Periodic Segmentation (LightGTS)
We invite you to learn about the innovative technique of adaptive patching — a method for flexibly segmenting time series while taking their internal periodicity into account. We will also look at an efficient encoding technique that preserves important semantic characteristics when working with data at different scales. These methods open up new possibilities for the accurate processing of complex, multiscale data characteristic of financial markets and significantly improve the stability and reliability of forecasts.
Building Volatility Models in MQL5 (Part V): Implementing EGARCH as an Alternate Asymmetric Volatility Process Building Volatility Models in MQL5 (Part V): Implementing EGARCH as an Alternate Asymmetric Volatility Process
EGARCH models log-variance, avoiding the non-negativity constraints that can distort GARCH estimates and enabling a clear treatment of leverage asymmetry. The article provides a complete MQL5 implementation with logarithmic backcasting, simulation-based multi-step forecasting, and diagnostics including the Engle–Ng Sign Bias, Leverage Correlation, and Volatility Runs tests. Practical outputs include EGARCH Volatility, an Innovation Z-Score, and an Asymmetric Volatility Regime Oscillator to support regime analysis and strategy design.
Trends and Traditions: Using Rademacher Functions in Trading Trends and Traditions: Using Rademacher Functions in Trading
Although the functions we will discuss have been known for quite some time, their application in the field of trading remains terra incognita to this day. In this article, we will explore some of the opportunities these old-but-new functions offer for developing trading strategies and assess their potential.
Elite Crystal Evolution Algorithm (CEO-inspired): Practical Implementation Elite Crystal Evolution Algorithm (CEO-inspired): Practical Implementation
Experimental evaluation on standard benchmark functions reveals the advantages and limitations of directly adapting combinatorial algorithms. The article provides a detailed description of the ECEA algorithm's mechanisms and test results.