preview
Building a Position Lifecycle Manager in MQL5 (Part 2): Managing Manual and Multi-Symbol Positions

Building a Position Lifecycle Manager in MQL5 (Part 2): Managing Manual and Multi-Symbol Positions

MetaTrader 5 — Examples |
90 0
Francis Nyoike Thumbi
Francis Nyoike Thumbi

Introduction

In Part 1, we built the foundation of the Position Lifecycle Manager around a clear separation of responsibilities. The MACD sample EA generated trading signals and opened positions, while the Position Manager handled protection and subsequent lifecycle management. Part 2 extends this architecture by moving order submission into the Position Manager.

This included protection, break-even management, lifecycle state and continuous monitoring. This phase focuses on extending that foundation to manual and multi-symbol position management.

While the MACD example continues to provide the trading context, broader testing exposed an important limitation. The manager was still using the chart symbol as part of its position discovery and management context. That works for a conventional single-symbol EA but limits the reusability of the component.

A position manager should follow the position it manages rather than the chart where the manager happens to be attached.

This becomes particularly important in Manual Mode. The manager can remain attached to one chart while a trader opens positions on other instruments. Those positions should be detected, adopted and managed without requiring another manager instance on each chart.

The same principle applies to EA Mode. Once a position has been identified as belonging to the configured trading system, its own symbol should determine the market data, price calculations, broker constraints and execution settings used throughout its lifecycle.

Part 2 therefore focuses on two extensions:

  • Manual position adoption
  • Multi-symbol position management

We continue building on the MACD example while making the lifecycle manager less dependent on the chart that hosts it. The goal is to make the managed position the reference for its entire lifecycle.

The resulting architecture can be summarized in the  following diagram.

Fig. 1. Position manager flow

Fig 1. Position Lifecycle Manager architecture for automatic and manual position management

The diagram highlights the central idea behind this extension: regardless of how a position enters the system, once adopted, the position and its symbol become the source of truth for management throughout its lifecycle.

New Inputs

Part 2 adds inputs for dual operating modes, risk-aware construction, adaptive protection and protection-failure handling.

//--- Part 2: New inputs - risk-aware construction, adaptive protection and dual modes
input ENUM_MANAGER_MODE    InpManagerMode              = MANAGER_MODE_EA;    // Manager Operating Mode
input bool   InpRespectExistingSLTP = true;                                  // Respect Existing SL/TP on Adopted Positions
input bool   InpUseRiskSizing       = true;                                  // Use Risk-Based Position Sizing
input double InpRiskPercent         = 1.0;                                   // Risk Percentage per Trade (%)
input ENUM_RISK_BASE InpRiskBase    = RISK_BASE_EQUITY;                      // Risk Base (Equity/Balance)
input double InpDefaultVolume       = 0.01;                                  // Default Volume (Risk Sizing Disabled)
input bool   InpUseATRTP            = false;                                 // Use ATR-Based Initial TP
input double InpATRTpMultiplier     = 1.5;                                   // ATR TP Multiplier
input double InpRiskRewardRatio     = 0.0;                                   // Risk-Reward Ratio: TP = SL x Ratio (0 = Off) Part 2 (new)
input ENUM_BE_TRIGGER_MODE InpBreakEvenTriggerMode = BE_TRIGGER_FIXED_PIPS;  // Break-Even Trigger Mode
input double InpBreakEvenATRMultiplier = 1.0;                                // Break-Even ATR Multiplier
//--- Part 2: optional protection retry escalation (0 = unlimited retries, current behavior)
input int    InpMaxProtectRetries      = 0;                                  // Max protection modify retries (0 = unlimited)
input bool   InpAlertOnProtectionFail  = false;                              // Alert when protection retries are exhausted

These inputs extend the manager without changing its core lifecycle model. They control how positions are adopted, sized, protected and recovered when protection requests fail. The first of these changes is the manager's operating mode. That leads directly to the next problem: what positions should the manager actually adopt?



From Chart-Centric to Position-Centric Management

The original implementation used the chart symbol during position discovery. If a position belonged to another symbol, the manager ignored it. This is acceptable for a conventional single-symbol EA but limits the reusability of the lifecycle manager. The manager should identify positions based on its operating mode and position-identification rules, not on the chart symbol.

The chart symbol therefore no longer defines the manager's scope.

   //--- main processing loop to scan positions and update lifecycles
   void              Process(void)
     {
      if(!InpEnablePositionManager || !m_is_initialized)
         return;

      //--- step 1: auto detect and register new positions if enabled
      if(InpAutoDetectPositions)
        {
         int total_positions = PositionsTotal();
         for(int i = total_positions - 1; i >= 0; i--)
           {
            ulong ticket = PositionGetTicket(i);
            if(ticket > 0)
              {
               //--- Part 2: adoption is magic-policy based, NOT chart-symbol based.
               //--- EA mode adopts any position carrying the manager magic.
               bool matches_magic = (PositionGetInteger(POSITION_MAGIC) == InpMagicNumber);
               bool adoptable = matches_magic;
               if(InpManagerMode == MANAGER_MODE_MANUAL)
                  adoptable = true;

               if(adoptable && !IsPositionTracked(ticket))
                 {
                  CManagedPosition *new_pos = new CManagedPosition();
                  if(new_pos != NULL)
                    {
                     //--- Part 2: Init interface extended with protection config and risk engine
                     new_pos.Init(ticket, InpMagicNumber, m_config, m_risk_engine);
                     m_managed_positions.Add(new_pos);
                    }
                 }
              }
           }
        }

      //--- step 2: update all active managed positions and clean up closed ones
      for(int i = m_managed_positions.Total() - 1; i >= 0; i--)
        {
         CManagedPosition *pos = dynamic_cast<CManagedPosition*>(m_managed_positions.At(i));
         if(pos != NULL)
           {
            //--- Part 2: Update signature simplified - config is stored internally
            pos.Update(m_risk_engine);

            //--- remove from active tracking if position has closed
            if(pos.GetState() == POSITION_CLOSED)
              {
               if(InpEnableLogging)
                  Print("Position closed, removing from manager, ticket: ", pos.GetTicket());
               m_managed_positions.Delete(i);
              }
           }
        }
     }
  };

Once a position is adopted, its own symbol becomes the reference for the remaining lifecycle operations.

EA Mode and Manual Mode

The manager supports two operating modes with different position-identification rules. In EA Mode, the configured magic number identifies positions belonging to the trading system. During initialization, the same magic number is assigned to the manager's trading client through m_trade.SetExpertMagicNumber(InpMagicNumber), ensuring that submitted orders use the configured identifier. The entry strategy generates trade intents, while the Position Manager handles order submission and the resulting position lifecycle.

In Manual Mode, the manager adopts all open account positions regardless of their origin or magic number, including positions opened by other EAs. Once adopted, these positions are subject to the configured lifecycle and protection rules, which may modify their existing Stop Loss or Take Profit.

enum ENUM_MANAGER_MODE
  {
   MANAGER_MODE_EA,     // EA mode - manager handles EA trade intents
   MANAGER_MODE_MANUAL  // Manual mode - manager adopts all account positions
  };

Once adopted, both modes use the same lifecycle management, including protection, break-even and monitoring. This allows the manager to remain attached to one chart while managing positions across different instruments.



The Position Becomes the Source of Truth

Once positions from other symbols can be adopted, the manager uses each position's symbol for market prices and symbol-specific trading properties. ATR calculations remain tied to PERIOD_CURRENT, so their timeframe follows the host chart.

The original Risk Engine created its ATR state from the chart symbol. This could cause the manager to use incorrect market data when managing a position on another instrument. The fix is to make the position symbol authoritative. ATR data is now maintained for the symbol requesting it.

   //--- find an existing ATR handle index for a symbol
   int               FindATRIndex(const string symbol)
     {
      for(int i = 0; i < ArraySize(m_atr_symbols); i++)
        {
         if(m_atr_symbols[i] == symbol)
            return i;
        }
      return -1;
     }
   //--- Part 2: current ATR value for the position's own symbol (used for entry ATR context capture and ATR-relative break-even trigger distance)
   double            ATRValue(const string symbol)
     {
      if(!m_use_atr && !m_use_atr_tp)
         return 0.0;
      int handle = EnsureATR(symbol);
      if(handle == INVALID_HANDLE)
         return 0.0;
      double atr_values[];
      ArraySetAsSeries(atr_values, true);
      if(CopyBuffer(handle, 0, 0, 1, atr_values) <= 0)
         return 0.0;
      return atr_values[0];
     }

The same principle extends to price precision, tick size, volume rules and broker constraints. Instruments with different specifications are handled through their own symbol properties rather than through chart-specific assumptions.



Making Protection Instrument-Aware

Protection is where instrument differences become operational. Stop distances, break-even offsets, price precision, tick size, StopsLevel and FreezeLevel all depend on the symbol being managed. A value that is valid for one instrument may be invalid or meaningless for another.

The manager therefore derives these properties from the managed position's symbol.

//--- Convert pips to price value based on symbol point and digits
double PipsToPrice(const string symbol, const double pips)
  {
   double point = SymbolInfoDouble(symbol, SYMBOL_POINT);
   int digits = (int)SymbolInfoInteger(symbol, SYMBOL_DIGITS);

//--- Adjust for 3 or 5 digit brokers if needed, standardizing via point
   double adjusted_point = point;
   if(digits == 3 || digits == 5)
      adjusted_point = point * 10.0;

   return pips * adjusted_point;
  }
//--- Part 2 round a stop price onto the symbol tick grid in the direction that is safe for the position side (BUY -> floor, SELL -> ceil)
double NormalizeStopPriceTick(const string symbol, const ENUM_POSITION_TYPE pos_type, const double price)
  {
   double tick_size = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_SIZE);
   if(tick_size <= 0.0)
      tick_size = SymbolInfoDouble(symbol, SYMBOL_POINT);
   if(tick_size <= 0.0)
      return NormalizePrice(symbol, price);

   double ticks = price / tick_size;
   double aligned = (pos_type == POSITION_TYPE_BUY) ? MathFloor(ticks) * tick_size
                    : MathCeil(ticks) * tick_size;
   return NormalizeDouble(aligned, (int)SymbolInfoInteger(symbol, SYMBOL_DIGITS));
  }

Rather than adding symbol-specific exceptions, the manager queries the symbol and applies its trading specifications. Price normalization differs between protection stages: initial Stop Loss and Take Profit calculations use the symbol's decimal precision, while break-even Stop Loss modifications are aligned to its tick grid. The same principle applies to trade execution, where filling mode and deviation are resolved from the managed symbol rather than the chart.

//--- Part 2: per-symbol deviation (in points) for the trading client, derived from the symbol's own digits (no longer chart-bound).
int DeviationPointsForSymbol(const string symbol)
  {
   int digits_adjust = 1;
   int symbol_digits = (int)SymbolInfoInteger(symbol, SYMBOL_DIGITS);
   if(symbol_digits == 3 || symbol_digits == 5)
      digits_adjust = 10;
   return 3 * digits_adjust;
  }

The result is a consistent flow:

Managed Position → Position Symbol → Market Properties → Protection and Execution

The position determines the environment in which its lifecycle is managed.



Keeping Risk Sizing Honest

Symbol independence also exposes a limitation in risk-based sizing. Accepting a symbol as an input does not mean the underlying pricing model supports every instrument. The sizing engine uses a linear tick-value calculation and checks that the required tick size, tick value, and Stop Loss distance are positive before calculating volume. These checks establish the availability of its inputs but do not independently verify the model's accuracy for every instrument.

   //--- Part 2: Estimate deposit-currency risk per lot using a linear tick-value model. Returns -1.0 for invalid calculation inputs.

   double              RiskMoneyPerLot(const string symbol, const double sl_distance_price)
     {
      double tick_size = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_SIZE);
      double tick_value = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_VALUE);
      if(tick_size <= 0.0 || tick_value <= 0.0 || sl_distance_price <= 0.0)
         return -1.0;
      return (sl_distance_price / tick_size) * tick_value;
     }
   //--- Part 2: Compute risk-based volume, normalized to broker constraints. The minimum lot may exceed the requested risk budget.
   //--- Returns -1.0 for invalid calculation inputs.
   double              CalculateVolume(const string symbol, const double sl_distance_price, const double risk_percent, const ENUM_RISK_BASE risk_base)
     {
      if(risk_percent <= 0.0 || sl_distance_price <= 0.0)
         return 0.0;
      double account_base = AccountRiskBase(risk_base);
      if(account_base <= 0.0)
         return 0.0;
      double risk_money = account_base * risk_percent / 100.0;
      double per_lot = RiskMoneyPerLot(symbol, sl_distance_price);
      if(per_lot < 0.0)
         return -1.0; // unsupported pricing model - do not guess
      if(per_lot <= 0.0)
         return 0.0;
      double volume = risk_money / per_lot;
      return NormalizeVolume(symbol, volume);
     }
Before execution, the sizing engine performs a preliminary margin check using the symbol's reported initial margin per lot and the account's free margin. This simplified check does not guarantee that the broker will accept the trade.
   //--- Part 2: check that the account can afford the position margin before execution. SYMBOL_MARGIN_INITIAL is the margin per one lot in the deposit
   //--- currency. When the symbol does not report margin we do not block.
   bool                HasSufficientMargin(const string symbol, const double volume)
     {
      if(volume <= 0.0)
         return false;
      double margin_per_lot = SymbolInfoDouble(symbol, SYMBOL_MARGIN_INITIAL);
      double margin_free = AccountInfoDouble(ACCOUNT_MARGIN_FREE);
      if(margin_per_lot <= 0.0)
         return true; // margin not reported for the symbol - do not block
      if(margin_free <= 0.0)
         return false;
      return (margin_per_lot * volume) <= margin_free + 0.005;
     }

The sizing engine rejects invalid tick-value inputs, while unavailable risk-sizing results can trigger the configured default-volume fallback. When ATR protection is disabled, no initial Stop Loss distance is calculated, so the trade-intent path uses default volume even if risk sizing is enabled.

The distinction between rejected calculations and fallback execution must remain explicit.



The Execution Path: From Intent to Protected Position

Risk sizing and margin checks precede order submission. The manager includes calculated Stop Loss and Take Profit values in the opening request when available and enabled; either value may be zero when the corresponding protection is not configured or cannot be calculated.

The trade intent provides direction and entry context. The manager calculates volume, Stop Loss, and Take Profit, then submits the position request. This keeps the execution path deliberate. Before sending the order to the broker, the manager validates trade permissions, existing positions, margin availability, and protection constraints. The execution path logs several rejection conditions when logging is enabled, including trade-permission failures, existing positions, invalid volume, insufficient margin, and invalid protection distances. Some early-return conditions do not produce a separate diagnostic.
   //--- Every execution decision is derived from the intent symbol, never the chart.
   bool              SubmitTradeIntent(const string symbol, const ENUM_ORDER_TYPE order_type, const double tp_price = 0.0)
     {
      if(!InpEnablePositionManager || !m_is_initialized)
         return false;
      if(InpManagerMode != MANAGER_MODE_EA)
        {
         if(InpEnableLogging)
            Print("SubmitTradeIntent rejected: manager is in MANUAL mode (no trade generation).");
         return false;
        }
      if(order_type != ORDER_TYPE_BUY && order_type != ORDER_TYPE_SELL)
         return false;

      //--- Part 2: broker trade permissions for the intent symbol (per symbol)
      if(!PositionManagerUtils::IsTradeModeOpenAllowed(symbol, order_type))
        {
         if(InpEnableLogging)
            Print("SubmitTradeIntent rejected: trade mode does not allow ", EnumToString(order_type), " on ", symbol);
         return false;
        }

      //--- safety: reject if a managed position already exists on the symbol
      if(PositionExists(symbol))
        {
         if(InpEnableLogging)
            Print("SubmitTradeIntent rejected: position already open on ", symbol);
         return false;
        }

      //--- reference price for market execution
      ENUM_POSITION_TYPE pos_type = (order_type == ORDER_TYPE_BUY) ? POSITION_TYPE_BUY : POSITION_TYPE_SELL;
      double ref_price = (pos_type == POSITION_TYPE_BUY) ? SymbolInfoDouble(symbol, SYMBOL_ASK) : SymbolInfoDouble(symbol, SYMBOL_BID);
      if(ref_price <= 0.0)
         return false;

      //--- Part 2: ATR-based initial SL (existing Part 1 calculation reused)
      double sl = 0.0;
      double sl_distance = 0.0;
      if(InpUseATRProtection)
        {
         sl = m_risk_engine.CalculateInitialStopLoss(symbol, pos_type, ref_price);
         sl_distance = MathAbs(ref_price - sl);
        }

      //--- Part 2: risk-based or default volume
      double volume = 0.0;
      if(InpUseRiskSizing)
        {
         if(sl_distance > 0.0)
            volume = m_sizing_engine.CalculateVolume(symbol, sl_distance, InpRiskPercent, InpRiskBase);
         if(volume < 0.0)
           {
            //--- Part 2: the sizing model cannot serve this symbol - refuse rather than guess
            if(InpEnableLogging)
               Print("SubmitTradeIntent rejected: risk sizing unsupported for ", symbol, " (tick geometry)");
            return false;
           }
         if(volume <= 0.0)
           {
            //--- risk sizing requires a usable SL distance - fall back to default volume
            if(InpEnableLogging)
               Print("Risk sizing unavailable (SL distance=", sl_distance, ") - using default volume.");
            volume = InpDefaultVolume;
           }
        }
      else
         volume = InpDefaultVolume;

      volume = m_sizing_engine.NormalizeVolume(symbol, volume);
      if(volume <= 0.0)
        {
         if(InpEnableLogging)
            Print("SubmitTradeIntent rejected: invalid volume for ", symbol);
         return false;
        }

      //--- Part 2: account margin guard before execution (per intent symbol)
      if(!m_sizing_engine.HasSufficientMargin(symbol, volume))
        {
         if(InpEnableLogging)
            Print("SubmitTradeIntent rejected: insufficient free margin for ", volume, " lots on ", symbol);
         return false;
        }

      //--- Part 2: TP ownership - ATR TP wins when enabled, otherwise EA-supplied TP
      double tp = tp_price;
      if(InpUseATRTP)
         tp = m_risk_engine.CalculateInitialTakeProfit(symbol, pos_type, ref_price, InpATRTpMultiplier);

      //--- Part 2 (new): user R:R ratio TP - derived from the ATR SL distance (overrides ATR/EA TP)
      if(InpRiskRewardRatio > 0.0 && sl > 0.0)
        {
         double rr_distance = sl_distance * InpRiskRewardRatio;
         tp = (pos_type == POSITION_TYPE_BUY) ? ref_price + rr_distance : ref_price - rr_distance;
         tp = PositionManagerUtils::NormalizePrice(symbol, tp);
        }

      //--- Part 2: broker stop-distance validation before execution
      if(sl > 0.0 && !PositionManagerUtils::IsValidStopDistance(symbol, sl, ref_price))
        {
         if(InpEnableLogging)
            Print("SubmitTradeIntent rejected: ATR SL too close to StopsLevel on ", symbol);
         return false;
        }
      if(tp > 0.0 && !PositionManagerUtils::IsValidStopDistance(symbol, tp, ref_price))
        {
         if(InpEnableLogging)
            Print("SubmitTradeIntent rejected: TP too close to StopsLevel on ", symbol);
         return false;
        }

      //--- Part 2: resolve execution properties from the intent symbol, never the chart symbol
      m_trade.SetTypeFillingBySymbol(symbol);
      m_trade.SetDeviationInPoints(PositionManagerUtils::DeviationPointsForSymbol(symbol));

      //--- Part 2: risk-aware position construction - execute with protection embedded.
      if(m_trade.PositionOpen(symbol, order_type, volume, ref_price, sl, tp))
        {
         if(InpEnableLogging)
            Print("EA intent executed: ", EnumToString(order_type), " ", volume, " lots on ", symbol,
                  " SL=", sl, " TP=", tp, " retcode=", m_trade.ResultRetcode());
         return true;
        }

      //--- Part 2: execution failure diagnostics
      if(InpEnableLogging)
         Print("EA intent execution FAILED: ", EnumToString(order_type), " ", volume, " lots on ", symbol,
               " retcode=", m_trade.ResultRetcode(), " (", m_trade.ResultRetcodeDescription(), ") ", m_trade.ResultComment());
      return false;
     }

The final request passes the available calculated protection directly to PositionOpen(). When initial protection is unavailable or not configured, the position may be opened without those levels and subsequently processed by the lifecycle manager. This reduces the gap between trade execution and protection while keeping the Position Manager responsible for the complete transition from trade intent to managed position.



Protection Is a Lifecycle, Not a Single Request

Testing across different symbol constraints exposes another important consideration. Initial protection may involve both Stop Loss and Take Profit, but the two operations serve different purposes.

The Stop Loss provides risk protection, while the Take Profit defines the intended exit target. If the Take Profit cannot currently be applied, that should not unnecessarily prevent a valid Stop Loss from protecting the position. The protection process handles Stop Loss and Take Profit sequentially, allowing a successful Stop Loss modification to remain in place even if the Take Profit phase requires another attempt. Both phases still share lifecycle state and retry handling.

   void              ApplyInitialProtection(CRiskEngine &risk_engine)
     {
      if(IsRetryLocked())
         return;
      if(EscalateIfExhausted())
         return;

      bool has_sl = (m_current_sl > 0.0);
      bool has_tp = (m_current_tp > 0.0);
      double point = SymbolInfoDouble(m_symbol, SYMBOL_POINT);
      bool sl_applied = false;
      bool tp_applied = false;

      //--- SL phase: independent of the TP outcome
      double desired_sl = m_current_sl;
      if(m_config.respect_existing_sltp && has_sl)
        {
         if(InpEnableLogging)
            Print("Existing SL respected for ticket: ", m_ticket, " SL=", m_current_sl);
        }
      else
         if(m_config.use_atr)
           {
            double atr_sl = risk_engine.CalculateInitialStopLoss(m_symbol, m_position_type, m_entry_price);
            if(atr_sl > 0.0)
              {
               //--- Part 2: broker stop-distance validation
               if(PositionManagerUtils::IsValidStopDistance(m_symbol, atr_sl, m_entry_price))
                  desired_sl = atr_sl;
               else
                 {
                  if(InpEnableLogging)
                     Print("ATR SL too close to StopsLevel for ticket: ", m_ticket);
                  LockRetry();
                  return;
                 }
              }
            else
              {
               //--- Part 2: ATR data unavailable - protection incomplete, retry later
               if(InpEnableLogging)
                  Print("ATR SL unavailable for ticket: ", m_ticket, " - retrying");
               LockRetry();
               return;
              }
           }

      //--- apply SL when it is not already in place
      if(desired_sl > 0.0 && MathAbs(desired_sl - m_current_sl) >= point)
        {
         if(!m_trade_client.PositionModify(m_ticket, desired_sl, m_current_tp))
           {
            if(InpEnableLogging)
               Print("SL modify FAILED for ticket: ", m_ticket, " retcode=", m_trade_client.ResultRetcode(),
                     " (", m_trade_client.ResultRetcodeDescription(), ") ", m_trade_client.ResultComment());
            LockRetry();
            return;
           }
         m_current_sl = desired_sl;
         sl_applied = true;
        }

      //--- TP phase: independent of the SL phase
      double desired_tp = m_current_tp;
      if(m_config.respect_existing_sltp && has_tp)
        {
         if(InpEnableLogging)
            Print("Existing TP detected for ticket: ", m_ticket, " TP=", m_current_tp);
        }
      else
         if(m_config.use_atr_tp)
           {
            double atr_tp = risk_engine.CalculateInitialTakeProfit(m_symbol, m_position_type, m_entry_price, m_config.atr_tp_multiplier);
            if(atr_tp > 0.0)
              {
               if(PositionManagerUtils::IsValidStopDistance(m_symbol, atr_tp, m_entry_price))
                  desired_tp = atr_tp;
               else
                 {
                  if(InpEnableLogging)
                     Print("ATR TP too close to StopsLevel for ticket: ", m_ticket);
                  LockRetry();
                  return;
                 }
              }
            else
              {
               if(InpEnableLogging)
                  Print("ATR TP unavailable for ticket: ", m_ticket, " - retrying");
               LockRetry();
               return;
              }
           }

      //--- Part 2 user R:R ratio TP - derived from the final SL distance
      if(m_config.rr_ratio > 0.0 && desired_sl > 0.0)
        {
         double rr_distance = MathAbs(m_entry_price - desired_sl) * m_config.rr_ratio;
         desired_tp = (m_position_type == POSITION_TYPE_BUY) ? m_entry_price + rr_distance : m_entry_price - rr_distance;
         desired_tp = PositionManagerUtils::NormalizePrice(m_symbol, desired_tp);
         if(!PositionManagerUtils::IsValidStopDistance(m_symbol, desired_tp, m_entry_price))
           {
            if(InpEnableLogging)
               Print("R:R TP too close to StopsLevel for ticket: ", m_ticket);
            LockRetry();
            return;
           }
        }

      //--- apply TP when it is not already in place
      if(desired_tp > 0.0 && MathAbs(desired_tp - m_current_tp) >= point)
        {
         if(!m_trade_client.PositionModify(m_ticket, m_current_sl, desired_tp))
           {
            if(InpEnableLogging)
               Print("TP modify FAILED for ticket: ", m_ticket, " retcode=", m_trade_client.ResultRetcode(),
                     " (", m_trade_client.ResultRetcodeDescription(), ") ", m_trade_client.ResultComment());
            LockRetry();
            return;
           }
         m_current_tp = desired_tp;
         tp_applied = true;
        }

      //--- both phases complete: protection is in place
      m_retry_count = 0;
      m_initial_sl = m_current_sl;
      m_initial_tp = m_current_tp;
      SetInitialRisk();
      m_lifecycle_state = POSITION_PROTECTED;
      if(InpEnableLogging)
        {
         if(!sl_applied && !tp_applied)
            Print("Initial protection phase completed (no modification required), ticket: ", m_ticket, " SL=", m_current_sl, " TP=", m_current_tp);
         else
            Print("Initial protection applied for ticket: ", m_ticket, " SL=", m_current_sl, " TP=", m_current_tp);
        }
     }

This separation makes the lifecycle more resilient. A failure to apply one protection component does not automatically invalidate the protection that can still be applied to the position.

Break-Even as a Lifecycle Transition

Initial protection is only the beginning of the position lifecycle. Once a position moves sufficiently into profit, the manager can transition its Stop Loss toward break-even.

Break-even management follows the same lifecycle approach as initial protection, although their validation checks differ. Initial protection validates the calculated levels against the entry price and the broker's StopsLevel, while break-even modification additionally checks the current market side and FreezeLevel. The manager advances to the break-even state after a successful modification or when the existing Stop Loss already satisfies the break-even condition.

The break-even trigger can use a fixed pip distance or the ATR captured when the position was adopted. Initial ATR-based protection, by contrast, uses the current ATR when calculating or retrying protection.

   //--- Part 2: break-even phase with fixed-pip or ATR-relative trigger. The ATR-relative trigger uses the ATR captured at/around entry,
   //--- it intentionally does NOT track ongoing volatility (no trailing logic).
   void              ApplyBreakEven(CRiskEngine &risk_engine)
     {
      if(IsRetryLocked())
         return;

      //--- Part 2: lazy ATR context capture if it was unavailable at adoption
      if(m_entry_atr <= 0.0)
         m_entry_atr = risk_engine.ATRValue(m_symbol);

      //--- Part 2: trigger distance depends on the selected trigger mode
      double trigger_distance = 0.0;
      if(m_config.be_trigger_mode == BE_TRIGGER_ATR)
        {
         if(m_entry_atr <= 0.0)
           {
            if(InpEnableLogging)
               Print("ATR BE trigger unavailable for ticket: ", m_ticket, " - retrying");
            LockRetry();
            return;
           }
         trigger_distance = m_entry_atr * m_config.be_atr_multiplier;
        }
      else
         trigger_distance = PositionManagerUtils::PipsToPrice(m_symbol, m_config.be_trigger_pips);

      double current_bid = SymbolInfoDouble(m_symbol, SYMBOL_BID);
      double current_ask = SymbolInfoDouble(m_symbol, SYMBOL_ASK);
      double offset_distance = PositionManagerUtils::PipsToPrice(m_symbol, m_config.be_offset);

      bool trigger_hit = false;
      double new_sl = 0.0;

      if(m_position_type == POSITION_TYPE_BUY)
        {
         if(current_bid >= (m_entry_price + trigger_distance))
           {
            new_sl = PositionManagerUtils::NormalizeStopPriceTick(m_symbol, m_position_type, m_entry_price + offset_distance);
            //--- BE must never worsen an existing protective SL
            if(new_sl > m_current_sl)
               trigger_hit = true;
           }
        }
      else
         if(m_position_type == POSITION_TYPE_SELL)
           {
            if(current_ask <= (m_entry_price - trigger_distance))
              {
               new_sl = PositionManagerUtils::NormalizeStopPriceTick(m_symbol, m_position_type, m_entry_price - offset_distance);
               if(new_sl < m_current_sl || m_current_sl == 0.0)
                  trigger_hit = true;
              }
           }

      if(trigger_hit)
        {
         //--- Part 2: avoid redundant identical modification when the SL is already at target
         double point = SymbolInfoDouble(m_symbol, SYMBOL_POINT);
         if(MathAbs(new_sl - m_position_info.StopLoss()) < point)
           {
            m_current_sl = new_sl;
            m_lifecycle_state = POSITION_BREAKEVEN;
            if(InpEnableLogging)
               Print("Lifecycle state changed to BREAKEVEN for ticket: ", m_ticket);
            return;
           }

         //--- Part 2: market-side + StopsLevel/FreezeLevel validation before
         //--- modifying. Unreachable BE targets are not sent - the manager waits
         if(!PositionManagerUtils::IsValidProtectionModify(m_symbol, m_position_type, new_sl, current_bid, current_ask))
           {

            //--- Part 2: log the wait state once per target, not on every retry
            if(m_last_be_wait_sl != new_sl)
              {
               m_last_be_wait_sl = new_sl;
               if(InpEnableLogging)
                  Print("BE SL unreachable for ticket: ", m_ticket, " SL=", new_sl, " bid=", current_bid, " ask=", current_ask, " - waiting for price");
              }
            LockRetry();
            return;
           }

         double tp = m_position_info.TakeProfit();
         //--- Part 2: failure handling - advance state only on successful modify
         if(m_trade_client.PositionModify(m_ticket, new_sl, tp))
           {
            m_current_sl = new_sl;
            m_lifecycle_state = POSITION_BREAKEVEN;
            if(InpEnableLogging)
               Print("Lifecycle state changed to BREAKEVEN for ticket: ", m_ticket, " SL=", new_sl);
           }
         else
           {
            if(InpEnableLogging)
               Print("BE modify FAILED for ticket: ", m_ticket, " retcode=", m_trade_client.ResultRetcode(),
                     " (", m_trade_client.ResultRetcodeDescription(), ") ", m_trade_client.ResultComment());
            //--- Part 2: state stays POSITION_PROTECTED; throttled retry
            LockRetry();
           }
        }
     }

The method also handles cases where the calculated break-even level cannot yet be placed. Instead of repeatedly submitting an invalid request, the manager records the condition and waits for the market to move into a valid state.

This makes break-even a genuine lifecycle transition rather than simply another trade modification.

POSITION_PROTECTED → POSITION_BREAKEVEN

The state advances when the broker accepts the break-even modification or the existing Stop Loss already satisfies the break-even condition.



Retry and Failure Recovery

Broker operations can fail temporarily as market prices move, freeze conditions change, or a requested protection level becomes valid on a later attempt. The manager therefore throttles protection retries rather than continuously submitting the same failed request.

The important improvement is distinguishing a temporary failure from a persistent protection failure.

   //--- Part 2: throttled retry to avoid hammering the server with identical requests
   bool              IsRetryLocked(void) const
     {
      if(m_last_retry_time == 0)
         return false;
      return (TimeCurrent() - m_last_retry_time) < 5;
     }

   void              LockRetry(void)
     {
      m_last_retry_time = TimeCurrent();
      m_retry_count++;
     }

   //--- Part 2: optional bounded retry escalation. When enabled (max > 0) and the retry budget is exhausted, the position moves to POSITION_PROTECTION_FAILED and optionally alerts. 
   bool              EscalateIfExhausted(void)
     {
      if(m_config.max_protect_retries <= 0)
         return false;
      if(m_retry_count < m_config.max_protect_retries)
         return false;
      m_lifecycle_state = POSITION_PROTECTION_FAILED;
      if(InpEnableLogging)
         Print("Position protection FAILED for ticket: ", m_ticket, " (", m_symbol, ") - retries exhausted, manual intervention required");
      if(m_config.alert_on_protection_fail)
         Alert("PositionManager: protection failed for ", m_symbol, " ticket ", m_ticket);
      return true;
     }

The manager can continue retrying by default, preserving the conservative behavior of the existing implementation, while persistent failures can be surfaced instead of remaining an invisible loop indefinitely.

A successful protection modification is recorded after broker acceptance. When ATR protection is disabled, the manager can also advance to POSITION_PROTECTED without submitting an initial protection modification. In this configuration, the state indicates lifecycle progression rather than confirming that a Stop Loss is present.



Telemetry and the R-Multiple: Lifecycle Proof

A lifecycle manager should not only perform its work; its behavior should also be observable. The manager maintains position metrics, including initial risk, current risk, current profit and R-multiple, which are accessible through its framework interfaces. The sample EA's logs demonstrate trade intents and lifecycle transitions rather than continuously logging these telemetry values.

The R-multiple expresses current profit relative to the initial risk, providing a consistent way to observe how the position has progressed without depending on the instrument's price scale.

   //--- Part 2: initial risk in deposit currency derived from the initial SL
   void              SetInitialRisk(void)
     {
      m_initial_sl = m_current_sl;
      m_initial_risk_money = 0.0;
      if(m_initial_sl > 0.0)
         m_initial_risk_money = m_sizing_engine.PriceDistanceToMoney(m_symbol, MathAbs(m_entry_price - m_initial_sl), m_volume);
     }
   //--- Part 2: refresh position telemetry from live terminal state
   void              RefreshTelemetry(void)
     {
      m_current_sl = m_position_info.StopLoss();
      m_current_tp = m_position_info.TakeProfit();
      m_current_profit = m_position_info.Profit();
      m_current_risk_money = 0.0;
      if(m_current_sl > 0.0)
         m_current_risk_money = m_sizing_engine.PriceDistanceToMoney(m_symbol, MathAbs(m_entry_price - m_current_sl), m_volume);
      m_r_multiple = (m_initial_risk_money > 0.0) ? (m_current_profit / m_initial_risk_money) : 0.0;
     }
   //--- Part 2: log current telemetry line
   void              LogTelemetry(void) const
     {
      if(!InpEnableLogging)
         return;
      Print("Telemetry ticket=", m_ticket, " state=", EnumToString(m_lifecycle_state),
            " SL=", m_current_sl, " TP=", m_current_tp,
            " initialRisk=", m_initial_risk_money, " currentRisk=", m_current_risk_money,
            " profit=", m_current_profit, " R=", m_r_multiple, " entryATR=", m_entry_atr);
     }

These values are not intended to predict performance. They provide accessible record of what the manager has done and the state the position is currently in.

Architectural Note: Scope and Known Limitations

Part 2 makes one important change: the manager no longer uses the chart symbol as its primary context. Instead, it resolves market and trading data from the managed position's symbol.

Price geometry, broker constraints and symbol-specific market data are resolved using the managed symbol. ATR calculations still use the host chart timeframe. Risk sizing uses a simplified tick-value model, with rejection for invalid pricing inputs and a default-volume fallback when sizing is unavailable. Stop Loss and Take Profit are processed sequentially, with shared lifecycle state and retry handling.

The architecture remains compact, with each component retaining its responsibility. ATR-based calculations still use PERIOD_CURRENT, so volatility data follows the host chart timeframe even when managing another symbol.

Manual Mode adopts all open positions on the account, regardless of origin or magic number, and is therefore intended for a single manager instance on an isolated account. Multiple EA instances sharing an account should use distinct magic numbers.

Existing Stop Loss and Take Profit levels are retained when the preservation setting applies, except that a configured risk-reward ratio may replace an existing Take Profit when a usable Stop Loss is available. Positions without protection may receive calculated levels according to the enabled settings.

Risk sizing respects broker volume limits, although raising a sub-minimum calculated volume to the minimum lot can exceed the requested risk budget. Initial protection failures use the failure and retry path; break-even modification failures use a separate throttled retry mechanism. Risk telemetry is available through framework getters but is not logged by the sample EA.

On netting accounts, positions are consolidated by symbol. A trade intent submitted by the manager may therefore affect an existing position associated with another trading system, even when its magic number differs. This interaction must be considered when multiple EAs operate on the same account.



Testing the Reusable Manager

The final validation was performed using the MACD sample EA as the base trading example in the Strategy Tester. The EA was run with the Position Lifecycle Manager enabled, allowing the strategy to open positions automatically. The resulting tester logs were then used to verify position discovery, adoption, protection, and lifecycle management.

Fig. 2. Automatic position life

Fig. 2. EA Mode trade execution and position lifecycle logs

The second test used Manual Mode on a demo account. Attach the manager to the chart, then manually open a position and observe the resulting adoption and management logs. This verifies that the manager can detect, adopt, protect, and manage an existing position independently of the EA's entry logic.

Fig. 3. Manual opened positions

Fig. 3. Manual Mode position adoption and lifecycle logs

Validation can be extended across instruments with different digits, tick sizes, volume steps, and broker constraints to confirm that management follows the position's symbol while using the same lifecycle process for both EA and manually opened positions.



Conclusion

Part 2 extends the Position Lifecycle Manager beyond chart-symbol-based position discovery. Manual Mode allows positions across account symbols to be adopted and managed, while EA Mode continues to use the MACD sample's chart-symbol signals. The manager resolves symbol-specific market properties from each managed position, subject to the documented ATR timeframe and risk-model limitations. Both operating modes use the same lifecycle machinery.

With this foundation in place, the next phase moves beyond managing individual positions in isolation. The next part will focus on position adding, partial closes, risk-level breach handling, and dynamic trailing systems, allowing the manager to handle evolving position structures and adapt protection as market conditions change.

File Description
PositionManager_MACD_Sample2.mq5

The MACD sample EA generates trading signals and forwards trade intents to the Position Manager, which handles order submission and position lifecycle management. The EA also retains an optional MACD-based exit path, disabled by default.

PositionManager.mqh
Implements the central orchestration layer of the framework. It discovers active positions, creates managed position objects, maintains the collection of tracked positions, coordinates lifecycle processing, and removes completed positions from active management.
ManagedPosition.mqh
Implements the behavior of an individual managed position. It stores position identity, initial trade information, lifecycle state, and runtime management data while controlling the progression of a single position through its defined lifecycle stages.
RiskEngine.mqh
Provides ATR and risk calculations used during position management, with market data resolved from the managed position's symbol. This keeps protection and risk calculations independent from the chart symbol and lifecycle control logic.
Common.mqh 
Contains shared definitions used throughout the framework, including lifecycle enumerations, common constants, configuration values, and supporting declarations required by multiple components.
SizingEngine.mqh  Provides position sizing calculations based on the managed position's symbol, risk settings, and protection distance. This keeps lot-size calculation independent from lifecycle control and ensures sizing uses the correct instrument properties.
MQL5.zip An archive containing the complete MQL5 folder structure. Extract it into the MetaTrader 5 terminal directory so that PositionManager_MACD_Sample2.mq5 is placed under MQL5\Experts\PositionManager\V2\ and all supporting.mqh files are placed under MQL5\Include\PositionManager\V2\.


Attached files |
MQL5.zip (21.83 KB)
The Kalman Filter for Price Smoothing: An Exact Implementation in MQL5 The Kalman Filter for Price Smoothing: An Exact Implementation in MQL5
This article implements the scalar Kalman Filter for price smoothing in MQL5 using the standard local-level model and the exact prediction–update equations. It explains how process and measurement noise (Q, R) shape the Kalman gain, builds two independently configured filters into a fast/slow crossover, and provides a complete indicator and Expert Advisor that trade a stability-confirmed signal via iCustom, giving a reproducible baseline for testing and tuning.
Uncertainty as a Model (Part 5): The Basics of Regression Uncertainty as a Model (Part 5): The Basics of Regression
A practical introduction to time series regression models: regression on a constant and simple regression with deterministic, exogenous, and endogenous regressors. It describes the key steps in the diagnostic process, including residual analysis and hypothesis testing, that are necessary for making informed trading decisions. MQL5 scripts for MetaTrader 5 are included; they implement tests and plots using real data.
Native Gaussian Process Regression in MQL5: Short-Horizon Price Forecasting with Uncertainty Bands Native Gaussian Process Regression in MQL5: Short-Horizon Price Forecasting with Uncertainty Bands
The article rebuilds a Gaussian Process forecaster in native MQL5 around market-state inputs and direct horizon log-return targets. It employs a Matern 3/2 kernel, symmetric Cholesky with adaptive jitter, standardized targets, and a log‑space grid search that avoids state side effects. The EA trades only when az‑score over observed variance clears a threshold and the edge beats the live spread. Calibration is verified in Strategy Tester.
How to Set Up an AI Hedge Fund in MetaTrader 5 How to Set Up an AI Hedge Fund in MetaTrader 5
The article examines the architecture of a committee consisting of 15 AI agents: ten analysts and four risk officers vote in three parallel phases, and the Chair records the final result. Isolated pair contexts with separate reputations are used for eight currency pairs. The dynamic vote threshold depends on the daily PnL targets. The Expert Advisor uses only the signal, SL and TP, which allows for evaluation pf the quality of its decisions without any additional mechanics.