preview
Designing a Partial Close Engine in MQL5 with Configurable Profit Ladders

Designing a Partial Close Engine in MQL5 with Configurable Profit Ladders

MetaTrader 5Trading |
992 1
Ushana Kevin Iorkumbul
Ushana Kevin Iorkumbul

Introduction

Partial position closing means scaling out of a winning trade at multiple profit targets rather than exiting all at once. It is a standard risk management technique among professional traders, but most retail MQL5 Expert Advisors either skip it entirely or get the mechanics wrong in ways that only show up once real money is on the line.

Three mistakes recur in custom implementations: (1) failing to round the close volume to the lot step, (2) applying percentages to the remaining volume instead of the entry volume, and (3) mishandling the move to breakeven after the first partial close. This leaves the remaining position sitting at its original stop, exposed to a full loss even after the trade has already covered its own risk.

This article builds a CPartialCloseEngine class that solves all three problems directly. It accepts an R-multiple profit ladder, monitors registered positions on each tick, computes a close volume that respects lot step and minimum lot, and moves the stop to breakeven when a ladder level requests it. By the end, you will have nine source files: seven include files, one Expert Advisor, and one verification script you can compile and run.

Architecture of the partial close engine

Architecture of the partial close engine. The Expert Advisor drives CPartialCloseEngine through Register() and OnTick(). The engine owns four components: CVolumeNormalizer, CChartLevelDrawer, CPartialCloseExecutor, and CBreakevenManager. Only the executor and the breakeven manager reach the trade server, both submitting requests through OrderSend().


Section 1: The Profit Ladder Design

An R-multiple is the distance from a trade's entry price to its stop loss, used as the unit for everything else about that trade. If a long position enters at 1.1000 with a stop at 1.0950, R is 0.0050, or 50 pips on a five-digit quote. A 1R profit target sits at entry + 1 * R, a 2R target at entry + 2 * R, and so on. R is the right unit for ladder triggers because it scales with each trade's own risk automatically. A ladder defined in R-multiples behaves the same way on a tight 20-pip stop and a wide 200-pip stop, without the EA needing separate settings for each.

A ladder level needs four pieces of information: the R-multiple that triggers it, the percentage of volume to close when it triggers, whether hitting it should also move the stop to breakeven, and whether it has already fired. The close percentage always applies to the position's original entry volume, never to whatever volume remains when the level triggers. Take a 1.00 lot position with a ladder of 50 percent at 1R and 50 percent at 2R. If the second level's percentage is applied to the volume left after the first close, then 2R closes 50 percent of 0.50 lots, which is 0.25 lots, leaving 0.25 lots open when the ladder was meant to fully exit by 2R. Applying both percentages to the original 1.00 lots instead closes 0.50 at 1R and 0.50 at 2R, exactly as configured, no matter how many levels came before.

The breakeven flag exists because not every ladder level should move the stop. A common pattern is to move to breakeven only once, at the first level, since moving it again at every level afterward adds no protection the first move did not already provide. The flag lets each level opt in or out on its own.


Section 2: CLadderLevel — the Building Block of a Ladder

CLadderLevel is a small struct-like class that stores the four pieces of state described above: the R-multiple, the close percentage, the breakeven flag, and the hit flag. The class declaration only holds member variables and method signatures, with one-line accessors as the only exception to the rule that method bodies live outside the class.

//+------------------------------------------------------------------+
//|                                                 LadderLevel.mqh  |
//+------------------------------------------------------------------+
#ifndef LADDERLEVEL_MQH
#define LADDERLEVEL_MQH

//+------------------------------------------------------------------+
//| CLadderLevel                                                     |
//| Holds one rung of a profit ladder used by CPartialCloseEngine.   |
//| A ladder level is defined purely in R-multiples so it applies    |
//| to any instrument and any stop distance without modification.    |
//+------------------------------------------------------------------+
class CLadderLevel
  {
private:
   double            m_r_multiple;
   double            m_close_pct;
   bool              m_move_to_breakeven;
   bool              m_hit;

public:
                     CLadderLevel(void);
                     CLadderLevel(const double r_multiple,
                                  const double close_pct,
                                  const bool move_to_breakeven);
                    ~CLadderLevel(void);

   double            RMultiple(void) const { return(m_r_multiple); }
   double            ClosePct(void) const { return(m_close_pct); }
   bool              MoveToBreakeven(void) const { return(m_move_to_breakeven); }
   bool              Hit(void) const { return(m_hit); }
   void              SetHit(const bool hit) { m_hit = hit; }
  };

The default constructor exists so CLadderLevel arrays can be declared before their real values are known, which the position record needs later. The parameterized constructor is the one actually used when a ladder is built, and it always starts a level as unhit regardless of what values are passed in.

//+------------------------------------------------------------------+
//| Default constructor                                              |
//+------------------------------------------------------------------+
CLadderLevel::CLadderLevel(void)
  {
   m_r_multiple        = 0.0;
   m_close_pct         = 0.0;
   m_move_to_breakeven = false;
   m_hit               = false;
  }

//+------------------------------------------------------------------+
//| Parameterized constructor                                        |
//| r_multiple    - trigger point expressed as a multiple of R       |
//| close_pct     - percentage of the ORIGINAL entry volume to close |
//| move_to_breakeven - whether hitting this level moves the SL      |
//+------------------------------------------------------------------+
CLadderLevel::CLadderLevel(const double r_multiple,
                            const double close_pct,
                            const bool move_to_breakeven)
  {
   m_r_multiple        = r_multiple;
   m_close_pct         = close_pct;
   m_move_to_breakeven = move_to_breakeven;
   m_hit               = false;
  }

//+------------------------------------------------------------------+
//| Destructor                                                       |
//+------------------------------------------------------------------+
CLadderLevel::~CLadderLevel(void)
  {
  }


Section 3: CPositionRecord — Freezing a Position's Starting State

CPositionRecord is the engine's memory for a single registered position. The engine cannot safely re-read a position's entry price, stop loss, or volume from the terminal on every tick once partial closes have started happening, since a position's live volume shrinks after each close and its stop loss can change independently of the engine. CPositionRecord captures the original entry price, original stop loss, original R, and original volume once, and every later ladder calculation reads from that frozen snapshot instead.

//+------------------------------------------------------------------+
//|                                               PositionRecord.mqh |
//+------------------------------------------------------------------+
#ifndef POSITIONRECORD_MQH
#define POSITIONRECORD_MQH

#include "LadderLevel.mqh"

#define PCE_MAX_LADDER_LEVELS 8

//+------------------------------------------------------------------+
//| CPositionRecord                                                  |
//+------------------------------------------------------------------+
class CPositionRecord
  {
private:
   ulong             m_ticket;
   string            m_symbol;
   long              m_type;
   double            m_original_volume;
   double            m_original_entry_price;
   double            m_original_sl;
   double            m_original_r;
   CLadderLevel      m_levels[PCE_MAX_LADDER_LEVELS];
   int               m_level_count;
   string            m_object_names[PCE_MAX_LADDER_LEVELS];
   int               m_object_count;

public:
                     CPositionRecord(void);
                    ~CPositionRecord(void);

   void              Init(const ulong ticket,
                          const string symbol,
                          const long type,
                          const double original_volume,
                          const double original_entry_price,
                          const double original_sl,
                          const double original_r);

   ulong             Ticket(void) const { return(m_ticket); }
   string            Symbol(void) const { return(m_symbol); }
   long              Type(void) const { return(m_type); }
   double            OriginalVolume(void) const { return(m_original_volume); }
   double            OriginalEntryPrice(void) const { return(m_original_entry_price); }
   double            OriginalSl(void) const { return(m_original_sl); }
   double            OriginalR(void) const { return(m_original_r); }

   void              AddLevel(const CLadderLevel &level);
   int               LevelCount(void) const { return(m_level_count); }
   CLadderLevel      *Level(const int index);

   void              AddObjectName(const string name);
   int               ObjectCount(void) const { return(m_object_count); }
   string            ObjectName(const int index) const;
  };

The constructor zeroes out every field, and Init() is what actually populates a record once a position is registered. Both reset the level count and object count to zero, since Init() can in principle be called again on a reused slot.

//+------------------------------------------------------------------+
//| Constructor                                                      |
//+------------------------------------------------------------------+
CPositionRecord::CPositionRecord(void)
  {
   m_ticket               = 0;
   m_symbol               = "";
   m_type                 = 0;
   m_original_volume      = 0.0;
   m_original_entry_price = 0.0;
   m_original_sl          = 0.0;
   m_original_r           = 0.0;
   m_level_count          = 0;
   m_object_count         = 0;
  }

//+------------------------------------------------------------------+
//| Destructor                                                       |
//+------------------------------------------------------------------+
CPositionRecord::~CPositionRecord(void)
  {
  }

//+------------------------------------------------------------------+
//| Init                                                             |
//| Captures the immutable state of a position at registration time. |
//+------------------------------------------------------------------+
void CPositionRecord::Init(const ulong ticket,
                           const string symbol,
                           const long type,
                           const double original_volume,
                           const double original_entry_price,
                           const double original_sl,
                           const double original_r)
  {
   m_ticket               = ticket;
   m_symbol               = symbol;
   m_type                 = type;
   m_original_volume      = original_volume;
   m_original_entry_price = original_entry_price;
   m_original_sl          = original_sl;
   m_original_r           = original_r;
   m_level_count          = 0;
   m_object_count         = 0;
  }

AddLevel() and Level() manage the record's own copy of the ladder. The record stores each CLadderLevel by value rather than by reference, so the engine's Register() method can build a temporary array of levels and hand it off without worrying about its lifetime.

//+------------------------------------------------------------------+
//| AddLevel                                                         |
//| Appends a ladder level to this position's rung array.            |
//+------------------------------------------------------------------+
void CPositionRecord::AddLevel(const CLadderLevel &level)
  {
   if(m_level_count >= PCE_MAX_LADDER_LEVELS)
     {
      //--- ladder array is full, refuse silently rather than overrun
      return;
     }
   m_levels[m_level_count] = level;
   m_level_count++;
  }

//+------------------------------------------------------------------+
//| Level                                                            |
//| Returns a pointer to the ladder level at the given index.        |
//+------------------------------------------------------------------+
CLadderLevel *CPositionRecord::Level(const int index)
  {
   if(index < 0 || index >= m_level_count)
      return(NULL);
   return(GetPointer(m_levels[index]));
  }

AddObjectName() and ObjectName() are the reason Deregister() can clean up a position's chart lines without searching the whole chart. Every object name created for a position gets stored here the moment it is drawn.

//+------------------------------------------------------------------+
//| AddObjectName                                                    |
//| Records the name of a chart object created for this position so  |
//| it can be cleaned up later during Deregister().                  |
//+------------------------------------------------------------------+
void CPositionRecord::AddObjectName(const string name)
  {
   if(m_object_count >= PCE_MAX_LADDER_LEVELS)
     {
      //--- object name array is full, refuse silently rather than overrun
      return;
     }
   m_object_names[m_object_count] = name;
   m_object_count++;
  }

//+------------------------------------------------------------------+
//| ObjectName                                                       |
//| Returns the chart object name at the given index.                |
//+------------------------------------------------------------------+
string CPositionRecord::ObjectName(const int index) const
  {
   if(index < 0 || index >= m_object_count)
      return("");
   return(m_object_names[index]);
  }


Section 4: CVolumeNormalizer — Correct Volume Computation

Every partial close volume starts as a raw arithmetic result: original volume times a percentage, divided by 100. That number almost never lands on a clean multiple of the symbol's lot step, so Normalize() rounds it, and ClampClose() separately checks that whatever remains after the close is still a tradeable size. Keeping these two jobs apart makes each one easy to test on its own.

//+------------------------------------------------------------------+
//|                                             VolumeNormalizer.mqh |
//+------------------------------------------------------------------+
#ifndef VOLUMENORMALIZER_MQH
#define VOLUMENORMALIZER_MQH

//+------------------------------------------------------------------+
//| CVolumeNormalizer                                                |
//+------------------------------------------------------------------+
class CVolumeNormalizer
  {
private:
   int               m_last_warning_count;

public:
                     CVolumeNormalizer(void);
                    ~CVolumeNormalizer(void);

   double            Normalize(const string symbol, const double volume) const;
   double            ClampClose(const string symbol,
                                const double position_volume,
                                const double raw_close_volume);
   int               WarningCount(void) const { return(m_last_warning_count); }
  };

//+------------------------------------------------------------------+
//| Constructor                                                      |
//+------------------------------------------------------------------+
CVolumeNormalizer::CVolumeNormalizer(void)
  {
   m_last_warning_count = 0;
  }

//+------------------------------------------------------------------+
//| Destructor                                                       |
//+------------------------------------------------------------------+
CVolumeNormalizer::~CVolumeNormalizer(void)
  {
  }

Normalize() rounds a volume to the nearest lot step using round(volume / lot_step) * lot_step. One detail matters here: MQL5's MathRound() rounds an exact half away from zero, so 0.5 rounds up to 1. This differs from languages that round an exact half to the nearest even number, so a case like 0.005 lots at a 0.01 lot step, which divides out to exactly 0.5, rounds up to 0.01 in MQL5. That is a real difference in rounding convention, not a bug, and it is worth knowing before comparing this formula against results computed somewhere else.

//+------------------------------------------------------------------+
//| Normalize                                                        |
//+------------------------------------------------------------------+
double CVolumeNormalizer::Normalize(const string symbol, const double volume) const
  {
   double lot_step = ::SymbolInfoDouble(symbol, SYMBOL_VOLUME_STEP);
   if(lot_step <= 0.0)
     {
      //--- defensive fallback, should never happen on a real symbol
      lot_step = 0.01;
     }

   double steps     = ::MathRound(volume / lot_step);
   double result    = steps * lot_step;

//--- round again to the symbol's own digit count to remove binary noise
   int digits = 2;
   double step_copy = lot_step;
   while(step_copy < 1.0 && digits < 8)
     {
      step_copy *= 10.0;
      digits++;
     }
   result = ::NormalizeDouble(result, digits);

   return(result);
  }

ClampClose() solves a specific edge case: a close percentage that rounds to a volume leaving less than SYMBOL_VOLUME_MIN behind. It steps the close volume down by one lot step at a time until the remainder is either zero, meaning a full close, or at least the minimum, logging a warning whenever this adjustment happens.

//+------------------------------------------------------------------+
//| ClampClose                                                       |
//+------------------------------------------------------------------+
double CVolumeNormalizer::ClampClose(const string symbol,
                                     const double position_volume,
                                     const double raw_close_volume)
  {
   double lot_step  = ::SymbolInfoDouble(symbol, SYMBOL_VOLUME_STEP);
   double min_lot   = ::SymbolInfoDouble(symbol, SYMBOL_VOLUME_MIN);
   if(lot_step <= 0.0)
      lot_step = 0.01;
   if(min_lot <= 0.0)
      min_lot = 0.01;

   double close_volume = Normalize(symbol, raw_close_volume);

//--- never propose closing more than the position actually holds
   if(close_volume > position_volume)
      close_volume = Normalize(symbol, position_volume);

   double remainder = position_volume - close_volume;
   remainder = Normalize(symbol, remainder);

//--- a remainder of exactly zero is a full close, always valid
   if(remainder <= 0.0000001)
      return(close_volume);

//--- if the remainder is below the minimum, and above zero, it is
//--- stranded volume the broker will refuse to hold; step the close
//--- volume down one lot step at a time until the remainder is valid
   int guard = 0;
   while(remainder > 0.0000001 && remainder < min_lot - 0.0000001 && guard < 10000)
     {
      close_volume -= lot_step;
      close_volume  = Normalize(symbol, close_volume);
      if(close_volume < 0.0)
        {
         close_volume = 0.0;
         break;
        }
      remainder = position_volume - close_volume;
      remainder = Normalize(symbol, remainder);
      guard++;
     }

   if(close_volume < min_lot && close_volume > 0.0)
     {
      //--- the close itself is now below the minimum tradeable size;
      //--- there is no valid partial close left for this ladder level
      m_last_warning_count++;
      ::PrintFormat("CVolumeNormalizer: adjusted close for %s would fall below "
                    "SYMBOL_VOLUME_MIN (%.2f); skipping this level",
                    symbol, min_lot);
      return(0.0);
     }

   return(close_volume);
  }


Section 5: CPartialCloseExecutor — Executing the Partial Close

A partial close in MQL5 is an ordinary TRADE_ACTION_DEAL request sent through OrderSend() against the position ticket, with a volume smaller than the position's full volume and the opposite order type of how the position was opened. The trade server recognizes the smaller volume against an existing position and reduces it instead of opening a new, opposite position.

//+------------------------------------------------------------------+
//|                                         PartialCloseExecutor.mqh |
//+------------------------------------------------------------------+
#ifndef PARTIALCLOSEEXECUTOR_MQH
#define PARTIALCLOSEEXECUTOR_MQH

#define PCE_MAX_RETRIES 3

//+------------------------------------------------------------------+
//| CPartialCloseExecutor                                            |
//+------------------------------------------------------------------+
class CPartialCloseExecutor
  {
private:
   ulong             m_last_deal_ticket;
   bool              IsRetryable(const int error_code) const;
   ENUM_ORDER_TYPE_FILLING SelectFillingMode(const string symbol) const;

public:
                     CPartialCloseExecutor(void);
                    ~CPartialCloseExecutor(void);

   bool              ExecutePartialClose(const ulong position_ticket,
                                         const string symbol,
                                         const long position_type,
                                         const double volume);
   ulong             LastDealTicket(void) const { return(m_last_deal_ticket); }
  };

//+------------------------------------------------------------------+
//| Constructor.                                                     |
//+------------------------------------------------------------------+
CPartialCloseExecutor::CPartialCloseExecutor(void)
  {
   m_last_deal_ticket = 0;
  }

//+------------------------------------------------------------------+
//| Destructor.                                                      |
//+------------------------------------------------------------------+
CPartialCloseExecutor::~CPartialCloseExecutor(void)
  {
  }

IsRetryable() separates trade server errors that come from a moving market, such as requotes and timeouts, from errors that will never succeed no matter how many times you try again, such as an invalid volume. Retrying only the first kind keeps the executor from hammering the trade server with a request that cannot possibly work.

//+------------------------------------------------------------------+
//| IsRetryable                                                      |
//+------------------------------------------------------------------+
bool CPartialCloseExecutor::IsRetryable(const int error_code) const
  {
   switch(error_code)
     {
      case TRADE_RETCODE_REQUOTE:
      case TRADE_RETCODE_PRICE_CHANGED:
      case TRADE_RETCODE_PRICE_OFF:
      case TRADE_RETCODE_TIMEOUT:
      case TRADE_RETCODE_CONNECTION:
         return(true);
      default:
         return(false);
     }
  }

SelectFillingMode() reads SYMBOL_FILLING_MODE and picks a fill type the symbol actually supports. Sending a request with an unsupported filling mode gets rejected by the client terminal itself, before it even reaches the trade server, so this check has to run before every request rather than once at startup, since different symbols on the same account can support different fill types.

//+------------------------------------------------------------------+
//| SelectFillingMode                                                |
//+------------------------------------------------------------------+
ENUM_ORDER_TYPE_FILLING CPartialCloseExecutor::SelectFillingMode(const string symbol) const
  {
   long filling_flags = ::SymbolInfoInteger(symbol, SYMBOL_FILLING_MODE);

   if((filling_flags & SYMBOL_FILLING_FOK) != 0)
      return(ORDER_FILLING_FOK);
   if((filling_flags & SYMBOL_FILLING_IOC) != 0)
      return(ORDER_FILLING_IOC);

   return(ORDER_FILLING_RETURN);
  }

ExecutePartialClose() builds the request, sends it, and retries up to PCE_MAX_RETRIES times only for retryable errors, refreshing the price before each retry since a stale price is often the reason the previous attempt failed. Every attempt, successful or not, is logged with the ticket, volume, price, and result code.

//+-------------------------------------------------------------------+
//| ExecutePartialClose                                               |
//+------------------------------------------------------------------+
bool CPartialCloseExecutor::ExecutePartialClose(const ulong position_ticket,
      const string symbol,
      const long position_type,
      const double volume)
  {
   MqlTradeRequest request;
   MqlTradeResult  result;
   ::ZeroMemory(request);
   ::ZeroMemory(result);

   double close_price = (position_type == POSITION_TYPE_BUY)
                        ? ::SymbolInfoDouble(symbol, SYMBOL_BID)
                        : ::SymbolInfoDouble(symbol, SYMBOL_ASK);

   request.action       = TRADE_ACTION_DEAL;
   request.position     = position_ticket;
   request.symbol       = symbol;
   request.volume       = volume;
   request.price        = close_price;
   request.deviation    = 10;
   request.type         = (position_type == POSITION_TYPE_BUY) ? ORDER_TYPE_SELL : ORDER_TYPE_BUY;
   request.type_filling = SelectFillingMode(symbol);

   int attempt = 0;
   bool sent = false;

   while(attempt < PCE_MAX_RETRIES && !sent)
     {
      attempt++;
      ::ZeroMemory(result);
      bool send_ok = ::OrderSend(request, result);

      if(send_ok && (result.retcode == TRADE_RETCODE_DONE || result.retcode == TRADE_RETCODE_PLACED))
        {
         sent = true;
         m_last_deal_ticket = result.deal;
         ::PrintFormat("CPartialCloseExecutor: closed ticket=%I64u volume=%.2f price=%.5f "
                       "deal=%I64u attempt=%d",
                       position_ticket, volume, result.price, result.deal, attempt);
         return(true);
        }

      ::PrintFormat("CPartialCloseExecutor: attempt %d failed for ticket=%I64u volume=%.2f "
                    "retcode=%d comment=%s",
                    attempt, position_ticket, volume, result.retcode, result.comment);

      if(!IsRetryable((int)result.retcode))
        {
         //--- non-retryable error, stop immediately
         break;
        }

      //--- refresh price before retrying a requote or stale-price error
      close_price = (position_type == POSITION_TYPE_BUY)
                    ? ::SymbolInfoDouble(symbol, SYMBOL_BID)
                    : ::SymbolInfoDouble(symbol, SYMBOL_ASK);
      request.price = close_price;
     }

   if(!sent)
     {
      ::PrintFormat("CPartialCloseExecutor: giving up on ticket=%I64u after %d attempts",
                    position_ticket, attempt);
     }

   return(sent);
  }


Section 6: CBreakevenManager — Moving the Stop

The breakeven stop is set to entry + 1 * point for a long, not to the entry price itself, since a stop placed exactly at entry can still close at a small loss once spread is applied. A short mirrors this with entry - 1 * point. The move is applied with a TRADE_ACTION_SLTP request, the correct action for changing a stop loss without touching volume or price.

//+------------------------------------------------------------------+
//|                                             BreakevenManager.mqh |
//+------------------------------------------------------------------+
#ifndef BREAKEVENMANAGER_MQH
#define BREAKEVENMANAGER_MQH

//+------------------------------------------------------------------+
//| CBreakevenManager                                                |
//+------------------------------------------------------------------+
class CBreakevenManager
  {
public:
                     CBreakevenManager(void);
                    ~CBreakevenManager(void);

   bool              MoveToBreakeven(const ulong position_ticket,
                                     const string symbol,
                                     const long position_type,
                                     const double entry_price,
                                     const double point,
                                     const double current_tp);
  };

//+------------------------------------------------------------------+
//| Constructor                                                      |
//+------------------------------------------------------------------+
CBreakevenManager::CBreakevenManager(void)
  {
  }

//+------------------------------------------------------------------+
//| Destructor                                                       |
//+------------------------------------------------------------------+
CBreakevenManager::~CBreakevenManager(void)
  {
  }

MoveToBreakeven() reads the old stop before changing anything, computes the new stop for either direction, and passes the current take profit through unchanged since this method only ever has authority over the stop. Both the old and new stop values are logged together so a partial close's log entry is always followed by a clear record of what changed.

//+------------------------------------------------------------------+
//| MoveToBreakeven                                                  |
//+------------------------------------------------------------------+
bool CBreakevenManager::MoveToBreakeven(const ulong position_ticket,
                                        const string symbol,
                                        const long position_type,
                                        const double entry_price,
                                        const double point,
                                        const double current_tp)
  {
   if(!::PositionSelectByTicket(position_ticket))
     {
      ::PrintFormat("CBreakevenManager: position %I64u no longer exists, skipping breakeven move",
                    position_ticket);
      return(false);
     }

   double old_sl = ::PositionGetDouble(POSITION_SL);

   double new_sl = (position_type == POSITION_TYPE_BUY)
                   ? entry_price + 1.0 * point
                   : entry_price - 1.0 * point;

   int digits = (int)::SymbolInfoInteger(symbol, SYMBOL_DIGITS);
   new_sl = ::NormalizeDouble(new_sl, digits);

   MqlTradeRequest request;
   MqlTradeResult  result;
   ::ZeroMemory(request);
   ::ZeroMemory(result);

   request.action   = TRADE_ACTION_SLTP;
   request.position = position_ticket;
   request.symbol   = symbol;
   request.sl       = new_sl;
   request.tp       = current_tp;

   bool send_ok = ::OrderSend(request, result);

   if(send_ok && (result.retcode == TRADE_RETCODE_DONE || result.retcode == TRADE_RETCODE_PLACED))
     {
      ::PrintFormat("CBreakevenManager: ticket=%I64u old_sl=%.5f new_sl=%.5f",
                    position_ticket, old_sl, new_sl);
      return(true);
     }

   ::PrintFormat("CBreakevenManager: failed to move ticket=%I64u to breakeven, "
                 "old_sl=%.5f attempted_sl=%.5f retcode=%d comment=%s",
                 position_ticket, old_sl, new_sl, result.retcode, result.comment);
   return(false);
  }


Section 7: CChartLevelDrawer — Visualizing the Ladder

Each ladder level is drawn as an OBJ_HLINE object, a horizontal line at the trigger price. Object names combine the position ticket and the R-multiple, in the form PCE_<ticket>_<R-multiple>R, which guarantees uniqueness across positions and lets RemoveAll() delete every line for a position with one prefix match.

//+------------------------------------------------------------------+
//|                                             ChartLevelDrawer.mqh |
//+------------------------------------------------------------------+
#ifndef CHARTLEVELDRAWER_MQH
#define CHARTLEVELDRAWER_MQH

//+------------------------------------------------------------------+
//| CChartLevelDrawer                                                |
//+------------------------------------------------------------------+
class CChartLevelDrawer
  {
public:
                     CChartLevelDrawer(void);
                    ~CChartLevelDrawer(void);

   string            DrawLevel(const long chart_id,
                               const ulong position_ticket,
                               const double r_multiple,
                               const double close_pct,
                               const double price,
                               const color line_color);
   void              RemoveLevel(const long chart_id, const string object_name);
   void              RemoveAll(const long chart_id, const ulong position_ticket);
   void              MarkHit(const long chart_id, const string object_name, const color hit_color);
   string            BuildPrefix(const ulong position_ticket) const;
  };

//+------------------------------------------------------------------+
//| Constructor                                                      |
//+------------------------------------------------------------------+
CChartLevelDrawer::CChartLevelDrawer(void)
  {
  }

//+------------------------------------------------------------------+
//| Destructor                                                       |
//+------------------------------------------------------------------+
CChartLevelDrawer::~CChartLevelDrawer(void)
  {
  }

//+------------------------------------------------------------------+
//| BuildPrefix                                                      |
//| Builds the common naming prefix shared by every object that      |
//| belongs to one position, used both for creation and cleanup.     |
//+------------------------------------------------------------------+
string CChartLevelDrawer::BuildPrefix(const ulong position_ticket) const
  {
   return(::StringFormat("PCE_%I64u_", position_ticket));
  }

DrawLevel() creates the line and sets its style, and returns the object name so the caller can store it for later cleanup. It deletes any object with the same name first, in case a stale line from a previous run was left behind.

//+------------------------------------------------------------------+
//| DrawLevel                                                        |
//+------------------------------------------------------------------+
string CChartLevelDrawer::DrawLevel(const long chart_id,
                                    const ulong position_ticket,
                                    const double r_multiple,
                                    const double close_pct,
                                    const double price,
                                    const color line_color)
  {
   string name = BuildPrefix(position_ticket) + ::StringFormat("%.1fR", r_multiple);

   ::ObjectDelete(chart_id, name);
   if(!::ObjectCreate(chart_id, name, OBJ_HLINE, 0, 0, price))
     {
      ::PrintFormat("CChartLevelDrawer: failed to create object %s, error=%d",
                    name, ::GetLastError());
      return("");
     }

   string label = ::StringFormat("%.1fR (%.0f%%)", r_multiple, close_pct);

   ::ObjectSetInteger(chart_id, name, OBJPROP_COLOR, line_color);
   ::ObjectSetInteger(chart_id, name, OBJPROP_STYLE, STYLE_DASH);
   ::ObjectSetInteger(chart_id, name, OBJPROP_WIDTH, 1);
   ::ObjectSetInteger(chart_id, name, OBJPROP_BACK, false);
   ::ObjectSetInteger(chart_id, name, OBJPROP_SELECTABLE, false);
   ::ObjectSetString(chart_id, name, OBJPROP_TEXT, label);

   return(name);
  }

RemoveLevel() and RemoveAll() handle cleanup at two different scopes: one object at a time, or every object belonging to a position at once. MarkHit() recolors a line in place once its level fires, leaving it on the chart as a visual record instead of deleting it.

//+------------------------------------------------------------------+
//| RemoveLevel                                                      |
//| Deletes a single named chart object.                             |
//+------------------------------------------------------------------+
void CChartLevelDrawer::RemoveLevel(const long chart_id, const string object_name)
  {
   if(object_name == "")
      return;
   ::ObjectDelete(chart_id, object_name);
  }

//+------------------------------------------------------------------+
//| RemoveAll                                                        |
//| Deletes every chart object created for the given position ticket |
//| by matching against the shared naming prefix.                    |
//+------------------------------------------------------------------+
void CChartLevelDrawer::RemoveAll(const long chart_id, const ulong position_ticket)
  {
   string prefix = BuildPrefix(position_ticket);
   ::ObjectsDeleteAll(chart_id, prefix);
  }

//+------------------------------------------------------------------+
//| MarkHit                                                          |
//| Changes a line's color to mark that its ladder level has been    |
//| hit, giving the chart a visual record of progress through the    |
//| ladder without needing to remove and redraw the line.            |
//+------------------------------------------------------------------+
void CChartLevelDrawer::MarkHit(const long chart_id, const string object_name, const color hit_color)
  {
   if(object_name == "")
      return;
   if(::ObjectFind(chart_id, object_name) < 0)
      return;
   ::ObjectSetInteger(chart_id, object_name, OBJPROP_COLOR, hit_color);
   ::ObjectSetInteger(chart_id, object_name, OBJPROP_STYLE, STYLE_SOLID);
  }


Section 8: CPartialCloseEngine — the Central Engine

CPartialCloseEngine owns one instance of every class covered so far and coordinates them through Register(), OnTick(), and Deregister(). It never calls OrderSend() or PositionModify() directly, since every trade action is delegated to CPartialCloseExecutor or CBreakevenManager.

//+------------------------------------------------------------------+
//|                                           PartialCloseEngine.mqh |
//+------------------------------------------------------------------+
#ifndef PARTIALCLOSEENGINE_MQH
#define PARTIALCLOSEENGINE_MQH

#include "LadderLevel.mqh"
#include "PositionRecord.mqh"
#include "VolumeNormalizer.mqh"
#include "PartialCloseExecutor.mqh"
#include "BreakevenManager.mqh"
#include "ChartLevelDrawer.mqh"

#define PCE_MAX_POSITIONS 32

//+------------------------------------------------------------------+
//| CPartialCloseEngine                                              |
//+------------------------------------------------------------------+
class CPartialCloseEngine
  {
private:
   long                    m_chart_id;
   CVolumeNormalizer       m_normalizer;
   CPartialCloseExecutor   m_executor;
   CBreakevenManager       m_breakeven;
   CChartLevelDrawer       m_drawer;
   CPositionRecord         m_positions[PCE_MAX_POSITIONS];
   int                     m_position_count;

   int               FindPositionIndex(const ulong ticket) const;
   double            TriggerPrice(const CPositionRecord &record, const double r_multiple) const;
   bool              LevelReached(const CPositionRecord &record,
                                  const double trigger_price,
                                  const double current_price) const;

public:
                     CPartialCloseEngine(void);
                    ~CPartialCloseEngine(void);

   void              SetChartId(const long chart_id) { m_chart_id = chart_id; }

   bool              Register(const ulong ticket,
                              const CLadderLevel &levels[],
                              const int level_count);
   void              Deregister(const ulong ticket);
   void              OnTick(void);
   string            GetStatus(void);
   int               PositionCount(void) const { return(m_position_count); }
  };

//+------------------------------------------------------------------+
//| Constructor                                                      |
//+------------------------------------------------------------------+
CPartialCloseEngine::CPartialCloseEngine(void)
  {
   m_chart_id       = 0;
   m_position_count = 0;
  }

//+------------------------------------------------------------------+
//| Destructor                                                       |
//+------------------------------------------------------------------+
CPartialCloseEngine::~CPartialCloseEngine(void)
  {
  }

FindPositionIndex() is a simple linear search used internally by Deregister(). TriggerPrice() and LevelReached() are the two small helpers that turn a stored R-multiple into a real price and check whether the current market has reached it, mirrored correctly for longs and shorts.

//+------------------------------------------------------------------+
//| FindPositionIndex                                                |
//+------------------------------------------------------------------+
int CPartialCloseEngine::FindPositionIndex(const ulong ticket) const
  {
   for(int i = 0; i < m_position_count; i++)
     {
      if(m_positions[i].Ticket() == ticket)
         return(i);
     }
   return(-1);
  }

//+------------------------------------------------------------------+
//| TriggerPrice                                                     |
//+------------------------------------------------------------------+
double CPartialCloseEngine::TriggerPrice(const CPositionRecord &record, const double r_multiple) const
  {
   if(record.Type() == POSITION_TYPE_BUY)
      return(record.OriginalEntryPrice() + r_multiple * record.OriginalR());
   return(record.OriginalEntryPrice() - r_multiple * record.OriginalR());
  }

//+------------------------------------------------------------------+
//| LevelReached                                                     |
//+------------------------------------------------------------------+
bool CPartialCloseEngine::LevelReached(const CPositionRecord &record,
                                       const double trigger_price,
                                       const double current_price) const
  {
   if(record.Type() == POSITION_TYPE_BUY)
      return(current_price >= trigger_price);
   return(current_price <= trigger_price);
  }

Register() reads a position's live state for the first and only time. It rejects registration outright if the stop loss is zero or produces a non-positive R, then stores the record and draws one chart line per ladder level.

//+------------------------------------------------------------------+
//| Register                                                         |
//+------------------------------------------------------------------+
bool CPartialCloseEngine::Register(const ulong ticket,
                                   const CLadderLevel &levels[],
                                   const int level_count)
  {
   if(m_position_count >= PCE_MAX_POSITIONS)
     {
      ::PrintFormat("CPartialCloseEngine: cannot register ticket=%I64u, position table full",
                    ticket);
      return(false);
     }

   if(!::PositionSelectByTicket(ticket))
     {
      ::PrintFormat("CPartialCloseEngine: cannot register ticket=%I64u, position not found",
                    ticket);
      return(false);
     }

   string symbol         = ::PositionGetString(POSITION_SYMBOL);
   long   type           = ::PositionGetInteger(POSITION_TYPE);
   double volume         = ::PositionGetDouble(POSITION_VOLUME);
   double entry_price    = ::PositionGetDouble(POSITION_PRICE_OPEN);
   double sl              = ::PositionGetDouble(POSITION_SL);

   if(sl <= 0.0)
     {
      ::PrintFormat("CPartialCloseEngine: ticket=%I64u has no stop loss, cannot derive R, "
                    "registration refused", ticket);
      return(false);
     }

   double r = (type == POSITION_TYPE_BUY) ? (entry_price - sl) : (sl - entry_price);
   if(r <= 0.0)
     {
      ::PrintFormat("CPartialCloseEngine: ticket=%I64u has a nonsensical stop (r<=0), "
                    "registration refused", ticket);
      return(false);
     }

   int index = m_position_count;
   m_positions[index].Init(ticket, symbol, type, volume, entry_price, sl, r);

   for(int i = 0; i < level_count; i++)
      m_positions[index].AddLevel(levels[i]);

   m_position_count++;

//--- draw one dashed line per ladder level
   for(int i = 0; i < m_positions[index].LevelCount(); i++)
     {
      CLadderLevel *level = m_positions[index].Level(i);
      double trigger = TriggerPrice(m_positions[index], level.RMultiple());
      string obj_name = m_drawer.DrawLevel(m_chart_id, ticket, level.RMultiple(),
                                           level.ClosePct(), trigger, clrDodgerBlue);
      if(obj_name != "")
         m_positions[index].AddObjectName(obj_name);
     }

   ::PrintFormat("CPartialCloseEngine: registered ticket=%I64u symbol=%s type=%s "
                 "volume=%.2f entry=%.5f sl=%.5f R=%.5f levels=%d",
                 ticket, symbol, (type == POSITION_TYPE_BUY ? "buy" : "sell"),
                 volume, entry_price, sl, r, level_count);

   return(true);
  }

Deregister() removes a position's record and asks CChartLevelDrawer to delete every chart object tied to that ticket.

//+------------------------------------------------------------------+
//| Deregister                                                       |
//+------------------------------------------------------------------+
void CPartialCloseEngine::Deregister(const ulong ticket)
  {
   int index = FindPositionIndex(ticket);
   if(index < 0)
      return;

   m_drawer.RemoveAll(m_chart_id, ticket);

//--- compact the array by shifting everything after index left by one
   for(int i = index; i < m_position_count - 1; i++)
      m_positions[i] = m_positions[i + 1];

   m_position_count--;

   ::PrintFormat("CPartialCloseEngine: deregistered ticket=%I64u", ticket);
  }

OnTick() is where everything comes together. It walks every registered position, checks each unhit ladder level against the current price, and when a level triggers, computes the close volume from the original volume, clamps it, and sends it through the executor. Only a confirmed close marks the level as hit and, if the breakeven flag is set, calls the breakeven manager.

//+------------------------------------------------------------------+
//| OnTick                                                           |
//+------------------------------------------------------------------+
void CPartialCloseEngine::OnTick(void)
  {
   for(int i = m_position_count - 1; i >= 0; i--)
     {
      ulong ticket = m_positions[i].Ticket();

      if(!::PositionSelectByTicket(ticket))
        {
         //--- position closed outside the engine, clean up and move on
         Deregister(ticket);
         continue;
        }

      string symbol       = m_positions[i].Symbol();
      double current_price = (m_positions[i].Type() == POSITION_TYPE_BUY)
                             ? ::SymbolInfoDouble(symbol, SYMBOL_BID)
                             : ::SymbolInfoDouble(symbol, SYMBOL_ASK);
      double position_volume = ::PositionGetDouble(POSITION_VOLUME);

      for(int lvl = 0; lvl < m_positions[i].LevelCount(); lvl++)
        {
         CLadderLevel *level = m_positions[i].Level(lvl);
         if(level.Hit())
            continue;

         double trigger = TriggerPrice(m_positions[i], level.RMultiple());
         if(!LevelReached(m_positions[i], trigger, current_price))
            continue;

         //--- percentage always applies to the ORIGINAL volume, not the
         //--- current, already-reduced position volume
         double raw_close = m_positions[i].OriginalVolume() * level.ClosePct() / 100.0;
         double close_volume = m_normalizer.ClampClose(symbol, position_volume, raw_close);

         if(close_volume <= 0.0)
           {
            ::PrintFormat("CPartialCloseEngine: ticket=%I64u level=%.1fR produced no valid "
                          "close volume, marking hit to avoid repeated attempts",
                          ticket, level.RMultiple());
            level.SetHit(true);
            continue;
           }

         ::PrintFormat("CPartialCloseEngine: ticket=%I64u level=%.1fR triggered at price=%.5f "
                       "raw_close=%.2f normalized_close=%.2f",
                       ticket, level.RMultiple(), current_price, raw_close, close_volume);

         bool closed = m_executor.ExecutePartialClose(ticket, symbol,
                       m_positions[i].Type(), close_volume);

         if(!closed)
           {
            //--- leave the level un-hit so the engine retries on the next tick
            continue;
           }

         level.SetHit(true);

         //--- mark the chart line for this level as hit
         for(int obj = 0; obj < m_positions[i].ObjectCount(); obj++)
           {
            string name = m_positions[i].ObjectName(obj);
            if(::StringFind(name, ::StringFormat("%.1fR", level.RMultiple())) >= 0)
               m_drawer.MarkHit(m_chart_id, name, clrGray);
           }

         if(level.MoveToBreakeven())
           {
            double point = ::SymbolInfoDouble(symbol, SYMBOL_POINT);
            double current_tp = ::PositionGetDouble(POSITION_TP);
            m_breakeven.MoveToBreakeven(ticket, symbol, m_positions[i].Type(),
                                        m_positions[i].OriginalEntryPrice(), point, current_tp);
           }

         //--- refresh the live volume before evaluating the next level
         if(::PositionSelectByTicket(ticket))
            position_volume = ::PositionGetDouble(POSITION_VOLUME);
        }
     }
  }

GetStatus() reports how many ladder levels remain unhit for every registered position. It needs to call Level() on each CPositionRecord, and Level() returns a pointer rather than being a const method. That means GetStatus() cannot itself be declared const, since a const method treats its members as const and cannot call a non-const method on them.

//+------------------------------------------------------------------+
//| GetStatus                                                        |
//+------------------------------------------------------------------+
string CPartialCloseEngine::GetStatus(void)
  {
   string status = ::StringFormat("CPartialCloseEngine: %d position(s) registered\n",
                                  m_position_count);
   for(int i = 0; i < m_position_count; i++)
     {
      int remaining = 0;
      for(int lvl = 0; lvl < m_positions[i].LevelCount(); lvl++)
        {
         if(!m_positions[i].Level(lvl).Hit())
            remaining++;
        }
      status += ::StringFormat("  ticket=%I64u symbol=%s levels_remaining=%d/%d\n",
                               m_positions[i].Ticket(), m_positions[i].Symbol(),
                               remaining, m_positions[i].LevelCount());
     }
   return(status);
  }


Section 9: PartialCloseEA.mq5 — Integration Demo

The demo EA opens one long position and hands it to the engine with a three-level ladder. Inputs let you adjust the stop distance and lot size for your broker's symbol without touching the code.

//+------------------------------------------------------------------+
//|                                               PartialCloseEA.mq5 |
//+------------------------------------------------------------------+

#include <PartialCloseEngine/PartialCloseEngine.mqh>
#include <PartialCloseEngine/LadderLevel.mqh>

input double InpStopLossPoints = 500;      // Stop loss distance in points
input double InpLotSize        = 0.10;     // Entry lot size
input int    InpMagicNumber    = 20260609; // Magic Number

CPartialCloseEngine g_engine;
ulong               g_managed_ticket = 0;

OnInit() opens the demo position, then builds and registers a three-level ladder: 1R closing 50 percent with the breakeven flag set, 2R closing 25 percent, and 3R closing the final 25 percent.

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit(void)
  {
   g_engine.SetChartId(::ChartID());

   if(!OpenDemoPosition())
     {
      ::Print("PartialCloseEA: failed to open demo position, EA will idle");
      return(INIT_SUCCEEDED);
     }

   CLadderLevel levels[3];
   levels[0] = CLadderLevel(1.0, 50.0, true);
   levels[1] = CLadderLevel(2.0, 25.0, false);
   levels[2] = CLadderLevel(3.0, 25.0, false);

   if(g_engine.Register(g_managed_ticket, levels, 3))
     {
      ::PrintFormat("PartialCloseEA: registered ticket=%I64u with a three-level ladder "
                    "(1R/50%%/BE, 2R/25%%, 3R/25%%)", g_managed_ticket);
     }

   return(INIT_SUCCEEDED);
  }

SelectFillingMode() mirrors the same check used inside CPartialCloseExecutor, checking SYMBOL_FILLING_MODE before the EA's own opening order instead of assuming one fill type will work on every symbol.

//+------------------------------------------------------------------+
//| SelectFillingMode                                                |
//+------------------------------------------------------------------+
ENUM_ORDER_TYPE_FILLING SelectFillingMode(const string symbol)
  {
   long filling_flags = ::SymbolInfoInteger(symbol, SYMBOL_FILLING_MODE);

   if((filling_flags & SYMBOL_FILLING_FOK) != 0)
      return(ORDER_FILLING_FOK);
   if((filling_flags & SYMBOL_FILLING_IOC) != 0)
      return(ORDER_FILLING_IOC);

//--- neither FOK nor IOC advertised, fall back to Return, which is
//--- always accepted on exchange/market execution accounts
   return(ORDER_FILLING_RETURN);
  }

OpenDemoPosition() sends the opening market order and confirms the resulting position can be selected before handing its ticket off to the engine.

//+------------------------------------------------------------------+
//| OpenDemoPosition                                                 |
//+------------------------------------------------------------------+
bool OpenDemoPosition(void)
  {
   string symbol = ::Symbol();
   double point  = ::SymbolInfoDouble(symbol, SYMBOL_POINT);
   double ask    = ::SymbolInfoDouble(symbol, SYMBOL_ASK);
   int    digits = (int)::SymbolInfoInteger(symbol, SYMBOL_DIGITS);

   double sl = ::NormalizeDouble(ask - InpStopLossPoints * point, digits);

   MqlTradeRequest request;
   MqlTradeResult  result;
   ::ZeroMemory(request);
   ::ZeroMemory(result);

   request.action       = TRADE_ACTION_DEAL;
   request.symbol       = symbol;
   request.volume       = InpLotSize;
   request.type         = ORDER_TYPE_BUY;
   request.price        = ask;
   request.sl           = sl;
   request.tp           = 0.0;
   request.deviation    = 10;
   request.magic        = InpMagicNumber;
   request.type_filling = SelectFillingMode(symbol);

   if(!::OrderSend(request, result))
     {
      ::PrintFormat("PartialCloseEA: OrderSend failed, error=%d", ::GetLastError());
      return(false);
     }

   if(result.retcode != TRADE_RETCODE_DONE && result.retcode != TRADE_RETCODE_PLACED)
     {
      ::PrintFormat("PartialCloseEA: order rejected, retcode=%d comment=%s",
                    result.retcode, result.comment);
      return(false);
     }

   g_managed_ticket = result.order;

//--- the position ticket is the same as the deal's order ticket for a
//--- market fill; re-select to confirm before handing off to the engine
   if(!::PositionSelectByTicket(g_managed_ticket))
     {
      ::PrintFormat("PartialCloseEA: could not select new position, ticket=%I64u",
                    g_managed_ticket);
      return(false);
     }

   ::PrintFormat("PartialCloseEA: opened ticket=%I64u volume=%.2f entry=%.5f sl=%.5f",
                 g_managed_ticket, InpLotSize, ask, sl);
   return(true);
  }

OnTick() and OnDeinit() are both short by design, since all real decision-making lives inside the engine.

//+------------------------------------------------------------------+
//| Expert tick function                                             |
//+------------------------------------------------------------------+
void OnTick(void)
  {
   if(g_managed_ticket == 0)
      return;

   g_engine.OnTick();
  }

//+------------------------------------------------------------------+
//| Expert deinitialization function                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
   if(g_managed_ticket != 0)
      g_engine.Deregister(g_managed_ticket);
  }

Mock-up chart view of the demo position

Chart view of the demo position. EURUSD, H1, showing a fresh long entry at 1.13717 (dotted line) with the three ladder lines drawn immediately at registration: 1.0R at 50 percent, 2.0R at 25 percent, and 3.0R at 25 percent. Price has not yet reached the first level, so all three lines remain in their unhit color.


Live chart output from PartialCloseEA on XAUUSD, H1

Live chart output from PartialCloseEA on XAUUSD, H1. The teal line marks the entry at 4087.21. The three dashed lines above show the registered ladder levels at 4091.20, 4096.20, and 4101.20. Price has pulled back after opening and has not yet reached the first target, so all three levels remain unhit.


Section 10: Verification — TestPartialCloseEngine.mq5

MQL5 has no native assert, so the script defines a small PCE_ASSERT macro backed by a TestAssert() function that logs PASS or FAIL and keeps a running count.

//+------------------------------------------------------------------+
//|                                      TestPartialCloseEngine.mq5  |
//+------------------------------------------------------------------+

#property script_show_inputs

#include <PartialCloseEngine/VolumeNormalizer.mqh>
#include <PartialCloseEngine/LadderLevel.mqh>

int g_pass_count = 0;
int g_fail_count = 0;

//+------------------------------------------------------------------+
//| ASSERT macro replacement, since MQL5 has no native assert.       |
//+------------------------------------------------------------------+
#define PCE_ASSERT(condition, message) TestAssert((condition), (message))

//+------------------------------------------------------------------+
//| TestAssert                                                       |
//+------------------------------------------------------------------+
void TestAssert(const bool condition, const string message)
  {
   if(condition)
     {
      g_pass_count++;
      ::PrintFormat("PASS: %s", message);
     }
   else
     {
      g_fail_count++;
      ::PrintFormat("FAIL: %s", message);
     }
  }

//+------------------------------------------------------------------+
//| Script program start function                                    |
//+------------------------------------------------------------------+
void OnStart(void)
  {
   ::Print("=== TestPartialCloseEngine starting ===");

   TestVolumeNormalization();
   TestRMultipleComputation();
   TestLadderTriggering();
   TestBreakevenComputation();
   TestRemainderClamping();

   ::PrintFormat("=== TestPartialCloseEngine finished: %d passed, %d failed ===",
                 g_pass_count, g_fail_count);
  }

TestVolumeNormalization() checks the five inputs validated before this article was written. The 0.005 lot case needs a note: it divides out to exactly 0.5, and MQL5's MathRound() rounds an exact half away from zero, so the correct expected result is 0.01, not 0.00.

//+------------------------------------------------------------------+
//| TestVolumeNormalization                                          |
//+------------------------------------------------------------------+
void TestVolumeNormalization(void)
  {
   ::Print("--- TestVolumeNormalization ---");

   double result_1 = ::MathRound(0.123 / 0.01) * 0.01;
   PCE_ASSERT(::MathAbs(result_1 - 0.12) < 0.0001, "0.123 lots at 0.01 step normalizes to 0.12");

   double result_2 = ::MathRound(0.155 / 0.10) * 0.10;
   PCE_ASSERT(::MathAbs(result_2 - 0.20) < 0.0001, "0.155 lots at 0.10 step normalizes to 0.20");

   double result_3 = ::MathRound(0.07 / 0.01) * 0.01;
   PCE_ASSERT(::MathAbs(result_3 - 0.07) < 0.0001, "0.07 lots at 0.01 step normalizes to 0.07 unchanged");

//--- 0.005 / 0.01 = 0.5 exactly, and MQL5's MathRound() rounds half
//--- away from zero (unlike Python's round-half-to-even), so this
//--- rounds UP to 0.01, not down to 0.00. 0.01 sits exactly at a
//--- typical SYMBOL_VOLUME_MIN, so it is valid, not below minimum.
   double result_4 = ::MathRound(0.005 / 0.01) * 0.01;
   PCE_ASSERT(::MathAbs(result_4 - 0.01) < 0.0001, "0.005 lots at 0.01 step normalizes to 0.01 (round half away from zero)");

   double result_5 = ::MathRound(1.0 / 0.01) * 0.01;
   PCE_ASSERT(::MathAbs(result_5 - 1.00) < 0.0001, "1.0 lots at 0.01 step normalizes to 1.00 unchanged");
  }

TestRMultipleComputation() checks the trigger price formula against an entry of 1.1000 and a stop of 1.0950, confirming R comes out to 50 pips and the 1R, 2R, and 3R prices land where they should.

//+------------------------------------------------------------------+
//| TestRMultipleComputation                                         |
//+------------------------------------------------------------------+
void TestRMultipleComputation(void)
  {
   ::Print("--- TestRMultipleComputation ---");

   double entry = 1.1000;
   double sl    = 1.0950;
   double r     = entry - sl;

   PCE_ASSERT(::MathAbs(r - 0.0050) < 0.00001, "R correctly computed as 0.0050 (50 pips)");

   double trigger_1r = entry + 1.0 * r;
   double trigger_2r = entry + 2.0 * r;
   double trigger_3r = entry + 3.0 * r;

   PCE_ASSERT(::MathAbs(trigger_1r - 1.1050) < 0.00001, "1R trigger price is 1.1050");
   PCE_ASSERT(::MathAbs(trigger_2r - 1.1100) < 0.00001, "2R trigger price is 1.1100");
   PCE_ASSERT(::MathAbs(trigger_3r - 1.1150) < 0.00001, "3R trigger price is 1.1150");
  }

TestLadderTriggering() constructs a CLadderLevel directly and confirms its fields, its hit tracking, and the exact price at which it should trigger.

//+------------------------------------------------------------------+
//| TestLadderTriggering                                             |
//+------------------------------------------------------------------+
void TestLadderTriggering(void)
  {
   ::Print("--- TestLadderTriggering ---");

   CLadderLevel level(1.0, 50.0, true);
   PCE_ASSERT(!level.Hit(), "Newly constructed ladder level starts unhit");
   PCE_ASSERT(::MathAbs(level.RMultiple() - 1.0) < 0.0001, "Ladder level stores its R-multiple correctly");
   PCE_ASSERT(::MathAbs(level.ClosePct() - 50.0) < 0.0001, "Ladder level stores its close percentage correctly");
   PCE_ASSERT(level.MoveToBreakeven(), "Ladder level stores its breakeven flag correctly");

   level.SetHit(true);
   PCE_ASSERT(level.Hit(), "Ladder level correctly reports hit after SetHit(true)");

   double entry   = 1.1000;
   double r       = 0.0050;
   double trigger = entry + 1.0 * r;
   double price_before = 1.1049;
   double price_at     = 1.1050;

   PCE_ASSERT(price_before < trigger, "Price just below trigger does not yet reach 1R");
   PCE_ASSERT(price_at >= trigger, "Price at the exact trigger reaches 1R");
  }

TestBreakevenComputation() checks the breakeven formula for both a long and a short.

//+------------------------------------------------------------------+
//| TestBreakevenComputation                                         |
//+------------------------------------------------------------------+
void TestBreakevenComputation(void)
  {
   ::Print("--- TestBreakevenComputation ---");

   double entry = 1.1000;
   double point = 0.00001;
   double be    = entry + 1.0 * point;

   PCE_ASSERT(::MathAbs(be - 1.10001) < 0.000001, "Breakeven price for a long is entry + 1 point (1.10001)");

   double short_entry = 1.1000;
   double short_be     = short_entry - 1.0 * point;
   PCE_ASSERT(::MathAbs(short_be - 1.09999) < 0.000001, "Breakeven price for a short is entry - 1 point (1.09999)");
  }

TestRemainderClamping() checks the 0.04 lot, 75 percent close case: the correct clamp reduces this to 0.03 lots, leaving a valid 0.01 remainder instead of the naive 0.04 that would strand the position at 0.00.

//+------------------------------------------------------------------+
//| TestRemainderClamping                                            |
//+------------------------------------------------------------------+
void TestRemainderClamping(void)
  {
   ::Print("--- TestRemainderClamping ---");

   double position_volume = 0.04;
   double lot_step        = 0.01;
   double min_lot         = 0.01;
   double close_pct       = 75.0;

   double raw_close        = position_volume * close_pct / 100.0;
   double normalized_close = ::MathRound(raw_close / lot_step) * lot_step;
   double remainder        = position_volume - normalized_close;

   PCE_ASSERT(::MathAbs(normalized_close - 0.03) < 0.0001,
              "75% of 0.04 lots normalizes to 0.03 before any clamping");
   PCE_ASSERT(::MathAbs(remainder - 0.01) < 0.0001,
              "0.03 close leaves a valid 0.01 remainder, no clamp adjustment needed");

//--- now simulate the invalid case: forcing a full 0.04 close would
//--- leave 0.00, which is not a real remainder but a stranded position
   double invalid_close     = 0.04;
   double invalid_remainder = position_volume - invalid_close;
   PCE_ASSERT(invalid_remainder < min_lot,
              "A 0.04 close on a 0.04 position leaves 0.00, correctly identified as invalid");
   PCE_ASSERT(normalized_close < invalid_close,
              "Clamped close (0.03) is correctly smaller than the naive full close (0.04)");
  }


Section 11: Extending the Engine

A trailing stop that only activates once every ladder level has fired fits naturally as a new method on CPartialCloseEngine, checked after the existing ladder loop in OnTick(). Supporting many positions with independent ladders already works as written, since each position's ladder lives separately inside m_positions, though raising PCE_MAX_POSITIONS and using a dynamic array would remove the current fixed ceiling. A time-based ladder level that fires after a fixed number of hours would need CPositionRecord to also store the position's open time, checked against TimeCurrent(). Persisting ladder state across terminal restarts is possible with GlobalVariableSet() and GlobalVariableGet(), encoding each position's hit flags as a bitmask keyed by ticket.


Section 12: Limitations

The engine cannot partially close a position on a broker whose trade server does not allow volume reduction on a TRADE_ACTION_DEAL request against an existing position. Some brokers running strict netting or FIFO rules reject this pattern outright, and no amount of retrying will change that.

The R-multiple calculation assumes the original stop loss is a meaningful, intentional risk boundary. A stop loss set to zero is refused at registration, but a stop that is technically non-zero yet placed arbitrarily close to entry still produces a small or negative R, and every ladder level built on that R triggers at a nonsensical price.

The breakeven move does not account for spread on spread-sensitive instruments. One point past entry is enough on most forex majors, but instruments with wide or volatile spreads may need that constant widened.

The chart objects created by CChartLevelDrawer exist only for the current session. They are not saved with the chart template, so a position that survives a terminal restart keeps trading correctly through the engine's internal state but loses its visual ladder lines until the EA registers it again.


Conclusion

This article built a complete CPartialCloseEngine across nine files: a ladder level struct, a position record struct, a volume normalizer, a partial close executor, a breakeven manager, a chart line drawer, the central engine that coordinates all of them, a demo Expert Advisor, and a verification script. The engine guarantees that every close volume it sends is rounded to the broker's lot step, that a close never strands a remainder below the minimum tradeable size, and that every ladder percentage is computed against the trade's original entry volume rather than a volume that has already shrunk. It also picks a supported filling mode before every order instead of assuming one will always work. What it does not cover is broker-specific rejection of volume-reduction requests, a stop loss placed without real intent, spread behavior on unusual instruments, or chart state surviving a terminal restart.


Programs used in the article:

# Name Type Description
1 LadderLevel.mqh Include File CLadderLevel struct holding one profit ladder level
2 PositionRecord.mqh Include File CPositionRecord struct holding all frozen, per-position engine state
3 VolumeNormalizer.mqh Include File CVolumeNormalizer class for lot-step rounding and remainder-safe clamping
4 PartialCloseExecutor.mqh Include File CPartialCloseExecutor class that sends the reduced-volume closing order
5 BreakevenManager.mqh Include File CBreakevenManager class that moves the stop to breakeven
6 ChartLevelDrawer.mqh Include File CChartLevelDrawer class that draws and removes ladder lines
7 PartialCloseEngine.mqh Include File CPartialCloseEngine class, the public coordinator of every sub-component
8 PartialCloseEA.mq5 Demo EA Demo EA opening one position with a three-level ladder
9 TestPartialCloseEngine.mq5 Script Verification script covering all core formulas with assertions
10 PartialCloseEngine.zip Zip Archive Zip archive containing all the attached files and their paths relative to the terminal's root folder.


Last comments | Go to discussion (1)
Syed Jawad Hussain Naqvi
Syed Jawad Hussain Naqvi | 24 Aug 2026 at 15:11
hi  Kevin  the first flow chart is not clear enough 
Online Machine Learning for Trade Signal Filtering in MQL5 (Part 1) Online Machine Learning for Trade Signal Filtering in MQL5 (Part 1)
This article implements an online logistic‑regression trade filter in native MQL5 and integrates it into an EMA‑crossover EA with a closed‑trade feedback loop. It details the shared class, features, SGD update, persistence, and a read‑only probability view. Synthetic experiments cover multi‑seed separation, calibration, feature ablation, regime‑shift baselines, and hyperparameter sweeps. You get reproducible scripts and a walk‑forward protocol to validate the filter on your own instrument.
How To Debug MQL5 Code in MetaEditor How To Debug MQL5 Code in MetaEditor
This article is a practical walk-through of the MetaEditor debugger using a rolling z‑score indicator with two planted bugs: an off‑by‑one array access and a silent wrong‑denominator variance. We show how to set breakpoints, step through code, read the call stack, and inspect values in the Watch window. You will learn a repeatable method to catch both crashing index errors and tiny numerical biases that charts cannot reveal.
Feature Engineering for ML (Part 14): Trend-Scanning Features in MQL5 Feature Engineering for ML (Part 14): Trend-Scanning Features in MQL5
A naive MQL5 port of trend-scanning features recomputes each candidate window per bar at O(H·L) cost. This article introduces CTrendScanningFeatures.mqh, which maintains three running sums per horizon and updates them in O(1) per bar, verified against a Python reference. The indicator exposes four causal buffers - window, slope, t_value, rsquared - at the confirmation bar and corrects a sign inversion present in the original backward labeling mode.
Developing Smart Chart Objects in MQL5 (Part 1): Building a Stateful Trendline Management Framework Developing Smart Chart Objects in MQL5 (Part 1): Building a Stateful Trendline Management Framework
This article details a practical framework for converting MetaTrader 5 trendlines from static drawings into managed runtime entities. It covers object discovery, event-driven synchronization of user edits, and confirmation logic based on ATR multipliers and closed candles. A central manager coordinates multiple lines and updates their visual state. Readers can implement consistent, extensible rules for detecting proximity, validating bounces, and confirming breakouts.