Пользовательский тестер стратегий на основе быстрых математических вычислений

28 декабря 2017, 15:14
Vasiliy Sokolov
29
7 678

Оглавление



Введение

Тестер стратегий, предоставляемый MetaTrader 5, имеет мощный функционал для решения разнообразных задач. С его помощью можно тестировать как сложные стратегии торговли корзинами инструментов, так и одиночные стратегии с простыми правилами входов и выходов. Однако такой обширный функционал нам пригождается далеко не всегда. Часто нам просто нужно быстро проверить какую-нибудь простую торговую идею или сделать приблизительные вычисления, точность которых будет компенсироваться их скоростью. Штатный тестер MetaTrader 5 обладает одной интересной, но мало используемой возможностью: он умеет производить расчеты в режиме математических вычислений. Это ограниченный режим запуска тестера стратегий, который, тем не менее, обладает всеми преимуществами полновесной оптимизации: в нем доступны облачные вычисления, можно использовать генетический оптимизатор и есть возможность сбора пользовательских типов данных.

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

Конечно, нельзя объять необъятное. Эта статья — не исключение. Написание собственного тестера стратегий требует серьезного и кропотливого труда. Наша цель скромнее: мы покажем, что используя нужные библиотеки, создать свой тестер не так сложно, как может показаться на первый взгляд.

Если тематика окажется интересной для моих коллег, эта статья увидит продолжение с развитием предложенных идей.


Общие сведения о режиме математических вычислений

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

Рис. 1. Выбор режима математических вычислений в тестере стратегий

В этом режиме вызывается лишь ограниченный набор функций, а торговое окружение (символы, информация о счете, свойства торгового сервера) недоступно. Основной функцией вызова становится OnTester(), через которую пользователь может задать специальный пользовательский критерий оптимизации. Он будет использоваться наряду с другими стандартными критериями оптимизации и доступен для отображения в стандартном пользовательском отчете стратегии. На скриншоте внизу он обведен красной рамкой:

 

Рис. 2. Пользовательский критерий оптимизации, рассчитанный в функции OnTester

Значения, возвращаемые функцией OnTester, поддаются перебору и оптимизации. Продемонстрируем это в простейшем советнике:

//+------------------------------------------------------------------+
//|                                                OnTesterCheck.mq5 |
//|                        Copyright 2017, MetaQuotes Software Corp. |
//|                                              http://www.mql5.com |
//+------------------------------------------------------------------+
#property copyright "Copyright 2017, MetaQuotes Software Corp."
#property link      "http://www.mql5.com"
#property version   "1.00"
input double x = 0.01;
//+------------------------------------------------------------------+
//| Tester function                                                  |
//+------------------------------------------------------------------+
double OnTester()
{
   double ret = MathSin(x);
   return(ret);
}
//+------------------------------------------------------------------+

В его коде нет ничего, кроме входного параметра x и функции OnTester, которая рассчитывает значение синуса от переданного ей аргумента, в данном случае это x. Попробуем теперь оптимизировать эту функцию. Для этого в тестере стратегий выберем тип оптимизации "Медленная (полный перебор параметров)", а режим симуляции оставим прежним: "математические вычисления".

В параметрах оптимизации укажем диапазон изменения x: стартовое значение — 0.01, шаг — 0.01, стоп — 10. После того, как все будет готово, запустим тестер стратегий. Он завершит свою работу почти мгновенно, после чего мы откроем график оптимизации и в контекстном меню выберем вид "Линейный график". Перед нами окажется функция синуса в графической интерпретации:

Рис. 3. Графическое представление функции синуса

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

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

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

Самодостаточность модуля означает, что в одном-единственном эксперте будут находиться все необходимые данные для тестирования и сама система тестирования, которая будет их использовать. Такой эксперт может будет легко распространять в сети распределенных вычислений, если потребуется его облачная оптимизация.

Перейдем к описанию первой части системы, а именно — к тому, как хранить историю для тестирования.


Сохранение истории инструмента для тестера математических вычислений

Режим математических вычислений не подразумевает доступ к торговым инструментам. Вызов функций вроде CopyRates(Symbol(),...) здесь теряет смысл. Однако для симуляции исторические данные необходимы. Для этого историю котировок требуемого символа мы можем хранить в предварительно сжатом массиве типа uchar[]:

uchar symbol[128394] = {0x98,0x32,0xa6,0xf7,0x64,0xbc...};

Любой тип данных — звук, изображение, числа и строки — можно представить как простой набор байтов. Байт — короткий блок, состоящий из восьми битов. Любая информация хранится "пачками" в последовательности, состоящей из этих байтов. В MQL5 есть специальный тип данных uchar, каждое значение которого может представлять ровно один байт. Таким образом, массив uchar размерностью 100 элементов способен хранить 100 байтов.

Котировки символа состоят из множества баров. Каждый бар включает в себя информацию о времени открытия бара, его ценах (максимум, минимум, открытие и закрытие) и объеме. Каждое такое значение хранится в переменной соответствующей длины. Приведем таблицу:

Значение Тип данных Размер в байтах
Время открытия datetime 8
Цена открытия double 8
Максимальная цена double 8
Минимальная цена double 8
Цена закрытия double 8
Тиковый объем long  8
Спред   int 4
Реальный объем   long 8

Нетрудно подсчитать, что для хранения одного бара потребуется 60 байтов, или массив uchar, состоящий из 60 элементов. Для круглосуточного рынка Forex один торговый день состоит из 1440 минутных баров. Следовательно, минутная история одного года состоит приблизительно из 391680 баров. Умножив это число на 60 байтов, мы выясним, что один год минутной истории в несжатом виде составляет примерно 23 Мб. Много это или мало? По современным меркам — немного, но представьте, что будет, если мы решим протестировать эксперт на данных за 10 лет. Понадобится хранить 230 Мб данных, а может быть, — даже распространять их по сети. Это очень много даже по современным меркам.

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

Итак, наш алгоритм будет загружать массив баров MqlRates, конвертировать его в байтовое представление, затем сжимать архиватором Zip, и уже сжатые данные сохранять в виде массива uchar, определенного в файле mqh.

Для конвертации котировок в байтовый массив воспользуется системой преобразования через объединение типов union. Это система позволяет размещать несколько типов данных в одном поле хранения. Таким образом, обратившись к одному типу, мы можем получить данные другого. В таком объединении будем хранить два типа: структуру MqlRates и массив uchar, количество элементов которого будет равно размеру MqlRates. Чтобы понять как работает эта система, обратимся к первой версии нашего скрипта SaveRates.mq5, конвертирующего историю инструмента в массив байтов uchar:

//+------------------------------------------------------------------+
//|                                                    SaveRates.mq5 |
//|                                 Copyright 2016, Vasiliy Sokolov. |
//|                                              http://www.mql5.com |
//+------------------------------------------------------------------+
#property copyright "Copyright 2016, Vasiliy Sokolov."
#property link      "http://www.mql5.com"
#property version   "1.00"
#include <Zip\Zip.mqh>
#include <ResourceCreator.mqh>
input ENUM_TIMEFRAMES MainPeriod;

union URateToByte
{
   MqlRates bar;
   uchar    bar_array[sizeof(MqlRates)];
}RateToByte;
//+------------------------------------------------------------------+
//| Script program start function                                    |
//+------------------------------------------------------------------+
void OnStart()
{
   //-- Загружаем котировки
   MqlRates rates[];
   int total = CopyRates(Symbol(), Period(), 0, 20000, rates);
   uchar symbol_array[];
   //-- Конвертируем их в байтовое представление
   ArrayResize(symbol_array, sizeof(MqlRates)*total);
   for(int i = 0, dst = 0; i < total; i++, dst +=sizeof(MqlRates))
   {
      RateToByte.bar = rates[i];
      ArrayCopy(symbol_array, RateToByte.bar_array, dst, 0, WHOLE_ARRAY);
   }
   //-- Сжимаем их в zip-архив
   CZip Zip;
   CZipFile* file = new CZipFile(Symbol(), symbol_array);
   Zip.AddFile(file);
   uchar zip_symbol[];
   //-- Получаем байтовое представление сжатого архива
   Zip.ToCharArray(zip_symbol);
   //-- Записываем его в виде включаемого mqh-файла
   CCreator creator;
   creator.ByteArrayToMqhArray(zip_symbol, "rates.mqh", "rates");
}
//+------------------------------------------------------------------+

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

Получить байтовое представление котировок и сжать их недостаточно. Нужно записать это представление в виде массива uchar. При этом массив должен подгружаться в виде ресурса, т.е. он должен быть скомпилирован вместе с программой. Для этих целей создадим специальный заголовочный файл mqh, содержащий наш массив как простой набор ASCII символов. Для этого воспользуемся специальным классом CResourceCreator:

//+------------------------------------------------------------------+
//|                                              ResourceCreator.mqh |
//|                                 Copyright 2017, Vasiliy Sokolov. |
//|                                              http://www.mql5.com |
//+------------------------------------------------------------------+
#property copyright "Copyright 2017, Vasiliy Sokolov."
#property link      "http://www.mql5.com"
#include <Arrays\ArrayObj.mqh>
//+------------------------------------------------------------------+
//| Содержит строковые идентификаторы создаваемого массива ресурсов  |
//+------------------------------------------------------------------+
class CResInfo : public CObject
{
public:
   string FileName;
   string MqhFileName;
   string ArrayName;
};
//+------------------------------------------------------------------+
//| Создает ресурс MQL в виде байтового массива.                     |
//+------------------------------------------------------------------+
class CCreator
{
private:
   int      m_common;
   bool     m_ch[256];
   string   ToMqhName(string name);
   void     CreateInclude(CArrayObj* list_info, string file_name);
public:
            CCreator(void);
   void     SetCommonDirectory(bool common);
   bool     FileToByteArray(string file_name, uchar& byte_array[]);
   bool     ByteArrayToMqhArray(uchar& byte_array[], string file_name, string array_name);
   void     DirectoryToMqhArray(string src_dir, string dst_dir, bool create_include = false);
};
//+------------------------------------------------------------------+
//| Конструктор по-умолчанию                                         |
//+------------------------------------------------------------------+
CCreator::CCreator(void) : m_common(FILE_COMMON)
{
   ArrayInitialize(m_ch, false);
   for(uchar i = '0'; i < '9'; i++)
      m_ch[i] = true;
   for(uchar i = 'A'; i < 'Z'; i++)
      m_ch[i] = true;
}
//+------------------------------------------------------------------+
//| Устанавливает флаг FILE_COMMON, либо снимает его                 |
//+------------------------------------------------------------------+
CCreator::SetCommonDirectory(bool common)
{
   m_common = common ? FILE_COMMON : 0;   
}

//+------------------------------------------------------------------+
//| Переводит все файлы в директориии src_dir в файлы mqh содержащие |
//| байтовое представление этих файлов                               |
//+------------------------------------------------------------------+
void CCreator::DirectoryToMqhArray(string src_dir,string dst_dir, bool create_include = false)
{
   string file_name;
   string file_mqh;
   CArrayObj list_info;
   long h = FileFindFirst(src_dir+"\\*", file_name, m_common);
   if(h == INVALID_HANDLE)
   {
      printf("Директория" + src_dir + " не найдена, либо она не содержит файлов");
      return;
   }
   do
   {
      uchar array[];
      if(FileToByteArray(src_dir+file_name, array))
      {
         string norm_name = ToMqhName(file_name);
         file_mqh = dst_dir + norm_name + ".mqh";
         ByteArrayToMqhArray(array, file_mqh, "m_"+norm_name);
         printf("Create resource: " + file_mqh);
         //Добавляем информацию о созданном ресурсе
         CResInfo* info = new CResInfo();
         list_info.Add(info);
         info.FileName = file_name;
         info.MqhFileName = norm_name + ".mqh";
         info.ArrayName = "m_"+norm_name;
      }
   }while(FileFindNext(h, file_name));
   if(create_include)
      CreateInclude(&list_info, dst_dir+"include.mqh");
}
//+------------------------------------------------------------------+
//| Создает mqh файл с включениями всех сгенерированных файлов       |
//+------------------------------------------------------------------+
void CCreator::CreateInclude(CArrayObj *list_info, string file_name)
{
   int handle = FileOpen(file_name, FILE_WRITE|FILE_TXT|m_common);
   if(handle == INVALID_HANDLE)
   {
      printf("Не удалось создать include-файл " + file_name);
      return;
   }
   //Создаем заголовок include
   for(int i = 0; i < list_info.Total(); i++)
   {
      CResInfo* info = list_info.At(i);
      string line = "#include \"" + info.MqhFileName + "\"\n";
      FileWriteString(handle, line);
   }
   //Создаем функцию, копирующую массив ресурса вызывающему коду
   FileWriteString(handle, "\n");
   FileWriteString(handle, "void CopyResource(string file_name, uchar &array[])\n");
   FileWriteString(handle, "{\n");
   for(int i = 0; i < list_info.Total(); i++)
   {
      CResInfo* info = list_info.At(i);
      if(i == 0)
         FileWriteString(handle, "   if(file_name == \"" + info.FileName + "\")\n");
      else
         FileWriteString(handle, "   else if(file_name == \"" + info.FileName + "\")\n");
      FileWriteString(handle,    "      ArrayCopy(array, " + info.ArrayName + ");\n");
   }
   FileWriteString(handle, "}\n");
   FileClose(handle);
}
//+------------------------------------------------------------------+
//| конвертирует переданное имя в правильное имя переменной MQL      |
//+------------------------------------------------------------------+
string CCreator::ToMqhName(string name)
{
   uchar in_array[];
   uchar out_array[];
   int total = StringToCharArray(name, in_array);
   ArrayResize(out_array, total);
   int t = 0;
   for(int i = 0; i < total; i++)
   {
      uchar ch = in_array[i];
      if(m_ch[ch])
         out_array[t++] = ch;
      else if(ch == ' ')
         out_array[t++] = '_';
      uchar d = out_array[t-1];
      int dbg = 4;
   }
   string line = CharArrayToString(out_array, 0, t);
   return line;
}
//+------------------------------------------------------------------+
//| Возвращает байтовое представление переданного файла в виде       |
//| массива byte_array                                               |
//+------------------------------------------------------------------+
bool CCreator::FileToByteArray(string file_name, uchar& byte_array[])
{
   int handle = FileOpen(file_name, FILE_READ|FILE_BIN|m_common);
   if(handle == -1)
   {
      printf("Filed open file " + file_name + ". Reason: " + (string)GetLastError());
      return false;
   }
   FileReadArray(handle, byte_array, WHOLE_ARRAY);
   FileClose(handle);
   return true;
}
//+------------------------------------------------------------------+
//| Конвертирует переданный байтовый массив byte_array в mqh-файл    |
//| file_name в котором располагается описание массива с именем      |
//| array_name                                                       |
//+------------------------------------------------------------------+
bool CCreator::ByteArrayToMqhArray(uchar& byte_array[], string file_name, string array_name)
{
   int size = ArraySize(byte_array);
   if(size == 0)
      return false;
   int handle = FileOpen(file_name, FILE_WRITE|FILE_TXT|m_common, "");
   if(handle == -1)
      return false;
   string strSize = (string)size;
   string strArray = "uchar " +array_name + "[" + strSize + "] = \n{\n";
   FileWriteString(handle, strArray);
   string line = "   ";
   int chaptersLine = 32;
   for(int i = 0; i < size; i++)
   {
      ushort ch = byte_array[i];
      line += (string)ch;
      if(i == size - 1)
         line += "\n";
      if(i>0 && i%chaptersLine == 0)
      {
         if(i < size-1)
            line += ",\n";
         FileWriteString(handle, line);
         line = "   ";
      }
      else if(i < size - 1)
         line += ",";
   }
   if(line != "")
      FileWriteString(handle, line);
   FileWriteString(handle, "};");
   FileClose(handle);
   return true;
}

Не будем подробно останавливаться на его работе подробно, опишу его лишь в общих чертах, перечислив его возможности.

  • Читает любой произвольный файл на жестком диске и сохраняет его байтовое представление в виде массива uchar в файле mqh
  • Читает любую произвольную директорию на жестком диске и сохраняет байтовое представление всех файлов, находящихся в этой директории. Байтовое представление для каждого такого файла находится в отдельном mqh-файле, содержащем массив uchar
  • Принимает на вход массив байтов uchar и сохраняет его в виде массива символов в отдельном файле mqh
  • Создает специальный заголовочный файл, содержащий ссылки на все mqh-файлы, созданные в процессе генерации. Также создается специальная функция, которая принимает на вход имя массива и возвращает его байтовое представление. Этот алгоритм использует динамическую генерацию кода. 

Описанный класс — мощная альтернатива штатной системе размещения ресурсов в mql-программе.

По умолчанию, все файловые операции проходят в общей файловой директории (FILE_COMMON). Если выполнить скрипт из предыдущего листинга, в ней появится новый файл rates.mqh (название файла задается вторым параметром метода ByteArrayToMqhArray). Он будет содержать гигантский массив rates[] (название массива указывается третьим параметром этого метода). Вот отрывок содержимого этого файла:


Рис. 4. Котировки MqlRates в виде сжатого байтового массива rates

Сжатие данных работает прекрасно. Один год несжатой минутной истории по валютной паре EURUSD занимает примерно 20 Мб, после сжатия — всего 5 Мб. Однако сам файл rates.mqh лучше не открывать в MetaEditor: его размер гораздо больше этой цифры, и редактор может подвиснуть. Но не волнуйтесь. После компиляции текст преобразуется в байты, и фактический размер программы увеличивается лишь на реальную величину хранимой информации, т.е. на 5 мегабайт в нашем случае.

Кстати, с помощью этой техники в ex5-программе можно хранить любой тип нужной информации, а не только историю котировок.


Загрузка котировок MqlRates из сжатого байтового массива

Теперь, когда история сохранена, мы можем подключить ее в любую mql-программу, для этого достаточно написать дерективу include в ее начале:

...
#include "rates.mqh"
...

При этом файл rates.mqh должен быть перемещен в директорию исходных текстов самой программы.

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

//+------------------------------------------------------------------+

//|                                                      Mtester.mqh |
//|                        Copyright 2017, MetaQuotes Software Corp. |
//|                                              http://www.mql5.com |
//+------------------------------------------------------------------+
#property copyright "Copyright 2017, MetaQuotes Software Corp."
#property link      "http://www.mql5.com"
#include <Zip\Zip.mqh>
#include "rates.mqh"
//+------------------------------------------------------------------+
//| Casting MqlRates to uchar[]                                      |
//+------------------------------------------------------------------+
union URateToByte
{
   MqlRates bar;
   uchar    bar_array[sizeof(MqlRates)];
};
//+------------------------------------------------------------------+
//| Преобразует сжатые данные в массив котировок MqlRates            |
//| Возвращает количество полученных баров, возвращает -1 в случае   |
//| неудачи                                                          |
//+------------------------------------------------------------------+
int LoadRates(string symbol_name, MqlRates &mql_rates[])
{
   CZip Zip;
   Zip.CreateFromCharArray(rates);
   CZipFile* file = dynamic_cast<CZipFile*>(Zip.ElementByName(symbol_name));
   if(file == NULL)
      return -1;
   uchar array_rates[];
   file.GetUnpackFile(array_rates);
   URateToByte RateToBar;
   ArrayResize(mql_rates, ArraySize(array_rates)/sizeof(MqlRates));
   for(int start = 0, i = 0; start < ArraySize(array_rates); start += sizeof(MqlRates), i++)
   {
      ArrayCopy(RateToBar.bar_array, array_rates, 0, start, sizeof(MqlRates));
      mql_rates[i] = RateToBar.bar;
   }
   return ArraySize(mql_rates);
}
//+------------------------------------------------------------------+

Функция находится в файле Mtester.mqh. Это будет первая наша функция для работы в режиме математических вычислений. Позже в файл Mtester.mqh будет добавляться новый функционал, и возможно, он превратится в полноценный движок математического тестирования стратегий.

Напишем элементарную стратегию для режима математических рассчетов. Она будет выполнять лишь две функции: загружать котировки в функции OnInit и рассчитывать среднее значение всех цен закрытия в функции OnTester. Результат расчета будет возвращаться в MetaTrader:

//+------------------------------------------------------------------+
//|                                                      MExpert.mq5 |
//|                        Copyright 2017, MetaQuotes Software Corp. |
//|                                              http://www.mql5.com |
//+------------------------------------------------------------------+
#property copyright "Copyright 2017, MetaQuotes Software Corp."
#property link      "http://www.mql5.com"
#property version   "1.00"
#include "Mtester.mqh"
//+------------------------------------------------------------------+
//| Котировки, на которых будет происходить тестирование             |
//+------------------------------------------------------------------+
MqlRates Rates[];
//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit()
{
   //-- Загружаем котировки для указанного символа.
   if(LoadRates(Symbol(), Rates)==-1)
   {
      printf("Котировки для символа " + Symbol() + " не найдены. Создайте соответствующий ресурс котировок.");
      return INIT_PARAMETERS_INCORRECT;
   }
   printf("Загружено " + (string)ArraySize(Rates) + " баров по символу " + Symbol());
   return(INIT_SUCCEEDED);
}

//+------------------------------------------------------------------+
//| Tester function                                                  |
//+------------------------------------------------------------------+
double OnTester()
{
   double mean = 0.0;
   for(int i = 0; i < ArraySize(Rates); i++)
      mean += Rates[i].close;
   mean /= ArraySize(Rates);
   return mean;
}
//+------------------------------------------------------------------+

После того, как эксперт будет скомпилирован, загрузим его в тестер стратегий и выберем режим "Математические вычисления". Запустим его тестирование и обратимся к журналу:

2017.12.13 15:12:25.127 Core 2  math calculations test of Experts\MTester\MExpert.ex5 started
2017.12.13 15:12:25.127 Core 2  Загружено 354159 баров по символу EURUSD
2017.12.13 15:12:25.127 Core 2  OnTester result 1.126596405653942
2017.12.13 15:12:25.127 Core 2  EURUSD,M15: mathematical test passed in 0:00:00.733 (total tester working time 0:00:01.342)
2017.12.13 15:12:25.127 Core 2  217 Mb memory used

Как видно, эксперт отработал так, как и планировалось. Были корректно загружены все котировки, о чем свидетельствует запись о количестве загруженных баров. Также был корректно произведен перебор всех баров для расчета среднего значения, которое и было возвращено вызывающему потоку. Как видим, средняя цена всех котировок по EURUSD за последний год составила 1.12660.


Прототип стратегии на скользящей средней

Мы добились впечатляющих результатов: получили данные и сжали их, сохранили в виде статического массива uchar и загрузили его в эксперт, разархивировали данные и снова сконвертировали в массив котировок. Теперь настало время написать первую полезную стратегию. Обратимся к классическому варианту, основанному на пересечении двух скользящих средних. Эту стратегию легко реализовать. Так как торговое окружение в режиме математических вычислений нам недоступно, мы не можем вызвать индикаторы вроде iMA непосредственно. Вместо этого мы должны рассчитать значение средней вручную. Основная наша задача в этом режиме тестирования — максимальное ускорение. Поэтому все алгоритмы, которые мы будем использовать, должны работать быстро. Известно, что расчет средней относится к классу простых задач со сложностью выполнения O(1). Это значит, что скорость расчета среднего значения не должна зависеть от периода средней. Для этих целей воспользуемся готовой библиотекой кольцевого буфера. Более подробно об этом алгоритме уже была написана отдельная статья.

Для начала напишем шаблон нашего первого  эксперта:

//+------------------------------------------------------------------+
//|                                                      MExpert.mq5 |
//|                        Copyright 2017, MetaQuotes Software Corp. |
//|                                              http://www.mql5.com |
//+------------------------------------------------------------------+
#property copyright "Copyright 2017, MetaQuotes Software Corp."
#property link      "http://www.mql5.com"
#property version   "1.00"
#include "Mtester.mqh"
#include <RingBuffer\RiSma.mqh>

input int PeriodFastMa = 9;
input int PeriodSlowMa = 16;

CRiSMA FastMA;    // Кольцевой буфер для расчета быстрой скользящей средней
CRiSMA SlowMA;    // Кольцевой буфер для расчета медленной скользящей средней
//+------------------------------------------------------------------+
//| Котировки, на которых будет происходить тестирование             |
//+------------------------------------------------------------------+
MqlRates Rates[];
//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit()
{
   //-- Проверяем корректность комбинации параметров
   //-- Быстрая средняя не может быть больше медленной
   if(PeriodFastMa >= PeriodSlowMa)
      return INIT_PARAMETERS_INCORRECT;
   //-- Инициализируем периоды кольцевых буферов
   FastMA.SetMaxTotal(PeriodFastMa);
   SlowMA.SetMaxTotal(PeriodSlowMa);
   //-- Загружаем котировки для указанного символа.
   if(LoadRates(Symbol(), Rates)==-1)
   {
      printf("Котировки для символа " + Symbol() + " не найдены. Создайте соответствующий ресурс котировок.");
      return INIT_FAILED;
   }
   printf("Загружено " + (string)ArraySize(Rates) + " баров по символу " + Symbol());
   return(INIT_SUCCEEDED);
}

//+------------------------------------------------------------------+
//| Описываем стратегию                                              |
//+------------------------------------------------------------------+
double OnTester()
{
   for(int i = 1; i < ArraySize(Rates); i++)
   {
      FastMA.AddValue(Rates[i].close);
      SlowMA.AddValue(Rates[i].close);
      //Здесь будет торговая логика нашего эксперта
   }
   return 0.0;
}
//+------------------------------------------------------------------+

В нем определяются два параметра с периодами усреднения быстрой и медленной МА. Затем объявляются два кольцевых буфера, рассчитывающих значения этих средних. В блоке инициализации проверяется корректность введенных параметров. Так как параметры будут устанавливаться не пользователем, а выбираться автоматически тестером стратегий в режиме оптимизации, то часто параметры будут комбинироваться неправильно. В данном случае наша быстрая МА может оказаться больше медленной. Чтобы избежать этой путаницы и сэкономить время на оптимизацию, будем завершать такой прогон еще до начала его запуска. Для этих целей в блоке OnInit возвращается константа INIT_PARAMETERS_INCORRECT.

После того, как буферы инициализированы, параметры проверены и котировки загружены, наступает время выполнения самого теста: запускается функция OnTester. В ней основное тестирование будет находиться внутри блока for. Из кода видно, что если среднее значение кольцевого буфера FastMA больше среднего значения SlowMA, необходимо открыть длинную позицию, и наоборот. Однако сейчас у нас нет торгового модуля, который бы открывал эти длинные и короткие позиции. Его еще предстоит написать. 


Класс виртуальной позиции

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

  • время открытия позиции;
  • цена открытия позиции;
  • время закрытия позиции;
  • цена закрытия позиции;
  • объем позиции;
  • спред в момент открытия позиции;
  • направление позиции.

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

//+------------------------------------------------------------------+
//|                                                      Mtester.mqh |
//|                        Copyright 2017, MetaQuotes Software Corp. |
//|                                              http://www.mql5.com |
//+------------------------------------------------------------------+
#property copyright "Copyright 2017, MetaQuotes Software Corp."
#property link      "http://www.mql5.com"
#include <Object.mqh>
#include "rates.mqh"
#include "Type2Char.mqh"
//+------------------------------------------------------------------+
//| Класс виртуальной позиции для тестера математических вычислений  |
//+------------------------------------------------------------------+
class CMposition : public CObject
{
private:
   datetime    m_time_open;
   datetime    m_time_close;
   double      m_price_open;
   double      m_price_close;
   double      m_volume;
   int         m_spread;
   ENUM_POSITION_TYPE m_type;
public:
               CMposition(void);
   static int  Sizeof(void);
   bool        IsActive(void);
   datetime    TimeOpen(void);
   datetime    TimeClose(void);
   double      PriceOpen(void);
   double      PriceClose(void);
   double      Volume(void);
   double      Profit(void);
   ENUM_POSITION_TYPE PositionType(void);
   static CMposition*  CreateOnBarOpen(MqlRates& bar, ENUM_POSITION_TYPE pos_type, double vol);
   void        CloseOnBarOpen(MqlRates& bar);
};
//+------------------------------------------------------------------+
//| Одна позиция CMposition занимает 45 байт данных                  |
//+------------------------------------------------------------------+
int CMposition::Sizeof(void)
{
   return 48;
}
CMposition::CMposition(void):m_time_open(0),
                             m_time_close(0),
                             m_price_open(0.0),
                             m_price_close(0.0),
                             m_volume(0.0)
{
}
//+------------------------------------------------------------------+
//| Истина, если позиция закрыта                                     |
//+------------------------------------------------------------------+
bool CMposition::IsActive()
{
   return m_time_close == 0;
}
//+------------------------------------------------------------------+
//| Время открытия позиции                                           |
//+------------------------------------------------------------------+
datetime CMposition::TimeOpen(void)
{
   return m_time_open;
}
//+------------------------------------------------------------------+
//| Время закрытия позиции                                           |
//+------------------------------------------------------------------+
datetime CMposition::TimeClose(void)
{
   return m_time_close;
}
//+------------------------------------------------------------------+
//| Цена открытия позиции                                            |
//+------------------------------------------------------------------+
double CMposition::PriceOpen(void)
{
   return m_price_open;
}
//+------------------------------------------------------------------+
//| Цена закрытия позиции                                            |
//+------------------------------------------------------------------+
double CMposition::PriceClose(void)
{
   return m_price_close;
}
//+------------------------------------------------------------------+
//| Объем позиции                                                    |
//+------------------------------------------------------------------+
double CMposition::Volume(void)
{
   return m_volume;
}
//+------------------------------------------------------------------+
//| Возвращает тип торговой позиции                                  |
//+------------------------------------------------------------------+
ENUM_POSITION_TYPE CMposition::PositionType(void)
{
   return m_type;
}
//+------------------------------------------------------------------+
//| Профит позиции                                                   |
//+------------------------------------------------------------------+
double CMposition::Profit(void)
{
   if(IsActive())
      return 0.0;
   int sign = m_type == POSITION_TYPE_BUY ? 1 : -1;
   double pips = (m_price_close - m_price_open)*sign;
   double profit = pips*m_volume;
   return profit;
}
//+------------------------------------------------------------------+
//| Создает позицию исходя из переданных параметров                  |
//+------------------------------------------------------------------+
static CMposition* CMposition::CreateOnBarOpen(MqlRates &bar, ENUM_POSITION_TYPE pos_type, double volume)
{
   CMposition* position = new CMposition();
   position.m_time_open = bar.time;
   position.m_price_open = bar.open;
   position.m_volume = volume;
   position.m_type = pos_type;
   return position;
}
//+------------------------------------------------------------------+
//| Закрывает позицию по цене открытия переданного бара              |
//+------------------------------------------------------------------+
void CMposition::CloseOnBarOpen(MqlRates &bar)
{
   m_price_close = bar.open;
   m_time_close = bar.time;
}
//+------------------------------------------------------------------+

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


Класс торгового блока

Теперь нужно создать класс, управляющий этими позициями. Он будет аналогом торговых функций MetaTrader. Очевидно, что в этом модуле нужно хранить и сами позиции. Для этого предназначены две коллекции CArrayObj: первая —  Active — нужна для хранения активных позиций стратегии, другая — History — будет содержать исторические позиции.

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

  • EntryAtOpenBar — открывает позицию нужного направления и объема;
  • CloseAtOpenBar — закрывает позицию по выбранному индексу.

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

Наш класс CMtrade (так мы его назовем), получился достаточно простым:

//+------------------------------------------------------------------+
//|                                                      Mtester.mqh |
//|                        Copyright 2017, MetaQuotes Software Corp. |
//|                                              http://www.mql5.com |
//+------------------------------------------------------------------+
#property copyright "Copyright 2017, MetaQuotes Software Corp."
#property link      "http://www.mql5.com"
#include <Object.mqh>
#include <Arrays\ArrayObj.mqh>
#include "Mposition.mqh"
//+------------------------------------------------------------------+
//| Торговый модуль открытия виртуальных позиций                     |
//+------------------------------------------------------------------+
class CMtrade
{
public:
               CMtrade(void);
               ~CMtrade();
   CArrayObj   Active;
   CArrayObj   History;
   void        EntryAtOpenBar(MqlRates &bar, ENUM_POSITION_TYPE pos_type, double volume);
   void        CloseAtOpenBar(MqlRates &bar, int pos_index);
};
//+------------------------------------------------------------------+
//| Конструктор по умолчанию                                         |
//+------------------------------------------------------------------+
CMtrade::CMtrade(void)
{
   Active.FreeMode(false);
}
//+------------------------------------------------------------------+
//| Удаление всех оставшихся позиций                                 |
//+------------------------------------------------------------------+
CMtrade::~CMtrade()
{
   Active.FreeMode(true);
   Active.Clear();
}
//+------------------------------------------------------------------+
//| Создает новую позицию и добавляет ее в список активных           |
//| позиций.                                                         |
//+------------------------------------------------------------------+
void CMtrade::EntryAtOpenBar(MqlRates &bar, ENUM_POSITION_TYPE pos_type, double volume)
{
   CMposition* pos = CMposition::CreateOnBarOpen(bar, pos_type, volume);
   Active.Add(pos);
}
//+------------------------------------------------------------------+
//| Закрывает виртуальную позицию по индексу pos_index по цене       |
//| открытия переданного бара                                        |
//+------------------------------------------------------------------+
void CMtrade::CloseAtOpenBar(MqlRates &bar, int pos_index)
{
   CMposition* pos = Active.At(pos_index);
   pos.CloseOnBarOpen(bar);
   Active.Delete(pos_index);
   History.Add(pos);
}
//+------------------------------------------------------------------+

 Фактически весь его функционал сводится к двум функциям:

  1. Получить новую позицию от статического метода CMposition::CreateOnBarOpen и добавить его в список Active (метод EntryOnOpenBar);
  2. Переместить выбранную позицию из списка активных позиций в список исторических, при этом перемещаемая позиция закрывается статическим методом CMposition::CLoseOnBarOpen.

Мы создали торговый класс, и теперь у нас есть все компоненты, чтобы провести тестирование эксперта.


Первое тестирование эксперта. Работа в оптимизаторе

Соберем все компоненты вместе. Приведем исходный код стратегии на двух скользящих средних для работы в математическом оптимизаторе. 

//+------------------------------------------------------------------+
//|                                                      MExpert.mq5 |
//|                        Copyright 2017, MetaQuotes Software Corp. |
//|                                              http://www.mql5.com |
//+------------------------------------------------------------------+
#property copyright "Copyright 2017, MetaQuotes Software Corp."
#property link      "http://www.mql5.com"
#property version   "1.00"
#include <RingBuffer\RiSma.mqh>
#include "Mtester.mqh"

input int PeriodFastMa = 9;
input int PeriodSlowMa = 16;

CRiSMA FastMA;    // Кольцевой буфер для расчета быстрой скользящей средней
CRiSMA SlowMA;    // Кольцевой буфер для расчета медленной скользящей средней
CMtrade Trade;    // Торговый модуль для виртуальных расчетов

//+------------------------------------------------------------------+
//| Котировки, на которых будет происходить тестирование             |
//+------------------------------------------------------------------+
MqlRates Rates[];
//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit()
{
   //-- Проверяем корректность комбинации параметров
   //-- Быстрая средняя не может быть больше медленной
   //if(PeriodFastMa >= PeriodSlowMa)
   //   return INIT_PARAMETERS_INCORRECT;
   //-- Инициализируем периоды кольцевых буферов
   FastMA.SetMaxTotal(PeriodFastMa);
   SlowMA.SetMaxTotal(PeriodSlowMa);
   //-- Загружаем котировки для указанного символа.
   if(LoadRates(Symbol(), Rates)==-1)
   {
      printf("Котировки для символа " + Symbol() + " не найдены. Создайте соответствующий ресурс котировок.");
      return INIT_FAILED;
   }
   printf("Загружено " + (string)ArraySize(Rates) + " баров по символу " + Symbol());
   return(INIT_SUCCEEDED);
}
//+------------------------------------------------------------------+
//| Описываем стратегию                                              |
//+------------------------------------------------------------------+
double OnTester()
{
   for(int i = 1; i < ArraySize(Rates)-1; i++)
   {
      MqlRates bar = Rates[i];
      FastMA.AddValue(Rates[i].close);
      SlowMA.AddValue(Rates[i].close);
      ENUM_POSITION_TYPE pos_type = FastMA.SMA() > SlowMA.SMA() ? POSITION_TYPE_BUY : POSITION_TYPE_SELL;
      //-- Закрываем противоположенные позиции по отношению к текущему сигналу
      for(int k = Trade.Active.Total()-1; k >= 0 ; k--)
      {
         CMposition* pos = Trade.Active.At(k);
         if(pos.PositionType() != pos_type)
            Trade.CloseAtOpenBar(Rates[i+1], k);   
      }
      //-- Если позиции нет, открываем новую заданного направления.
      if(Trade.Active.Total() == 0)
         Trade.EntryAtOpenBar(Rates[i+1], pos_type, 1.0);
   }
   double profit = 0.0;
   for(int i = 0; i < Trade.History.Total(); i++)
   {
      CMposition* pos = Trade.History.At(i);
      profit += pos.Profit();
   }
   return profit;
}
//+------------------------------------------------------------------+

Теперь функция OnTester полностью заполнена. Код устроен крайне просто. По пунктам опишем его работу.

  1. Перебираются все котировки в цикле for.
  2. Внутри цикла определяется текущее направление сделки: покупка, если быстрая SMA выше медленной, и продажа — в обратном случае.
  3. Перебираются все активные сделки, и если их направление не совпадает с текущим направлением, они закрываются.
  4. Если позиции нет, открывается новая позиция заданного направления.
  5. В конце перебора снова перебираются все закрытые позиции и рассчитывается суммарная прибыль по ним, которая возвращается тестеру стратегий.

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

Рис. 5. Выбор оптимизационного поля параметров

В нашем примере 1000 прогонов оптимизации, каждый из которых обрабатывает 1 год минутной истории. Тем не менее, в данном режиме расчет занимает не так много времени. На компьютере с процессором i7 вся оптимизация заняла около 1 минуты, после чего был построен график:

Рис. 6. График 1000 прогонов в режиме "медленная оптимизация".

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

Сохранение пользовательских результатов оптимизации с помощью механизма фреймов

В MetaTrader 5 реализована весьма продвинутая техника работы с пользовательскими данными. Она основана на механизме генерации и получения так называемых фреймов. Это, по сути, обычные бинарные данные, размещенные либо как отдельные значения, либо как массив этих значений. Например, в момент оптимизации в функции OnTester можно сгенерировать массив данных произвольного размера и отправить их тестеру стратегий MetaTrader 5. Данные, содержащиеся в этом массиве, можно прочитать с помощью функции FrameNext, и в дальнейшем обработать — например, вывести на экран. Сама работа с фреймами возможна только в режиме оптимизации и только внутри трех функций: OnTesterInit(), OnTesterDeinit() и OnTesterPass(). Все они не имеют параметров и не возвращают никаких значений. Но все проще, чем может показаться. Чтобы это проиллюстрировать, напишем простой скрипт, показывающий общий алгоритм работы с фреймами:

//+------------------------------------------------------------------+
//|                                               OnTesterSample.mq5 |
//|                        Copyright 2017, MetaQuotes Software Corp. |
//|                                              http://www.mql5.com |
//+------------------------------------------------------------------+
#property copyright "Copyright 2017, MetaQuotes Software Corp."
#property link      "http://www.mql5.com"
#property version   "1.00"
input int Param = 1;
//+------------------------------------------------------------------+
//| OnTesterInit function                                            |
//+------------------------------------------------------------------+
void OnTesterInit()
{
   printf("Начинаем оптимизацию");      
}
//+------------------------------------------------------------------+
//| Здесь происходит прогон стратегии                                |
//+------------------------------------------------------------------+
double OnTester()
{
   uchar data[5] = {1,2,3,4,5};        // Генерируем данные для нашего фрейма
   FrameAdd("sample", 1, Param, data); // Создаем новый фрейм с нашими данными
   return 3.0;
}
//+------------------------------------------------------------------+
//| Здесь можно получить последний добавленный фрейм оптимизации     |
//+------------------------------------------------------------------+
void OnTesterPass()
{
   ulong pass = 0;
   string name = "";
   ulong id = 0;
   double value = 0.0;
   uchar data[];
   FrameNext(pass, name, id, value, data);
   //-- формируем файл прогона и добавляем его в зип-архив
   printf("Получен новый фрейм # " + (string)pass + ". Имя " + (string)name + " ID: " + (string)id + " Значение параметра Param: " + DoubleToString(value, 0));
}
//+------------------------------------------------------------------+
//| OnTesterDeinit function                                          |
//+------------------------------------------------------------------+
void OnTesterDeinit()
{
   printf("Заканчиваем оптимизацию");
}
//+------------------------------------------------------------------+

Запустим этот код в тестере стратегий, выбрав режим математических вычислений. Установим режим медленной оптимизации. Единственный параметр Param будем изменять в пределах от 10 до 90, с шагом 10.

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

2017.12.19 16:58:08.101 OnTesterSample (EURUSD,M15)     Начинаем оптимизацию
2017.12.19 16:58:08.389 OnTesterSample (EURUSD,M15)     Получен новый фрейм # 1. Имя sample ID: 1 Значение параметра Param: 20
2017.12.19 16:58:08.396 OnTesterSample (EURUSD,M15)     Получен новый фрейм # 0. Имя sample ID: 1 Значение параметра Param: 10
2017.12.19 16:58:08.408 OnTesterSample (EURUSD,M15)     Получен новый фрейм # 4. Имя sample ID: 1 Значение параметра Param: 50
2017.12.19 16:58:08.426 OnTesterSample (EURUSD,M15)     Получен новый фрейм # 5. Имя sample ID: 1 Значение параметра Param: 60
2017.12.19 16:58:08.426 OnTesterSample (EURUSD,M15)     Получен новый фрейм # 2. Имя sample ID: 1 Значение параметра Param: 30
2017.12.19 16:58:08.432 OnTesterSample (EURUSD,M15)     Получен новый фрейм # 3. Имя sample ID: 1 Значение параметра Param: 40
2017.12.19 16:58:08.443 OnTesterSample (EURUSD,M15)     Получен новый фрейм # 6. Имя sample ID: 1 Значение параметра Param: 70
2017.12.19 16:58:08.444 OnTesterSample (EURUSD,M15)     Получен новый фрейм # 7. Имя sample ID: 1 Значение параметра Param: 80
2017.12.19 16:58:08.450 OnTesterSample (EURUSD,M15)     Получен новый фрейм # 8. Имя sample ID: 1 Значение параметра Param: 90
2017.12.19 16:58:08.794 OnTesterSample (EURUSD,M15)     Заканчиваем оптимизацию

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

Интересная особенность этого режима — двойной запуск эксперта. Эксперт, в коде которого есть данные обработчики событий, запускается дважды: первый раз в оптимизаторе стратегий, а во второй — на графике, в режиме реального времени. В то время как эксперт в оптимизаторе генерирует новые данные, эксперт, загруженный на графике, их получает. Таким образом, исходные коды эксперта, хоть и находятся в одном месте, но обрабатываются разными экземплярами эксперта. 

Получив данные в функции OnTesterPass, мы можем обработать их любым способом. В тестовом примере эти данные просто выводятся на консоль с помощью функции printf. Но обработка данных, которую нам нужно организовать,  может быть гораздо сложней. Об этом и поговорим в следующем разделе.


Получение байтового представления истории позиций. Сохранение данных в фреймах

Механизм фреймов дает удобный способ сохранения, обработки и распространения информации. Однако нам потребуется сгенерировать саму  эту информацию. В примере выше это был простой статический массив uchar, содержащий значения 1, 2, 3, 4, 5. Пользы от таких данных немного. Но ведь байтовый массив может быть произвольной длины и хранить любые данные. Для этого пользовательские типы данных нужно сконвертировать в байтовый массив типа uchar. Нечто подобное мы уже проделывали с MqlRates, когда сохраняли котировки в байтовый массив. Так же поступим и в случае с нашими данными.

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

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

  • Процедура конвертации пользовательского типа в байтовый массив;
  • Процедура конвертации байтового массива в пользовательский тип.

Мы уже написали торговый модуль CMtrade с двумя коллекциями позиций — активных и исторических. Сосредоточимся только на исторических позициях. Процедуры конвертации виртуальных позиций напишем в виде соответствующих методов.

Метод, конвертирующий позицию в байтовый массив:

//+------------------------------------------------------------------+
//| Конвертирует позицию в байтовое представление в виде массива     |
//+------------------------------------------------------------------+
int CMposition::ToCharArray(int dst_start, uchar &array[])
{
   int offset = dst_start;
   //-- Copy time open position
   type2char.time_value = m_time_open;
   ArrayCopy(array, type2char.char_array, offset, 0, sizeof(datetime));
   offset += sizeof(datetime);
   //-- Copy time close position
   type2char.time_value = m_time_close;
   ArrayCopy(array, type2char.char_array, offset, 0, sizeof(datetime));
   offset += sizeof(datetime);
   //-- Copy price open position
   type2char.double_value = m_price_open;
   ArrayCopy(array, type2char.char_array, offset, 0, sizeof(double));
   offset += sizeof(double);
   //-- Copy price close position
   type2char.double_value = m_price_close;  
   ArrayCopy(array, type2char.char_array, offset, 0, sizeof(double));
   offset += sizeof(double);
   //-- Copy volume position
   type2char.double_value = m_volume; 
   ArrayCopy(array, type2char.char_array, offset, 0, sizeof(double));
   offset += sizeof(double);
   //-- Copy spread symbol
   type2char.int_value = m_spread;
   ArrayCopy(array, type2char.char_array, offset, 0, sizeof(int));
   offset += sizeof(int);
   //-- Copy type of position
   type2char.int_value = m_type;
   ArrayCopy(array, type2char.char_array, offset, 0, sizeof(char));
   offset += sizeof(int);
   //-- return last offset
   return offset;
}

 Обратная процедура:

//+------------------------------------------------------------------+
//| Загружает позицию из байтового массива                           |
//+------------------------------------------------------------------+
int CMposition::FromCharArray(int dst_start, uchar &array[])
{
   int offset = dst_start;
   //-- Copy time open position
   ArrayCopy(type2char.char_array, array, 0, offset, sizeof(datetime));
   m_time_open = type2char.time_value;
   offset += sizeof(datetime);
   //-- Copy time close position
   ArrayCopy(type2char.char_array, array, 0, offset, sizeof(datetime));
   m_time_close = type2char.time_value;
   offset += sizeof(datetime);
   //-- Copy price open position
   ArrayCopy(type2char.char_array, array, 0, offset, sizeof(double));
   m_price_open = type2char.double_value;
   offset += sizeof(double);
   //-- Copy price close position
   ArrayCopy(type2char.char_array, array, 0, offset, sizeof(double));
   m_price_close = type2char.double_value;
   offset += sizeof(double);
   //-- Copy volume position
   ArrayCopy(type2char.char_array, array, 0, offset, sizeof(double));
   m_volume = type2char.double_value;
   offset += sizeof(double);
   //-- Copy spread symbol
   ArrayCopy(type2char.char_array, array, 0, offset, sizeof(int));
   m_spread = type2char.int_value;
   offset += sizeof(int);
   //-- Copy type of position
   ArrayCopy(type2char.char_array, array, 0, offset, sizeof(int));
   m_type = (ENUM_POSITION_TYPE)type2char.int_value;
   offset += sizeof(int);
   //-- return last offset
   return offset;
}

Ядром обоих процедур преобразования является объединение TypeToChar (используется его экземпляр type2char):

//+------------------------------------------------------------------+
//| Конвертация простых типов в байтовый массив                      |
//+------------------------------------------------------------------+
union TypeToChar
{
   uchar    char_array[128];
   int      int_value;
   double   double_value;
   float    float_value;
   long     long_value;
   short    short_value;
   bool     bool_value;
   datetime time_value;
   char     char_value;
};

Всё устроено аналогично объединению RateToByte, рассмотренному в разделе о конвертации котировок. 

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

Сам перебор всех исторических позиций будет осуществлять класс CMTrade. Это логично, учитывая, что именно в нем хранится коллекция исторических позиций. Класс, как и CMposition, работает в двух направлениях: позволяет конвертировать коллекцию исторических позиций в массив uchar, а также выполнять обратную процедуру: загружать список исторических позиций из байтового массива.

Процедура конвертации коллекции в байтовый массив:

//+------------------------------------------------------------------+
//| Конвертирует список исторических позиций в сжатый zip-архив в    |
//| виде байтового массива . Возвращает истину в случае удачи        |
//| и ложь в противном случае.                                       |
//+------------------------------------------------------------------+
bool CMtrade::ToCharArray(uchar &array[])
{
   int total_size = CMposition::Sizeof()*History.Total();
   if(total_size == 0)
   {
      printf(__FUNCTION__ +  ": Received  array is empty");
      return false;
   }
   if(ArraySize(array) != total_size && ArrayResize(array, total_size) != total_size)
   {
      printf(__FUNCTION__ +  ": failed resized received array");
      return false;
   }
   //-- Сохраняем позиции в байтовый поток
   for(int offset = 0, i = 0; offset < total_size; i++)
   {
      CMposition* pos = History.At(i);
      offset = pos.ToCharArray(offset, array);
   }
   return true;
}

Обратная процедура:

//+------------------------------------------------------------------+
//| Загружает список исторических позиций из сжатого zip-архива      |
//| переданного в виде байтового массива. Возвращает истину в случае |
//| удачи и ложь в противном случае.                                 |
//+------------------------------------------------------------------+
bool CMtrade::FromCharArray(uchar &array[], bool erase_prev_pos = true)
{
   if(ArraySize(array) == 0)
   {
      printf(__FUNCTION__ +  ": Received  array is empty");
      return false;
   }
   //-- Размер байтового потока в точности должен соответствовать байтовому представлению позиций
   int pos_total = ArraySize(array)/CMposition::Sizeof();
   if(ArraySize(array)%CMposition::Sizeof() != 0)
   {
      printf(__FUNCTION__ +  ": Wrong size of received  array");
      return false;
   }
   if(erase_prev_pos)
      History.Clear();
   //-- Восстанавливаем из байтового потока все позиции
   for(int offset = 0; offset < ArraySize(array);)
   {
      CMposition* pos = new CMposition();
      offset = pos.FromCharArray(offset, array);
      History.Add(pos);
   }
   return History.Total() > 0;
}

Чтоб собрать все элементы вместе, нам достаточно получить байтовое представление исторических позиций в конце прогона и сохранить их в фрейме:

//+------------------------------------------------------------------+
//| Описываем стратегию                                              |
//+------------------------------------------------------------------+
double OnTester()
{
   for(int i = 1; i < ArraySize(Rates)-1; i++)
   {
      MqlRates bar = Rates[i];
      FastMA.AddValue(Rates[i].close);
      SlowMA.AddValue(Rates[i].close);
      ENUM_POSITION_TYPE pos_type = FastMA.SMA() > SlowMA.SMA() ? POSITION_TYPE_BUY : POSITION_TYPE_SELL;
      //-- Закрываем противоположенные позиции по отношению к текущему сигналу
      for(int k = Trade.Active.Total()-1; k >= 0 ; k--)
      {
         CMposition* pos = Trade.Active.At(k);
         if(pos.PositionType() != pos_type)
            Trade.CloseAtOpenBar(Rates[i+1], k);   
      }
      //-- Если позиции нет, открываем новую заданного направления.
      if(Trade.Active.Total() == 0)
         Trade.EntryAtOpenBar(Rates[i+1], pos_type, 1.0);
   }
   uchar array[];
   //-- Получаем байтовое представление исторических позиций
   Trade.ToCharArray(array); 
   //-- Загружаем байтовое представление в фрейм и передаем его для дальнейшей обработки
   FrameAdd(MTESTER_STR, MTESTER_ID, 0.0, array);  
   return Trade.History.Total();
}

После того, как фрейм сформирован и отправлен в процедуру обработки OnTesterPass(), необходимо подумать о том, что делать с ним дальше. Мы уже говорили, что наш тестер стратегий состоит из двух частей: блока генерации данных и блока анализа собранной информации. Для такого анализа нам необходимо все сгенерированные фреймы сохранить в удобном и экономичном формате, чтобы позже можно было так же удобно проанализировать этот формат. Для этого можно воспользоваться zip-архивом. Во-первых, он эффективно сжимает данные — а значит, информация даже о тысяче сделок займет не так много места. Во-вторых, он предоставляет удобную файловую систему. Каждый прогон может храниться в виде отдельного файла внутри единственного zip-архива.

Итак, напишем процедуру конвертации байтового содержимого фрейма в zip-архив.

//+------------------------------------------------------------------+
//| Добавляем каждый новый прогон в zip-архив                        |
//+------------------------------------------------------------------+
void OnTesterPass()
{
   ulong pass = 0;
   string name = "";
   ulong id = 0;
   double value = 0.0;
   uchar data[];
   FrameNext(pass, name, id, value, data);
   //-- формируем файл прогона и добавляем его в зип-архив
   printf("Получен новый фрейм размером " + (string)ArraySize(data));
   string file_name = name + "_" + (string)id + "_" + (string)pass + "_" + DoubleToString(value, 5)+".hps";
   CZipFile* zip_file = new CZipFile(file_name, data);
   Zip.AddFile(zip_file);
}

Благодаря тому, что класс для работы с zip архивами достаточно мощный и обладает универсальными методами, добавлять новый прогон в виде отдельного файла в архив крайне просто. По сути, в OnTesterPass добавляется новый zip-файл в архив Zip, объявленный на глобальном уровне:

CZip     Zip;     // Zip-архив, который мы будем заполнять проходами оптимизации

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

После окончания оптимизации сформированный zip-архив нужно просто сохранить в виде соответствующего zip-файла. Сделать это также очень просто. Это выполняется в процедуре OnTesterDeinit():

//+------------------------------------------------------------------+
//| Сохраняем zip-архив всех прогонов на жесткий диск компьютера     |
//+------------------------------------------------------------------+
void OnTesterDeinit()
{
   Zip.SaveZipToFile(OptimizationFile, FILE_COMMON);
   string f_totals = (string)Zip.TotalElements();
   printf("Оптимизация завершена. Всего прогонов оптимизации сохранено: " + f_totals);
}

Здесь OptimizationFile — строковый пользовательский параметр, задающий имя оптимизации. По умолчанию он равен "Optimization.zip". Таким образом, после завершения оптимизации нашей обновленной стратегии SmaSample будет записан соответствующий zip-архив. Найдем его в папке Files и откроем стандартными средствами:

Рис. 7. Внутреннее содержимое файла оптимизации

Как видно, все сохраненные прогоны отлично хранятся, показывая высокую степень сжатия от 3 до 5 раз. 

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


Создание анализатора стратегий

В предыдущем разделе мы создали zip-архив, включающий в себя информацию обо всех прогонах. Теперь надо обработать эту информацию. Для этого специально создадим программу M-Tester Analyzer. Она будет загружать сгенерированный архив и отображать каждый прогон в виде удобного графика баланса. Также M-Tester Analyzer будет рассчитывать сводную статистику для выбранного прогона.

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

Последовательность действий анализатора:

  1. Загрузка выбранного архива оптимизации
  2. Выбор одного из прогонов оптимизации в этом архиве
  3. Построение графика динамики виртуального баланса, на основе имеющихся сделок
  4. Расчет основной статистики прогона, включающую такие параметры, как количество сделок, общая прибыль, общий убыток, профит-фактор, математическое ожидание и пр.
  5. Вывод рассчитанной статистики в виде таблицы в главном окне программы.

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

Программа будет базироваться на графическом движке CPanel. На сегодняшний момент нет отдельной статьи об этой библиотеке, однако она проста в освоении, компактна и уже неоднократно использовалась в различных проектах и статьях.

Основной код нашего анализатора размещается в классе CAnalizePanel, производном от CElChart. Сам анализатор выполнен в виде эксперта. Основной файл эксперта запускает графическое окно анализатора. Вот основной  файл эксперта:

//+------------------------------------------------------------------+
//|                                                    mAnalizer.mq5 |
//|                        Copyright 2017, MetaQuotes Software Corp. |
//|                                              http://www.mql5.com |
//+------------------------------------------------------------------+
#include "mAnalizerPanel.mqh"
CAnalyzePanel Panel;
input string FilePasses = "Optimization.zip";
//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit()
  {
//---
   Panel.Width(800);
   Panel.Height(630);
   Panel.XCoord(10);
   Panel.YCoord(20);
   Panel.LoadFilePasses(FilePasses);
   Panel.Show();
   ChartRedraw();
//---
   return(INIT_SUCCEEDED);
  }
//+------------------------------------------------------------------+
//| Expert deinitialization function                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
{
   Panel.Hide();
}

//+------------------------------------------------------------------+
//| ChartEvent function                                              |
//+------------------------------------------------------------------+
void OnChartEvent(const int id,
                  const long &lparam,
                  const double &dparam,
                  const string &sparam)
{
   switch(id)
   {
      case CHARTEVENT_OBJECT_ENDEDIT:
      {
         CEventChartEndEdit event(sparam);
         Panel.Event(&event);
         break;
      }
      case CHARTEVENT_OBJECT_CLICK:
      {
         CEventChartObjClick event(sparam);
         Panel.Event(&event);
         break;
      }
   }
   ChartRedraw();
}
//+------------------------------------------------------------------+

Как видно, код крайне простой. Создается объект типа CAnalyzePanel. Затем в методе OnInit задаются его размеры, после чего он отображается на текущем графике (метод Show). Из всех событий, поступающих с графика, нас интересуют только два: окончание ввода текста и нажатие на графический объект. Эти события конвертируются в специальный объект типа CEvent и передаются нашей панели (Panel.Event(...)). Панель анализатора получает эти события и обрабатывает их.

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

//+------------------------------------------------------------------+
//|                                                    mAnalizer.mq5 |
//|                        Copyright 2017, MetaQuotes Software Corp. |
//|                                              http://www.mql5.com |
//+------------------------------------------------------------------+
#include <Panel\ElChart.mqh>
#include <Panel\ElButton.mqh>
#include <Graphics\Graphic.mqh>
#include "ListPass.mqh"
#include "TradeAnalyze.mqh"
//+------------------------------------------------------------------+
//| Панель анализатора прогонов математического анализатора          |
//+------------------------------------------------------------------+
class CAnalizePanel : public CElChart
{
private:
   //-- Массивы элементов и их статистика
   CArrayObj      m_stat_descr;     // Описание статистик
   CArrayObj      m_stat_all;       // Значение статистик для всех сделок
   CArrayObj      m_stat_long;      // Значение статистик для длинных сделок
   CArrayObj      m_stat_short;     // Значение статистик для коротких сделок
   CTradeAnalize  m_analize;        // Модуль расчета статистики
   //-- Графические элементы
   CElChart       m_name_analyze;   // Имя главного окна
   CElChart       m_np;             // Надпись "Pass #"
   CElChart       m_of_pass;        // Надпись "of ### passes"
   CElChart       m_pass_index;     // Окно ввода номера прогона
   CElButton      m_btn_next;       // Кнопка "следующий прогон"
   CElButton      m_btn_prev;       // Кнопка "предыдущий прогон"
   CGraphic       m_graphic;        // График динамики баланса
   //-- Инфраструктура
   CListPass      m_passes;         // Список прогонов
   int            m_curr_pass;      // Индекс текущего прогона
   CCurve*        m_balance_hist;   // Линия динамики баланса на графике
   bool           IsEndEditPass(CEvent* event);
   bool           IsClick(CEvent* event, CElChart* el);
   void           NextPass(void);
   void           PrevPass(void);
   int            GetCorrectPass(string text);
   void           RedrawGraphic(void);
   void           RedrawCurrPass(void);
   void           PlotStatistic(void);
   string         TypeStatToString(ENUM_MSTAT_TYPE type);
   void           CreateStatElements(void);
   string         ValueToString(double value, ENUM_MSTAT_TYPE type);
public:
                  CAnalizePanel(void);
   bool           LoadFilePasses(string file_name, int file_common = FILE_COMMON);
   virtual void   OnShow();
   virtual void   OnHide();
   virtual void   Event(CEvent *event);
};

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

  1. Создание графика и добавление на него линии баланса.
  2. Создание текстовых меток в виде элементов CElChart, которые отображают статистику тестирования.
  3. Собственно расчет статистики прогона.

Вкратце опишем каждый из этих разделов. 

Нужно создать достаточно много элементов, чтобы отобразить всю собранную статистику по каждому прогону. Наш анализатор отображает десять основных статистических параметров. Кроме того, каждый параметр отдельно рассчитывается для всех сделок, только для покупок и только для продаж. Помимо, нужны еще 10 текстовых меток, отображающих названия показателей. Таким образом, требуется создать 40 текстовых меток. Чтобы не создавать каждый элемент вручную, напишем  процедуру автоматизации. Для этого каждому определяемому статистическому параметру дается свой идентификатор в специальном перечислении:

//+------------------------------------------------------------------+
//| Идентификаторы типа статистического значения                     |
//+------------------------------------------------------------------+
enum ENUM_MSTAT_TYPE
{
   MSTAT_PROFIT,
   MSTAT_ALL_WINS_MONEY,
   MSTAT_ALL_LOSS_MONEY,
   MSTAT_TRADERS_TOTAL,
   MSTAT_WIN_TRADERS,
   MSTAT_LOSS_TRADERS,   
   MSTAT_MAX_PROFIT,
   MSTAT_MAX_LOSS,
   MSTAT_PROFIT_FACTOR,
   MSTAT_MATH_EXP,   
};
#define MSTAT_ELEMENTS_TOTAL 10

Также определим идентификатор для направления расчета:

//+------------------------------------------------------------------+
//| Статистика может быть рассчитана для одного из трех направлений  |
//+------------------------------------------------------------------+
enum ENUM_MSTATE_DIRECT
{
   MSTATE_DIRECT_ALL,      // Для всех сделок
   MSTATE_DIRECT_LONG,     // Только для покупок
   MSTATE_DIRECT_SHORT,    // Только для продаж
};

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

  • Элементы, отображающие название статистики (массив m_stat_descr)
  • Элементы, отображающие значения статистик для всех сделок (массив m_stat_all)
  • Элементы, отображающие значения статистик для длинных сделок (массив m_stat_long)
  • Элементы, отображающие значения статистик для коротких сделок (массив m_stat_short)

Все эти элементы создаются в момент первого запуска в методе CAnalyzePanel::CreateStatElements(void).

После того, как элементы созданы, их надо заполнить правильными значениями. Расчет этих значений делегируется внешнему классу CTradeAnalize:

#include <Arrays\ArrayObj.mqh>
#include <Dictionary.mqh>
#include "..\MTester\Mposition.mqh"
//+------------------------------------------------------------------+
//| Вспомогательный элемент, содержащий необходимые поля             |
//+------------------------------------------------------------------+
class CDiffValues : public CObject
{
public:
   double all;
   double sell;
   double buy;
   CDiffValues(void) : all(0), buy(0), sell(0)
   {
   }
};
//+------------------------------------------------------------------+
//| Класс статистического анализа                                    |
//+------------------------------------------------------------------+
class CTradeAnalize
{
private:
   CDictionary m_values;
   
public:
   void     CalculateValues(CArrayObj* history);
   double   GetStatistic(ENUM_MSTAT_TYPE type, ENUM_MSTATE_DIRECT direct);
};
//+------------------------------------------------------------------+
//| Рассчитывает значение статистики                                 |
//+------------------------------------------------------------------+
double CTradeAnalize::GetStatistic(ENUM_MSTAT_TYPE type, ENUM_MSTATE_DIRECT direct)
{
   CDiffValues* value = m_values.GetObjectByKey(type);
   switch(direct)
   {
      case MSTATE_DIRECT_ALL:
         return value.all;
      case MSTATE_DIRECT_LONG:
         return value.buy;
      case MSTATE_DIRECT_SHORT:
         return value.sell;
   }
   return EMPTY_VALUE;
}
//+------------------------------------------------------------------+
//| Рассчитывает количество сделок для каждого из направления        |
//+------------------------------------------------------------------+
void CTradeAnalize::CalculateValues(CArrayObj *history)
{
   m_values.Clear();
   for(int i = 0; i < MSTAT_ELEMENTS_TOTAL; i++)
      m_values.AddObject(i, new CDiffValues());
   CDiffValues* profit = m_values.GetObjectByKey(MSTAT_PROFIT);
   CDiffValues* wins_money = m_values.GetObjectByKey(MSTAT_ALL_WINS_MONEY);
   CDiffValues* loss_money = m_values.GetObjectByKey(MSTAT_ALL_LOSS_MONEY);
   CDiffValues* total_traders = m_values.GetObjectByKey(MSTAT_TRADERS_TOTAL);
   CDiffValues* win_traders = m_values.GetObjectByKey(MSTAT_WIN_TRADERS);
   CDiffValues* loss_traders = m_values.GetObjectByKey(MSTAT_LOSS_TRADERS);
   CDiffValues* max_profit = m_values.GetObjectByKey(MSTAT_MAX_PROFIT);
   CDiffValues* max_loss = m_values.GetObjectByKey(MSTAT_MAX_LOSS);
   CDiffValues* pf = m_values.GetObjectByKey(MSTAT_PROFIT_FACTOR);
   CDiffValues* mexp = m_values.GetObjectByKey(MSTAT_MATH_EXP);
   total_traders.all = history.Total();
   for(int i = 0; i < history.Total(); i++)
   {
      CMposition* pos = history.At(i);
      profit.all += pos.Profit();
      if(pos.PositionType() == POSITION_TYPE_BUY)
      {
         if(pos.Profit() > 0)
         {
            win_traders.buy++;
            wins_money.buy += pos.Profit();
         }
         else
         {
            loss_traders.buy++;
            loss_money.buy += pos.Profit();
         }
         total_traders.buy++;
         profit.buy += pos.Profit();
      }
      else
      {
         if(pos.Profit() > 0)
         {
            win_traders.sell++;
            wins_money.sell += pos.Profit();
         }
         else
         {
            loss_traders.sell++;
            loss_money.sell += pos.Profit();
         }
         total_traders.sell++;
         profit.sell += pos.Profit();
      }
      if(pos.Profit() > 0)
      {
         win_traders.all++;
         wins_money.all += pos.Profit();
      }
      else
      {
         loss_traders.all++;
         loss_money.all += pos.Profit();
      }
      if(pos.Profit() > 0 && max_profit.all < pos.Profit())
         max_profit.all = pos.Profit();
      if(pos.Profit() < 0 && max_loss.all > pos.Profit())
         max_loss.all = pos.Profit();
   }
   mexp.all = profit.all/total_traders.all;
   mexp.buy = profit.buy/total_traders.buy;
   mexp.sell = profit.sell/total_traders.sell;
   pf.all = wins_money.all/loss_money.all;
   pf.buy = wins_money.buy/loss_money.buy;
   pf.sell = wins_money.sell/loss_money.sell;
}

Собственно расчетом занимается метод CalculateValues. Для его работы нужно передать массив CArrayObj, содержащий элементы CMposition. Но откуда берется сам массив этих виртуальных позиций?

Дело в том, что в классе CAnalyzePanel есть еще один класс — CListPass. Именно он загружает zip-архив и создает коллекцию прогонов. Этот класс очень просто устроен:

//+------------------------------------------------------------------+
//|                                                    Optimazer.mq5 |
//|                        Copyright 2017, MetaQuotes Software Corp. |
//|                                              http://www.mql5.com |
//+------------------------------------------------------------------+
#include <Zip\Zip.mqh>
#include <Dictionary.mqh>
#include "..\MTester\MTrade.mqh"
//+------------------------------------------------------------------+
//| Хранит список оптимизаций                                        |
//+------------------------------------------------------------------+
class CListPass
{
private:
   CZip        m_zip_passes;  // Архив всех прогонов оптимизаций
   CDictionary m_passes;      // Уже загруженные исторические позиции   
public:
   bool        LoadOptimazeFile(string file_name, int file_common = FILE_COMMON);
   int         PassTotal(void);
   CArrayObj*  PassAt(int index);
};
//+------------------------------------------------------------------+
//| Загружает список оптимизаций из zip-архива                       |
//+------------------------------------------------------------------+
bool CListPass::LoadOptimazeFile(string file_name,int file_common=FILE_COMMON)
{
   m_zip_passes.Clear();
   if(!m_zip_passes.LoadZipFromFile(file_name, file_common))
   {     
      printf("Failed load optimization file. Last Error");
      return false;
   }
   return true;
}
//+------------------------------------------------------------------+
//| Количество прогонов                                              |
//+------------------------------------------------------------------+
int CListPass::PassTotal(void)
{
   return m_zip_passes.TotalElements();
}
//+------------------------------------------------------------------+
//| Возвращает список сделок прогона с номером index                 |
//+------------------------------------------------------------------+
CArrayObj* CListPass::PassAt(int index)
{
   if(!m_passes.ContainsKey(index))
   {
      CZipFile* zip_file = m_zip_passes.ElementAt(index);
      uchar array[];
      zip_file.GetUnpackFile(array);
      CMtrade* trade = new CMtrade();
      trade.FromCharArray(array);
      m_passes.AddObject(index, trade);
   }
   CMtrade* trade = m_passes.GetObjectByKey(index);
   //printf("Total Traders: " + (string)trade.History.Total());
   return &trade.History;
}

Как видно, класс CListPass загружает архив оптимизации, но не распаковывает его. Это значит, что даже в памяти компьютера все неиспользуемые данные оптимизации хранятся в сжатом виде, благодаря чему экономится оперативная память компьютера. Запрошенный прогон распаковывается и преобразуется в объект CMtrade, после чего он уже сохраняется во внутреннее хранилище в распакованном виде. При обращении к этому элементу в следующий раз повторная распаковка уже не потребуется.

Снова обратимся к классу CAnalyzePanel. Мы разобрались с тем, откуда загружаются позиции (класс CListPass) и как рассчитывается статистика по ним (класс CTradeAnalyze). После создания графических элементов осталось заполнить их нужными значениями. Этим занимается метод CAnalyzePanel::PlotStatistic(void):

//+------------------------------------------------------------------+
//| Отображает статистику                                            |
//+------------------------------------------------------------------+
void CAnalyzePanel::PlotStatistic(void)
{
   if(m_stat_descr.Total() == 0)
      CreateStatElements();
   CArrayObj* history = m_passes.PassAt(m_curr_pass-1);
   m_analize.CalculateValues(history);
   for(int i = 0; i < MSTAT_ELEMENTS_TOTAL; i++)
   {
      ENUM_MSTAT_TYPE stat_type = (ENUM_MSTAT_TYPE)i;
      //-- all traders
      CElChart* el = m_stat_all.At(i);
      string v = ValueToString(m_analize.GetStatistic(stat_type, MSTATE_DIRECT_ALL), stat_type);
      el.Text(v);
      //-- long traders
      el = m_stat_long.At(i);
      v = ValueToString(m_analize.GetStatistic(stat_type, MSTATE_DIRECT_LONG), stat_type);
      el.Text(v);
      //-- short traders
      el = m_stat_short.At(i);
      v = ValueToString(m_analize.GetStatistic(stat_type, MSTATE_DIRECT_SHORT), stat_type);
      el.Text(v);
   }
}

Мы рассмотрели все основные элементы, необходимые для работы панели нашего анализатора. Описание получилось непоследовательным, но такова суть программирования: все элементы взаимосвязаны друг с другом, и порою все их приходится описывать одновременно.

Итак, настало время запустить анализатор на графике. Сделаем это, но прежде удостоверьтесь, что zip-архив оптимизации присутствует в директории FILE_COMMON. По умолчанию, анализатор загружает файл "Optimization.zip", и именно он должен находится в общей директории.

Наибольший эффект от реализованных возможностей можно увидеть в момент переключения прогонов. График и статистика при этом автоматически обновляются. На скриншоте внизу запечатлен такой момент:


Рис. 8. Переключение прогонов в анализаторе математических вычислений

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


Рис. 9. Основные элементы интерфейса

В заключение опишем структуру нашего получившегося проекта. Все исходные коды находятся в архиве MTester.zip. Сам проект находится в папке MQL5\Experts\MTester. Однако, как и любая сложная программа, проект требует подключения дополнительных библиотек. Те их них, что не включены в стандартную поставку MetaTrader 5, присутствуют в этом архиве в папке MQL5\Include. В первую очередь, это графическая библиотека CPanel (ее местоположение: MQL5\Include\Panel). Также это библиотека для работы с zip-архивами (MQL5\Include\Zip) и класс для организации ассоциативного массива (MQL5\Include\Dictionary). Также для удобства пользователей были созданы два MQL5-проекта. Это новая возможность MetaTrader 5, которая появилась совсем недавно. Первый проект называется MTester и содержит собственно тестер стратегий и саму стратегию, основанную на пересечении скользящих средних (SmaSample.mq5). Второй проект называется MAnalyzer и содержит исходный код панели анализатора.

Помимо исходных кодов, архив содержит файл оптимизации Optimization.zip, содержащий около 160 проходов стратегии на тестовых данных. Это поможет быстро проверить функциональность анализатора прогонов, без необходимости проводить новую оптимизацию. Файл располагается в папке MQL5\Files.


Заключение

В заключение дадим краткие тезисы материалов, описанных в статье.

  • Тестер математических вычислений обладает высокой скоростью за счет отсутствия имитации торгового окружения. Благодаря этому, на его основе можно написать свой высокопроизводительный алгоритм тестирования простых торговых стратегий. Однако из-за отсутствия всякого контроля за корректностью совершения торговых операций можно непреднамеренно "заглянуть в будущее" — обратиться к котировкам, которые еще не пришли. Ошибки в таких "граалях" могут идентифицироваться достаточно тяжело, однако такова цена за высокую производительность.
  • Находясь в тестере математических вычислений, невозможно получить доступ к торговому окружению. Следовательно, нельзя и получить котировки желаемого инструмента. Поэтому в этом режиме приходится самостоятельно и заблаговременно загружать необходимые данные, а также пользоваться своими библиотеками для расчета индикаторов. В статье было показано, как подготовить данные, эффективно их сжать и внедрить в исполняющий модуль программы. Эта техника также может пригодится всем тем, кто хочет распространять со своей программой дополнительные данные для ее работы.
  • Доступ к стандартным индикаторам в режиме математических вычислений также закрыт. Поэтому приходится выполнять расчет нужных индикаторов самостоятельно. Однако скорость также очень важна. Поэтому самостоятельный расчет индикаторов внутри эксперта — не только единственное, но и наилучшее решение в плане скорости. К счастью, библиотека кольцевых буферов может предоставить эффективный расчет необходимых индикаторов за константное время.
  • Режим генерации фреймов в MetaTrader 5 — мощный, хотя и непростой механизм, дающий пользователю огромные возможности в области написания своих алгоритмов анализа. Например, можно написать свой тестер стратегий, что и было сделано в данной статье. Для использования возможностей режима генерации фреймов в полной мере нужно уметь работать с бинарными данными. Именно благодаря возможности работы с этим типом данных, становится возможно генерировать данные сложного типа — например, список позиций. В статье было показано: как создать свой сложный пользовательский тип данных (класс позиции CMPosition); как сконвертировать его в байтовое представление и добавить его в фрейм; как получить из фрейма массив байтов и преобразовать их обратно в пользовательский список позиций.
  • Одна из важнейших частей тестера стратегий — система хранения данных. Очевидно, что данных получаемых при тестировании, очень много: каждое тестирование может включать в себя сотни и даже тысячи прогонов, а каждый прогон включает в себя множество сделок, которое легко может доходить до нескольких десятков тысяч. От того, насколько эффективно будет храниться и распространяться эта информация, зависит успех всего проекта. Поэтому был выбран zip-архив. Благодаря тому, что для MQL5 имеется мощная и быстрая библиотека для работы с этим типом файлов, становится легко организовать свое собственное файловое хранилище прогонов оптимизаций. Каждая оптимизация — это один zip-файл, содержащий все прогоны. Каждый прогон представлен одним сжатым файлом. За счет сжатия достигается высокая компрессия данных, благодаря чему даже масштабная оптимизация имеет весьма скромные размеры.
  • Написать свой тестер стратегий недостаточно. Также необходима отдельная подсистема, анализирующая эти результаты оптимизации. Такая подсистема реализована нами в виде программы M-Tester Analyzer. Это отдельный программный модуль, который загружает результаты оптимизации в виде zip-архива и выводит их на график, также отображая основную статистику для каждого прогона. M-Tester Analyzer базируется на нескольких классах и графической библиотеке CPanel. Это простая и удобная библиотека, с помощью которой можно достаточно быстро построить мощный графический интерфейс. Благодаря наличию системной библиотеки CGraphic, анализатор выводит информативный график динамики баланса. 

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

  • Информация о символе (название, стоимость одного тика, своп и спред и т.д.). Это информация необходима для расчета возможных комиссионных, спреда и свопа. Понадобится она и для расчета прибыли в валюте депозита (сейчас прибыль считается в пунктах).
  • Информация о стратегии и ее параметрах для каждого прогона. Необходимо знать не только результат стратегии, но и значение всех ее параметров. Для этого в генерируемый отчет также необходимо внести дополнительный тип данных.
  • Контроль корректности производимых действий. На данном этапе достаточно легко "заглянуть в будущее", благодаря чему будет получен "грааль", не имеющий ничего общего с реальностью. В будущих версиях необходим механизм хотя бы минимального контроля. Однако пока сложно определить, как он должен выглядеть.
  • Интеграция механизма генерации отчета с реальным тестером стратегий. Ничто не мешает конвертировать результат, полученный в обычном тестере стратегий MetaTrader 5 в разработанный нами формат отчета. Это даст возможность анализировать результаты надежного тестирования с помощью M-Trade Analyzer. Таким образом, у нас будет несколько систем тестирования и одна система анализа.
  • Дальнейшее развитие M-Trade Analayzer. Сейчас программа анализа обладает лишь базовым функционалом. Его явно недостаточно для полноценной обработки данных. Необходимо добавить дополнительные статистики и отдельные графики баланса как для продаж, так и для покупок. Также было бы неплохо научиться сохранять историю сделок в текстовый файл, и затем загружать его допустим в Excel.

Мы рассмотрели все основные аспекты M-Tester, а также дальнейшие перспективы его развития. Если предложенная тема окажется достаточно интересной, эта статья увидит продолжение. Многое было сделано, но еще большее предстоит сделать. Будем надеяться, что время для новой версии M-Tester наступит очень скоро!
Прикрепленные файлы |
MTester.zip (12282.19 KB)
Последние комментарии | Перейти к обсуждению на форуме трейдеров (29)
Vasiliy Sokolov
Vasiliy Sokolov | 29 дек 2017 в 14:05
fxsaber:

Вы не поняли меня. Вы предлагаете ТС для теста писать на своем торговом API специально для этого. А это равносильно использованию других тестерных решений.

Теоретически возможно использовать единое API. Единственное абстрактное API, которое сейчас имеется это CStrategy. Вот его поддержку и можно реализовать в математическом тестере. Но даже если реализовывать поддержку базовых вещей - это очень сложная задача. Поэтому таки-да, сейчас да и в обозримом будущем API разное, следовательно и ТС придется писать дважды. Однако сравнивать со сторонними тестерами не совсем корректно, потому что все-таки все расчеты происходят в инфраструктуре MetaTrader и блок анализа един. Т.е. всегда можно прогнать ТС в стандартном тестере и даже на демке и сравнить отчет с таковым в мат. тестере. Может быть в следующей части интегрирую отчетность с стандартным тестером МТ.
fxsaber
fxsaber | 29 дек 2017 в 14:11

Vasiliy Sokolov:
Теоретически возможно использовать единое API. Единственное абстрактное API, которое сейчас имеется это CStrategy. Вот его поддержку и можно реализовать в математическом тестере.

Тогда это будет решение только для Вас. Если реализовать, то либо MQ4/5, либо СБ на крайний случай.

Но даже если реализовывать поддержку базовых вещей - это очень сложная задача. Поэтому таки-да, сейчас да и в обозримом будущем API разное, следовательно и ТС придется писать дважды.

Это и фигово. Писать ТС на одном API, затем переписывать на другой и искать, а где же расхождения.

Однако сравнивать со сторонними тестерами не совсем корректно, потому что все-таки все расчеты происходят в инфраструктуре MetaTrader и блок анализа един. Т.е. всегда можно прогнать ТС в стандартном тестере и даже на демке и сравнить отчет с таковым в мат. тестере. Может быть в следующей части интегрирую отчетность с стандартным тестером МТ.

Инфраструктура MT здесь - это Агенты и GUI задания параметров. В общем, сомнительно.


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

Andrey Khatimlianskii
Andrey Khatimlianskii | 2 янв 2018 в 03:00

Грандиозная работа проделана! Мои замечания ниже.

Режим мат. вычислений не подходит для решения поставленной задачи по нескольким причинам.

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

О вопросах еще одного торгового API высказался fxsaber, полностью с ним согласен. Решение должно быть универсальным, иначе рискует быть даже не невостребованным, а не опробованным. Ну, а необходимость переписывания индикаторов (в том числе, стандартных) для простой проверки идеи, ставит на этом подходе жирный крест.

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


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

Именно в этом направлении, на мой взгляд, стоит продолжать цикл статей.

Спасибо за работу!

Carl Schreiber
Carl Schreiber | 1 мар 2018 в 12:01

Хотя Рождество прошло давно, я бы хотел, чтобы ваш тестер стратегий мог читать и обрабатывать данные локального тика в формате ASCII.
Данные по тику выросли до ~ 400 ГБ на внешнем USB-HD с помощью бесплатного TickDownloader со мной - который я бы хотел использовать - другие, вероятно, также - чтобы не зависели от разных брокеры. Возможно также с возможностью одновременного использования нескольких символов (арбитраж, корзины, ...).
Это также было бы интересно для MT4, потому что он не может этого сделать!

Although Christmas is long gone, I would wish that your strategy tester could read and process the local tick data in ASCII format.
The tick data has grown to ~400 GB on an external USB-HD by the free https://strategyquant.com/tickdownloader# with me - which I would like to use - others probably also - in order not to be dependent on different brokers. Possibly also with the possibility to use several symbols at the same time (arbitrage, baskets,...).
That would also be interesting for the MT4, because it can't do that!

(by google translate)

Calli

PA: Anyway thanks for this interesting approach!

Aleksey Vyazmikin
Aleksey Vyazmikin | 15 июл 2018 в 02:00

Спасибо за интересную статью.

Считаю, что тестеру необходимы стандартные торговые функции - открытие/закрытие/TP/SL, которые можно было бы легко добавить в любой советник и получить приблизительный результат без сверх усилий.

Относительно индикаторов, тут опять же необходимо реализовать возможность загрузки показателей индикаторов из файла/файлов, и сделать это так, что бы у агентов оставался этот файл, а не передавался постоянно (если этого нет). Соответственно реализовать при инициализации подмен хендла с индикатора на массив файла с готовыми расчетами индикатора. И, если большое число таких массивов будет работать достаточно быстро, то в этом действительно есть прок.

Сам режим "Математические вычисления" следует, видимо, рассматривать как урезанный аналог скрипта, который нужен для каких либо вычислений не связанных с индикаторами.

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

Как снизить риски трейдера Как снизить риски трейдера

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

Создаем новую торговую стратегию с использованием технологии разложения входов на индикаторы Создаем новую торговую стратегию с использованием технологии разложения входов на индикаторы

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

Паттерн прорыва канала Паттерн прорыва канала

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

Тестирование паттернов, возникающих при торговле корзинами валютных пар. Часть III Тестирование паттернов, возникающих при торговле корзинами валютных пар. Часть III

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