English 中文 Español Deutsch 日本語 Português
preview
Разрабатываем мультивалютный советник (Часть 1): Совместная работа нескольких торговых стратегий

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

MetaTrader 5Трейдинг | 24 января 2024, 11:41
2 751 31
Yuriy Bykov
Yuriy Bykov

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


Постановка задачи

Надо определиться, что мы хотим и что у нас есть.

У нас есть (ну или почти есть):

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

Мы хотим:

  • совместной работы всех выбранных стратегий на одном счете на нескольких символах и таймфреймах
  • распределения стартового депозита между всеми поровну или в соответствии с задаваемыми коэффициентами
  • автоматического расчета объемов открываемых позиций для соблюдения максимальной допустимой просадки
  • корректной обработки перезапуска терминала
  • возможности запуска в MT5 и MT4

Будем использовать объектно-ориентированный подход, MQL5, штатный тестер в MetaTrader 5.

Поставленная задача достаточно большая, поэтому будем решать ее поэтапно.

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


От торговой идеи к стратегии

В качестве подопытной торговой идеи возьмем такую.  

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

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

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

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

Это уже более детальное описание, но не полное описание. Поэтому читаем его снова и выделяем все места, где что-то не понятно. В этих местах требуется дать более детальные пояснения. 

Вот какие вопросы возникли:

  • "Выставляем отложенный ордер ..." — Какие отложенные ордера будем выставлять?
  • "... средний объем, ... Как вычислить средний объем свечи?
  • "... превышает средний объем, ..." Как определить превышение среднего объема?
  • "... Если тиковый объем еще сильнее превышает средний, ..." Как определить это еще большее превышение?
  • "... могут выставляться дополнительные ордера" Сколько всего может быть выставлено ордеров?

Какие отложенные ордера будем выставлять? Исходя из идеи, мы надеемся на продолжение движения цены в том же направлении, в котором она пошла от начала свечи. Если, например, цена на текущий момент выше, чем в начале периода свечи, то нам стоит открыть отложенный ордер на покупку. Если мы откроем BUY_LIMIT, то чтобы он сработал, цена должна сначала немного вернуться (опуститься), а затем, чтобы открывшаяся позиция дала прибыль, цена должна снова подняться. Если же мы откроем BUY_STOP, то для открытия позиции цена должна еще немного продолжить движение (подняться), а затем подняться еще выше для получения прибыли.

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

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

Как определить превышение среднего объема? Если мы возьмём условие вида

V > V_avr ,

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

V > V_avr + D * V_avr,

где D - числовой коэффициент. Если D = 1, то открытие будет происходить, когда текущий объем превышает средний в 2 раза, а если, например, D = 2, то открытие будет происходить, когда текущий объем превышает средний в 3 раза.

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

Как определить это еще большее превышение? Для этого добавим в формулу условия еще один параметр - количество открытых ордеров N:

V > V_avr + D * V_avr + N * D * V_avr.

Тогда, чтобы открылся второй ордер после первого (то есть N = 1), должно быть выполнено условие:

V > V_avr + 2 * D * V_avr.

Для открытия первого ордера (N = 0), формула приобретает уже известный нам вид:

V > V_avr + D * V_avr.

И последнее исправление формулы открытия. Сделаем вместо одинакового параметра D для первого и последующих ордеров два независимых параметра D и D_add:

V > V_avr + D * V_avr + N * D_add * V_avr,

V > V_avr * (1 + D + N * D_add)

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

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

Когда всё ясно, перечислим величины, которые могут принимать разные значения, а не только одно единственное. Это и будут наши входные параметры стратегии. Учтем, что для открытия ордеров нам надо знать еще объем, расстояние от текущей цены, время истечения и уровни StopLoss и TakeProfit. Тогда получим такое описание:

Советник запускается на определенном символе и периоде (таймфрейме) на счете Hedge

Задаем входные параметры:

  • Количество свечей для усреднения объемов (K)
  • Относительное отклонение от среднего для открытия первого ордера (D)
  • Относительное отклонение от среднего для открытия второго и последующих ордеров (D_add)
  • Расстояние от цены до отложенного ордера
  • Stop Loss (в пунктах)
  • Take Profit (в пунктах)
  • Время истечения отложенных ордеров (в минутах)
  • Максимальное количество одновременно отрытых ордеров (N_max)
  • Объем одного ордера

Находим количество открытых ордеров и позиций (N).
Если оно меньше N_max, то:
        вычисляем средний тиковый объём за последние K закрытых свечей, получаем величину V_avr.
        Если выполнено условие V > V_avr * (1 + D + N * D_add), то:
                определяем направление изменения цены на текущей свече: если цена увеличилась, то будем выставлять отложенный ордер BUY_STOP, а иначе - SELL_STOP
                выставляем отложенный ордер на заданном в параметрах расстоянии, времени истечения и уровнями StopLoss и TakeProfit.


Реализация торговой стратегии

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

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

input group "===  Параметры сигнала к открытию"
input int         signalPeriod_        = 48;    // Количество свечей для усреднения объемов
input double      signalDeviation_     = 1.0;   // Относ. откл. от среднего для открытия первого ордера
input double      signaAddlDeviation_  = 1.0;   // Относ. откл. от среднего для открытия второго и последующих ордеров

input group "===  Параметры отложенных ордеров"
input int         openDistance_        = 200;   // Расстояние от цены до отлож. ордера
input double      stopLevel_           = 2000;  // Stop Loss (в пунктах)
input double      takeLevel_           = 75;    // Take Profit (в пунктах)
input int         ordersExpiration_    = 6000;  // Время истечения отложенных ордеров (в минутах)

input group "===  Параметры управление капиталом"
input int         maxCountOfOrders_    = 3;     // Макс. количество одновременно отрытых ордеров
input double      fixedLot_            = 0.01;  // Объем одного ордера

input group "===  Параметры советника"
input ulong       magicN_              = 27181; // Magic

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

CTrade            trade;            // Объект для совершения торговых операций

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

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

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

Также для работы нам понадобится считать количество открытых ордеров и позиций. Для этого создадим глобальный объекты классов COrderInfo и CPositionInfo, посредством которых мы будем получать информацию об открытых ордерах и позициях. Само количество будем сохранять в двух глобальных переменных countOrders и countPositions .

COrderInfo        orderInfo;        // Объект для получения информации о выставленных ордерах
CPositionInfo     positionInfo;     // Объект для получения информации об открытых позициях

int               countOrders;      // Количество выставленных отложенных ордеров
int               countPositions;   // Количество открытых позиций

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

int               iVolumesHandle;   // Хэндл индикатора тиковых объемов
double            volumes[];        // Массив-приемник значений индикатора (самих объемов)

Теперь мы можем приступать к функции инициализации советника OnInit() и функции обработки тиков OnTick().

При инициализации нам можно сделать следующее:

  • Загрузить индикатор для получения тиковых объемов и запомнить его хэндл
  • Установить размер массива-приемника в соответствии с количеством свечей для расчёта среднего объема и задать ему адресацию как в таймсериях 
  • Установить Magic Number для выставления ордеров через объект trade

Вот как будет выглядеть наша функция инициализации:

int OnInit() {
   // Загружаем индикатор для получения тиковых объемов
   iVolumesHandle = iVolumes(Symbol(), PERIOD_CURRENT, VOLUME_TICK);
   
   // Устанавливаем размер массива-приемника тиковых объемов и нужную адресацию
   ArrayResize(volumes, signalPeriod_);
   ArraySetAsSeries(volumes, true);

   // Установим Magic Number для выставления ордеров через trade
   trade.SetExpertMagicNumber(magicN_);
   
   return(INIT_SUCCEEDED);
}

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

void UpdateCounts() {
// Обнуляем счетчики позиций и ордеров
   countPositions = 0;
   countOrders = 0;

// Цикл перебора всех позиций
   for(int i = 0; i < PositionsTotal(); i++) {
      // Если позиция с индексом i выбрана успешно и её Magic - наш, то считаем её
      if(positionInfo.SelectByIndex(i) && positionInfo.Magic() == magicN_) {
         countPositions++;
      }
   }

// Цикл перебора всех ордеров
   for(int i = 0; i < OrdersTotal(); i++) {
      // Если ордер с индексом i выбран успешно и его Magic - наш, то считаем его
      if(orderInfo.SelectByIndex(i) && orderInfo.Magic() == magicN_) {
         countOrders++;
      }
   }
}

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

  • +1  — сигнал для открытия ордера BUY_STOP
  •  0  — нет сигнала на открытие
  • -1  — сигнал для открытия ордера SELL_STOP

Для выставления отложенных ордеров тоже напишем две отдельные функции: OpenBuyOrder() и OpenSellOrder(). 

Теперь мы можем написать полностью реализацию функции OnTick().

void OnTick() {
// Подсчитываем открытые позиции и ордера
   UpdateCounts();

// Если их количество меньше допустимого
   if(countOrders + countPositions < maxCountOfOrders_) {
      // Получаем сигнал на открытие
      int signal = SignalForOpen();

      if(signal == 1) {          // Если сигнал на покупку, то
         OpenBuyOrder();         // открываем ордер BUY_STOP
      } else if(signal == -1) {  // Если сигнал на продажу, то
         OpenSellOrder();        // открываем ордер SELL_STOP
      }
   }
}

После этого добавляем реализацию оставшихся функций и код советника готов. Сохраним его в файле SimpleVolumes.mq5 в текущей папке.

#include <Trade\OrderInfo.mqh>
#include <Trade\PositionInfo.mqh>
#include <Trade\SymbolInfo.mqh>
#include <Trade\Trade.mqh>

input group "===  Параметры сигнала к открытию"
input int         signalPeriod_        = 48;    // Количество свечей для усреднения объемов
input double      signalDeviation_     = 1.0;   // Относ. откл. от среднего для открытия первого ордера
input double      signaAddlDeviation_  = 1.0;   // Относ. откл. от среднего для открытия второго и последующих ордеров

input group "===  Параметры отложенных ордеров"
input int         openDistance_        = 200;   // Расстояние от цены до отлож. ордера
input double      stopLevel_           = 2000;  // Stop Loss (в пунктах)
input double      takeLevel_           = 75;    // Take Profit (в пунктах)
input int         ordersExpiration_    = 6000;  // Время истечения отложенных ордеров (в минутах)

input group "===  Параметры управление капиталом"
input int         maxCountOfOrders_    = 3;     // Макс. количество одновременно отрытых ордеров
input double      fixedLot_            = 0.01;  // Объем одного ордера

input group "===  Параметры советника"
input ulong       magicN_              = 27181; // Magic


CTrade            trade;            // Объект для совершения торговых операций

COrderInfo        orderInfo;        // Объект для получения информации о выставленных ордерах
CPositionInfo     positionInfo;     // Объект для получения информации об открытых позициях

int               countOrders;      // Количество выставленных отложенных ордеров
int               countPositions;   // Количество открытых позиций

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

int               iVolumesHandle;   // Хэндл индикатора тиковых объемов
double            volumes[];        // Массив-приемник значений индикатора (самих объемов)

//+------------------------------------------------------------------+
//| Initialization function of the expert                            |
//+------------------------------------------------------------------+
int OnInit() {
// Загружаем индикатор для получения тиковых объемов
   iVolumesHandle = iVolumes(Symbol(), PERIOD_CURRENT, VOLUME_TICK);

// Устанавливаем размер массива-приемника тиковых объемов и нужную адресацию
   ArrayResize(volumes, signalPeriod_);
   ArraySetAsSeries(volumes, true);

// Установим Magic Number для выставления ордеров через trade
   trade.SetExpertMagicNumber(magicN_);

   return(INIT_SUCCEEDED);
}

//+------------------------------------------------------------------+
//| "Tick" event handler function                                    |
//+------------------------------------------------------------------+
void OnTick() {
// Подсчитываем открытые позиции и ордера
   UpdateCounts();

// Если их количество меньше допустимого
   if(countOrders + countPositions < maxCountOfOrders_) {
      // Получаем сигнал на открытие
      int signal = SignalForOpen();

      if(signal == 1) {          // Если сигнал на покупку, то
         OpenBuyOrder();         // открываем ордер BUY_STOP
      } else if(signal == -1) {  // Если сигнал на продажу, то
         OpenSellOrder();        // открываем ордер SELL_STOP
      }
   }
}

//+------------------------------------------------------------------+
//| Подсчет количества открытых ордеров и позиций                    |
//+------------------------------------------------------------------+
void UpdateCounts() {
// Обнуляем счетчики позиций и ордеров
   countPositions = 0;
   countOrders = 0;

// Цикл перебора всех позиций
   for(int i = 0; i < PositionsTotal(); i++) {
      // Если позиция с индексом i выбрана успешно и её Magic - наш, то считаем её
      if(positionInfo.SelectByIndex(i) && positionInfo.Magic() == magicN_) {
         countPositions++;
      }
   }

// Цикл перебора всех ордеров
   for(int i = 0; i < OrdersTotal(); i++) {
      // Если ордер с индексом i выбран успешно и его Magic - наш, то считаем его
      if(orderInfo.SelectByIndex(i) && orderInfo.Magic() == magicN_) {
         countOrders++;
      }
   }
}

//+------------------------------------------------------------------+
//| Открытие ордера BUY_STOP                                         |
//+------------------------------------------------------------------+
void OpenBuyOrder() {
// Обновляем информацию о текущих ценах для символа
   symbolInfo.Name(Symbol());
   symbolInfo.RefreshRates();

// Берем необходимую нам информацию о символе и ценах
   double point = symbolInfo.Point();
   int digits = symbolInfo.Digits();
   double bid = symbolInfo.Bid();
   double ask = symbolInfo.Ask();
   int spread = symbolInfo.Spread();

// Сделаем, чтобы расстояние открытия было не меньше спреда
   int distance = MathMax(openDistance_, spread);

// Цена открытия
   double price = ask + distance * point; 
   
// Уровни StopLoss и TakeProfit
   double sl = NormalizeDouble(price - stopLevel_ * point, digits);
   double tp = NormalizeDouble(price + (takeLevel_ + spread) * point, digits);
   
// Время истечения
   datetime expiration = TimeCurrent() + ordersExpiration_ * 60; 
   
// Объем ордера
   double lot = fixedLot_; 
   
// Устанавливаем отложенный ордер
   bool res = trade.BuyStop(lot,
                            NormalizeDouble(price, digits),
                            Symbol(),
                            NormalizeDouble(sl, digits),
                            NormalizeDouble(tp, digits),
                            ORDER_TIME_SPECIFIED,
                            expiration);

   if(!res) {
      Print("Error opening order");
   }
}

//+------------------------------------------------------------------+
//| Открытие ордера SELL_STOP                                        |
//+------------------------------------------------------------------+
void OpenSellOrder() {
// Обновляем информацию о текущих ценах для символа
   symbolInfo.Name(Symbol());
   symbolInfo.RefreshRates();

// Берем необходимую нам информацию о символе и ценах
   double point = symbolInfo.Point();
   int digits = symbolInfo.Digits();
   double bid = symbolInfo.Bid();
   double ask = symbolInfo.Ask();
   int spread = symbolInfo.Spread();

// Сделаем, чтобы расстояние открытия было не меньше спреда
   int distance = MathMax(openDistance_, spread);

// Цена открытия
   double price = bid - distance * point;
   
// Уровни StopLoss и TakeProfit
   double sl = NormalizeDouble(price + stopLevel_ * point, digits);
   double tp = NormalizeDouble(price - (takeLevel_ + spread) * point, digits);

// Время истечения
   datetime expiration = TimeCurrent() + ordersExpiration_ * 60;

// Объем ордера
   double lot = fixedLot_;

// Устанавливаем отложенный ордер
   bool res = trade.SellStop(lot,
                             NormalizeDouble(price, digits),
                             Symbol(),
                             NormalizeDouble(sl, digits),
                             NormalizeDouble(tp, digits),
                             ORDER_TIME_SPECIFIED,
                             expiration);

   if(!res) {
      Print("Error opening order");
   }
}

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

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

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

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

   return signal;
}

//+------------------------------------------------------------------+
//| Среднее значение массива чисел                                   |
//+------------------------------------------------------------------+
double ArrayAverage(const double &array[]) {
   double s = 0;
   int total = ArraySize(array);
   for(int i = 0; i < total; i++) {
      s += array[i];
   }

   return s / MathMax(1, total);
}
//+------------------------------------------------------------------+

Запустим оптимизацию параметров советника для EURGBP на периоде H1 на котировках от MetaQuotes на периоде от 2018-01-01 до 2023-01-01 со стартовым депозитом $100 000 с минимальным лотом 0.01. Стоит отметить, что один и тот же советник может показывать несколько отличающиеся результаты при тестировании на котировках от разных брокеров. А порой эти результаты могут отличаться очень сильно.

Из полученных результатов тестирования выберем два симпатичных набора параметров с такими результатами:

Рис.1. Результаты тестирования для параметров [130, 0.9, 1.4, 231, 3750, 50, 600, 3, 0.01] 


Рис. 2. Результаты тестирования для параметров [159, 1.7, 0.8, 248, 3600, 495, 39000, 3, 0.01] 

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

Приведем пример. Пусть у нас стартовый депозит был $1 000. При прогоне в тестере мы получили такие показатели:

  • Конечный депозит $11 000 (прибыль 1000%, советник заработал +$10 000 к начальным $1 000)
  • Максимальная абсолютная просадка $2 000

Очевидно, что нам просто повезло, что такая просадка случилась уже после того, как советник увеличил депозит до величины, большей чем $2 000. Поэтому проход в тестере завершился, и мы смогли увидеть эти результаты. Если бы такая просадка случилась раньше (например, мы бы выбрали другое начало периода тестирования), то мы бы получили только то, что советник теряет весь депозит.

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

Если вернуться к примеру, то если стартовый депозит был бы $100 000, то при повторении просадки в $2 000 потери всего депозита не произошло и тестер получил бы эти результаты. А мы смогли бы рассчитать, что если для нас максимальная допустимая просадка составляет 10%, то начальный депозит должен быть не менее $20 000. Прибыльность в этом случае составит уже только 50% (советник заработал +$10 000 к начальным $20 000)

Проделаем подобные расчеты для наших двух выбранных комбинаций параметров для размера стартового депозита $10 000 и допустимой просадки 10% от стартового депозита.

Параметры Лот  Просадка Прибыль  Допустимая
просадка
Допустимый
лот
Допустимая
прибыль
   L  D  P  Da La = L * (Da / D) Pa =   P * (Da / D)
[130, 0.9, 1.4, 231,
3750, 50, 600, 3, 0.01]

0.01 28.70 (0.04%)  260.41 1000 (10%) 0.34 9073 (91%)
[159, 1.7, 0.8, 248,
3600, 495, 39000, 3, 0.01
]
0.01 92.72 (0.09%)  666.23 1000 (10%)
0.10 7185 (72%)

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

Итак, мы нашли несколько комбинаций входных параметров, подающих надежды, приступим к их объединению в один советник.


Базовый класс стратегии

Сделаем класс CStrategy, в который соберем все свойства и методы, присущие всем стратегиям. Например, у любой стратегии будет какой-то символ и таймфрейм вне зависимости от ее отношения к индикаторам. Также каждой стратегии выделим свой Magic Number для открытия позиций и размер одной позиции. Для простоты не будем пока рассматривать работу стратегии с переменным размером позиции, это мы обязательно добавим, но позднее.

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

class CStrategy : public CObject {
protected:
   ulong             m_magic;          // Magic
   string            m_symbol;         // Символ (торговый инструмент)
   ENUM_TIMEFRAMES   m_timeframe;      // Период графика (таймфрейм)
   double            m_fixedLot;       // Размер открываемых позиций (фиксированный)

public:
   // Конструктор
   CStrategy(ulong p_magic,
             string p_symbol,
             ENUM_TIMEFRAMES p_timeframe,
             double p_fixedLot);

   virtual int       Init() = 0; // Инициализация стратегии - обработка событий OnInit
   virtual void      Tick() = 0; // Основной метод - обработка событий OnTick
};

Методы Init() и Tick() объявлены чисто виртуальными (после заголовка метода стоит = 0). Это значит, что в классе CStrategy мы не будем писать реализацию этих методов. На основе этого класса будем создавать классы потомков, в которых методы Init() и Tick() должны обязательно быть и содержать реализацию конкретных правил торговли.

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

CStrategy::CStrategy(
   ulong p_magic,
   string p_symbol,
   ENUM_TIMEFRAMES p_timeframe,
   double p_fixedLot) :
// Список инициализации
   m_magic(p_magic),
   m_symbol(p_symbol),
   m_timeframe(p_timeframe),
   m_fixedLot(p_fixedLot)
{}

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


Класс торговой стратегии

Перенесем логику работы исходного простого советника в новый класс-потомок CSimpleVolumesStrategy. Для этого все переменные входных параметров и глобальные переменные сделаем членами класса. Мы только уберем переменные fixedLot_ и magicN_, вместо которых будем использовать члены базового класса m_fixedLot и m_magic, унаследованные от базового класса CStrategy.

#include "Strategy.mqh"

class CSimpleVolumeStrategy : public CStrategy {
   //---  Параметры сигнала к открытию
   int               signalPeriod_;       // Количество свечей для усреднения объемов
   double            signalDeviation_;    // Относ. откл. от среднего для открытия первого ордера
   double            signaAddlDeviation_; // Относ. откл. от среднего для открытия второго и последующих ордеров

   //---  Параметры отложенных ордеров
   int               openDistance_;       // Расстояние от цены до отлож. ордера
   double            stopLevel_;          // Stop Loss (в пунктах)
   double            takeLevel_;          // Take Profit (в пунктах)
   int               ordersExpiration_;   // Время истечения отложенных ордеров (в минутах)

   //---  Параметры управление капиталом
   int               maxCountOfOrders_;   // Макс. количество одновременно отрытых ордеров

   CTrade            trade;               // Объект для совершения торговых операций

   COrderInfo        orderInfo;           // Объект для получения информации о выставленных ордерах
   CPositionInfo     positionInfo;        // Объект для получения информации об открытых позициях

   int               countOrders;         // Количество выставленных отложенных ордеров
   int               countPositions;      // Количество открытых позиций

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

   int               iVolumesHandle;      // Хэндл индикатора тиковых объемов
   double            volumes[];           // Массив-приемник значений индикатора (самих объемов)  
};

Функции OnInit() и OnTick() превратятся в публичные методы Init() и Tick(), а все остальные функции станут новыми частными методами класса CSimpleVolumesStrategy. Публичные методы можно будет вызывать для стратегий из внешнего кода, например из методов объекта-советника. Частные методы могут вызываться только из методов данного класса. Добавим заголовки методов в описание класса.

class CSimpleVolumeStrategy : public CStrategy {
private:
   //---  ... прошлый код
   double            volumes[];           // Массив-приемник значений индикатора (самих объемов)

   //--- Методы
   void              UpdateCounts();      // Подсчет количества открытых ордеров и позиций
   int               SignalForOpen();     // Сигнал для открытия отложенных ордеров
   void              OpenBuyOrder();      // Открытие ордера BUY_STOP
   void              OpenSellOrder();     // Открытие ордера SELL_STOP
   double            ArrayAverage(
      const double &array[]);             // Среднее значение массива чисел

public:
   //--- Публичные методы
   virtual int       Init();              // Метод инициализации стратегии
   virtual void      Tick();              // Обработчик события OnTick
};

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

class CSimpleVolumeStrategy : public CStrategy {
   // Описание класса с перечислением свойств и методов...
};

int CSimpleVolumeStrategy::Init() {
// Код функции ...
}

void CSimpleVolumeStrategy::Tick() {
// Код функции ...
}

void CSimpleVolumeStrategy::UpdateCounts() {
// Код функции ...
}

int CSimpleVolumeStrategy::SignalForOpen() {
// Код функции ...
}

void CSimpleVolumeStrategy::OpenBuyOrder() {
// Код функции ...
}

void CSimpleVolumeStrategy::OpenSellOrder() {
// Код функции ...
}

double CSimpleVolumeStrategy::ArrayAverage(const double &array[]) {
// Код функции ...
}

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

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

class CSimpleVolumeStrategy : public CStrategy {
private:
   //---  ... прошлый код   

public:
   //--- Публичные методы
   CSimpleVolumeStrategy(
      ulong            p_magic,
      string           p_symbol,
      ENUM_TIMEFRAMES  p_timeframe,
      double           p_fixedLot,
      int              p_signalPeriod,
      double           p_signalDeviation,
      double           p_signaAddlDeviation,
      int              p_openDistance,
      double           p_stopLevel,
      double           p_takeLevel,
      int              p_ordersExpiration,
      int              p_maxCountOfOrders
   );                                     // Конструктор

   virtual int       Init();              // Метод инициализации стратегии
   virtual void      Tick();              // Обработчик события OnTick
};

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

CSimpleVolumeStrategy::CSimpleVolumeStrategy(
   ulong            p_magic,
   string           p_symbol,
   ENUM_TIMEFRAMES  p_timeframe,
   double           p_fixedLot,
   int              p_signalPeriod,
   double           p_signalDeviation,
   double           p_signaAddlDeviation,
   int              p_openDistance,
   double           p_stopLevel,
   double           p_takeLevel,
   int              p_ordersExpiration,
   int              p_maxCountOfOrders) : 
   // Список инициализации
   CStrategy(p_magic, p_symbol, p_timeframe, p_fixedLot), // Вызов конструктора базового класса
   signalPeriod_(p_signalPeriod),
   signalDeviation_(p_signalDeviation),
   signaAddlDeviation_(p_signaAddlDeviation),
   openDistance_(p_openDistance),
   stopLevel_(p_stopLevel),
   takeLevel_(p_takeLevel),
   ordersExpiration_(p_ordersExpiration),
   maxCountOfOrders_(p_maxCountOfOrders)
{}

Остаётся сделать совсем немного. Переименуем fixedLot_ и magicN_, в m_fixedLot и m_magic во всех местах, где они встречались. Заменим использование функции получения текущего символа Symbol() на переменную базового класса m_symbol и константу PERIOD_CURRENT на m_timeframe. Сохраним этот код в файле SimpleVolumesStrategy.mqh в текущей папке.


Класс эксперта

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

#include "Strategy.mqh"

class CAdvisor : public CObject {
protected:
   CStrategy         *m_strategies[];  // Массив торговых стратегий
   int               m_strategiesCount;// Количество стратегий

public:
   virtual int       Init();           // Метод инициализации советника
   virtual void      Tick();           // Обработчик события OnTick
   virtual void      Deinit();         // Метод деинициализации

   void              AddStrategy(CStrategy &strategy);   // Метод добавления стратегии
};

В методах Init() и Tick() в цикле перебираются все стратегии из массива m_strategies[], и для них вызываются соответствующие методы обработки событий.

void CAdvisor::Tick(void) {
   // Для всех стратегий вызываем обработку OnTick
   for(int i = 0; i < m_strategiesCount; i++) {
      m_strategies[i].Tick();
   }
}

В методе добавления стратегий происходит ровно это.

void CAdvisor::AddStrategy(CStrategy &strategy) {
   // Увеличивем счётчик количества стратегий на 1
   m_strategiesCount = ArraySize(m_strategies) + 1;
   
   // Увеличиваем размер массива стратегий
   ArrayResize(m_strategies, m_strategiesCount);
   // В последний элемент записываем указатель на объект стратегии
   m_strategies[m_strategiesCount - 1] = GetPointer(strategy);
}

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


Торговый советник с несколькими стратегиями

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

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

Обработчики событий OnTick() и OnDeinit() просто вызывают соответствующие методы объекта эксперта.

#include "Advisor.mqh"
#include "SimpleVolumesStartegy.mqh"

input double depoPart_  = 0.8;      // Часть депозита для одной стратегии
input ulong  magic_     = 27182;    // Magic

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

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit() {
   expert.AddStrategy(...);
   expert.AddStrategy(...);

   int res = expert.Init();   // Инициализация всех стратегий эксперта

   return(res);
}

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

//+------------------------------------------------------------------+
//| Expert deinitialization function                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason) {
   expert.Deinit();
}
//+------------------------------------------------------------------+

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

   expert.AddStrategy(new CSimpleVolumeStrategy(magic_ + 1, ...));
   expert.AddStrategy(new CSimpleVolumeStrategy(magic_ + 2, ...));

Вторым и третьим параметром конструктора является символ и период. Поскольку мы проводили оптимизацию на EURGBP и периоде H1, то указываем эти конкретные значения.

   expert.AddStrategy(new CSimpleVolumeStrategy(
                         magic_ + 1, "EURGBP", PERIOD_H1, ...));
   expert.AddStrategy(new CSimpleVolumeStrategy(
                         magic_ + 2, "EURGBP", PERIOD_H1, ...));

Следующий параметр очень важный это размер открываемых позиций. Мы выше вычислили подходящий размер для двух стратегий (0.34 и 0.10). Но это размер для работы с просадкой до 10% от $10 000 при отдельной работе стратегий. Если две стратегии будут работать одновременно, то просадка первой может прибавиться к просадке второй. В худшем случае, чтобы остаться в рамках заявленных 10%, нам придется вдвое уменьшить размер открываемых позиций. Но может так случиться, что просадки двух стратегий не совпадают или даже несколько компенсируют друг друга. В этом случае мы можем уменьшить размер позиций не так сильно и всё равно не превысить 10%. Поэтому давайте сделаем уменьшающий множитель параметром советника (depoPart_), для которого затем подберем оптимальное значение.

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

   expert.AddStrategy(new CSimpleVolumeStrategy(
                         magic_ + 1, "EURGBP", PERIOD_H1,
                         NormalizeDouble(0.34 * depoPart_, 2),
                         130, 0.9, 1.4, 231, 3750, 50, 600, 3)
                     );
   expert.AddStrategy(new CSimpleVolumeStrategy(
                         magic_ + 2, "EURGBP", PERIOD_H1,
                         NormalizeDouble(0.10 * depoPart_, 2),
                         159, 1.7, 0.8, 248, 3600, 495, 39000, 3)
                     );

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


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

Перед тестированием объединённого советника давайте вспомним, что стратегия с первым набором параметром должна была давать прибыль примерно 91%, а со вторым набором параметров - 72% (для стартового депозита $10000 и максимальной просадки в 10% ($1000) при оптимальном лоте).

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

Рис. 3. Результат работы объединённого советника

Баланс на момент окончания периода тестирования составил примерно $22400, то есть прибыль составила 124%. Это больше, чем мы получили при запуске отдельных экземпляров этой стратегии. У нас получилось улучшить торговые результаты, работая только с имеющейся торговой стратегией, не внося в нее никах изменений.


Заключение

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

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

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


    Прикрепленные файлы |
    SimpleVolumes.mq5 (21.24 KB)
    Strategy.mqh (3.84 KB)
    Advisor.mqh (6.32 KB)
    Последние комментарии | Перейти к обсуждению на форуме трейдеров (31)
    Stanislav Korotky
    Stanislav Korotky | 25 янв. 2024 в 15:19
    fxsaber #:

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

    Дальше небольшое обрастание сухожилиями. Должно быть очень просто.

    Есть нечто похожее простое (в интерфейсном плане), но расширенное (в плане реализации) в книге.

    interface TradingSignal
    {
       virtual int signal(void);
    };
    
    interface TradingStrategy
    {
       virtual bool trade(void);
    };
    
    ...
    ...
    AutoPtr<TradingStrategy> strategy;
       
    int OnInit()
    {
       strategy = new SimpleStrategy(
          new BandOsMaSignal(...параметры...), Magic, StopLoss, Lots);
       return INIT_SUCCEEDED;
    }
       
    void OnTick()
    {
       if(strategy[] != NULL)
       {
          strategy[].trade();
       }
    }
    ...
    fxsaber
    fxsaber | 26 янв. 2024 в 13:09
    Stanislav Korotky #:

    Есть нечто похожее простое (в интерфейсном плане), но расширенное (в плане реализации) в книге.

    Где скачать исходники?

    Aleksandr Slavskii
    Aleksandr Slavskii | 26 янв. 2024 в 13:47
    fxsaber #:

    Где скачать исходники?

    https://www.mql5.com/ru/code/45595

    Isaac Amo
    Isaac Amo | 23 мая 2024 в 19:21
    Очень интересная стратегия!!!
    gardee005
    gardee005 | 28 окт. 2024 в 15:36
    Отличная статья, то, что я понимаю как новичок. хорошо объяснено. спасибо.
    Тип рисования DRAW_ARROW в мультисимвольных мультипериодных индикаторах Тип рисования DRAW_ARROW в мультисимвольных мультипериодных индикаторах
    В статье рассмотрим рисование стрелочных мультисимвольных мультипериодных индикаторов. Доработаем методы класса для корректного отображения стрелок, отображающих данные стрелочных индикаторов, рассчитанных на символе/периоде, не соответствующих символу/периоду текущего графика.
    Теория категорий в MQL5 (Часть 20): Самовнимание и трансформер Теория категорий в MQL5 (Часть 20): Самовнимание и трансформер
    Немного отвлечемся от наших постоянных тем и рассмотрим часть алгоритма ChatGPT. Есть ли у него какие-то сходства или понятия, заимствованные из естественных преобразований? Попытаемся ответить на эти и другие вопросы, используя наш код в формате класса сигнала.
    Разработка MQTT-клиента для MetaTrader 5: методология TDD (Часть 2) Разработка MQTT-клиента для MetaTrader 5: методология TDD (Часть 2)
    Статья является частью серии, описывающей этапы разработки нативного MQL5-клиента для протокола MQTT. В этой части мы описываем организацию нашего кода, первые заголовочные файлы и классы, а также написание тестов. В эту статью также включены краткие заметки о разработке через тестирование (Test-Driven-Development) и о ее применении в этом проекте.
    Популяционные алгоритмы оптимизации: Бинарный генетический алгоритм (Binary Genetic Algorithm, BGA). Часть I Популяционные алгоритмы оптимизации: Бинарный генетический алгоритм (Binary Genetic Algorithm, BGA). Часть I
    В этой статье мы проведем исследование различных методов, применяемых в бинарных генетических алгоритмах и других популяционных алгоритмах. Мы рассмотрим основные компоненты алгоритма, такие как селекция, кроссовер и мутация, а также их влияние на процесс оптимизации. Кроме того, мы изучим способы представления информации и их влияние на результаты оптимизации.