preview
Разрабатываем мультивалютный советник (Часть 16): Влияние разных историй котировок на результаты тестирования

Разрабатываем мультивалютный советник (Часть 16): Влияние разных историй котировок на результаты тестирования

MetaTrader 5Тестер | 31 июля 2024, 11:32
63 1
Yuriy Bykov
Yuriy Bykov

Введение

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

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

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


Сравнение результатов

В начале запустим наш советник на котировках с сервера MetaQuotes-Demo. Первый запуск был с включённым риск-менеджером. Однако, забегая вперед, скажем, что на других котировках риск-менеджер завершил торговлю значительно раньше окончания интервала тестирования, поэтому отключим его для получения полной картины. Так мы сможем обеспечить более честное сравнение результатов. Вот что получилось:


Рис. 1. Результаты тестирования на котировках сервера MetaQuotes-Demo без риск-менеджера

Теперь подключим терминал к реальному серверу другого брокера и снова запустим тестирование советника с теми же параметрами:

Рис. 2. Результаты тестирования на котировках реального сервера другого брокера без риск-менеджера

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


Ищем причину

Сохраним отчеты тестера для сделанных проходов в виде XML-файлов, откроем их и найдем место, где начинается список совершённых сделок. Расположим окна открытых файлов так, чтобы можно было одновременно видеть верхние части списков сделок для обоих отчётов:

Рис. 3. Верхние части списков сделок, совершённых советником при тестировании на котировках различных серверов

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

Посмотрим, где у нас в стратегиях определяется моменты открытия позиций. Искать это следует в файле, реализующем класс одиночного экземпляра торговой стратегии SimpleVolumesStrategy.mqh. Если заглянуть в код, то мы довольно быстро найдём метод SignalForOpen(), возвращающий сигнал для открытия:

//+------------------------------------------------------------------+
//| Сигнал для открытия отложенных ордеров                           |
//+------------------------------------------------------------------+
int CSimpleVolumesStrategy::SignalForOpen() {
// По-умолчанию сигнала на открытие нет
   int signal = 0;

// Копируем значения объемов из индикаторного буфера в массив-приёмник
   int res = CopyBuffer(m_iVolumesHandle, 0, 0, m_signalPeriod, m_volumes);

// Если скопировалось нужное количество чисел
   if(res == m_signalPeriod) {
      // Вычисляем их среднее значение
      double avrVolume = ArrayAverage(m_volumes);

      // Если текущий объем превысил заданный уровень, то
      if(m_volumes[0] > avrVolume * (1 + m_signalDeviation + m_ordersTotal * m_signaAddlDeviation)) {
         // если цена открытия свечи меньше текущей цены (закрытия), то
         if(iOpen(m_symbol, m_timeframe, 0) < iClose(m_symbol, m_timeframe, 0)) {
            signal = 1; // сигнал на покупку
         } else {
            signal = -1; // иначе - сигнал на продажу
         }
      }
   }

   return signal;
}

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

Это вполне возможно, поскольку для того, чтобы у разных брокеров визуально совпадали свечные графики цены, достаточно дать всего четыре правильных тика в минуту для построения Open, Close, High и Low цен у свечи наименьшего периода M1. Сколько при этом было промежуточных тиков, в которые цена находилась в заданных пределах между Low и High — не важно. А значит, сколько тиков хранить в истории, как они будут распределены по времени внутри одной свечи зависит только от брокера, которые обладают достаточной свободой выбирать наиболее удобные для себя параметры. Не стоит забывать ещё и о том, что даже у одного брокера серверы для демо-счетов и для реальных счетов могут показывать не совсем совпадающую картину.

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


Намечаем путь

Для осуществления проверки нашего предположения нам пригодятся следующие инструменты:

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

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

Сделав эти инструменты, мы запустим вначале в тестере наш советник, используя котировки с сервера MetaQuotes-Demo и сохраним историю торговли этого прохода в файл. Это будет первый проход. Затем запустим в тестере новый советник воспроизведения торговли на котировках другого сервера, используя сохранённый файл с историей. Это будет второй проход. Если полученные ранее различия торговых результатов действительно обусловлены слишком сильно отличающимися данными о тиковых объёмах, а сами цены примерно одинаковы, то во втором проходе мы должны получить результаты, похожие на результаты первого прохода.


Сохранение истории

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

Основной публичный метод класса будет только один — Export(), остальные методы будут выполнять вспомогательную роль. Методу Export() мы будем передавать два параметра: имя файла для записи истории и флаг использования общей папки данных терминалов. Имя файла по умолчанию может быть пустой строкой. В этом случае для формирования имени файла будет использоваться вспомогательный метод GetHistoryFileName(). С помощью флага записи в общую папку мы сможем выбирать, куда будет сохраняться файл с историей: в общую папку данных или в локальную папку данных терминала. По умолчанию значение флага будет установлено на запись в общую папку, так как при запуске в тестере открыть потом локальную папку агента тестирования более сложно, чем общую папку.

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

//+------------------------------------------------------------------+
//| Экспорт истории сделок в файл                                    |
//+------------------------------------------------------------------+
class CExpertHistory {
private:
   static string     s_sep;            // Символ-разделитель
   static int        s_file;           // Хендл файла для записи
   static string     s_columnNames[];  // Массив названий столбцов

   // Запись истории сделок в файл
   static void       WriteDealsHistory();

   // Запись одной строки истории сделок в файл
   static void       WriteDealsHistoryRow(const string &fields[]);

   // Получение даты первой сделки
   static datetime   GetStartDate();

   // Формирование имени файла
   static string     GetHistoryFileName();

public:
   // Экспорт истории сделок
   static void       Export(
      string exportFileName = "",   // Имя файла для экспорта. Если пустое, то имя будет сгенерировано
      int commonFlag = FILE_COMMON  // Сохранять файл в общей папке данных
   );
};

// Статические переменные класса
string CExpertHistory::s_sep = ",";
int    CExpertHistory::s_file;
string CExpertHistory::s_columnNames[] = {"DATE", "TICKET", "TYPE",
                                          "SYMBOL", "VOLUME", "ENTRY", "PRICE",
                                          "STOPLOSS", "TAKEPROFIT", "PROFIT",
                                          "COMMISSION", "FEE", "SWAP",
                                          "MAGIC", "COMMENT"
                                         };

В основном методе Export() мы будем создавать и открывать файл на запись с указанным или сгенерированным именем. Если файл удалось открыть, то вызываем метод записи истории сделок и закрываем файл.

//+------------------------------------------------------------------+
//| Экспорт истории сделок                                           |
//+------------------------------------------------------------------+
void CExpertHistory::Export(string exportFileName = "", int commonFlag = FILE_COMMON) {
   // Если имя файла не задано, то сгенерируем его
   if(exportFileName == "") {
      exportFileName = GetHistoryFileName();
   }

   // Открываем файл на запись в нужной папке данных
   s_file = FileOpen(exportFileName, commonFlag | FILE_WRITE | FILE_CSV | FILE_ANSI, s_sep);

   // Если файл открыт, то
   if(s_file > 0) {
      // Записываем историю сделок
      WriteDealsHistory();

      // Закрываем файл
      FileClose(s_file);
   } else {
      PrintFormat(__FUNCTION__" | ERROR: Can't open file [%s]. Last error: %d",  exportFileName, GetLastError());
   }
}

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

Если имя получится слишком длинным, то укоротим его до допустимой длины и добавим расширение ".history.csv".

//+------------------------------------------------------------------+
//| Формирование имени файла                                         |
//+------------------------------------------------------------------+
string CExpertHistory::GetHistoryFileName() {
   // Берём название советника
   string fileName = MQLInfoString(MQL_PROGRAM_NAME);

   // Если указана версия, то добавляем её
#ifdef __VERSION__
   fileName += "." + __VERSION__;
#endif

   fileName += " ";

   // Добавляем дату начала и окончания истории
   fileName += "[" + TimeToString(GetStartDate(), TIME_DATE);
   fileName += " - " + TimeToString(TimeCurrent(), TIME_DATE) + "]";

   fileName += " ";

   // Добавляем несколько статистических характеристик
   fileName += "[" + DoubleToString(TesterStatistics(STAT_INITIAL_DEPOSIT), 0);
   fileName += ", " + DoubleToString(TesterStatistics(STAT_INITIAL_DEPOSIT) + TesterStatistics(STAT_PROFIT), 0);
   fileName += ", " + DoubleToString(TesterStatistics(STAT_EQUITY_DD_RELATIVE), 0);
   fileName += ", " + DoubleToString(TesterStatistics(STAT_SHARPE_RATIO), 2);
   fileName += "]";

   // Если имя получилось слишком длинным, то сокращаем его
   if(StringLen(fileName) > 255 - 13) {
      fileName = StringSubstr(fileName, 0, 255 - 13);
   }

   // Добавляем расширение
   fileName += ".history.csv";

   return fileName;
}

В методе записи истории в файл мы записываем сначала заголовок, то есть строку с названиями столбцов данных. Затем выбираем всю доступную историю и начинаем перебор всех сделок. Для каждой сделки получаем её свойства. Если это сделка на открытие позиции или балансовая операция, то формируем массив со значениями всех свойств сделки и передаём его методу записи одной сделки WriteDealsHistoryRow().

//+------------------------------------------------------------------+
//| Запись истории сделок в файл                                     |
//+------------------------------------------------------------------+
void CExpertHistory::WriteDealsHistory() {
   // Записываем заголовок с названиями столбцов
   WriteDealsHistoryRow(s_columnNames);

   // Переменные для свойств каждой сделки
   uint     total;
   ulong    ticket = 0;
   long     entry;
   double   price;
   double   sl, tp;
   double   profit, commission, fee, swap;
   double   volume;
   datetime time;
   string   symbol;
   long     type, magic;
   string   comment;

   // Берём всю историю
   HistorySelect(0, TimeCurrent());
   total = HistoryDealsTotal();

   // Для всех сделок
   for(uint i = 0; i < total; i++) {
      // Если сделка успешно выбрана, то
      if((ticket = HistoryDealGetTicket(i)) > 0) {
         // Получаем значения её свойств
         time  = (datetime)HistoryDealGetInteger(ticket, DEAL_TIME);
         type  = HistoryDealGetInteger(ticket, DEAL_TYPE);
         symbol = HistoryDealGetString(ticket, DEAL_SYMBOL);
         volume = HistoryDealGetDouble(ticket, DEAL_VOLUME);
         entry = HistoryDealGetInteger(ticket, DEAL_ENTRY);
         price = HistoryDealGetDouble(ticket, DEAL_PRICE);
         sl = HistoryDealGetDouble(ticket, DEAL_SL);
         tp = HistoryDealGetDouble(ticket, DEAL_TP);
         profit = HistoryDealGetDouble(ticket, DEAL_PROFIT);
         commission = HistoryDealGetDouble(ticket, DEAL_COMMISSION);
         fee = HistoryDealGetDouble(ticket, DEAL_FEE);
         swap = HistoryDealGetDouble(ticket, DEAL_SWAP);
         magic = HistoryDealGetInteger(ticket, DEAL_MAGIC);
         comment = HistoryDealGetString(ticket, DEAL_COMMENT);

         if(type == DEAL_TYPE_BUY || type == DEAL_TYPE_SELL || type == DEAL_TYPE_BALANCE) {
            // Заменяем в комментарии символы-разделители на пробел
            StringReplace(comment, s_sep, " ");

            // Формируем массив значений для записи одной сделки в строку файла
            string fields[] = {TimeToString(time, TIME_DATE | TIME_MINUTES | TIME_SECONDS),
                               IntegerToString(ticket), IntegerToString(type), symbol, DoubleToString(volume), IntegerToString(entry),
                               DoubleToString(price, 5), DoubleToString(sl, 5), DoubleToString(tp, 5), DoubleToString(profit),
                               DoubleToString(commission), DoubleToString(fee), DoubleToString(swap), IntegerToString(magic), comment
                              };

            // Записываем значения одной сделки в файл
            WriteDealsHistoryRow(fields);
         }
      }
   }
}

В методе WriteDealsHistoryRow() мы просто соединяем все значения из переданного массива в одну строку через указанный разделитель и записываем в открытый CSV-файл. Для соединения мы воспользовались новым макросом JOIN, который добавили к нашей коллекции макросов в файле Macros.mqh.

//+------------------------------------------------------------------+
//| Запись одной строки истории сделок в файл                        |
//+------------------------------------------------------------------+
void CExpertHistory::WriteDealsHistoryRow(const string &fields[]) {
   // Строка для записи
   string row = "";

   // Соединяем все значения массива в одну строку через разделитель
   JOIN(fields, row, ",");

   // Записываем строку в файл
   FileWrite(s_file, row);
}

Сохраним сделанные изменения в файле ExpertHistory.mqh в текущей папке.

Теперь остаётся совсем немного: подключить этот файл к файлу советника и добавить вызов метода CExpertHistory::Export()  в обработчик события OnTester():

...

#include "ExpertHistory.mqh"

...

//+------------------------------------------------------------------+
//| Результат тестирования                                           |
//+------------------------------------------------------------------+
double OnTester(void) {
   CExpertHistory::Export();
   return expert.Tester();
}

Сохраним сделанные изменения в файле SimpleVolumesExpert.mq5 в текущей папке.

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

SimpleVolumesExpert.1.19 [2021.01.01 - 2022.12.30] [10000, 34518, 1294, 3.75].history.csv

Из имени понятно, что история сделок охватывает два года (2021 и 2022), стартовый баланс счёта составлял $10000, а конечный — $34518. На протяжении интервала тестирования максимальная относительная просадка по средствам составила $1294, коэффициент Шарпа равен 3.75. Если открыть полученный файл в Excel, то увидим следующее:

Рис. 4. Результаты выгрузки истории сделок в CSV-файл

Данные выглядят вполне корректно. Приступим теперь ко второму этапу — напишем советник, который по этому CSV-файлу сможет воспроизвести торговлю на другом счёте.


Воспроизведение торговли

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

Как станет ясно в дальнейшем, к новому классу нам достаточно добавить следующие свойства:

  • m_symbols — массив названий символов (торговых инструментов);
  • m_history —  двумерный массив для чтения из файла истории сделок (N строк * 15 столбцов);
  • m_totalDeals — количество сделок в истории;
  • m_currentDeal —  текущий номер сделки;
  • m_symbolInfo — объект для получения информации о свойствах символа.
Начальные значения этим свойствам будут устанавливаться в конструкторе.
//+------------------------------------------------------------------+
//| Торговая стратегия воспроизведения истории сделок                |
//+------------------------------------------------------------------+
class CHistoryStrategy : public CVirtualStrategy {
protected:
   string            m_symbols[];            // Символы (торговые инструменты)
   string            m_history[][15];        // Массив истории сделок (N строк * 15 столбцов)
   int               m_totalDeals;           // Количество сделок в истории
   int               m_currentDeal;          // Текущий номер сделки

   CSymbolInfo       m_symbolInfo;           // Объект для получения информации о свойствах символа

public:
                     CHistoryStrategy(string p_params);        // Конструктор
   virtual void      Tick() override;        // Обработчик события OnTick
   virtual string    operator~() override;   // Преобразование объекта в строку
};

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

//+------------------------------------------------------------------+
//| Конструктор                                                      |
//+------------------------------------------------------------------+
CHistoryStrategy::CHistoryStrategy(string p_params) {
   m_params = p_params;

// Читаем имя файла из параметров
   string fileName = ReadString(p_params);

// Если имя прочитано, то
   if(IsValid()) {
      // Пробуем открыть файл в папке данных
      int f = FileOpen(fileName, FILE_READ | FILE_CSV | FILE_ANSI | FILE_SHARE_READ, ',');

      // Если открыть не получилось, то пробуем открыть файл из общей папки
      if(f == INVALID_HANDLE) {
         f = FileOpen(fileName, FILE_COMMON | FILE_READ | FILE_CSV | FILE_ANSI | FILE_SHARE_READ, ',');
      }

      // Если не получилось, то сообщаем об ошибке и выходим
      if(f == INVALID_HANDLE) {
         SetInvalid(__FUNCTION__,
                    StringFormat("ERROR: Can't open file %s from common folder %s, error code: %d",
                                 fileName, TerminalInfoString(TERMINAL_COMMONDATA_PATH), GetLastError()));
         return;
      }

      // Читаем файл до строки заголовка (обычно она идёт первой)
      while(!FileIsEnding(f)) {
         string s = FileReadString(f);
         // Если нашли строку заголовка, то читаем названия всех столбцов не сохраняя их
         if(s == "DATE") {
            FORI(14, FileReadString(f));
            break;
         }
      }

      // Читаем остальные строки до конца файла
      while(!FileIsEnding(f)) {
         // Если массив для хранения прочитанной истории заполнен, то увеличиваем его размер
         if(m_totalDeals == ArraySize(m_history)) {

            ArrayResize(m_history, ArraySize(m_history) + 10000, 100000);
         }

         // Читаем 15 значений из очередной строки файла в строку массива
         FORI(15, m_history[m_totalDeals][i] = FileReadString(f));

         // Если символ у сделки не пустой, то
         if(m_history[m_totalDeals][SYMBOL] != "") {
            // Добавляем его в массив символов, если такого символа там ещё нет
            ADD(m_symbols, m_history[m_totalDeals][SYMBOL]);
         }

         // Увеличиваем счётчик прочитанных сделок
         m_totalDeals++;
      }

      // Закрываем файл
      FileClose(f);

      PrintFormat(__FUNCTION__" | OK: Found %d rows in %s", m_totalDeals, fileName);

      // Если есть прочитанные сделки кроме самой первой (пополнения счёта), то
      if(m_totalDeals > 1) {
         // Устанавливаем точный размер для массива истории
         ArrayResize(m_history, m_totalDeals);

         // Текущее время
         datetime ct = TimeCurrent();

         PrintFormat(__FUNCTION__" |\n"
                     "Start time in tester:  %s\n"
                     "Start time in history: %s",
                     TimeToString(ct, TIME_DATE), m_history[0][DATE]);

         // Если дата начала тестирования больше даты начала истории, то сообщаем об ошибке
         if(StringToTime(m_history[0][DATE]) < ct) {
            SetInvalid(__FUNCTION__,
                       StringFormat("ERROR: For this history file [%s] set start date less than %s",
                                    fileName, m_history[0][DATE]));
         }
      }

      // Создаём виртуальные позиции для каждого символа
      CVirtualReceiver::Get(GetPointer(this), m_orders, ArraySize(m_symbols));

      // Регистрируем обработчик события нового бара на минимальном таймфрейме
      FOREACH(m_symbols, IsNewBar(m_symbols[i], PERIOD_M1));
   }
}

В нём мы читаем имя файла из строки инициализации и пытаемся его открыть. Если файл удалось открыть из локальной или из общей папки данных, то читаем его содержимое, наполняя им массив m_history. В процессе чтения мы также наполняем массив названий символов m_symbols: как только встречается новое название, мы сразу добавляем его в этот массив. Эту работу выполняет макрос ADD().

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

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

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

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

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

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

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

Чтобы упростить расчёты, мы поступим следующим образом:

  • Переведём объём новой сделки в формат "со знаком". То есть если она была в направлении SELL, то сделаем её объём отрицательным.
  • Получим объём открытой сделки по такому же символу, как в новой сделке. Метод CVirtualOrder::Volume() сразу возвращает объём в формате "со знаком".
  • Прибавим к объёму новой сделки объём уже открытой позиции. Мы получим новый объём, который должен остаться открытым после учёта новой сделки. Этот объём тоже будет в формате "со знаком".
  • Закроем открытую виртуальную позицию.
  • Если новый объём не равен нулю, то открываем новую виртуальную позицию по данному символу. Её направление мы определяем по знаку нового объёма (положительный — BUY, отрицательный — SELL), а в качестве объёма в метод открытия виртуальной позиции передаём модуль нового объёма.

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

//+------------------------------------------------------------------+
//| Обработчик события OnTick                                        |
//+------------------------------------------------------------------+
void CHistoryStrategy::Tick() override {
//---
   while(m_currentDeal < m_totalDeals && StringToTime(m_history[m_currentDeal][DATE]) <= TimeCurrent()) {
      // Символ сделки
      string symbol = m_history[m_currentDeal][SYMBOL];
      
      // Ищем индекс символа текущей сделки в массиве символов
      int index;
      FIND(m_symbols, symbol, index);

      // Если не нашли, то пропускаем текущую сделку
      if(index == -1) {
         m_currentDeal++;
         continue;
      }
      
      // Тип сделки
      ENUM_DEAL_TYPE type = (ENUM_DEAL_TYPE) StringToInteger(m_history[m_currentDeal][TYPE]);

      // Объем текущей сделки
      double volume = NormalizeDouble(StringToDouble(m_history[m_currentDeal][VOLUME]), 2);

      // Если это пополнение/снятие со счёта, то пропускаем эту сделку
      if(volume == 0) {
         m_currentDeal++;
         continue;
      }

      // Сообщаем информацию о прочитанной сделке
      PrintFormat(__FUNCTION__" | Process deal #%d: %s %.2f %s",
                  m_currentDeal, (type == DEAL_TYPE_BUY ? "BUY" : (type == DEAL_TYPE_SELL ? "SELL" : EnumToString(type))),
                  volume, symbol);

      // Если это сделка на продажу, то делаем объём отрицательным
      if(type == DEAL_TYPE_SELL) {
         volume *= -1;
      }

      // Если виртуальная позиция для символа текущей сделки открыта, то
      if(m_orders[index].IsOpen()) {
         // Добавляем её объем к объёму текущей сделки
         volume += m_orders[index].Volume();
         
         // Закрываем виртуальную позицию
         m_orders[index].Close();
      }

      // Если объём по текущему символу не равен 0, то
      if(MathAbs(volume) > 0.00001) {
         // Открываем виртуальную позицию нужного объёма и направления
         m_orders[index].Open(symbol, (volume > 0 ? ORDER_TYPE_BUY : ORDER_TYPE_SELL), MathAbs(volume));
      }

      // Увеличиваем счётчик обработанных сделок
      m_currentDeal++;
   }
}

Сохраним полученный код в файле HistoryStrategy.mqh в текущей папке.

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

input group "::: Тестирование истории сделок"
input string historyFileName_    = "";    // Файл с историей

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

В строке инициализации эксперта нам нужно прописать создание одного экземпляра стратегии класса CHistoryStrategy с передачей ей имени файла с историей в качестве аргумента:

// Подготавливаем строку инициализации для эксперта с группой из нескольких стратегий
   string expertParams = StringFormat(
                            "class CVirtualAdvisor(\n"
                            "    class CVirtualStrategyGroup(\n"
                            "       [\n"
                            "        class CHistoryStrategy(\"%s\")\n"
                            "       ],%f\n"
                            "    ),\n"
                            "    class CVirtualRiskManager(\n"
                            "       %d,%.2f,%d,%.2f,%.2f,%d,%.2f,%.2f,%d,%.2f,%d,%.2f,%.2f"
                            "    )\n"
                            "    ,%d,%s,%d\n"
                            ")",
                            historyFileName_, scale_,
                            rmIsActive_, rmStartBaseBalance_,
                            rmCalcDailyLossLimit_, rmMaxDailyLossLimit_, rmCloseDailyPart_,
                            rmCalcOverallLossLimit_, rmMaxOverallLossLimit_, rmCloseOverallPart_,
                            rmCalcOverallProfitLimit_, rmMaxOverallProfitLimit_, rmMaxOverallProfitDate_,
                            rmMaxRestoreTime_, rmLastVirtualProfitFactor_,
                            magic_, "HistoryReceiver", useOnlyNewBars_
                         );

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

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

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

Реализация такого класса может выглядеть примерно так:

//+------------------------------------------------------------------+
//| Класс эксперта воспроизведения истории сделок                    |
//+------------------------------------------------------------------+
class CVirtualHistoryAdvisor : public CAdvisor {
protected:
   CVirtualReceiver *m_receiver;       // Объект получателя, выводящий позиции на рынок
   bool              m_useOnlyNewBar;  // Обрабатывать только тики нового бара
   datetime          m_fromDate;       // Время начала работы (тестирования)

public:
   CVirtualHistoryAdvisor(string p_param);   // Конструктор
   ~CVirtualHistoryAdvisor();                // Деструктор

   virtual void      Tick() override;        // Обработчик события OnTick
   virtual double    Tester() override;      // Обработчик события OnTester

   virtual string    operator~() override;   // Преобразование объекта в строку
};


//+------------------------------------------------------------------+
//| Конструктор                                                      |
//+------------------------------------------------------------------+
CVirtualHistoryAdvisor::CVirtualHistoryAdvisor(string p_params) {
// Запоминаем строку инициализации
   m_params = p_params;

// Читаем имя файла из строки инициализации
   string fileName = ReadString(p_params);

// Читаем признак работы на только на открытии бара
   m_useOnlyNewBar = (bool) ReadLong(p_params);

// Если нет ошибок чтения, то
   if(IsValid()) {
      if(!MQLInfoInteger(MQL_TESTER)) {
         // Иначе устанавливаем ошибочное состояние объекта
         SetInvalid(__FUNCTION__, "ERROR: This expert can run only in tester");
         return;
      }

      if(fileName == "") {
         // Иначе устанавливаем ошибочное состояние объекта
         SetInvalid(__FUNCTION__, "ERROR: Set file name with deals history in ");
         return;
      }

      string strategyParams = StringFormat("class CHistoryStrategy(\"%s\")", fileName);

      CREATE(CHistoryStrategy, strategy, strategyParams);

      Add(strategy);

      // Инициализируем получателя статическим получателем
      m_receiver = CVirtualReceiver::Instance(65677);

      // Запоминаем время начала работы (тестирования)
      m_fromDate = TimeCurrent();
   }
}

//+------------------------------------------------------------------+
//| Деструктор                                                       |
//+------------------------------------------------------------------+
void CVirtualHistoryAdvisor::~CVirtualHistoryAdvisor() {
   if(!!m_receiver)     delete m_receiver;      // Удаляем получатель
   DestroyNewBar();           // Удаляем объекты отслеживания нового бара
}


//+------------------------------------------------------------------+
//| Обработчик события OnTick                                        |
//+------------------------------------------------------------------+
void CVirtualHistoryAdvisor::Tick(void) {
// Определяем новый бар по всем нужным символам и таймфреймам
   bool isNewBar = UpdateNewBar();

// Если нигде нового бара нет, а мы работаем только по новым барам, то выходим
   if(!isNewBar && m_useOnlyNewBar) {
      return;
   }

// Запуск обработки в стратегиях
   CAdvisor::Tick();

// Получатель обрабатывает виртуальные позиции
   m_receiver.Tick();

// Корректировка рыночных объемов
   m_receiver.Correct();
}

//+------------------------------------------------------------------+
//| Обработчик события OnTester                                      |
//+------------------------------------------------------------------+
double CVirtualHistoryAdvisor::Tester() {
// Максимальная абсолютная просадка
   double balanceDrawdown = TesterStatistics(STAT_EQUITY_DD);

// Прибыль
   double profit = TesterStatistics(STAT_PROFIT);

// Фиксированный баланс для торговли из настроек
   double fixedBalance = CMoney::FixedBalance();

// Коэффициент возможного увеличения размеров позиций для просадки 10% от fixedBalance_
   double coeff = fixedBalance * 0.1 / MathMax(1, balanceDrawdown);

// Пресчитываем прибыль в годовую
   long totalSeconds = TimeCurrent() - m_fromDate;
   double totalYears = totalSeconds / (365.0 * 24 * 3600);
   double fittedProfit = profit * coeff / totalYears;

// Если он не указан, то берём начальный баланс (хотя это будет давать искажённый результат)
   if(fixedBalance < 1) {
      fixedBalance = TesterStatistics(STAT_INITIAL_DEPOSIT);
      balanceDrawdown = TesterStatistics(STAT_EQUITY_DDREL_PERCENT);
      coeff = 0.1 / balanceDrawdown;
      fittedProfit = fixedBalance * MathPow(1 + profit * coeff / fixedBalance, 1 / totalYears);
   }

   return fittedProfit;
}

//+------------------------------------------------------------------+
//| Преобразование объекта в строку                                  |
//+------------------------------------------------------------------+
string CVirtualHistoryAdvisor::operator~() {
   return StringFormat("%s(%s)", typename(this), m_params);
}
//+------------------------------------------------------------------+

Эксперт этого класса будет принимать только два параметра в строке инициализации: имя файла с историей и флаг работы только на открытии минутного бара. Сохраним этот код в файле VirtualHistoryAdvisor.mqh в текущей папке.

Файл советника, использующего этот класс, тоже можно несколько подсократить по сравнению с предыдущим вариантом:

//+------------------------------------------------------------------+
//| Входные параметры                                                |
//+------------------------------------------------------------------+
input group "::: Тестирование истории сделок"
input string historyFileName_    = "";    // Файл с историей
input group "::: Управление капиталом"
sinput double fixedBalance_      = 10000; // - Используемый депозит (0 - использовать весь) в валюте счета
input  double scale_             = 1.00;  // - Масштабирующий множитель для группы

input group "::: Прочие параметры"
input bool     useOnlyNewBars_   = true;  // - Работать только на открытии бара

datetime fromDate = TimeCurrent();        // Время начала работы (тестирования)

CVirtualHistoryAdvisor     *expert;       // Объект эксперта

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit() {
// Устанавливаем параметры в классе управления капиталом
   CMoney::DepoPart(scale_);
   CMoney::FixedBalance(fixedBalance_);

// Подготавливаем строку инициализации для эксперта воспроизведения истории сделок
   string expertParams = StringFormat(
                            "class CVirtualHistoryAdvisor(\"%s\",%f,%d)",
                            historyFileName_, useOnlyNewBars_
                         );

// Создаем эксперта, работающего с виртуальными позициями
   expert = NEW(expertParams);

// Если эксперт не создан, то возвращаем ошибку
   if(!expert) return INIT_FAILED;

// Успешная инициализация
   return(INIT_SUCCEEDED);
}

//+------------------------------------------------------------------+
//| Expert tick function                                             |
//+------------------------------------------------------------------+
void OnTick() {
   expert.Tick();
}

//+------------------------------------------------------------------+
//| Expert deinitialization function                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason) {
   if(!!expert) delete expert;
}

//+------------------------------------------------------------------+
//| Результат тестирования                                           |
//+------------------------------------------------------------------+
double OnTester(void) {
   return expert.Tester();
}
//+------------------------------------------------------------------+

Сохраним этот код в файле SimpleHistoryReceiverExpert.mq5 в текущей папке.


Результаты тестирования

Запустим один из созданных советников, указав корректное имя файла с сохранённой историей сделок. Сначала запустим на том же сервере котировок, который использовался для получения истории (MetaQuotes-Demo). Полученные результаты тестирования совпали с исходными результатами полностью! Надо признаться, что это даже несколько неожиданно хороший результат, говорящий о корректной реализации задуманного. 

Теперь посмотрим, что будет при запуске советника на другом сервере:


Рис. 5. Результаты воспроизведения истории сделок на котировках реального сервера другого брокера

График кривой баланса на взгляд почти неотличим от графика для исходных результатов торговли на MetaQuotes-Demo. Однако численные значения немного отличаются. Давайте ещё раз посмотрим на исходные значения для сравнения:


Рис. 6. Результаты исходного тестирования на котировках сервера MetaQuotes-Demo

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


Заключение

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

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

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

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

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

Спасибо за внимание, до новых встреч! 


Содержание архива

#
 Имя
Версия  Описание   Последние изменения
 MQL5/Experts/Article.15330
1 Advisor.mqh 1.04 Базовый класс эксперта Часть 10
2 Database.mqh 1.03 Класс для работы с базой данных Часть 13
3 ExpertHistory.mqh 1.00 Класс для экспорта истории сделок в файл Часть 16
4 Factorable.mqh 1.01 Базовый класс объектов, создаваемых из строки Часть 10
5 HistoryReceiverExpert.mq5 1.00 Советник воспроизведения истории сделок с риск-менеджером Часть 16  
6 HistoryStrategy.mqh  1.00 Класс торговой стратегии воспроизведения истории сделок  Часть 16
7 Interface.mqh 1.00 Базовый класс визуализации различных объектов Часть 4
8 Macros.mqh 1.02 Полезные макросы для операций с массивами Часть 16  
9 Money.mqh 1.01  Базовый класс управления капиталом Часть 12
10 NewBarEvent.mqh 1.00  Класс определения нового бара для конкретного символа  Часть 8
11 Receiver.mqh 1.04  Базовый класс перевода открытых объемов в рыночные позиции  Часть 12
12 SimpleHistoryReceiverExpert.mq5 1.00 Упрощённый советник воспроизведения истории сделок   Часть 16
13 SimpleVolumesExpert.mq5 1.19 Советник для параллельной работы нескольких групп модельных стратегий. Параметры должны загружаться из базы данных оптимизации. Часть 16
14 SimpleVolumesStrategy.mqh 1.09  Класс торговой стратегии с использованием тиковых объемов Часть 15
15 Strategy.mqh 1.04  Базовый класс торговой стратегии Часть 10
16 TesterHandler.mqh  1.02 Класс для обработки событий оптимизации  Часть 13 
17 VirtualAdvisor.mqh  1.06  Класс эксперта, работающего с виртуальными позициями (ордерами) Часть 15
18 VirtualChartOrder.mqh  1.00  Класс графической виртуальной позиции Часть 4  
19 VirtualFactory.mqh 1.04  Класс фабрики объектов  Часть 16
20 VirtualHistoryAdvisor.mqh 1.00  Класс эксперта воспроизведения истории сделок  Часть 16
21 VirtualInterface.mqh  1.00  Класс графического интерфейса советника  Часть 4  
22 VirtualOrder.mqh 1.04  Класс виртуальных ордеров и позиций  Часть 8
23 VirtualReceiver.mqh 1.03  Класс перевода открытых объемов в рыночные позиции (получатель)  Часть 12
24 VirtualRiskManager.mqh  1.02  Класс управления риском (риск-менеждер)  Часть 15
25 VirtualStrategy.mqh 1.05  Класс торговой стратегии с виртуальными позициями  Часть 15
26 VirtualStrategyGroup.mqh  1.00  Класс группы торговых стратегий или групп торговых стратегий Часть 11 
27 VirtualSymbolReceiver.mqh  1.00 Класс символьного получателя  Часть 3
MQL5/Files 
1 SimpleVolumesExpert.1.19 [2021.01.01 - 2022.12.30] [10000, 34518, 1294, 3.75].history.csv    История сделок советника SimpleVolumesExpert.mq5, полученная после экспорта. Может быть использована для воспроизведения в тестере сделок с помощью советников SimpleHistoryReceiverExpert.mq5 или HistoryReceiverExpert.mq5  
Прикрепленные файлы |
MQL5.zip (172.36 KB)
Последние комментарии | Перейти к обсуждению на форуме трейдеров (1)
fxsaber
fxsaber | 31 июл. 2024 в 13:24
Сервис Сигналов ровно этим и занимается.
Машинное обучение и Data Science (Часть 21): Сравниваем алгоритмы оптимизации в нейронных сетях Машинное обучение и Data Science (Часть 21): Сравниваем алгоритмы оптимизации в нейронных сетях
В этой статье мы заглянем в самую глубь нейронных сетей и поговорим об используемых в них алгоритмах оптимизации. В частности обсудим ключевые методы, которые позволяют раскрыть потенциал нейронных сетей и повысить точность и эффективность моделей.
Возможности Мастера MQL5, которые вам нужно знать (Часть 13): DBSCAN для класса сигналов советника Возможности Мастера MQL5, которые вам нужно знать (Часть 13): DBSCAN для класса сигналов советника
Основанная на плотности пространственная кластеризация для приложений с шумами (Density Based Spatial Clustering for Applications with Noise, DBSCAN) - это неконтролируемая форма группировки данных, которая практически не требует каких-либо входных параметров, за исключением всего двух, что по сравнению с другими подходами, такими как k-средние, является преимуществом. Разберемся в том, как это может быть полезно в тестировании и торговле с применением советников, собранных в Мастере.
Возможности Мастера MQL5, которые вам нужно знать (Часть 14): Многоцелевое прогнозирование таймсерий с помощью STF Возможности Мастера MQL5, которые вам нужно знать (Часть 14): Многоцелевое прогнозирование таймсерий с помощью STF
Пространственно-временное слияние (Spatial Temporal Fusion, STF), которое использует как "пространственные", так и временные метрики при моделировании данных, в первую очередь применяется в дистанционном обследовании и во многих других областях, связанных с визуализацией, для лучшего понимания нашего окружения. Основываясь на опубликованной статье, мы изучим потенциал этого подхода для трейдеров.
Нейросети в трейдинге: Использование языковых моделей для прогнозирования временных рядов Нейросети в трейдинге: Использование языковых моделей для прогнозирования временных рядов
Мы продолжаем рассмотрения моделей прогнозирования временных рядов. И в данной статье я предлагаю познакомиться с комплексным алгоритмом, построенным на использовании предварительно обученной языковой модели.