Создание самооптимизирующихся советников в MQL5 (Часть 9): Двойное пересечение скользящих средних
В нашей серии статей мы рассмотрели различные подходы к уменьшению запаздывания в классической стратегии пересечения скользящих средних.
Наши первые попытки включали использование инструментов статистического моделирования для прогнозирования пересечений скользящих средних заранее. Мы добились определённого прогресса в этом направлении и обнаружили, что при подходящих рыночных условиях прогнозирование пересечений скользящих средних может быть более точным, чем прямое прогнозирование цены. Далее мы нашли ещё один способ дополнительно сократить запаздывание. Этот подход заключается в фиксировании периодов двух скользящих средних таким образом, чтобы они имели одинаковое значение, и создании пересечений за счёт применения одной скользящей средней к цене открытия, а другой — к цене закрытия. Эта альтернативная система показала свою эффективность, позволив дополнительно уменьшить задержку без использования сложных моделей — лишь за счёт одинакового периода и различия в применяемой цене для двух индикаторов.
В данном обсуждении мы рассматриваем ещё один уникальный подход, который ранее не изучали. Как и в большинстве задач в жизни и математике, существует несколько способов решения одной и той же проблемы, и каждый из них имеет свои преимущества и недостатки. Сравнивая эти альтернативы, мы стремимся понять, насколько сильно мы можем контролировать запаздывание системы.
Здесь мы попробуем реализовать то, что я называю стратегией двойного пересечения скользящих средних. Как показано на Рисунке 1, классическая стратегия пересечения скользящих средних обычно применяется на одном таймфрейме с двумя средними разных периодов. В отличие от предыдущего обсуждения, где обе скользящие имели одинаковый фиксированный период, на этот раз мы частично возвращаемся к классическому подходу, позволяя индикаторам использовать разные периоды.
Проблема исходной формы заключается в том, что подтверждение сигналов на вход часто поступает слишком поздно — уже после начала движения — что приводит к запоздалым входам или упущенным возможностям.

Рисунок 1: Визуализация нашей стратегии пересечения скользящих средних на дневном таймфрейме.
Предлагаемый здесь подход не является совершенно новым. На самом деле дискреционные трейдеры уже давно используют похожую логику. Основная идея заключается в том, чтобы сначала наблюдать за пересечениями на более высоком таймфрейме (например, дневном графике, как показано на Рисунке 1). Однако мы не входим в рынок сразу после этого сигнала. Вместо этого, заметив пересечение на старшем таймфрейме, мы переходим на более низкий — например, M30, как показано на рисунке 2 — и ищем аналогичные сигналы пересечения, соответствующие наблюдаемому на старшем таймфрейме.
Трейдеры часто говорят: "Торгуй в направлении старшего таймфрейма." В большинстве наших обсуждений алгоритмической торговли мы применяем стратегию на одном и том же таймфрейме. Однако в данном случае мы используем стратегию дважды — сначала на старшем таймфрейме, а затем на младшем. Старший таймфрейм задаёт направленное смещение на день, а младший используется для поиска точек входа, совпадающих с этим направлением. В этом и заключается суть стратегии двойного пересечения. Сначала мы определяем направление на старшем таймфрейме, а затем ищем возможности для входа на младшем таймфрейме в соответствии с этим направлением, тем самым стараясь уменьшить запаздывание сигналов, возникающее на старшем таймфрейме.
Теперь, когда общее понимание сформировано, можно переходить к реализации стратегии, чтобы проверить её практическую ценность. Однако перед тем как перейти к коду, очевидно, что этот подход включает множество компонентов, которые необходимо тщательно продумать. Один из ключевых вопросов — как определить условия входа? Предположим, что на старшем таймфрейме наблюдается бычье пересечение. В этом случае у нас есть два варианта действий на младшем таймфрейме:
- Контртрендовый вход: дождаться медвежьего пересечения на младшем таймфрейме и открыть позицию против него, предполагая, что младший таймфрейм в конечном итоге снова согласуется с бычьим направлением старшего таймфрейма к концу дня.
- Трендовый вход: дождаться формирования бычьего пересечения на младшем таймфрейме в том же направлении, что и сигнал старшего таймфрейма.
Эти два подхода отражают разные идеологии входа в рамках данной стратегии. Выход из позиции добавляет ещё больше сложности и вариаций, каждая из которых имеет свои компромиссы. Например, можно закрывать позицию, когда младший таймфрейм перестаёт соответствовать направлению старшего. Либо можно выходить только тогда, когда меняется сам сигнал на старшем таймфрейме. То есть, если день начинается с бычьего сигнала на дневном графике, позиция удерживается до тех пор, пока этот сигнал не сменится на медвежий.
Как видно, существует множество способов входа и выхода в рамках этого метода. Вместо того чтобы полагаться исключительно на логические рассуждения при выборе оптимальной комбинации, необходимо использовать генетический оптимизатор. Он позволит определить на основе рыночных данных, какая комбинация этих подходов может оказаться наиболее прибыльной.

Рисунок 2: Визуализация нашей стратегии пересечения скользящих средних на младшем таймфрейме (M30).
Первым шагом мы определим несколько системных констант, которые должны оставаться неизменными на протяжении этапа разработки нашего приложения. Для простоты мы зафиксируем таймфреймы: дневной график будет выступать в роли старшего таймфрейма, M15 — младшего, а H4 — таймфрейма, используемого для расчёта уровня стоп-лосса.
В дальнейшем можно рассмотреть возможность преобразования этих системных констант в настраиваемые параметры, которые генетический оптимизатор сможет использовать для поиска наилучших точек входа. Однако на начальном этапе мы оставим эти значения фиксированными, чтобы упростить разработку и быстрее приступить к тестированию стратегии.//+------------------------------------------------------------------+ //| Double Crossover.mq5 | //| Copyright 2024, MetaQuotes Ltd. | //| https://www.mql5.com | //+------------------------------------------------------------------+ #property copyright "Copyright 2024, MetaQuotes Ltd." #property link "https://www.mql5.com" #property version "1.00" //+------------------------------------------------------------------+ //| System constants | //+------------------------------------------------------------------+ //--- System time frames #define TF_1 PERIOD_D1 #define TF_2 PERIOD_M15 #define TF_3 PERIOD_H4 #define LOT_MULTILPLE 1
Нам также необходимо определить набор пользовательских перечислений, чтобы представить различные режимы, в которых может работать наша стратегия. Например, приложение может функционировать в режиме следования за трендом или в режиме возврата к среднему, и отдельное перечисление позволяет пользователю переключаться между ними. Аналогичным образом мы вводим ещё одно перечисление для указания того, на каком таймфрейме следует оценивать условия закрытия позиции — на младшем или на старшем.
//+------------------------------------------------------------------+ //| Custom enumerations | //+------------------------------------------------------------------+ //--- What trading style should we follow when opening our positions, trend following or mean reverting? enum STRATEGY_MODES { TREND = 0, //Trend Following Mode MEAN_REVERTING = 1 //Mean Reverting Mode }; //--- Which time frame should we consult, when determining if we should close our position? enum CLOSING_TIME_FRAME { HIGHER_TIME_CLOSE = 0, //Close on higher time frames LOWER_TIME_CLOSE = 1 //Close on lower time frames };
Наши входные параметры достаточно просты. Мы задаём одно значение периода для старшего таймфрейма, а затем определяем разницу между периодами первой и второй скользящих средних. Такая конструкция гарантирует, что генетический оптимизатор будет выбирать разницу не менее единицы — тем самым по определению исключается нулевое различие между периодами.
//+------------------------------------------------------------------+ //| Inputs | //+------------------------------------------------------------------+ input group "Technical Indicators" input int ma_1_period = 10; //Higher Time Frame Period input int ma_1_gap = 20; //Higher Time Frame Period Gap input int ma_2_period = 10; //Lower Time Frame Period input int ma_2_gap = 20; //Lower Time Frame Period Gap input group "Strategy Settings" input STRATEGY_MODES strategy_mode = 0; //Strategy Operation Mode input CLOSING_TIME_FRAME closing_tf = 0; //Strategy Closing Timeframe
Приложению потребуется лишь умеренное количество глобальных переменных, таких как технические индикаторы и объекты, используемые для отслеживания текущих рыночных цен.
//+------------------------------------------------------------------+ //| Global variables | //+------------------------------------------------------------------+ int ma_c_1_handle,ma_c_2_handle,ma_c_3_handle,ma_c_4_handle; double ma_c_1[],ma_c_2[],ma_c_3[],ma_c_4[]; double volume_min; double bid,ask; int state;
Единственной внешней зависимостью в данном случае является библиотека Trade, которую мы используем для открытия и закрытия позиций при необходимости.
//+------------------------------------------------------------------+ //| Libraries | //+------------------------------------------------------------------+ #include <Trade\Trade.mqh> CTrade Trade;
При инициализации мы настраиваем технические индикаторы в соответствии с параметрами, заданными пользователем. Также мы сбрасываем состояние системы до значения -1, что означает отсутствие открытых позиций, и сохраняем минимально допустимый торговый объём на рынке.
//+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { //--- volume_min = SymbolInfoDouble(Symbol(),SYMBOL_VOLUME_MIN); ma_c_2_handle = iMA(Symbol(),TF_1,ma_1_period,0,MODE_SMA,PRICE_CLOSE); ma_c_1_handle = iMA(Symbol(),TF_1,(ma_1_period + ma_1_gap),0,MODE_SMA,PRICE_CLOSE); ma_c_4_handle = iMA(Symbol(),TF_2,ma_2_period,0,MODE_SMA,PRICE_CLOSE); ma_c_3_handle = iMA(Symbol(),TF_2,(ma_2_period + ma_2_gap),0,MODE_SMA,PRICE_CLOSE); state = -1; //--- return(INIT_SUCCEEDED); }
Когда приложение больше не используется, мы освобождаем ресурсы индикаторов для экономии памяти.
//+------------------------------------------------------------------+ //| Expert deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { //--- IndicatorRelease(ma_c_1_handle); IndicatorRelease(ma_c_2_handle); IndicatorRelease(ma_c_3_handle); IndicatorRelease(ma_c_4_handle); }
При поступлении новых ценовых данных мы проверяем, сформировалась ли новая свеча на младшем таймфрейме (в данном случае M15). Если новая свеча обнаружена, мы обновляем внутреннее состояние системы.
//+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick() { //--- datetime current_time = iTime(Symbol(),TF_2,0); static datetime time_stamp; if(time_stamp != current_time) { time_stamp = current_time; update(); } } //+------------------------------------------------------------------+
Одним из ключевых компонентов является функция, отвечающая за поиск торговых сигналов. Эта функция принимает параметр padding, который определяет размер стоп-лосса для позиций. Значение padding рассчитывается родительской функцией на основе исторических диапазонов рынка. В качестве меры предосторожности функция сначала проверяет, что общее количество открытых позиций равно нулю, чтобы избежать чрезмерной торговли.
Если на старшем таймфрейме происходит бычье пересечение и стратегия работает в режиме следования за трендом, функция ищет соответствующее бычье пересечение на младшем таймфрейме. Если же используется режим возврата к среднему, система ищет противоположный сигнал (то есть медвежье пересечение) и открывает позицию против него. Аналогичная логика применяется и для медвежьих входов: при медвежьем пересечении на старшем таймфрейме мы ищем соответствующий сигнал на младшем таймфрейме в зависимости от выбранного режима.
//+------------------------------------------------------------------+ //| Find a trading signal | //+------------------------------------------------------------------+ void find_setup(double padding) { if(PositionsTotal() == 0) { //--- Reset the system state state = -1; //--- Bullish on the higher time frame if(ma_c_1[0] > ma_c_2[0]) { //--- Trend following mode if((ma_c_3[0] > ma_c_4[0]) && (strategy_mode == 0)) { Trade.Buy(volume_min,Symbol(),ask,(bid - padding),0,""); state = 1; } //--- Mean reverting mode if((ma_c_3[0] < ma_c_4[0]) && (strategy_mode == 1)) { Trade.Buy(volume_min,Symbol(),ask,(bid - padding),0,""); state = 1; } } //--- Bearish on the higher time frame if(ma_c_1[0] < ma_c_2[0]) { //--- Trend following mode if((ma_c_3[0] < ma_c_4[0]) && (strategy_mode == 0)) { Trade.Sell(volume_min,Symbol(),bid,(ask + padding),0,""); state = 0; } //--- Mean reverting mode if((ma_c_3[0] > ma_c_4[0]) && (strategy_mode == 1)) { Trade.Sell(volume_min,Symbol(),bid,(ask + padding),0,""); state = 0; } } } }
После открытия позиции вызывается отдельная функция управления позициями, которая также работает в двух режимах — в зависимости от того, хотим ли мы закрывать сделки на основе сигналов младшего или старшего таймфрейма. Состояние системы обновляется при открытии позиций и определяет направление торговли. Например, если состояние равно нулю, это означает, что была открыта короткая позиция. Если затем первая скользящая средняя оказывается выше второй (что указывает на бычий импульс на дневном графике), и если управление выходом осуществляется по старшему таймфрейму, позиция будет закрыта. В противном случае, при управлении по младшему таймфрейму, мы ожидаем соответствующего пересечения уже на нём. Сразу определить, какой вариант выхода эффективнее, невозможно, поэтому необходимо протестировать оба подхода.
//+------------------------------------------------------------------+ //| Manage our open positions | //+------------------------------------------------------------------+ void manage_setup(void) { if(closing_tf == 0) { if((state ==0) && (ma_c_1[0] > ma_c_2[0])) Trade.PositionClose(Symbol()); if((state ==1) && (ma_c_1[0] < ma_c_2[0])) Trade.PositionClose(Symbol()); } else if(closing_tf == 1) { if((state ==0) && (ma_c_3[0] > ma_c_4[0])) Trade.PositionClose(Symbol()); if((state ==1) && (ma_c_3[0] < ma_c_4[0])) Trade.PositionClose(Symbol()); } }
Наконец, нам нужна функция для обновления ключевых системных переменных, таких как текущие цены Bid и Ask Также мы будем отслеживать последние 10 максимумов и минимумов на третьем таймфрейме — таймфрейме риска. В данном примере это H4, который удобно располагается между M15 и дневным графиком. Это делает его подходящим для оценки рыночного риска и установки стоп-лоссов, которые не будут ни слишком близкими, ни слишком удалёнными от точки входа.
//+------------------------------------------------------------------+ //| Update our technical indicators and positions | //+------------------------------------------------------------------+ void update(void) { //Update technical indicators and market readings CopyBuffer(ma_c_2_handle,0,0,1,ma_c_2); CopyBuffer(ma_c_1_handle,0,0,1,ma_c_1); CopyBuffer(ma_c_4_handle,0,0,1,ma_c_4); CopyBuffer(ma_c_3_handle,0,0,1,ma_c_3); bid = SymbolInfoDouble(Symbol(),SYMBOL_BID); ask = SymbolInfoDouble(Symbol(),SYMBOL_ASK); vector high = vector::Zeros(10); vector low = vector::Zeros(10); low.CopyRates(Symbol(),TF_3,COPY_RATES_LOW,0,10); high.CopyRates(Symbol(),TF_3,COPY_RATES_HIGH,0,10); vector var = high - low; double padding = var.Mean(); //Find an open position if(PositionsTotal() == 0) find_setup(padding); //Manage our open positions else if(PositionsTotal() > 0) manage_setup(); } //+------------------------------------------------------------------+
Чтобы приступить к бэктестированию, сначала необходимо выбрать созданный нами экспертный советник — double crossover EX5. Затем выбираем инструмент EURUSD, таймфрейм M1 и период тестирования с января 2020 года по текущий момент — всего пять лет. Форвард-тестирование будем проводить на половине доступных данных, чтобы сохранить реалистичность результатов.

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

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

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

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

Рисунок 7: Результаты форвард-теста из первоначального эксперимента показывают, что большинство стратегий не являются стабильными.
Мне пришлось вручную фильтровать результаты форвард-теста, чтобы найти настройки стратегии, которые были прибыльны в обоих тестах. Именно это и является признаком стабильности — стратегия должна показывать прибыль как на бэктесте, так и на форвард-тесте. Тот факт, что лишь небольшое количество конфигураций соответствовало этому критерию, побудил меня к дальнейшим доработкам.

Рисунок 8: Лишь небольшая часть стратегий, найденных генетическим оптимизатором, оказалась прибыльной в обоих тестах — это плохой признак.
Дальнейшие улучшения
Одним из веских аргументов в пользу улучшения стратегии является предоставление генетическому оптимизатору контроля над таймфреймом, который используется для расчета стоп-лосса и параметров риска. Кроме того, мы позволим оптимизатору самому решать, какое количество исторических баров следует использовать в расчете стоп-лосса. В нашей первой попытке мы предполагали, что 10 баров на таймфрейме H4 будет достаточно. Однако теперь мы позволим оптимизатору корректировать эту настройку и проверим, улучшит ли это изменение общие показатели производительности. //+------------------------------------------------------------------+ //| Inputs | //+------------------------------------------------------------------+ input group "Money Management Settings" input ENUM_TIMEFRAMES TF_3 = PERIOD_H4; //Risk Time Frame input int HISTORICAL_BARS = 10; //Historical bars for risk calculation
Нам также необходимо внести изменения в код. В предыдущей версии метод update отвечал за расчет отступа для каждой позиции. В этой новой версии за расчет отступа будет отвечать отдельная функция, так как теперь мы хотим, чтобы стоп-лосс был "скользящим" и следовал за прибыльными позициями.
//+------------------------------------------------------------------+ //| Get the stop loss size to use | //+------------------------------------------------------------------+ double get_padding(void) { vector high = vector::Zeros(10); vector low = vector::Zeros(10); low.CopyRates(Symbol(),TF_3,COPY_RATES_LOW,0,HISTORICAL_BARS); high.CopyRates(Symbol(),TF_3,COPY_RATES_HIGH,0,HISTORICAL_BARS); vector var = high - low; double padding = var.Mean(); return(padding); }
Метод update изменится соответствующим образом. Отступ (padding) теперь будет рассчитываться с помощью метода get_padding. Та часть метода update, которая изначально идентифицировала позиции, теперь будет вызывать функцию, ответственную за поиск торгового сетапа, и другой метод для управления открытыми позициями.
//+------------------------------------------------------------------+ //| Update our technical indicators and positions | //+------------------------------------------------------------------+ void update(void) { //Update technical indicators and market readings CopyBuffer(ma_c_2_handle,0,0,1,ma_c_2); CopyBuffer(ma_c_1_handle,0,0,1,ma_c_1); CopyBuffer(ma_c_4_handle,0,0,1,ma_c_4); CopyBuffer(ma_c_3_handle,0,0,1,ma_c_3); bid = SymbolInfoDouble(Symbol(),SYMBOL_BID); ask = SymbolInfoDouble(Symbol(),SYMBOL_ASK); double padding = get_padding(); //Find an open position if(PositionsTotal() == 0) find_setup(padding); //Manage our open positions else if(PositionsTotal() > 0) manage_setup(); } //+------------------------------------------------------------------+
Метод, управляющий позициями, всегда будет проверять, является ли новое предложенное значение стоп-лосса более выгодным, чем текущее. Если это так, он обновляет стоп-лосс; в противном случае — сохраняет текущее значение.
//+------------------------------------------------------------------+ //| Manage our open positions | //+------------------------------------------------------------------+ void manage_setup(void) { //Does the position exist? if(PositionSelect(Symbol())) { //Get the current stop loss double current_sl = PositionGetDouble(POSITION_SL); double padding = get_padding(); double new_sl; //Sell position if((state == 0)) { new_sl = (ask + padding); if(new_sl < current_sl) Trade.PositionModify(Symbol(),new_sl,0); } //Buy position if((state == 1)) { new_sl = (bid - padding); if(new_sl > current_sl) Trade.PositionModify(Symbol(),new_sl,0); } if(closing_tf == 0) { if((state ==0) && (ma_c_1[0] > ma_c_2[0])) Trade.PositionClose(Symbol()); if((state ==1) && (ma_c_1[0] < ma_c_2[0])) Trade.PositionClose(Symbol()); } else if(closing_tf == 1) { if((state ==0) && (ma_c_3[0] > ma_c_4[0])) Trade.PositionClose(Symbol()); if((state ==1) && (ma_c_3[0] < ma_c_4[0])) Trade.PositionClose(Symbol()); } } }
Для проведения наших тестов я выбрал одну конфигурацию, которая была прибыльной как в бэктесте, так и в форвард-тесте. Затем я зафиксировал остальные настройки, позволив генетическому оптимизатору искать более эффективные параметры риска.

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

Рисунок 10: Наши новые результаты по-прежнему не были прибыльными в обоих тестах.
Заключение
Это упражнение многому нас научило. На примере стратегии пересечения двух скользящих средних мы увидели, что действительно можем контролировать степень запаздывания, хотя последствия таких изменений не всегда очевидны сразу. Возможно, нам стоит рассмотреть вариант повторного запуска оптимизации со всеми доступными параметрами одновременно. Вероятно, настройка одного параметра при фиксации остальных — не самый оптимальный подход. Поиск по всем параметрам сразу может дать более стабильные результаты.
В нашей последующей дискуссии, после проведения полного цикла оптимизации, мы построим статистические модели на основе наиболее прибыльных конфигураций. Это может помочь нам еще больше сократить запаздывание. Однако на данный момент мы заложили отличный фундамент. Помните, что оптимизация не дает никаких гарантий, а ИИ не заменит трудолюбивых разработчиков. Мы должны повторять процедуру оптимизации до тех пор, пока генетический оптимизатор не предложит нам группы стратегий, прибыльных в обоих тестах (бэктест и форвард), иначе оптимизация, скорее всего, будет преждевременной.
Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/18793
Предупреждение: все права на данные материалы принадлежат MetaQuotes Ltd. Полная или частичная перепечатка запрещена.
Данная статья написана пользователем сайта и отражает его личную точку зрения. Компания MetaQuotes Ltd не несет ответственности за достоверность представленной информации, а также за возможные последствия использования описанных решений, стратегий или рекомендаций.
Особенности написания Пользовательских Индикаторов
Преодоление проблем доступности в торговых инструментах на MQL5 (Часть I): Как добавить контекстные голосовые оповещения в индикаторы MQL5
Создание самооптимизирующихся советников в MQL5 (Часть 8): Анализ нескольких стратегий (3) — Политика взвешенного голосования
- Бесплатные приложения для трейдинга
- 8 000+ сигналов для копирования
- Экономические новости для анализа финансовых рынков
Вы принимаете политику сайта и условия использования