Полное и частичное закрытие встречных позиций (хедж)

На счетах с хеджированием разрешено одновременно открывать несколько позиций, причем в большинстве случаев эти позиции могут быть и противоположного направления. В некоторых юрисдикциях на хедж-счета накладывают ограничение: одновременно можно иметь только позиции одного направления — выяснить это можно, получив код ошибки TRADE_RETCODE_HEDGE_PROHIBITED при попытке выполнить встречную торговую операцию. Также зачастую данное ограничение коррелирует с настройкой свойства счета ACCOUNT_FIFO_CLOSE, равного true.

Когда две противоположных позиции открыты одновременно, платформа поддерживает механизм их одновременного взаимного закрытия с помощью операции TRADE_ACTION_CLOSE_BY. Для выполнения данного действия в структуре MqlTradeTransaction следует заполнить помимо поля action только два поля — position и position_by с тикетами закрываемых позиций.

Доступность этой возможности зависит от свойства SYMBOL_ORDER_MODE финансового инструмента: в битовой маске разрешенных флагов должен присутствовать SYMBOL_ORDER_CLOSEBY (64).

Данная операция не только упрощает закрытие (одна операция вместо двух), но и позволяет сэкономить один спред.

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

Если одновременно закрывать позиции штатным способом, их цены выхода окажутся на расстоянии текущего спреда друг от друга. Однако если воспользоваться операцией TRADE_ACTION_CLOSE_BY, то обе позиции закроются без учета текущих цен. Цена, по которой происходит взаимозачет позиций, равна цене открытия позиции position_by (в структуре запроса). Именно она фигурирует в ордере ORDER_TYPE_CLOSE_BY, генерируемом по запросу TRADE_ACTION_CLOSE_BY.

К сожалению, в отчетах в разрезе сделок и позиций цены закрытия и открытия противоположных позиций/сделок отображаются парами одинаковых значений, в зеркальном направлении, из-за чего складывается впечатление о двойной прибыли или убытке. На самом деле финансовый результат операции (разница между ценами с поправкой на лот) записывается только на сделку выхода первой позиции (поле position в структуре запроса). Результат второй сделки выхода всегда равен 0, невзирая на разницу цен.

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

На следующей схеме приведено графическое пояснение процесса (размер спредов намеренно преувеличен).

Учет спреда при встречном закрытии прибыльных позиций

Учет спреда при встречном закрытии прибыльных позиций

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

Учет спреда при встречном закрытии убыточных позиций

Учет спреда при встречном закрытии убыточных позиций

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

В файле MqlTradeSync.mqh встречное закрытие реализовано с помощью метода closeby с двумя параметрами, принимающими тикеты позиций.

struct MqlTradeRequestSyncpublic MqlTradeRequest
{
   ...
   bool closeby(const ulong ticket1const ulong ticket2)
   {
      if(!PositionSelectByTicket(ticket1)) return false;
      double volume1 = PositionGetDouble(POSITION_VOLUME);
      if(!PositionSelectByTicket(ticket2)) return false;
      double volume2 = PositionGetDouble(POSITION_VOLUME);
   
      action = TRADE_ACTION_CLOSE_BY;
      position = ticket1;
      position_by = ticket2;
      
      ZeroMemory(result);
      if(volume1 != volume2)
      {
         // запоминаем, какая позиция должна исчезнуть
         if(volume1 < volume2)
            result.position = ticket1;
         else
            result.position = ticket2;
      }
      return OrderSend(thisresult);
   }

Для контроля результата закрытия мы запоминаем в переменной result.position тикет меньшей позиции. В методе completed и структуре MqlTradeResultSync уже все готово для синхронного отслеживания закрытия позиции: этот же алгоритм работал и при обычном закрытии позиции.

struct MqlTradeRequestSyncpublic MqlTradeRequest
{
   ...
   bool completed()
   {
      ...
      else if(action == TRADE_ACTION_CLOSE_BY)
      {
         return result.closed(timeout);
      }
      return false;
   }

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

Разовьем идею побаровой стратегии "price action", изложенную в предыдущем примере. Назовем новый эксперт TradeCloseBy.mq5.

Оставим прежний сигнал на вход в рынок при обнаружении двух последовательных свечей, закрывшихся в одном направлении. За его формирование будет отвечать функция GetTradeDirection — без изменений. Однако разрешим повторные входы, если тренд продолжается. Общее максимально разрешенное количество позиций зададим во входной переменной PositionLimit, по умолчанию — 5.

Функция GetMyPositions претерпит некоторые изменения: у неё добавятся два параметра — ссылки на массивы, принимающие тикеты позиций — раздельно покупки и продажи.

#define PUSH(A,V) (A[ArrayResize(AArraySize(A) + 1ArraySize(A) * 2) - 1] = V)
   
int GetMyPositions(const string sconst ulong m,
   ulong &ticketsLong[], ulong &ticketsShort[])
{
   for(int i = 0i < PositionsTotal(); ++i)
   {
      if(PositionGetSymbol(i) == s && PositionGetInteger(POSITION_MAGIC) == m)
      {
         if((ENUM_POSITION_TYPE)PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY)
            PUSH(ticketsLongPositionGetInteger(POSITION_TICKET));
         else
            PUSH(ticketsShortPositionGetInteger(POSITION_TICKET));
      }
   }
   
   const int min = fmin(ArraySize(ticketsLong), ArraySize(ticketsShort));
   if(min == 0return -fmax(ArraySize(ticketsLong), ArraySize(ticketsShort));
   return min;
}

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

Если минимальный массив нулевого размера, функция вернет размер другого массива, но со знаком минус — просто, чтобы дать знать вызывающему коду, что все позиции в одном направлении.

Если позиций нет ни в одном направлении, функция вернет 0.

Открытие позиций останется в ведении функции OpenPosition — без изменений.

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

bool CloseByPosition(const ulong ticket1const ulong ticket2)
{
   MqlTradeRequestSync request;
   request.magic = Magic;
   
   ResetLastError();
   // отправляем запрос и ждем его завершения
   if(request.closeby(ticket1ticket2))
   {
      Print("Positions collapse initiated");
      if(request.completed())
      {
         Print("OK CloseBy Order/Deal/Position");
         return true// успех
      }
   }
   
   Print(TU::StringOf(request));
   Print(TU::StringOf(request.result));
   
   return false// ошибка
}

Здесь как раз используется описанный выше метод request.closeby с заполнением полей position, position_by и вызовом OrderSend.

Торговая логика описана в обработчике OnTick, который как и раньше анализирует конфигурацию цен лишь в момент формирования нового бара и получает сигнал из функции GetTradeDirection.

void OnTick()
{
   static bool error = false;
   // ждем формирования нового бара, если не было ошибки
   static datetime lastBar = 0;
   if(iTime(_Symbol_Period0) == lastBar && !errorreturn;
   lastBar = iTime(_Symbol_Period0);
   
   const ENUM_ORDER_TYPE type = GetTradeDirection();
   ...

Далее заполняем массивы ticketsLong и ticketsShort тикетами позиций по рабочему символу и с заданным Magic-номером. Если функция GetMyPositions вернула значение больше нуля, оно означает количество сформировавшихся пар встречных позиций. Их можно закрыть в цикле с помощью функции CloseByPosition. Сочетание пар в данном случае выбирается случайно (в порядке следования позиций в окружении терминала), однако на практике может быть важно подбирать пары по объемам или таким образом, чтобы сперва закрывались наиболее прибыльные.

   ulong ticketsLong[], ticketsShort[];
   const int n = GetMyPositions(_SymbolMagicticketsLongticketsShort);
   if(n > 0)
   {
      for(int i = 0i < n; ++i)
      {
         error = !CloseByPosition(ticketsShort[i], ticketsLong[i]) && error;
      }
   }
   ...

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

   else if(type == ORDER_TYPE_BUY || type == ORDER_TYPE_SELL)
   {
      error = !OpenPosition(type);
   }
   ...

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

   else if(n < 0)
   {
      if(-n >= (int)PositionLimit)
      {
         if(ArraySize(ticketsLong) > 0)
         {
            error = !OpenPosition(ORDER_TYPE_SELL);
         }
         else // (ArraySize(ticketsShort) > 0)
         {
            error = !OpenPosition(ORDER_TYPE_BUY);
         }
      }
   }
}

Запустим эксперт в тестере на XAUUSD,H1 с начала 2022 года, с настройками по умолчанию. Ниже представлен внешний вид графика с позициями в процессе работы программы, а также кривая баланса.

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

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

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

2022.01.03 01:05:00   instant buy 0.01 XAUUSD at 1831.13 (1830.63 / 1831.13 / 1830.63)

2022.01.03 01:05:00   deal #2 buy 0.01 XAUUSD at 1831.13 done (based on order #2)

2022.01.03 01:05:00   deal performed [#2 buy 0.01 XAUUSD at 1831.13]

2022.01.03 01:05:00   order performed buy 0.01 at 1831.13 [#2 buy 0.01 XAUUSD at 1831.13]

2022.01.03 01:05:00   Waiting for position for deal D=2

2022.01.03 01:05:00   OK New Order/Deal/Position

2022.01.03 02:00:00   instant buy 0.01 XAUUSD at 1828.77 (1828.47 / 1828.77 / 1828.47)

2022.01.03 02:00:00   deal #3 buy 0.01 XAUUSD at 1828.77 done (based on order #3)

2022.01.03 02:00:00   deal performed [#3 buy 0.01 XAUUSD at 1828.77]

2022.01.03 02:00:00   order performed buy 0.01 at 1828.77 [#3 buy 0.01 XAUUSD at 1828.77]

2022.01.03 02:00:00   Waiting for position for deal D=3

2022.01.03 02:00:00   OK New Order/Deal/Position

2022.01.03 03:00:00   instant buy 0.01 XAUUSD at 1830.40 (1830.16 / 1830.40 / 1830.16)

2022.01.03 03:00:00   deal #4 buy 0.01 XAUUSD at 1830.40 done (based on order #4)

2022.01.03 03:00:00   deal performed [#4 buy 0.01 XAUUSD at 1830.40]

2022.01.03 03:00:00   order performed buy 0.01 at 1830.40 [#4 buy 0.01 XAUUSD at 1830.40]

2022.01.03 03:00:00   Waiting for position for deal D=4

2022.01.03 03:00:00   OK New Order/Deal/Position

2022.01.03 05:00:00   instant sell 0.01 XAUUSD at 1826.22 (1826.22 / 1826.45 / 1826.22)

2022.01.03 05:00:00   deal #5 sell 0.01 XAUUSD at 1826.22 done (based on order #5)

2022.01.03 05:00:00   deal performed [#5 sell 0.01 XAUUSD at 1826.22]

2022.01.03 05:00:00   order performed sell 0.01 at 1826.22 [#5 sell 0.01 XAUUSD at 1826.22]

2022.01.03 05:00:00   Waiting for position for deal D=5

2022.01.03 05:00:00   OK New Order/Deal/Position

2022.01.03 06:00:00   close position #5 sell 0.01 XAUUSD by position #2 buy 0.01 XAUUSD (1825.64 / 1825.86 / 1825.64)

2022.01.03 06:00:00   deal #6 buy 0.01 XAUUSD at 1831.13 done (based on order #6)

2022.01.03 06:00:00   deal #7 sell 0.01 XAUUSD at 1826.22 done (based on order #6)

2022.01.03 06:00:00   Positions collapse initiated

2022.01.03 06:00:00   OK CloseBy Order/Deal/Position

Интересным артефактом является сделка #3. Внимательный читатель заметит, что она открылась ниже предыдущей, вроде бы нарушая нашу стратегию. На самом деле ошибки здесь нет, и это следствие того, что условия сигналов прописаны максимально просто: только на основе цен закрытия баров. Поэтому разворотная медвежья свеча (D), открывшаяся с гепом вверх и закрывшаяся выше окончания предыдущей бычьей свечи (C), сгенерировала сигнал для покупки. Данная ситуация иллюстрируется следующим скриншотом.

Сделки на восходящем тренде по ценам закрытия

Сделки на восходящем тренде по ценам закрытия

Все свечи в последовательности A, B, C, D, E закрываются выше предыдущей и стимулируют продолжение покупок. Для исключения подобных артефактов следует дополнительно проводить анализ направления самих баров.

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

int OnInit()
{
   ...
   if(AccountInfoInteger(ACCOUNT_MARGIN_MODE) != ACCOUNT_MARGIN_MODE_RETAIL_HEDGING)
   {
      Alert("An account with hedging is required for this EA!");
      return INIT_FAILED;
   }
   
   if((SymbolInfoInteger(_SymbolSYMBOL_ORDER_MODE) & SYMBOL_ORDER_CLOSEBY) == 0)
   {
      Alert("'Close By' mode is not supported for "_Symbol);
      return INIT_FAILED;
   }
   
   return INIT_SUCCEEDED;
}

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