preview
Adaptive Trade Management in MQL5: Building a Broker-Aware, Deterministic, and Explainable Exit Engine

Adaptive Trade Management in MQL5: Building a Broker-Aware, Deterministic, and Explainable Exit Engine

MetaTrader 5 — Trading systems |
220 0
Stanley Kimathi Kibaara
Stanley Kimathi Kibaara

Introduction

Many Expert Advisors implement breakeven stops, trailing stops, ATR-based stops, and time exits as independent pieces of logic. Implementing each rule is rarely the difficult part. The difficult part is deciding what happens when several rules trigger on the same tick. A trailing stop might want to move the stop closer, a breakeven rule might want to move it to entry, and a time exit might want to close the position entirely. When every module modifies the position directly, the order in which the modules run decides the outcome, and that order is often implicit and undocumented.

This article presents an architecture where exit modules never modify a position directly. Each module produces a proposal. A policy engine ranks all well-formed proposals in a strict, documented order. A validator checks the candidates in that order against risk invariants and broker constraints, and the first candidate that passes is handed to an execution adapter, the only component that interacts with the trade server. A reconciliation step then compares the broker-side position with the request before the internal state is updated. The engine does not decide when to enter. It only manages positions after they are open, using an R-multiple framework so that every threshold is expressed relative to the initial risk of the position.

The goal is to demonstrate a modular, broker-aware, deterministic decision process that can be tested and explained. The result is a seven-stage pipeline: Proposal, Ranking, Validation (with fallback to the next candidate), Execution, Reconciliation, State update, and Trace.


Why Traditional Exit Logic Becomes Difficult

Imagine a BUY position that reaches several conditions at once. The configuration uses a breakeven threshold of 0.5R, a trailing activation of 1.5R, and a maximum lifetime of 120 minutes:

  • Price has advanced to +2.0R, so both the breakeven threshold and the trailing activation are reached.
  • The position has been open for 120 minutes, so the time exit condition is satisfied.

Now three modules want different things:

  • Breakeven: move SL to entry plus buffer.
  • Trailing: move SL to current price minus a fixed distance.
  • Time exit: close the position completely.

Which one should win? This is a strategy decision, not a technical one. One strategy treats the maximum lifetime as a hard rule; another prefers to secure breakeven first and let the trailing stop work. If each module modifies the position on its own, the answer is decided by whichever module runs last, which is fragile and hard to reproduce.

The architecture proposed here turns each module into a proposal generator. Each generator returns an SExitAdjustment structure that describes what it would like to do but does not touch the broker. The policy engine ranks all proposals by a priority that comes from configuration, and the validator decides, candidate by candidate, which request the broker can accept. The selection rule is therefore visible in configuration and testable, instead of being hidden in the order of function calls.

Exit pipeline with ranked proposals, validation fallback, reconciliation and decision trace

Figure 1: Exit pipeline with ranked proposals, validation fallback, reconciliation and decision trace.


Shared Data Model

The engine is built around a few core structs and enums defined in AdaptiveExitTypes.mqh. A position can be in several management states at the same time, for example after a partial close while the trailing stop is active. The state is therefore a bit mask of ENUM_POSITION_FLAG values instead of a single enum value:

// --- Management flags of a position (bit mask, flags can be combined)
enum ENUM_POSITION_FLAG
  {
   PSF_INITIAL_STOP  = 1,                             // Confirmed protective stop exists and 1R is measured
   PSF_BREAKEVEN     = 2,                             // Breakeven stop move confirmed by the broker
   PSF_TRAILING      = 4,                             // At least one trailing stop move confirmed
   PSF_PARTIAL_TAKEN = 8,                             // Partial close filled (fully or partly)
   PSF_CLOSED        = 16                             // Full close confirmed
  };

The SPositionSnapshot holds live data captured from the terminal, plus the management fields copied from the persistent state:

// --- Snapshot of a position at a given moment (transient live data)
struct SPositionSnapshot
  {
   ulong             ticket;                          // Unique position ticket
   string            symbol;                          // Trading symbol (e.g., EURUSD)
   long              magic;                           // Expert Advisor magic number
   ENUM_POSITION_TYPE direction;                      // POSITION_TYPE_BUY or SELL
   double            entry_price;                     // Open price (price units)
   double            current_price;                   // Bid for long, ask for short (price units)
   double            current_sl;                      // Current stop-loss (price units, 0 if none)
   double            current_tp;                      // Current take-profit (price units, 0 if none)
   double            volume;                          // Position volume (lots)
   datetime          open_time;                       // Open time (timestamp)
   double            initial_risk_distance;           // 1R distance |entry - initial SL| (price units)
   double            current_R;                       // Unrealized profit/loss (R-multiple)
   double            max_favorable_R;                 // Highest current R reached (R-multiple)
   uint              flags;                           // ENUM_POSITION_FLAG bits from management state
  };

Two fields need a precise definition. The field initial_risk_distance is 1R expressed as an absolute price distance, |entry - initial SL|, in price units (not points). The field current_R stores the unrealized result of the open position in R-multiples. A realized result exists only after a full or partial close. Both values are 0 until a protective stop has been confirmed and 1R is known.

The SManagementState is separate from the snapshot. It persists 1R, the maximum favorable R, and the flags across ticks. Without this persistence the engine would lose the original risk distance every time it queries the terminal. The pending fields hold a request that the broker has accepted but whose result is not yet visible on the position (see State Reconciliation):

// --- Persistent management state for a position (stored separately)
struct SManagementState
  {
   ulong             ticket;                          // Position ticket (key for lookup)
   double            initial_risk_distance;           // 1R from a confirmed loss-side stop (price units)
   double            max_favorable_R;                 // Highest R reached (R-multiple)
   uint              flags;                           // ENUM_POSITION_FLAG bits, source of truth for state
   datetime          last_update_time;                // Last state update (timestamp)
   bool              pending;                         // True while a request waits for broker confirmation
   ENUM_EXIT_ACTION  pending_action;                  // Action of the outstanding request
   ENUM_EXIT_REASON  pending_reason;                  // Reason of the outstanding request
   double            pending_sl;                      // Requested stop-loss (price units)
   double            pending_close_volume;            // Requested close volume (lots)
   double            pending_base_volume;             // Position volume when the request was sent (lots)
   datetime          pending_since;                   // Time the request was sent (timestamp)
  };

SExitAdjustment represents a proposal from a module:

// --- Proposal generated by an exit module (ranked later by the policy engine)
struct SExitAdjustment
  {
   ulong             ticket;                          // Position ticket to which this proposal applies
   ENUM_EXIT_ACTION  action;                          // Action to perform
   double            proposed_sl;                     // New stop-loss for STOP_MODIFICATION (price units)
   double            proposed_tp;                     // Take-profit to keep for STOP_MODIFICATION (price units)
   double            close_volume;                    // Volume to close for PARTIAL/FULL_EXIT (lots)
   ENUM_EXIT_REASON  reason;                          // Why this proposal was generated
   string            explanation;                     // Human-readable description of the proposal logic
   bool              well_formed;                     // Module-level sanity only, not broker validity
  };

The action type is separate from the reason. A time exit is a FULL_EXIT action with a TIME reason. An ATR stop is a STOP_MODIFICATION action with an ATR reason. The policy engine uses both, as described in the next sections.

The field well_formed only states that the module produced a sane proposal (fields filled, a positive price or volume). It does not mean that the broker will accept the proposal. That decision belongs exclusively to the validator, which returns an ENUM_EXIT_RESULT. The field close_volume is the volume to close in lots; it is always 0 for a stop modification.


Proposal Generation

Each exit module implements a GenerateProposal method. It receives an SPositionSnapshot and fills an SExitAdjustment. It never calls OrderSend. This keeps broker-side execution outside the proposal modules and makes them testable with synthetic snapshots. Each module sets its own action; the engine never rewrites it.

The ATR calculator creates the indicator handle once in the constructor, caches it, and releases it in the destructor. It reads ATR from the last closed bar (shift 1), so the value does not change within a bar:

   // --- Read ATR of the last closed bar, which does not change within the bar
   bool              GetClosedBarATR(double &value, ENUM_EXIT_ERROR &error)
     {
      if(m_atr_handle == INVALID_HANDLE)                                          // If handle is not valid
        {
         error = EXIT_INVALID_CONFIG;                                             // Set configuration error
         return false;                                                            // Return failure
        }

      double atr[1];                                                              // Buffer for one ATR value
      if(CopyBuffer(m_atr_handle, 0, 1, 1, atr) != 1 || atr[0] <= 0)              // Shift 1 = last closed bar
        {
         error = EXIT_NOT_ENOUGH_DATA;                                            // Set insufficient data error
         return false;                                                            // Return failure
        }
      value = atr[0];                                                             // ATR value (price units)
      error = EXIT_OK;                                                            // No error
      return true;                                                                // Indicate success
     }

The handle belongs to one symbol, so a position on another symbol is refused with EXIT_SYMBOL_MISMATCH. The initial stop is measured from the entry price. This keeps it consistent with the validator rule that an initial BUY stop lies below entry and an initial SELL stop above entry, no matter how far price has moved since the entry:

   // --- Generate the initial stop proposal, anchored to the entry price
   bool              GenerateProposal(SPositionSnapshot &position, SExitAdjustment &proposal, ENUM_EXIT_ERROR &error)
     {
      // --- The cached handle belongs to m_symbol and must not be applied to another instrument
      if(position.symbol != m_symbol)                                             // Position is on another symbol
        {
         error = EXIT_SYMBOL_MISMATCH;                                            // Set symbol mismatch error
         return false;                                                            // Return failure
        }

      double atr;                                                                 // ATR value (price units)
      if(!GetClosedBarATR(atr, error))                                            // Read ATR of the last closed bar
         return false;                                                            // Return failure (error already set)

      // --- ATR is already in price units, so it is not multiplied by _Point
      double distance = atr * m_multiplier_stop;                                  // Stop distance from entry (price units)

      // --- Anchoring to entry keeps the stop valid for the initial-stop rule (BUY below, SELL above entry)
      double new_sl;                                                              // New stop level (price units)
      if(position.direction == POSITION_TYPE_BUY)                                 // Long position
         new_sl = position.entry_price - distance;                                // Stop below entry
      else                                                                        // Short position
         new_sl = position.entry_price + distance;                                // Stop above entry

      int digits = ExitSymbolDigits(position.symbol);                             // Digits of the position symbol
      string side = (position.direction == POSITION_TYPE_BUY) ? "-" : "+";        // Sign shown in the explanation
      string mult_text = DoubleToString(m_multiplier_stop, 2);                    // Multiplier as text
      string period_text = IntegerToString(m_atr_period);                         // ATR period as text
      string sl_text = DoubleToString(new_sl, digits);                            // Stop level as text

      ZeroMemory(proposal);                                                       // Clear all fields
      proposal.ticket = position.ticket;                                          // Set the ticket
      proposal.action = EXIT_ACTION_STOP_MODIFICATION;                            // Action: modify SL/TP
      proposal.proposed_sl = new_sl;                                              // Proposed SL (price units)
      proposal.proposed_tp = position.current_tp;                                 // Keep existing TP
      proposal.close_volume = 0.0;                                                // Stop modification carries no volume
      proposal.reason = EXIT_REASON_ATR;                                          // Reason: ATR-based
      proposal.explanation = "ATR initial stop: entry " + side + " " + mult_text; // Explain the formula
      proposal.explanation += " x ATR(" + period_text + ") = " + sl_text;         // Explain the result
      proposal.well_formed = (new_sl > 0);                                        // A non-positive price is malformed
      error = EXIT_OK;                                                            // No error
      return true;                                                                // Indicate success
     }

ATR is already expressed in price units. Multiplying it by _Point is a common mistake that turns a 0.0020 ATR into 0.00000002, so the code does not do it. For the same reason, the breakeven buffer and the trailing step, which are configured in points, are converted with the point of the position symbol (SYMBOL_POINT) rather than the chart's _Point.

The structure adjuster uses a pivot-based algorithm. It scans the last N closed bars for a price that is higher than its left and right neighbors (pivot high) or lower than both (pivot low). The lookback period, left strength, and right strength are configurable. If no pivot is found, it falls back to the lookback extreme. It produces a proposal only when the structural level lies on the loss side of entry and is tighter than the ATR stop. Otherwise it produces no proposal, because the ATR proposal already represents that stop.

Breakeven, trailing, and partial exit are R-based, so they have no meaning until 1R exists. The engine calls them only after a protective stop has been confirmed (PSF_INITIAL_STOP). Until then, only the two initial-stop modules and the time exit are evaluated, and the trace records why the other modules were skipped:

      if((pos.flags & PSF_INITIAL_STOP) == 0)                                               // No confirmed stop yet
        {
         // --- Only initial-stop modules run, R-based modules need 1R
         if(m_atr.GenerateProposal(pos, p, err))                                            // ATR initial stop
           {
            Append(list, p);                                                                // Append ATR proposal
            double atr_stop = p.proposed_sl;                                                // ATR stop passed explicitly
            SExitAdjustment sp;                                                             // Structure proposal
            if(m_structure.GenerateProposal(pos, atr_stop, sp, err))                        // Structure stop if tighter
               Append(list, sp);                                                            // Append structure proposal
            else                                                                            // No structure proposal
               notes += ModuleNote("STRUCTURE", err);                                       // Report error, if any
           }
         else                                                                               // No ATR proposal
            notes += ModuleNote("ATR", err);                                                // Report error
         notes += "  - BREAKEVEN/TRAILING/PARTIAL: not evaluated (1R not established)\n";   // Explain skip

Each R-based module also refuses to work without 1R and returns EXIT_NO_INITIAL_RISK, so it is safe even when called directly, for example from a test.

The breakeven controller proposes a stop at entry plus a buffer (points) once the current R reaches its threshold. The trailing manager activates when the maximum favorable R reaches its threshold, and then trails the stop a fixed number of points behind price. The partial exit controller closes a fraction of the volume, rounded down to the volume step, once the maximum favorable R reaches its trigger. The time-exit controller proposes a full close when the position age exceeds the configured lifetime (minutes). These modules are deliberately simple; the subject of the article is how the engine chooses between them.


Deterministic Policy Resolution

All proposals are passed to CExitPolicyEngine. The policy engine does not pick a single winner. It returns all well-formed proposals in a strict total order, and the validator then works through that ranked list (next section). The order is defined by five keys; the first key that differs decides.

Key
PRECEDENCE_BY_REASON (default)
PRECEDENCE_BY_ACTION
1 Reason rank (index in the priority list)
Action rank (FULL_EXIT = 0, PARTIAL_EXIT = 1, STOP_MODIFICATION = 2)
2 Action rank
Reason rank
3 Reason enum value (orders reasons missing from the list)
Reason enum value
4 Stop modifications: tighter stop first. Partial exits: larger close volume (lots) first
Same
5 Explanation text, lexicographic
Same

Table 1. Ranking keys of the policy engine

The reason rank is the index of the reason in the configured priority list. The list has one slot per reason (EXIT_PRIORITY_SLOTS = 6), so every reason, including PARTIAL, can be ranked and no reason is silently ignored. A reason missing from the list receives the lowest rank, and ConfigWarnings() reports missing or duplicated entries when the engine starts. The demo EA fills all six slots:

void SetDefaultPriority(SExitConfig &config)
  {
   config.priority[0] = EXIT_REASON_TIME;             // Highest priority: maximum lifetime
   config.priority[1] = EXIT_REASON_PARTIAL;          // Second: scaling out
   config.priority[2] = EXIT_REASON_BREAKEVEN;        // Third: protecting entry
   config.priority[3] = EXIT_REASON_TRAILING;         // Fourth: trailing
   config.priority[4] = EXIT_REASON_STRUCTURE;        // Fifth: initial stop from structure
   config.priority[5] = EXIT_REASON_ATR;              // Lowest: initial stop from ATR
  }

Precedence follows the strategy, not the action type. With the default PRECEDENCE_BY_REASON, a time exit wins over a breakeven move only because the configuration ranks TIME above BREAKEVEN. A full exit is not preferred simply because it closes the position. Until the close is executed and confirmed, it is only an intention, and a rejection, slippage, or a partial fill can leave risk open. A strategy that does want the most decisive action first can select PRECEDENCE_BY_ACTION explicitly; reason priority then only breaks ties within an action type.

   // --- Order of two proposals: -1 = a first, +1 = b first, 0 = identical requests.
   // --- Every key is a property of the proposal, so module call order cannot change the result.
   int               Compare(SPositionSnapshot &pos, SExitAdjustment &a, SExitAdjustment &b)
     {
      bool by_reason = (m_mode == PRECEDENCE_BY_REASON);                              // Selected ordering mode
      int ra = ReasonRank(a.reason);                                                  // Reason rank of a
      int rb = ReasonRank(b.reason);                                                  // Reason rank of b
      int aa = ActionRank(a.action);                                                  // Action rank of a
      int ab = ActionRank(b.action);                                                  // Action rank of b
      int k1a = by_reason ? ra : aa;                                                  // Key 1 of a
      int k1b = by_reason ? rb : ab;                                                  // Key 1 of b
      int k2a = by_reason ? aa : ra;                                                  // Key 2 of a
      int k2b = by_reason ? ab : rb;                                                  // Key 2 of b

      if(k1a != k1b)                                                                  // Key 1 decides
         return (k1a < k1b) ? -1 : 1;                                                 // Lower rank first
      if(k2a != k2b)                                                                  // Key 2 decides
         return (k2a < k2b) ? -1 : 1;                                                 // Lower rank first
      if(a.reason != b.reason)                                                        // Key 3: reason value
         return ((int)a.reason < (int)b.reason) ? -1 : 1;                             // Orders reasons missing from the list

      // --- From here both proposals have the same action
      bool stops = (a.action == EXIT_ACTION_STOP_MODIFICATION);                       // Both are stop modifications
      bool partials = (a.action == EXIT_ACTION_PARTIAL_EXIT);                         // Both are partial closes
      if(stops && a.proposed_sl != b.proposed_sl)                                     // Key 4 for stops
         return IsTighter(pos, a.proposed_sl, b.proposed_sl) ? -1 : 1;                // Tighter stop first
      if(partials && a.close_volume != b.close_volume)                                // Key 4 for partials
         return (a.close_volume > b.close_volume) ? -1 : 1;                           // Larger close volume first

      int s = StringCompare(a.explanation, b.explanation);                            // Key 5: explanation text
      if(s != 0)                                                                      // Texts differ
         return (s < 0) ? -1 : 1;                                                     // Lexicographic order
      return 0;                                                                       // Identical requests
     }

Every key is a property of the proposal itself. Changing the order in which the modules produce their proposals cannot change the ranking, and this is what makes the selection deterministic. Tests 8, 10, and 11 check it by ranking the same proposals in both input orders. Two proposals that are equal on all five keys are identical requests, so selecting either one leads to the same result.

Rank() sorts the proposals with an insertion sort into one pre-sized array. This is sufficient because the number of proposals is small and bounded (at most one per module per tick). Rank() also writes every candidate with its rank, and every malformed proposal that was dropped, into the decision trace:

   // --- Rank all well-formed proposals and return their number; 'trace' lists every proposal
   int               Rank(SPositionSnapshot &position, SExitAdjustment &adjustments[], int count, SExitAdjustment &ranked[], string &trace)
     {
      ArrayResize(ranked, count);                                                     // Single allocation for all candidates
      int n = 0;                                                                      // Number of ranked candidates
      string filtered = "";                                                           // Dropped proposals (for the trace)
      for(int i = 0; i < count; i++)                                                  // Every input proposal
        {
         if(!adjustments[i].well_formed)                                              // Malformed module output
           {
            filtered += "  - " + Describe(position, adjustments[i]);                  // Describe the proposal
            filtered += " (not well-formed, dropped)\n";                              // Mark it as dropped
            continue;                                                                 // Skip it
           }

         // --- Insertion sort: shift every candidate that must come after the new one
         int j = n;                                                                   // Insert position
         while(j > 0 && Compare(position, adjustments[i], ranked[j-1]) < 0)           // New one comes first
           {
            ranked[j] = ranked[j-1];                                                  // Shift right
            j--;                                                                      // Move left
           }
         ranked[j] = adjustments[i];                                                  // Insert
         n++;                                                                         // One more ranked candidate
        }
      ArrayResize(ranked, n);                                                         // Trim to the ranked count

      string mode = (m_mode == PRECEDENCE_BY_REASON) ? "BY_REASON" : "BY_ACTION";     // Mode name
      trace = "Candidates (" + IntegerToString(n) + " ranked, mode " + mode + "):\n"; // Section header
      for(int k = 0; k < n; k++)                                                      // One line per candidate
        {
         string line = Describe(position, ranked[k]);                                 // Candidate description
         trace += "  #" + IntegerToString(k + 1) + " " + line + "\n";                 // Numbered candidate line
        }
      if(n == 0)                                                                      // Nothing ranked
         trace += "  (none)\n";                                                       // Say so
      trace += filtered;                                                              // Append dropped proposals
      return n;                                                                       // Return ranked count
     }

The policy engine does not validate broker rules. That is the job of the validator.


Broker-Aware Validation

CExitValidator checks one candidate at a time against risk invariants, broker constraints, and idempotency. The engine calls it for each ranked candidate in order and executes the first candidate that returns EXIT_RESULT_OK. A candidate that is rejected, or that would not change anything, does not stop the process; the next candidate is validated. Candidates ranked below the accepted one are not validated, and the trace says so. Validation therefore covers the selected candidate and every candidate ranked above it, not every proposal.

   // --- Validate ranked candidates in order and return the index of the first that passes (-1 if none)
   int               SelectFirstExecutable(SPositionSnapshot &pos, SExitAdjustment &ranked[], int n, SExitAdjustment &validated, ENUM_EXIT_RESULT &last_result, string &trace)
     {
      trace = "Validation:\n";                                                              // Section header
      last_result = EXIT_RESULT_NO_EXECUTABLE_CANDIDATE;                                    // Default result
      for(int k = 0; k < n; k++)                                                            // Candidates in rank order
        {
         SExitAdjustment v;                                                                 // Validated candidate
         string expl = "";                                                                  // Validator explanation
         ENUM_EXIT_RESULT r = m_validator.Validate(pos, ranked[k], v, expl);                // Validate this candidate
         string name = ReasonName(ranked[k].reason) + " " + ActionName(ranked[k].action);   // Candidate name
         trace += "  #" + IntegerToString(k + 1) + " " + name;                              // Candidate number and name
         trace += " -> " + ResultName(r) + ": " + expl + "\n";                              // Verdict
         last_result = r;                                                                   // Remember last verdict
         if(r != EXIT_RESULT_OK)                                                            // Rejected or no change
            continue;                                                                       // Try the next candidate
         validated = v;                                                                     // Broker-ready request
         if(k + 1 < n)                                                                      // Lower-ranked candidates remain
           {
            string from_k = IntegerToString(k + 2);                                         // First skipped candidate
            string to_k = IntegerToString(n);                                               // Last skipped candidate
            trace += "  #" + from_k + "..#" + to_k + " not validated\n";                    // Note
           }
         return k;                                                                          // Selected index
        }
      return -1;                                                                            // No candidate passed
     }

For a stop modification, the validator first normalizes the proposed stop to the tick size, so every later check sees the price that will actually be sent. It then applies the risk invariants. An initial stop (no current SL) must lie on the loss side of entry. An existing stop may only move in the direction that improves protection. The following table summarizes the direction rules:

Rule Buy Sell
Initial SL SL < Entry SL > Entry
Improve SL New SL > Current SL New SL < Current SL
1R (price units) Entry - Initial SL Initial SL - Entry
Current R (Price - Entry) / 1R (Entry - Price) / 1R
Breakeven Entry + buffer (points x SYMBOL_POINT) Entry - buffer (points x SYMBOL_POINT)

Table 2. Direction rules used by the validator and the R calculation

Idempotency prevents requests that would not change anything. If the position already has a stop and the normalized change is smaller than min_effective_sl_change (points), the result is EXIT_RESULT_NO_CHANGE and the next candidate is tried. Without an existing stop there is nothing to compare with. Repeated initial-stop requests are prevented by the pending mechanism described in State Reconciliation: no new request is sent for a position until the previous one is visible or a timeout expires.

      // --- Idempotency: a change smaller than the threshold is not worth a request
      if(position.current_sl != 0)                                                         // Existing stop present
        {
         double cur = NormalizeToTickSize(position.symbol, position.current_sl);           // Existing stop on the grid
         double diff = MathAbs(sl - cur);                                                  // Size of the change (price units)
         if(diff < m_config.min_effective_sl_change * point)                               // Below threshold (points)
           {
            string diff_text = DoubleToString(diff / point, 1);                            // Change as text (points)
            explanation = "SL change " + diff_text + " points below threshold.";           // Set explanation
            return EXIT_RESULT_NO_CHANGE;                                                  // Nothing to do
           }
        }

      // --- Risk invariants: an initial stop sits on the loss side, an existing stop may only improve
      bool is_buy = (position.direction == POSITION_TYPE_BUY);                             // Direction of the position
      if(position.current_sl == 0)                                                         // Initial stop
        {
         if(is_buy && sl >= position.entry_price)                                          // BUY stop must be below entry
           {
            explanation = "Initial BUY stop must be below entry.";                         // Set explanation
            return EXIT_RESULT_INVALID_PRICE;                                              // Wrong side
           }
         if(!is_buy && sl <= position.entry_price)                                         // SELL stop must be above entry
           {
            explanation = "Initial SELL stop must be above entry.";                        // Set explanation
            return EXIT_RESULT_INVALID_PRICE;                                              // Wrong side
           }
        }
      else                                                                                 // Existing stop
        {
         bool higher = (sl > position.current_sl);                                         // New stop above the current one
         bool lower = (sl < position.current_sl);                                          // New stop below the current one
         bool improves = is_buy ? higher : lower;                                          // Protection improves
         if(!improves)                                                                     // Stop would not improve
           {
            explanation = "Proposed stop worsens protection.";                             // Set explanation
            return EXIT_RESULT_RISK_REGRESSION;                                            // Risk regression
           }
        }

Broker constraints come next. The stop must lie on the correct side of the current price and at least SYMBOL_TRADE_STOPS_LEVEL points away from it. An existing SL or TP that is closer to the price than SYMBOL_TRADE_FREEZE_LEVEL points cannot be modified, so the modification is rejected. Both levels are read in points and converted to price units with SYMBOL_POINT. For a partial close, the validator checks that both the closed volume and the remaining volume are multiples of the volume step and inside the broker volume range (lots). For a full close, it takes the volume from the freshly read position.

The engine does not implement an exposure guard. It never opens or adds to positions; every action either reduces volume or moves a stop, so an exposure limit would never apply.


Execution Adapter

The execution adapter is the only component that sends orders. It converts a validated SExitAdjustment into an MqlTradeRequest. For full and partial closes it uses TRADE_ACTION_DEAL with the opposite order type (sell to close a buy, buy to close a sell), the current bid or ask, a configurable deviation (points), and a filling mode supported by the symbol. For stop modifications it uses TRADE_ACTION_SLTP.

   // --- Choose a filling mode the symbol supports (FOK, then IOC, then RETURN)
   static ENUM_ORDER_TYPE_FILLING SelectFillingMode(string symbol)
     {
      long modes = SymbolInfoInteger(symbol, SYMBOL_FILLING_MODE);                              // Filling modes allowed by the symbol
      if((modes & SYMBOL_FILLING_FOK) == SYMBOL_FILLING_FOK)                                    // Fill or Kill allowed
         return ORDER_FILLING_FOK;                                                              // All or nothing, simplest to reconcile
      if((modes & SYMBOL_FILLING_IOC) == SYMBOL_FILLING_IOC)                                    // Immediate or Cancel allowed
         return ORDER_FILLING_IOC;                                                              // May fill partially (DONE_PARTIAL)
      return ORDER_FILLING_RETURN;                                                              // Exchange or request execution fallback
     }

A return code describes what the trade server answered, not what the position looks like afterwards. The adapter therefore classifies the code instead of treating it as the final result:

   // --- Map a return code to an engine result; DONE_PARTIAL and PLACED still need reconciliation
   static ENUM_EXIT_RESULT ClassifyRetcode(uint retcode)
     {
      switch(retcode)                                                                           // Branch on return code
        {
         case TRADE_RETCODE_DONE:                                                               // Request completed
            return EXIT_RESULT_OK;                                                              // Executed
         case TRADE_RETCODE_DONE_PARTIAL:                                                       // Only part of the volume filled
            return EXIT_RESULT_PARTIAL_FILL;                                                    // Partly executed
         case TRADE_RETCODE_PLACED:                                                             // Order placed, not filled yet
            return EXIT_RESULT_PENDING_CONFIRMATION;                                            // Outcome not visible yet
         default:                                                                               // Any other code
            return EXIT_RESULT_EXECUTION_ERROR;                                                 // Failure
        }
     }

Only TRADE_RETCODE_DONE counts as executed. TRADE_RETCODE_DONE_PARTIAL and TRADE_RETCODE_PLACED are accepted requests whose effect still has to be confirmed on the position. Every other code is an execution error, and the management state is left unchanged. Execute() returns an SExitResult whose success flag states whether the request was accepted; the final values it reports are expected values until reconciliation confirms them.


State Reconciliation and Explainability

After the execution adapter returns, the engine reads the position again and compares it with the request. A stop modification is confirmed when the broker-side SL is within half a tick of the requested level. A partial close is confirmed when the volume has dropped by the requested amount; a smaller drop counts as a partial fill. A full close is confirmed when the position no longer exists. If the result is not visible yet, for example after TRADE_RETCODE_PLACED, the request is stored as pending. On subsequent ticks, the pending request is checked first, and no new request is sent for that position until the result appears or pending_timeout_seconds expires.

   // --- Reconcile the request that was just executed; state changes only on a confirmed outcome
   string            ReconcileExecution(int idx, SPositionSnapshot &pos, SExitAdjustment &validated, SExitResult &exec)
     {
      if(!exec.success)                                                                     // Request not accepted
         return "Reconciliation: request not accepted, management state unchanged.\n";      // No change

      if(m_config.dry_run || m_test_mode)                                                   // No broker: simulate the result
        {
         SPositionSnapshot sim;                                                             // Simulated position
         sim = pos;                                                                         // Start from the fresh snapshot
         if(validated.action == EXIT_ACTION_STOP_MODIFICATION)                              // Stop modification
            sim.current_sl = validated.proposed_sl;                                         // Simulated SL (price units)
         else                                                                               // Partial or full close
            sim.volume = MathMax(0.0, pos.volume - validated.close_volume);                 // Simulated volume (lots)
         ENUM_EXIT_ACTION sim_act = validated.action;                                       // Executed action
         string d = ApplyOutcome(idx, sim_act, validated.reason, RECONCILE_CONFIRMED, sim); // Apply
         return "Reconciliation (simulated): " + d + "\n";                                  // Trace
        }

      SPositionSnapshot actual;                                                             // Position after execution
      bool exists = RefreshPositionSnapshot(pos.ticket, actual);                            // Real refresh
      ENUM_EXIT_ACTION act = validated.action;                                              // Executed action
      double sl = validated.proposed_sl;                                                    // Requested SL (price units)
      double vol = validated.close_volume;                                                  // Requested close volume (lots)
      ENUM_RECONCILE_OUTCOME o = RECONCILE_NOT_VISIBLE;                                     // Reconciliation outcome
      if(!exists && act == EXIT_ACTION_FULL_EXIT)                                           // Position gone after full close
         o = RECONCILE_CONFIRMED;                                                           // Close confirmed
      if(exists)                                                                            // Position still exists
         o = CheckOutcome(act, sl, vol, pos.volume, actual);                                // Compare with the request

      if(o == RECONCILE_NOT_VISIBLE)                                                        // Not visible yet (e.g. PLACED)
        {
         SetPending(idx, validated, pos.volume);                                            // Wait for confirmation
         string code = ResultName(exec.result_code);                                        // Execution result name
         string timeout = IntegerToString(m_config.pending_timeout_seconds);                // Timeout (seconds)
         string msg = "Reconciliation: " + code + " not yet visible";                       // Trace
         return msg + ", marked PENDING (timeout " + timeout + "s).\n";                     // Trace
        }
      string d;                                                                             // Outcome description
      if(exists)                                                                            // Use the refreshed position
         d = ApplyOutcome(idx, validated.action, validated.reason, o, actual);              // Apply
      else                                                                                  // Position gone after full close
         d = ApplyOutcome(idx, validated.action, validated.reason, o, pos);                 // Apply
      return "Reconciliation: " + d + "\n";                                                 // Trace
     }

Flags and 1R change in one place only, ApplyOutcome(), which runs only for a confirmed or partly confirmed outcome. 1R is taken from a confirmed, non-zero stop on the loss side of entry, so a rejected request can never set it, and the R calculation never divides by zero. If a newly seen position already carries a loss-side stop, for example one set by the entry logic, that stop defines 1R as soon as the engine sees the position. In dry-run and test mode the broker-side result is simulated, and the same ApplyOutcome() path is used.

   // --- Apply a confirmed or partial outcome; the only place where flags change and 1R is set
   string            ApplyOutcome(int idx, ENUM_EXIT_ACTION action, ENUM_EXIT_REASON reason, ENUM_RECONCILE_OUTCOME outcome, SPositionSnapshot &actual)
     {
      int digits = ExitSymbolDigits(actual.symbol);                                         // Digits of the symbol
      string vol_text = DoubleToString(actual.volume, 2);                                   // Volume as text (lots)
      string d = "";                                                                        // Description for the trace
      m_states[idx].last_update_time = TimeCurrent();                                       // Update time (timestamp)

      if(action == EXIT_ACTION_STOP_MODIFICATION)                                           // Stop modification confirmed
        {
         d = "SL " + DoubleToString(actual.current_sl, digits) + " confirmed";              // Describe the stop
         bool first_stop = ((m_states[idx].flags & PSF_INITIAL_STOP) == 0);                 // 1R not established yet
         if(first_stop && IsLossSideStop(actual, actual.current_sl))                        // Confirmed loss-side stop
           {
            double risk = MathAbs(actual.entry_price - actual.current_sl);                  // 1R (price units)
            m_states[idx].initial_risk_distance = risk;                                     // Establish 1R
            m_states[idx].flags |= PSF_INITIAL_STOP;                                        // Initial stop established
            d += "; 1R established = " + DoubleToString(risk, digits);                      // Describe 1R
           }
         else
            if(first_stop)                                                                  // Stop is not on the loss side
               d += "; not a loss-side stop, 1R not established";                           // Describe why
         if(reason == EXIT_REASON_BREAKEVEN)                                                // Breakeven move
            m_states[idx].flags |= PSF_BREAKEVEN;                                           // Breakeven done
         if(reason == EXIT_REASON_TRAILING)                                                 // Trailing move
            m_states[idx].flags |= PSF_TRAILING;                                            // Trailing active
         return d;                                                                          // Description
        }

      if(action == EXIT_ACTION_PARTIAL_EXIT)                                                // Partial close filled
        {
         m_states[idx].flags |= PSF_PARTIAL_TAKEN;                                          // Partial close taken
         bool full = (outcome == RECONCILE_CONFIRMED);                                      // Whole request filled
         d = full ? "partial close confirmed" : "partial close only partly filled";         // Describe fill
         return d + " (volume now " + vol_text + ")";                                       // Description
        }

      if(outcome == RECONCILE_CONFIRMED)                                                    // Full close, position gone
        {
         m_states[idx].flags |= PSF_CLOSED;                                                 // Position closed
         return "position closed";                                                          // Description
        }
      string rest = "full close only partly filled (volume now " + vol_text + ")";          // Partial fill
      return rest + ", re-evaluated next tick";                                             // Description
     }

Every decision, whether executed, rejected, skipped, or waiting for confirmation, produces a decision trace. It contains the position, the current and maximum R, 1R, and the flags; every ranked candidate with its rank; the modules that produced no proposal and why; the verdict of every validated candidate; the selected request; the execution result; the reconciliation outcome; and the flags afterwards. ExplainDecision(ticket) returns the last trace of a position. With persistence enabled, every trace is also written to the CSV log on a single row.

The following trace shows an initial stop decision for a new BUY position on EURUSD without a stop. The structural pivot is tighter than the ATR stop and is therefore ranked first. It is only 30 points from the price, below the broker stop level of 50 points, so the validator falls back to the ATR stop. The values are an example; the line format below is the one the engine produces:

Ticket: 123456 | EURUSD BUY | entry 1.10000 SL 0.00000 price 1.10020 | volume 0.10
R: 0.00 (max 0.00, 1R = not established) | Flags: NEW
Candidates (2 ranked, mode BY_REASON):
  #1 STRUCTURE STOP_MODIFICATION [rank 4] SL=1.09990 : Structural pivot low at 1.09990 is tighter than ATR stop 1.09759
  #2 ATR STOP_MODIFICATION [rank 5] SL=1.09759 : ATR initial stop: entry - 2.00 x ATR(14) = 1.09759
Modules without proposal:
  - BREAKEVEN/TRAILING/PARTIAL: not evaluated (1R not established)
Validation:
  #1 STRUCTURE STOP_MODIFICATION -> STOP_LEVEL_VIOLATION: Stop distance 30.0 points < stop level 50 points.
  #2 ATR STOP_MODIFICATION -> OK: Stop modification accepted (SL normalized 1.097593 -> 1.09759).
Selected: #2 ATR STOP_MODIFICATION
Execution: Accepted: retcode 10009 (OK)
Reconciliation: SL 1.09759 confirmed; 1R established = 0.00241
Flags after: INITIAL_STOP

The trace shows what the engine considered, which candidates were rejected and why, what was sent, what the trade server answered, and what the position looked like afterwards. Explainability is limited to what the trace records. It explains how the engine chose among the proposals it received; it does not explain why the broker returned a particular code. Modules that were not called, such as the R-based modules before 1R exists, appear only as a note.


Testing Strategy

The demo EA runs 23 tests in OnInit() that exercise the actual classes, and it refuses to start if any test fails. Tests marked D use synthetic inputs only and give the same result on every symbol. Tests marked B read live symbol data (ATR, bid/ask, tick size, volume step, or stop level), so their results can differ between symbols and brokers. All tests run in dry-run mode and never send orders.

# Test
Component
Type
1 ATR initial stop anchored to entry, no volume
ATRStopCalculator
B
2 ATR refuses a position on another symbol
ATRStopCalculator
D
3 Structure pivot tighter than ATR; no duplicate proposal otherwise
StructureAwareAdjuster
D
4 Breakeven at entry plus buffer once 1R exists
BreakevenController
D
5 No breakeven proposal before 1R exists
BreakevenController
D
6 Trailing stop at price minus step
TrailingStopManager
D
7 Partial close volume rounded down to the volume step
PartialExitController
B
8 Reason priority beats action type, same result in both input orders
ExitPolicyEngine
D
9 PRECEDENCE_BY_ACTION puts the full exit first
ExitPolicyEngine
D
10 All six reasons ranked; an unlisted reason is ranked last and reported
ExitPolicyEngine
D
11 Tighter stop wins a tie, same result in both input orders
ExitPolicyEngine
D
12 Idempotency (change below threshold returns NO_CHANGE)
ExitValidator
B
13 Risk regression rejected
ExitValidator
B
14 Tick-size normalization before the checks
ExitValidator
B
15 Synthetic stop level violation
ExitValidator
B
16 Fallback to the next candidate after a rejection
AdaptiveExitEngine
B
17 Retcode classification (DONE, DONE_PARTIAL, PLACED, REJECT)
ExitExecutionAdapter
D
18 Dry-run execution sends nothing
ExitExecutionAdapter
D
19 Full pipeline trace; 1R set after the confirmed stop
AdaptiveExitEngine
B
20 Rejected request leaves the state unchanged
AdaptiveExitEngine
B
21 Existing loss-side stop adopted as 1R
AdaptiveExitEngine
D
22 Initial SELL stop above entry accepted
ExitValidator
B
23 Initial SELL stop below entry rejected
ExitValidator
B

Table 3. Deterministic (D) and broker-aware (B) module tests in AdaptiveExitDemo.mq5

The tests are named "Deterministic and Broker-Aware Module Tests" because some depend on live symbol data while others use only synthetic inputs. This distinction matters for an honest evaluation: a B test that passes on one symbol is evidence for that symbol, not proof for all symbols.


Scope of the Engine

The engine intentionally does not:

  • Generate trade entries (the optional demo entry exists only to give the engine a position to manage).
  • Predict market direction.
  • Determine whether a strategy has positive expectancy.
  • Perform portfolio optimization or coordinate decisions across positions.
  • Guarantee execution at the requested price.
  • Guarantee that a close request results in a closed position; reconciliation checks the outcome instead of assuming it.
  • Guarantee profitability.
It receives an already-open position and determines how that position should be managed. Keeping this scope narrow keeps each stage of the pipeline small enough to test on its own.


Known Limitations and Future Work

No research prototype is complete. The following limitations are known and should be considered in any deployment:

  • Management state is memory-resident. A terminal restart loses 1R, the maximum favorable R, the flags, and any pending request.
  • The filling mode is chosen from SYMBOL_FILLING_MODE in the order FOK, IOC, RETURN. RETURN is a fallback and can be rejected on symbols with market execution.
  • The initial ATR stop is measured from entry. If price has already moved beyond that level, the stop is rejected. If no structural stop passes either, the position stays without a protective stop, and the trace reports this on every tick until a candidate passes.
  • After pending_timeout_seconds an unconfirmed request is re-evaluated. With a very slow broker this can lead to a second request for the same action.
  • Persistence is synchronous CSV logging, which is transparent but not high-throughput. ExplainDecision() keeps only the last trace per position; the full history exists only in the CSV log.
  • Slippage, latency, and execution quality are not modeled.
  • The structure detector is a configurable pivot model, not a universal market structure engine.
  • The state and trace tables have a fixed capacity of 100 positions.
  • Tests marked B depend on live symbol data and are not fully deterministic across symbols and brokers. ATR is read from the last closed bar, so it is reproducible within a bar, not across different data feeds.
  • Each position is processed independently, and at most one action is executed per position per tick. Strategies that need several simultaneous actions, conditional order sequences, or portfolio-level coordination are outside the current scope.
  • The partial exit triggers on the maximum favorable R, so it can still fire after price has retraced below the trigger level.
These limitations do not diminish the core contribution: a pipeline that separates proposals from execution, validates candidates against broker rules in a documented order, confirms outcomes before changing state, and records each decision.


Conclusion

The key design point is the separation between the actions that modules propose and the actions that the broker accepts. Proposals are ranked by a documented rule, validated candidate by candidate, executed by a single adapter, and confirmed by reconciliation before the internal state changes. This separation makes each stage observable and testable in isolation, and it moves the selection rule out of the order of function calls and into configuration.

The prototype presented here is not a system that guarantees profits. The separation does not by itself make a strategy profitable or its execution reliable: the quality of the decisions still depends on the configured priorities, on the individual exit rules, and on the broker. What the pipeline provides is a decision process that can be inspected. Every tick leaves a trace that shows what was proposed, what was rejected and why, what was sent, and what the broker-side position looked like afterwards.

New exit rules can be added as proposal generators, each with its own reason and priority slot, without rewriting the engine. Strategies that require several simultaneous actions per tick or coordination across positions need extensions beyond the design shown here.


Files in MQL5.zip:

# File Description
1 AdaptiveExitTypes.mqh Shared enums, structs, configuration, and small helper functions used across the engine.
2 PositionTracker.mqh Reads live position snapshots, with caching and forced refresh.
3 ATRStopCalculator.mqh Proposes the initial stop from ATR of the last closed bar, anchored to entry.
4 StructureAwareAdjuster.mqh Detects pivot-based structural levels and proposes an initial stop when it is tighter than ATR.
5 TrailingStopManager.mqh Proposes trailing stops once the maximum favorable R reaches the activation threshold.
6 BreakevenController.mqh Proposes moving the stop to entry plus buffer once the current R reaches the threshold.
7 PartialExitController.mqh Proposes closing a fraction of the volume once the maximum favorable R reaches the trigger.
8 CTimeExitController.mqh Proposes a full close when a position exceeds its maximum lifetime.
9 ExitPolicyEngine.mqh Ranks all proposals in a strict total order (reason priority, action, documented tie-breakers).
10 ExitValidator.mqh Validates one candidate against risk invariants, broker constraints, and idempotency.
11 ExitExecutionAdapter.mqh The only component that sends trade orders; selects the filling mode and classifies return codes.
12 AdaptiveExitEngine.mqh Facade that runs the pipeline, validates candidates in rank order, reconciles outcomes, manages state, and stores decision traces.
13 ExitPersistence.mqh Appends every decision, with its full trace, to a CSV file for audit.
14 AdaptiveExitDemo.mq5 Demonstration EA with 23 deterministic and broker-aware tests and an optional demo entry for verification.
Attached files |
MQL5.zip (49.42 KB)
Agent Based Market Simulation: Growing a Market From Rules, and Using It to Catch an Overfitted Strategy Agent Based Market Simulation: Growing a Market From Rules, and Using It to Catch an Overfitted Strategy
A compact MetaTrader 5 library builds a synthetic market from agents: a minority game, a zero‑intelligence double auction, a stylized‑facts analyzer, and a parameter scanner. The result is a testbed with known answers, providing the control real price backtests lack. Each result is cross‑checked by the model's phase transition, a matching Python implementation, and detection of a planted edge.
The MQL5 Standard Library Explorer (Part 17): Building a Reusable Portfolio Manager with CArrayObj The MQL5 Standard Library Explorer (Part 17): Building a Reusable Portfolio Manager with CArrayObj
We present a multi‑symbol EA structure that moves symbol‑specific state into independently initialized CStrategyNode objects and manages them with CPortfolioManager. Using CArrayObj with FreeMode, Add(), At(), Delete(), Total(), and Clear(), the manager ensures safe ownership and isolation, while timer‑driven routing and completed‑bar gates keep processing deterministic. This lets you scale portfolios, control risk per node, and avoid duplicated event handling.
Try/Catch style in MQL5: Bringing Modern Error Management to Algorithmic Trading Try/Catch style in MQL5: Bringing Modern Error Management to Algorithmic Trading
This article presents an exception-like model for MQL5 that prevents VM faults through defensive guards and cooperative TRY/CATCH/FINALLY macros. It introduces structured error objects (CException), result propagation (CResult), manual stack tracing, and integrated logging for clear diagnostics. Readers learn how to validate risky operations, propagate failures predictably, and keep EAs stable when facing zero‑divide, null access, invalid handles, and array‑bounds conditions.
Online False Discovery Rate Control: Putting an Error Budget on a Live Signal Stream Online False Discovery Rate Control: Putting an Error Budget on a Live Signal Stream
The article addresses online false discovery rate control for streaming signals, where fixed thresholds inflate noise discoveries. We port LORD3, LORD++ and SAFFRON into MQL5, construct a causal bootstrap p‑value source without a resolution floor, and verify correctness before market data. Dependence stress tests, a budget/level indicator and a two-arm Strategy Tester show how to configure, monitor and throttle streams to keep the acted-on noise fraction within target.