Building a Position Lifecycle Manager in MQL5 (Part 2): Managing Manual and Multi-Symbol Positions
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 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 InputsPart 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 ModeThe 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. 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 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\. |
Warning: All rights to these materials are reserved by MetaQuotes Ltd. Copying or reprinting of these materials in whole or in part is prohibited.
This article was written by a user of the site and reflects their personal views. MetaQuotes Ltd is not responsible for the accuracy of the information presented, nor for any consequences resulting from the use of the solutions, strategies or recommendations described.
The Kalman Filter for Price Smoothing: An Exact Implementation in MQL5
Uncertainty as a Model (Part 5): The Basics of Regression
Native Gaussian Process Regression in MQL5: Short-Horizon Price Forecasting with Uncertainty Bands
How to Set Up an AI Hedge Fund in MetaTrader 5
- Free trading apps
- Over 8,000 signals for copying
- Economic news for exploring financial markets
You agree to website policy and terms of use