Большой пример эксперта

Для обобщения и закрепления знаний о возможностях тестера рассмотрим поэтапно большой пример эксперта, в котором сведем воедино:

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

За техническую основу для эксперта возьмем MultiMartingale.mq5, но сделаем его менее рисковым за счет переключения на торговлю по мультивалютным сигналам перекупленности/перепроданности и увеличения лотов только в качестве опционального дополнения. Схема работы по торговым сигналам индикатора у нас уже обкатана на примере BandOsMA.mq5, но теперь в качестве сигнального индикатора выступит UseUnityPercentPro.mq5. Правда, сначала нам потребуется слегка его модифицировать. Новую версию назовем UnityPercentEvent.mq5, и суффикс "Event" здесь неспроста.

UnityPercentEvent.mq5

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

  • дальнейший пробой (подтверждение и продолжение сильного движения в стороны);
  • отскок (разворот движения к центру из-за перекупленности и перепроданности).

Для торговли любого из этих сигналов мы должны составить рабочий инструмент из двух валют (или вообще тикеров), если для данного сочетания есть что-то подходящее в Обзоре рынка. Например, если верхняя линия индикатора принадлежит EUR, а нижняя — USD, им соответствует пара EURUSD, и по стратегии на пробой мы должны её купить, а по стратегии на отскок — продать.

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

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

Дело в том, что MQL5 не позволяет прочитать названия буферов стороннего индикатора, да и вообще любые свойства линий кроме целочисленных: для установки свойств имеется полная тройка функций — PlotIndexSetInteger, PlotIndexSetDouble, PlotIndexSetString, а вот для чтения — только одна PlotIndexGetInteger. Таково ограничение платформы, по крайней мере, сейчас.

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

Одно из возможных решений — рассылка индикатором сообщений с номерами и названиями буферов после инициализации.

Вот как это сделано в обработчике OnInit индикатора UnityPercentEvent.mq5 (приведено с сокращениями, так как почти ничего не изменилось).

int OnInit()
{
   // находим общую валюту у всех пар
   const string common = InitSymbols();
   ...
   // настраиваем отображаемые линии в цикле по валютам
   int replaceIndex = -1;
   for(int i = 0i <= SymbolCounti++)
   {
      string name;
      // порядок меняем так, чтобы базовая (общая) валюта шла под индексом 0,
      // остальные зависят от порядка ввода пар пользователем
      if(i == 0)
      {
         name = common;
         if(name != workCurrencies.getKey(i))
         {
            replaceIndex = i;
         }
      }
      else
      {
         if(common == workCurrencies.getKey(i) && replaceIndex > -1)
         {
            name = workCurrencies.getKey(replaceIndex);
         }
         else
         {
            name = workCurrencies.getKey(i);
         }
      }
    
      // настраиваем отрисовку буферов
      PlotIndexSetString(iPLOT_LABELname);
      ...
      // рассылаем индексы и названия буферов для программ, где они нужны
      EventChartCustom(0, (ushort)BarLimitiSymbolCount + 1name);
   }
   ...
}

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

Теперь индикатор готов для того, чтобы использовать его сигналы в сторонних экспертах. Начнем разработку эксперта UnityMartingale.mq5. Чтобы упростить изложение, разобьем его на 4 этапа, постепенно добавляя новые блоки. У нас получится три предварительных версии и одна окончательная.

UnityMartingaleDraft1.mq5

На первом этапе, для версии UnityMartingaleDraft1.mq5, возьмем за основу MultiMartingale.mq5 и начнем его модифицировать.

Бывшую входную переменную StartType, которая определяла направление первой сделки в серии, переименуем в SignalType и будем использовать для выбора между рассмотренными стратегиями BREAKOUT и PULLBACK.

enum SIGNAL_TYPE
{
   BREAKOUT,
   PULLBACK
};
...
input SIGNAL_TYPE StartType = 0// SignalType

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

input group "U N I T Y   S E T T I N G S"
input string UnitySymbols = "EURUSD,GBPUSD,USDCHF,USDJPY,AUDUSD,USDCAD,NZDUSD";
input int UnityBarLimit = 10;
input ENUM_APPLIED_PRICE UnityPriceType = PRICE_CLOSE;
input ENUM_MA_METHOD UnityPriceMethod = MODE_EMA;
input int UnityPricePeriod = 1;

Обратите внимание, что параметр UnitySymbols содержит перечень инструментов кластера для построения индикатора, и как правило отличается от перечня рабочих инструментов, которыми мы хотим торговать. Торгуемые инструменты по-прежнему задаются в параметре WorkSymbols.

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

Управление индикатором обернем в класс UnityController. Помимо дескриптора самого индикатора (handle) в полях класса хранятся:

  • количество буферов индикатора buffers, которое будет получено из сообщений от индикатора после его инициализации;
  • номер бара bar, с которого считываются данные (обычно текущий незавершенный — 0 или последний завершенный — 1);
  • массив data со значениями, прочитанными из буферов индикатора на указанном баре;
  • время последнего чтения lastRead;
  • признак работы по тикам или барам tickwise.

Кроме того, в классе используется объект MultiSymbolMonitor для синхронизации баров всех задействованных символов.

class UnityController
{
   int handle;
   int buffers;
   const int bar;
   double data[];
   datetime lastRead;
   const bool tickwise;
   MultiSymbolMonitor sync;
   ...

В конструкторе, принимающем через аргументы все параметры для индикатора, создаем сам индикатор и настраиваем объект sync.

public:
   UnityController(const string symbolListconst int offsetconst int limit,
      const ENUM_APPLIED_PRICE typeconst ENUM_MA_METHOD methodconst int period):
      bar(offset), tickwise(!offset)
   {
      handle = iCustom(_Symbol_Period"MQL5Book/p6/UnityPercentEvent",
         symbolListlimittypemethodperiod);
      lastRead = 0;
      
      string symbols[];
      const int n = StringSplit(symbolList, ',', symbols);
      for(int i = 0i < n; ++i)
      {
         sync.attach(symbols[i]);
      }
   }
   
   ~UnityController()
   {
      IndicatorRelease(handle);
   }
   ...

Количество буферов устанавливается методом attached. Мы его вызовем по получению сообщения от индикатора.

   void attached(const int b)
   {
      buffers = b;
      ArrayResize(databuffers);
   }

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

   bool isReady()
   {
      return sync.check(true) == 0;
   }

Текущее время мы определяем по-разному в зависимости от режима работы индикатора: при пересчете на каждом тике (tickwise равно true) берем серверное время, а при пересчете один раз за бар — время открытия последнего бара.

   datetime lastTime() const
   {
      return tickwise ? TimeTradeServer() : iTime(_Symbol_Period0);
   }

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

   bool read()
   {
      if(!buffersreturn false;
      for(int i = 0i < buffers; ++i)
      {
         double temp[1];
         if(CopyBuffer(handleibar1temp) == 1)
         {
            data[i] = temp[0];
         }
         else
         {
            return false;
         }
      }
      lastRead = lastTime();
      return true;
   }

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

Основными внешними методами контроллера являются getOuterIndices для получения индексов максимального и минимального значений, а также оператор '[]' для чтения самих значений.

   bool isNewTime() const
   {
      return lastRead != lastTime();
   }
   
   bool getOuterIndices(int &minint &max)
   {
      if(isNewTime())
      {
         if(!read()) return false;
      }
      max = ArrayMaximum(data);
      min = ArrayMinimum(data);
      return true;
   }
   
   double operator[](const int buffer)
   {
      if(isNewTime())
      {
         if(!read())
         {
            return EMPTY_VALUE;
         }
      }
      return data[buffer];
   }
};

Напомним, что в эксперте BandOsMA.mq5 мы ввели концепцию интерфейса TradingSignal.

interface TradingSignal
{
   virtual int signal(void);
};

На его основе опишем реализацию сигнала с использованием индикатора UnityPercentEvent. Объект контроллера UnityController передается в конструктор. Здесь же указываются индексы валют (буферов), сигналы по которым нас интересуют. Мы сможем создать произвольный набор разных сигналов для выбранных рабочих инструментов.

class UnitySignalpublic TradingSignal
{
   UnityController *controller;
   const int currency1;
   const int currency2;
   
public:
   UnitySignal(UnityController *parentconst int c1const int c2):
      controller(parent), currency1(c1), currency2(c2) { }
   
   virtual int signal(voidoverride
   {
      if(!controller.isReady()) return 0// ждем синхронизации баров
      if(!controller.isNewTime()) return 0// ждем изменения времени
      
      int minmax;
      if(!controller.getOuterIndices(minmax)) return 0;
      
      // перекупленность
      if(currency1 == max && currency2 == minreturn +1;
      // перепроданность
      if(currency2 == max && currency1 == minreturn -1;
      return 0;
   }
};

Метод signal возвращает 0 в неопределенной ситуации, либо +1 или -1 в состояниях перекупленности и перепроданности двух конкретных валют.

Для формализации торговых стратегий мы использовали интерфейс TradingStrategy.

interface TradingStrategy
{
   virtual bool trade(void);
};

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

class UnityMartingalepublic TradingStrategy
{
protected:
   ...
   AutoPtr<TradingSignalcommand;
   
public:
   UnityMartingale(const Settings &stateTradingSignal *signal)
   {
      ...
      command = signal;
   }
   virtual bool trade() override
   {
      ...
      int s = command[].signal(); // получаем сигнал контроллера
      if(s != 0)
      {
         if(settings.startType == PULLBACKs *= -1// обратная логика для отскока
      }
      ulong ticket = 0;
      if(position[] == NULL// чистый старт - позиций не было и нет
      {
         if(s == +1)
         {
            ticket = openBuy(settings.lots);
         }
         else if(s == -1)
         {
            ticket = openSell(settings.lots);
         }
      }
      else
      {
         if(position[].refresh()) // позиция существует
         {
            if((position[].get(POSITION_TYPE) == POSITION_TYPE_BUY && s == -1)
            || (position[].get(POSITION_TYPE) == POSITION_TYPE_SELL && s == +1))
            {
               // сигнал в другом направлении - нужно закрыться
               PrintFormat("Opposite signal: %d for position %d %lld",
                  sposition[].get(POSITION_TYPE), position[].get(POSITION_TICKET));
               if(close(position[].get(POSITION_TICKET)))
               {
                  // position = NULL; - сохраняем позицию в кэше
               }
               else
               {
                  position[].refresh(); // контролируем возможные ошибки закрытия
               }
            }
            else
            {
               // сигнал прежний или отсутствует - "тралим"
               position[].update();
               if(trailing[]) trailing[].trail();
            }
         }
         else // позиции нет - откроем новую
         {
            if(s == 0// отсутствие сигналов
            {
               // здесь полностью логика старого эксперта:
               // - переворот для убытка по мартингейлу
               // - продолжение начальным лотом в прибыльном направлении
               ...
            }
            else // сигнал есть
            {
               double lots;
               if(position[].get(POSITION_PROFIT) >= 0.0)
               {
                  lots = settings.lots// начальный лот после прибыли
               }
               else // увеличиваем лот после убытка
               {
                  lots = MathFloor((position[].get(POSITION_VOLUME) * settings.factor) / lotsStep) * lotsStep;
      
                  if(lotsLimit < lots)
                  {
                     lots = settings.lots;
                  }               
               }
               
               ticket = (s == +1) ? openBuy(lots) : openSell(lots);
            }
         }
      }
   }
   ...
}

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

AutoPtr<TradingStrategyPoolpool;
AutoPtr<UnityControllercontroller;
   
int currenciesCount;
string currencies[];

В обработчике OnInit создаем объект UnityController и ждем, когда индикатор пришлет распределение валют по индексам буферов.

int OnInit()
{
   currenciesCount = 0;
   ArrayResize(currencies0);
   
   if(!StartUp(true)) return INIT_PARAMETERS_INCORRECT;
   
   const bool barwise = UnityPriceType == PRICE_CLOSE && UnityPricePeriod == 1;
   controller = new UnityController(UnitySymbolsbarwise,
      UnityBarLimitUnityPriceTypeUnityPriceMethodUnityPricePeriod);
   // ждем сообщений от индикатора по валютам в буферах
   return INIT_SUCCEEDED;
}

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

Вспомогательный метод StartUp в целом занимается тем же делом, что старый обработчик OnInit в эксперте MultiMartingale — заполнением структуры Settings с настройками, их проверкой на корректность и созданием пула торговых систем TradingStrategyPool, состоящего из объектов класса UnityMartingale для разных торгуемых символов WorkSymbols. Правда, теперь данный процесс разделен на 2 этапа из-за того, что нам нужно дождаться информации о распределении валют по буферам. Поэтому функция StartUp имеет входной параметр, обозначающий вызов из OnInit и — позднее — из OnChartEvent.

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

bool StartUp(const bool init = false)
{
   if(WorkSymbols == "")
   {
      Settings settings =
      {
         UseTimeHourStartHourEnd,
         LotsFactorLimit,
         StopLossTakeProfit,
         StartTypeMagicSkipTimeOnErrorTrailing_Symbol
      };
      
      if(settings.validate())
      {
         if(init)
         {
            Print("Input settings:");
            settings.print();
         }
      }
      else
      {
         if(initPrint("Wrong settings, please fix");
         return false;
      }
      if(!init)
      {
         ... // создание торговой системы на основе индикатора
      }
   }
   else
   {
      Print("Parsed settings:");
      Settings settings[];
      if(!Settings::parseAll(WorkSymbolssettings))
      {
         if(initPrint("Settings are incorrect, can't start up");
         return false;
      }
      if(!init)
      {
         ... // создание торговой системы на основе индикатора
      }
   }
   return true;
}

В OnInit функция StartUp вызывается с параметром true, что означает лишь проверку корректности настроек. Создание объекта торговой системы откладывается до получения сообщения от индикатора в OnChartEvent.

void OnChartEvent(const int id,
   const long &lparamconst double &dparamconst string &sparam)
{
   if(id == CHARTEVENT_CUSTOM + UnityBarLimit)
   {
      PrintFormat("%lld %f '%s'"lparamdparamsparam);
      if(lparam == 0ArrayResize(currencies0);
      currenciesCount = (int)MathRound(dparam);
      PUSH(currenciessparam);
      if(ArraySize(currencies) == currenciesCount)
      {
         if(pool[] == NULL)
         {
            StartUp(); // подтверждение готовности индикатора
         }
         else
         {
            Alert("Repeated initialization!");
         }
      }
   }
}

Здесь мы запоминаем количество валют в глобальной переменной currenciesCount и сохраняем их в массиве currencies, после чего вызываем StartUp уже с параметром false (значение по умолчанию, поэтому опущено). Сообщения поступают из очереди в том порядке, в котором они находятся в буферах индикатора. Таким образом, мы получаем соответствие между индексом и названием валюты.

При повторном вызове StartUp выполняется дополнительный код:

bool StartUp(const bool init = false)
{
   if(WorkSymbols == ""// один текущий символ
   {
      ...
      if(!init// окончательная инициализация уже после OnInit
      {
         controller[].attached(currenciesCount);
         // разделяем _Symbol на 2 валюты из массива currencies[]
         int firstsecond;
         if(!SplitSymbolToCurrencyIndices(_Symbolfirstsecond))
         {
            PrintFormat("Can't find currencies (%s %s) for %s",
               (first == -1 ? "base" : ""), (second == -1 ? "profit" : ""), _Symbol);
            return false;
         }
         // создаем пул из единственной стратегии
         pool = new TradingStrategyPool(new UnityMartingale(settings,
            new UnitySignal(controller[], firstsecond)));
      }
   }
   else // корзина символов
   {
      ...
      if(!init// окончательная инициализация уже после OnInit
      {
         controller[].attached(currenciesCount);
      
         const int n = ArraySize(settings);
         pool = new TradingStrategyPool(n);
         for(int i = 0i < ni++)
         {
            ...
            // разделяем settings[i].symbol на 2 валюты из currencies[]
            int firstsecond;
            if(!SplitSymbolToCurrencyIndices(settings[i].symbolfirstsecond))
            {
               PrintFormat("Can't find currencies (%s %s) for %s",
                  (first == -1 ? "base" : ""), (second == -1 ? "profit" : ""),
                  settings[i].symbol);
            }
            else
            {
               // добавляем в пул стратегию на очередном торговом символе
               pool[].push(new UnityMartingale(settings[i],
                  new UnitySignal(controller[], firstsecond)));
            }
         }
      }
   }

Вспомогательная функция SplitSymbolToCurrencyIndices выделяет базовую валюту и валюту прибыли переданного символа и находит для них индексы в массиве currencies. Таким образом, мы получаем опорные данные для генерации сигналов в объектах UnitySignal — в каждом будет своя пара индексов валют.

bool SplitSymbolToCurrencyIndices(const string symbolint &firstint &second)
{
   const string s1 = SymbolInfoString(symbolSYMBOL_CURRENCY_BASE);
   const string s2 = SymbolInfoString(symbolSYMBOL_CURRENCY_PROFIT);
   first = second = -1;
   for(int i = 0i < ArraySize(currencies); ++i)
   {
      if(currencies[i] == s1first = i;
      else if(currencies[i] == s2second = i;
   }
   
   return first != -1 && second != -1;
}

В целом, эксперт готов.

Нетрудно заметить, что в последних примерах экспертов у нас фигурируют классы стратегий и классы торговых сигналов. Мы специально сделали их наследниками обобщенных интерфейсов TradingStrategy и TradingSignal, чтобы впоследствии иметь возможность собирать коллекции совместимых, но различных реализаций, которые можно комбинировать при разработке будущих экспертов. Подобные унифицированные конкретные классы обычно подлежат выделению в отдельные заголовочные файлы. В наших примерах мы не сделали это ради упрощения пошаговой модификации.
 
Но описанный подход является стандартным для ООП. В частности, как мы уже упоминали в разделе о создании заготовок экспертов, вместе с MetaTrader 5 поставляется "фреймворк" (framework) заголовочных файлов со стандартными классами торговых операций, сигнальных индикаторов и управления денежными средствами, которые используются в Мастере MQL. Другие похожие решения публикуются на сайте mql5.com в разделе статей и базы кодов.
 
В своих разработках вы можете взять за основу любую из готовых иерархий классов, которая удовлетворяет вас по своим возможностям и удобству пользования.

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

Для этого в обработчике OnTester сделаем выборку сделок только по типам DEAL_TYPE_BUY и DEAL_TYPE_SELL и с направлением "выход" (OUT). Для сделок запросим все свойства, формирующие финансовый результат (прибыль/убыток), то есть DEAL_PROFIT, DEAL_SWAP, DEAL_COMMISSION, DEAL_FEE, а также их объем DEAL_VOLUME.

#define STAT_PROPS 5 // количество запрашиваемых свойств сделки

   

double OnTester()

{

   HistorySelect(0LONG_MAX);

   

   const ENUM_DEAL_PROPERTY_DOUBLE props[STAT_PROPS] =

   {

      DEAL_PROFITDEAL_SWAPDEAL_COMMISSIONDEAL_FEEDEAL_VOLUME

   };

   double expenses[][STAT_PROPS];

   ulong tickets[]; // нужно из-за прототипа метода 'select', но удобно для отладки

   

   DealFilter filter;

   filter.let(DEAL_TYPE, (1 << DEAL_TYPE_BUY) | (1 << DEAL_TYPE_SELL), IS::OR_BITWISE)

      .let(DEAL_ENTRY, (1 << DEAL_ENTRY_OUT) | (1 << DEAL_ENTRY_INOUT) | (1 << DEAL_ENTRY_OUT_BY),

      IS::OR_BITWISE)

      .select(propsticketsexpenses);

   ...

Далее в массиве balance накапливаем прибыли/убытки, нормированные торговыми объемами, и вычисляем для него критерий R2.

   const int n = ArraySize(tickets);
   double balance[];
   ArrayResize(balancen + 1);
   balance[0] = TesterStatistics(STAT_INITIAL_DEPOSIT);
   
   for(int i = 0i < n; ++i)
   {
      double result = 0;
      for(int j = 0j < STAT_PROPS - 1; ++j)
      {
         result += expenses[i][j];
      }
      result /= expenses[i][STAT_PROPS - 1]; // нормируем объемом
      balance[i + 1] = result + balance[i];
   }
   const double r2 = RSquaredTest(balance);
   return r2 * 100;
}

На этом первая версия эксперта в принципе готова. Мы оставили "за скобками" проверку модели тиков с помощью TickModel.mqh. Предполагается, что эксперт будет тестироваться при генерации тиков в режиме OHLC M1 или лучше. При обнаружении модели "только цены открытия" эксперт пошлет в терминал специальный фрейм со статусом ошибки и выгрузит себя из тестера. К сожалению, это остановит только данный проход, но оптимизация продолжится. Поэтому копия эксперта, которая выполняется в терминале, выдает "алерт" для пользователя, чтобы он прервал оптимизацию вручную.

void OnTesterPass()
{
   ulong   pass;
   string  name;
   long    id;
   double  value;
   uchar   data[];
   while(FrameNext(passnameidvaluedata))
   {
      if(name == "status" && id == 1)
      {
         Alert("Please stop optimization!");
         Alert("Tick model is incorrect: OHLC M1 or better is required");
         // было бы логично, если бы следующий вызов останавливал всю оптимизацию,
         // но это не так
         ExpertRemove();
      }
   }
}

Вы можете провести оптимизацию параметров SYMBOL SETTINGS для любого символа, и повторить её для разных символов. При этом в группах COMMON SETTINGS и UNITY SETTINGS всегда должны быть одни и те же настройки, потому что они применяются ко всем символам и экземплярам торговых систем. Например, сопровождение (Trailing) должно быть либо включено, либо выключено для всех оптимизаций. Также следует помнить, что входные переменные для отдельного символа (т.е. группы SYMBOL SETTINGS) имеют эффект только пока в WorkSymbols находится пустая строка. Поэтому на стадии оптимизаций следует держать его пустым.

Например, для диверсификации рисков можно последовательно оптимизировать эксперт на полностью независимых парах: EURUSD, AUDJPY, GBPCHF, NZDCAD или в других сочетаниях. К исходному коду подключено 3 set-файла с примерами частных настроек.

#property tester_set "UnityMartingale-eurusd.set"
#property tester_set "UnityMartingale-gbpchf.set"
#property tester_set "UnityMartingale-audjpy.set"

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

EURUSD+0.01*1.6^5(200,200)[17,21];GBPCHF+0.01*1.2^8(600,800)[7,20];AUDJPY+0.01*1.2^8(600,800)[7,20]

Такая настройка тоже приложена в отдельном файле.

#property tester_set "UnityMartingale-combo.set"

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

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

UnityMartingaleDraft2.mq5

Подсчет статистики — это часто возникающая задача, поэтому выделим её в отдельный заголовочный файл TradeReport.mqh, где организуем исходный код в соответствующие классы.

Основной класс так и назовем — TradeReport. Многие показатели торговли зависят от кривых баланса и свободных средств (эквити). Поэтому в классе имеются переменные для отслеживания текущего баланса и прибыли, а также постоянно дополняемый массив с историей баланса. Историю эквити мы хранить не будем, потому что она может меняться на каждом тике, и лучше считать её прямо на ходу. А для чего нам понадобится кривая баланса, мы расскажем чуть позже.

class TradeReport
{
   double balance;     // текущий баланс
   double floating;    // текущая плавающая прибыль
   double data[];      // кривая баланса целиком - цены
   datetime moments[]; // и дата/время
   ...

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

   TradeReport()
   {
      balance = AccountInfoDouble(ACCOUNT_BALANCE);
   }
   
   void resetFloatingPL()
   {
      floating = 0;
   }
   
   void addFloatingPL(const double pl)
   {
      floating += pl;
   }
   
   void addBalance(const double pl)
   {
      balance += pl;
   }
   
   double getCurrent() const
   {
      return balance + floating;
   }
   ...

Эти методы потребуются для итеративного расчета просадки по эквити (на лету). Массив баланса data потребуется для одномоментного расчета просадки по балансу (это будем делать уже в конце теста).

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

   struct DrawDown
   {
      double
      series_start,
      series_min,
      series_dd,
      series_dd_percent,
      series_dd_relative_percent,
      series_dd_relative;
      ...
      void reset();
      void calcDrawdown(const double &data[]);
      void calcDrawdown(const double amount);
      void print() const;
   };

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

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

   struct GenericStatspublic DrawDown
   {
      long deals;
      long trades;
      long buy_trades;
      long wins;
      long buy_wins;
      long sell_wins;
      
      double profits;
      double losses;
      double net;
      double pf;
      double average_trade;
      double recovery;
      double max_profit;
      double max_loss;
      double sharpe;
      ...

По названиям переменных легко догадаться, каким стандартным метрикам они соответствуют. Некоторые показатели излишни и потому опущены. Например, имея общее количество трейдов (trades) и количество покупок среди них (buy_trades), легко найти количество продаж (trades - sell_trades). То же самое касается дополняющих друг друга статистик по выигрышам/проигрышам. Длительности выигрышных и проигрышных серий не подсчитываются. Желающие могут дополнить наш отчет этими показателями.

Для унификации с общей статистикой тестера имеется метод fillByTester, заполняющий все поля через функцию TesterStatistics. Позднее мы им воспользуется.

      void fillByTester()
      {
         deals = (long)TesterStatistics(STAT_DEALS);
         trades = (long)TesterStatistics(STAT_TRADES);
         buy_trades = (long)TesterStatistics(STAT_LONG_TRADES);
         wins = (long)TesterStatistics(STAT_PROFIT_TRADES);
         buy_wins = (long)TesterStatistics(STAT_PROFIT_LONGTRADES);
         sell_wins = (long)TesterStatistics(STAT_PROFIT_SHORTTRADES);
         
         profits = TesterStatistics(STAT_GROSS_PROFIT);
         losses = TesterStatistics(STAT_GROSS_LOSS);
         net = TesterStatistics(STAT_PROFIT);
         pf = TesterStatistics(STAT_PROFIT_FACTOR);
         average_trade = TesterStatistics(STAT_EXPECTED_PAYOFF);
         recovery = TesterStatistics(STAT_RECOVERY_FACTOR);
         sharpe = TesterStatistics(STAT_SHARPE_RATIO);
         max_profit = TesterStatistics(STAT_MAX_PROFITTRADE);
         max_loss = TesterStatistics(STAT_MAX_LOSSTRADE);
         
         series_start = TesterStatistics(STAT_INITIAL_DEPOSIT);
         series_min = TesterStatistics(STAT_EQUITYMIN);
         series_dd = TesterStatistics(STAT_EQUITY_DD);
         series_dd_percent = TesterStatistics(STAT_EQUITYDD_PERCENT);
         series_dd_relative_percent = TesterStatistics(STAT_EQUITY_DDREL_PERCENT);
         series_dd_relative = TesterStatistics(STAT_EQUITY_DD_RELATIVE);
      }
   };

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

   static double calcSharpe(const double &data[], const double riskFreeRate = 0);

Как нетрудно догадаться, при вызове этого метода мы передадим в параметре data одноименный массив-член класса TradeReport с отсчетами баланса. Процесс же заполнения этого массива и вызов вышеупомянутых методов для конкретных показателей происходит в методе calcStatistics (см. ниже). Ему на вход передается объект-фильтр сделок (filter), начальные депозит (start) и время (origin). Предполагается, что вызывающий код настроит фильтр таким образом, чтобы под него попали только сделки интересующей нас торговой системы.

Метод возвращает заполненную структуру GenericStats, а кроме того заполняет два массива внутри объекта TradeReport: data и moments — значениями баланса и временными отсчетами изменений, соответственно. Нам это пригодится в окончательной версии эксперта.

   GenericStats calcStatistics(DealFilter &filter,
      const double start = 0const datetime origin = 0,
      const double riskFreeRate = 0)
   {
      GenericStats stats;
      ArrayResize(data0);
      ArrayResize(moments0);
      ulong tickets[];
      if(!filter.select(tickets)) return stats;
      
      balance = start;
      PUSH(databalance);
      PUSH(momentsorigin);
      
      for(int i = 0i < ArraySize(tickets); ++i)
      {
         DealMonitor m(tickets[i]);
         if(m.get(DEAL_TYPE) == DEAL_TYPE_BALANCE// пополнение/снятие
         {
            balance += m.get(DEAL_PROFIT);
            PUSH(databalance);
            PUSH(moments, (datetime)m.get(DEAL_TIME));
         }
         else if(m.get(DEAL_TYPE) == DEAL_TYPE_BUY 
            || m.get(DEAL_TYPE) == DEAL_TYPE_SELL)
         {
            const double profit = m.get(DEAL_PROFIT) + m.get(DEAL_SWAP)
               + m.get(DEAL_COMMISSION) + m.get(DEAL_FEE);
            balance += profit;
            
            stats.deals++;
            if(m.get(DEAL_ENTRY) == DEAL_ENTRY_OUT 
               || m.get(DEAL_ENTRY) == DEAL_ENTRY_INOUT
               || m.get(DEAL_ENTRY) == DEAL_ENTRY_OUT_BY)
            {
               PUSH(databalance);
               PUSH(moments, (datetime)m.get(DEAL_TIME));
               stats.trades++;        // трейды считаем по сделкам выхода
               if(m.get(DEAL_TYPE) == DEAL_TYPE_SELL)
               {
                  stats.buy_trades++; // закрытие сделкой в обратном направлении
               }
               if(profit >= 0)
               {
                  stats.wins++;
                  if(m.get(DEAL_TYPE) == DEAL_TYPE_BUY)
                  {
                     stats.sell_wins++; // закрытие сделкой в обратном направлении
                  }
                  else
                  {
                     stats.buy_wins++;
                  }
               }
            }
            else if(!TU::Equal(profit0))
            {
               PUSH(databalance); // коммиссия на вход (если есть)
               PUSH(moments, (datetime)m.get(DEAL_TIME));
            }
            
            if(profit >= 0)
            {
               stats.profits += profit;
               stats.max_profit = fmax(profitstats.max_profit);
            }
            else
            {
               stats.losses += profit;
               stats.max_loss = fmin(profitstats.max_loss);
            }
         }
      }
      
      if(stats.trades > 0)
      {
         stats.net = stats.profits + stats.losses;
         stats.pf = -stats.losses > DBL_EPSILON ?
            stats.profits / -stats.losses : MathExp(10000.0); // NaN(+inf)
         stats.average_trade = stats.net / stats.trades;
         stats.sharpe = calcSharpe(datariskFreeRate);
         stats.calcDrawdown(data);     // заполняем все поля подструктуры DrawDown
         stats.recovery = stats.series_dd > DBL_EPSILON ?
            stats.net / stats.series_dd : MathExp(10000.0);
      }
      return stats;
   }
};

Здесь видно, как мы вызываем calcSharpe и calcDrawdown для получения соответствующих показателей на массиве data. Остальные показатели считаются непосредственно в цикле внутри calcStatistics.

Вооружившись классом TradeReport, расширим функционал эксперта до версии UnityMartingaleDraft2.mq5.

Добавим в класс UnityMartingale новые члены.

class UnityMartingalepublic TradingStrategy
{
protected:
   ...
   TradeReport report;
   TradeReport::DrawDown equity;
   const double deposit;
   const datetime epoch;
   ...

Объект report нам нужен для вызова calcStatistics, куда будет включена просадка по балансу. Объект equity потребовался для независимого расчета просадки по эквити. Начальный баланс и дата, а также начало расчета просадки по эквити задаются в конструкторе.

public:
   UnityMartingale(const Settings &stateTradingSignal *signal):
      symbol(state.symbol), deposit(AccountInfoDouble(ACCOUNT_BALANCE)),
      epoch(TimeCurrent())
   {
      ...
      equity.calcDrawdown(deposit);
      ...
   }

Продолжение расчета просадки по эквити делается на лету — при каждом вызове метода trade.

   virtual bool trade() override
   {
      ...
      if(MQLInfoInteger(MQL_TESTER))
      {
         if(position[])
         {
            report.resetFloatingPL();
            // после сброса нужно просуммировать все плавающие прибыли
            // для чего вызываем addFloatingPL для каждой существующей позиции,
            // но в этой стратегии есть максимум 1 позиция в каждый момент
            report.addFloatingPL(position[].get(POSITION_PROFIT)
               + position[].get(POSITION_SWAP));
            // после учета всех сумм - обновляем просадку
            equity.calcDrawdown(report.getCurrent());
         }
      }
      ...
   }

Но это далеко не все, что нужно для правильного расчета. Дело в том, что плавающую прибыль или убыток мы должны учитывать поверх баланса. В показанном фрагменте делается вызов только addFloatingPL, но в классе TradeReport есть еще и метод для модификации баланса — addBalance. Однако баланс изменяется только при закрытии позиции.

Благодаря концепции ООП, закрытие позиции у нас соответствует удалению объекта position класса PositionState. Так не можем ли мы перехватить его?

Непосредственно в классе PositionState для этого средств не предусмотрено, но мы можем объявить производный класс PositionStateWithEquity с особым конструктором и деструктором.

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

class PositionStateWithEquitypublic PositionState
{
   TradeReport *report;
   
public:
   PositionStateWithEquity(const long tTradeReport *r):
      PositionState(t), report(r) { }
   ...

В деструкторе мы находим все сделки по идентификатору закрытой позиции, подсчитываем общий финансовый результат (вместе с комиссиями и прочими отчислениями) и затем вызываем addBalance для связанного объекта report.

   ~PositionStateWithEquity()
   {
      if(HistorySelectByPosition(get(POSITION_IDENTIFIER)))
      {
         double result = 0;
         DealFilter filter;
         int props[] = {DEAL_PROFITDEAL_SWAPDEAL_COMMISSIONDEAL_FEE};
         Tuple4<doubledoubledoubledoubleoverheads[];
         if(filter.select(propsoverheads))
         {
            for(int i = 0i < ArraySize(overheads); ++i)
            {
               result += NormalizeDouble(overheads[i]._12)
                  + NormalizeDouble(overheads[i]._22)
                  + NormalizeDouble(overheads[i]._32)
                  + NormalizeDouble(overheads[i]._42);
            }
         }
         if(CheckPointer(report) != POINTER_INVALIDreport.addBalance(result);
      }
   }
};

Осталось прояснить один момент — как мы создадим для позиций объекты класса PositionStateWithEquity вместо PositionState. Для этого достаточно поменять оператор new в паре мест, где он вызывается в классе TradingStrategy.

   position = MQLInfoInteger(MQL_TESTER) ?

      new PositionStateWithEquity(tickets[0], &report) : new PositionState(tickets[0]); 

Таким образом, мы разобрались со сбором данных, но осталось непосредственно сформировать отчет, то есть вызвать calcStatistics. Здесь не обойтись без расширения нашего интерфейса TradingStrategy: добавим в него метод statement.

interface TradingStrategy
{
   virtual bool trade(void);
   virtual bool statement();
};

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

class UnityMartingalepublic TradingStrategy
{
   ...
   virtual bool statement() override
   {
      if(MQLInfoInteger(MQL_TESTER))
      {
         Print("Separate trade report for "settings.symbol);
         // просадка по эквити должна уже быть посчитана на лету
         Print("Equity DD:");
         equity.print();
         
         // просадка по балансу считается в результирующем отчете
         Print("Trade Statistics (with Balance DD):");
         // настраиваем фильтр под конкретную стратегию
         DealFilter filter;
         filter.let(DEAL_SYMBOLsettings.symbol)
            .let(DEAL_MAGICsettings.magicIS::EQUAL_OR_ZERO);
            // нулевое "магическое" число нужно для последней сделки выхода
            // - её делает сам тестер
         HistorySelect(0LONG_MAX);
         TradeReport::GenericStats stats =
            report.calcStatistics(filterdepositepoch);
         stats.print();
      }
      return false;
   }
   ...

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

double OnTester()
{
   ...
   if(pool[] != NULL)
   {
      pool[].statement(); // просим все торговые системы вывести свои результаты
   }
   ...
}

Проверим корректность работы своего отчета. Для этого запустим эксперт в тестере по одному символу и сравним стандартный отчет с нашими расчетами. Например, для настройки UnityMartingale-eurusd.set, торгуя на EURUSD H1 получим за 2021 такие показатели.

Отчет тестера за 2021 год, EURUSD H1

Отчет тестера за 2021 год, EURUSD H1

В журнале наш вариант отображается как две структуры: DrawDown с показателями просадки по эквити и GenericStats с показателями просадки по балансу и прочей статистикой.

Separate trade report for EURUSD

Equity DD:

    [maxpeak] [minpeak] [series_start] [series_min] [series_dd] [series_dd_percent] »

[0]  10022.48  10017.03       10000.00      9998.20        6.23                0.06 »

» [series_dd_relative_percent] [series_dd_relative]

»                         0.06                 6.23

 

Trade Statistics (with Balance DD):

    [maxpeak] [minpeak] [series_start] [series_min] [series_dd] [series_dd_percent] »

[0]  10022.40  10017.63       10000.00      9998.51        5.73                0.06 »

» [series_dd_relative_percent] [series_dd_relative] »

»                         0.06                 5.73 »

» [deals] [trades] [buy_trades] [wins] [buy_wins] [sell_wins] [profits] [losses] [net] [pf] »

»     194       97           43     42         19          23     57.97   -39.62 18.35 1.46 »

» [average_trade] [recovery] [max_profit] [max_loss] [sharpe]

»            0.19       3.20         2.00      -2.01     0.15

Легко убедиться, что эти числа совпадают с отчетом тестера.

Теперь запустим на том же периоде торговлю сразу по трем символам (настройка UnityMartingale-combo.set).

В дополнение к записям по EURUSD в журнале появятся структуры для GBPCHF и AUDJPY.

Separate trade report for GBPCHF

Equity DD:

    [maxpeak] [minpeak] [series_start] [series_min] [series_dd] [series_dd_percent] »

[0]  10029.50  10000.19       10000.00      9963.65       62.90                0.63 »

» [series_dd_relative_percent] [series_dd_relative]

»                         0.63                62.90

Trade Statistics (with Balance DD):

    [maxpeak] [minpeak] [series_start] [series_min] [series_dd] [series_dd_percent] »

[0]  10023.68   9964.28       10000.00      9964.28       59.40                0.59 »

» [series_dd_relative_percent] [series_dd_relative] »

»                         0.59                59.40 »

» [deals] [trades] [buy_trades] [wins] [buy_wins] [sell_wins] [profits] [losses] [net] [pf] »

»     600      300          154    141         63          78    394.53  -389.33  5.20 1.01 »

» [average_trade] [recovery] [max_profit] [max_loss] [sharpe]

»            0.02       0.09         9.10      -6.73     0.01

 

Separate trade report for AUDJPY

Equity DD:

    [maxpeak] [minpeak] [series_start] [series_min] [series_dd] [series_dd_percent] »

[0]  10047.14  10041.53       10000.00      9961.62       48.20                0.48 »

» [series_dd_relative_percent] [series_dd_relative]

»                         0.48                48.20

Trade Statistics (with Balance DD):

    [maxpeak] [minpeak] [series_start] [series_min] [series_dd] [series_dd_percent] »

[0]  10045.21  10042.75       10000.00      9963.62       44.21                0.44 »

» [series_dd_relative_percent] [series_dd_relative] »

»                         0.44                44.21 »

» [deals] [trades] [buy_trades] [wins] [buy_wins] [sell_wins] [profits] [losses] [net] [pf] »

»     332      166           91     89         54          35    214.79  -170.20 44.59 1.26 »

» [average_trade] [recovery] [max_profit] [max_loss] [sharpe]

»            0.27       1.01         7.58      -5.17     0.09

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

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

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

Это будет предпоследняя версия примера UnityMartingaleDraft3.mq5.

UnityMartingaleDraft3.mq5

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

Основу алгоритма оформим в виде класса TradeReportWriter (TradeReportWriter.mqh). Класс сможет хранить произвольное количество отчетов разных торговых систем: каждая в отдельном объекте DataHolder, который включает массивы значений и временных меток баланса (data и when, соответственно), структуру со статистикой stats, а также название, цвет и ширину линии для отображения.

class TradeReportWriter
{
protected:
   class DataHolder
   {
   public:
      double data[];                   // изменения баланса
      datetime when[];                 // временные метки баланса
      string name;                     // описание
      color clr;                       // цвет
      int width;                       // ширина линии
      TradeReport::GenericStats stats// торговые показатели
   };
   ...

Под объекты класса DataHolder выделен массив автоуказателей curves. Кроме того, нам понадобятся общие границы по суммам и срокам для совмещения линий всех торговых систем в картинке — это обеспечат переменные lower, upper, start и stop.

   AutoPtr<DataHoldercurves[];
   double lowerupper;
   datetime startstop;
   
public:
   TradeReportWriter(): lower(DBL_MAX), upper(-DBL_MAX), start(0), stop(0) { }
   ...

Добавить линию баланса позволяет метод addCurve.

   virtual bool addCurve(double &data[], datetime &when[], const string name,
      const color clr = clrNONEconst int width = 1)
   {
      if(ArraySize(data) == 0 || ArraySize(when) == 0return false;
      if(ArraySize(data) != ArraySize(when)) return false;
      DataHolder *c = new DataHolder();
      if(!ArraySwap(datac.data) || !ArraySwap(whenc.when))
      {
         delete c;
         return false;
      }
   
      const double max = c.data[ArrayMaximum(c.data)];
      const double min = c.data[ArrayMinimum(c.data)];
      
      lower = fmin(minlower);
      upper = fmax(maxupper);
      if(start == 0start = c.when[0];
      else if(c.when[0] != 0start = fmin(c.when[0], start);
      stop = fmax(c.when[ArraySize(c.when) - 1], stop);
      
      c.name = name;
      c.clr = clr;
      c.width = width;
      ZeroMemory(c.stats); // по умолчанию статистики нет
      PUSH(curvesc);
      return true;
   }

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

   virtual bool addCurve(TradeReport::GenericStats &stats,
      double &data[], datetime &when[], const string name,
      const color clr = clrNONEconst int width = 1)
   {
      if(addCurve(datawhennameclrwidth))
      {
         curves[ArraySize(curves) - 1][].stats = stats;
         return true;
      }
      return false;
   }

Наконец, самый главный метод класса — для визуализации отчета — сделан абстрактным.

   virtual void render() = 0;

Это дает возможность реализовать много способов отображения отчетов, например, как с записью в файлы разных форматов, так и с отрисовкой непосредственно на графике. Мы сейчас ограничимся формированием html-файлов, так как это наиболее технологичный и широко распространенный способ.

Новый класс HTMLReportWriter имеет конструктор, в параметрах которого указывается название файла, а также размеры картинки с кривыми балансов. Само изображение будем генерировать в известном формате векторной графики SVG: он идеально походит в данном случае, так как представляет собой подмножество XML-языка, коим является и сам HTML.

class HTMLReportWriterpublic TradeReportWriter
{
   int handle;
   int widthheight;
   
public:
   HTMLReportWriter(const string nameconst int w = 600const int h = 400):
      width(w), height(h)
   {
      handle = FileOpen(name,
         FILE_WRITE | FILE_TXT | FILE_ANSI | FILE_REWRITE);
   }
   
   ~HTMLReportWriter()
   {
      if(handle != 0FileClose(handle);
   }
   
   void close()
   {
      if(handle != 0FileClose(handle);
      handle = 0;
   }
   ...

Прежде чем обратиться к главному публичному методу render, потребуется познакомить читателя с одной технологией, которая будет подробно описана в заключительной 7-ой Части книги. Речь о ресурсах: файлах и массивах произвольных данных, подключаемых к MQL-программе для работы с мультимедиа (звук и изображения), встраивания откомпилированных индикаторов или просто как хранилище прикладной информации. Именно последним вариантом мы сейчас и воспользуемся.

Дело в том, что генерировать HTML-страницу лучше не целиком из MQL-кода, а на основе шаблона (заготовки страницы), в которую MQL-код лишь вставит значения некоторых переменных. Это известный прием в программировании, позволяющий разделить алгоритм и внешнее представление программы (или результата её работы). За счет этого мы можем раздельно экспериментировать с HTML-шаблоном и MQL-кодом, работая с каждой из составных частей в привычной среде. В частности, MetaEditor все же не очень приспособлен для редактирования веб-страниц и их просмотра, точно также как стандартный браузер ничего не "знает" о MQL5 (хотя это можно исправить).

HTML-шаблоны отчетов мы как раз и будем хранить в текстовых файлах, подключенных к исходному коду MQL5 как ресурсы. Подключение делается с помощью специальной директивы #resource. Например, вот какая строка есть в файле TradeReportWriter.mqh.

#resource "TradeReportPage.htm" as string ReportPageTemplate

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

<!DOCTYPE html>
<html>
   <head>
      <title>Trade Report</title>
      <style>
         *{font: 9pt "Segoe UI";}
         .center{width:fit-content;margin:0 auto;}
         ...
      </style>
   </head>
   <body>
      <div class="center">
         <h1>Trade Report</h1>
         ~
      </div>
   </body>
   <script>
   ...
   </script>
</html>

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

Для начала обратим внимание, что в большинстве веб-страниц есть начальная часть (заголовок), есть завершающая часть ("подвал"), и между ними располагается полезная информация. Вышеприведенная заготовка отчета в этом смысле не исключение. Для обозначения полезного содержимого в ней использован символ тильды '~'. Вместо него MQL-код должен будет вставить изображение баланса и таблицу с показателями. Но наличие '~' необязательно, так как страница может представлять собой единое целое, то есть ту саму полезную среднюю часть: ведь MQL-код сможет при необходимости вставлять результат обработки одного шаблона в другой.

Чтобы завершить отступление касательно HTML-шаблонов, обратим внимание на еще один нюанс. В принципе, веб-страница состоит из тегов, выполняющих разные по сути функции. Стандартные теги HTML сообщают браузеру, что отображать. Кроме них имеются каскадные стили (CSS), которые описывают, как это отображать. Наконец, в странице может быть динамическая составляющая в виде скриптов JavaScript, которые интерактивно управляют и первым, и вторым.
 
Обычно эти три компонента подвергаются шаблонизации независимо, то есть, например, HTML-шаблон, строго говоря, должен содержать только HTML, но не CSS и JavaScript. Это позволяет, опять-таки, "развязать" содержимое, оформление и поведение веб-страницы, что облегчает разработку (рекомендуется придерживаться такого же подхода и в MQL5!).
 
Однако в нашем примере мы внесли в шаблон все компоненты. В частности, в приведенном шаблоне мы видим тег <style> со стилями CSS и тег <script> с некоторыми функциями JavaScript, которые опущены. Это сделано для упрощения примера, с акцентом на возможности MQL5, а не веб-разработки.

Имея шаблон веб-страницы в переменной ReportPageTemplate, подключенной как ресурс, мы можем написать метод render.

   virtual void render() override
   {
      string headerAndFooter[2];
      StringSplit(ReportPageTemplate, '~', headerAndFooter);
      FileWriteString(handleheaderAndFooter[0]);
      renderContent();
      FileWriteString(handleheaderAndFooter[1]);
   }
   ...

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

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

private:
   void renderContent()
   {
      renderSVG();
      renderTables();
   }

Генерация картинки внутри renderSVG основана на еще одном файле шаблона TradeReportSVG.htm, который связывается со строковой переменной SVGBoxTemplate:

#resource "TradeReportSVG.htm" as string SVGBoxTemplate

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

<span id="params" style="display:block;width:%WIDTH%px;text-align:center;"></span>
<a id="main" style="display:block;text-align:center;">
   <svg width="%WIDTH%" height="%HEIGHT%" xmlns="http://www.w3.org/2000/svg">
      <style>.legend {font: bold 11px Consolas;}</style>
      <rect x="0" y="0" width="%WIDTH%" height="%HEIGHT%"
         style="fill:none; stroke-width:1; stroke: black;"/>
      ~
   </svg>
</a>

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

   void renderSVG()
   {
      string headerAndFooter[2];
      if(StringSplit(SVGBoxTemplate, '~', headerAndFooter) != 2return;
      StringReplace(headerAndFooter[0], "%WIDTH%", (string)width);
      StringReplace(headerAndFooter[0], "%HEIGHT%", (string)height);
      FileWriteString(handleheaderAndFooter[0]);
      
      for(int i = 0i < ArraySize(curves); ++i)      
      {
         renderCurve(icurves[i][].datacurves[i][].when,
            curves[i][].namecurves[i][].clrcurves[i][].width);
      }
      
      FileWriteString(handleheaderAndFooter[1]);
   }

В начальной части страницы, в строке headerAndFooter[0] мы ищем подстроки особого вида "%WIDTH%" и "%HEIGHT%", и заменяем их на требуемые ширину и высоту картинки. Именно по такому принципу в наших шаблонах действует подстановка значений. Например, в данном шаблоне действительно встречаются данные подстроки в теге rect:

<rect x="0" y="0" width="%WIDTH%" height="%HEIGHT%" style="fill:none; stroke-width:1; stroke: black;"/>

Таким образом, если построение отчета заказано размером 600 на 400, строка преобразуется в следующую:

<rect x="0" y="0" width="600" height="400" style="fill:none; stroke-width:1; stroke: black;"/>

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

Генерацией тегов для рисования конкретных линий баланса занимается метод renderCurve, в который передаются все необходимые массивы и прочие настройки (название, цвет, толщина). Мы оставим данный метод и прочие узкоспециализированные методы (renderTables, renderTable) для самостоятельного изучения.

Вернемся к основному модулю эксперта UnityMartingaleDraft3.mq5. Зададим размеры изображения графиков балансов и подключим TradeReportWriter.mqh.

#define MINIWIDTH  400
#define MINIHEIGHT 200
   
#include <MQL5Book/TradeReportWriter.mqh>

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

interface TradingStrategy
{
   virtual bool trade(void);
   virtual bool statement(TradeReportWriter *writer = NULL);
};

В конкретной реализации этого метода в классе нашей стратегии UnityMartingale добавим несколько строк.

class UnityMartingalepublic TradingStrategy
{
   ...
   TradeReport report;
   ...
   virtual bool statement(TradeReportWriter *writer = NULLoverride
   {
      if(MQLInfoInteger(MQL_TESTER))
      {
         ...
         // это все уже было
         DealFilter filter;
         filter.let(DEAL_SYMBOLsettings.symbol)
            .let(DEAL_MAGICsettings.magicIS::EQUAL_OR_ZERO);
         HistorySelect(0LONG_MAX);
         TradeReport::GenericStats stats =
            report.calcStatistics(filterdepositepoch);
         ...
         // это добавляем
         if(CheckPointer(writer) != POINTER_INVALID)
         {
            double data[];               // значения баланса
            datetime time[];             // время точек баланса для синхронизации кривых
            report.getCurve(datatime); // заполняем массивы и передаем на запись в файл
            return writer.addCurve(statsdatatimesettings.symbol);
         }
         return true;
      }
      return false;
   }

Все сводится к тому, чтобы получить массив баланса и структуру с показателями из объекта report (класса TradeReport) и передать в объект TradeReportWriter, вызвав addCurve.

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

class TradingStrategyPoolpublic TradingStrategy
{
   ...
   virtual bool statement(TradeReportWriter *writer = NULLoverride
   {
      bool result = false;
      for(int i = 0i < ArraySize(pool); i++)
      {
         result = pool[i][].statement(writer) || result;
      }
      return result;
   }

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

double OnTester()
{
   ...
   const static string tempfile = "temp.html";
   HTMLReportWriter writer(tempfileMINIWIDTHMINIHEIGHT);
   if(pool[] != NULL)
   {
      pool[].statement(&writer); // просим стратегии сообщить свои результаты
   }
   writer.render(); // записываем полученные данные в файл
   writer.close();
}

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

Это потребовало чуть больше кода.

double OnTester()
{
   ...
   // это уже было
   DealFilter filter;
   // настраиваем фильтр и заполняем по нему массив сделок tickets
   ...
   const int n = ArraySize(tickets);
   
   // это добавляем
   const bool singleSymbol = WorkSymbols == "";
   double curve[];    // кривая общего баланса
   datetime stamps[]; // дата и время точек общего баланса
   
   if(!singleSymbol// общий баланс выводим, только если несколько символов/стратегий
   {
      ArrayResize(curven + 1);
      ArrayResize(stampsn + 1);
      curve[0] = TesterStatistics(STAT_INITIAL_DEPOSIT);
      
      // MQL5 не позволяет узнать время начала теста,
      // это можно было бы выяснить из первой сделки,
      // но она вне условий фильтра конкретной системы,
      // поэтому просто договоримся пропускать в расчетах время 0
      stamps[0] = 0;
   }
   
   for(int i = 0i < n; ++i// цикл по сделкам
   {
      double result = 0;
      for(int j = 0j < STAT_PROPS - 1; ++j)
      {
         result += expenses[i][j];
      }
      if(!singleSymbol)
      {
         curve[i + 1] = result + curve[i];
         stamps[i + 1] = (datetime)HistoryDealGetInteger(tickets[i], DEAL_TIME);
      }
      ...
   }
   if(!singleSymbol// отправляем статистику тестера и общую кривую в отчет 
   {
      TradeReport::GenericStats stats;
      stats.fillByTester();
      writer.addCurve(statscurvestamps"Overall"clrBlack3);
   }
   ...
}

Посмотрим, что у нас получилось. Если запустить эксперт с настройками UnityMartingale-combo.set, мы получим в папке одного из агентов MQL5/Files файл temp.html. Вот как он выглядит в браузере.

HTML-отчет для эксперта с несколькими торговыми стратегиями/символами

HTML-отчет для эксперта с несколькими торговыми стратегиями/символами

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

UnityMartingale.mq5

Как мы знаем, для отправки файла в терминал достаточно вызвать функцию FrameAdd. Файл мы уже сформировали в рамках предыдущей версии.

double OnTester()
{
   ...
   if(MQLInfoInteger(MQL_OPTIMIZATION))
   {
      FrameAdd(tempfile0r2 * 100tempfile);
   }
}

В приемной копии эксперта выполним необходимую подготовку. Опишем структуру Pass с основными параметрами каждого прохода оптимизации.

struct Pass
{
   ulong id;          // номер прохода
   double value;      // значение критерия оптимизации
   string parameters// оптимизированные параметры в виде списка 'имя=значение'
   string preset;     // текст для генерации set-файла (со всеми параметрами)
};

В строке parameters пары "имя=значение" соединяются символом '&' — это пригодится для взаимодействия веб-страниц отчетов в дальнейшем (символ '&' — стандарт для объединения параметров в веб-адресах). Формат set-файлов мы не описывали, но исходный код далее для формирования строки preset позволяет изучить данный вопрос, так сказать, изнутри, на практике.

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

Pass TopPasses[];     // стек постоянно улучшающихся проходов (последний - лучший)
Pass BestPass;        // текущий лучший проход
string ReportPath;    // выделенная папка для всех html-файлов данной оптимизации

В обработчике OnTesterInit сформируем имя папки.

void OnTesterInit()
{
   BestPass.value = -DBL_MAX;
   ReportPath = _Symbol + "-" + PeriodToString(_Period) + "-"
      + MQLInfoString(MQL_PROGRAM_NAME) + "/";
}

В обработчике OnTesterPass будем последовательно отбирать только те фреймы, в которых показатель улучшился, выяснять для них значения оптимизируемых и прочих параметров и складывать всю эту информацию в массив структур Pass.

void OnTesterPass()
{
   ulong   pass;
   string  name;
   long    id;
   double  value;
   uchar   data[];
   
   // входные параметры для прохода, соответствующего текущему фрейму
   string  params[];
   uint    count;
   
   while(FrameNext(passnameidvaluedata))
   {
      // собираем проходы с улучшением статистики
      if(value > BestPass.value && FrameInputs(passparamscount))
      {
         BestPass.preset = "";
         BestPass.parameters = "";
         // получаем оптимизируемые и прочие параметры для формирования set-файла
         for(uint i = 0i < counti++)
         {
            string name2value[];
            int n = StringSplit(params[i], '=', name2value);
            if(n == 2)
            {
               long pvaluepstartpsteppstop;
               bool enabled = false;
               if(ParameterGetRange(name2value[0], enabledpvaluepstartpsteppstop))
               {
                  if(enabled)
                  {
                     if(StringLen(BestPass.parameters)) BestPass.parameters += "&";
                     BestPass.parameters += params[i];
                  }
                  
                  BestPass.preset += params[i] + "||" + (string)pstart + "||"
                    + (string)pstep + "||" + (string)pstop + "||"
                    + (enabled ? "Y" : "N") + "<br>\n";
               }
               else
               {
                  BestPass.preset += params[i] + "<br>\n";
               }
            }
         }
      
         BestPass.value = value;
         BestPass.id = pass;
         PUSH(TopPassesBestPass);
         // записываем фрейм с отчетом в html-файл
         const string text = CharArrayToString(data);
         int handle = FileOpen(StringFormat(ReportPath + "%06.3f-%lld.htm"valuepass),
            FILE_WRITE | FILE_TXT | FILE_ANSI);
         FileWriteString(handletext);
         FileClose(handle);
      }
   }
}

Полученные отчеты с улучшениями сохраняются в файлах с именами, включающими значение критерия оптимизации и номер прохода.

А теперь самое интересное. В обработчике OnTesterDeinit мы можем сформировать общий html-файл (overall.htm), позволяющий увидеть все отчеты сразу (или, скажем, 100 лучших). Здесь используется тот же принцип с шаблонами, что мы рассмотрели раньше.

#resource "OptReportPage.htm" as string OptReportPageTemplate
#resource "OptReportElement.htm" as string OptReportElementTemplate
   
void OnTesterDeinit()
{
   int handle = FileOpen(ReportPath + "overall.htm",
      FILE_WRITE | FILE_TXT | FILE_ANSI0CP_UTF8);
   string headerAndFooter[2];
   StringSplit(OptReportPageTemplate, '~', headerAndFooter);
   StringReplace(headerAndFooter[0], "%MINIWIDTH%", (string)MINIWIDTH);
   StringReplace(headerAndFooter[0], "%MINIHEIGHT%", (string)MINIHEIGHT);
   FileWriteString(handleheaderAndFooter[0]);
   // читаем не более 100 лучших записей из TopPasses
   for(int i = ArraySize(TopPasses) - 1k = 0i >= 0 && k < 100; --i, ++k)
   {
      string p = TopPasses[i].parameters;
      StringReplace(p"&"" ");
      const string filename = StringFormat("%06.3f-%lld.htm",
         TopPasses[i].valueTopPasses[i].id);
      string element = OptReportElementTemplate;
      StringReplace(element"%FILENAME%"filename);
      StringReplace(element"%PARAMETERS%"TopPasses[i].parameters);
      StringReplace(element"%PARAMETERS_SPACED%"p);
      StringReplace(element"%PASS%"IntegerToString(TopPasses[i].id));
      StringReplace(element"%PRESET%"TopPasses[i].preset);
      StringReplace(element"%MINIWIDTH%", (string)MINIWIDTH);
      StringReplace(element"%MINIHEIGHT%", (string)MINIHEIGHT);
      FileWriteString(handleelement);
   }
   FileWriteString(handleheaderAndFooter[1]);
   FileClose(handle);
}

На следующем изображении показано, как выглядит обзорная веб-страница после выполнения оптимизации UnityMartingale.mq5 по параметру UnityPricePeriod в мультивалютном режиме.

Обзорная веб-страница с торговыми отчетами лучших проходов оптимизации

Обзорная веб-страница с торговыми отчетами лучших проходов оптимизации

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

Над каждым графиком выводятся списки оптимизируемых параметров ("имя=значение&имя=значение..."). По щелчку мыши на такой строке открывается блок с текстом для set-файла всех настроек данного прохода. Если щелкнуть мышью внутри блока, его содержимое будет скопировано в буфер обмена. Его можно сохранить в текстовом редакторе и тем самым получить готовый set-файл.

Щелчок мыши по графику вызовет переход на страницу конкретного отчета, вместе с таблицами показателей (приводилась выше).

В завершении раздела коснемся еще одного вопроса. Ранее мы обещали продемонстрировать эффект от функции TesterHideIndicators. В эксперте UnityMartingale.mq5 сейчас используется индикатор UnityPercentEvent.mq5, и после любого теста индикатор выводится на открывающийся график. Предположим, что мы хотим скрыть от пользователя механизм работы эксперта и откуда он берет сигналы. Тогда можно вызвать функцию TesterHideIndicators (с параметром true) в обработчике OnInit, перед созданием объекта UnityController, в котором и происходит получение дескриптора через iCustom.

int OnInit()
{
   ...
   TesterHideIndicators(true);
   ...
   controller = new UnityController(UnitySymbolsbarwise,
      UnityBarLimitUnityPriceTypeUnityPriceMethodUnityPricePeriod);
   return INIT_SUCCEEDED;
}

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

...
expert file added: Experts\MQL5Book\p6\UnityMartingale.ex5.
...
program file added: \Indicators\MQL5Book\p6\UnityPercentEvent.ex5. 
...

Таким образом, дотошный пользователь может узнать имя индикатора. Исключить такую возможность позволяет механизм ресурсов, о котором мы уже вскользь говорили в контексте заготовок веб-страниц. Оказывается, что откомпилированный индикатор также можно встроить в MQL-программу (в эксперт или в другой индикатор) как ресурс. И подобные программы-ресурсы уже не упоминаются в журнале тестера. Подробно мы изучим ресурсы в 7-й Части книги, а сейчас покажем связанные с ними строки в окончательной версии нашего эксперта.

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

#resource "\\Indicators\\MQL5Book\\p6\\UnityPercentEvent.ex5"

Затем в строках с вызовом iCustom заменим прежний оператор:

   UnityController(const string symbolListconst int offsetconst int limit,
      const ENUM_APPLIED_PRICE typeconst ENUM_MA_METHOD methodconst int period):
      bar(offset), tickwise(!offset)
   {
      handle = iCustom(_Symbol_Period,
         "MQL5Book/p6/UnityPercentEvent",                      // <---
         symbolListlimittypemethodperiod);
      ...

на точно такой же, но со ссылкой на ресурс (обратите внимание на синтаксис с ведущей парой двоеточий '::' — это нужно для различения обычных путей в файловой системе и путей внутри ресурсов).

   UnityController(const string symbolListconst int offsetconst int limit,
      const ENUM_APPLIED_PRICE typeconst ENUM_MA_METHOD methodconst int period):
      bar(offset), tickwise(!offset)
   {
      handle = iCustom(_Symbol_Period,
         "::Indicators\\MQL5Book\\p6\\UnityPercentEvent.ex5",  // <---
         symbolListlimittypemethodperiod);
      ...

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