Событие OnTester

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

Функция может быть использована только в экспертах.

double OnTester()

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

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

Обратите внимание: значения, возвращаемые функцией OnTester, принимаются в расчет, только когда в настройках тестера выбран пользовательский критерий. Наличие функции OnTester не означает автоматически её использование "генетикой".
 
К сожалению, MQL5 API не содержит средств, чтобы программно узнать, какой оптимизационный критерий в настройках тестера выбрал пользователь. А иногда это очень важно знать для реализации собственных аналитических алгоритмов постобработки результатов оптимизации.

Функция вызывается ядром только в тестере, непосредственно перед вызовом функции OnDeinit.

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

Создадим в эксперте BandOsMA.mq5 обработчик OnTester, учитывающий несколько показателей: прибыль, прибыльность, количество трейдов и коэффициент Шарпа. Все показатели перемножим, предварительно взяв от каждого квадратный корень. Разумеется, у каждого разработчика могут быть свои предпочтения и идеи по конструированию подобных обобщенных критериев качества.

double sign(const double x)
{
   return x > 0 ? +1 : (x < 0 ? -1 : 0);
}
   
double OnTester()
{
   const double profit = TesterStatistics(STAT_PROFIT);
   return sign(profit) * sqrt(fabs(profit))
      * sqrt(TesterStatistics(STAT_PROFIT_FACTOR))
      * sqrt(TesterStatistics(STAT_TRADES))
      * sqrt(fabs(TesterStatistics(STAT_SHARPE_RATIO)));
}

В журнале единичного теста выводится строка со значением функции OnTester.

Запустим генетическую оптимизацию эксперта за 2021 год на EURUSD,H1 с подбором параметров индикаторов и размера стоплосса (к книге прилагается файл MQL5/Presets/MQL5Book/BandOsMA.set). Для проверки качества оптимизации включим также форвард тесты с начала 2022 года (5 месяцев).

Сначала сделаем оптимизацию по нашему критерию.

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

Мы можем экспортировать таблицу оптимизации в XML-файл сначала при выбранном нашем критерии, а затем под другим именем — с комплексным критерием (к сожалению, в файл экспорта записывается лишь один критерий; важно не менять сортировку между двумя операциями экспорта). Это дает возможность во внешней программе объединить 2 таблицы и построить диаграмму, на которой по осям отложены два критерия, а каждая точка обозначает сочетание критериев в одном прогоне.

Сопоставление пользовательского и комплексного критерия оптимизации

Сопоставление пользовательского и комплексного критерия оптимизации

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

Проверим, насколько хорош наш критерий за счет анализа его значений на форвард периоде.

Значения пользовательского критерия на периодах оптимизации и форвард-тестов

Значения пользовательского критерия на периодах оптимизации и форвард-тестов

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

Прибыль на периодах оптимизации и форвард-тестов

Прибыль на периодах оптимизации и форвард-тестов

Здесь картина похожая. Из 6850 проходов с прибылью на периоде оптимизации — 3123 оказались прибыльными и на форварде (45%). А из первой 1000 наилучших — лишь 323, что не очень хорошо. Следовательно, для выявления стабильных прибыльных настроек у этого эксперта потребуется еще много работы. Но может быть дело в критерии оптимизации?

Повторим оптимизацию, на этот раз — по встроенному комплексному критерию.

Внимание! MetaTrader 5 в ходе оптимизаций генерирует кэши оптимизаций — opt-файлы по пути Tester/cache. При запуске очередной оптимизации он ищет подходящие кэши, чтобы продолжить оптимизацию. При наличии файла кэша с прежними настройками процесс запускается не с самого начала, а с учетом предыдущих результатов. Это позволяет выстраивать генетические оптимизации в цепочки, в предположении о нахождении лучших результатов (ведь каждая генетическая оптимизация — это случайный процесс).
 
MetaTrader 5 не учитывает критерий оптимизации как отличительный фактор в настройках. Кому-то это может пригодиться, исходя из вышеизложенного, но нам это сейчас помешает. Нам для проведения чистого эксперимента требуется оптимизация с чистого листа (извините за тавтологию). Поэтому мы не можем сразу после первой оптимизации с использованием нашего критерия запустить вторую с использованием комплексного критерия.
 
Отключить текущее поведение из интерфейса терминала нельзя. Поэтому следует либо удалить, либо переименовать (сменить расширение) предыдущий opt-файл вручную в любом файловом менеджере. Чуть позже мы познакомимся с директивой препроцессора для тестера tester_no_cache, которая может быть указана в исходном коде конкретного эксперта — она позволяет отключить считывание кэша.

Сопоставление значений комплексного критерия на периодах оптимизации и форвард-периода принимает следующий вид.

Комплексный критерий на периодах оптимизации и форвард-тестов

Комплексный критерий на периодах оптимизации и форвард-тестов

А вот как обстоит дело со стабильностью прибыли на форвардах.

Прибыль на периодах оптимизации и форвард-тестов

Прибыль на периодах оптимизации и форвард-тестов

Из 5952 положительных результатов на истории лишь 2655 (тоже примерно 45%) остались в плюсе. Но из первой 1000 удачными на форварде оказались уже 581.

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

Здесь программирование плавно перетекает в исследовательско-научную деятельность и за рамки данной книги. Но мы приведем один пример критерия, рассчитываемого на собственной метрике, а не на готовых показателях TesterStatistics. Речь пойдет о критерии R2, известном также как коэффициент детерминации (RSquared.mqh).

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

По сути R2 представляет собой обратную меру дисперсии данных относительно построенной по ним линейной регрессии. Диапазон значений R2 лежит от минус бесконечности до +1 (правда большие отрицательные значения в нашем случае очень маловероятны). Очевидно, что найденная линия попутно характеризуется углом наклона, поэтому с целью универсализации кода будем сохранять в качестве промежуточного результата и R2, и тангенс угла в структуре R2A.

struct R2A
{
   double r2;    // квадрат коэффициента корреляции
   double angle// тангенс угла наклона
   R2A(): r2(0), angle(0) { }
};

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

R2A RSquared(const double &data[])
{
   int size = ArraySize(data);
   if(size <= 2return R2A();
   double xydiv;
   int k = 0;
   double Sx = 0Sy = 0Sxy = 0Sx2 = 0Sy2 = 0;
   for(int i = 0i < size; ++i)
   {
      if(data[i] == EMPTY_VALUE
      || !MathIsValidNumber(data[i])) continue;
      x = i + 1;
      y = data[i];
      Sx  += x;
      Sy  += y;
      Sxy += x * y;
      Sx2 += x * x;
      Sy2 += y * y;
      ++k;
   }
   size = k;
   const double Sx22 = Sx * Sx / size;
   const double Sy22 = Sy * Sy / size;
   const double SxSy = Sx * Sy / size;
   div = (Sx2 - Sx22) * (Sy2 - Sy22);
   if(fabs(div) < DBL_EPSILONreturn R2A();
   R2A result;
   result.r2 = (Sxy - SxSy) * (Sxy - SxSy) / div;
   result.angle = (Sxy - SxSy) / (Sx2 - Sx22);
   return result;
}

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

double RSquaredTest(const double &data[])
{
   const R2A result = RSquared(data);
   const double weight = 1.0 - 1.0 / sqrt(ArraySize(data) + 1);
   if(result.angle < 0return -fabs(result.r2) * weight;
   return result.r2 * weight;
}

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

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

#define STAT_PROPS 4
   
double GetR2onBalanceCurve()
{
   HistorySelect(0LONG_MAX);
   
   const ENUM_DEAL_PROPERTY_DOUBLE props[STAT_PROPS] =
   {
      DEAL_PROFITDEAL_SWAPDEAL_COMMISSIONDEAL_FEE
   };
   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);
   
   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; ++j)
      {
         result += expenses[i][j];
      }
      balance[i + 1] = result + balance[i];
   }
   const double r2 = RSquaredTest(balance);
   return r2 * 100;
}

В обработчике OnTester будем использовать новый критерий под директивой условной компиляции, поэтому нужно раскомментировать директиву #define USE_R2_CRITERION в начале исходного кода.

double OnTester()
{
#ifdef USE_R2_CRITERION
   return GetR2onBalanceCurve();
#else
   const double profit = TesterStatistics(STAT_PROFIT);
   return sign(profit) * sqrt(fabs(profit))
      * sqrt(TesterStatistics(STAT_PROFIT_FACTOR))
      * sqrt(TesterStatistics(STAT_TRADES))
      * sqrt(fabs(TesterStatistics(STAT_SHARPE_RATIO)));
#endif      
}

Удалим прежние результаты оптимизаций (opt-файлы с кэшем) и запустим новую оптимизацию эксперта — по критерию R2.

При сравнении значений критерия R2 с комплексным критерием можно сказать, что "конвергенция" между ними увеличилась.

Сравнение пользовательского критерия R2 и комплексного встроенного критерия

Сравнение пользовательского критерия R2 и комплексного встроенного критерия

Значения критерия R2 в окне оптимизации и на форвард-периоде для соответствующих наборов параметров выглядят следующим образом.

Критерий R2 на периодах оптимизации и форвард-тестов

Критерий R2 на периодах оптимизации и форвард-тестов

А вот как сочетаются прибыли в прошлом и в будущем.

Прибыль на периодах оптимизации и форвард-тестов

Прибыль на периодах оптимизации и форвард-тестов для R2

Статистика такова: из числа прошлых прибыльных 5582 проходов таковыми остались 2638 (47%), а из первой 1000 наиболее прибыльных проходов — 566, что сопоставимо со встроенным комплексным критерием.

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