Как в MetaTrader 5 быстро разработать и отладить торговую стратегию

MetaQuotes | 25 августа, 2016

"Верить нельзя никому, мне – можно"  (с) Отладчик

Скальперские автоматические системы по праву считаются вершиной алгоритмического трейдинга, но при этом они же являются и самыми сложными для написания кода. В этой статье мы покажем, как с помощью встроенных средств отладки и визуального тестирования строить стратегии, основанные на анализе поступающих тиков. Для выработки правил входа и выхода зачастую требуются годы ручной торговли. Но с помощью MetaTrader 5 вы можете быстро проверить любую подобную стратегию на реальной истории.



Торговая идея на тиках

Прежде всего, нам необходимо создать индикатор, который будет строить тиковые графики — то есть графики,  на которых можно увидеть каждое изменение цены. Один из первых таких индикаторов вы можете найти в Библиотеке — https://www.mql5.com/ru/code/89. В отличие от обычных, на тиковых графиках при поступлении нового тика необходимо весь график смещать назад.


За основу проверяемой идеи возьмем ряд изменений цены между двумя последовательными тиками, это будет примерно такая последовательность в пунктах:

+1, 0, +2, -1, 0, +1, -2, -1, +1, -5, -1, +1, 0, -1, +1, 0, +2, -1, +1, +6, -1, +1,...

Закон нормального распределения гласит, что 99 % изменений цены между двумя тиками укладывается в пределах 3-х сигм.  Мы попробуем в режиме реального времени вычислять на каждом тике среднеквадратичное отклонение и помечать резкие скачки цены значками красного и синего цвета. Таким образом мы попытаемся визуально выбрать стратегию для использования таких резких выбросов — торговать в направлении изменения или же использовать "возврат к среднему". Как видите, идея совсем простая, и наверняка по этому пути прошло большинство любителей математики.


Создаем тиковый индикатор

В MetaEditor запускаем Мастер MQL, задаем имя  и два входных параметра:

Далее отмечаем "Индикатор в отдельном окне" и указываем 2 графических построения, которые будут отображать информацию в подокне: линия для тиков и цветные стрелки для сигналов о появлении резких изменений цены.

Внесем в полученную заготовку изменения, которые отмечены желтым

//+------------------------------------------------------------------+
//|                                              TickSpikeHunter.mq5 |
//|                        Copyright 2016, MetaQuotes Software Corp. |
//|                                             https://www.mql5.com |
//+------------------------------------------------------------------+
#property copyright "Copyright 2016, MetaQuotes Software Corp."
#property link      "https://www.mql5.com"
#property version   "1.00"
#property indicator_separate_window
#property indicator_buffers 3
#property indicator_plots   2
//--- plot TickPrice
#property indicator_label1  "TickPrice"
#property indicator_type1   DRAW_LINE
#property indicator_color1  clrGreen
#property indicator_style1  STYLE_SOLID
#property indicator_width1  1
//--- plot Signal
#property indicator_label2  "Signal"
#property indicator_type2   DRAW_COLOR_ARROW
#property indicator_color2  clrRed,clrBlue,C'0,0,0',C'0,0,0',C'0,0,0',C'0,0,0',C'0,0,0',C'0,0,0'
#property indicator_style2  STYLE_SOLID
#property indicator_width2  1
//--- input parameters
input int      ticks=50;         // количество тиков в расчетах
input double   gap=3.0;          // ширина канала в сигмах
//--- indicator buffers
double         TickPriceBuffer[];
double         SignalBuffer[];
double         SignalColors[];
//--- счетчик изменений цены
int ticks_counter;
//--- первый вызов индикатора
bool first;
//+------------------------------------------------------------------+
//| Custom indicator initialization function                         |
//+------------------------------------------------------------------+
int OnInit()
  {
//--- indicator buffers mapping
   SetIndexBuffer(0,TickPriceBuffer,INDICATOR_DATA);
   SetIndexBuffer(1,SignalBuffer,INDICATOR_DATA);
   SetIndexBuffer(2,SignalColors,INDICATOR_COLOR_INDEX);
//--- укажем пустые значения, которые нужно игнорировать при отрисовке  
   PlotIndexSetDouble(0,PLOT_EMPTY_VALUE,0);
   PlotIndexSetDouble(1,PLOT_EMPTY_VALUE,0);
//--- сигналы будем выводить в виде этого значка
   PlotIndexSetInteger(1,PLOT_ARROW,159);
//--- инициализация глобальных переменных
   ticks_counter=0;
   first=true;
//--- успешная инициализация программы
   return(INIT_SUCCEEDED);
  }
//+------------------------------------------------------------------+
//| Custom indicator iteration function                              |
//+------------------------------------------------------------------+
int OnCalculate(const int rates_total,
                const int prev_calculated,
                const datetime &time[],
                const double &open[],
                const double &high[],
                const double &low[],
                const double &close[],
                const long &tick_volume[],
                const long &volume[],
                const int &spread[])
  {
//---
   
//--- return value of prev_calculated for next call
   return(rates_total);
  }
//+------------------------------------------------------------------+

Теперь осталось добавить код в предопределенный обработчик поступающих тиков OnCalculate(). При первом вызове функции явно обнулим значения в индикаторных буферах, а также для удобства установим для них признак таймсерии — таким образом индексация у них будет справа налево. Это позволит обращаться к самому свежему значению индикаторного буфера по индексу ноль, то есть в TickPriceBuffer[0] будет храниться значение последнего  тика.

Кроме того,  основную обработку тиков мы вынесем в отдельную функцию ApplyTick():

//+------------------------------------------------------------------+
//| Custom indicator iteration function                              |
//+------------------------------------------------------------------+
int OnCalculate(const int rates_total,
                const int prev_calculated,
                const datetime &time[],
                const double &open[],
                const double &high[],
                const double &low[],
                const double &close[],
                const long &tick_volume[],
                const long &volume[],
                const int &spread[])
  {
//--- при первом вызове обнулим индикаторные буферы и установим признак серии
   if(first)
     {
      ZeroMemory(TickPriceBuffer);
      ZeroMemory(SignalBuffer);
      ZeroMemory(SignalColors);
      //--- массивы серии идут задом наперед, так удобнее в данном случае
      ArraySetAsSeries(SignalBuffer,true);
      ArraySetAsSeries(TickPriceBuffer,true);
      ArraySetAsSeries(SignalColors,true);
      first=false;
     }
//--- возьмем в качестве цены текущее значение Close
   double lastprice=close[rates_total-1];
//--- считаем тики
   ticks_counter++;
   ApplyTick(lastprice); // проведем вычисления и сдвиг в буферах   
//--- return value of prev_calculated for next call
   return(rates_total);
  }
//+------------------------------------------------------------------+
//| применяет тик для вычислений                                     |
//+------------------------------------------------------------------+
void ApplyTick(double price)
  {
   int size=ArraySize(TickPriceBuffer);
   ArrayCopy(TickPriceBuffer,TickPriceBuffer,1,0,size-1);
   ArrayCopy(SignalBuffer,SignalBuffer,1,0,size-1);
   ArrayCopy(SignalColors,SignalColors,1,0,size-1);
//--- запишем последнее значение цены
   TickPriceBuffer[0]=price;
//---
  }

Функция ApplyTick() пока производит самые простые действия — сдвигает все значения буфера на одну позицию вглубь истории и пишет в TickPriceBuffer[0] последний тик. Запускаем индикатор под отладкой и наблюдаем некоторое время.

Видим, что цена Bid, по которой строится Close текущей свечи, очень часто остается неизменной, и поэтому график рисуется кусками "плато". Немного подправим код, чтобы получать только "пилу" - так глазу более понятно.

//--- вычисляем только если цена изменилась
   if(lastprice!=TickPriceBuffer[0])
     {
      ticks_counter++;      // считаем тики
      ApplyTick(lastprice); // проведем вычисления и сдвиг в буферах
     }

Итак, первую версию индикатора мы создали, теперь у нас не бывает нулевых приращений цены.


Добавляем вспомогательный буфер и расчет среднеквадратичного отклонения

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

#property indicator_separate_window
#property indicator_buffers 4
#property indicator_plots   2
...
//--- indicator buffers
double         TickPriceBuffer[];
double         SignalBuffer[];
double         DeltaTickBuffer[];
double         ColorsBuffers[];
...
//+------------------------------------------------------------------+
//| Custom indicator initialization function                         |
//+------------------------------------------------------------------+
int OnInit()
  {
//--- indicator buffers mapping
   SetIndexBuffer(0,TickPriceBuffer,INDICATOR_DATA);
   SetIndexBuffer(1,SignalBuffer,INDICATOR_DATA);
   SetIndexBuffer(2,SignalColors,INDICATOR_COLOR_INDEX);
   SetIndexBuffer(3,DeltaTickBuffer,INDICATOR_CALCULATIONS);
...
  }
//+------------------------------------------------------------------+
//| Custom indicator iteration function                              |
//+------------------------------------------------------------------+
int OnCalculate(const ...)

//--- при первом вызове обнулим индикаторные буфера и установим признак серии
   if(first)
     {
      ZeroMemory(TickPriceBuffer);
      ZeroMemory(SignalBuffer);
      ZeroMemory(SignalColors);
      ZeroMemory(DeltaTickBuffer);
      //--- массивы серии идут задом наперед, так удобнее в данном случае
      ArraySetAsSeries(TickPriceBuffer,true);
      ArraySetAsSeries(SignalBuffer,true);
      ArraySetAsSeries(SignalColors,true);
      ArraySetAsSeries(DeltaTickBuffer,true);
      first=false;
     }
...
   return(rates_total);
  }
//+------------------------------------------------------------------+
//| применяет тик для вычислений                                     |
//+------------------------------------------------------------------+
void ApplyTick(double price)
  {
   int size=ArraySize(TickPriceBuffer);
   ArrayCopy(TickPriceBuffer,TickPriceBuffer,1,0,size-1);
   ArrayCopy(SignalBuffer,SignalBuffer,1,0,size-1);
   ArrayCopy(SignalColors,SignalColors,1,0,size-1);  
   ArrayCopy(DeltaTickBuffer,DeltaTickBuffer,1,0,size-1);
//--- запишем последнее значение цены
   TickPriceBuffer[0]=price;
//--- вычислим разницу с предыдущим значением
   DeltaTickBuffer[0]=TickPriceBuffer[0]-TickPriceBuffer[1];
//--- получим ср.кв. отклонение
   double stddev=getStdDev(ticks);  

Теперь мы готовы вычислить среднеквадратичное отклонение. Сначала напишем функцию getStdDev(), которая делает все вычисления"в лоб", пробегая по всем элементам массива столько циклов, сколько нужно.

//+------------------------------------------------------------------+
//| вычисляет стандартное отклонение "в лоб"                            |
//+------------------------------------------------------------------+
double getStdDev(int number)
  {
   double summ=0,sum2=0,average,stddev;
//--- считаем сумму изменений и вычисляем матожидание
   for(int i=0;i<ticks;i++)
      summ+=DeltaTickBuffer[i];
   average=summ/ticks;
//--- теперь считаем среднеквадратичное отклонение
   sum2=0;
   for(int i=0;i<ticks;i++)
      sum2+=(DeltaTickBuffer[i]-average)*(DeltaTickBuffer[i]-average);
   stddev=MathSqrt(sum2/(number-1));
   return (stddev);
  }

Затем там же допишем блок, который отвечает за выставление сигналов на тиковом графике — установку кружков красного и синего цвета

//+------------------------------------------------------------------+
//| применяет тик для вычислений                                     |
//+------------------------------------------------------------------+
void ApplyTick(double price)
  {
   int size=ArraySize(TickPriceBuffer);
   ArrayCopy(TickPriceBuffer,TickPriceBuffer,1,0,size-1);
   ArrayCopy(SignalBuffer,SignalBuffer,1,0,size-1);
   ArrayCopy(SignalColors,SignalColors,1,0,size-1);
   ArrayCopy(DeltaTickBuffer,DeltaTickBuffer,1,0,size-1);   
//--- запишем последнее значение цены
   TickPriceBuffer[0]=price;
//--- вычислим разницу с предыдущим значением
   DeltaTickBuffer[0]=TickPriceBuffer[0]-TickPriceBuffer[1];   
//--- получим ср.кв. отклонение
   double stddev=getStdDev(ticks);   
//--- если изменение цены превысило заданный порог
   if(MathAbs(DeltaTickBuffer[0])>gap*stddev) // при первом тике будет показан сигнал, оставим как фичу
     {
      SignalBuffer[0]=price;     // поставим точку
      string col="Red";          // по умолчанию, точка красного цвета
      if(DeltaTickBuffer[0]>0)   // цена резко выросла
        {
         SignalColors[0]=1;      // тогда точка синего цвета
         col="Blue";             // запомним для вывода в лог
        }
      else                       // цена резко упала
      SignalColors[0]=0;         // точка красного цвета
      //--- выведем запись в журнал Экспертов
      PrintFormat("tick=%G change=%.1f pts, trigger=%.3f pts,  stddev=%.3f pts %s",
                  TickPriceBuffer[0],DeltaTickBuffer[0]/_Point,gap*stddev/_Point,stddev/_Point,col);
     }
   else SignalBuffer[0]=0;       // нет сигнала      
//---
  }

Нажимаем кнопку F5 (Начало отладки/продолжение выполнения) и наблюдаем в терминале MetaTrader 5, как работает наш индикатор.

Теперь пришло время заняться отладкой кода, которая позволит выявить ошибки и ускорить работу программы.


Профилировка кода для ускорения работы

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

Как видите, большая часть времени (95.21%) ушла на отработку функции ApplyTick(), которая была вызвана 41 раз из функции OnCalculate(). Сама же OnCalculate() вызывалась 143 раза, но только в 41 случае цена в пришедшем тике отличалась от цены предыдущего. При этом в самой функции ApplyTick() большую часть времени заняли вызовы функции ArrayCopy(), которые выполняют только вспомогательные действия и не производят вычислений, ради которых и был задуман данный индикатор. Вычисление среднеквадратичного отклонения на 111 строке кода заняло только 0.57% общего времени выполнения программы. 

Постараемся уменьшить непроизводительные затраты, для этого попробуем копировать не все элементы массивов  (TickPriceBuffer и т.д), а только 200 последних. Ведь нам на графике достаточно будет видеть 200 последних значений, к тому же количество тиков за одну торговую сессию может достигать десятков и сотен тысяч. Просматривать их все нет необходимости. Поэтому введем входной параметр shift=200, который задает  количество сдвигаемых значений. Добавьте в код строки, выделенные желтым:

//--- input parameters
input int      ticks=50;         // кол-во тиков в расчетах
input int      shift=200;        // кол-во сдвигаемых значений
input double   gap=3.0;          // ширина канала в сигмах
...
void ApplyTick(double price)
  {
//--- сколько элементов сдвигаем в индикаторных буферах на каждом тике
   int move=ArraySize(TickPriceBuffer)-1;
   if(shift!=0) move=shift;
   ArrayCopy(TickPriceBuffer,TickPriceBuffer,1,0,move);
   ArrayCopy(SignalBuffer,SignalBuffer,1,0,move);
   ArrayCopy(SignalColors,SignalColors,1,0,move);
   ArrayCopy(DeltaTickBuffer,DeltaTickBuffer,1,0,move);

Запускаем заново профилировку и видим новый результат — время на копирование массивов упало в в сотни или тысячи раз, теперь основное время занимает вызов StdDev(), которая отвечает за вычисление среднеквадратичного отклонения.

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


Аналитическая оптимизация кода

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


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

Создадим новую функцию getStdDevOptimized(), в которой применим уже знакомый метод сдвига значений массива внутри себя.

//+------------------------------------------------------------------+
//| вычисляет стандартное отклонение по формулам                     |
//+------------------------------------------------------------------+
double getStdDevOptimized(int number)
  {
//---
   static double X2[],X[],X2sum=0,Xsum=0;
   static bool firstcall=true;
//--- первый вызов
   if(firstcall)
     {
      //--- зададим размер динамических массивов на 1 больше количества тиков
      ArrayResize(X2,ticks+1);
      ArrayResize(X,ticks+1);
      //--- гарантируем себе нулевые значения в начале вычислений
      ZeroMemory(X2);
      ZeroMemory(X);

      firstcall=false;
     }
//--- сдвигаем массивы
   ArrayCopy(X,X,1,0,ticks);
   ArrayCopy(X2,X2,1,0,ticks);
//--- вычислим новые входящие значения сумм
   X[0]=DeltaTickBuffer[0];
   X2[0]=DeltaTickBuffer[0]*DeltaTickBuffer[0];
//--- вычислим новые суммы
   Xsum=Xsum+X[0]-X[ticks];
   X2sum=X2sum+X2[0]-X2[ticks];
//--- квадрат стандартного отклонения
   double S2=(1.0/(ticks-1))*(X2sum-Xsum*Xsum/ticks);
//--- считаем сумму тиков и вычисляем матожидание
   double stddev=MathSqrt(S2);
//---
   return (stddev);
  } 

Добавим в функцию ApplyTick() вычисление среднеквадратичного отклонения вторым способом через функцию getStdDevOptimized() и вновь запустим профилировку.

//--- вычислим разницу с предыдущим значением
   DeltaTickBuffer[0]=TickPriceBuffer[0]-TickPriceBuffer[1];
//--- получим ср.кв. отклонение
   double stddev=getStdDev(ticks);
   double std_opt=getStdDevOptimized(ticks);

Результат выполнения:

Видно, что новая функция getStdDevOptimized() требует в два раза меньше времени — 4.56%, чем лобовой обсчет в getStdDev() — 9.54%. Она выполняется даже быстрее, чем встроенная функция PrintFormat(), которая использовала 4.74% времени работы программы. Таким образом, использование оптимального способа вычисления дает еще больший выигрыш по скорости работы программы. Рекомендуем также посмотреть статью 3 метода ускорения индикаторов на примере линейной регрессии.

Кстати, о вызове стандартных функций - в данном индикаторе мы получаем цену из таймсерии close[], которая строится по ценам Bid. Есть еще два способа получить эту цену — с помощью функций SymbolInfoDouble() и SymbolInfoTick(). Добавим эти вызовы в код и снова сделаем профилировку.

Как видите, здесь тоже есть разница по скорости работы. И это понятно, так как чтение готовой цены из close[] не требует затрат по сравнению с вызовом универсальных функций.

Отладка на реальных тиках в тестере

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

Важно: в окне Отладка укажите режим моделирования "Каждый тик на основе реальных тиков". Это позволит использовать для отладки реальные записанные котировки, которые хранит у себя торговый сервер. При первом запуске тестирования они автоматически загрузятся на ваш компьютер.

Если эти параметры не заданы в MetaEditor, то при в виузальном режиме тестирования будут использоваться текущие настройки тестера. Укажите в них режим "Каждый тик на основе реальных тиков".



Мы видим, что на тиковом графике появляются странные разрывы. Значит, в алгоритме допущена какая-то ошибка. Неизвестно, сколько времени ушло бы на её проявление при тестировании в реальном времени. В данном случае по выводам в Журнал визуального тестирования видно, что странные разрывы возникают в момент появления нового бара. Точно! — мы забыли, что при переходе на новый бар размер индикаторных буферов автоматически увеличивается на 1. Внесём исправление в код:

void ApplyTick(double price)
  {
//--- будем запоминать размер массива TickPriceBuffer - он равен кол-ву баров на графике
   static int prev_size=0;
   int size=ArraySize(TickPriceBuffer);
//--- если размер индикаторных буферов не изменился, то сдвинем все элементы на 1 позицию назад
   if(size==prev_size)
     {
      //--- сколько элементов сдвигаем в индикаторных буферах на каждом тике
      int move=ArraySize(TickPriceBuffer)-1;
      if(shift!=0) move=shift;
      ArrayCopy(TickPriceBuffer,TickPriceBuffer,1,0,move);
      ArrayCopy(SignalBuffer,SignalBuffer,1,0,move);
      ArrayCopy(SignalColors,SignalColors,1,0,move);
      ArrayCopy(DeltaTickBuffer,DeltaTickBuffer,1,0,move);
     }
   prev_size=size;
//--- запишем последнее значение цены
   TickPriceBuffer[0]=price;
//--- вычислим разницу с предыдущим значением

Запустим визуальное тестирование и поставим точку остановки, чтобы поймать момент открытия нового бара. Добавим наблюдаемые значения и убедимся, что всё сделали правильно: количество баров на графике увеличилось на единицу, тиковый объем текущего бара равен 1 — это самый первый тик нового бара.

Ну вот, мы провели оптимизацию кода, исправили ошибки, замерили время выполнения различных функций, теперь индикатор готов к работе. Можно запускать визуальное тестирование и наблюдать, что происходит после появления сигналов на тиковом графике. Можно что-то еще улучшить в коде индикатора? Перфекционист от кодинга скажет — да! Мы еще не попробовали использовать кольцевой буфер для ускорения работы.  Желающие могут проверить сами — дает ли это прирост производительности?


MetaEditor — это готовая лаборатория для разработки торговых стратегий

Для написания автоматической торговой системы важно иметь не только удобную среду разработки и мощный язык программирования, но и дополнительные инструменты для отладки и калибровки программы.  В этой статье мы показали как:

  1. создавать за пару минут тиковый график в первом приближении;
  2. пользоваться отладкой в режиме реального времени на графике по кнопке F5;
  3. запускать профилировку для выявления неэффективных мест в коде;
  4. проводить быструю отладку на исторических данных в режиме визуального тестирования;
  5. просматривать значения нужных переменных в процессе отладки.

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

Пользуйтесь всеми возможностями среды разработки MetaEditor для создания эффективных торговых роботов!

Статьи по теме:

  1. Как написать индикатор в MQL5
  2. Создание тиковых индикаторов
  3. Принципы экономного пересчета индикаторов
  4. Усреднение ценовых рядов без дополнительных буферов для промежуточных расчетов
  5. Отладка программ на MQL5