Торговля по календарю

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

Попробуем создать эксперт, который будет входить в рынок по новостям, в соответствии с оценкой их влияния на цену. Файл кэша "xyz.cal" был только что создан с помощью индикатора CalendarMonitorCached.mq5.

Напомним, что образ календаря в кэше всегда соответствует моменту сохранения и требует осторожности при чтении: у более поздних событий актуальные показатели неизвестны, а более отдаленные события могут вообще не существовать. Регулярно обновляейте файл кэша календаря перед очередной оптимизацией или тестированием.
 
При необходимости также учтите переводы часов на "летнее" и "зимнее" время в течение года: события из периодов с режимом DST, противоположном режиму DST в момент сохранения архива календаря, потребуется сдвинуть на 1 час назад или вперед. Избежать данных сложностей можно за счет выбора брокера без переключения DST или построение стратегии на таймфреймах больше H1.

Эксперт CalendarTrading.mq5 будет торговать только по новостям, которые:

  • относятся к рабочему символу графика;
  • имеют тип финансового индикатора (то есть количественные);
  • высокой важности;
  • получили только что актуальное значение индикатора;

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

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

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

input double Volume;               // Volume (0 = minimal lot)
input int Distance2SLTP = 500;     // Distance to SL/TP in points (0 = no)
input uint MultiplePositions = 25;

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

Опционально эксперт поддерживает фильтры на идентификатор вида новости и текст для поиска по названию.

sinput ulong EventID;
sinput string Text;

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

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

AutoPtr<CalendarFilterfptr;
AutoPtr<CalendarCachecache;
AutoPtr<TrailingStoptrailing[];

Режим работы и пара валют текущего рабочего символа сохраняются в соответствующих переменных. Для упрощения примера предполагается применение на Forex (на других рынках получится торговля одной валютой — валютой котирования тикера).

const bool Hedging =
   AccountInfoInteger(ACCOUNT_MARGIN_MODE) == ACCOUNT_MARGIN_MODE_RETAIL_HEDGING;
const string Base = SymbolInfoString(_SymbolSYMBOL_CURRENCY_BASE);
const string Profit = SymbolInfoString(_SymbolSYMBOL_CURRENCY_PROFIT);

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

int OnInit()
{
   cache = new CalendarCache("xyz.cal"true);
   if(cache[].isLoaded())
   {
      fptr = new CalendarFilterCached(cache[]);
   }
   else
   {
      if(!MQLInfoInteger(MQL_TESTER))
      {
         Print("Calendar cache file not found, fall back to online mode");
         fptr = new CalendarFilter();
      }
      else
      {
         Print("Can't proceed in the tester without calendar cache file");
         return INIT_FAILED;
      }
   }
   CalendarFilter *f = fptr[];
   
   if(!f.isLoaded()) return INIT_FAILED;
   
   // если задан конкретный вид события, смотрим только его
   if(EventID > 0f.let(EventID);
   else
   {
      // иначе следим за новостями по валютам текущего символа
      f.let(Base);
      if(Base != Profit)
      {
         f.let(Profit);
      }
      
      // финансовые показатели, высокая важность, наличие актуального значения
      f.let(CALENDAR_TYPE_INDICATOR);
      f.let(LONG_MINCALENDAR_PROPERTY_RECORD_FORECASTNOT_EQUAL);
      f.let(CALENDAR_IMPORTANCE_HIGH);
   
      if(StringLen(Text)) f.let(Text);
   }
   
   f.describe();
   
   if(Distance2SLTP)
   {
      ArrayResize(trailingHedging && MultiplePositions ? MultiplePositions : 1);
   }
   // проверку фильтра новостей и торговлю по нему запускаем по секундному таймеру
   EventSetTimer(1);
   return INIT_SUCCEEDED;
}

В обработчике OnTimer запросим изменения новостей по настроенным фильтрам.

void OnTimer()
{
   CalendarFilter *f = fptr[];
   MqlCalendarValue records[];
   
   f.let(TimeTradeServer() - SCOPE_DAYTimeTradeServer() + SCOPE_DAY);
   
   if(f.update(records)) // найти изменения, подпадающие под фильтры
   {
      // вывод свойств измененных новостей в журнал
      static const ENUM_CALENDAR_PROPERTY props[] =
      {
         CALENDAR_PROPERTY_RECORD_TIME,
         CALENDAR_PROPERTY_COUNTRY_CURRENCY,
         CALENDAR_PROPERTY_COUNTRY_CODE,
         CALENDAR_PROPERTY_EVENT_NAME,
         CALENDAR_PROPERTY_EVENT_IMPORTANCE,
         CALENDAR_PROPERTY_RECORD_ACTUAL,
         CALENDAR_PROPERTY_RECORD_FORECAST,
         CALENDAR_PROPERTY_RECORD_PREVISED,
         CALENDAR_PROPERTY_RECORD_IMPACT,
      };
      static const int p = ArraySize(props);
      string result[];
      f.format(recordspropsresult);
      for(int i = 0i < ArraySize(result) / p; ++i)
      {
         Print(SubArrayCombine(result" | "i * pp));
      }
      ...

Когда подходящие изменения обнаружены, они выводятся в журнал следующим образом (фрагмент реального журнала ниже), с указанием времени, валюты, страны, названия, актуального и прогнозного значений, предыдущего значения и теоретической трактовки сигнала:

...

Filtering 5 records

2021.02.16 13:00 | EUR | EU | Employment Change q/q | HIGH | +0.3 | -0.4 | +1.0 | POSITIVE

2021.02.16 13:00 | EUR | EU | GDP q/q | HIGH | -0.6 | -0.7 | -0.7 | POSITIVE

instant buy 0.01 EURUSD at 1.21638 sl: 1.21138 tp: 1.22138 (1.21637 / 1.21638 / 1.21637)

deal #64 buy 0.01 EURUSD at 1.21638 done (based on order #64)

...

Filtering 3 records

2021.07.06 12:05 | EUR | DE | ZEW Economic Sentiment Indicator | HIGH | +63.3 | +84.1 | +79.8 | NEGATIVE

instant sell 0.01 EURUSD at 1.18473 sl: 1.18973 tp: 1.17973 (1.18473 / 1.18474 / 1.18473)

deal #265 sell 0.01 EURUSD at 1.18473 done (based on order #265)

...

На основе оценки в поле impact_type следует вычислить потенциальное влияние новостей на цену. Здесь важно отметить, что у нас две валюты: базовая и котирования. Когда новость имеет положительный эффект для базовой валюты, курс предположительно будет повышаться, а если отрицательный — то понижаться. Для валюты котирования все наоборот: положительный эффект должен удорожать вторую валюту в паре, что означает уменьшение курса, в то время как отрицательный — ведет к его увеличению. Это нормализованное направление движения цены вычисляется в следующем фрагменте с помощью переменной sign.

      static const int impacts[3] = {0, +1, -1};
      int impact = 0;
      string about = "";
      ulong lasteventid = 0;
      for(int i = 0i < ArraySize(records); ++i)
      {
         int sign = result[i * p + 1] == Profit ? -1 : +1;
         impact += sign * impacts[records[i].impact_type];
         about += StringFormat("%+lld "sign * (long)records[i].event_id);
         lasteventid = records[i].event_id;
      }
      
      if(impact == 0return// нет сигнала
      ...

Часто несколько новостей выходит одновременно, поэтому требуется аккумулировать оценки для них всех. Это делается в переменной impact. Поскольку в нашей стратегии фильтруются только новости одной, самой высокой важности, все одиночные сигналы от них просто суммируются, без весовых коэффициентов. В строковой переменной about подготавливается текст для комментария готовящейся сделки: там будут упомянуты идентификаторы событий, вызвавших сделку.

В случае, если робот запущен на счете с неттингом или достигнуто максимальное разрешенное количество позиций, закроем одну.

      PositionFilter positions;
      ulong tickets[];
      positions.let(POSITION_SYMBOL_Symbol).select(tickets);
      const int n = ArraySize(tickets);
      
      if(n >= (int)(Hedging ? MultiplePositions : 1))
      {
         MqlTradeRequestSync position;
         position.close(_Symbol) && position.completed();
      }
      ...

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

      MqlTradeRequestSync request;
      request.magic = lasteventid;
      request.comment = about;
      const double ask = SymbolInfoDouble(_SymbolSYMBOL_ASK);
      const double bid = SymbolInfoDouble(_SymbolSYMBOL_BID);
      const double point = SymbolInfoDouble(_SymbolSYMBOL_POINT);
      ulong ticket = 0;
      
      if(impact > 0)
      {
         ticket = request.buy(Lot0,
            Distance2SLTP ? ask - point * Distance2SLTP : 0,
            Distance2SLTP ? ask + point * Distance2SLTP : 0);
      }
      else if(impact < 0)
      {
         ticket = request.sell(Lot0,
            Distance2SLTP ? bid + point * Distance2SLTP : 0,
            Distance2SLTP ? bid - point * Distance2SLTP : 0);
      }
      
      if(ticket && request.completed() && Distance2SLTP)
      {
         for(int i = 0i < ArraySize(trailing); ++i)
         {
            if(trailing[i][] == NULL// ищем свободный слот для объекта сопровождения позиции
            {
               trailing[i] = new TrailingStop(ticketDistance2SLTPDistance2SLTP / 50);
               break;
            }
         }
      }
   }
}

Двигаем стоп-лоссы у всех позиций по приходу тиков.

void OnTick()
{
   for(int i = 0i < ArraySize(trailing); ++i)
   {
      if(trailing[i][])
      {
         if(!trailing[i][].trail()) // позиция была закрыта
         {
            trailing[i] = NULL// освобождаем объект и слот
         }
      }
   }
}

А теперь самое интересное. Благодаря тестеру появляется возможность проанализировать успешность новостной стратегии не только в целом, но и в разбивке по конкретным новостям. Соответствующий блок реализован у нас в обработчике OnTester. Сбор данных выполняется с помощью фильтра сделок. Получив из него массив кортежей trades, в котором сообщается прибыль, своп, комиссия и "магическое" число каждой сделки, мы аккумулируем результаты в трех объектах MapArray: они подсчитывают раздельно прибыли, убытки и количество трейдов для каждого magic-а.

double OnTester()
{
   Print("Trade profits by calendar events:");
   HistorySelect(0LONG_MAX);
   DealFilter filter;
   int props[] = {DEAL_PROFITDEAL_SWAPDEAL_COMMISSIONDEAL_MAGIC};
   filter.let(DEAL_TYPE, (1 << DEAL_TYPE_BUY) | (1 << DEAL_TYPE_SELL), IS::OR_BITWISE)
      .let(DEAL_ENTRY, (1 << DEAL_ENTRY_OUT) | (1 << DEAL_ENTRY_INOUT) | (1 << DEAL_ENTRY_OUT_BY),
      IS::OR_BITWISE);
   Tuple4<doubledoubledoubleulongtrades[];
   MapArray<ulong,doubleprofits;
   MapArray<ulong,doublelosses;
   MapArray<ulong,intcounts;
   if(filter.select(propstrades))
   {
      for(int i = 0i < ArraySize(trades); ++i)
      {
         counts.inc((ulong)trades[i]._4);
         const double payout = trades[i]._1 + trades[i]._2 + trades[i]._3;
         if(payout >= 0)
         {
            profits.inc((ulong)trades[i]._4payout);
            losses.inc((ulong)trades[i]._40);
         }
         else
         {
            profits.inc((ulong)trades[i]._40);
            losses.inc((ulong)trades[i]._4payout);
         }
      }
      ...

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

      for(int i = 0i < profits.getSize(); ++i)
      {
         MqlCalendarEvent event;
         MqlCalendarCountry country;
         const ulong keyId = profits.getKey(i);
         if(cache[].calendarEventById(keyIdevent)
            && cache[].calendarCountryById(event.country_idcountry))
         {
            PrintFormat("%lld %s %s %+.2f [%d] (PF:%.2f) %s",
               event.idcountry.codecountry.currency,
               profits[keyId] + losses[keyId], counts[keyId],
               profits[keyId] / (losses[keyId] != 0 ? -losses[keyId] : DBL_MIN),
               event.name);
         }
         else
         {
            Print("undefined "DoubleToString(profits.getValue(i), 2));
         }
      }
   }
   return 0;
}

Для проверки идеи запустим эксперт на периоде с начала 2021 года (по середину 2022) на паре EURUSD. Ниже приведен фрагмент журнала с распечаткой из OnTester.

Trade profits by calendar events:
840040001 US USD -21.81 [17] (PF:0.53) ISM Manufacturing PMI
840190001 US USD -10.95 [17] (PF:0.69) ADP Nonfarm Employment Change
840200001 US USD -67.09 [78] (PF:0.60) EIA Crude Oil Stocks Change
999030003 EU EUR +14.13 [19] (PF:1.46) Retail Sales m/m
840040003 US USD -17.12 [18] (PF:0.59) ISM Non-Manufacturing PMI
840030016 US USD -1.20 [19] (PF:0.97) Nonfarm Payrolls
840030021 US USD +5.25 [14] (PF:1.21) JOLTS Job Openings
840020010 US USD -14.63 [17] (PF:0.63) Retail Sales m/m
276070001 DE EUR -22.71 [17] (PF:0.47) ZEW Economic Sentiment Indicator
840020005 US USD +10.76 [18] (PF:1.37) Building Permits
840120001 US USD -20.78 [17] (PF:0.49) Existing Home Sales
276030003 DE EUR +18.57 [17] (PF:1.87) Ifo Business Climate
840180002 US USD -3.22 [14] (PF:0.89) CB Consumer Confidence Index
840020014 US USD -8.74 [16] (PF:0.74) Core Durable Goods Orders m/m
840020008 US USD -14.54 [16] (PF:0.63) New Home Sales
250010005 FR EUR +0.66 [10] (PF:1.03) GDP q/q
840010007 US USD +0.99 [15] (PF:1.04) GDP q/q
840120003 US USD +4.53 [18] (PF:1.15) Pending Home Sales m/m
276010008 DE EUR -0.72 [10] (PF:0.97) GDP q/q
999030016 EU EUR -14.04 [14] (PF:0.59) GDP q/q
999030001 EU EUR +1.30 [2] (PF:1.35) Employment Change q/q

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

В целом, данный тест выявил, что любимые трейдерами Nonfarm Payrolls или отчеты по GDP не гарантируют успех, по крайней мере, с нашими настройками по умолчанию. Далее требуется, в обычном порядке, анализировать отдельные сделки, выяснять, что пошло не так, подбирать параметры и совершенствовать алгоритм, в частности добавить модуль корректировки времени по переключению DST в таймзоне сервера.

Вместе с тем, сам технический прием работает нормально, и мы можем для начала просто попытаться выбрать наиболее успешные новости. Например, возьмем новость 276030003 (Ifo Business Climate). Установив её в EventID, получим следующий отчет, совпадающим с нашими расчетными показателями.

Отчет торговли в тестере по новостям Ifo Business Climate

Отчет торговли в тестере по новостям Ifo Business Climate

Вы можете также попробовать торговлю по группе одноименных событий. В частности, чтобы реагировать только на новости по GDP (разных стран), введите в переменную Text строку "*GDP*". Звездочки добавлены, потому что без них строка длиной 3 символа будет трактоваться классом фильтра как валюта. Строки любой длины, отличной от 2 (код страны) или 3 (код валюты), могут задаваться как есть, например "farm", "Nonfarm", "Sales" — они будут искаться фильтром как подстроки названий, с учетом регистра.