preview
Position Management: Safe Pyramiding with a Unified Stop in MQL5

Position Management: Safe Pyramiding with a Unified Stop in MQL5

MetaTrader 5Trading |
177 0
Tola Moses Hector
Tola Moses Hector

Introduction

Pyramiding—adding to a winning trade as it moves in your favor—is one of the most effective position-management techniques available to an algorithmic trader. When a trend produces a sustained directional move, a pyramid can turn a single entry into a compounding structure that extracts maximum value from the move. When implemented without discipline, that same technique can destroy an otherwise profitable trade in a single reversal.

This article delivers a reusable, self-contained MQL5 class called CPyramidEngine. Any expert advisor can plug it in with roughly six changes to existing code. The entry signal belongs to your EA. The engine handles everything else: add-on triggers, lot sizing, the unified stop, state recovery after restart, external close detection, and all broker-level validation.

Before showing what the engine does, this article explains why it is built this way. Every design decision in "CPyramidEngine" exists because of a specific failure mode in naive pyramiding implementations. Understanding those failure modes is what separates a developer who can use this engine from one who can truly trust it—and adapt it when conditions require.

The central question the engine is designed to answer is this:

How does total account risk change with each new position added?

Under a correctly designed pyramid, the answer is that total risk decreases with every add-on. By the time the third position opens, the worst-case outcome—all stops triggered simultaneously—is already a net profit. This is not a claim about directional accuracy or market prediction. It is a mathematical property of two constraints applied together: decreasing lot sizes and a unified stop that advances after each addition.

Most MQL5 pyramiding implementations never ask that question. They add positions, move stops, and assume everything works. This article shows what happens when those assumptions fail, builds the correct architecture from first principles, and delivers it as a plug-in module that you can integrate into your own EA without modifying a single line of engine code.


Why Most Pyramiding Implementations Fail

Before building the correct solution, it is worth understanding precisely why the common approaches break down. There are two primary failure modes, and they often appear together.

Failure Mode One: Uniform Lot Sizing

The most common pyramiding mistake is using the same lot size for every position. It feels intuitive—if 0.30 lots were the right size for the entry, why would they be wrong for the add-ons? The problem becomes clear when you trace what happens to total monetary exposure.

Three positions at 0.30 lots each means 0.90 lots in a single direction. If the trade then reverses, all three positions lose simultaneously. The total loss is three times what the trader originally planned to risk. The pyramid did not reduce risk as it grew—it multiplied it. What looked like a scaling-in strategy was actually a compounding of exposure that most position-sizing frameworks would never allow at entry.

The mathematics of safe pyramiding require that each successive add-on contribute less new risk than the one before it. The initial entry is the largest position in the pyramid. Each add-on must be strictly smaller. This is not a suggestion—it is the constraint that makes the risk reduction property mathematically provable. CPyramidEngine validates this at initialization and refuses to operate if the condition is not met.

Failure Mode Two: Independent Stops

The second failure mode is the absence of a coordinated stop architecture. When each position carries its own independent stop loss, the combined risk of the structure becomes difficult to reason about and impossible to control precisely.

Consider what this looks like in practice. The initial entry has a stop at level A. The first add-on has a stop at level B. The second add-on has a stop at level C. If the market reverses sharply, these stops fire at different prices, in different sequences, and at different monetary amounts. There is no single calculation that tells you your worst-case outcome at any moment.

More critically, when you try to move one stop to lock in profit, you must remember to move the others. If a stop modification fails—due to broker rejection, a freeze level violation, or a network interruption—one position remains exposed while the others are protected. The structure that looked coherent on paper has silently become incoherent in the market.

The solution is a single unified stop applied to every open position in the pyramid. When the stop moves, it moves for all positions at once. When you calculate worst-case exposure, there is exactly one number: the sum of all open lots multiplied by the distance from the current price to the unified stop. That number decreases after each add-on. The pyramid becomes safer as it grows.

The Engineering Consequence

These two failure modes—uniform lots and independent stops—are practical engineering problems. They manifest as specific bugs in MQL5 code: global variable clutter that becomes unmanageable across restarts, stop modifications that silently fail without detection, ticket tracking that breaks on hedging accounts because "ResultOrder()" is not the same as a position ticket, and management logic that only runs on new bars, causing add-on triggers to be missed entirely when price moves quickly within a single bar.

"CPyramidEngine" is built specifically to avoid every one of these failure modes. The sections that follow explain how each architectural decision addresses a real problem and why alternative approaches break down.


The Mathematics of Safe Pyramiding

The Three Conditions

The claim that total risk decreases with every add-on is not always true. It is true when three conditions hold simultaneously. "CPyramidEngine" is designed to maintain all three.

  1. Lot sizes must strictly decrease. The engine validates this at "Init()" and refuses to start if the condition is violated.
  2. The unified stop must advance enough after each add-on that the locked profit from prior positions exceeds the new risk introduced by the incoming one. With the default parameters in this article, this holds for standard forex. On instruments with larger spreads or nonstandard lot values, verify dollar risk figures using "GetPipValue()" before deployment.
  3. All stop modifications must execute. If a broker rejects a stop modification, the locked-profit calculation for that position is invalid until the modification succeeds. The engine logs all failures explicitly so the operator can detect broker-side issues during testing.

When all three conditions hold, the following inequality is satisfied after each add-on:

∑ Risk_new ≤ Risk_initial

Where "Risk_initial" is the monetary exposure at entry and "Risk_new" is the total worst-case exposure after all add-ons and stop movements.

Why Dollar Risk, Not Pips

Many programmers express risk as "pips × lots" because it is easy to calculate. For a standard EURUSD account with fixed pip values, this works as an approximation. For a production system deployed across multiple instruments, it fails.

Gold has a tick size of 0.01 and a tick value that differs dramatically from a standard forex pair at the same lot size. Index CFDs have contract sizes measured in currency units per index point. Cryptocurrency pairs have nonstandard decimal structures. A risk calculation expressed in pips that is not converted through the broker's actual tick value will be wrong on any of these instruments.

"CPyramidEngine" uses "GetPipValue()" from "PyramidUtils.mqh," which computes monetary risk correctly using "SYMBOL_TRADE_TICK_VALUE" and "SYMBOL_TRADE_TICK_SIZE." This is the broker-provided monetary value for one tick of movement on one standard lot, converted to account currency. The calculation is correct on all instrument types at all brokers.

Note on Dollar Figures in This Article.

The dollar amounts in the table below use "GetPipValue(): pip_size / tick_size × tick_value × lot_size." The figures shown assume EURUSD on a standard account, where this produces approximately $1 per pip per 0.10 lot. On different instruments the magnitudes will differ, but the structural principle holds as long as the three conditions above are satisfied.

Risk Evolution Table

The following table traces a complete three-position pyramid on EURUSD using the default parameters in this article.

Stage Price Positions Total Lots Unified Stop Per-Position Exposure Total Worst Case
Initial entry 1.0800 P1 0.30 1.0680 P1: -$360 risk -$360
Add-on 1 fires (+50 pips) 1.0850 P1 + P2 0.50 1.0825 P1: +$75 locked | P2: −$50 risk +$25
Add-on 2 fires (100 pips) 1.0900 P1 + P2 + P3 0.60 1.0890 P1: +$270 locked | P2: +$80 locked | P3: −$10 risk +$340.

Reading the table row by row:

  • At the initial entry, the trader carries $360 of maximum risk. This is standard exposure for this position size—120 pips at $1/pip/0.10 lot × 3.
  • When Add-on 1 fires at +50 pips, the unified stop moves to 1.0825. Position 1 cannot lose money—its stop is 25 pips above entry. Position 2 risks 25 pips at 0.20 lots = $50. The locked profit from P1 ($75) already exceeds the new risk from P2 ($50). Total worst-case exposure: +$25. The pyramid is risk-free after a single add-on.
  • When Add-on 2 fires at +100 pips, positions 1 and 2 have locked in $350 combined. Position 3 risks only 10 pips at 0.10 lots = $10. Total worst case if all stops fire simultaneously: +$340 net profit. The structure is profitable under every remaining market outcome.

Key Insight: The pyramid grows, but the risk does not grow—it shrinks. This is not a hopeful property. It is a provable consequence of the three conditions above. CPyramidEngine enforces all three mechanically so the property holds even when the market moves fast.


Account Mode: Hedging vs. Netting

Before examining the engine code, there is one prerequisite that must be stated clearly: CPyramidEngine requires a hedging account. This is not an arbitrary limitation—it is a consequence of how the engine manages state.

What Changes Between Account Modes?

  • In a netting account ("ACCOUNT_MARGIN_MODE_RETAIL_NETTING"), MetaTrader 5 maintains a single position per symbol. Opening a second trade in the same direction does not create a second position—it increases the volume of the existing one. The broker aggregates all exposure into one record with one ticket. The consequence for pyramiding is significant. On a netting account, adding 0.20 lots to an existing 0.30-lot position does not create a second ticket. There is no `ticket_addon1` to track, no per-position stop to modify independently, and no way to assign different stop distances to different layers of the pyramid. The unified stop architecture—which depends on modifying individual position records—cannot operate this way.
  • In a hedging account ("ACCOUNT_MARGIN_MODE_RETAIL_HEDGING"), each trade creates a separate position with its own ticket, its own stop loss, and its own entry price. Three positions in the same direction exist simultaneously as three independent records. The engine can track each ticket, modify each stop, and reason about each position's monetary contribution to the pyramid independently.

What the Engine Does

"CPyramidEngine" checks the account mode in "OnInit()" via "IsHedgingAccount()" from "PyramidUtils.mqh." If the account is not in hedging mode, the EA refuses to start and prints a clear diagnostic message. This is the correct behavior—a silent failure on a netting account would produce incorrect behavior that could be difficult to diagnose in the test.

The check looks like this in the EA demonstration:

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

If you are testing in the Strategy Tester, confirm that your broker demo account is marked as hedging. Most MetaTrader 5 demo accounts from brokers that offer hedging will pass this check automatically.


Architecture: Three Files, One Clear Responsibility Each

"CPyramidEngine" is distributed across three files. This separation is intentional—each file solves a different category of problem, and a developer who needs only part of the solution can use it independently.

This layering has a practical consequence. If your EA already has an entry signal you are happy with, you do not need to read "PyramidEA.mq5" in detail. Include "PyramidEngine.mqh," follow the six integration steps in Section 8, and your existing signal drives the pyramid.

The CPyramidEngine Public Interface

The most important design decision in this interface is that "OpenInitial()" exists at all. The engine does not know when to enter a trade. The calling EA detects its signal and calls "OpenInitial()" with the direction, price, stop loss, and lot size it has already decided. From that point, "Manage()" handles everything. This boundary is what makes the engine reusable across any strategy.

Decision Flow

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

The following diagram shows exactly when each method is called and what decisions it makes. Entry logic runs only on new bars. Position management runs on every tick.

The separation of entry timing from management timing is deliberate and important. Entry on a new bar prevents signal noise within a bar. But management—add-on triggers, stop movements, and trailing—must run on every tick. If management were limited to new bars, price could reach an add-on trigger within a bar and move away before the next bar opens. The trigger would be missed. In live trading, this produces behavior that looks fine in the tester but fails in real time.


PyramidUtils.mqh—The Infrastructure Library

Save to "MQL5\Include\Pyramid\PyramidUtils.mqh." This library has no dependencies on the Pyramid engine and can be included in any EA that needs accurate monetary pip values, broker-level stop validation, or volatility filtering.

The GetPipValue Problem

Before looking at the code, it is worth spending a moment on why "GetPipValue()" is needed at all. A common MQL5 shortcut is to calculate pip value as "point × pip multiplier × lots." This works for EURUSD on a standard dollar account. It breaks in three common situations.

First, when the account currency differs from the quote currency, the conversion factor changes with the exchange rate. A pip on GBPJPY is worth a different dollar amount today than it was last month. Second, for gold and index instruments, the relationship between points and monetary value is defined by the broker contract specification, not a simple multiplier. Third, on some brokers, USDJPY has three decimal places and EURUSD has five—the pip multiplier of 10 does not apply uniformly.

"GetPipValue()" solves this by using "SYMBOL_TRADE_TICK_VALUE," the monetary value the broker provides for one tick of movement on one standard lot. Dividing pip size by tick size and multiplying by tick value gives the correct account-currency monetary value per pip for any instrument at any broker.

//+------------------------------------------------------------------+
//|                      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
//+------------------------------------------------------------------+

Each function in "PyramidUtils.mqh" has a single, narrow responsibility. "IsStopLevelValid()" checks "SYMBOL_TRADE_STOPS_LEVEL" before any modification. "IsModificationAllowed()" checks "SYMBOL_TRADE_FREEZE_LEVEL" to avoid broker-side rejection on volatile instruments like gold. "IsRetcodeSuccess()" prevents silent trade failures by treating only known success codes as valid. None of these functions know anything about pyramiding—they are general-purpose infrastructure.


PyramidEngine.mqh—The Reusable Engine Class

Save to "MQL5\Include\Pyramid\PyramidEngine.mqh." This is the heart of the article. "CPyramidEngine" is a self-contained pyramid manager that any EA can include and use without modification. Every design decision below addresses a specific failure mode identified in Section 2.

Why ResultDeal(), Not ResultOrder()

In MQL5, an order ticket and a position ticket are not the same thing. An order is a request. A position is the result of fills. On some execution models and broker configurations—particularly when partial fills occur or when the broker uses an aggregation layer—the order ticket returned by "ResultOrder()" does not match the position ticket you need for "PositionSelectByTicket." If you store the wrong ticket, every subsequent attempt to modify or close that position silently fails.

The correct approach is to use "ResultDeal()" to get the deal ticket, then retrieve the position ID from the deal record via "HistoryDealGetInteger(deal, DEAL_POSITION_ID)." This position ID is what "PositionSelectByTicket" expects. The engine stores this value via the "GetPositionTicketFromLastDeal()" helper function, which is called after every successful order execution.

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

Why the Unified Stop Updates After Confirmation

A subtle but important engineering decision is the order of operations in "MoveUnifiedStop()." A naive implementation updates the state variable first, then attempts broker-side modifications.

// 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?

If either modification fails—because the broker rejects it, the position is in the freeze zone, or there is a connectivity issue—the engine's internal state says the stop is at the new level while one or more broker-side positions still carry the old stop. The locked-profit calculation for those positions is now wrong. The engine thinks it is safe when it is not.

The engine resolves this by only updating "m_state.unified_stop" after attempting all modifications and confirming they all succeeded. If any modification fails, the failure is logged with the ticket and retcode, so the engine retries on the next tick.

The Full Engine Code

//+------------------------------------------------------------------+
//|                     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—The Demonstration EA

Save to "MQL5\Experts\PyramidEA.mq5." This EA shows exactly how to integrate "CPyramidEngine" into an existing strategy. Engine-specific changes are limited to the include section, declaration, "Init(),""IsActive()" gate, "OpenInitial(),""Manage(),""HandleTransaction()" forwarding, and "RecoverState." That is the total integration cost.

The EMA crossover entry logic in "CheckForEntry()" can be replaced with any signal without changing a single line of engine code.

Inputs and Declarations

The inputs are organized into four groups using the "input group" directive. The "Entry" group controls the EMA periods and the ATR multiplier used to calculate the initial stop distance. The "Volatility Filter" group enables a pre-trade filter that prevents entries when ATR is outside the configured pip range—keeping the EA out of both unusually quiet and unusually volatile conditions. The "Pyramid Engine" group passes all pyramid configurations directly to the engine at initialization: lot sizes, add-on triggers in pips, unified stop distances after each add-on, and trailing stop settings. The "General" group sets the magic number for position identification and the slippage allowance in points.

The global section declares one "CPyramidEngine" instance, three indicator handles, three price buffers for the EMA and ATR, and a "datetime" variable to track the last processed bar.

//+------------------------------------------------------------------+
//|                       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—Validation and Engine Initialization

"OnInit()" begins with the hedging account check—calling "IsHedgingAccount()" from "PyramidUtils.mqh" and returning "INIT_FAILED" immediately if the account is not in hedging mode. This prevents the engine from running silently in an incompatible environment. The engine is then initialized via "Init," which internally validates that lot sizes are strictly decreasing and that the first add-on trigger is smaller than the second, returning false and blocking startup if either condition fails. The three indicator handles are created for the fast EMA, slow EMA, and ATR, and all three are checked against "INVALID_HANDLE" before proceeding. "RecoverState()" is called last—it scans any open positions that match the magic number and reconstructs the pyramid state, allowing the EA to resume managing an existing structure after a restart. "OnDeinit()" releases all indicator handles to free memory.

//+------------------------------------------------------------------+
//| 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—The Entry/Management Split

"OnTick()" separates two distinct responsibilities. The indicator buffers are refreshed only on new bars—detected by comparing the current bar's open time against "last_bar"—because EMA and ATR values are only meaningful on completed bars. "Manage()" runs unconditionally on every tick because position management—add-on triggers, stop movements, and trailing—must respond to price in real time, not wait for bar close. The entry gate combines two independent conditions: IsActive() prevents a second pyramid opening while one is running, and is_new_bar prevents the signal from being re-evaluated on every tick within the same bar. "OnTradeTransaction()" forwards every trade event to "HandleTransaction," which monitors for externally closed positions and cascade-closes any remaining add-ons in the structure.

//+------------------------------------------------------------------+
//| 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();
  }

Entry Signal—Fully Decoupled From the Engine

"CheckForEntry()" demonstrates exactly what the engine expects from the calling EA: a direction, a price, a stop loss, and a lot size. Replace the EMA crossover logic with any signal that produces those four values, and the integration is complete. The engine does not care how those values were calculated—it only begins working when "OpenInitial()" is called.

//+------------------------------------------------------------------+
//| 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");
        }
  }


Integrating the Engine Into Your Own EA

Plugging "CPyramidEngine" into an existing EA requires exactly six changes. The entry signal and all other strategy logic remain entirely unchanged. The engine is a pyramid manager—your strategy stays yours.

Step 1—Add the Include

Add the "#include" directive for "PyramidEngine.mqh" at the top of your EA file.

#include <PyramidEngine.mqh>

Step 2—Declare the Engine

Declare one "CPyramidEngine" instance at global scope.

CPyramidEngine pyramid;

Step 3—Initialize in 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();

Follow immediately with "RecoverState()" to handle any open positions from a previous session.

Step 4—Manage Every Tick, Gate Entry on New Bar

Call "pyramid.manage()" unconditionally at the top of "OnTick()." Gate your entry function and your new-bar flag.

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

Step 5—Replace the Order Call in Your Entry Function

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

Step 6—Forward Trade Events

Add or update "OnTradeTransaction()" to forward all events to "HandleTransaction()."

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

 Six changes. Zero modifications to the engine itself.


Walking Through a Live Example

To make the mechanics concrete, here is a complete pyramid from open to close. EMA 20 crosses above EMA 50. ATR reads 60 pips. The volatility filter passes.

Bar 1—Initial Entry:
  • Price: 1.0800
  • Lot size: 0.30
  • Initial SL: 1.0800 − (2.0 × 0.0060) = 1.0680 (120 pips away)
  • Initial risk on 0.30 lots at 120 pips ≈ $360
  • Pyramid active. Waiting for add-on 1 trigger at +50 pips.
Bar 7—Price reaches 1.0850 (+50 pips):
  • Add-on 1 is triggered.
  • Open 0.20 lots BUY at 1.0850.
  • The unified stop moves to 1.0850−25 pips = 1.0825.
  • Position 1 (0.30 lots): opened at 1.0800, stop now at 1.0825. 25 pips in profit—cannot lose.
  • Position 2 (0.20 lots): opened at 1.0850, stop at 1.0825. Risk = 25 pips × 0.20 lots = $50.
  • Total account risk: $50 (down from $360 at entry).
Bar 14—Add-on 2 at 1.0900 (+100 Pips):
  • "Manage()" detects trigger. "OpenAddon(2, 0.10)" executes.
  • The unified stop moves to 1.0890 (10 pips below the current BID).
  • P1: 90 pips locked = $270. P2: 40 pips locked = $80. P3: 10 pips at risk = $10.
  • Worst-case if all stop firing simultaneously: +$340 net profit. The position is structurally profitable.
  • Trailing begins. A 15-pip trail with a 5-pip step follows the price higher on every tick.

Bar 22—Price reaches 1.0970 and reverses:
  • Trail has moved the unified stop to 1.0955 (15 pips below the peak of 1.0970).
  • Market reverses. All three positions stopped out at 1.0955.
  • P1: 155 pips × 0.30 = $465 | P2: 105 pips × 0.20 = $210 | P3: 55 pips × 0.10 = $55
  • Total profit: $730 from an initial risk of $360—a 2.03R return.

At no point did the pyramid exceed the initial $360 risk. Within seven bars, risk dropped to $50. By bar 14 the position was profitable under every remaining scenario. This is what the mathematics of Section 2 looks like in practice.


The Five Rules the Engine Enforces

Rule 1: Lot sizes must strictly decrease. The engine validates "Lot_Initial > Lot_Addon1 > Lot_Addon2" at "Init()" and refuses to start if the condition is not met. Each successive add-on must contribute less monetary risk than the previous one. This is the condition that makes the risk-reduction property mathematically provable.

Rule 2: The unified stop can only move toward profit. "MoveUnifiedStop()" and "ModifyStop()" both include directional guards. The stop may only advance upward for long positions and downward for short positions. It has never moved backward under any circumstances.

Rule 3: Add-ons open sequentially with gap-bar protection. The "return" statement after processing Add-on 1 prevents Add-on 2 from evaluating on the same "Manage()" call. Even if price has already passed both trigger levels, Add-on 2 waits until the next call after Add-on 1 is confirmed and its stop modification is complete.

Rule 4: Each add-on uses the current unified stop as its own stop loss. This makes monetary risk calculable at the moment of each opening. The add-on is never exposed to more than the distance from its entry price to the then-current unified stop. There are no independent stops anywhere in the structure.

Rule 5: The engine never initiates a trade. The entry signal, timing, and initial position sizing belong entirely to the calling EA. The engine's role begins when "OpenInitial()" is called and ends when all positions are closed. This boundary is what makes the engine reusable across any strategy without modification.


Testing and Deployment Guidance

Recommended Test Parameters

EURUSD

The EURUSD test runs on the H1 timeframe using every-tick modeling based on real ticks, with an initial deposit of $10,000. The EMA periods are 20 and 50, the ATR period is 14, and the lot sizes are 0.30, 0.20, and 0.10 for the initial entry and the two add-ons, respectively. Add-on 1 triggers at +50 pips and Add-on 2 at +100 pips. The unified stop moves to 25 pips below the price after Add-on 1 and 10 pips below the price after Add-on 2. Trailing is set to 15 pips with a 5-pip step, and the volatility filter is active with an ATR range of 5 to 80 pips.

Gold

The Gold test uses the same H1 timeframe and every-tick modeling, with an initial deposit of $10,000. The EMA periods are 20 and 50, the ATR period is 14, and the lot sizes are 0.30, 0.20, and 0.10. Given Gold's higher pip value, the add-on triggers are widened to +500 pips and +1,000 pips. The unified stop moves to 250 pips below the price after Add-on 1 and 1,000 pips below the price after Add-on 2. Trailing is set to 1,000 pips with a 100-pip step, and the volatility filter remains active with an ATR range of 5 to 80 pips.

What to Measure

When evaluating tester results, three metrics are most diagnostic for a pyramiding EA.

  • Average winner versus average loser. Pyramids that reach Add-on 2 carry three simultaneous positions. Losses are always limited to the initial position's risk, since the stop fires before any trigger is reached. The average winner should be substantially larger than the average loser. If it is not, the add-on triggers are too close, and the structure is not extracting the full value of trends.
  • Trade duration distribution. A healthy result shows a bimodal pattern: many short trades stopped at the initial stop and fewer but significantly longer winners that more than compensate. A uniform distribution suggests the strategy is behaving as a simple trend follower rather than extracting the compounding benefit of the pyramid.
  • Profit factor by period. If the profit factor falls below 1.2 in any test window, the volatility filter is insufficient for prevailing conditions. Tightening "max_ATR_pips" or raising "addon1_trigger_pips" will reduce entries during choppy, low-momentum periods.

Demonstration on Gold

Test input parameters

Test input parameters.

Demo

Demonstration on gold.

Graph

graph results

Test results

results

results

Live Deployment Checklist:

  • Account Mode. Confirm "ACCOUNT_MARGIN_MODE = 2" (RETAIL_HEDGING) before attaching. The engine checks this via "IsHedgingAccount()" and refuses to start on netting accounts.
  • Broker stop and freeze levels. "IsStopLevelValid()" and "IsModificationAllowed()" handle these automatically. Frequent stop-level log messages indicate nonstandard broker requirements—widening stop distances resolve this.
  • Non-standard instruments. "GetPipValue()" computes exact monetary risk for any instrument. Verify log output at startup before running the engine on gold or CFDs.
  • Lot sizing. Lot sizes are not automatically adjusted for account balance. Calibrate before live deployment and after any significant equity change.


Known Limitations

No system should be deployed without a clear understanding of what it does not handle. The following limitations are present in this implementation by design choice or scope constraint:

  • The EMA crossover signal underperforms in ranging markets. The volatility filter reduces but does not eliminate entries during low-momentum periods. Any trend-following entry signal has this characteristic.
  • Add-on triggers are fixed in pips, not ATR-scaled. On instruments with variable volatility, fixed pip triggers may be too tight in high-volatility regimes and too wide in quiet ones. An ATR-based trigger calculation is a logical next extension.
  • The engine supports exactly two add-ons. Extending to three or more requires adding ticket fields to "SPyramidState" and additional trigger logic in "Manage." The architecture supports this without structural change.
  • State recovery relies on comment strings and lot volume matching. If multiple EAs with the same magic number and different lot sizes are running simultaneously, "RecoverState()" may misidentify positions. Use unique magic numbers.
  • The engine assumes sequential order fill. If an add-on order is partially filled, the lot size stored in state will differ from the actual position volume. Partial fill handling is not implemented.
  • The code is a demonstration architecture, not a production-ready system. Before live use, verify retcode handling in your specific broker environment, confirm stop-level requirements, and run a minimum 12-month backtest on every instrument you intend to trade.


Conclusion

Pyramiding is not inherently dangerous. The danger comes from implementing it without answering one question: how does total account risk change as positions are added? Most implementations never ask. They scale in, move stops individually, and hope the market keeps moving. When it does not, the losses are larger than any single position would have been.

The engine presented here—"CPyramidEngine"—is built around a specific answer to that question. Decreasing lot sizes combined with a unified stop that advances after each add-on produces a structure where risk falls with every new position. After the first add-on, the combined position cannot lose money. After the second, the worst-case outcome is already a substantial net profit. This is not a claim about trend prediction—it is a mathematical property of the architecture, provable from the three conditions in Section 3.

The engine packages that architecture as a reusable, drop-in class. The pyramid management logic is written once, tested once, and integrated into any EA with six changes to existing code. The calling EA supplies only what it should: an entry signal. The engine handles everything else.

The supporting infrastructure in "PyramidUtils.mqh" handles the production engineering that most pyramiding articles skip: exact pip value computation using tick data, broker stop and freeze level validation, retcode checking, account mode detection, and volatility filtering. These components are instrument-agnostic and useful beyond pyramiding alone.

The result is not just a pyramiding EA. It is a pyramiding system—one that any intermediate MQL5 developer can adopt, adapt, and trust.

All code was compiled and tested in MetaTrader 5. "CPyramidEngine" requires a hedging account mode. Always run a full Strategy Tester pass on a demo account before live deployment.

Attached files |
PyramidEA.mq5 (5.74 KB)
PyramidUtils.mqh (5.71 KB)
PyramidEngine.mqh (17.15 KB)
Beyond GARCH (Part I): Mandelbrot's MMAR versus Engle's GARCH Beyond GARCH (Part I): Mandelbrot's MMAR versus Engle's GARCH
This article starts the MMAR pipeline on EURUSD M5 data. We load market data via the MetaTrader5 Python API and run partition-function analysis with non-overlapping intervals to test for multifractal scaling. The result is an evidence-based decision on fractality, a prerequisite for building MMAR and for choosing whether to proceed beyond GARCH.
Downloading International Monetary Fund Data Using Python Downloading International Monetary Fund Data Using Python
Downloading international monetary fund data in Python: Mining IMF data for use in macroeconomic currency strategies. How can macroeconomics help an ordinary and an algorithmic trader?
Features of Experts Advisors Features of Experts Advisors
Creation of expert advisors in the MetaTrader trading system has a number of features.
MQL5 Trading Tools (Part 31): Creating an Interactive Tools Palette in MQL5 MQL5 Trading Tools (Part 31): Creating an Interactive Tools Palette in MQL5
We turn the Tools Palette sidebar from a static shell into an interactive MQL5 system. The article implements flyout menus per category, a chart event handler, a multi-click drawing engine (one-, two-, and three-click tools), and mouse interactions including drag, bottom-edge resize, scrolling, hover states, and live theme toggling. You will be able to select a tool and place chart objects directly from the palette for analysis