Трейлинг стоп

Одна из самых распространенных задач, в которых используется возможность менять защитные ценовые уровни, заключается в последовательном сдвиге Stop Loss на более выгодную цену по мере сохранения благоприятного тренда. Это "трейлинг стоп". Реализуем его с помощью новых структур MqlTradeRequestSync и MqlTradeResultSync из предыдущих разделов.

Чтобы иметь возможность подключать механизм в любой эксперт, объявим его как класс TrailingStop (см. файл TrailingStop.mqh). В личных переменных класса будем хранить номер контролируемой позиции, её символ и размер пункта цены, а также требуемую дистанцию уровня стоплосса от текущей цены и шаг изменений уровня.

#include <MQL5Book/MqlTradeSync.mqh>
   
class TrailingStop
{
   const ulong ticket;  // тикет контролируемой позиции
   const string symbol// символ позиции
   const double point;  // размер пункта цены символа
   const uint distance// расстояние до стопа в пунктах
   const uint step;     // шаг движений (чувствительность) в пунктах
   ...

Дистанция нужна только для алгоритма стандартного сопровождения позиции, который предоставит базовый класс. Производные классы смогут двигать защитный уровень по другим принципам, таким как скользящая средняя, каналы, индикатор SAR и другие. После знакомства с базовым классом мы приведем пример производного класса со скользящей средней.

Под текущий уровень стоп-цены выделим переменную level. В переменной ok будем поддерживать актуальный статус позиции: true — позиция еще существует, false — возникла ошибка, позиция закрылась.

protected:
   double level;
   bool ok;
   virtual double detectLevel() 
   {
      return DBL_MAX;  
   }

Виртуальный метод detectLevel предназначен для переопределения в классах-наследниках, где стоп-цена должна вычисляться по произвольному алгоритму. В данной реализации возвращается специальное значение DBL_MAX, сигнализирующее работу по стандартному алгоритму (см. ниже).

В конструкторе заполним все поля значениями соответствующих параметров. Функция PositionSelectByTicket проверяет наличие позиции с заданным тикетом и выделяет её в программном окружении, так что последующий вызов PositionGetString возвращает её строковое свойство с именем символа.

public:
   TrailingStop(const ulong tconst uint dconst uint s = 1) :
      ticket(t), distance(d), step(s),
      symbol(PositionSelectByTicket(t) ? PositionGetString(POSITION_SYMBOL) : NULL),
      point(SymbolInfoDouble(symbolSYMBOL_POINT))
   {
      if(symbol == NULL)
      {
         Print("Position not found: " + (string)t);
         ok = false;
      }
      else
      {
         ok = true;
      }
   }
   
   bool isOK() const
   {
      return ok;
   }

Теперь рассмотрим главный публичный метод класса trail: MQL-программа должна будет вызывать его на каждом тике или по таймеру, чтобы сопровождать позицию. Метод возвращает true, пока позиция существует.

   virtual bool trail()
   {
      if(!PositionSelectByTicket(ticket))
      {
         ok = false;
         return false// позиция закрылась
      }
   
      // выясним цены для расчетов: текущую котировку и стоп-уровень
      const double current = PositionGetDouble(POSITION_PRICE_CURRENT);
      const double sl = PositionGetDouble(POSITION_SL);
      ...

Здесь и далее используются функции чтения свойств позиции. Они будут подробно рассмотрены в отдельном разделе. В частности, нам требуется выяснить направление торговли — покупка и продажа, чтобы знать, в какую сторону следует откладывать стоп-уровень.

      // POSITION_TYPE_BUY  = 0 (false)
      // POSITION_TYPE_SELL = 1 (true)
      const bool sell = (bool)PositionGetInteger(POSITION_TYPE);
      TU::TradeDirection dir(sell);
      ...

Для расчетов и проверок далее используется вспомогательный класс TU::TradeDirection и его объект dir. Например, его метод negative позволяет вычислить цену, расположенную на заданном расстоянии от текущей цены в убыточном направлении, вне зависимости от типа операции. Это упрощает код, поскольку в противном случае пришлось бы делать "зеркальные" вычисления для покупок и продаж.

      level = detectLevel();
      // не можем тралить без уровня: удаление стоп-уровня должен делать вызывающий код
      if(level == 0return true;
      // если есть значение по умолчанию, делаем стандартный отступ от текущей цены
      if(level == DBL_MAXlevel = dir.negative(currentpoint * distance);
      level = TU::NormalizePrice(levelsymbol);
      
      if(!dir.better(currentlevel))
      {
         return true// нельзя ставить стоп-уровень с прибыльной стороны
      }
      ...

Другой метод класса TU::TradeDirectionbetter — проверяет, что полученный стоп-уровень расположен с правильной стороны от цены. Если бы не этот метод, нам опять потребовалось бы написать проверку дважды (для покупок и продаж).

Некорректное значение стоп-уровня у нас может появиться, поскольку метод detectLevel может быть переопределен в производных классах. При стандартном расчете эта проблема исключена, потому что уровень считает сам объект dir.

Наконец, когда уровень посчитан, необходимо применить его к позиции. Если у позиции еще нет стоп-лосса, подойдет любой корректный уровень. Если же стоп-лосс уже установлен, то новое значение должно быть лучше прежнего и отличается больше, чем на заданный шаг.

      if(sl == 0)
      {
         PrintFormat("Initial SL: %f"level);
         move(level);
      }
      else
      {
         if(dir.better(levelsl) && fabs(level - sl) >= point * step)
         {
            PrintFormat("SL: %f -> %f"sllevel);
            move(level);
         }
      }
      
      return true// успех
   }

Непосредственно отправку запроса на модификацию позиции мы обернули в метод move, в котором используется уже знакомый метод adjust структуры MqlTradeRequestSync (см. раздел Модификация уровней Stop Loss и/или Take Profit).

   bool move(const double sl)
   {
      MqlTradeRequestSync request;
      request.position = ticket;
      if(request.adjust(sl0) && request.completed())
      {
         Print("OK Trailing: "TU::StringOf(sl));
         return true;
      }
      return false;
   }
};

Теперь все готово для подключения трейлинга в тестовый эксперт TrailingStop.mq5. Во входных параметрах можно указать направление торговли, расстояние до стоп-уровня в пунктах и шаг в пунктах. Параметр TrailingDistance равен по умолчанию 0, что означает автоматическое вычисление дневного размаха котировок и использование его половины в качестве дистанции.

#include <MQL5Book/MqlTradeSync.mqh>
#include <MQL5Book/TrailingStop.mqh>
   
enum ENUM_ORDER_TYPE_MARKET
{
   MARKET_BUY = ORDER_TYPE_BUY,   // ORDER_TYPE_BUY
   MARKET_SELL = ORDER_TYPE_SELL  // ORDER_TYPE_SELL
};
   
input int TrailingDistance = 0;   // Distance to Stop Loss in points (0 = autodetect)
input int TrailingStep = 10;      // Trailing Step in points
input ENUM_ORDER_TYPE_MARKET Type;
input string Comment;
input ulong Deviation;
input ulong Magic = 1234567890;

При запуске эксперт выяснит, есть ли позиция на текущем символе с указанным Magic-номером, и создаст её в случае отсутствия.

Трейлинг будет осуществляться объектом класса TrailingStop, обернутым в умный указатель AutoPtr. Последний избавит нас от необходимости вручную удалять старый объект, когда ему на смену потребуется новый объект сопровождения для новой создаваемой позиции. При присваивании нового объекта умному указателю старый объект удаляется автоматически. Напомним, что разыменование умного указателя, то есть получение доступа к хранящемуся внутри рабочему объекту, производится с помощью перегруженного оператора [].

#include <MQL5Book/AutoPtr.mqh>
   
AutoPtr<TrailingStoptr;

В обработчике OnTick проверим, есть ли объект, и если да, то существует ли позиция (признак возвращается из метода trail). Сразу после запуска программы объекта не будет, и указатель равен NULL. В этом случае следует либо создать новую позицию, либо найти уже открытую и создать для неё объект TrailingStop. Этим занимается функция Setup. При последующих вызовах OnTick объект начинает и продолжает сопровождение, не давая программе зайти внутрь блока if, пока позиция "жива".

void OnTick()
{
   if(tr[] == NULL || !tr[].trail())
   {
      // если трейлинга пока нет, создадим или найдем подходящую позицию
      Setup();
   }
}

А вот и функция Setup.

void Setup()
{
   int distance = 0;
   const double point = SymbolInfoDouble(_SymbolSYMBOL_POINT);
   
   if(TrailingDistance == 0// автоопределяем дневной размах цен
   {
      distance = (int)((iHigh(_SymbolPERIOD_D11) - iLow(_SymbolPERIOD_D11))
         / point / 2);
      Print("Autodetected daily distance (points): "distance);
   }
   else
   {
      distance = TrailingDistance;
   }
   
   // обрабатываем только позицию текущего символа и нашего Magic
   if(GetMyPosition(_SymbolMagic))
   {
      const ulong ticket = PositionGetInteger(POSITION_TICKET);
      Print("The next position found: "ticket);
      tr = new TrailingStop(ticketdistanceTrailingStep);
   }
   else // нет нашей позиции
   {
      Print("No positions found, lets open it...");
      const ulong ticket = OpenPosition();
      if(ticket)
      {
         tr = new TrailingStop(ticketdistanceTrailingStep);
      }
   }
   
   if(tr[] != NULL)
   {
      // 1-й раз выполняем трейлинг сразу после создания или обнаружения позиции
      tr[].trail();
   }
}

Поиск подходящей открытой позиции делегирован функции GetMyPosition, а открытие новой позиции — функции OpenPosition. Обе представлены ниже. В любом случае мы получаем тикет позиции, и для неё создаем объект трейлинга.

bool GetMyPosition(const string sconst ulong m)
{
   for(int i = 0i < PositionsTotal(); ++i)
   {
      if(PositionGetSymbol(i) == s && PositionGetInteger(POSITION_MAGIC) == m)
      {
         return true;
      }
   }
   return false;
}

Из названий встроенных функций легко предположить их назначение и общий смысл алгоритма. В цикле по всем открытым позициям (PositionsTotal) мы последовательно выбираем каждую из них с помощью PositionGetSymbol и получаем её символ. Если символ совпал с запрошенным, читаем и сравниваем свойство позиции POSITION_MAGIC с переданным "магиком". Все функции для работы с позициями будут рассмотрены в отдельном разделе.

Функция вернет true, как только найдется первая подходящая позиция. При этом позиция останется выбранной в торговом окружении терминала, что дает возможность в остальном коде читать её другие свойства при необходимости.

Алгоритм открытия позиции нам уже знаком.

ulong OpenPosition()
{
   MqlTradeRequestSync request;
   
   // значения по умолчанию
   const bool wantToBuy = Type == MARKET_BUY;
   const double volume = SymbolInfoDouble(_SymbolSYMBOL_VOLUME_MIN);
   // опциональные поля заполняем напрямую в структуре
   request.magic = Magic;
   request.deviation = Deviation;
   request.comment = Comment;
   ResetLastError();
   // выполняем выбранную торговую операцию и ждем её подтверждения
   if((bool)(wantToBuy ? request.buy(volume) : request.sell(volume))
      && request.completed())
   {
      Print("OK Order/Deal/Position");
   }
   
   return request.position// ненулевое значение - признак успеха
}

Для наглядности посмотрим, как эта программа работает в тестере, в визуальном режиме.

После компиляции откроем панель тестера стратегий в терминале, на закладке Обзор выберем первый вариант: Одиночный тест.

На закладке Настройки выберем:

  • в выпадающем списке Советник: MQL5Book\p6\TralingStop
  • Символ: EURUSD
  • Таймфрейм: H1
  • Интервал: последний год, месяц или произвольный
  • Форвард: нет
  • Задержки: отключены
  • Моделирование: на основе реальных или генерируемых тиков
  • Оптимизация: отключена
  • опция Визуальный режим: включена

Нажав кнопку Старт, увидим в отдельном окне тестера примерно такую картину:

Стандартный трейлинг-стоп в тестере

Стандартный трейлинг-стоп в тестере

В журнале появятся записи следующего вида:

2022.01.10 00:02:00   Autodetected daily distance (points): 373

2022.01.10 00:02:00   No positions found, lets open it...

2022.01.10 00:02:00   instant buy 0.01 EURUSD at 1.13612 (1.13550 / 1.13612 / 1.13550)

2022.01.10 00:02:00   deal #2 buy 0.01 EURUSD at 1.13612 done (based on order #2)

2022.01.10 00:02:00   deal performed [#2 buy 0.01 EURUSD at 1.13612]

2022.01.10 00:02:00   order performed buy 0.01 at 1.13612 [#2 buy 0.01 EURUSD at 1.13612]

2022.01.10 00:02:00   Waiting for position for deal D=2

2022.01.10 00:02:00   OK Order/Deal/Position

2022.01.10 00:02:00   Initial SL: 1.131770

2022.01.10 00:02:00   position modified [#2 buy 0.01 EURUSD 1.13612 sl: 1.13177]

2022.01.10 00:02:00   OK Trailing: 1.13177

2022.01.10 00:06:13   SL: 1.131770 -> 1.131880

2022.01.10 00:06:13   position modified [#2 buy 0.01 EURUSD 1.13612 sl: 1.13188]

2022.01.10 00:06:13   OK Trailing: 1.13188

2022.01.10 00:09:17   SL: 1.131880 -> 1.131990

2022.01.10 00:09:17   position modified [#2 buy 0.01 EURUSD 1.13612 sl: 1.13199]

2022.01.10 00:09:17   OK Trailing: 1.13199

2022.01.10 00:09:26   SL: 1.131990 -> 1.132110

2022.01.10 00:09:26   position modified [#2 buy 0.01 EURUSD 1.13612 sl: 1.13211]

2022.01.10 00:09:26   OK Trailing: 1.13211

2022.01.10 00:09:35   SL: 1.132110 -> 1.132240

2022.01.10 00:09:35   position modified [#2 buy 0.01 EURUSD 1.13612 sl: 1.13224]

2022.01.10 00:09:35   OK Trailing: 1.13224

2022.01.10 10:06:38   stop loss triggered #2 buy 0.01 EURUSD 1.13612 sl: 1.13224 [#3 sell 0.01 EURUSD at 1.13224]

2022.01.10 10:06:38   deal #3 sell 0.01 EURUSD at 1.13221 done (based on order #3)

2022.01.10 10:06:38   deal performed [#3 sell 0.01 EURUSD at 1.13221]

2022.01.10 10:06:38   order performed sell 0.01 at 1.13221 [#3 sell 0.01 EURUSD at 1.13224]

2022.01.10 10:06:38   Autodetected daily distance (points): 373

2022.01.10 10:06:38   No positions found, lets open it...

Обратите внимание, как алгоритм смещает уровень SL вверх при благоприятном движении цены, вплоть до момента, когда позиция закрывается по стоплоссу. Сразу после ликвидации позиции программа открывает новую.

Чтобы проверить возможность применения нестандартных механизмов сопровождения, реализуем пример алгоритма на скользящей средней. Для этого вернемся в файл TrailingStop.mqh и опишем производный класс TrailingStopByMA.

class TrailingStopByMApublic TrailingStop
{
   int handle;
   
public:
   TrailingStopByMA(const ulong tconst int period,
      const int offset = 1,
      const ENUM_MA_METHOD method = MODE_SMA,
      const ENUM_APPLIED_PRICE type = PRICE_CLOSE): TrailingStop(t01)
   {
      handle = iMA(_SymbolPERIOD_CURRENTperiodoffsetmethodtype);
   }
   
   virtual double detectLevel() override
   {
      double array[1];
      ResetLastError();
      if(CopyBuffer(handle001array) != 1)
      {
         Print("CopyBuffer error: "_LastError);
         return 0;
      }
      return array[0];
   }
};

Его особенностью является создание в конструкторе экземпляра индикатора iMA: период, метод усреднения, тип цены передаются через параметры.

В переопределенном методе detectLevel мы считываем значение из буфера индикатора, причем по умолчанию это делается со смещением 1 бар, то есть бар — закрытый, и его показания не меняются по приходу тиков. Желающие могут брать значение с нулевого бара, но такие сигналы нестабильны для всех типов цен, кроме PRICE_OPEN.

Для использования нового класса в том же тестовом эксперте TrailingStop.mq5 добавим еще один входной параметр MATrailingPeriod с периодом скользящей (прочие параметры индикатора оставим без изменений).

input int MATrailingPeriod = 0;   // Period for Trailing by MA (0 = disabled)

Значение 0 в данном параметре отключает трейлинг по скользящей средней. Если он включен, настройки дистанции в параметре TrailingDistance игнорируются.

В зависимости от его установки будем создавать либо стандартный объект трейлинга TrailingStop, либо производный с iMATrailingStopByMA.

      ...
      tr = MATrailingPeriod > 0 ?
         new TrailingStopByMA(ticketMATrailingPeriod) :
         new TrailingStop(ticketdistanceTrailingStep);
      ...

Посмотрим, как обновленная программа ведет себя в тестере. В настройках эксперта поставьте ненулевой период для MA, например, 10.

Трейлинг-стоп по скользящей средней в тестере

Трейлинг-стоп по скользящей средней в тестере

Обратите внимание, что в те моменты, когда средняя подходит близко к цене, возникает эффект частого срабатывания стоплосса и закрытия позиции. Когда средняя располагается над котировками, защитный уровень вообще не ставится, потому что для покупки это некорректно. Это следствие того, что наш эксперт не имеет никакой стратегии и всегда открывает позиции одного типа, независимо от обстановки на рынке. Для продаж будет эпизодически возникать такая же парадоксальная ситуация, когда средняя идет под ценой — значит рынок растет, а робот "упрямо" встает в короткую позицию.  

В рабочих стратегиях, как правило, направление позиции выбирается с учетом движения рынка, и скользящая средняя находится с правильной стороны относительно текущей цены — там, где разрешено ставить стоплосс.