English
preview
Управление позициями: Безопасный пирамидинг с единым стопом в MQL5

Управление позициями: Безопасный пирамидинг с единым стопом в MQL5

MetaTrader 5Трейдинг |
71 3
Tola Moses Hector
Tola Moses Hector

Введение

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

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

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

Ключевой вопрос, на который должен ответить этот движок, звучит так:

Как меняется общий риск по счету с каждой новой добавленной позицией?

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

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


Почему большинство реализаций пирамидирования дают сбой

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

Сценарий отказа 1 – одинаковый размер лота

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

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

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

Сценарий отказа 2 – независимые стопы

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

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

Что еще важнее, если вы пытаетесь передвинуть один стоп для фиксации прибыли, нужно не забыть передвинуть и остальные. Если модификация стопа не выполняется – из-за отклонения со стороны брокера, нарушения уровня заморозки (freeze level) или сетевого сбоя, – одна позиция остается под риском, тогда как остальные уже защищены. Структура, которая в теории выглядела согласованной, на практике незаметно теряет согласованность.

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

Инженерное следствие

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

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


Математика безопасного пирамидирования

Три условия

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

  1. Размеры лотов должны строго уменьшаться. Движок проверяет это в Init() и отказывается запускаться, если условие нарушено.
  2. Единый стоп после каждого добавления должен сдвигаться достаточно далеко, чтобы зафиксированная прибыль по предыдущим позициям превышала новый риск, который приносит следующая. С параметрами по умолчанию из этой статьи это условие выполняется для стандартных инструментов Forex. На инструментах с более широкими спредами или нестандартной стоимостью лота перед запуском нужно проверить денежные оценки риска через GetPipValue().
  3. Все модификации стопа должны выполняться. Если брокер отклоняет модификацию стопа, расчет зафиксированной прибыли для этой позиции остается недействительным, пока модификация не пройдет успешно. Движок явно заносит в журнал все ошибки, чтобы оператор мог во время тестирования выявить проблемы на стороне брокера.

Когда выполняются все три условия, после каждого добавления соблюдается следующее неравенство:

∑ Risk_new ≤ Risk_initial

где Risk_initial – это денежный риск на входе, а Risk_new – суммарный наихудший риск после всех добавлений и перемещений стопа.

Почему риск измеряется в долларах, а не в пипсах

Многие программисты выражают риск как "пипсы × лоты", потому что это легко посчитать. Для стандартного счета EURUSD с фиксированной стоимостью пипса это работает как приближение. Для рабочей системы, применяемой на нескольких инструментах, такой подход не годится.

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

CPyramidEngine использует функцию GetPipValue() из библиотеки PyramidUtils.mqh, которая корректно вычисляет денежный риск через SYMBOL_TRADE_TICK_VALUE и SYMBOL_TRADE_TICK_SIZE. Это денежная стоимость одного тика движения для одного стандартного лота, предоставляемая брокером и выраженная в валюте счета. Такой расчет корректен для всех типов инструментов у любых брокеров.

Примечание о долларовых значениях в этой статье.

Долларовые суммы в таблице ниже рассчитаны по формуле GetPipValue(): pip_size / tick_size × tick_value × lot_size. Показанные значения предполагают EURUSD на стандартном счете, где это дает примерно $1 за пипс при 0,10 лота. На разных инструментах величины будут отличаться, но структурный принцип сохранится, если выполняются три приведенных выше условия.

Таблица изменения риска

В таблице ниже показана полная трехпозиционная пирамида по EURUSD с параметрами по умолчанию из этой статьи.

Этап Цена Позиции Общее количество лотов Единый стоп Риск по позициям Общий наихудший сценарий
Начальный вход 1.0800 P1 0.30 1.0680 P1: риск -$360 -$360
Срабатывает добавление 1 (+50 пипсов) 1.0850 P1 + P2 0.50 1.0825 P1: зафиксировано +$75 | P2: риск -$50 +$25
Срабатывает добавление 2 (100 пипсов) 1.0900 P1 + P2 + P3 0.60 1.0890 P1: зафиксировано +$270 | P2: зафиксировано +$80 | P3: риск -$10 +$340

Читая таблицу построчно:

  • При начальном входе трейдер несет максимальный риск в размере $360. Это стандартная экспозиция для такого размера позиции – 120 пипсов при $1 за пипс на 0,10 лота, умноженные на 3.
  • Когда на отметке +50 пипсов срабатывает добавление 1, единый стоп сдвигается к 1,0825. Позиция 1 больше не может принести убыток – ее стоп установлен на 25 пипсов выше цены входа. Позиция 2 несет риск в 25 пипсов при объеме 0,20 лота, то есть 50 долларов. Зафиксированная прибыль по P1 (75 долларов) уже превышает новый риск по P2 (50 долларов). Совокупный результат в худшем случае: +25 долларов. После первого добавления пирамида становится безрисковой.
  • Когда второе добавление срабатывает на уровне +100 пипсов, по позициям 1 и 2 уже зафиксирована общая прибыль 350 долларов. Позиция 3 несет риск всего в 10 пипсов при объеме 0,10 лота, то есть 10 долларов. Итог в худшем случае, если все стопы сработают одновременно: чистая прибыль 340 долларов. Структура остается прибыльной при любом дальнейшем развитии рынка.

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


Режим счета – хеджирование или неттинг

Прежде чем разбирать код движка, нужно четко обозначить одно предварительное условие: для работы CPyramidEngine нужен счет с хеджированием. Это не произвольное ограничение – оно следует из того, как движок управляет состоянием.

Что меняется в разных режимах счета?

  • На неттинговом счете ("ACCOUNT_MARGIN_MODE_RETAIL_NETTING") MetaTrader 5 поддерживает только одну позицию по символу. Открытие второй сделки в том же направлении не создает отдельную позицию – оно увеличивает объем уже существующей. Брокер сводит всю экспозицию в одну запись с одним тикетом. Для пирамидирования это имеет серьезные последствия. На неттинговом счете добавление 0,20 лота к существующей позиции объемом 0,30 лота не создает второго тикета. Нет `ticket_addon1` для отслеживания, нет отдельного стопа, который можно было бы независимо менять для каждой позиции, и нет возможности задавать разное расстояние до стопа для разных уровней пирамиды. Архитектура единого стопа, которая опирается на изменение отдельных записей позиций, в таком режиме работать не сможет.
  • На счете с хеджированием ("ACCOUNT_MARGIN_MODE_RETAIL_HEDGING") каждая сделка создает отдельную позицию со своим тикетом, своим стоп-лоссом и своей ценой входа. Три позиции в одном направлении существуют одновременно как три независимые записи. Движок может отслеживать каждый тикет, изменять каждый стоп и независимо оценивать денежный вклад каждой позиции в пирамиду.

Что делает движок

CPyramidEngine проверяет режим счета в OnInit() с помощью функции IsHedgingAccount() из библиотеки PyramidUtils.mqh. Если счет не находится в режиме хеджирования, советник отказывается запускаться и выводит понятное диагностическое сообщение. Это правильное поведение – сбой без явной индикации на неттинговом счете привел бы к некорректной работе, которую потом было бы трудно диагностировать в тесте.

В демонстрационном советнике эта проверка выглядит так:

if(!IsHedgingAccount())
{
   Print("PyramidEA requires a hedging account. ",
         "Check ACCOUNT_MARGIN_MODE in account properties.");
   return INIT_FAILED;
}

Если вы тестируете систему в Тестере стратегий, убедитесь, что демо-счет открыт в режиме хеджирования. Большинство демо-счетов MetaTrader 5 у брокеров, поддерживающих хеджирование, проходят эту проверку автоматически.


Архитектура: три файла – три четкие зоны ответственности

CPyramidEngine разделен на три файла. Это разделение сделано намеренно – каждый файл решает свой класс задач, и разработчик, которому нужна только часть решения, может использовать ее отдельно.

У этого разбиения есть практическое следствие. Если в вашем советнике уже есть сигнал входа, который вас устраивает, подробно разбирать PyramidEA.mq5 не придется. Достаточно подключить PyramidEngine.mqh, выполнить шесть шагов интеграции из раздела 8, и ваш текущий сигнал будет управлять пирамидой.

Публичный интерфейс CPyramidEngine

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

Поток решений

Calling EA — OnTick()
      │
      ├── pyramid.Manage()         ← runs on EVERY TICK
      │        │
      │   [checks add-on triggers, moves stops, trails]
      │
      └── if !pyramid.IsActive() && is_new_bar
               │
         CheckForEntry()           ← EA's own signal logic (new bar only)
               │
         signal fires?
               │
         pyramid.OpenInitial(dir, price, sl, lots)

OnTradeTransaction()
      │
      └── pyramid.HandleTransaction(trans)  ← cascade-close on external exit

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

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


PyramidUtils.mqh – библиотека инфраструктуры

Сохраните этот файл по пути "MQL5\Include\Pyramid\PyramidUtils.mqh". Эта библиотека не зависит от движка Pyramid и может подключаться к любому советнику, которому нужны точная стоимость пипса в валюте счета, проверка стопов на уровне брокера или фильтрация по волатильности.

Проблема GetPipValue

Прежде чем переходить к коду, стоит коротко объяснить, зачем вообще нужна функция GetPipValue(). Распространенное упрощение в MQL5 – считать стоимость пипса как "пункт × множитель пипса × лоты". Это работает для EURUSD на стандартном счете в долларах США. Но такой подход дает сбой в трех типичных ситуациях.

Во-первых, когда валюта счета отличается от валюты котировки, коэффициент пересчета меняется вместе с обменным курсом. Сегодня один пипс по GBPJPY стоит не столько же долларов, сколько месяц назад. Во-вторых, для золота и индексных инструментов соотношение между пунктами и денежной стоимостью задается спецификацией контракта у брокера, а не простым множителем. В-третьих, у некоторых брокеров USDJPY котируется с тремя знаками после запятой, а EURUSD – с пятью, поэтому множитель пипса 10 работает не во всех случаях.

GetPipValue() решает эту задачу, опираясь на SYMBOL_TRADE_TICK_VALUE – денежную стоимость одного тика движения на стандартном лоте, которую предоставляет брокер. Если разделить размер пипса на размер тика и умножить результат на стоимость тика, получится корректная денежная стоимость одного пипса в валюте счета для любого инструмента у любого брокера.

//+------------------------------------------------------------------+
//|                      PyramidUtils.mqh                            |
//|  Reusable infrastructure helpers — no pyramid logic              |
//+------------------------------------------------------------------+
#ifndef PYRAMIDUTILS_MQH
#define PYRAMIDUTILS_MQH

//+------------------------------------------------------------------+
//| Monetary value of one pip for a given lot size.                  |
//| Correct for standard forex, JPY pairs, gold, indices.            |
//+------------------------------------------------------------------+
double GetPipValue(const string symbol, double lot_size)
  {
   double tick_value = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_VALUE);
   double tick_size  = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_SIZE);
   int    digits     = (int)SymbolInfoInteger(symbol, SYMBOL_DIGITS);
   double point      = SymbolInfoDouble(symbol, SYMBOL_POINT);
   double pip_size   = (digits == 3 || digits == 5) ? point * 10.0 : point;
   if(tick_size <= 0 || tick_value <= 0)
      return 0;
   return (pip_size / tick_size) * tick_value * lot_size;
  }

//+------------------------------------------------------------------+
//| Pip size in price units.                                         |
//+------------------------------------------------------------------+
double GetPipSize(const string symbol)
  {
   int    digits = (int)SymbolInfoInteger(symbol, SYMBOL_DIGITS);
   double point  = SymbolInfoDouble(symbol, SYMBOL_POINT);
   return (digits == 3 || digits == 5) ? point * 10.0 : point;
  }

//+----------------------------------------------------------------------+
//| True if the proposed stop satisfies the broker minimum stop distance.|
//+----------------------------------------------------------------------+
bool IsStopLevelValid(const string symbol, double sl_price,
                      ENUM_ORDER_TYPE order_type)
  {
   long   stop_level = SymbolInfoInteger(symbol, SYMBOL_TRADE_STOPS_LEVEL);
   double point      = SymbolInfoDouble(symbol, SYMBOL_POINT);
   double min_dist   = stop_level * point;
   double reference  = (order_type == ORDER_TYPE_BUY)
                       ? SymbolInfoDouble(symbol, SYMBOL_BID)
                       : SymbolInfoDouble(symbol, SYMBOL_ASK);
   if(MathAbs(reference - sl_price) < min_dist)
     {
      Print(StringFormat(
               "StopLevelCheck FAILED | SL:%.5f Ref:%.5f Gap:%.5f Min:%.5f",
               sl_price, reference, MathAbs(reference - sl_price), min_dist));
      return false;
     }
   return true;
  }

//+------------------------------------------------------------------+
//| True if the position is not in the broker freeze zone.           |
//+------------------------------------------------------------------+
bool IsModificationAllowed(const string symbol, ulong ticket)
  {
   long freeze_level = SymbolInfoInteger(symbol, SYMBOL_TRADE_FREEZE_LEVEL);
   if(freeze_level == 0)
      return true;
   if(!PositionSelectByTicket(ticket))
      return false;
   double point    = SymbolInfoDouble(symbol, SYMBOL_POINT);
   double min_dist = freeze_level * point;
   double sl       = PositionGetDouble(POSITION_SL);
   long   pos_type = PositionGetInteger(POSITION_TYPE);
   double price    = (pos_type == POSITION_TYPE_BUY)
                     ? SymbolInfoDouble(symbol, SYMBOL_BID)
                     : SymbolInfoDouble(symbol, SYMBOL_ASK);
   if(sl > 0 && MathAbs(price - sl) < min_dist)
     {
      Print(StringFormat(
               "FreezeCheck FROZEN | Ticket:%I64u SL:%.5f Price:%.5f",
               ticket, sl, price));
      return false;
     }
   return true;
  }

//+------------------------------------------------------------------+
//| True if the account uses hedging margin mode.                    |
//+------------------------------------------------------------------+
bool IsHedgingAccount()
  {
   return (AccountInfoInteger(ACCOUNT_MARGIN_MODE) ==
           ACCOUNT_MARGIN_MODE_RETAIL_HEDGING);
  }

//+------------------------------------------------------------------+
//| True if a retcode indicates a successful trade operation.        |
//+------------------------------------------------------------------+
bool IsRetcodeSuccess(uint retcode)
  {
   return (retcode == TRADE_RETCODE_DONE         ||
           retcode == TRADE_RETCODE_DONE_PARTIAL  ||
           retcode == TRADE_RETCODE_PLACED        ||
           retcode == TRADE_RETCODE_NO_CHANGES);
  }

//+------------------------------------------------------------------+
//| True if ATR is within an acceptable pip range.                   |
//| Filters entries during extremely quiet or volatile conditions.   |
//+------------------------------------------------------------------+
bool IsVolatilityAcceptable(const string symbol, ENUM_TIMEFRAMES tf,
                            int atr_period,
                            double min_atr_pips, double max_atr_pips)
  {
   int handle = iATR(symbol, tf, atr_period);
   if(handle == INVALID_HANDLE)
      return true;
   double buf[];
   ArraySetAsSeries(buf, true);
   bool ok = (CopyBuffer(handle, 0, 1, 1, buf) >= 1);
   IndicatorRelease(handle);
   if(!ok)
      return true;
   double pip_size = GetPipSize(symbol);
   if(pip_size <= 0)
      return true;
   double atr_pips = buf[0] / pip_size;
   if(atr_pips < min_atr_pips)
     { Print(StringFormat("VolFilter: too quiet | ATR %.1f pips", atr_pips)); return false; }
   if(atr_pips > max_atr_pips)
     { Print(StringFormat("VolFilter: too extreme | ATR %.1f pips", atr_pips)); return false; }
   return true;
  }

#endif // PYRAMIDUTILS_MQH
//+------------------------------------------------------------------+

Каждая функция в PyramidUtils.mqh имеет свою узкую зону ответственности. IsStopLevelValid() проверяет SYMBOL_TRADE_STOPS_LEVEL перед любой модификацией. IsModificationAllowed() проверяет SYMBOL_TRADE_FREEZE_LEVEL, чтобы не получать отказ брокера из-за нарушения уровня заморозки на волатильных инструментах, таких как золото. IsRetcodeSuccess() предотвращает тихие сбои торговых операций, считая успешными только известные коды успеха. Ни одна из этих функций ничего не знает о пирамидировании – это инфраструктура общего назначения.


PyramidEngine.mqh – переиспользуемый класс движка

Сохраните этот файл по пути "MQL5\Include\Pyramid\PyramidEngine.mqh". Это основной файл в рамках данной статьи. CPyramidEngine – это самодостаточный менеджер пирамиды, который можно подключить к любому советнику и использовать без изменений. Каждое проектное решение ниже устраняет конкретный сценарий отказа, выявленный в разделе 2.

Почему ResultDeal(), а не ResultOrder()

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

Правильный подход – использовать ResultDeal() для получения тикета сделки, а затем извлечь идентификатор позиции из записи сделки через HistoryDealGetInteger(deal, DEAL_POSITION_ID). Именно такой идентификатор позиции ожидает PositionSelectByTicket. Движок сохраняет этот идентификатор через вспомогательную функцию GetPositionTicketFromLastDeal(), которая вызывается после каждого успешного исполнения ордера.

ulong GetPositionTicketFromLastDeal()
{
   ulong deal = m_trade.ResultDeal();
   if(deal == 0) return 0;
   if(!HistoryDealSelect(deal)) return 0;
   return (ulong)HistoryDealGetInteger(deal, DEAL_POSITION_ID);
}

Почему единый стоп обновляется только после подтверждения

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

// WRONG — state updated before confirmation
m_state.unified_stop = new_stop;   // state says stop is moved
ModifyStop(ticket_1, new_stop);    // what if this fails?
ModifyStop(ticket_2, new_stop);    // and this?

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

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

Полный код движка

//+------------------------------------------------------------------+
//|                     PyramidEngine.mqh                            |
//|  Self-contained pyramid engine — plug into any trend EA          |
//+------------------------------------------------------------------+
#ifndef PYRAMIDENGINE_MQH
#define PYRAMIDENGINE_MQH

#include <Trade\Trade.mqh>
#include <PyramidEA/PyramidUtils.mqh>

//+------------------------------------------------------------------+
//|                              CPyramid                            |
//+------------------------------------------------------------------+
class CPyramidEngine
  {
private:
   struct SPyramidState
     {
      ulong          ticket_initial;
      ulong          ticket_addon1;
      ulong          ticket_addon2;
      double         entry_price;
      double         unified_stop;
      long           direction;
      bool           addon1_open;
      bool           addon2_open;
      bool           active;
     };

   SPyramidState     m_state;
   CTrade            m_trade;
   int               m_magic;
   string            m_symbol;

   double            m_lot_initial;
   double            m_lot_addon1;
   double            m_lot_addon2;
   double            m_addon1_trigger_pips;
   double            m_addon2_trigger_pips;
   double            m_stop_after_addon1;
   double            m_stop_after_addon2;
   bool              m_trail_after_full;
   double            m_trail_pips;
   double            m_trail_step_pips;

//+--------------------------------------------------------------------------+
//| Retrieve reliable position ticket via deal record.                       |
//| ResultOrder() is NOT always equal to position ticket on hedging accounts.|
//+--------------------------------------------------------------------------+
   ulong             GetPositionTicketFromLastDeal()
     {
      ulong deal = m_trade.ResultDeal();
      if(deal == 0)
         return 0;
      if(!HistoryDealSelect(deal))
         return 0;
      return (ulong)HistoryDealGetInteger(deal, DEAL_POSITION_ID);
     }

   void              ResetState()
     {
      m_state.ticket_initial = 0;
      m_state.ticket_addon1 = 0;
      m_state.ticket_addon2  = 0;
      m_state.entry_price   = 0;
      m_state.unified_stop   = 0;
      m_state.direction     = -1;
      m_state.addon1_open    = false;
      m_state.addon2_open = false;
      m_state.active         = false;
     }

   bool              OpenAddon(int num, double lots)
     {
      double sl = m_state.unified_stop;
      if(m_state.direction == POSITION_TYPE_BUY)
        {
         double ask = SymbolInfoDouble(m_symbol, SYMBOL_ASK);
         if(sl >= ask)
           { Print("PyramidEngine: Add-on ",num," skipped — stop above ask."); return false; }
         if(!IsStopLevelValid(m_symbol, sl, ORDER_TYPE_BUY))
            return false;
         if(!m_trade.Buy(lots, m_symbol, ask, sl, 0,
                         "Pyramid Add-on " + IntegerToString(num)))
           {
            Print("PyramidEngine: Add-on ",num," buy failed | Retcode:",
                  m_trade.ResultRetcode());
            return false;
           }
        }
      else
        {
         double bid = SymbolInfoDouble(m_symbol, SYMBOL_BID);
         if(sl > 0 && sl <= bid)
           { Print("PyramidEngine: Add-on ",num," skipped — stop below bid."); return false; }
         if(!IsStopLevelValid(m_symbol, sl, ORDER_TYPE_SELL))
            return false;
         if(!m_trade.Sell(lots, m_symbol, bid, sl, 0,
                          "Pyramid Add-on " + IntegerToString(num)))
           {
            Print("PyramidEngine: Add-on ",num," sell failed | Retcode:",
                  m_trade.ResultRetcode());
            return false;
           }
        }
      if(!IsRetcodeSuccess(m_trade.ResultRetcode()))
        {
         Print("PyramidEngine: Add-on ",num," unexpected retcode:",
               m_trade.ResultRetcode());
         return false;
        }

//+----------------------------------------------------------------------+
//| Use deal-based ticket capture, not ResultOrder()                     |
//+----------------------------------------------------------------------+
      ulong ticket = GetPositionTicketFromLastDeal();
      if(ticket == 0)
        { Print("PyramidEngine: Add-on ",num," — could not resolve position ticket."); return false; }
      if(num == 1)
         m_state.ticket_addon1 = ticket;
      if(num == 2)
         m_state.ticket_addon2 = ticket;
      Print(StringFormat("PyramidEngine: Add-on %d opened | Ticket:%I64u Lots:%.2f SL:%.5f",
                         num, ticket, lots, sl));
      return true;
     }

   double            CalculateUnifiedStop(double pips_dist)
     {
      double dist = pips_dist * GetPipSize(m_symbol);
      if(m_state.direction == POSITION_TYPE_BUY)
         return NormalizeDouble(
                   SymbolInfoDouble(m_symbol, SYMBOL_BID) - dist, _Digits);
      else
         return NormalizeDouble(
                   SymbolInfoDouble(m_symbol, SYMBOL_ASK) + dist, _Digits);
     }

//+--------------------------------------------------------------------+
//| Modify a single position's stop. Returns true on confirmed success.|
//+--------------------------------------------------------------------+
   bool              ModifyStop(ulong ticket, double new_sl)
     {
      if(ticket == 0 || !PositionSelectByTicket(ticket))
         return false;
      if(!IsModificationAllowed(m_symbol, ticket))
         return false;
      double cur_sl = PositionGetDouble(POSITION_SL);
      if(m_state.direction == POSITION_TYPE_BUY  && new_sl <= cur_sl)
         return true;
      if(m_state.direction == POSITION_TYPE_SELL &&
         cur_sl > 0 && new_sl >= cur_sl)
         return true;
      ENUM_ORDER_TYPE ot = (m_state.direction == POSITION_TYPE_BUY)
                           ? ORDER_TYPE_BUY : ORDER_TYPE_SELL;
      if(!IsStopLevelValid(m_symbol, new_sl, ot))
         return false;
      if(!m_trade.PositionModify(ticket, new_sl, 0))
        {
         Print("PyramidEngine: ModifyStop failed | Ticket:", ticket,
               " Retcode:", m_trade.ResultRetcode());
         return false;
        }
      return IsRetcodeSuccess(m_trade.ResultRetcode());
     }

//+--------------------------------------------------------------------------+
//| Move unified stop only after ALL modifications confirmed.                |
//| State is not updated if any modification fails.                          |
//+--------------------------------------------------------------------------+
   void              MoveUnifiedStop(double new_stop)
     {
      if(m_state.direction == POSITION_TYPE_BUY &&
         new_stop <= m_state.unified_stop)
         return;
      if(m_state.direction == POSITION_TYPE_SELL &&
         m_state.unified_stop > 0 &&
         new_stop >= m_state.unified_stop)
         return;

//+--------------------------------------------------------------------+
//| Attempt all modifications first                                    |
//+--------------------------------------------------------------------+
      bool ok_init = ModifyStop(m_state.ticket_initial, new_stop);
      bool ok_a1   = !m_state.addon1_open || ModifyStop(m_state.ticket_addon1, new_stop);
      bool ok_a2   = !m_state.addon2_open || ModifyStop(m_state.ticket_addon2, new_stop);

//+--------------------------------------------------------------------+
//| Only update state if ALL modifications succeeded                   |
//+--------------------------------------------------------------------+
      if(ok_init && ok_a1 && ok_a2)
        {
         m_state.unified_stop = new_stop;
         Print("PyramidEngine: Unified stop moved to ",
               DoubleToString(new_stop, _Digits));
        }
      else
         Print("PyramidEngine: WARNING — partial stop modification. Retrying next tick.");
     }

   void              TrailUnifiedStop()
     {
      double pip_size = GetPipSize(m_symbol);
      double trail    = m_trail_pips      * pip_size;
      double step     = m_trail_step_pips * pip_size;
      if(m_state.direction == POSITION_TYPE_BUY)
        {
         double new_sl = NormalizeDouble(
                            SymbolInfoDouble(m_symbol, SYMBOL_BID) - trail, _Digits);
         if(new_sl > m_state.unified_stop + step)
            MoveUnifiedStop(new_sl);
        }
      else
        {
         double new_sl = NormalizeDouble(
                            SymbolInfoDouble(m_symbol, SYMBOL_ASK) + trail, _Digits);
         if(m_state.unified_stop == 0 ||
            new_sl < m_state.unified_stop - step)
            MoveUnifiedStop(new_sl);
        }
     }

public:
                     CPyramidEngine() { m_symbol = _Symbol; ResetState(); }

   bool              Init(int magic, int slippage,
                          double lot_initial,  double lot_addon1,  double lot_addon2,
                          double addon1_trig,  double addon2_trig,
                          double stop_addon1,  double stop_addon2,
                          bool   trail,        double trail_pips,  double trail_step)
     {
      if(lot_addon1 >= lot_initial)
        { Print("PyramidEngine: Lot_Addon1 must be < Lot_Initial."); return false; }
      if(lot_addon2 >= lot_addon1)
        { Print("PyramidEngine: Lot_Addon2 must be < Lot_Addon1."); return false; }
      if(addon1_trig >= addon2_trig)
        { Print("PyramidEngine: Addon1 trigger must be < Addon2 trigger."); return false; }
      m_magic               = magic;
      m_lot_initial         = lot_initial;
      m_lot_addon1          = lot_addon1;
      m_lot_addon2          = lot_addon2;
      m_addon1_trigger_pips = addon1_trig;
      m_addon2_trigger_pips = addon2_trig;
      m_stop_after_addon1   = stop_addon1;
      m_stop_after_addon2   = stop_addon2;
      m_trail_after_full    = trail;
      m_trail_pips          = trail_pips;
      m_trail_step_pips     = trail_step;
      m_trade.SetExpertMagicNumber(magic);
      m_trade.SetDeviationInPoints(slippage);
      ResetState();
      Print("PyramidEngine initialised | Magic:",magic,
            " | Lots:",lot_initial,"/",lot_addon1,"/",lot_addon2);
      return true;
     }

   bool              OpenInitial(long direction, double price, double sl,
                                 double lots, string comment = "Pyramid Entry")
     {
      if(m_state.active)
        { Print("PyramidEngine: OpenInitial called while pyramid active."); return false; }
      if(!IsStopLevelValid(m_symbol, sl,
                           (direction == POSITION_TYPE_BUY ? ORDER_TYPE_BUY : ORDER_TYPE_SELL)))
         return false;
      bool ok = (direction == POSITION_TYPE_BUY)
                ? m_trade.Buy(lots,  m_symbol, price, sl, 0, comment)
                : m_trade.Sell(lots, m_symbol, price, sl, 0, comment);
      if(!ok || !IsRetcodeSuccess(m_trade.ResultRetcode()))
        {
         Print("PyramidEngine: Initial order failed | Retcode:",
               m_trade.ResultRetcode(), " Error:", GetLastError());
         return false;
        }
//+--------------------------------------------------------------------+
//| Reliable ticket via deal, not ResultOrder()                        |
//+--------------------------------------------------------------------+
      ulong ticket = GetPositionTicketFromLastDeal();
      if(ticket == 0)
        { Print("PyramidEngine: Could not resolve initial position ticket."); return false; }
      m_state.ticket_initial = ticket;
      m_state.entry_price    = price;
      m_state.direction      = direction;
      m_state.unified_stop   = sl;
      m_state.addon1_open    = false;
      m_state.addon2_open    = false;
      m_state.active         = true;
      Print(StringFormat(
               "PyramidEngine: STARTED | %s | Entry:%.5f | SL:%.5f | Lots:%.2f",
               (direction == POSITION_TYPE_BUY ? "BUY" : "SELL"),
               price, sl, lots));
      return true;
     }

   void              Manage()
     {
      if(!m_state.active)
         return;
      if(!PositionSelectByTicket(m_state.ticket_initial))
        { Print("PyramidEngine: Initial position gone. Resetting."); ResetState(); return; }
      double pip_size    = GetPipSize(m_symbol);
      double current     = (m_state.direction == POSITION_TYPE_BUY)
                           ? SymbolInfoDouble(m_symbol, SYMBOL_BID)
                           : SymbolInfoDouble(m_symbol, SYMBOL_ASK);
      double profit_pips = (m_state.direction == POSITION_TYPE_BUY)
                           ? (current - m_state.entry_price) / pip_size
                           : (m_state.entry_price - current) / pip_size;

      if(!m_state.addon1_open && profit_pips >= m_addon1_trigger_pips)
        {
         if(OpenAddon(1, m_lot_addon1))
           {
            m_state.addon1_open = true;
            MoveUnifiedStop(CalculateUnifiedStop(m_stop_after_addon1));
           }
         return; // Gap-bar protection: re-evaluate Add-on 2 on next call
        }
      if(m_state.addon1_open && !m_state.addon2_open &&
         profit_pips >= m_addon2_trigger_pips)
        {
         if(OpenAddon(2, m_lot_addon2))
           {
            m_state.addon2_open = true;
            MoveUnifiedStop(CalculateUnifiedStop(m_stop_after_addon2));
           }
         return;
        }
      if(m_state.addon1_open && m_state.addon2_open && m_trail_after_full)
         TrailUnifiedStop();
     }

   void              HandleTransaction(const MqlTradeTransaction& trans)
     {
      if(!m_state.active)
         return;
      if(trans.type != TRADE_TRANSACTION_DEAL_ADD)
         return;
      if(!HistoryDealSelect(trans.deal))
         return;
      if(HistoryDealGetInteger(trans.deal, DEAL_MAGIC)  != m_magic)
         return;
      if(HistoryDealGetString(trans.deal,  DEAL_SYMBOL) != m_symbol)
         return;
      if(HistoryDealGetInteger(trans.deal, DEAL_ENTRY)  != DEAL_ENTRY_OUT)
         return;
      ulong  closed_id = HistoryDealGetInteger(trans.deal, DEAL_POSITION_ID);
      double profit    = HistoryDealGetDouble(trans.deal,  DEAL_PROFIT);
      Print(StringFormat("PyramidEngine: Position %I64u closed | P&L: %.2f",
                         closed_id, profit));
      if(closed_id == m_state.ticket_initial)
        {
         Print("PyramidEngine: Initial position closed. Cascade-closing add-ons.");
         if(m_state.addon1_open && m_state.ticket_addon1 > 0)
            if(PositionSelectByTicket(m_state.ticket_addon1))
               m_trade.PositionClose(m_state.ticket_addon1);
         if(m_state.addon2_open && m_state.ticket_addon2 > 0)
            if(PositionSelectByTicket(m_state.ticket_addon2))
               m_trade.PositionClose(m_state.ticket_addon2);
         ResetState();
        }
     }

   void              RecoverState()
     {
      ulong found_init = 0, found_a1 = 0, found_a2 = 0;
      int   count      = 0;
      for(int i = PositionsTotal() - 1; i >= 0; i--)
        {
         ulong ticket = PositionGetTicket(i);
         if(!PositionSelectByTicket(ticket))
            continue;
         if(PositionGetString(POSITION_SYMBOL)  != m_symbol)
            continue;
         if(PositionGetInteger(POSITION_MAGIC)  != m_magic)
            continue;
         string comment = PositionGetString(POSITION_COMMENT);
         double volume  = PositionGetDouble(POSITION_VOLUME);
         count++;
         if(StringFind(comment, "Pyramid Entry") >= 0 ||
            MathAbs(volume - m_lot_initial) < 0.001)
           {
            found_init           = ticket;
            m_state.entry_price  = PositionGetDouble(POSITION_PRICE_OPEN);
            m_state.unified_stop = PositionGetDouble(POSITION_SL);
            m_state.direction    = PositionGetInteger(POSITION_TYPE);
           }
         else
            if(StringFind(comment, "Add-on 1") >= 0 ||
               MathAbs(volume - m_lot_addon1) < 0.001)
              { found_a1 = ticket; m_state.addon1_open = true; }
            else
               if(StringFind(comment, "Add-on 2") >= 0 ||
                  MathAbs(volume - m_lot_addon2) < 0.001)
                 { found_a2 = ticket; m_state.addon2_open = true; }
        }
      if(count > 0 && found_init > 0)
        {
         m_state.ticket_initial = found_init;
         m_state.ticket_addon1  = found_a1;
         m_state.ticket_addon2  = found_a2;
         m_state.active         = true;
         Print(StringFormat(
                  "PyramidEngine: Recovered %d position(s) | Dir:%s | SL:%.5f | A1:%s | A2:%s",
                  count,
                  (m_state.direction == POSITION_TYPE_BUY ? "BUY" : "SELL"),
                  m_state.unified_stop,
                  (m_state.addon1_open ? "open" : "pending"),
                  (m_state.addon2_open ? "open" : "pending")));
        }
     }

   void              Reset()          { ResetState(); }
   bool              IsActive()       { return m_state.active; }
   double            GetUnifiedStop() { return m_state.unified_stop; }
  };

#endif // PYRAMIDENGINE_MQH
//+------------------------------------------------------------------+



PyramidEA.mq5 – демонстрационный советник

Сохраните этот файл по пути "MQL5\Experts\PyramidEA.mq5". Этот советник показывает, как именно интегрировать CPyramidEngine в существующую стратегию. Изменения, относящиеся именно к движку, ограничиваются секцией  include, объявлением, проверкой через Init() и IsActive(), пробросом вызовов OpenInitial(), Manage() и HandleTransaction(), а также RecoverState(). Вот и все, что требуется для интеграции.

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

Входные параметры и объявления

Входные параметры организованы в четыре группы с помощью директивы input group. Группа Entry задает периоды EMA и множитель ATR, который используется для расчета начального расстояния до стопа. Группа Volatility Filter включает предторговый фильтр, который блокирует входы, когда ATR выходит за пределы заданного диапазона в пипсах, не позволяя советнику открывать позиции при аномально низкой или аномально высокой волатильности. Группа Pyramid Engine передает все параметры пирамиды напрямую в движок при инициализации: размеры лотов, триггеры добавления в пипсах, отступы единого стопа после каждого добавления и настройки трейлинг-стопа. Группа General задает магическое число для идентификации позиций и допустимое проскальзывание в пунктах.

В глобальном разделе объявляются экземпляр CPyramidEngine, три хэндла индикаторов, три буфера значений для EMA и ATR, а также переменная datetime для отслеживания последнего обработанного бара.

//+------------------------------------------------------------------+
//|                       PyramidEA.mq5                              |
//|  EMA-crossover demonstration of CPyramidEngine                   |
//+------------------------------------------------------------------+
#property copyright "Tola Moses Hector"
#property version   "1.00"
#property description "Demonstrates CPyramidEngine integration."
#property description "Replace CheckForEntry() to use in your own EA."
#property description "Requires hedging account mode."

#include <Trade\Trade.mqh>
#include <PyramidEngine.mqh>           // ← the only engine-specific include

input group   "=== Entry ==="
input int     Fast_MA_Period      = 20;
input int     Slow_MA_Period      = 50;
input int     ATR_Period          = 14;
input double  Initial_SL_ATR     = 2.0;

input group   "=== Volatility Filter ==="
input bool    Use_Vol_Filter      = true;
input double  Min_ATR_Pips        = 5.0;
input double  Max_ATR_Pips        = 80.0;

input group   "=== Pyramid Engine ==="
input double  Lot_Initial         = 0.30;
input double  Lot_Addon1          = 0.20;
input double  Lot_Addon2          = 0.10;
input double  Addon1_Trigger_Pips = 50.0;
input double  Addon2_Trigger_Pips = 100.0;
input double  Stop_After_Addon1   = 25.0;
input double  Stop_After_Addon2   = 10.0;
input bool    Trail_After_Full    = true;
input double  Trail_Pips          = 15.0;
input double  Trail_Step_Pips     = 5.0;

input group   "=== General ==="
input int     Magic_Number        = 555001;
input int     Slippage            = 10;

CPyramidEngine pyramid;            // ← engine declaration

int      fast_handle, slow_handle, atr_handle;
double   fast_buf[], slow_buf[], atr_buf[];
datetime last_bar = 0;

OnInit – проверка и инициализация движка

В OnInit() сначала проверяется, поддерживает ли счет хеджирование: вызывается IsHedgingAccount() из PyramidUtils.mqh, и если счет не в режиме хеджирования, функция сразу возвращает INIT_FAILED. Это не дает движку молча запуститься в несовместимой среде. Затем движок инициализируется через Init, которая внутри проверяет, что размеры лотов строго убывают, а первый триггер добавления меньше второго; если хотя бы одно условие не выполняется, функция возвращает false и блокирует запуск. Создаются три хэндла индикаторов – для быстрой EMA, медленной EMA и ATR, – после чего все три проверяются на INVALID_HANDLE. В конце вызывается RecoverState() – функция просматривает все открытые позиции с этим магическим числом и восстанавливает состояние пирамиды, чтобы советник после перезапуска продолжил сопровождение уже существующей структуры. OnDeinit() освобождает все хэндлы индикаторов, чтобы освободить память.

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit()
  {
   if(!IsHedgingAccount())
     {
      Print("PyramidEA requires a hedging account. ",
            "Check ACCOUNT_MARGIN_MODE in account properties.");
      return INIT_FAILED;
     }

   if(!pyramid.Init(Magic_Number, Slippage,
                    Lot_Initial, Lot_Addon1, Lot_Addon2,
                    Addon1_Trigger_Pips, Addon2_Trigger_Pips,
                    Stop_After_Addon1, Stop_After_Addon2,
                    Trail_After_Full, Trail_Pips, Trail_Step_Pips))
      return INIT_FAILED;

   fast_handle = iMA(_Symbol, PERIOD_H1, Fast_MA_Period, 0, MODE_EMA, PRICE_CLOSE);
   slow_handle = iMA(_Symbol, PERIOD_H1, Slow_MA_Period, 0, MODE_EMA, PRICE_CLOSE);
   atr_handle  = iATR(_Symbol, PERIOD_H1, ATR_Period);

   if(fast_handle == INVALID_HANDLE ||
      slow_handle == INVALID_HANDLE ||
      atr_handle  == INVALID_HANDLE)
     { Print("Indicator handle creation failed."); return INIT_FAILED; }

   ArraySetAsSeries(fast_buf, true);
   ArraySetAsSeries(slow_buf, true);
   ArraySetAsSeries(atr_buf,  true);

   pyramid.RecoverState();     // ← reconstruct from any open positions
   return INIT_SUCCEEDED;
  }

OnTick – разделение входа и сопровождения

OnTick() разделяет две разные задачи. Буферы индикаторов обновляются только на новых барах – это определяется сравнением времени открытия текущего бара с last_bar, – потому что значения EMA и ATR имеют значимость только на завершенных барах. Manage() безусловно выполняется на каждом тике, потому что сопровождение позиции – срабатывание добавлений, переносы стопа и трейлинг – должно реагировать на цену в реальном времени, а не ждать закрытия бара. Условие входа объединяет два независимых фильтра: IsActive() не дает открыть вторую пирамиду, пока активна первая, а is_new_bar не позволяет повторно оценивать сигнал на каждом тике внутри одного и того же бара. OnTradeTransaction() передает каждое торговое событие в HandleTransaction(), которая отслеживает внешнее закрытие позиций и каскадно закрывает все оставшиеся позиции в структуре.

//+------------------------------------------------------------------+
//| Expert tick function                                             |
//+------------------------------------------------------------------+
void OnTick()
  {
   datetime current_bar = iTime(_Symbol, PERIOD_H1, 0);
   bool     is_new_bar  = (current_bar != last_bar);
   if(is_new_bar)
     {
      last_bar = current_bar;
      if(CopyBuffer(fast_handle, 0, 0, 5, fast_buf) < 1)
         return;
      if(CopyBuffer(slow_handle, 0, 0, 5, slow_buf) < 1)
         return;
      if(CopyBuffer(atr_handle,  0, 0, 5, atr_buf)  < 1)
         return;
     }

   pyramid.Manage();                          // ← runs on every tick

   if(!pyramid.IsActive() && is_new_bar)      // ← entry gated to new bar
      CheckForEntry();
  }

Сигнал входа – полностью отделен от движка

CheckForEntry() показывает, что именно движок ожидает от вызывающего советника: направление, цену, стоп-лосс и размер лота. Замените логику пересечения EMA любым сигналом, который выдает эти четыре значения, и этого будет достаточно для интеграции. Движку неважно, как вычислены эти значения – он начинает работать только после вызова OpenInitial().

//+------------------------------------------------------------------+
//| Check for entry                                                  |
//+------------------------------------------------------------------+
void CheckForEntry()
  {
   if(Use_Vol_Filter &&
      !IsVolatilityAcceptable(_Symbol, PERIOD_H1, ATR_Period,
                              Min_ATR_Pips, Max_ATR_Pips))
      return;

   double fast_prev = fast_buf[2], fast_curr = fast_buf[1];
   double slow_prev = slow_buf[2], slow_curr = slow_buf[1];
   double sl_dist   = atr_buf[1] * Initial_SL_ATR;

//--- Bullish crossover
   if(fast_prev < slow_prev && fast_curr > slow_curr)
     {
      double ask = SymbolInfoDouble(_Symbol, SYMBOL_ASK);
      double sl  = NormalizeDouble(ask - sl_dist, _Digits);
      pyramid.OpenInitial(POSITION_TYPE_BUY, ask, sl,
                          Lot_Initial, "Pyramid Entry");
     }
//--- Bearish crossover
   else
      if(fast_prev > slow_prev && fast_curr < slow_curr)
        {
         double bid = SymbolInfoDouble(_Symbol, SYMBOL_BID);
         double sl  = NormalizeDouble(bid + sl_dist, _Digits);
         pyramid.OpenInitial(POSITION_TYPE_SELL, bid, sl,
                             Lot_Initial, "Pyramid Entry");
        }
  }


Интеграция движка в ваш советник

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

Шаг 1 – Добавьте #include

Добавьте директиву #include для PyramidEngine.mqh в начало файла советника.

#include <PyramidEngine.mqh>

Шаг 2 – Объявите движок

Объявите один экземпляр CPyramidEngine в глобальной области видимости.

CPyramidEngine pyramid;

Шаг 3 – Инициализация в OnInit()

if(!pyramid.Init(Magic_Number, Slippage,
                 Lot_Initial, Lot_Addon1, Lot_Addon2,
                 Addon1_Trigger_Pips, Addon2_Trigger_Pips,
                 Stop_After_Addon1, Stop_After_Addon2,
                 Trail_After_Full, Trail_Pips, Trail_Step_Pips))
   return INIT_FAILED;
pyramid.RecoverState();

Сразу после этого вызовите "RecoverState()", чтобы обработать открытые позиции, оставшиеся от предыдущей сессии.

Шаг 4 – Manage() на каждом тике, вход только на новом баре

Вызовите pyramid.Manage() безусловно в начале OnTick(). Ограничьте вызов функции входа условием нового бара.

pyramid.Manage();
if(!pyramid.IsActive() && is_new_bar)
   CheckForEntry();   // your existing entry function

Шаг 5 – Замените вызов ордера в функции входа

pyramid.OpenInitial(direction, price, sl, lots, "Pyramid Entry");

Шаг 6 – Передайте торговые события

Добавьте или обновите OnTradeTransaction(), чтобы передавать все события в HandleTransaction().

void OnTradeTransaction(const MqlTradeTransaction& trans,
                        const MqlTradeRequest& req,
                        const MqlTradeResult& res)
{
   pyramid.HandleTransaction(trans);
}

 Всего шесть изменений. И ни одной правки в самом движке.


Разбор живого примера

Чтобы показать механику на конкретном примере, разберем полную пирамиду – от открытия до закрытия. EMA 20 пересекает EMA 50 снизу вверх. ATR показывает 60 пипсов. Фильтр волатильности допускает вход.

Бар 1 – начальный вход:
  • Цена: 1.0800
  • Размер лота: 0.30
  • Начальный SL: 1.0800 - (2.0 × 0.0060) = 1.0680 (120 пипсов от цены входа)
  • Начальный риск при 0.30 лота и 120 пипсах ≈ 360 долларов США
  • Пирамида активна. Ожидаем срабатывания первого добавления на уровне +50 пипсов.
Бар 7 – цена достигает 1.0850 (+50 пипсов):
  • Срабатывает первое добавление.
  • Открывается BUY объемом 0.20 лота по цене 1.0850.
  • Единый стоп перемещается на 1.0850 - 25 пипсов = 1.0825.
  • Позиция 1 (0.30 лота): открыта на 1.0800, стоп теперь на 1.0825. Прибыль 25 пипсов – убыток уже невозможен.
  • Позиция 2 (0.20 лота): открыта на 1.0850, стоп на 1.0825. Риск = 25 пипсов × 0.20 лота = 50 долларов США.
  • Общий риск по счету: 50 долларов США (против 360 долларов США на входе).
Бар 14 – второе добавление на 1.0900 (+100 пипсов):
  • Manage() фиксирует срабатывание триггера. Выполняется OpenAddon(2, 0.10).
  • Единый стоп перемещается на 1.0890 (на 10 пипсов ниже текущего Bid).
  • P1: зафиксировано 90 пипсов = 270 долларов США. P2: зафиксировано 40 пипсов = 80 долларов США. P3: под риском 10 пипсов = 10 долларов США.
  • В худшем случае, если все стопы сработают одновременно, результат составит +340 долларов США чистой прибыли. Структура позиции становится гарантированно прибыльной.
  • Начинается трейлинг. Трейлинг с отступом 15 пипсов и шагом 5 пипсов на каждом тике подтягивается вслед за ростом цены.

Бар 22 – цена достигает 1.0970 и разворачивается:
  • К этому моменту трейлинг переместил единый стоп на 1.0955 (на 15 пипсов ниже максимума 1.0970).
  • Рынок разворачивается. Все три позиции закрываются по стопу на уровне 1.0955.
  • P1: 155 пипсов × 0.30 = 465 долларов США | P2: 105 пипсов × 0.20 = 210 долларов США | P3: 55 пипсов × 0.10 = 55 долларов США
  • Общая прибыль: 730 долларов США при первоначальном риске 360 долларов США – прибыль 2,03R.

Ни в один момент пирамида не превысила первоначальный риск в 360 долларов США. За семь баров риск снизился до 50 долларов США. К 14-му бару позиция становится прибыльной при любом из оставшихся сценариев. Вот как на практике выглядят математические принципы из раздела 2.


Пять правил, которые обеспечивает движок

Правило 1. Размеры лотов должны строго убывать. Движок проверяет условие Lot_Initial > Lot_Addon1 > Lot_Addon2 в Init() и отказывается запускаться, если оно не выполняется. Каждое следующее добавление должно вносить меньший денежный риск, чем предыдущее. Именно это условие делает свойство снижения риска математически доказуемым.

Правило 2. Единый стоп может двигаться только в сторону прибыли. В MoveUnifiedStop() и ModifyStop() предусмотрены проверки направления. Для длинных позиций стоп может смещаться только вверх, а для коротких – только вниз. Ни при каких обстоятельствах стоп не может быть сдвинут в противоположную сторону.

Правило 3. Добавления открываются последовательно с защитой от ценового гэпа внутри бара. Оператор return после обработки первого добавления не дает второму добавлению проверяться в том же вызове Manage(). Даже если цена уже прошла оба триггерных уровня, второе добавление будет ждать следующего вызова Manage() только после подтверждения первого добавления и завершения переноса стопа.

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

Правило 5. Движок никогда сам не инициирует сделки. Сигнал входа, момент входа и начальный размер позиции полностью остаются за вызывающим советником. Роль движка начинается после вызова OpenInitial() и заканчивается, когда все позиции закрыты. Именно эта граница делает движок пригодным для повторного использования в любой стратегии без доработок.


Рекомендации по тестированию и развертыванию

Рекомендуемые параметры тестирования

EURUSD

Тест для EURUSD проводится на таймфрейме H1 с моделированием каждого тика на основе реальных тиков и начальным депозитом 10 000 долларов США. Периоды EMA составляют 20 и 50, период ATR – 14, а размеры лотов для начального входа и двух добавлений – 0,30, 0,20 и 0,10 соответственно. Первое добавление срабатывает на +50 пипсах, второе – на +100 пипсах. После первого добавления единый стоп переносится на 25 пипсов ниже цены, а после второго – на 10 пипсов ниже цены. Трейлинг задается отступом 15 пипсов и шагом 5 пипсов, а фильтр волатильности остается активным в диапазоне ATR от 5 до 80 пипсов.

Золото

Тест для золота проводится на том же таймфрейме H1 и с тем же моделированием каждого тика, при начальном депозите 10 000 долларов США. Периоды EMA составляют 20 и 50, период ATR – 14, а размеры лотов – 0,30, 0,20 и 0,10. Из-за более высокой стоимости пипса у золота триггеры добавлений увеличиваются до +500 и +1 000 пипсов. После первого добавления единый стоп переносится на 250 пипсов ниже цены, а после второго – на 1 000 пипсов ниже цены. Трейлинг задается отступом 1 000 пипсов и шагом 100 пипсов, а фильтр волатильности остается активным в диапазоне ATR от 5 до 80 пипсов.

Что измерять

При оценке результатов тестера наиболее показательны три метрики для советника с пирамидированием.

  • Средняя прибыльная сделка против средней убыточной. В пирамидах, дошедших до второго добавления, одновременно открыты три позиции. Убыток всегда ограничен риском начальной позиции, поскольку стоп срабатывает до достижения любого из триггеров. Средняя прибыльная сделка должна быть существенно больше средней убыточной. Если это не так, триггеры добавления расположены слишком близко, и структура не использует тренды в полной мере.
  • Распределение продолжительности сделок. Хороший результат дает бимодальную картину: много коротких сделок, закрытых по первоначальному стопу, и меньшее число, но заметно более длинных прибыльных сделок, которые с избытком это компенсируют. Равномерное распределение указывает на то, что стратегия ведет себя как обычная трендовая система, а не извлекает кумулятивный эффект пирамиды.
  • Профит-фактор по периодам. Если профит-фактор падает ниже 1,2 в любом тестовом окне, фильтр волатильности недостаточен для текущих условий. Ужесточение max_ATR_pips или повышение addon1_trigger_pips сократит число входов в периоды бокового движения с низким импульсом.

Демонстрация на золоте

Тестовые входные параметры

Test input parameters.

Демо

Demonstration on gold.

График

graph results

Результаты тестов

results

results

Контрольный список перед запуском в реальной торговле

  • Режим счета. Перед подключением советника убедитесь, что ACCOUNT_MARGIN_MODE = 2 (RETAIL_HEDGING). Это проверяется через IsHedgingAccount(); на неттинговых счетах движок не запускается.
  • Уровни стопа и заморозки брокера. Эти ограничения автоматически учитываются в IsStopLevelValid() и IsModificationAllowed(). Частые сообщения в логе об уровне стопа указывают на нестандартные требования брокера – проблему обычно решает увеличение расстояния до стопов.
  • Нестандартные инструменты. GetPipValue() вычисляет точный денежный риск для любого инструмента. Перед запуском движка на золоте или CFD проверьте записи в журнале при запуске.
  • Расчет размера лота. Размеры лотов не подстраиваются автоматически под баланс счета. Выполняйте калибровку перед запуском в реальной торговле и после любого существенного изменения капитала.


Известные ограничения

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

  • Сигнал на пересечении EMA хуже работает на рынках с боковым движением. Фильтр волатильности сокращает число входов в периоды слабого импульса, но не устраняет их полностью. Эта особенность присуща любому трендовому сигналу на вход.
  • Триггеры добавления заданы в фиксированных пипсах, а не масштабируются по ATR. На инструментах с переменной волатильностью фиксированные пипсовые триггеры могут быть слишком близкими в высоковолатильных режимах и слишком далекими в спокойных. Логичным следующим шагом было бы рассчитывать триггеры на основе ATR.
  • Движок поддерживает ровно два добавления. Для расширения до трех и более требуется добавить поля тикетов в SPyramidState и дополнительную логику триггеров в Manage. Архитектура допускает это без структурных изменений.
  • Восстановление состояния опирается на строки комментариев и совпадение объема лота. Если одновременно работают несколько советников с одним и тем же магическим числом и разными размерами лота, RecoverState() может ошибочно сопоставить позиции. Используйте уникальные магические числа.
  • Движок предполагает последовательное исполнение ордеров. Если ордер добавления исполнен частично, размер лота, сохраненный в состоянии, будет отличаться от фактического объема позиции. Обработка частичного исполнения не реализована.
  • Этот код представляет собой демонстрационную архитектуру, а не систему промышленного уровня, готовую к эксплуатации. Перед использованием в реальной торговле проверьте обработку кодов возврата в среде вашего брокера, проверьте требования брокера к уровню стопа и проведите тестирование на исторических данных продолжительностью не менее 12 месяцев по каждому инструменту, которым планируете торговать.


Заключение

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

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

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

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

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

Весь код был скомпилирован и протестирован в MetaTrader 5. Для работы CPyramidEngine требуется счет в режиме хеджирования. Перед запуском в реальной торговле всегда выполняйте полный прогон в Тестере стратегий на демо-счете.

Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/22187

Прикрепленные файлы |
PyramidEA.mq5 (5.74 KB)
PyramidUtils.mqh (5.71 KB)
PyramidEngine.mqh (17.15 KB)
Последние комментарии | Перейти к обсуждению на форуме трейдеров (3)
patrick
patrick | 18 мая 2026 в 18:35

Здравствуйте, спасибо, что поделились своим советником.

Извините, файл pad должен находиться в папке PyramidEA.

Можете ли вы его отредактировать?

Сохраните в «MQL5\Include\Pyramid\PyramidEngine.mqh».


Сейчас тестирую

Tola Moses Hector
Tola Moses Hector | 19 мая 2026 в 13:19
patrick #:

Здравствуйте, спасибо, что поделились своим советником.

Извините, файл pad должен находиться в папке PyramidEA.

Можете ли вы его отредактировать?

Сохраните файл в папке «MQL5\Include\Pyramid\PyramidEngine.mqh».


Я сейчас тестирую

Здравствуйте, Патрик

Сохраните файл в папке «MQL5\Include\Pyramid\PyramidEngine.mqh».

Это рекомендация, чтобы поддерживать порядок в папке «Include». Просто создайте новую папку с именем «Pyramid» в папке «Include», затем скопируйте два файла .mqh в эту папку — и всё.

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

ZeroCafeine
ZeroCafeine | 2 июл. 2026 в 07:27
Привет всем, надеюсь, у вас всё хорошо 😊,

— спасибо, Тола, Мозес, Гектор


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


Бегло просмотрев ваш код, я заметил, что, возможно, есть небольшая проблема с методом GetPositionTicketFromLastDeal() , который использует метод m_trade.ResultDeal(),


если сервер немного задерживается, мы получим 0,


Например, можно использовать цикл while с 10 попытками и коротким интервалом в 50 миллисекунд для каждой попытки

С уважением,
ZeroCafeine 😉

Торговые инструменты на MQL5 (Часть 32): Перекрестие, лупа и режим измерения Торговые инструменты на MQL5 (Часть 32): Перекрестие, лупа и режим измерения
В этой статье мы расширяем палитру инструментов, добавляя прецизионное перекрестие для графиков MQL5: ретикул с делениями, линии на всю ширину и всю высоту графика с метками осей, а также круговую лупу для отображения увеличенных свечей. Режим измерения двойным щелчком добавляет якорные маркеры, диагональный соединитель и плавающую метку с количеством баров, расстоянием в пипсах и разницей цен. Детали реализации включают менеджер перекрестия, одиннадцать слоев холста (canvas), алгоритм Брезенхема для рисования линий и поведение с учётом темы оформления: элементы перекрестия скрываются при наведении на боковую или выдвижную панель.
Разработка инструментария для анализа Price Action (Часть 69): Обнаружение паттерна "флаг" в MQL5 Разработка инструментария для анализа Price Action (Часть 69): Обнаружение паттерна "флаг" в MQL5
В этой статье показано, как преобразовать субъективное распознавание паттерна "флаг" в воспроизводимую логику на языке MQL5 для графиков в реальном времени. Она объединяет нормализованную по ATR силу флагштока, ограничения на откат, проверку структуры консолидации, подтверждение пробоя и контроль перекрытия. В результате читатель получает практический подход, который строит адаптивные каналы и зоны, эффективно обновляет активные сетапы и при необходимости выдает алерты о новых подтвержденных паттернах.
MLP (многослойный перцептрон) внутри советника MQL5: Обучение на истории без Python и без файлов весов MLP (многослойный перцептрон) внутри советника MQL5: Обучение на истории без Python и без файлов весов
Статья показывает, как перенести MLP-фильтр советника GridSurvivor из офлайн-обучения в Python в полностью встроенное обучение в MQL5. Сеть тренируется на истории текущего символа и таймфрейма, периодически переобучается и используется как последний фильтр сигналов. Подход исключает внешние файлы и рассинхрон нормализации, делая советник самодостаточным и воспроизводимым.
Рыночные секреты Ларри Уильямса (Часть 15): Торговля разворотами по паттерну Hidden Smash Day с учетом рыночного контекста Рыночные секреты Ларри Уильямса (Часть 15): Торговля разворотами по паттерну Hidden Smash Day с учетом рыночного контекста
Создадим советник MQL5, который автоматизирует развороты Hidden Smash Day Ларри Уильямса. Он считывает подтвержденные сигналы из пользовательского индикатора, применяет фильтры рыночного контекста (включая проверку направления по Supertrend и необязательные правила торговых дней) и управляет риском с помощью моделей стоп-лосса на основе структуры бара Smash или ATR, а также фиксированного или риск-ориентированного размера позиции. В результате получается воспроизводимая система, готовая к тестированию и расширению.