preview
Designing a Unified Order Execution Gateway Class in MQL5

Designing a Unified Order Execution Gateway Class in MQL5

MetaTrader 5Trading systems |
221 1
Ushana Kevin Iorkumbul
Ushana Kevin Iorkumbul

Introduction

Open almost any moderately complex Expert Advisor and you will find OrderSend() called from several different places: one call site for the entry signal, another for a scale-in, another buried inside a trailing stop routine, maybe a fourth inside a recovery or grid module. Each of these call sites tends to grow its own retry logic, its own lot-size rounding, its own idea of what counts as an acceptable stop distance. The code works, mostly, until the broker changes its minimum lot step, or a new error code starts showing up in the Experts log and needs to be added to a retry list — at which point the fix has to be found and applied in every location that touches OrderSend(), and it is easy to miss one.

This is not a hypothetical problem. It is the normal trajectory of an EA codebase that started small and grew. The execution logic is not wrong, exactly — it is just scattered, which makes it hard to test in isolation and hard to change with confidence.

This article builds CExecutionGateway as a single point of contact for trade operations in an EA. The gateway normalizes lot sizes and validates SL/TP against broker constraints. It also resolves the supported filling policy, retries transient retcodes, checks slippage, and returns a structured result. By the end you will have four reusable include files, a thin demo EA that wires them together, and a script that verifies the normalization and validation logic with a set of synthetic assertions.

Architectural diagram

Architectural diagram showing CExecutionGateway mediating between the strategy layer and OrderSend, using CLotNormalizer and CSlTpValidator to validate requests before submission.


Section 1: The Gateway Pattern in Trading Systems

The core idea behind a gateway is a boundary: strategy logic decides what to trade, and the gateway decides how that decision reaches the broker safely. Everything on the "how" side of that boundary belongs inside the gateway.

That includes:

  • Rounding a lot size onto a broker-valid step and clamping it to the symbol's minimum and maximum volume
  • Making sure a stop-loss or take-profit is not so close to the current price that the broker will reject it outright
  • Determining which order filling policy the broker actually supports for a given symbol
  • Deciding whether a failed OrderSend() call is worth retrying, and how many times
  • Comparing the price the strategy asked for against the price the broker actually filled at

None of this is a strategy decision. A moving-average crossover system and a mean-reversion system both need their lot sizes rounded the same way, and both need the same handling for a requote. Putting this logic in one place means a single fix updates every strategy that uses the gateway. For example, adding TRADE_RETCODE_PRICE_OFF to the retry list would not require any changes to strategy code.

What does not belong inside the gateway is anything that requires knowledge of why a trade is happening: position sizing based on account risk percentage, decisions about which symbols to trade, or logic about when to exit based on an indicator value. The gateway receives a fully-formed request — a symbol, a direction, a lot size, optional stops — and its only job is to get that request to the broker correctly, or report clearly why it could not.

Drawing this boundary early keeps the gateway testable in isolation. The tests run against the normalizer and validator directly, without a chart, open positions, or live orders. This logic does not depend on a strategy layer.


Section 2: CGatewayResult — the Execution Result Contract

A raw MqlTradeResult.retcode tells you what the trade server did, but not what your EA should do next. Returning a bool from an execution function is even less useful — a false value collapses "the broker rejected the stop distance," "the connection timed out after every retry," and "the fill happened but at a price you shouldn't accept" into the same signal.

CGatewayResult is a plain struct rather than a class, because it is pure data with no behavior of its own — every field is meant to be read, not computed, by the time the caller sees it.

//+------------------------------------------------------------------+
//|                                              GatewayResult.mqh   |
//|                        Structured outcome contract for the       |
//|                        execution gateway                         |
//+------------------------------------------------------------------+

#ifndef GATEWAY_RESULT_MQH
#define GATEWAY_RESULT_MQH

//+------------------------------------------------------------------+
//| CGatewayResult                                                   |
//| Everything a caller needs after an execution attempt, without    |
//| having to parse a raw MqlTradeResult or retcode itself.          |
//+------------------------------------------------------------------+
struct CGatewayResult
  {
   bool              success;             // true if the trade server accepted and filled the request within tolerance
   double            fill_price;          // actual price the position was opened or closed at
   double            filled_volume;       // actual lot size that was filled
   double            submitted_sl;        // SL price actually sent to the broker after normalization
   double            submitted_tp;        // TP price actually sent to the broker after normalization
   uint              retcode;             // raw trade server return code from the last attempt
   int               attempts_used;       // number of OrderSend attempts consumed, including the first
   bool              slippage_rejected;   // true if the fill was outside the caller's slippage tolerance
   string            reason;              // human-readable description of the outcome

   //+---------------------------------------------------------------+
   //| Constructor - zero-initializes every field                    |
   //+---------------------------------------------------------------+
                     CGatewayResult(void)
     {
      success           = false;
      fill_price        = 0.0;
      filled_volume     = 0.0;
      submitted_sl      = 0.0;
      submitted_tp      = 0.0;
      retcode           = 0;
      attempts_used     = 0;
      slippage_rejected = false;
      reason            = "";
     }

   //+---------------------------------------------------------------+
   //| Destructor                                                    |
   //+---------------------------------------------------------------+
                    ~CGatewayResult(void)
     {
     }
  };

#endif // GATEWAY_RESULT_MQH
//+---------------------------------------------------------------+

Each field earns its place by answering a question a caller will actually ask:

  • success is the first thing any caller checks. It is true only when the trade server confirmed the request and the fill was within the caller's slippage tolerance — not merely when OrderSend() returned true, since that only means the request reached the server, not that it was accepted.
  • fill_price and filled_volume report what actually happened, which can differ from what was requested even on a successful fill, particularly on partial fills or during fast markets.
  • submitted_sl and submitted_tp report what the gateway actually sent after CSlTpValidator may have widened them — useful for a caller that wants to log or display the real protective levels rather than the ones it originally asked for.
  • retcode preserves the raw code for callers that want to branch on it directly, or simply log it for later analysis, without forcing every caller to do that parsing.
  • attempts_used exposes how many tries the internal retry loop needed, which is useful telemetry for tuning m_max_attempts later.
  • slippage_rejected is a separate flag from success on purpose: a fill that happened but exceeded the caller's slippage tolerance is still a fill — the position is open — but the caller may want to manage it differently than a clean fill, for instance by tightening a stop immediately.
  • reason is the field a Print() call reaches for. It is written in plain language rather than a code, precisely so nothing downstream has to translate it further.


Section 3: CLotNormalizer — Lot Size Normalization

Every symbol publishes three volume constraints through SymbolInfoDouble(): SYMBOL_VOLUME_STEP (the smallest increment a lot size can move by), SYMBOL_VOLUME_MIN (the smallest tradable size), and SYMBOL_VOLUME_MAX (the largest). A request that ignores any of these gets rejected by the trade server with TRADE_RETCODE_INVALID_VOLUME — a completely avoidable rejection if the lot size is corrected before it is ever submitted.

The rounding formula is straightforward: divide the requested lot size by the step, round to the nearest whole number of steps, multiply back. CLotNormalizer caches the three constraints per symbol so repeated calls within the same tick do not repeatedly hit SymbolInfoDouble().

//+------------------------------------------------------------------+
//|                                             LotNormalizer.mqh    |
//|                        Rounds and clamps a requested lot size    |
//|                        to the symbol's broker constraints        |
//+------------------------------------------------------------------+

#ifndef LOT_NORMALIZER_MQH
#define LOT_NORMALIZER_MQH

//+------------------------------------------------------------------+
//| CLotNormalizer                                                   |
//| Reads SYMBOL_VOLUME_STEP, SYMBOL_VOLUME_MIN, and                 |
//| SYMBOL_VOLUME_MAX for a given symbol and rounds any requested    |
//| lot size onto a valid step, clamped to the broker's range.       |
//+------------------------------------------------------------------+
class CLotNormalizer
  {
private:
   string            m_last_symbol;       // symbol most recently queried, used to avoid redundant lookups
   double            m_cached_step;       // cached SYMBOL_VOLUME_STEP for m_last_symbol
   double            m_cached_min;        // cached SYMBOL_VOLUME_MIN for m_last_symbol
   double            m_cached_max;        // cached SYMBOL_VOLUME_MAX for m_last_symbol
   bool              m_cache_valid;       // whether the cache currently holds valid data

   void              RefreshCache(const string symbol);

public:
                     CLotNormalizer(void);
                    ~CLotNormalizer(void);

   double            Normalize(const string symbol,const double requested_lots);
   bool              GetConstraints(const string symbol,double &step,double &vol_min,double &vol_max);
  };

//+------------------------------------------------------------------+
//| Constructor                                                      |
//+------------------------------------------------------------------+
CLotNormalizer::CLotNormalizer(void)
  {
   m_last_symbol = "";
   m_cached_step = 0.0;
   m_cached_min  = 0.0;
   m_cached_max  = 0.0;
   m_cache_valid = false;
  }

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

//+------------------------------------------------------------------+
//| RefreshCache                                                     |
//| Pulls SYMBOL_VOLUME_STEP / MIN / MAX for symbol and stores them  |
//| so repeated calls for the same symbol on the same tick avoid     |
//| redundant SymbolInfoDouble() calls.                              |
//+------------------------------------------------------------------+
void CLotNormalizer::RefreshCache(const string symbol)
  {
   if(m_cache_valid && m_last_symbol == symbol)
      return;

//--- pull the three constraints fresh for this symbol
   m_cached_step = ::SymbolInfoDouble(symbol,SYMBOL_VOLUME_STEP);
   m_cached_min  = ::SymbolInfoDouble(symbol,SYMBOL_VOLUME_MIN);
   m_cached_max  = ::SymbolInfoDouble(symbol,SYMBOL_VOLUME_MAX);

//--- guard against a broker returning zero step, which would cause division by zero below
   if(m_cached_step <= 0.0)
      m_cached_step = 0.01;

   m_last_symbol = symbol;
   m_cache_valid = true;
  }

The zero-step guard matters more than it looks: a misconfigured symbol or a data feed gap can occasionally report SYMBOL_VOLUME_STEP as 0.0, and dividing by that in Normalize() would produce a division-by-zero runtime error rather than a rejected order — a much worse failure mode.

//+------------------------------------------------------------------+
//| GetConstraints                                                   |
//| Exposes the raw broker constraints to callers that want to       |
//| display or log them without normalizing a lot value.             |
//+------------------------------------------------------------------+
bool CLotNormalizer::GetConstraints(const string symbol,double &step,double &vol_min,double &vol_max)
  {
   RefreshCache(symbol);

   step    = m_cached_step;
   vol_min = m_cached_min;
   vol_max = m_cached_max;

   return(true);
  }

//+------------------------------------------------------------------+
//| Normalize                                                        |
//| Rounds requested_lots to the nearest multiple of the symbol's    |
//| volume step, then clamps the result to [SYMBOL_VOLUME_MIN,       |
//| SYMBOL_VOLUME_MAX]. Returns the normalized lot size.             |
//+------------------------------------------------------------------+
double CLotNormalizer::Normalize(const string symbol,const double requested_lots)
  {
   RefreshCache(symbol);

//--- round to the nearest step: divide by step, round to nearest integer, multiply back
   double steps_count = ::MathRound(requested_lots / m_cached_step);
   double normalized  = steps_count * m_cached_step;

//--- clamp against the broker's minimum and maximum allowed volume
   if(normalized < m_cached_min)
      normalized = m_cached_min;

   if(normalized > m_cached_max)
      normalized = m_cached_max;

//--- guard against floating point drift by rounding to a sane number of decimals
   int digits_after_step = 0;
   double step_check = m_cached_step;

   while(step_check < 1.0 && digits_after_step < 8)
     {
      step_check *= 10.0;
      digits_after_step++;
     }

   normalized = ::NormalizeDouble(normalized,digits_after_step);

   return(normalized);
  }

The final NormalizeDouble() pass is there because floating-point division and multiplication can leave a value like 0.14000000000000001 instead of a clean 0.14, which some brokers' validation is stricter about than others. Working out how many decimal places the step implies, rather than hardcoding two, keeps the function correct for symbols with a coarser step such as 1.0 lot minimum increments on some futures-style contracts.


Section 4: CSlTpValidator — Stop Level Enforcement

SYMBOL_TRADE_STOPS_LEVEL, read through SymbolInfoInteger(), gives the minimum distance in points that a stop-loss or take-profit must sit from the current price. Send a stop closer than that and the broker returns TRADE_RETCODE_INVALID_STOPS — again, an avoidable rejection.

The minimum distance in points has to be converted to a price distance by multiplying by SYMBOL_POINT, and then the check differs by direction: for a BUY, both the SL (below) and TP (above) must be at least that price-distance from the entry price; for a SELL it is mirrored.

//+------------------------------------------------------------------+
//|                                             SlTpValidator.mqh    |
//|                        Checks and adjusts SL/TP distances        |
//|                        against SYMBOL_TRADE_STOPS_LEVEL          |
//+------------------------------------------------------------------+

#ifndef SLTP_VALIDATOR_MQH
#define SLTP_VALIDATOR_MQH

//+------------------------------------------------------------------+
//| CSlTpValidator                                                   |
//| Reads SYMBOL_TRADE_STOPS_LEVEL for a symbol and, given an order  |
//| type, entry price, and requested SL/TP, widens any stop that     |
//| falls inside the broker's minimum distance. A stop of exactly    |
//| 0.0 is treated as "not set" and left untouched.                  |
//+------------------------------------------------------------------+
class CSlTpValidator
  {
private:
   string            m_last_symbol;         // symbol most recently queried
   double            m_cached_min_distance; // cached minimum distance in price terms
   bool              m_cache_valid;         // whether the cache currently holds valid data

   void              RefreshCache(const string symbol);

public:
                     CSlTpValidator(void);
                    ~CSlTpValidator(void);

   double            GetMinDistance(const string symbol);
   void              Validate(const string symbol,
                              const ENUM_ORDER_TYPE order_type,
                              const double entry_price,
                              double &sl,
                              double &tp,
                              bool &sl_adjusted,
                              bool &tp_adjusted);
  };

//+------------------------------------------------------------------+
//| Constructor                                                      |
//+------------------------------------------------------------------+
CSlTpValidator::CSlTpValidator(void)
  {
   m_last_symbol         = "";
   m_cached_min_distance = 0.0;
   m_cache_valid         = false;
  }

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

//+------------------------------------------------------------------+
//| RefreshCache                                                     |
//| Pulls SYMBOL_TRADE_STOPS_LEVEL and SYMBOL_POINT for symbol and   |
//| converts the stops level from points into a price distance. If   |
//| the broker reports zero, the minimum is derived from the         |
//| current spread instead of a fixed constant, since a zero stops   |
//| level commonly means the broker enforces a floating distance     |
//| tied to spread rather than a genuine zero minimum.               |
//+------------------------------------------------------------------+
void CSlTpValidator::RefreshCache(const string symbol)
  {
   if(m_cache_valid && m_last_symbol == symbol)
      return;

   long stops_level_points = ::SymbolInfoInteger(symbol,SYMBOL_TRADE_STOPS_LEVEL);
   double point_size       = ::SymbolInfoDouble(symbol,SYMBOL_POINT);

   if(stops_level_points <= 0)
     {
      //--- derive a spread-based fallback: many brokers enforce roughly
      //--- 2x to 3x the current spread as their real minimum distance
      //--- when SYMBOL_TRADE_STOPS_LEVEL reports zero
      double ask = ::SymbolInfoDouble(symbol,SYMBOL_ASK);
      double bid = ::SymbolInfoDouble(symbol,SYMBOL_BID);
      double spread_points = (point_size > 0.0) ? (ask - bid) / point_size : 0.0;

      double multiplier = 3.0; // conservative; adjust per broker if needed
      stops_level_points = (long)::MathCeil(spread_points * multiplier);

      //--- guard against a zero or negative spread reading
      if(stops_level_points <= 0)
         stops_level_points = 10; // last-resort fallback only if spread is unavailable
     }

   m_cached_min_distance = (double)stops_level_points * point_size;
   m_last_symbol         = symbol;
   m_cache_valid         = true;
  }

A stop level of zero rarely means "no minimum enforced." More often it signals that the broker ties its real minimum to the current spread rather than reporting a fixed point value. RefreshCache() reflects this by deriving the fallback from the live spread using a 3x multiplier, a common convention on ECN-style accounts, rather than assuming a flat point value. The hardcoded 10-point fallback is kept only as a last resort, used solely when the spread itself cannot be read.

//+------------------------------------------------------------------+
//| GetMinDistance                                                   |
//| Exposes the computed minimum SL/TP distance in price terms so    |
//| callers can log it or use it in their own pre-checks.            |
//+------------------------------------------------------------------+
double CSlTpValidator::GetMinDistance(const string symbol)
  {
   RefreshCache(symbol);
   return(m_cached_min_distance);
  }

//+------------------------------------------------------------------+
//| Validate                                                         |
//| Checks sl and tp against the minimum distance from entry_price   |
//| for the given order_type, widening either one outward to the     |
//| minimum distance if it is too close. sl_adjusted and tp_adjusted |
//| report which fields, if any, were changed. A value of 0.0 for    |
//| sl or tp means "no stop requested" and is never touched.         |
//+------------------------------------------------------------------+
void CSlTpValidator::Validate(const string symbol,
                              const ENUM_ORDER_TYPE order_type,
                              const double entry_price,
                              double &sl,
                              double &tp,
                              bool &sl_adjusted,
                              bool &tp_adjusted)
  {
   RefreshCache(symbol);

   sl_adjusted = false;
   tp_adjusted = false;

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

   if(order_type == ORDER_TYPE_BUY)
     {
      //--- for a BUY, SL must sit below entry by at least the minimum distance
      if(sl != 0.0 && (entry_price - sl) < m_cached_min_distance)
        {
         sl = ::NormalizeDouble(entry_price - m_cached_min_distance,digits);
         sl_adjusted = true;
        }

      //--- for a BUY, TP must sit above entry by at least the minimum distance
      if(tp != 0.0 && (tp - entry_price) < m_cached_min_distance)
        {
         tp = ::NormalizeDouble(entry_price + m_cached_min_distance,digits);
         tp_adjusted = true;
        }
     }
   else
      if(order_type == ORDER_TYPE_SELL)
        {
         //--- for a SELL, SL must sit above entry by at least the minimum distance
         if(sl != 0.0 && (sl - entry_price) < m_cached_min_distance)
           {
            sl = ::NormalizeDouble(entry_price + m_cached_min_distance,digits);
            sl_adjusted = true;
           }

         //--- for a SELL, TP must sit below entry by at least the minimum distance
         if(tp != 0.0 && (entry_price - tp) < m_cached_min_distance)
           {
            tp = ::NormalizeDouble(entry_price - m_cached_min_distance,digits);
            tp_adjusted = true;
           }
        }
  }

Two details are easy to get wrong here and worth calling out explicitly. First, a stop-loss or take-profit value of exactly 0.0 means "the caller did not request one" and must never be pushed to the minimum distance — the check on sl != 0.0 guards this. Second, the adjustment always widens outward, in the safe direction: a too-close SL is moved further from the entry price, never closer, and the same logic applies to the TP. The function never silently narrows a stop the caller explicitly asked for.


Section 5: CExecutionGateway — the Central Gateway

CExecutionGateway owns one instance of each sub-component and exposes two public methods: OpenPosition() and ClosePosition(). Internally, opening a position walks through several steps in order — normalize the lot, read the current price, validate the stops, resolve the correct filling policy, submit with retries, and check the fill against the slippage tolerance.

//+------------------------------------------------------------------+
//|                                            ExecutionGateway.mqh  |
//|                        The single point of contact for all       |
//|                        trade operations in the EA                |
//+------------------------------------------------------------------+

#ifndef EXECUTION_GATEWAY_MQH
#define EXECUTION_GATEWAY_MQH

#include "GatewayResult.mqh"
#include "LotNormalizer.mqh"
#include "SlTpValidator.mqh"

//+------------------------------------------------------------------+
//| CExecutionGateway                                                |
//| Owns a CLotNormalizer and a CSlTpValidator. Every open, modify,  |
//| and close operation in the EA is expected to route through this  |
//| class rather than calling OrderSend() directly. Internally it    |
//| normalizes lots, validates stops, retries on transient retcodes, |
//| and checks the actual fill against a slippage tolerance before   |
//| reporting success back to the caller.                            |
//+------------------------------------------------------------------+
class CExecutionGateway
  {
private:
   CLotNormalizer    m_lot_normalizer;       // sub-component: lot size rounding and clamping
   CSlTpValidator    m_sltp_validator;       // sub-component: stop distance enforcement
   int               m_max_attempts;         // maximum OrderSend attempts per request, including the first
   int               m_retry_sleep_ms;       // sleep between retry attempts, in milliseconds
   int               m_max_slippage_points;  // caller's maximum tolerated slippage, in points
   ulong             m_magic_number;         // magic number stamped on every request

   bool              IsRetryableRetcode(const uint retcode);
   double            CalculateSlippagePoints(const string symbol,const double requested_price,const double fill_price);
   void              LogAdjustment(const string context,const double before,const double after);
   ENUM_ORDER_TYPE_FILLING ResolveFillingMode(const string symbol);

public:
                     CExecutionGateway(void);
                    ~CExecutionGateway(void);

   void              Configure(const int max_attempts,const int retry_sleep_ms,const int max_slippage_points,const ulong magic_number);

   CGatewayResult    OpenPosition(const string symbol,
                                  const ENUM_ORDER_TYPE order_type,
                                  const double requested_lots,
                                  double sl,
                                  double tp,
                                  const string comment);

   CGatewayResult    ClosePosition(const ulong ticket);
  };

//+------------------------------------------------------------------+
//| Constructor                                                      |
//+------------------------------------------------------------------+
CExecutionGateway::CExecutionGateway(void)
  {
   m_max_attempts        = 3;
   m_retry_sleep_ms      = 200;
   m_max_slippage_points = 20;
   m_magic_number        = 0;
  }

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

//+------------------------------------------------------------------+
//| Configure                                                        |
//| Sets the gateway's retry policy, slippage tolerance, and magic   |
//| number. Call this once during OnInit() before any trade calls.   |
//+------------------------------------------------------------------+
void CExecutionGateway::Configure(const int max_attempts,const int retry_sleep_ms,const int max_slippage_points,const ulong magic_number)
  {
   m_max_attempts        = (max_attempts >= 1 ? max_attempts : 1);
   m_retry_sleep_ms      = (retry_sleep_ms >= 0 ? retry_sleep_ms : 0);
   m_max_slippage_points = (max_slippage_points >= 0 ? max_slippage_points : 0);
   m_magic_number        = magic_number;
  }

IsRetryableRetcode() is the single place the three retryable retcodes are listed, matching the values documented in the MQL5 reference for ENUM_TRADE_RETCODE: TRADE_RETCODE_REQUOTE (10004), TRADE_RETCODE_TIMEOUT (10012), and TRADE_RETCODE_PRICE_CHANGED (10020).

//+------------------------------------------------------------------+
//| IsRetryableRetcode                                               |
//| Returns true for the three retcodes the gateway is configured to |
//| retry internally: REQUOTE, PRICE_CHANGED, and TIMEOUT. All other |
//| retcodes are treated as final and surface immediately.           |
//+------------------------------------------------------------------+
bool CExecutionGateway::IsRetryableRetcode(const uint retcode)
  {
   return(retcode == TRADE_RETCODE_REQUOTE ||
          retcode == TRADE_RETCODE_PRICE_CHANGED ||
          retcode == TRADE_RETCODE_TIMEOUT);
  }

Keeping this classification in one function is the entire point of centralizing execution logic: adding a fourth retryable code later — TRADE_RETCODE_PRICE_OFF, say, if a particular broker's behavior warrants it — means changing this one function, not searching the codebase for every retry loop.

//+------------------------------------------------------------------+
//| CalculateSlippagePoints                                          |
//| Returns the absolute distance between requested_price and        |
//| fill_price expressed in points for the symbol.                   |
//+------------------------------------------------------------------+
double CExecutionGateway::CalculateSlippagePoints(const string symbol,const double requested_price,const double fill_price)
  {
   double point_size = ::SymbolInfoDouble(symbol,SYMBOL_POINT);

   if(point_size <= 0.0)
      return(0.0);

   return(::MathAbs(fill_price - requested_price) / point_size);
  }

//+------------------------------------------------------------------+
//| LogAdjustment                                                    |
//| Writes a single line to the Experts log describing a value that  |
//| the gateway changed before submission, so the change is visible  |
//| without stepping through a debugger.                             |
//+------------------------------------------------------------------+
void CExecutionGateway::LogAdjustment(const string context,const double before,const double after)
  {
   PrintFormat("CExecutionGateway: %s adjusted from %.5f to %.5f",context,before,after);
  }

Not every broker accepts every filling policy for every symbol. Sending ORDER_FILLING_FOK to a broker that only supports IOC or RETURN for a given instrument produces TRADE_RETCODE_INVALID_FILL, another entirely avoidable rejection. SYMBOL_FILLING_MODE, read through SymbolInfoInteger(), returns a bitmask of the filling policies the broker allows, so ResolveFillingMode() checks that mask and picks the best supported option rather than assuming one:

//+------------------------------------------------------------------+
//| ResolveFillingMode                                               |
//| Reads SYMBOL_FILLING_MODE, a bitmask of the filling policies the |
//| broker allows for symbol, and returns the best supported         |
//| ENUM_ORDER_TYPE_FILLING value. FOK is preferred when available,  |
//| then IOC, falling back to ORDER_FILLING_RETURN when neither flag |
//| is set. RETURN is always accepted outside of pure market         |
//| execution and is the trade server's own default when no filling  |
//| type is specified at all.                                        |
//+------------------------------------------------------------------+
ENUM_ORDER_TYPE_FILLING CExecutionGateway::ResolveFillingMode(const string symbol)
  {
   uint filling_flags = (uint)::SymbolInfoInteger(symbol,SYMBOL_FILLING_MODE);

   if((filling_flags & SYMBOL_FILLING_FOK) == SYMBOL_FILLING_FOK)
      return(ORDER_FILLING_FOK);

   if((filling_flags & SYMBOL_FILLING_IOC) == SYMBOL_FILLING_IOC)
      return(ORDER_FILLING_IOC);

   return(ORDER_FILLING_RETURN);
  }

OpenPosition() is where these pieces come together:

//+------------------------------------------------------------------+
//| OpenPosition                                                     |
//| The primary entry point for opening a new position. Normalizes   |
//| the lot size, validates SL/TP against the broker's stop level,   |
//| then submits the request with an internal retry loop on          |
//| transient retcodes. Returns a fully populated CGatewayResult.    |
//+------------------------------------------------------------------+
CGatewayResult CExecutionGateway::OpenPosition(const string symbol,
      const ENUM_ORDER_TYPE order_type,
      const double requested_lots,
      double sl,
      double tp,
      const string comment)
  {
   CGatewayResult result;

//--- only BUY and SELL are supported by this gateway; anything else is a caller error
   if(order_type != ORDER_TYPE_BUY && order_type != ORDER_TYPE_SELL)
     {
      result.success = false;
      result.reason  = "unsupported order type for OpenPosition";
      return(result);
     }

//--- step 1: normalize the requested lot size against broker constraints
   double original_lots   = requested_lots;
   double normalized_lots = m_lot_normalizer.Normalize(symbol,requested_lots);

   if(::MathAbs(normalized_lots - original_lots) > 0.0000001)
      LogAdjustment("lot size",original_lots,normalized_lots);

//--- step 2: fetch the current price for this order type
   double entry_price = (order_type == ORDER_TYPE_BUY)
                        ? ::SymbolInfoDouble(symbol,SYMBOL_ASK)
                        : ::SymbolInfoDouble(symbol,SYMBOL_BID);

   if(entry_price <= 0.0)
     {
      result.success = false;
      result.reason  = "unable to read a valid market price for " + symbol;
      return(result);
     }

//--- step 3: validate and, if necessary, widen SL/TP against the stop level
   double original_sl = sl;
   double original_tp = tp;
   bool sl_adjusted   = false;
   bool tp_adjusted   = false;

   m_sltp_validator.Validate(symbol,order_type,entry_price,sl,tp,sl_adjusted,tp_adjusted);

   if(sl_adjusted)
      LogAdjustment("stop loss",original_sl,sl);

   if(tp_adjusted)
      LogAdjustment("take profit",original_tp,tp);

   result.submitted_sl = sl;
   result.submitted_tp = tp;

At this point the request has a valid lot size and stops that satisfy the minimum distance the gateway is aware of — before a single byte reaches OrderSend(). The retry loop follows, resolving the filling policy fresh on every attempt:

//--- step 4: submit with an internal retry loop on transient retcodes
   int attempt = 0;
   MqlTradeRequest request;
   MqlTradeResult  send_result;

   while(attempt < m_max_attempts)
     {
      attempt++;

      ::ZeroMemory(request);
      ::ZeroMemory(send_result);

      //--- refresh the price on every attempt since a retry implies the previous price may be stale
      entry_price = (order_type == ORDER_TYPE_BUY)
                    ? ::SymbolInfoDouble(symbol,SYMBOL_ASK)
                    : ::SymbolInfoDouble(symbol,SYMBOL_BID);

      request.action       = TRADE_ACTION_DEAL;
      request.symbol       = symbol;
      request.volume       = normalized_lots;
      request.type         = order_type;
      request.price        = entry_price;
      request.sl           = sl;
      request.tp           = tp;
      request.deviation    = (ulong)m_max_slippage_points;
      request.magic        = m_magic_number;
      request.comment      = comment;
      request.type_filling = ResolveFillingMode(symbol);

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

      result.retcode       = send_result.retcode;
      result.attempts_used = attempt;

      if(!send_ok)
        {
         //--- OrderSend() itself failed to reach the trade server; check whether this is retryable
         if(IsRetryableRetcode(send_result.retcode) && attempt < m_max_attempts)
           {
            ::Sleep(m_retry_sleep_ms);
            continue;
           }

         result.success = false;
         result.reason  = "OrderSend failed with retcode " + (string)send_result.retcode;
         return(result);
        }

Refreshing the price and filling mode on each retry matters. A requote implies the previous price is stale, and supported filling policies can change for fast-moving instruments. Rebuilding the request on each attempt reduces repeated failures.

The rest of the method inspects the outcome and, on success, runs the slippage check:

//--- OrderSend() returned true; inspect the retcode to determine whether the deal actually completed
      if(send_result.retcode == TRADE_RETCODE_DONE || send_result.retcode == TRADE_RETCODE_PLACED)
        {
         //--- step 5: check the fill against the caller's slippage tolerance
         double slippage_points = CalculateSlippagePoints(symbol,entry_price,send_result.price);

         result.fill_price    = send_result.price;
         result.filled_volume = send_result.volume;

         if(slippage_points > (double)m_max_slippage_points)
           {
            result.success           = true;
            result.slippage_rejected = true;
            result.reason            = "filled but slippage of " + DoubleToString(slippage_points,1) +
                                        " points exceeded tolerance of " + (string)m_max_slippage_points + " points";
            return(result);
           }

         result.success           = true;
         result.slippage_rejected = false;
         result.reason            = "filled within tolerance";
         return(result);
        }

      //--- non-success retcode: decide whether to retry or surface immediately
      if(IsRetryableRetcode(send_result.retcode) && attempt < m_max_attempts)
        {
         ::Sleep(m_retry_sleep_ms);
         continue;
        }

      result.success = false;
      result.reason  = "trade server rejected request with retcode " + (string)send_result.retcode;
      return(result);
     }

//--- exhausted all attempts without a terminal outcome above; report the last known retcode
   result.success = false;
   result.reason  = "exhausted " + (string)m_max_attempts + " attempts without a successful fill";
   return(result);
  }

Notice that slippage_rejected = true is paired with success = true, not false — the position was genuinely opened, and the caller needs to know both facts: the trade exists, and it did not fill where expected. Collapsing that into a single boolean would force the caller to guess which condition happened.

ClosePosition() follows the same retry, logging, and filling-mode structure, adapted for closing an existing ticket rather than opening a new one:

//+------------------------------------------------------------------+
//| ClosePosition                                                    |
//| Closes an open position by ticket using its full current volume. |
//| Applies the same slippage check and retry loop as OpenPosition.  |
//+------------------------------------------------------------------+
CGatewayResult CExecutionGateway::ClosePosition(const ulong ticket)
  {
   CGatewayResult result;

   if(!::PositionSelectByTicket(ticket))
     {
      result.success = false;
      result.reason  = "no open position found for ticket " + (string)ticket;
      return(result);
     }

   string symbol          = ::PositionGetString(POSITION_SYMBOL);
   double position_volume = ::PositionGetDouble(POSITION_VOLUME);
   long position_type     = ::PositionGetInteger(POSITION_TYPE);

   ENUM_ORDER_TYPE close_order_type = (position_type == POSITION_TYPE_BUY) ? ORDER_TYPE_SELL : ORDER_TYPE_BUY;

   int attempt = 0;
   MqlTradeRequest request;
   MqlTradeResult  send_result;

   while(attempt < m_max_attempts)
     {
      attempt++;

      ::ZeroMemory(request);
      ::ZeroMemory(send_result);

      double close_price = (close_order_type == ORDER_TYPE_SELL)
                           ? ::SymbolInfoDouble(symbol,SYMBOL_BID)
                           : ::SymbolInfoDouble(symbol,SYMBOL_ASK);

      request.action        = TRADE_ACTION_DEAL;
      request.symbol        = symbol;
      request.volume        = position_volume;
      request.type          = close_order_type;
      request.price         = close_price;
      request.position      = ticket;
      request.deviation     = (ulong)m_max_slippage_points;
      request.magic         = m_magic_number;
      request.type_filling  = ResolveFillingMode(symbol);

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

      result.retcode        = send_result.retcode;
      result.attempts_used  = attempt;

      if(!send_ok)
        {
         if(IsRetryableRetcode(send_result.retcode) && attempt < m_max_attempts)
           {
            ::Sleep(m_retry_sleep_ms);
            continue;
           }

         result.success = false;
         result.reason  = "OrderSend failed closing ticket " + (string)ticket + " with retcode " + (string)send_result.retcode;
         return(result);
        }

      if(send_result.retcode == TRADE_RETCODE_DONE)
        {
         result.success        = true;
         result.fill_price     = send_result.price;
         result.filled_volume  = send_result.volume;
         result.reason         = "position closed";
         return(result);
        }

      if(IsRetryableRetcode(send_result.retcode) && attempt < m_max_attempts)
        {
         ::Sleep(m_retry_sleep_ms);
         continue;
        }

      result.success = false;
      result.reason  = "trade server rejected close with retcode " + (string)send_result.retcode;
      return(result);
     }

   result.success = false;
   result.reason  = "exhausted " + (string)m_max_attempts + " attempts closing ticket " + (string)ticket;
   return(result);
  }

ClosePosition() reads the position's own symbol, volume, and direction from PositionGetString(), PositionGetDouble(), and PositionGetInteger() rather than requiring the caller to supply them — the ticket is the only input needed, which removes an entire category of mistake where a caller closes the wrong volume or the wrong symbol.


Section 6: GatewayEA.mq5 — Integration Demo

The demo EA is intentionally thin. Its only responsibilities are configuring the gateway once and calling its two public methods — there is no OrderSend() call anywhere in this file.

//+------------------------------------------------------------------+
//|                                                    GatewayEA.mq5 |
//|                        Thin demo EA wiring up CExecutionGateway  |
//|                        Opens and closes one position per day     |
//+------------------------------------------------------------------+

#property strict

#include <ExecutionGateway/ExecutionGateway.mqh>

//--- Input parameters
input double InpLots             = 0.10;     // requested lot size before normalization
input int    InpStopLossPoints   = 300;      // stop loss distance in points from entry
input int    InpTakeProfitPoints = 600;      // take profit distance in points from entry
input int    InpMaxAttempts      = 3;        // maximum OrderSend attempts per request
input int    InpRetrySleepMs     = 200;      // sleep between retries, in milliseconds
input int    InpMaxSlippagePts   = 20;       // maximum tolerated slippage, in points
input ulong  InpMagicNumber      = 990011;   // magic number stamped on every request
input int    InpCloseHour        = 20;       // server hour at which the day's position is closed

//--- Module-level state
CExecutionGateway g_gateway;
datetime          g_last_open_day = 0;
ulong             g_open_ticket   = 0;

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit(void)
  {
//--- configure the gateway once; every trade call afterward reuses this policy
   g_gateway.Configure(InpMaxAttempts,InpRetrySleepMs,InpMaxSlippagePts,InpMagicNumber);

   ::Print("GatewayEA: initialized, routing all trade calls through CExecutionGateway");

   return(INIT_SUCCEEDED);
  }

//+------------------------------------------------------------------+
//| Expert deinitialization function                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
   ::Print("GatewayEA: deinitialized, reason code " + (string)reason);
  }

OnTick() decides when to trade — once per calendar day, closing at a configured server hour — but delegates every mechanical detail of how to the gateway:

//+------------------------------------------------------------------+
//| Expert tick function                                             |
//+------------------------------------------------------------------+
void OnTick(void)
  {
   MqlDateTime now;
   ::TimeToStruct(::TimeCurrent(),now);

   datetime today_start = ::TimeCurrent() - (now.hour * 3600 + now.min * 60 + now.sec);

//--- open exactly one position per calendar day, if none is already open
   if(g_last_open_day != today_start && g_open_ticket == 0)
     {
      TryOpenDailyPosition();
      g_last_open_day = today_start;
     }

//--- close the day's position once the configured hour is reached
   if(g_open_ticket != 0 && now.hour >= InpCloseHour)
     {
      TryCloseDailyPosition();
     }
  }

TryOpenDailyPosition() builds a request in terms a strategy naturally thinks in — points of distance from the current price — and hands the whole thing to OpenPosition():

//+------------------------------------------------------------------+
//| TryOpenDailyPosition                                             |
//| Calls CExecutionGateway::OpenPosition() and logs the structured  |
//| result. No raw OrderSend() call appears anywhere in this EA.     |
//+------------------------------------------------------------------+
void TryOpenDailyPosition(void)
  {
   string symbol = ::Symbol();
   double ask    = ::SymbolInfoDouble(symbol,SYMBOL_ASK);

   double sl     = ask - InpStopLossPoints * ::SymbolInfoDouble(symbol,SYMBOL_POINT);
   double tp     = ask + InpTakeProfitPoints * ::SymbolInfoDouble(symbol,SYMBOL_POINT);

   CGatewayResult result = g_gateway.OpenPosition(symbol,ORDER_TYPE_BUY,InpLots,sl,tp,"GatewayEA daily open");

   if(result.success)
     {
      ::PrintFormat("GatewayEA: opened position, fill=%.5f volume=%.2f slippage_rejected=%s reason=%s",
                    result.fill_price,result.filled_volume,
                    (result.slippage_rejected ? "true" : "false"),result.reason);

      //--- locate the ticket of the position we just opened so it can be closed later
      if(::PositionSelect(symbol))
         g_open_ticket = (ulong)::PositionGetInteger(POSITION_TICKET);
     }
   else
     {
      ::PrintFormat("GatewayEA: failed to open position, retcode=%d reason=%s",result.retcode,result.reason);
     }
  }

//+------------------------------------------------------------------+
//| TryCloseDailyPosition                                            |
//| Calls CExecutionGateway::ClosePosition() and logs the outcome.   |
//+------------------------------------------------------------------+
void TryCloseDailyPosition(void)
  {
   CGatewayResult result = g_gateway.ClosePosition(g_open_ticket);

   if(result.success)
     {
      ::PrintFormat("GatewayEA: closed position, fill=%.5f volume=%.2f reason=%s",
                    result.fill_price,result.filled_volume,result.reason);
      g_open_ticket = 0;
     }
   else
     {
      ::PrintFormat("GatewayEA: failed to close position, retcode=%d reason=%s",result.retcode,result.reason);
     }
  }

Everything the EA logs comes straight from the CGatewayResult fields — there is no retcode parsing, no manual slippage arithmetic, and no direct broker interaction anywhere in this file.

Expected output:

GatewayEA: initialized, routing all trade calls through CExecutionGateway
GatewayEA: opened position, fill=1.14696 volume=0.10 slippage_rejected=false reason=filled within tolerance


Section 7: Verification — TestExecutionGateway.mq5

The test script exercises CLotNormalizer and CSlTpValidator directly against the current chart symbol's real broker constraints, rather than against invented mock values — this means the tests reflect whatever broker the script is actually run against, including brokers that report unusual stop levels or volume steps.

//+------------------------------------------------------------------+
//|                                        TestExecutionGateway.mq5  |
//|                        Verification script: synthetic unit tests |
//|                        for the normalizer, validator, and        |
//|                        gateway retcode classification logic      |
//+------------------------------------------------------------------+

#property script_show_inputs

#include <ExecutionGateway/LotNormalizer.mqh>
#include <ExecutionGateway/SlTpValidator.mqh>
#include <ExecutionGateway/ExecutionGateway.mqh>

//--- test bookkeeping
int g_tests_run    = 0;
int g_tests_passed = 0;

//+------------------------------------------------------------------+
//| ASSERT                                                           |
//| Records a single test outcome and prints a pass/fail line. Does  |
//| not halt the script on failure so every test still runs.         |
//+------------------------------------------------------------------+
void ASSERT(const bool condition,const string test_name)
  {
   g_tests_run++;

   if(condition)
     {
      g_tests_passed++;
      ::PrintFormat("PASS: %s",test_name);
     }
   else
     {
      ::PrintFormat("FAIL: %s",test_name);
     }
  }

//+------------------------------------------------------------------+
//| ASSERT_DOUBLE_EQ                                                 |
//| Compares two doubles within a small tolerance, since exact       |
//| equality is unreliable for floating point results.               |
//+------------------------------------------------------------------+
void ASSERT_DOUBLE_EQ(const double actual,const double expected,const string test_name)
  {
   bool ok = (::MathAbs(actual - expected) < 0.0000001);
   g_tests_run++;

   if(ok)
     {
      g_tests_passed++;
      ::PrintFormat("PASS: %s (expected %.6f, got %.6f)",test_name,expected,actual);
     }
   else
     {
      ::PrintFormat("FAIL: %s (expected %.6f, got %.6f)",test_name,expected,actual);
     }
  }

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

   TestLotNormalizer();
   TestSlTpValidator();
   TestRetryableRetcodes();

   ::PrintFormat("=== TestExecutionGateway finished: %d/%d passed ===",g_tests_passed,g_tests_run);

   if(g_tests_passed == g_tests_run)
      ::Print("ALL TESTS PASSED");
   else
      ::Print("SOME TESTS FAILED - see log above");
  }

TestLotNormalizer() covers five edge cases: below minimum, above maximum, off a step boundary, already valid, and zero.

//+------------------------------------------------------------------+
//| TestLotNormalizer                                                |
//| Uses the current chart symbol's real broker constraints so the   |
//| test reflects actual SYMBOL_VOLUME_STEP/MIN/MAX behavior rather  |
//| than a mocked value.                                             |
//+------------------------------------------------------------------+
void TestLotNormalizer(void)
  {
   ::Print("--- CLotNormalizer tests ---");

   CLotNormalizer normalizer;
   string symbol = ::Symbol();

   double step = 0.0, vol_min = 0.0, vol_max = 0.0;
   normalizer.GetConstraints(symbol,step,vol_min,vol_max);

   ::PrintFormat("Using symbol %s constraints: step=%.5f min=%.5f max=%.5f",symbol,step,vol_min,vol_max);

//--- a lot far below the minimum must clamp up to the minimum
   double below_min = normalizer.Normalize(symbol,vol_min * 0.1);
   ASSERT_DOUBLE_EQ(below_min,vol_min,"lot below minimum clamps to SYMBOL_VOLUME_MIN");

//--- a lot far above the maximum must clamp down to the maximum
   double above_max = normalizer.Normalize(symbol,vol_max * 2.0);
   ASSERT_DOUBLE_EQ(above_max,vol_max,"lot above maximum clamps to SYMBOL_VOLUME_MAX");

//--- a lot already on a valid step and within range must be returned unchanged
   double valid_lot         = vol_min + step * 3.0;
   double normalized_valid  = normalizer.Normalize(symbol,valid_lot);
   ASSERT_DOUBLE_EQ(normalized_valid,valid_lot,"already-valid lot on a step boundary is unchanged");

//--- a lot slightly off a step boundary must round onto the nearest step
   double off_boundary       = vol_min + step * 3.3;
   double normalized_rounded = normalizer.Normalize(symbol,off_boundary);
   double expected_rounded   = ::MathRound(off_boundary / step) * step;
   ASSERT_DOUBLE_EQ(normalized_rounded,expected_rounded,"off-boundary lot rounds to the nearest step");

//--- zero must clamp up to the minimum
   double zero_lot = normalizer.Normalize(symbol,0.0);
   ASSERT_DOUBLE_EQ(zero_lot,vol_min,"zero lot clamps to SYMBOL_VOLUME_MIN");
  }

TestSlTpValidator() checks a too-close BUY stop widens, a sufficiently far BUY stop stays untouched, a too-close SELL take-profit widens, and a zero SL/TP is never adjusted.

//+------------------------------------------------------------------+
//| TestSlTpValidator                                                |
//| Uses the current chart symbol's real SYMBOL_TRADE_STOPS_LEVEL    |
//| and current Bid/Ask so the test reflects live broker behavior.   |
//+------------------------------------------------------------------+
void TestSlTpValidator(void)
  {
   ::Print("--- CSlTpValidator tests ---");

   CSlTpValidator validator;
   string symbol = ::Symbol();

   double min_distance = validator.GetMinDistance(symbol);
   double point_size   = ::SymbolInfoDouble(symbol,SYMBOL_POINT);
   double ask          = ::SymbolInfoDouble(symbol,SYMBOL_ASK);
   double bid          = ::SymbolInfoDouble(symbol,SYMBOL_BID);

   ::PrintFormat("Using symbol %s min stop distance: %.5f (in price terms)",symbol,min_distance);

//--- BUY with an SL far too close must be widened
   double sl_close = ask - point_size;             // one point away, certainly inside the minimum
   double tp_far   = ask + min_distance * 5.0;     // comfortably far, should not be touched
   bool sl_adj = false, tp_adj = false;

   validator.Validate(symbol,ORDER_TYPE_BUY,ask,sl_close,tp_far,sl_adj,tp_adj);

   ASSERT(sl_adj == true,"BUY: too-close SL is flagged as adjusted");
   ASSERT(tp_adj == false,"BUY: sufficiently far TP is left untouched");
   ASSERT((ask - sl_close) >= min_distance - 0.0000001,"BUY: adjusted SL now meets the minimum distance");

//--- BUY with an SL already far enough must be left unchanged
   double sl_ok = ask - min_distance * 5.0;
   double tp_ok = ask + min_distance * 5.0;
   bool sl_adj2 = false, tp_adj2 = false;
   double sl_ok_before = sl_ok;

   validator.Validate(symbol,ORDER_TYPE_BUY,ask,sl_ok,tp_ok,sl_adj2,tp_adj2);

   ASSERT(sl_adj2 == false,"BUY: sufficiently far SL is not adjusted");
   ASSERT_DOUBLE_EQ(sl_ok,sl_ok_before,"BUY: unadjusted SL value is unchanged");

//--- SELL with a TP far too close must be widened
   double sl_far_sell   = bid + min_distance * 5.0;
   double tp_close_sell = bid - point_size;
   bool sl_adj3         = false, tp_adj3 = false;

   validator.Validate(symbol,ORDER_TYPE_SELL,bid,sl_far_sell,tp_close_sell,sl_adj3,tp_adj3);

   ASSERT(tp_adj3 == true,"SELL: too-close TP is flagged as adjusted");
   ASSERT((bid - tp_close_sell) >= min_distance - 0.0000001,"SELL: adjusted TP now meets the minimum distance");

//--- a zero SL/TP (no stop requested) must never be touched
   double sl_zero = 0.0;
   double tp_zero = 0.0;
   bool sl_adj4   = false, tp_adj4 = false;

   validator.Validate(symbol,ORDER_TYPE_BUY,ask,sl_zero,tp_zero,sl_adj4,tp_adj4);

   ASSERT(sl_adj4 == false && tp_adj4 == false,"zero SL/TP (no stop requested) is never adjusted");
  }

Because every check here reads min_distance from GetMinDistance() rather than assuming a fixed number, these tests remain valid regardless of what fallback value CSlTpValidator happens to be using internally on a given broker.

TestRetryableRetcodes() checks the retryable constants against their documented values and confirms a permanent rejection is never misclassified as transient.

//+------------------------------------------------------------------+
//| TestRetryableRetcodes                                            |
//| Confirms the three constants the gateway treats as retryable     |
//| match the documented MQL5 ENUM_TRADE_RETCODE values, and that a  |
//| terminal retcode such as TRADE_RETCODE_INVALID_STOPS is not      |
//| misclassified as retryable.                                      |
//+------------------------------------------------------------------+
void TestRetryableRetcodes(void)
  {
   ::Print("--- Retryable retcode classification tests ---");

   ASSERT((uint)TRADE_RETCODE_REQUOTE       == 10004,"TRADE_RETCODE_REQUOTE equals 10004");
   ASSERT((uint)TRADE_RETCODE_TIMEOUT       == 10012,"TRADE_RETCODE_TIMEOUT equals 10012");
   ASSERT((uint)TRADE_RETCODE_PRICE_CHANGED == 10020,"TRADE_RETCODE_PRICE_CHANGED equals 10020");

//--- a permanent rejection such as invalid stops must never be treated as retryable
   ASSERT((uint)TRADE_RETCODE_INVALID_STOPS != (uint)TRADE_RETCODE_REQUOTE &&
          (uint)TRADE_RETCODE_INVALID_STOPS != (uint)TRADE_RETCODE_TIMEOUT &&
          (uint)TRADE_RETCODE_INVALID_STOPS != (uint)TRADE_RETCODE_PRICE_CHANGED,
          "TRADE_RETCODE_INVALID_STOPS is not classified as retryable");
  }

Expected output:

=== TestExecutionGateway starting ===
--- CLotNormalizer tests ---
Using symbol EURUSD constraints: step=0.01000 min=0.01000 max=50.00000
PASS: lot below minimum clamps to SYMBOL_VOLUME_MIN (expected 0.010000, got 0.010000)
PASS: lot above maximum clamps to SYMBOL_VOLUME_MAX (expected 50.000000, got 50.000000)
PASS: already-valid lot on a step boundary is unchanged (expected 0.040000, got 0.040000)
PASS: off-boundary lot rounds to the nearest step (expected 0.040000, got 0.040000)
PASS: zero lot clamps to SYMBOL_VOLUME_MIN (expected 0.010000, got 0.010000)
--- CSlTpValidator tests ---
Using symbol EURUSD min stop distance: 0.00010 (in price terms)
PASS: BUY: too-close SL is flagged as adjusted
PASS: BUY: sufficiently far TP is left untouched
PASS: BUY: adjusted SL now meets the minimum distance
PASS: BUY: sufficiently far SL is not adjusted
PASS: BUY: unadjusted SL value is unchanged (expected 1.146610, got 1.146610)
PASS: SELL: too-close TP is flagged as adjusted
PASS: SELL: adjusted TP now meets the minimum distance
PASS: zero SL/TP (no stop requested) is never adjusted
--- Retryable retcode classification tests ---
PASS: TRADE_RETCODE_REQUOTE equals 10004
PASS: TRADE_RETCODE_TIMEOUT equals 10012
PASS: TRADE_RETCODE_PRICE_CHANGED equals 10020
PASS: TRADE_RETCODE_INVALID_STOPS is not classified as retryable
=== TestExecutionGateway finished: 17/17 passed ===
ALL TESTS PASSED


Section 8: Extending the Gateway

Three extensions come up often enough in production EAs to mention directly, even though none of them are built into the version above.

Position sizing by risk percent: OpenPosition() currently accepts a requested_lots value the caller has already computed. A natural extension is a companion method, or an overload, that instead accepts a risk percentage and a stop distance, computes the lot size from AccountInfoDouble(ACCOUNT_BALANCE) and the symbol's tick value via SymbolInfoDouble(SYMBOL_TRADE_TICK_VALUE), and then feeds the result through the existing CLotNormalizer::Normalize() call — the normalization step does not need to change at all, only the code that decides what number to normalize.

A pre-trade margin check: Before submitting, a call to OrderCalcMargin() can confirm the account has sufficient free margin for the normalized lot size, avoiding a wasted round trip to the broker that would come back as TRADE_RETCODE_NO_MONEY. This would fit as an additional validation step inside OpenPosition(), structurally alongside the existing SL/TP validation step, returning a CGatewayResult with success = false and a clear reason before any OrderSend() attempt is made.

A broker-specific stop-distance override: As Section 9 details, SYMBOL_TRADE_STOPS_LEVEL reporting zero does not always mean zero distance is genuinely accepted. A natural extension to CSlTpValidator is an optional override parameter — a minimum number of points a specific EA deployment wants enforced regardless of what the broker reports — set once per account during onboarding and taking priority over the automatic fallback when supplied.


Section 9: Limitations

This gateway is deliberately scoped, and it is worth being explicit about what it does not do.

A stops level of zero from SYMBOL_TRADE_STOPS_LEVEL is not always a true green light. Some brokers report 0 while enforcing a real minimum tied to the current spread. CSlTpValidator derives its fallback from the live spread using a 3x multiplier in that case, which reflects common ECN broker behavior, though the exact multiplier can still vary by broker. A stop the validator "adjusts" can still return TRADE_RETCODE_INVALID_STOPS on brokers using a different multiplier. If that happens, adjust the multiplier in CSlTpValidator::RefreshCache() to match your broker's actual enforcement. Verify this value against your own broker during Section 7's validation steps rather than assuming it works universally.

Partial fills are reported, not resolved. CGatewayResult.filled_volume will correctly report a volume smaller than what was requested if the broker only fills part of the order, but the gateway does not automatically resubmit for the remainder. A caller that needs guaranteed full-size fills has to check filled_volume against the lot size it requested and decide whether to submit a follow-up order itself.

Hedging accounts vs netting accounts. ClosePosition() assumes a straightforward netting-style close: read the position's symbol, volume, and direction, then submit an opposite-direction deal against that ticket. On a hedging account where multiple positions can coexist on the same symbol, this remains correct per-ticket, but a caller managing several simultaneous positions on one symbol needs to track tickets itself — the gateway has no concept of "the EA's positions" as a group, only individual tickets passed to it one at a time.

Symbol info is assumed to be available. Every sub-component calls SymbolInfoDouble() and SymbolInfoInteger() and trusts the returned values once a sanity check (such as the zero-step guard) has been applied. On a symbol that has not yet loaded into the Market Watch window, these calls can return stale or zero values that look superficially valid. Production use should confirm SymbolSelect(symbol, true) has succeeded, and that SymbolInfoTick() returns fresh data, before the gateway is exercised on a newly added symbol.

No queuing or rate limiting. If a caller invokes OpenPosition() many times in rapid succession — for instance, from a loop over several signals in the same tick — nothing in the gateway throttles those calls. A broker enforcing TRADE_RETCODE_TOO_MANY_REQUESTS would need that handled by the caller, or added as a further extension.


Conclusion

CExecutionGateway replaces a pattern of scattered OrderSend() calls with a single, testable boundary between strategy logic and broker interaction. Every request that passes through it has its lot size rounded to a valid step, its stops checked against the broker's reported minimum distance, its filling policy resolved against what the broker actually supports, and a bounded number of retries applied only to the retcodes that represent genuinely transient conditions — never to a permanent rejection like an invalid stop or insufficient margin. The caller gets back one structured result instead of a raw retcode to interpret, with a slippage_rejected flag that distinguishes "the trade happened, but not where you expected" from an outright failure.

This implementation does not cover: a guaranteed-correct fallback for brokers that under-report their real stop-distance enforcement, partial-fill resubmission, multi-position tracking on hedging accounts, and request throttling. The stop-distance fallback is broker-specific and requires validation. The remaining items are natural next steps (see Section 8) and can be added without changing the gateway contract.


Programs used in the article:

# Name Type Description
1 GatewayResult.mqh Include File CGatewayResult struct holding the outcome of a single execution attempt.
2 LotNormalizer.mqh Include File CLotNormalizer class rounding and clamping lot size to broker constraints.
3 SlTpValidator.mqh Include File CSlTpValidator class checking and widening SL/TP against the broker's reported minimum stop distance.
4 ExecutionGateway.mqh Include File CExecutionGateway class, the central single point of contact for all trade operations, including filling-mode resolution.
5 GatewayEA.mq5 Demo EA Demo EA that wires up CExecutionGateway and opens/closes one position per day.
6 TestExecutionGateway.mq5 Script Verification script with synthetic unit tests for the normalizer, validator, and retcode classification logic.
7 Execution_Gateway.zip Zip Archive Zip archive containing all the attached files and their paths relative to the terminal's root folder.


Last comments | Go to discussion (1)
Vladislav Boyko
Vladislav Boyko | 12 Aug 2026 at 14:02

How does CExecutionGateway in this article differ from the class with the same name in the article "OrderSend Retries and Circuit Breaker in MQL5"?

  • If this class performs the same task, why did you re-develop it instead of using existing code?
  • If this class performs a different task, why did you create a name collision?

How can I integrate the developments from both of your articles into a single EA? Do you personally use both developments or only one of them, and why?

Unified Multi-Timeframe Renko: Synthesizing the Market's Temporal Dimensions Unified Multi-Timeframe Renko: Synthesizing the Market's Temporal Dimensions
The article presents an innovative concept for a multi-timeframe Renko chart that combines signals from four timeframes (M5, M15, H1, H4) into a unified synthetic instrument. The system creates a virtual symbol in MetaTrader 5 by using the EMA of each timeframe to generate a composite signal through three methods: simple average, weighted average, and consensus. The implementation includes ATR-based adaptive brick sizing, real-time operation, and full integration with MetaTrader 5.
From Novice to Expert: Systematic Profit Conservation Using Candle Range Theory From Novice to Expert: Systematic Profit Conservation Using Candle Range Theory
A hybrid exit engine for MQL5 replaces static TPs with CRT-derived structural levels. The CRT_ProfitConserve class secures a partial at the first level and then trails the remaining position by structural anchors rather than fixed pips. The article walks through the class API, essential methods, and example usage in EAs, providing a clear path to embed CRT-based exits into existing strategies.
Features of Experts Advisors Features of Experts Advisors
Creation of expert advisors in the MetaTrader trading system has a number of features.
Automating Trading Strategies in MQL5 (Part 52): The tCISD Model with SSMT and Quarterly Theory Automating Trading Strategies in MQL5 (Part 52): The tCISD Model with SSMT and Quarterly Theory
We build a tCISD program in MQL5 that pairs Quarterly Theory cycles anchored to New York time with a correlated-symbol SSMT divergence to time reversals. The article shows how to map cycles and quarters, detect the cross-symbol sweep disagreement, and derive the tCISD level whose break confirms the change in delivery. You will get a working entry logic that arms on divergence and executes on a confirmation close or a retest.