English 中文 Español Deutsch 日本語 Português
preview
Визуальная оценка результатов оптимизации

Визуальная оценка результатов оптимизации

MetaTrader 5Тестер | 24 декабря 2021, 08:59
3 356 23
Aleksandr Slavskii
Aleksandr Slavskii

Введение

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

Если перефразировать известного писателя Сергея Михалкова, то можно сказать так: Визуализации разные нужны, визуализации разные важны. И это будет верное высказывание, так как больше восьмидесяти процентов информации, наш мозг получает через глаза. Поэтому разговор в этой статье пойдёт о том, как построить графики всех проходов оптимизации и подобрать оптимальный пользовательский критерий.

А также о том, как, имея минимальные знания в MQL5 и большое желание, используя статьи сайта и комментарии на форуме, написать то, что хочется.


Ставим задачу

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


Шаги для решения задачи

Так как без внесения изменений в код советника не обойтись, постараемся хотя бы минимизировать эти изменения.

  • Поэтому весь код сбора данных будет написан в подключаемом файле SkrShotOpt.mqh, расчёт пользовательского критерия будет осуществляться в файле CustomCriterion.mqh,
  • Рисовать графики и сохранять скриншоты будет скрипт ScreenShotOptimization.mq5.

Таким образом, в советник нужно будет добавить только несколько строк кода.


1. Сбор данных.   SkrShotOpt.mqh

В функции OnTick() будут записываться значения максимального и минимального эквити.

   double _Equity = AccountInfoDouble(ACCOUNT_EQUITY);
   if(tempEquityMax < _Equity)
      tempEquityMax = _Equity;
   if(tempEquityMin > _Equity)
      tempEquityMin = _Equity;

Чтобы не проверять изменения позиции(й) на каждом тике, отслеживать изменение позиций будем в функции OnTradeTransaction()

void IsOnTradeTransaction(const MqlTradeTransaction & trans,
                          const MqlTradeRequest & request,
                          const MqlTradeResult & result)
  {
   if(trans.type == TRADE_TRANSACTION_DEAL_ADD)
      if(HistoryDealSelect(trans.deal))
        {
         if(_deal_entry != DEAL_ENTRY_OUT && _deal_entry != DEAL_ENTRY_OUT_BY)
            _deal_entry = HistoryDealGetInteger(trans.deal, DEAL_ENTRY);
         if(trans.deal_type == DEAL_TYPE_BUY || trans.deal_type == DEAL_TYPE_SELL)
            if(_deal_entry == DEAL_ENTRY_IN || _deal_entry == DEAL_ENTRY_OUT || _deal_entry == DEAL_ENTRY_INOUT || _deal_entry == DEAL_ENTRY_OUT_BY)
               allowed = true;
        }
  }

При изменении количества открытых сделок заполняем массивы баланса и эквити.

   if(allowed) // если была сделка
     {
      double accBalance = AccountInfoDouble(ACCOUNT_BALANCE);
      double accEquity = AccountInfoDouble(ACCOUNT_EQUITY);

      ArrayResize(balance, _size + 1);
      ArrayResize(equity, _size + 1);
      balance[_size] = accBalance;

      if(_deal_entry != DEAL_ENTRY_OUT && _deal_entry != DEAL_ENTRY_OUT_BY) // если появилась новая позиция
         equity[_size] = accEquity;
      else // если позиция закрылась
        {
         if(changesB < accBalance)
            equity[_size] = tempEquityMin;
         else
            switch(s_view)
              {
               case  min_max_E:
                  equity[_size] = tempEquityMax;
                  break;
               default:
                  equity[_size] = tempEquityMin;
                  break;
              }
         tempEquityMax = accEquity;
         tempEquityMin = accEquity;
        }

      _size = _size + 1;
      changesPos = PositionsTotal();
      changesB = accBalance;
      _deal_entry = -1;
      allowed = false;
     }

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

При открытии сделки записываем значение баланса и эквити:

  • при закрытии записываем значение максимального эквити — если сделка закрылась в минус,
  • значение минимального эквити, записываем — если сделка закрылась в плюс. 

Таким образом, почти каждая сделка имеет четыре значения, записанных в массивы: баланс и эквити при открытии, баланс и макс/мин эквити при закрытии.

Бывает, что на одном тике одна позиция закрылась, а другая открылась, в этом случае запишутся не две позиции, а только одна. На расчёты и на визуализацию графиков это не повлияет, а вот массивы уменьшаются хорошо.

 

Сохранение собранных данных в файл

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

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

bool  FrameAdd( 
   const string  name,        // публичное имя/метка 
   long          id,          // публичный id 
   double        value,       // значение 
   const void&   data[]       // массив любого типа 
   );

Подробный и понятный пример как работать с функцией  FrameAdd() приводится здесь:  https://www.mql5.com/ru/forum/11277/page4#comment_469771

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

   if(id == 1)  // если это Back проход
     {
      // если % профита и количество трейдов больше заданного в настройках, то такой проход записываем в файл
      if(TesterStatistics(STAT_PROFIT) / TesterStatistics(STAT_INITIAL_DEPOSIT) * 100 > _profit && TesterStatistics(STAT_TRADES) >= trades)
        {
         double TeSt[42]; // всего элементов в перечислении ENUM_STATISTICS 41
         IsRecordStat(TeSt); // запись в массив статистики тестирования
         IsCorrect(); // корректировка массивов баланса и эквити

         if(m_sort != none)
           {
            while((sort)size_sort != none)
               size_sort++;
            double LRB[], LRE[], coeff[];
            Coeff = Criterion(balance, equity, LRB, LRE, TeSt, coeff, 3);// расчёт пользовательского критерия
            ArrayInsert(balance, equity, _size + 1, 0);     // объединяем массивы баланса и эквити в один массив
            ArrayInsert(balance, TeSt, (_size + 1) * 2, 0); // дописываем в получившийся массив , массив с данными ENUM_STATISTICS
            FrameAdd(name, id, _size + 1, balance);         // записываем фрейм в файл
           }
         else
           {
            ArrayInsert(balance, equity, _size + 1, 0);     // объединяем массивы баланса и эквити в один массив
            ArrayInsert(balance, TeSt, (_size + 1) * 2, 0); // дописываем в получившийся массив, массив с данными ENUM_STATISTICS
            FrameAdd(name, id, _size + 1, balance);         // записываем фрейм в файл
           }
        }
     }

Форвард-проходы обрабатываются так же, как и Back, но по сути являются следствием оптимизации, поэтому для них будут сохранятся только значения баланса и эквити, а значения перечисления ENUM_STATISTICS нет.

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

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

void IsCorrect()
  {
   if(changesPos > 0) // если на момент окончания тестирования была открыта позиция, её нужно как бы закрыть, так как такую позицию закрывает тестер сам
     {
      _size++;
      ArrayResize(balance, _size + 1);
      ArrayResize(equity, _size + 1);
      if(balance[_size - 2] > AccountInfoDouble(ACCOUNT_BALANCE))
        {
         balance[_size - 1] = AccountInfoDouble(ACCOUNT_BALANCE);
         switch(s_view)
           {
            case  min_max_E:
               equity[_size - 1] = tempEquityMax;
               break;
            default:
               equity[_size - 1] = tempEquityMin;
               break;
           }
        }
      else
        {
         balance[_size - 1] = AccountInfoDouble(ACCOUNT_BALANCE);
         equity[_size - 1] = tempEquityMin;
        }
      balance[_size] = AccountInfoDouble(ACCOUNT_BALANCE);
      equity[_size] = AccountInfoDouble(ACCOUNT_EQUITY);
     }
   else
     {
      ArrayResize(balance, _size + 1);
      ArrayResize(equity, _size + 1);
      balance[_size] = AccountInfoDouble(ACCOUNT_BALANCE);
      equity[_size] = AccountInfoDouble(ACCOUNT_EQUITY);
     }
  }

На этом запись данных заканчивается.


Чтение данных из файла. ScreenShotOptimization.mq5

После оптимизации создаётся файл с фреймами, находится он по пути:  C:\Users\имя пользователя\AppData\Roaming\MetaQuotes\Terminal\идентификатор терминала\MQL5\Files\Tester с расширением: имя советника.символ.таймфрейм.mqd. Сразу после оптимизации доступа к этому файлу нет, а вот после перезагрузки терминала, к файлу можно обращаться с помощью обычных файловых функций.

Находим нужный файл с фреймами, он лежит в папке C:\Users\имя пользователя\AppData\Roaming\MetaQuotes\Terminal\ID терминала\MQL5\Files\Tester.

   int count = 0;
   long search_handle = FileFindFirst("Tester\\*.mqd", FileName);
   do
     {
      if(FileName != "")
         count++;
      FileName = "Tester\\" + FileName;
     }
   while(FileFindNext(search_handle, FileName));
   FileFindClose(search_handle);

Сначала чтение идёт в структуру.

FRAME Frame = {0};
FileReadStruct(handle, Frame);
struct FRAME
  {
   ulong             Pass;
   long              ID;
   short             String[64];
   double            Value;
   int               SizeOfArray;
   long              Tmp[2];

   void              GetArrayB(int handle, Data & m_FB)
     {
      ArrayFree(m_FB.Balance);
      FileReadArray(handle, m_FB.Balance, 0, (int)Value);
      ArrayFree(m_FB.Equity);
      FileReadArray(handle, m_FB.Equity, 0, (int)Value);
      ArrayFree(m_FB.TeSt);
      FileReadArray(handle, m_FB.TeSt, 0, (SizeOfArray / sizeof(m_FB.TeSt[0]) - (int)Value * 2));
     }
   void              GetArrayF(int handle, Data & m_FB, int size)
     {
      FileReadArray(handle, m_FB.Balance, size, (int)Value);
      FileReadArray(handle, m_FB.Equity, size, (int)Value);
     }
  };

В функциях структуры FRAME заполняются массивы структуры Data, из  которых в дальнейшем строятся графики.

struct Data
  {
   ulong             Pass;
   long              id;
   int               size;
   double            Balance[];
   double            Equity[];
   double            LRegressB[];
   double            LRegressE[];
   double            coeff[];
   double            TeSt[];
  };
Data                 m_Data[];

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

Весь файл с фреймами обрабатывается в цикле. 

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

Затем начинается перебор всех Back проходов и к ним подбираются Forward проходы по номеру прохода и к массиву баланса Back-прохода дописывается массив баланса Forward-прохода.

Так же предусмотрено рисование двух видов графиков — один такой же как в тестере стратегий, то есть Forward-прогон начинается со стартового депозита.

Второй вариант Forward-проход начинается с депозита, которым закончился Back проход. В этом случае к значениям баланса и эквити Forward-прохода прибавляется значение профита Back-прохода, и так же дописывается в конец массива с Back-проходом.

Конечно, это делается, если оптимизация проводилась с форвардом.

   int handle = FileOpen(FileName, FILE_READ | FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_BIN);
   if(handle != INVALID_HANDLE)
     {
      FileSeek(handle, 260, SEEK_SET);

      while(Res && !IsStopped())
        {
         FRAME Frame = {0};
         // читаем из файла в структуру Frame
         Res = (FileReadStruct(handle, Frame) == sizeof(Frame));
         if(Res)
            if(Frame.ID == 1) // если это Back проход записываем данные в структуру m_Data
              {
               ArrayResize(m_Data, size + 1);
               m_Data[size].Pass = Frame.Pass;
               m_Data[size].id = Frame.ID;
               m_Data[size].size = (int)Frame.Value;
               Frame.GetArrayB(handle, m_Data[size]);  // записываем данные в массивы структуры m_Data
               // если профит данного прохода соответствует input настройкам, то сразу расчитываем наши критерии оптимизации
               if(m_Data[size].TeSt[STAT_PROFIT] / m_Data[size].TeSt[STAT_INITIAL_DEPOSIT] * 100 >= profitPersent)
                 {
                  Criterion(m_Data[size].Balance, m_Data[size].Equity, m_Data[size].LRegressB, m_Data[size].LRegressE, m_Data[size].TeSt, m_Data[size].coeff, m_lineR);
                  size++;
                 }
              }
            else  // если это Forward проход, то данные дописываем в конец массивов структуры m_Data
               if(m_Forward != BackOnly) // если в настройках не выбрано рисовать только Back проходы
                  for(int i = 0; i < size; i++)
                    {
                     if(Frame.Pass == m_Data[i].Pass) // если номера проходов Back и Forward совпадают
                       {
                        int m = 0;
                        if(m_Forward == Back_Next_Forward) // если выбрано рисовать график Forward прохода как продолжение Back
                          {
                           Frame.GetArrayF(handle, m_Data[i], m_Data[i].size - 1); // дописываем данные в конец массивы структуры m_Data со сдвигом на одну сделку
                           for(int x = m_Data[i].size - 1; x < m_Data[i].size + (int)Frame.Value - 1; x++)
                             {
                              m_Data[i].Balance[x] = m_Data[i].Balance[x] + m_Data[i].TeSt[STAT_PROFIT]; //  прибавляем к Forward проходу профит Back прохода
                              m_Data[i].Equity[x] = m_Data[i].Equity[x] + m_Data[i].TeSt[STAT_PROFIT];
                             }
                           m = 1;
                          }
                        else
                           Frame.GetArrayF(handle, m_Data[i], m_Data[i].size); // если выбрано рисовать график Forward прохода от стартового баланса

                        m_Data[i].coeff[Forward_Trade] = (int)(Frame.Value / 2); // количество форвард трейдов (но это не точно))
                        m_Data[i].coeff[Profit_Forward] = m_Data[i].Balance[m_Data[i].size + (int)Frame.Value - m - 1] - m_Data[i].Balance[m_Data[i].size - m];
                        break;
                       }
                     if(i == size - 1) // если не найден Back для данного Forward прохода, переносим файловый указатель в конец записи
                        FileSeek(handle, Frame.SizeOfArray, SEEK_CUR); // данного фрейма, как будто мы считали из файла данные массива
                    }
        }
      FileClose(handle);
      //---


Построение графиков

Функция построения графиков.

string _GraphPlot(double& y1[],
                  double& y2[],
                  double& LRegressB[],
                  double& LRegressE[],
                  double& coeff[],
                  double& TeSt[],
                  ulong pass)
  {
   CGraphic graphic;
//--- create graphic
   bool res = false;
   if(ObjectFind(0, "Graphic") >= 0)
      res = graphic.Attach(0, "Graphic");
   else
      res = graphic.Create(0, "Graphic", 0, 0, 0, _width, _height);

   if(!res)
      return(NULL);

   graphic.BackgroundMain(FolderName);  // распечатаем имя эксперта
   graphic.BackgroundMainSize(FontSet + 1); // размер шрифта для имени эксперта

   graphic.IndentLeft(FontSet);
   graphic.HistoryNameSize(FontSet); //размер шрифта названия линий
   graphic.HistorySymbolSize(FontSet);

   graphic.XAxis().Name("pass " + IntegerToString(pass)); // номер прохода напечатаем на оси X
   graphic.XAxis().NameSize(FontSet + 1);

   graphic.XAxis().ValuesSize(12); // размер шрифта цены
   graphic.YAxis().ValuesSize(12);

//--- add curves
   CCurve *curve = graphic.CurveAdd(y1, ColorToARGB(clrBlue), CURVE_POINTS_AND_LINES, "Balance"); // строим график баланса
   curve.LinesWidth(widthL);  // толщина линий графика
   curve.PointsSize(widthL + 1); // размер точек на графике баланса

   CCurve *curve1 = graphic.CurveAdd(y2, ColorToARGB(clrGreen), CURVE_LINES, "Equity");  // строим график эквити
   curve1.LinesWidth(widthL);

   int size = 0;
   switch(m_lineR) // строим линию регрессии
     {
      case  lineR_Balance: // линию регрессии баланса
        {
         size = ArraySize(LRegressB);
         CCurve *curve2 = graphic.CurveAdd(LRegressB, ColorToARGB(clrBlue), CURVE_LINES, "LineR_Balance");
         curve2.LinesWidth(widthL);
        }
      break;
      case  lineR_Equity: // линию регрессии эквити
        {
         size = ArraySize(LRegressE);
         CCurve *curve2 = graphic.CurveAdd(LRegressE, ColorToARGB(clrRed), CURVE_LINES, "LineR_Equity");
         curve2.LinesWidth(widthL);
        }
      break;
      case  lineR_BalanceEquity: // линию регрессии баланса и эквити
        {
         size = ArraySize(LRegressB);
         CCurve *curve2 = graphic.CurveAdd(LRegressB, ColorToARGB(clrBlue), CURVE_LINES, "LineR_Balance");
         curve2.LinesWidth(widthL);

         CCurve *curve3 = graphic.CurveAdd(LRegressE, ColorToARGB(clrRed), CURVE_LINES, "LineR_Equity");
         curve2.LinesWidth(widthL);
        }
      break;
      default:
         break;
     }
//--- plot curves
   graphic.CurvePlotAll();

// важно!!!  Все линии и надписи на графике нужно создавать после того как создали сам график, иначе они затрутся графиком

   if(size == 0)
     {
      size = ArraySize(LRegressE);
      if(size == 0)
         size = ArraySize(LRegressB);
     }

   int x1 = graphic.ScaleX(size - 1); //Масштабирует значение количества сделок по оси X.
   graphic.LineAdd(x1, 30, x1, _height - 45, ColorToARGB(clrBlue), LINE_END_BUTT); // строим вертикальную линию обозначающую конец Back прохода

   string txt = "";
   int txt_x = 70;// отступ текста по оси х
   int txt_y = 30;// отступ текста по оси у

   graphic.FontSet("Arial", FontSet);//Установить параметры текущего шрифта

   for(int i = 0; i < size_sort; i++)  // записываем на график все коэфициенты и критерии
     {
      if(coeff[i] == 0)
         continue;
      if(i == 1 || i == 3)
         txt = StringFormat("%s = %d", EnumToString((sort)i), (int)coeff[i]);
      else
         if(i == 0 || i == 2)
            txt = StringFormat("%s = %.2f", EnumToString((sort)i), coeff[i]);
         else
            txt = StringFormat("%s = %.4f", EnumToString((sort)i), coeff[i]);
      graphic.TextAdd(txt_x, txt_y + FontSet * i, txt, ColorToARGB(clrGreen));
     }

   txt_y = txt_y + FontSet * (size_sort - 1);
   txt = StringFormat("Прибыльность = %.2f", TeSt[STAT_PROFIT_FACTOR]);
   graphic.TextAdd(txt_x, txt_y + FontSet, txt, ColorToARGB(clrGreen));
   txt = StringFormat("Мат. ожидание выигрыша = %.2f", TeSt[STAT_EXPECTED_PAYOFF]);
   graphic.TextAdd(txt_x, txt_y + FontSet * 2, txt, ColorToARGB(clrGreen));

   graphic.Update();
//--- return resource name
   return graphic.ChartObjectName();
  }


Как работать с CGraphic, можно почитать в статьях:


Скриншоты графиков сохраняются в отдельную папку в папке Files. Имя папки содержит: имя советника.символ.таймфрем.

bool BitmapObjectToFile(const string ObjName, const string _FileName, const bool FullImage = true)
  {
   if(ObjName == "")
      return(true);

   const ENUM_OBJECT Type = (ENUM_OBJECT)ObjectGetInteger(0, ObjName, OBJPROP_TYPE);
   bool Res = (Type == OBJ_BITMAP_LABEL) || (Type == OBJ_BITMAP);

   if(Res)
     {
      const string Name = __FUNCTION__ + (string)MathRand();

      ObjectCreate(0, Name, OBJ_CHART, 0, 0, 0);
      ObjectSetInteger(0, Name, OBJPROP_XDISTANCE, -5e3);

      const long chart = ObjectGetInteger(0, Name, OBJPROP_CHART_ID);

      Res = ChartSetInteger(chart, CHART_SHOW, false) && ObjectCreate(chart, Name, OBJ_BITMAP_LABEL, 0, 0, 0) &&
            ObjectSetString(chart, Name, OBJPROP_BMPFILE, ObjectGetString(0, ObjName, OBJPROP_BMPFILE)) &&
            (FullImage || (ObjectSetInteger(chart, Name, OBJPROP_XSIZE, ObjectGetInteger(0, ObjName, OBJPROP_XSIZE)) &&
                           ObjectSetInteger(chart, Name, OBJPROP_YSIZE, ObjectGetInteger(0, ObjName, OBJPROP_YSIZE)) &&
                           ObjectSetInteger(chart, Name, OBJPROP_XOFFSET, ObjectGetInteger(0, ObjName, OBJPROP_XOFFSET)) &&
                           ObjectSetInteger(chart, Name, OBJPROP_YOFFSET, ObjectGetInteger(0, ObjName, OBJPROP_YOFFSET)))) &&
            ChartScreenShot(chart, FolderName + "\\" + _FileName, (int)ObjectGetInteger(chart, Name, OBJPROP_XSIZE), (int)ObjectGetInteger(chart, Name, OBJPROP_YSIZE));
      ObjectDelete(0, Name);
     }

   return(Res);
  }


Вот такие графики в итоге получились.

Так выглядят графики в папке.

Если выбрано печатать все скрины, то имена скриншотов состоят из: пользовательский критерий, который был выбран для сортировки + профит + номер прохода. 

Если выбрано печатать только лучшие, то имена скриншотов состоят из: пользовательский критерий + профит.

Так выглядит график, нарисованный скриптом.


Ну и для сравнения этот же график из тестера стратегий


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

После оптимизации перед запуском скрипта ScreenShotOptimization необходимо перезапустить терминал!

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

Я давно обратил внимание, что алготрейдеры делятся на две категории:

  1. Считают, что советник нужно оптимизировать на очень большом временном промежутке, несколько лет, а лучше несколько десятков лет, и потом он должен работать.
  2. Считают, что советник нужно регулярно переоптимизировать, используя при этом небольшой промежуток времени, например, месяц оптимизации + неделя торговли или три месяца оптимизации + месяц торговли, ну или любой другой график переоптимизации.

Я отношусь ко второму типу.

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


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

Расчёты всех пользовательских критериев оптимизации будем делать отдельным подключаемым файлом CustomCriterion.mqh, так как все эти расчёты будут использоваться как в работе скрипта для рисования графиков, так и в работе оптимизируемого советника.

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

R-квадрат как оценка качества кривой баланса стратегии

В статье очень подробно описывается, что такое линейная регрессия и как её вычислить с помощью библиотеки AlgLib, а также очень подробно и доступно дано описание коэффициента детерминации R^2 и его применение в оценке результатов тестирования. Рекомендую почитать.

Функция для расчёта линейной регрессии, R^2, а также ProfitStability:

void Coeff(double& Array[], double& LR[], double& coeff[], double& TeSt[], int total, int c)
  {
//-- Заполняем матрицу Y - значение Array, X - порядковый номер значения
   CMatrixDouble xy(total, 2);
   for(int i = 0; i < total; i++)
     {
      xy[i].Set(0, i);
      xy[i].Set(1, Array[i]);
     }

//-- Находим коэффициенты a и b линейной модели y = a*x + b;
   int retcode = 0;
   double a, b;
   CLinReg::LRLine(xy, total, retcode, b, a);

//-- Генерируем значения линейной регрессии для каждого X;
   ArrayResize(LR, total);
   for(int x = 0; x < total; x++)
      LR[x] = x * a + b;

   if(m_calc == c)
     {
      //-- Находим коэффициент корреляции значений с их же линейной регрессией
      corr = CAlglib::PearsonCorr2(Array, LR);

      //-- Находим R^2 и его знак
      coeff[r2] = MathPow(corr, 2.0);
      int sign = 1;
      if(Array[0] > Array[total - 1])
         sign = -1;
      coeff[r2] *= sign;

      //-- Находим LR Standard Error
      if(total - 2 == 0)
         stand_err = 0;
      else
        {
         for(int i = 0; i < total; i++)
           {
            double delta = MathAbs(Array[i] - LR[i]);
            stand_err =  stand_err + delta * delta;
           }
         stand_err = MathSqrt(stand_err / (total - 2));
        }
     }
//-- Находим ProfitStability = Profit_LR/stand_err
   if(stand_err == 0)
      coeff[ProfitStability] = 0;
   else
      coeff[ProfitStability] = (LR[total - 1] - LR[0]) / stand_err;
  }


Оптимизируем стратегию по графику баланса и сравниваем результаты с критерием "balance + max sharpe ratio"

Из этой статьи я взял расчёт пользовательского критерия оптимизации ProfitStability. Рассчитывается он просто: сначала рассчитываем LR Standard error - усредненное отклонение линии регрессии от графика баланса или эквити. Затем от конечного значения линии регрессии отнимаем стартовое, таким образом получаем TrendProfit.

ProfitStability рассчитывается как отношение TrendProfit к LR Standard error.

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

Так как линейная регрессия может рассчитываться как от баланса, так и от эквити, а ProfitStability привязан к расчёту линейной регрессии, поэтому расчёт ProfitStability вынесен в функцию расчёта линейной регрессии.


Создание собственных критериев оптимизации параметров эксперта

Статья довольно-таки древняя, 2011 год, но интересная и всё ещё актуальная. Из неё я взял расчёт коэффициента безопасности торговой системы (КБТС)

КБТС = Avg.Win / Avg.Loss ((110% - %Win) / (%Win-10%) + 1)

   if(TeSt[STAT_PROFIT_TRADES] == 0 || TeSt[STAT_LOSS_TRADES] == 0 || TeSt[STAT_TRADES] == 0)
      coeff[TSSF] = 0;
   else
     {
      double  avg_win = TeSt[STAT_GROSS_PROFIT] / TeSt[STAT_PROFIT_TRADES];
      double  avg_loss = -TeSt[STAT_GROSS_LOSS] / TeSt[STAT_LOSS_TRADES];
      double  win_perc = 100.0 * TeSt[STAT_PROFIT_TRADES] / TeSt[STAT_TRADES];
      //  Вычислим безопасное отношение для данного процента профитных сделок:
      if((win_perc - 10.0) + 1.0 == 0)
         coeff[TSSF] = 0;
      else
        {
         double  teor = (110.0 - win_perc) / (win_perc - 10.0) + 1.0;
         //  Вычислим реальное отношение:
         double  real = avg_win / avg_loss;
         if(teor != 0)
            coeff[TSSF] = real / teor;
         else
            coeff[TSSF] = 0;
        }
     }


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

Из этой статьи я взял Фактор линейности (LinearFactor), рассчитывается он так:

  • LinearFactor = MaxDeviation/EndBalance
  • MaxDeviaton = Max(MathAbs(Balance[i]-AverageLine))
  • AverageLine=StartBalance+K*i
  • K=(EndBalance-StartBalance)/n
  • n - количество сделок в тесте

Более подробно можно почитать в статье автора. Статья очень интересная и хоть её прилично так раскритиковали в комментариях (имхо, безосновательно), в ней можно почерпнуть для себя много полезного.

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

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

   double MaxDeviaton = 0;
   double K = (Balance[total - 1] - Balance[0]) / total;
   for(int i = 0; i < total; i++)
     {
      if(i == 0)
         MaxDeviaton = MathAbs(Balance[i] - (Balance[0] + K * i));
      else
         if(MathAbs(Balance[i] - (Balance[0] + K * i) > MaxDeviaton))
            MaxDeviaton = MathAbs(Balance[i] - (Balance[0] + K * i));
     }
   if(MaxDeviaton ==0 || Balance[0] == 0)
      coeff[LinearFactor] = 0;
   else
      coeff[LinearFactor] = 1 / (MaxDeviaton / Balance[0]);

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


Таким образом получилось добавить в код четыре пользовательских критерия оптимизации.

  1. R^2 - коэффициент детерминации.
  2. ProfitStability.
  3. КБТС - коэффициента безопасности торговой системы.
  4. LinearFactor.

Итак, все эти критерии оптимизации мы добавили в наш проект.

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


Мой критерий оптимизации

И вот на основе всех этих статей можно приступить к созданию своего собственного критерия оптимизации.

Какой график баланса хочется видеть? Ну конечно же в виде прямой, уходящей в небо под максимальным углом... Мечты!

Рассмотрим пример графика, у которого есть прибыль.



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

Так же я не учитываю объёмы, но если лот рассчитывается динамически, то объёмы как-то надо включить в расчёт пользовательского критерия (не сделано).

Количество сделок?  Мне всё равно, сколько сделок мне принесут тысячу тугриков, одна сделка или сто сделок, поэтому количество сделок учитывать тоже не буду. Но нужно учесть, что если сделок слишком мало, то линейная регрессия будет рассчитана некорректно.

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

Relative_Prof = TeSt[STAT_PROFIT] / TeSt[STAT_INITIAL_DEPOSIT];

Не менее важно, а возможно даже и самый важный параметр — это просадка

Как рассчитывается просадка средств в тестере? Берётся максимальное количество средств слева и сравнивается с минимальным количеством средств справа.


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

Поэтому для меня важнее максимальная просадка — та, которая ниже баланса. То есть "обидно" игнорим, главное, чтобы не было сильно "больно".


double equityDD(const double & Balance[], const double & Equity[], const double & TeSt[], const double & coeff[], const int total)
  {
   if(TeSt[STAT_INITIAL_DEPOSIT] == 0)
      return(0);

   double Balance_max = Balance[0];
   double Equity_min = Equity[0];
   difference_B_E = 0;
   double Max_Balance = 0;

   switch((int)TeSt[41])
     {
      case  0:
         difference_B_E = TeSt[STAT_EQUITY_DD];
         break;
      default:
         for(int i = 0; i < total - 1; i++)
           {
            if(Balance_max < Balance[i])
               Balance_max = Balance[i];
            if(Balance[i] == 10963)
               Sleep(1);
            if(Balance_max - Equity[i + 1] > difference_B_E)
              {
               Equity_min = Equity[i + 1];
               difference_B_E = Balance_max - Equity_min;
               Max_Balance = Balance_max;
              }
           }
         break;
     }

   return(1 - difference_B_E / TeSt[STAT_INITIAL_DEPOSIT]);
  }

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

Получившееся значение я назвал: equity_rel — просадка средств относительно стартового баланса

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

Чтобы скрипт знал, как мы собирали эквити, пришлось эти варианты записывать в массив со статистикой тестера TeSt[41], и в функции EquityDD() рассчитывать equity_rel и difference_B_E, исходя из варианта сбора эквити.

//---

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

//---

На основе equity_rel можно посчитать альтернативный фактор восстановления. 

difference_B_E — максимальная просадка средств в деньгах.

coeff[c_recovery_factor] = coeff[Profit_Bak] / difference_B_E;

Но хотелось бы, чтобы график был поближе к прямой, поэтому во второй альтернативный фактор восстановления я добавил R^2

coeff[c_recovery_factor_r2] = coeff[Profit_Bak] / difference_B_E * coeff[r2];

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

Такая формула: относительный профит * R^2, может дать интересные результаты пользовательского критерия.

coeff[profit_r2] = relative_prof * coeff[r2];

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

Относительный профит *R^2 / Standard Error

   if(stand_err == 0)
      coeff[profit_r2_Err] = 0;
   else
      coeff[profit_r2_Err] = relative_prof * coeff[r2] / stand_err;

Относительный профит  есть,  просадка средств относительно стартового баланса есть, R^2 есть, напрашивается формула учитывающая и профит и просадку и максимальное приближение графика к прямой

relative_prof + equity_rel + r2;

Но если вдруг захочется какой-нибудь из этих параметров сделать более значимым? Для этого я ввёл переменную веса — ratio 

Получилось ещё три пользовательских критерия оптимизации.

coeff[profit_R_equity_r2] = relative_prof * ratio + coeff[equity_rel] + coeff[r2];

coeff[profit_equity_R_r2] = relative_prof + coeff[equity_rel] * ratio + coeff[r2];

coeff[profit_equity_r2_R] = relative_prof + coeff[equity_rel] + coeff[r2] * ratio;


Итого получилось двенадцать пользовательских критерия оптимизации.

1. R^2 - коэффициент детерминации.

2.  ProfitStability.

3.  КБТС - коэффициента безопасности торговой системы.

4.  LinearFactor

5.  equity_rel  

6. c_recovery_factor

7. c_recovery_factor_r2

8. profit_r2

9. profit_r2_Err

10. profit_R_equity_r2

11. profit_equity_R_r2

12. profit_equity_r2_R


Проверка получившегося результата

Для проверки  напишем простенький советник.....

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

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

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

Итак, запускаем оптимизацию три месяца оптимизации месяц форвард.  



После оптимизации перезагружаем терминал.

Запускаю скрипт, выбираю в настройках только лучшие результаты. Получаю в папке вот такие миниатюры.


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


В тестере этот же проход выглядит так:


Для сравнения максимальный баланс:


Максимум комплексного тестирования выглядит так:


Как видно, с настройками лучшего прохода profit_equity_R_r2, сделок на графике намного меньше, чем при максимальном балансе и максимумом комплексного, прибыль примерно одинаковая, но сам график намного ровнее.


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

Запускаем оптимизацию с форвардом и смотрим.

Настройки оптимизации.  


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

В результате мы видим, что если бы мы оптимизировали советник за последние три месяца с пользовательским критерием profit_equity_R_r2,

а затем торговали бы с 1 апреля по 1 мая с этими настройками, то советник заработал бы 750 тугриков, с просадкой по эквити в 300 тугриков.



Ну и контрольный выстрел в голову советника —  эксперт Validate   от fxsaber !!!

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


Как видим, советник выжил и после контрольного!!!

Для сравнения график, который получился с такими же настройками, но оптимизировали по "максимум комплексного критерия".



Видим, что советник тоже выжил, но ....


Заключение

Достоинства:

  1. Можно посмотреть сразу все графики результатов оптимизации.
  2. Возможность подобрать оптимальный пользовательский критерий оптимизации для своего советника.

Недостатки.

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

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

//---

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

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

//---

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


Как этим пользоваться

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

потом в терминале жмём -> файл -> открыть каталог данных -> щелчок правой кнопки мыши на пустом месте открывшейся папки и жмём "вставить", может появиться окно с вопросом "Заменить файлы в папке назначения" -> жмём заменить.

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

1. В функцию OnTick() вставить --> IsOnTick();

2. Этот код вставить в самый низ советника:

  #include <SkrShotOpt.mqh>     

  double OnTester() {return(IsOnTester());}

  void OnTradeTransaction(const MqlTradeTransaction & trans, const MqlTradeRequest & request,const MqlTradeResult & result) 
    {
      IsOnTradeTransaction(trans, request, result);
     }
 Если в советнике уже есть функция OnTradeTransaction(), то просто вставить в неё: IsOnTradeTransaction(trans, request, result);

3. Нажать кнопку "Компилировать". 

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


Настройки

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

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


  • Записывать проход, если трейдов больше — если ваш советник совершает много сделок, то этот параметр можно увеличить, чтоб сократить количество записываемых данных в файл с фреймами.
  • Записывать проход если профит больше % — по умолчанию отсеиваются убыточные проходы. Можно менять, если вас в принципе не волнует прибыль меньше определённого процента от стартового баланса.
  • Выбор значений эквити — выбираем "сохранять только min equity", если нужно правильно рассчитать следующие пользовательские критерии: quity_rel, c_recovery_factor, c_recovery_factor_r2, profit_r2, profit_r2_Err, profit_R_equity_r2, profit_equity_R_r2, profit_equity_r2_R  
    если же нужны графики более похожие на графики тестера, то нужно выбрать "сохранять min и max equity"

  • Пользовательский критерий — если ставим "none", то идёт запись фреймов в файл, пользовательский критерий при этом не рассчитывается (чтобы не увеличивать время оптимизации),

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

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

Нужно помнить, что расчёт пользовательского критерия зависит от выбора параметра "выбор значений эквити" и от параметра "расчёт критерия по"

  • расчёт критерия по - выбор расчёта R^2 на основе баланса или эквити, соответственно все значения пользовательских критериев, в расчёте которых участвует R^2 будут меняться
r2, profit_r2, profit_r2_Err, profit_R_equity_r2, profit_equity_R_r2, profit_equity_r2_R

    //----

    Настройки скрипта.


    • Рисовать линию регрессии - выбор какую линию регрессии рисовать, баланс, эквити, баланс и эквити, не рисовать линию регрессии.
    • Процент прибыли больше - распечатать несколько тысяч скринов занимает продолжительное время, поэтому можно распечатывать только скрины с прибылью больше, чем указано в этой настройке.
    • Только лучшие результаты - если true, то сохраняет скрины, только с лучшим результатом каждого пользовательского критерия иначе сохраняет все скрины.
    • Пользовательский критерий - если выбрано печатать все скрины, то этим параметром можно задать пользовательский критерий, по результатам которого будут отсортированы скрины в папке.
    • ratio - весовой коэффициент для расчётов пользовательских критериев profit_R_equity_r2, profit_equity_R_r2, profit_equity_r2_R.
    • расчёт критерия по - выбор расчёта R^2 на основе баланса или эквити, соответственно все значения пользовательских критериев, в расчёте которых участвует R^2 будут меняться

             r2, profit_r2, profit_r2_Err, profit_R_equity_r2, profit_equity_R_r2, profit_equity_r2_R.

    • График - выбор между, печатать график как в тестере "Back отдельно Forward" то есть форвард начинается со стартового баланса,  

    либо "Back продолжение Forward" в этом случае форвард будет продолжаться от последнего значения баланса Back-прохода.

    //---

    Статьи с этого сайта очень помогли мне в написании программы.

    Выражаю огромную благодарность всем авторам перечисленных здесь статей!


    Прикрепленные файлы |
    SkrShotOpt.mqh (17.58 KB)
    CustomCriterion.mqh (21.73 KB)
    MQL5.zip (12.79 KB)
    Последние комментарии | Перейти к обсуждению на форуме трейдеров (23)
    Mikola_2
    Mikola_2 | 6 окт. 2023 в 05:38
    Aleksandr Slavskii #:

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

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

    Нет-нет, я о графиках ВНУТРИ одного критерия.
    Aleksandr Slavskii
    Aleksandr Slavskii | 6 окт. 2023 в 05:45
    Mikola_2 #:
    Нет-нет, я о графиках ВНУТРИ одного критерия.

    Ну, получается никак.

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

    Здесь происходит то же самое, просто результаты сортируются по пользовательским критериям.

    Mikola_2
    Mikola_2 | 7 окт. 2023 в 13:36
    Aleksandr Slavskii #:

    Ну, получается никак.

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

    Здесь происходит то же самое, просто результаты сортируются по пользовательским критериям.

    А если предварительно отсортировать структуру и в m_BackBest[x][y].res запоминать только несовпадающее с предыдущим занесенным значение? Я попробовал - не осилил...   ))
    Aleksandr Slavskii
    Aleksandr Slavskii | 7 окт. 2023 в 15:57
    Mikola_2 #:
    А если предварительно отсортировать структуру и в m_BackBest[x][y].res запоминать только несовпадающее с предыдущим занесенным значение? Я попробовал - не осилил...   ))

    Дело в том, что там не может быть совпадающих значений.

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

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

    Andrey Dik
    Andrey Dik | 18 апр. 2024 в 18:14

    Спасибо за замечательную статью!

    На мой взгляд, название статьи мало отражает её главную ценность - работа с фитнес-функциями, хотя в этой статье всё прекрасно, автору респект!

    Графика в библиотеке DoEasy (Часть 92): Класс памяти стандартных графических объектов. История изменения свойств объекта Графика в библиотеке DoEasy (Часть 92): Класс памяти стандартных графических объектов. История изменения свойств объекта
    В статье создадим класс памяти стандартного графического объекта, позволяющий объекту сохранять свои состояния при модификации его свойств, что в свою очередь позволит в любое время вернуться к прошлым состояниям графического объекта.
    Графика в библиотеке DoEasy (Часть 91): События стандартных графических объектов в программе. История изменения имени объекта Графика в библиотеке DoEasy (Часть 91): События стандартных графических объектов в программе. История изменения имени объекта
    В статье доработаем базовый функционал для предоставления контроля событий графических объектов из программы, работающей на основе библиотеки. Начнём создание функционала для хранения истории изменений свойств графических объектов на примере свойства "Имя объекта".
    Разработка торгового советника с нуля Разработка торгового советника с нуля
    Давайте разберемся, как разработать советник для торговли с минимальным количеством программирования
    Работаем со временем (Часть 1): Основные принципы Работаем со временем (Часть 1): Основные принципы
    Рассмотренные в статье функции и код помогут лучше понять принципы обработки времени, смещение времени брокера и перехода на летнее или зимнее время. Точная работа со временем — очень важный аспект трейдинга. Лондонская или нью-йоркская биржа уже открылась или еще нет? Когда начинается и заканчивается торговая сессия на форексе?