preview
Разрабатываем мультивалютный советник (Часть 9): Сбор результатов оптимизации одиночных экземпляров торговой стратегии

Разрабатываем мультивалютный советник (Часть 9): Сбор результатов оптимизации одиночных экземпляров торговой стратегии

MetaTrader 5Тестер | 24 апреля 2024, 16:56
388 0
Yuriy Bykov
Yuriy Bykov

Введение

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

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

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


Основные этапы

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

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

  2. Оптимизация торговой стратегии. Подбираем хорошие наборы входных параметров торговой стратегии, показывающие достойные внимания результаты. Если таковых не обнаружено, то возвращаемся к пункту 1.
    Как правило, нам удобнее проводить оптимизацию на одном символе и таймфрейме. Для генетической оптимизации нам, скорее всего, понадобится запускать её несколько раз с различными критериями оптимизации, в том числе и каким-то своим. Использовать оптимизацию с полным перебором получится только в стратегиях с очень небольшим числом параметров. Даже в нашей модельной стратегии полный перебор слишком затратный. Поэтому дальше, говоря про оптимизацию, будем
    подразумевать именно генетическую оптимизацию в тестере стратегий MetaTrader5. Процесс оптимизации детально не описывался в статьях, так как он стандартный.

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

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

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

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

  7. Другие стратегии. Если на примете есть ещё другие торговые стратегии, то повторяем для каждой из них этапы 1 - 6.

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

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

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

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

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

Для всех этих получаемых данных хотелось бы реализовать единую схему хранения и использования. 


Варианты реализации

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

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

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

Тогда посмотрим в сторону организации хранения всех результатов в базе данных. На первый взгляд, это потребует достаточно больших временных затрат на реализацию. Но эту работу можно будет разбить на более мелкие этапы, и её результатами мы сможем воспользоваться сразу, не дожидаясь полной реализации. Также у такого подхода присутствует большая свобода в выборе наиболее удобных средств промежуточной обработки хранимых результатов. Например, какую-то обработку мы сможем поручить простым SQL-запросам, что-то будет рассчитываться в MQL5, а что-то — в программах, написанных, например, на Python или R. Мы сможем попробовать разные варианты обработки и выбрать наиболее подходящий.

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


Начинаем проектирование БД

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

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

CREATE TABLE passes (
    id                    INTEGER  PRIMARY KEY AUTOINCREMENT,
    pass                  INT,	-- номер прохода

    inputs                TEXT, -- значения входных параметров прохода
    params                TEXT, -- дополнительная информация о проходе

    initial_deposit       REAL, -- результаты прохода...
    withdrawal            REAL,
    profit                REAL,
    gross_profit          REAL,
    gross_loss            REAL,
    max_profittrade       REAL,
    max_losstrade         REAL,
    conprofitmax          REAL,
    conprofitmax_trades   REAL,
    max_conwins           REAL,
    max_conprofit_trades  REAL,
    conlossmax            REAL,
    conlossmax_trades     REAL,
    max_conlosses         REAL,
    max_conloss_trades    REAL,
    balancemin            REAL,
    balance_dd            REAL,
    balancedd_percent     REAL,
    balance_ddrel_percent REAL,
    balance_dd_relative   REAL,
    equitymin             REAL,
    equity_dd             REAL,
    equitydd_percent      REAL,
    equity_ddrel_percent  REAL,
    equity_dd_relative    REAL,
    expected_payoff       REAL,
    profit_factor         REAL,
    recovery_factor       REAL,
    sharpe_ratio          REAL,
    min_marginlevel       REAL,
    deals                 REAL,
    trades                REAL,
    profit_trades         REAL,
    loss_trades           REAL,
    short_trades          REAL,
    long_trades           REAL,
    profit_shorttrades    REAL,
    profit_longtrades     REAL,
    profittrades_avgcon   REAL,
    losstrades_avgcon     REAL,
    complex_criterion     REAL,
    custom_ontester       REAL,
    pass_date             DATETIME DEFAULT (datetime('now') ) 
                                   NOT NULL
);

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

В составе этого класса будет поле s_db для хранения хендла открытой базы данных. Устанавливать его значение будет метод открытия базы данных Open(). Если база данных на момент открытия ещё не создана, то она будет создаваться с помощью вызова метода Create(). После открытия мы сможем выполнять одиночные SQL-запросы к базе данных при помощи метода Execute() или массовые SQL-запросы в одной транзакции с помощью метода ExecuteTransaction(). В конце мы будем закрывать базу данных при помощи метода Close().

Также мы можем объявить короткий макрос, позволяющий использовать вместо более длинного имени класса CDatabase более короткое имя DB.

#define DB CDatabase

//+------------------------------------------------------------------+
//| Класс для работы с базой данных                                  |
//+------------------------------------------------------------------+
class CDatabase {
   static int        s_db;          // Хендл соединения с БД
   static string     s_fileName;    // Имя файла БД
public:
   static bool       IsOpen();      // Открыта ли БД?

   static void       Create();      // Создание пустой БД
   static void       Open();        // Открытие БД
   static void       Close();       // Закрытие БД

   // Выполнение одного запроса к БД
   static bool       Execute(string &query);

   // Выполнение нескольких запросов к БД в одной транзакции
   static bool       ExecuteTransaction(string &queries[]);
};

int    CDatabase::s_db       =  INVALID_HANDLE;
string CDatabase::s_fileName = "database.sqlite";


В методе создания базы данных мы пока просто создадим массив с SQL-запросами на создание таблиц и выполним их в одной транзакции:

//+------------------------------------------------------------------+
//| Создание пустой БД                                               |
//+------------------------------------------------------------------+
void CDatabase::Create() {
   // Массив запросов создания БД 
   string queries[] = {
      "DROP TABLE IF EXISTS passes;",

      "CREATE TABLE passes ("
      "id                    INTEGER  PRIMARY KEY AUTOINCREMENT,"
      "pass                  INT,"
      "inputs                TEXT,"
      "params                TEXT,"
      "initial_deposit       REAL,"
      "withdrawal            REAL,"
      "profit                REAL,"
      "gross_profit          REAL,"
      "gross_loss            REAL,"
      ...
      "pass_date             DATETIME DEFAULT (datetime('now') ) NOT NULL"
      ");"
      ,
   };

   // Выполняем все запросы
   ExecuteTransaction(queries);
}


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

//+------------------------------------------------------------------+
//| Открыта ли БД?                                                   |
//+------------------------------------------------------------------+
bool CDatabase::IsOpen() {
   return (s_db != INVALID_HANDLE);
}
...

//+------------------------------------------------------------------+
//| Открытие БД                                                      |
//+------------------------------------------------------------------+
void CDatabase::Open() {
// Пробуем открыть существующий файл БД
   s_db = DatabaseOpen(s_fileName, DATABASE_OPEN_READWRITE | DATABASE_OPEN_COMMON);

// Если файл БД не найден, то пытаемся создать его при открытии
   if(!IsOpen()) {
      s_db = DatabaseOpen(s_fileName,
                          DATABASE_OPEN_READWRITE | DATABASE_OPEN_CREATE |
                          DATABASE_OPEN_COMMON);

      // Сообщаем об ошибке при неудаче
      if(!IsOpen()) {
         PrintFormat(__FUNCTION__" | ERROR: %s open failed with code %d",
                     s_fileName, GetLastError());
         return;
      }

      // Создаём структуру базы данных
      Create();
   }
   PrintFormat(__FUNCTION__" | Database %s opened successfully", s_fileName);
}


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

//+------------------------------------------------------------------+
//| Выполнение нескольких запросов к БД в одной транзакции           |
//+------------------------------------------------------------------+
bool CDatabase::ExecuteTransaction(string &queries[]) {
// Открываем транзакцию
   DatabaseTransactionBegin(s_db);

   bool res = true;
// Отправляем все запросы на выполнение
   FOREACH(queries, {
      res &= Execute(queries[i]);
      if(!res) break;
   });

// Если в каком-то запросе возникла ошибка, то
   if(!res) {
      // Сообщаем о ней
      PrintFormat(__FUNCTION__" | ERROR: Transaction failed, error code=%d", GetLastError());
      // Отменяем транзакцию
      DatabaseTransactionRollback(s_db);
   } else {
      // Иначе - подтверждаем транзакцию
      DatabaseTransactionCommit(s_db);
      PrintFormat(__FUNCTION__" | Transaction done successfully");
   }
   return res;
}


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


Модификация советника для сбора данных оптимизации

При использовании в процессе оптимизации только агентов на локальном компьютере мы можем организовать сохранение результатов прохода в базу данных либо в обработчике события OnTester(), либо в обработчике события OnDeinit(). При использовании агентов в локальной сети или агентов в MQL5 Cloud Network добиться сохранения результатов будет если и возможно, то очень сложно. К счастью, MQL5 предлагает замечательный стандартный способ получения любой информации от агентов тестирования, где бы они ни находились, через создание, отправку и получение фреймов данных.

Этот механизм достаточно подробно описан в справке и в учебнике по алготрейдингу. Для того чтобы им воспользоваться, нам необходимо добавить в оптимизируемого советника три дополнительных обработчика событий: OnTesterInit(), OnTesterPass() и OnTesterDeinit()

Запуск оптимизации всегда выполняется из какого-либо терминала MetaTrader 5, который мы далее будем условно называть главным. При запуске из главного терминала оптимизации советника с такими обработчиками, перед раздачей экземпляров советника агентам тестирования для выполнения обычных проходов оптимизации с разными наборами параметров, происходит открытие нового графика в главном терминале, и на этом графике запускается ещё один экземпляр советника.

Этот экземпляр запускается в особом режиме: в нём не выполняются стандартные обработчики OnInit(), OnTick() и OnDeinit(), а выполняются только эти три новых обработчика. У этого режима даже есть своё название - режим сбора фреймов результатов оптимизации. При необходимости в функциях советника мы можем проверить, что советник запущен в этом режиме с помощью такого вызова функции MQLInfoInteger():

// Проверка запуска советника в режиме сбора фреймов данных 
bool isFrameMode = MQLInfoInteger(MQL_FRAME_MODE); 

Как следует из названий, в режиме сбора фреймов обработчик OnTesterInit() выполняется однократно перед началом процесса оптимизации, OnTesterPass() выполняется каждый раз, когда какой-либо из агентов тестирования завершает проход, а OnTesterDeinit() выполняется однократно после завершения всех запланированных проходов оптимизации или её прерывании.

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

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

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

Сейчас у нас в нём расположен код, который рассчитывает пользовательский критерий, показывающий прогнозируемую прибыль, которую можно было бы получить при условии максимальной достигаемой просадки в 10%:

//+------------------------------------------------------------------+
//| Результат тестирования                                           |
//+------------------------------------------------------------------+
double OnTester(void) {
// Максимальная абсолютная просадка
   double balanceDrawdown = TesterStatistics(STAT_EQUITY_DD);

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

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

// Пресчитываем прибыль
   double fittedProfit = profit * coeff;

   return fittedProfit;
}


Давайте перенесем этот код из файла советника SimpleVolumesExpertSingle.mq5 в новый метод класса CVirtualAdvisor, а в советнике оставим только возврат результата вызова этого метода:

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


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

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

//+------------------------------------------------------------------+
//| Класс эксперта, работающего с виртуальными позициями (ордерами)  |
//+------------------------------------------------------------------+
class CVirtualAdvisor : public CAdvisor {
protected:
   ...

   datetime          m_fromDate;

public:
   ...
   virtual double    Tester() override;         // Обработчик события OnTester
   ...
};


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

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

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

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

// Выполняем формирование фрейма данных на агенте тестирования
   CTesterHandler::Tester(fittedProfit,
                          ~((CVirtualStrategy *) m_strategies[0]));

   return fittedProfit;
}


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

Добавим теперь в файл советника SimpleVolumesExpertSingle.mq5 новые обработчики OnTesterInit(), OnTesterPass() и OnTesterDeinit(). Так как по нашей задумке логика работы этих функций должна быть единой для всех советников, то их реализацию мы сначала опустим на уровень эксперта (объекта класса CVirtualAdvisor).

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

//+------------------------------------------------------------------+
//| Инициализация перед началом оптимизации                          |
//+------------------------------------------------------------------+
int OnTesterInit(void) {
   return CVirtualAdvisor::TesterInit();
}

//+------------------------------------------------------------------+
//| Действия после завершения очередного прохода при оптимизации     |
//+------------------------------------------------------------------+
void OnTesterPass() {
   CVirtualAdvisor::TesterPass();
}

//+------------------------------------------------------------------+
//| Действия после завершения оптимизации                            |
//+------------------------------------------------------------------+
void OnTesterDeinit(void) {
   CVirtualAdvisor::TesterDeinit();
}


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

При таком подходе функция инициализации советника OnInit() будет выглядеть так:

int OnInit() {
   CMoney::FixedBalance(fixedBalance_);

// Создаем эксперта, работающего с виртуальными позициями
   expert = new CVirtualAdvisor(
      new CSimpleVolumesStrategy(
         symbol_, timeframe_,
         signalPeriod_, signalDeviation_, signaAddlDeviation_,
         openDistance_, stopLevel_, takeLevel_, ordersExpiration_,
         maxCountOfOrders_, 0), // Один экземпляр стратегии
      magic_, "SimpleVolumesSingle", true);

   return(INIT_SUCCEEDED);
}

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

Модификация класса эксперта

Чтобы не перегружать кодом класс эксперта CVirtualAdvisor, давайте вынесем код обработчиков событий TesterInit, TesterPass и OnTesterDeinit в отдельный класс CTesterHandler, в котором создадим статические методы для обработки каждого из этих событий. Тогда в классе CVirtualAdvisor нам достаточно добавить примерно такой же код, как и в основном файле советника:

//+------------------------------------------------------------------+
//| Класс эксперта, работающего с виртуальными позициями (ордерами)  |
//+------------------------------------------------------------------+
class CVirtualAdvisor : public CAdvisor {
   ...

public:
   ...
   static int        TesterInit();     // Обработчик события OnTesterInit
   static void       TesterPass();     // Обработчик события OnTesterDeinit
   static void       TesterDeinit();   // Обработчик события OnTesterDeinit
};


//+------------------------------------------------------------------+
//| Инициализация перед началом оптимизации                                                                 |
//+------------------------------------------------------------------+
int CVirtualAdvisor::TesterInit() {
   return CTesterHandler::TesterInit();
}

//+------------------------------------------------------------------+
//| Действия после завершения очередного прохода при оптимизации     |
//+------------------------------------------------------------------+
void CVirtualAdvisor::TesterPass() {
   CTesterHandler::TesterPass();
}

//+------------------------------------------------------------------+
//| Действия после завершения оптимизации                            |
//+------------------------------------------------------------------+
void CVirtualAdvisor::TesterDeinit() {
   CTesterHandler::TesterDeinit();
}


Внесём также некоторые дополнения в код конструктора объекта эксперта. С прицелом на будущее вынесем все действия из конструктора в новый метод инициализации Init(). Это позволит нам добавить несколько конструкторов с разными наборами параметров, которые будут использовать один и тот же метод инициализации после небольшой предобработки параметров.

Добавим конструкторы, у которых первым аргументом будет предаваться либо объект стратегии, либо объект группы стратегий. Тогда провести добавление стратегий к эксперту можно будет прямо в конструкторе. В этом случае нам уже не понадобится вызывать метод Add() в функции OnInit() у советника.

//+------------------------------------------------------------------+
//| Класс эксперта, работающего с виртуальными позициями (ордерами)  |
//+------------------------------------------------------------------+
class CVirtualAdvisor : public CAdvisor {
protected:
   ...

   datetime          m_fromDate;

public:
                     CVirtualAdvisor(CVirtualStrategy *p_strategy, ulong p_magic = 1, string p_name = "", bool p_useOnlyNewBar = false); // Конструктор
                     CVirtualAdvisor(CVirtualStrategyGroup *p_group, ulong p_magic = 1, string p_name = "", bool p_useOnlyNewBar = false); // Конструктор
   void              CVirtualAdvisor::Init(CVirtualStrategyGroup *p_group,
                                           ulong p_magic = 1,
                                           string p_name = "",
                                           bool p_useOnlyNewBar = false
                                          );
   ...
};

...

//+------------------------------------------------------------------+
//| Конструктор                                                      |
//+------------------------------------------------------------------+
CVirtualAdvisor::CVirtualAdvisor(CVirtualStrategy *p_strategy,
                                 ulong p_magic = 1,
                                 string p_name = "",
                                 bool p_useOnlyNewBar = false
                                ) {
   CVirtualStrategy *strategies[] = {p_strategy};
   Init(new CVirtualStrategyGroup(strategies), p_magic, p_name, p_useOnlyNewBar);
};

//+------------------------------------------------------------------+
//| Конструктор                                                      |
//+------------------------------------------------------------------+
CVirtualAdvisor::CVirtualAdvisor(CVirtualStrategyGroup *p_group,
                                 ulong p_magic = 1,
                                 string p_name = "",
                                 bool p_useOnlyNewBar = false
                                ) {
   Init(p_group, p_magic, p_name, p_useOnlyNewBar);
};

//+------------------------------------------------------------------+
//| Метод инициализации эксперта                                     |
//+------------------------------------------------------------------+
void CVirtualAdvisor::Init(CVirtualStrategyGroup *p_group,
                           ulong p_magic = 1,
                           string p_name = "",
                           bool p_useOnlyNewBar = false
                          ) {
// Инициализируем получателя статическим получателем
   m_receiver = CVirtualReceiver::Instance(p_magic);
// Инициализируем интерфейс статическим интерфейсом
   m_interface = CVirtualInterface::Instance(p_magic);
   m_lastSaveTime = 0;
   m_useOnlyNewBar = p_useOnlyNewBar;
   m_name = StringFormat("%s-%d%s.csv",
                         (p_name != "" ? p_name : "Expert"),
                         p_magic,
                         (MQLInfoInteger(MQL_TESTER) ? ".test" : "")
                        );

   m_fromDate = TimeCurrent();

   Add(p_group);
   delete p_group;
};

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


Класс обработки событий оптимизации

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

//+------------------------------------------------------------------+
//| Класс для обработки событий оптимизации                          |
//+------------------------------------------------------------------+
class CTesterHandler {
   static string     s_fileName;                   // Имя файла для записи данных фрейма
   static void       ProcessFrames();              // Обработка пришедших фреймов
   static string     GetFrameInputs(ulong pass);   // Получение input-параметров прохода
public:
   static int        TesterInit();     // Обработка начала оптимизации в главном терминале
   static void       TesterDeinit();   // Обработка завершения оптимизации в главном терминале
   static void       TesterPass();     // Обработка завершения прохода на агенте в главном терминале

   static void       Tester(const double OnTesterValue,
                            const string params);  // Обработка завершения прохода тестера для агента
};

string CTesterHandler::s_fileName = "data.bin";    // Имя файла для записи данных фрейма


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

//+------------------------------------------------------------------+
//| Обработка начала оптимизации в главном терминале                 |
//+------------------------------------------------------------------+
int CTesterHandler::TesterInit(void) {
// Открываем / создаём базу данных
   DB::Open();

// Если открыть не удалось, то не запускаем оптимизацию
   if(!DB::IsOpen()) {
      return INIT_FAILED;
   }

// Закрываем успешно открытую базу данных
   DB::Close();

   return INIT_SUCCEEDED;
}

//+------------------------------------------------------------------+
//| Обработка завершения оптимизации в главном терминале             |
//+------------------------------------------------------------------+
void CTesterHandler::TesterDeinit(void) {
// Обрабатываем последние пришедшие от агентов фреймы данных
   ProcessFrames();

// Закрываем график с советником, запущенным в режиме сбора фреймов
   ChartClose();
}

//+------------------------------------------------------------------+
//| Обработка завершения прохода на агенте в главном терминале       |
//+------------------------------------------------------------------+
void CTesterHandler::TesterPass(void) {
// Обрабатываем поступившие от агента фреймы данных
   ProcessFrames();
}


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

  • Для агента тестирования. Именно там после прохода будет собираться необходимая информация и  создаваться фрейм данных для отправки в главный терминал. Эти действия будут собраны в обработчике события Tester().

  • Для главного терминала. Здесь могут выполняться действия по получению фреймов данных от агентов тестирования, разбору поступившей во фрейме информации и занесении её в базу данных. Эти действия будут собраны в обработчике TesterPass().

Действия по формированию фрейма данных для агента тестирования необходимо выполнять в советнике внутри обработчика OnTester. Так как его код мы перенесли на уровень объекта эксперта (в класс CVirtualAdvisor), то именно туда надо добавить вызов метода CTesterHandler::Tester(). В качестве параметров этого метода мы будем передавать только что рассчитанное значение пользовательского критерия оптимизации и строку с описанием параметров стратегии, которая использовалась в оптимизируемом советнике. Для формирования такой строки воспользуемся уже сделанным ранее оператором ~ (тильда) для объектов класса CVirtualStrategy.

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

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

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

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

// Выполняем формирование фрейма данных на агенте тестирования
   CTesterHandler::Tester(fittedProfit,
                          ~((CVirtualStrategy *) m_strategies[0]));

   return fittedProfit;
}


В самом методе CTesterHandler::Tester() мы пройдёмся по всем возможным именам доступных статистических характеристик, получим их значения, преобразуем в строки и добавим эти строки в массив stats. Зачем нам понадобилось преобразовывать вещественные числовые характеристики в строки? Только затем, чтобы можно было их передать в одном фрейме со строковым описанием параметров стратегии. В одном фрейме можно передать либо массив значений одного из простых типов (к которым строки не относятся), либо предварительно созданный файл с любыми данными. Поэтому, чтобы не связываться с отправкой двух разных фреймов (в одном — числа, а в другом — строки из файла), мы все данные превратим в строки, запишем в файл и отправим его содержимое в одном фрейме: 

//+------------------------------------------------------------------+
//| Обработка завершения прохода тестера для агента                  |
//+------------------------------------------------------------------+
void CTesterHandler::Tester(double custom,   // Пользовательский критерий
                            string params    // Описание параметров советника в текущем проходе
                           ) {
// Массив имён сохраняемых статистических характеристик прохода
   ENUM_STATISTICS statNames[] = {
      STAT_INITIAL_DEPOSIT,
      STAT_WITHDRAWAL,
      STAT_PROFIT,
      ... 
   };

// Массив для значений статистических характеристик прохода в виде строк
   string stats[];
   ArrayResize(stats, ArraySize(statNames));

// Заполняем массив значений статистических характеристик прохода
   FOREACH(statNames, stats[i] = DoubleToString(TesterStatistics(statNames[i]), 2));

// Добавляем в него значение пользовательского критерия
   APPEND(stats, DoubleToString(custom, 2));

// В описании параметров экранируем кавычки (на всякий случай на будущее)
   StringReplace(params, "'", "\\'");

// Открываем файл для записи данных для фрейма
   int f = FileOpen(s_fileName, FILE_WRITE | FILE_TXT | FILE_ANSI);

// Записываем статистические характеристики
   FOREACH(stats, FileWriteString(f, stats[i] + ","));

// Записываем описание параметров советника
   FileWriteString(f, StringFormat("'%s'", params));

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

// Создаём фрейм с данными из записанного файла и отправляем его в главный терминал
   if(!FrameAdd("", 0, 0, s_fileName)) {
      PrintFormat(__FUNCTION__" | ERROR: Frame add error: %d", GetLastError());
   }
}


И наконец — вспомогательный метод, который будет принимать фреймы данных и сохранять информацию из них в базу данных. В нем мы в цикле получаем все пришедшие фреймы, которые ещё не обработаны к текущему моменту времени. Из каждого фрейма получаем данные в виде массива символов и преобразуем их в строку. Далее формируем строку с именами и значениями параметров прохода с заданным индексом. Полученные значения мы используем для формирования SQL-запроса на вставку новой строки в таблицу passes в нашей базе данных. Созданный SQL-запрос добавляем в массив SQL-запросов.

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

//+------------------------------------------------------------------+
//| Обработка пришедших фреймов                                      |
//+------------------------------------------------------------------+
void CTesterHandler::ProcessFrames(void) {
// Открываем базу данных
   DB::Open();

// Переменные для чтения данных из фреймов
   string   name;      // Название фрейма (не используется)
   ulong    pass;      // Индекс прохода фрейма
   long     id;        // Идентификатор типа фрейма (не используется)
   double   value;     // Одиночное значение фрейма (не используется)
   uchar    data[];    // Массив данных фрейма в виде массива символа

   string   values;    // Данные фрейма в виде строки
   string   inputs;    // Строка с именами и значениями параметров прохода
   string   query;     // Строка одного SQL-запроса
   string   queries[]; // SQL-запросы на добавление записей в БД


// Проходим по фреймам и читаем данные из них
   while(FrameNext(pass, name, id, value, data)) {
      // Переводим в строку массив символов, прочитанный из фрейма
      values = CharArrayToString(data);

      // Формируем строку с именами и значениями параметров прохода
      inputs = GetFrameInputs(pass);

      // Формируем SQL-запрос из полученных данных
      query = StringFormat("INSERT INTO passes "
                           "VALUES (NULL, %d, %s,\n'%s',\n'%s');",
                           pass, values, inputs,
                           TimeToString(TimeLocal(), TIME_DATE | TIME_SECONDS));

      // Добавляем его в массив SQL-запросов
      APPEND(queries, query);
   }

// Выполняем все запросы
   DB::ExecuteTransaction(queries);

// Закрываем базу данных
   DB::Close();
}


Вспомогательный метод формирования строки с именами и значениями input-переменных прохода GetFrameInputs() взят из учебника по алготрейдингу и немного дополнен под наши нужды.

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


Проверка работы

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

Рис. 1. Результаты оптимизации в тестере стратегий

Рис. 2. Результаты оптимизации в базе данных 

Как видно, результаты в базе совпадают с результатами в тестере: при одинаковой сортировке по пользовательскому критерию мы наблюдаем одну и ту же последовательность значений прибыли (profit) в обоих местах. Наилучший проход сообщает, что за один год ожидаемая прибыль может составить более $5000 при начальном депозите $10000 и максимальной достигаемой просадке по средствам 10% от начального депозита ($1000). Но нам сейчас не столь важны количественные характеристики результатов оптимизации, как то, что эти результаты теперь могут сохраняться в базе данных.


Заключение

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

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

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

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



Прикрепленные файлы |
Advisor.mqh (4.4 KB)
Database.mqh (13.43 KB)
Interface.mqh (3.22 KB)
Macros.mqh (2.3 KB)
Money.mqh (4.6 KB)
NewBarEvent.mqh (11.7 KB)
Receiver.mqh (1.8 KB)
Strategy.mqh (1.74 KB)
TesterHandler.mqh (17.79 KB)
VirtualAdvisor.mqh (23.05 KB)
VirtualOrder.mqh (39.52 KB)
VirtualReceiver.mqh (17.67 KB)
Разработка системы репликации (Часть 37): Прокладываем путь (I) Разработка системы репликации (Часть 37): Прокладываем путь (I)
В этой статье мы начнем делать то, что хотелось сделать гораздо раньше. Однако из-за отсутствия "твердой почвы" я не чувствовал себя уверенно, чтобы представить вопрос публично. Теперь у меня есть основа для того, чтобы делать то, что мы начнем сейчас. Неплохо бы максимально сосредоточиться на понимании содержания этой статьи, и я говорю это не для того, чтобы вы просто это прочитали. Я хочу подчеркнуть, что если вы не поймете данную статью, то можете полностью отказаться от надежды понять содержание следующих статей.
Разработка системы репликации (Часть 36): Внесение корректировок (II) Разработка системы репликации (Часть 36): Внесение корректировок (II)
Одна из вещей, которая может усложнить нашу жизнь как программистов, - это предположения. В этой статье я покажу вам, как опасно делать предположения: как в части программирования на MQL5, где принимается, что у курса будет определенная величина, так и при использовании MetaTrader 5, где принимается, что разные серверы работают одинаково.
Реализация расширенного теста Дики-Фуллера в MQL5 Реализация расширенного теста Дики-Фуллера в MQL5
В статье показаны реализация расширенного теста Дики-Фуллера и его применение для проведения коинтеграционных тестов с использованием метода Энгла-Грейнджера.
Разработка системы репликации (Часть 35): Внесение корректировок (I) Разработка системы репликации (Часть 35): Внесение корректировок (I)
Прежде чем мы сможем двигаться дальше, нам нужно исправить несколько моментов. Но это не обязательные исправления, а улучшение в способе управления и использования класса. Причина в том, что сбои происходят из-за какого-то взаимодействия внутри системы. Несмотря на попытки узнать причину некоторых неудач, для их последующего устранения, все эти попытки оказались безуспешными, поскольку некоторые из них не имели смысла. Когда мы используем указатели или рекурсию в C / C++, программа аварийно завершается.