preview
OrderSend retries and circuit breaker in MQL5

OrderSend retries and circuit breaker in MQL5

MetaTrader 5Trading systems |
693 0
Ushana Kevin Iorkumbul
Ushana Kevin Iorkumbul

Introduction

A production Expert Advisor running live during high-volatility news will encounter failure sequences that most EA code is not designed to handle. The broker rejects the first order submission with TRADE_RETCODE_REQUOTE. The EA's OnTick() handler retries immediately — still requoted. A third attempt arrives while the terminal's connection is momentarily interrupted — TRADE_RETCODE_CONNECTION. By the fourth attempt, the spread has widened beyond the EA's filter and the entry is missed entirely. Meanwhile, a second tick has arrived, triggering the same OnTick() path again. The EA now submits two orders in rapid succession for the same signal, creating a doubled position that no risk management logic anticipated.

This is not an edge case. It is the normal behavior of volatile markets. The problem is not that failure happens — broker-side transient failures are unavoidable. The issue is that many EAs handle failures ad hoc: Sleep(500), local retry loops, and GetLastError() checks that treat all errors the same. These patterns share three deficiencies. First, they do not classify errors. A requote is transient and should be retried; a margin rejection is permanent and must not be retried. Retrying permanent failures wastes time and risks duplicate submissions. Second, they do not track cumulative failure state: if the broker's execution system is genuinely overloaded or the terminal's connection is degraded, individual retry loops have no awareness that five consecutive failures have occurred and that further submissions should be suspended. Third, they are not reusable: the retry logic is duplicated across every place in the EA that calls OrderSend(), meaning that a fix in one location does not propagate to others.

The operational cost is concrete. A missed entry after a valid signal is a measurable performance loss. A duplicate submission from a second tick triggering the same signal while the first submission is still being retried is an uncontrolled risk event. A position opened without a stop loss because the stop-attachment order was rejected and not retried is a regulatory and capital risk. An EA that fails silently — logging nothing, recording no diagnostics, providing no visibility into why execution failed — is an EA that cannot be debugged or improved.

This article presents a structured solution in three composable layers. CRetryExecutor wraps a single OrderSend() call with configurable retry count, exponential backoff delay, and systematic error classification that separates transient failures from permanent rejections. CCircuitBreaker tracks consecutive failure counts across multiple submission attempts and blocks further submissions after a configurable threshold, entering a half-open probe state after a cooldown period to allow controlled recovery. CExecutionGateway composes these two classes into a single submission interface whose behavior is fully specified by two configuration structs. Two verification scripts and a demonstration EA show the system operating under simulated and unit-tested conditions.



Section 1: The Execution Failure Taxonomy

Not all OrderSend() failures are equivalent. The MqlTradeResult structure's retcode field is an uint that contains one of the trade server return codes defined by MetaQuotes on every submission.

Transient failures are conditions expected to resolve on their own within a few seconds. TRADE_RETCODE_REQUOTE (10004) means the broker offered a new price; the same order submitted at the new price will often succeed. TRADE_RETCODE_CONNECTION (10031) means the trade server was temporarily unreachable. TRADE_RETCODE_PRICE_CHANGED (10020) indicates price movement between request construction and server processing. TRADE_RETCODE_TIMEOUT (10012) indicates a server response timeout. TRADE_RETCODE_PRICE_OFF (10021) means the requested price is no longer available but a nearby price may be. All of these warrant a retry with updated pricing after a short delay.

Environment-specific transient failures are transient conditions specific to the MetaTrader 5 execution environment. TRADE_RETCODE_TOO_MANY_REQUESTS (10024) means the EA is submitting faster than the trade server will accept. Retrying after an exponential backoff delay is the correct response.

Permanent rejections are conditions that will not change regardless of how many times the order is resubmitted. TRADE_RETCODE_INVALID_STOPS (10016) means the stop loss or take profit levels violate broker constraints. TRADE_RETCODE_NO_MONEY (10019) means insufficient margin. TRADE_RETCODE_MARKET_CLOSED (10018) means trading is suspended for the instrument. Retrying permanent rejections is actively harmful: it wastes tick processing time, fills the Experts log with noise, and — critically — risks submitting duplicate orders if the EA logic does not correctly distinguish between "failed and should not retry" and "failed and should try again."

The classification decision is therefore architecturally significant. The SRetryConfig struct encodes this classification explicitly as a configurable array of retryable return codes, rather than hardcoding it into the execution logic.

Partial fills add a third dimension. TRADE_RETCODE_DONE_PARTIAL (10010) indicates that part of the requested volume was filled. This is not a failure — it is a partial success. Whether to submit a second order for the remaining volume, accept the partial fill, or close the partial position depends on EA strategy logic that the execution layer cannot know. The gateway reports the partial fill outcome and leaves the decision to the caller.



Section 2: Exponential Backoff Theory

When a transient failure occurs, the naive response is to retry immediately. In most real-world broker failure scenarios, immediate retry compounds the problem: if the broker's execution queue is overloaded, an immediate retry from every affected EA simultaneously creates a thundering herd that prolongs the congestion. Linear delay — waiting a fixed interval between retries — reduces the rate of retry requests but applies constant pressure regardless of how long the failure condition has persisted.

Exponential backoff addresses this by increasing the wait interval geometrically with each successive failure:

delay(attempt) = min(base_delay × 2^(attempt - 1), max_delay)

For a base_delay of 200 milliseconds and a max_delay of 5,000 milliseconds, the delays across attempts are:

Attempt Raw Delay Capped Delay
1 200 ms 200 ms
2 400 ms 400 ms
3 800 ms 800 ms
4 1,600 ms 1,600 ms
5 3,200 ms 3,200 ms
6 6,400 ms 5,000 ms

The ceiling (max_delay) prevents the delay from growing indefinitely. Without a ceiling, a strategy experiencing a prolonged disconnection could accumulate a delay of minutes before the next retry, during which the market has moved past the entry point.

In MQL5, the delay is implemented using Sleep(), which suspends the EA's execution thread for the specified number of milliseconds. MQL5 runs one thread per EA instance, so Sleep() in a retry loop blocks OnTick() from processing new ticks. This is an intentional trade-off: it prevents a second tick from triggering a duplicate submission while the first submission is still being retried.


Section 3: Circuit Breaker Pattern

The retry executor addresses individual submission failures. The circuit breaker addresses a different failure mode: a sustained degradation in which every submission attempt fails because the broker's execution infrastructure or the terminal's connectivity is genuinely impaired. In this condition, the retry executor will exhaust its attempt budget on every order, spending its full backoff delay sequence on each submission.

The circuit breaker pattern tracks cumulative failure state across multiple submission attempts and proactively blocks further submissions when the failure rate exceeds a threshold. It operates as a three-state machine.

CLOSED is the normal operating state. The circuit permits all submissions. Each successful submission resets the consecutive failure count. Each failed submission increments it. When the count reaches the configured threshold, the circuit transitions to OPEN.

OPEN is the fault state. The circuit blocks all submissions immediately without delegating to the retry executor. The circuit records the timestamp of the transition to OPEN. After a configured cooldown period has elapsed, the circuit transitions to HALF-OPEN.

HALF-OPEN is the probe state. The circuit allows a limited number of submissions through to test whether the underlying condition has resolved. If probe submissions succeed and the success count reaches the configured probe threshold, the circuit transitions back to CLOSED. If a probe submission fails, the circuit transitions immediately back to OPEN and resets the cooldown timer.

Circuit breaker state transition diagram

Figure 1: Circuit breaker state transition diagram. Three nodes labeled CLOSED, OPEN, and HALF-OPEN are connected by directed arcs. CLOSED → OPEN: consecutive failures reach the configured threshold. OPEN → HALF-OPEN: the cooldown period elapses. HALF-OPEN → CLOSED: probe successes reach the probe threshold. HALF-OPEN → OPEN: a probe submission fails.

The half-open probe mechanism prevents the thundering-herd problem at recovery time. Without it, the circuit would transition directly from OPEN to CLOSED after the cooldown, allowing all pending submissions to arrive simultaneously at a broker system that may have only partially recovered.


Section 4: Implementation — RetryConfig.mqh

SRetryConfig is a plain data container with no methods. Its role is to separate configuration from logic: all parameters that control retry behavior are grouped into a single struct that can be constructed once in OnInit(), validated, and passed to CRetryExecutor.

//+------------------------------------------------------------------+
//|                                                 RetryConfig.mqh  |
//+------------------------------------------------------------------+
#ifndef EXECUTION_GATEWAY_RETRYCONFIG_MQH
#define EXECUTION_GATEWAY_RETRYCONFIG_MQH
//+------------------------------------------------------------------+
//| Retry policy configuration for CRetryExecutor.                   |
//| All members are public to allow aggregate initialization.        |
//+------------------------------------------------------------------+
struct SRetryConfig
  {
   int               m_max_attempts;       // Maximum submission attempts (including first)
   uint              m_base_delay_ms;      // Base backoff delay in milliseconds
   uint              m_max_delay_ms;       // Ceiling on backoff delay in milliseconds
   uint              m_retryable_codes[];  // Return codes classified as transient/retryable
  };
//+------------------------------------------------------------------+
//| Returns a default SRetryConfig suitable for most live EAs.       |
//+------------------------------------------------------------------+
SRetryConfig DefaultRetryConfig(void)
  {
   SRetryConfig cfg;
   cfg.m_max_attempts  = 4;
   cfg.m_base_delay_ms = 200;
   cfg.m_max_delay_ms  = 5000;
//--- Standard transient return codes
   ArrayResize(cfg.m_retryable_codes, 6);
   cfg.m_retryable_codes[0] = 10004; // TRADE_RETCODE_REQUOTE
   cfg.m_retryable_codes[1] = 10006; // TRADE_RETCODE_CONNECTION
   cfg.m_retryable_codes[2] = 10010; // TRADE_RETCODE_TIMEOUT
   cfg.m_retryable_codes[3] = 10020; // TRADE_RETCODE_PRICE_CHANGED
   cfg.m_retryable_codes[4] = 10021; // TRADE_RETCODE_PRICE_OFF
   cfg.m_retryable_codes[5] = 10030; // TRADE_RETCODE_TOO_MANY_REQUESTS
   return(cfg);
  }

#endif // EXECUTION_GATEWAY_RETRYCONFIG_MQH
//+------------------------------------------------------------------+

The include guard uses the prefixed name EXECUTION_GATEWAY_RETRYCONFIG_MQH rather than the shorter RETRYCONFIG_MQH. The longer prefix prevents guard collisions when the file is included alongside other projects that might define a guard of the same shorter name — a subtle but real compilation failure mode in MQL5 multi-file projects.

m_max_attempts is the total number of submission attempts including the initial attempt. A value of 4 means one initial attempt plus three retries. m_base_delay_ms and m_max_delay_ms are both uint because they represent durations that are never negative and because Sleep() accepts a uint argument. m_retryable_codes[] is a dynamic array of uint values holding the raw numeric codes. The array is populated by DefaultRetryConfig() with the six standard transient codes. DefaultRetryConfig() is a free function rather than a constructor because MQL5 structs do not support constructors, and it provides a ready-to-use configuration without requiring the caller to populate the struct member by member.


Section 5: Implementation — CircuitBreakerConfig.mqh

//+------------------------------------------------------------------+
//|                                         CircuitBreakerConfig.mqh |
//+------------------------------------------------------------------+
#ifndef CIRCUITBREAKERCONFIG_MQH
#define CIRCUITBREAKERCONFIG_MQH
//+------------------------------------------------------------------+
//| Circuit breaker policy configuration for CCircuitBreaker.        |
//+------------------------------------------------------------------+
struct SCircuitBreakerConfig
  {
   int               m_failure_threshold;      // Consecutive failures that open the circuit
   uint              m_cooldown_ms;            // Milliseconds the circuit stays open
   int               m_half_open_probe_count;  // Successful probes required to close circuit
  };
//+------------------------------------------------------------------+
//| Returns a default SCircuitBreakerConfig for live EA operation.   |
//+------------------------------------------------------------------+
SCircuitBreakerConfig DefaultCircuitBreakerConfig(void)
  {
   SCircuitBreakerConfig cfg;
   cfg.m_failure_threshold     = 5;
   cfg.m_cooldown_ms           = 30000; // 30 seconds
   cfg.m_half_open_probe_count = 2;
   return(cfg);
  }

#endif // CIRCUITBREAKERCONFIG_MQH
//+------------------------------------------------------------------+

m_failure_threshold defines how many consecutive failures must occur before the circuit opens. m_cooldown_ms is typed as uint to match GetTickCount()'s return type, avoiding signed/unsigned arithmetic in the duration comparison inside AllowRequest(). Thirty seconds is appropriate for most broker connectivity interruptions. m_half_open_probe_count sets how many consecutive successful probes are required before the circuit closes. A value of 2 provides a small confirmation buffer against a single lucky success during a partially recovered connection.


Section 6: Implementation — RetryExecutor.mqh

CRetryExecutor is responsible for a single concern: taking one MqlTradeRequest, submitting it to the broker via OrderSend(), and retrying it up to m_max_attempts times with exponential backoff delays between attempts. It knows nothing about circuit breaker state — that concern belongs to CCircuitBreaker.

Class Declaration

//+------------------------------------------------------------------+
//|                                             RetryExecutor.mqh    |
//+------------------------------------------------------------------+
#ifndef RETRYEXECUTOR_MQH
#define RETRYEXECUTOR_MQH

#include "RetryConfig.mqh"
//+------------------------------------------------------------------+
//| Outcome codes returned by CRetryExecutor::Execute().             |
//+------------------------------------------------------------------+
enum ENUM_RETRY_OUTCOME
  {
   RETRY_OUTCOME_SUCCESS          = 0, // Submission accepted by broker
   RETRY_OUTCOME_PARTIAL          = 1, // Partial fill received
   RETRY_OUTCOME_FAILED_TRANSIENT = 2, // All attempts exhausted on transient errors
   RETRY_OUTCOME_FAILED_PERMANENT = 3, // Non-retryable error received; stopped early
  };
//+------------------------------------------------------------------+
//| Wraps OrderSend() with configurable retry and backoff logic.     |
//+------------------------------------------------------------------+
class CRetryExecutor
  {
private:
   SRetryConfig   m_config;

protected:
   bool           IsRetryable(uint return_code) const;
   uint           ComputeDelay(int attempt) const;

public:
                     CRetryExecutor(void);
                     CRetryExecutor(const SRetryConfig &config);
                    ~CRetryExecutor(void);

   ENUM_RETRY_OUTCOME Execute(MqlTradeRequest &request,
                              MqlTradeResult  &result);
  };

ENUM_RETRY_OUTCOME is declared outside the class so that CExecutionGateway can use the enum type directly without scoping. m_config is stored by value, which is correct for a plain struct with no heap resources. IsRetryable() and ComputeDelay() are declared protected rather than private so that test subclasses can call them directly without requiring access to private members — this is the correct visibility for methods that are implementation details but need to be exercised in unit testing through inheritance.

Default Constructor

//+------------------------------------------------------------------+
//| Default constructor — uses DefaultRetryConfig().                 |
//+------------------------------------------------------------------+
CRetryExecutor::CRetryExecutor(void)
  {
   m_config = DefaultRetryConfig();
  }

The default constructor calls DefaultRetryConfig() so that a zero-argument instantiation produces a usable executor without requiring the caller to construct a configuration struct. This is the constructor used when CExecutionGateway is constructed with its own default constructor.

Parameterized Constructor

//+------------------------------------------------------------------+
//| Parameterized constructor — accepts a caller-supplied config.    |
//+------------------------------------------------------------------+
CRetryExecutor::CRetryExecutor(const SRetryConfig &config)
  {
   m_config = config;
  }

The parameterized constructor accepts the configuration by const reference and copies it into m_config. No further action is needed since SRetryConfig is a plain struct containing only scalar members and one dynamic array, which MQL5 copies by value automatically.

Destructor

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

The destructor is explicitly declared and defined with an empty body. MQL5 manages the dynamic array m_config.m_retryable_codes[] automatically when the struct goes out of scope. The destructor is still declared because a common MetaQuotes convention is to provide explicit constructor and destructor definitions even when the body is empty.

IsRetryable(uint return_code)

//+------------------------------------------------------------------+
//| Returns true if the given return code warrants a retry attempt.  |
//+------------------------------------------------------------------+
bool CRetryExecutor::IsRetryable(uint return_code) const
  {
   int n = ::ArraySize(m_config.m_retryable_codes);
   for(int i = 0; i < n; i++)
     {
      if(m_config.m_retryable_codes[i] == return_code)
         return(true);
     }
   return(false);
  }

This method performs a linear search through m_retryable_codes for the given return_code. The array is small — typically six entries — so linear search is appropriate. The cost of the search is negligible relative to the network round-trip of an OrderSend() call. The :: prefix on ArraySize() resolves it at global scope within the class method body, preventing any hypothetical shadowing by a member or local with the same name. The method is const because it does not modify any member variables.

ComputeDelay(int attempt)

//+------------------------------------------------------------------+
//| Computes the backoff delay for a given attempt number (1-based). |
//| Returns delay in milliseconds, capped at m_config.m_max_delay_ms.|
//+------------------------------------------------------------------+
uint CRetryExecutor::ComputeDelay(int attempt) const
  {
   int  shift      = attempt - 1;
   uint multiplier = (shift < 30) ? ((uint)1 << shift) : (uint)0x3FFFFFFF;
   uint raw_delay  = m_config.m_base_delay_ms * multiplier;
   if(raw_delay > m_config.m_max_delay_ms || raw_delay < m_config.m_base_delay_ms)
      return(m_config.m_max_delay_ms);
   return(raw_delay);
  }

The exponential multiplier is computed using a left-bit-shift: (uint)1 << (attempt - 1) equals 2^(attempt-1). For attempt 1, the shift is 0 and the multiplier is 1, giving base_delay × 1. For attempt 2, the shift is 1, giving base_delay × 2. The explicit (uint) cast is required because MQL5 does not support the u suffix on integer literals (1u, 0x3FFFFFFFu) that C++ programmers may expect. Using (uint)1 rather than (int)1 ensures the left-shift operates on an unsigned value and avoids signed integer overflow for large shift values. The guard shift < 30 prevents undefined behavior from shifting beyond the width of a 32-bit integer. The overflow check raw_delay < m_config.m_base_delay_ms catches unsigned integer wraparound: if multiplication overflows a uint, the result will be numerically smaller than the base delay, which is the detection condition. Either overflow or exceeding the ceiling causes the method to return m_max_delay_ms.

Execute(MqlTradeRequest &request, MqlTradeResult &result)

//+------------------------------------------------------------------+
//| Submits the request, retrying with backoff on transient errors.  |
//+------------------------------------------------------------------+
ENUM_RETRY_OUTCOME CRetryExecutor::Execute(MqlTradeRequest &request,
      MqlTradeResult  &result)
  {
   for(int attempt = 1; attempt <= m_config.m_max_attempts; attempt++)
     {
      bool sent    = ::OrderSend(request, result);
      uint retcode = result.retcode;
      ::Print("RetryExecutor: attempt=", attempt,
              "  retcode=", retcode,
              "  comment=", result.comment);
      if(sent && retcode == TRADE_RETCODE_DONE)
        {
         ::Print("RetryExecutor: SUCCESS on attempt ", attempt);
         return(RETRY_OUTCOME_SUCCESS);
        }
      if(retcode == TRADE_RETCODE_DONE_PARTIAL)
        {
         ::Print("RetryExecutor: PARTIAL FILL on attempt ", attempt,
                 "  volume=", result.volume);
         return(RETRY_OUTCOME_PARTIAL);
        }
      if(!IsRetryable(retcode))
        {
         ::Print("RetryExecutor: PERMANENT FAILURE retcode=", retcode,
                 " — aborting retry.");
         return(RETRY_OUTCOME_FAILED_PERMANENT);
        }
      if(attempt < m_config.m_max_attempts)
        {
         uint delay = ComputeDelay(attempt);
         ::Print("RetryExecutor: transient error, backing off ", delay, " ms");
         ::Sleep(delay);
        }
     }
   ::Print("RetryExecutor: EXHAUSTED all ", m_config.m_max_attempts, " attempts.");
   return(RETRY_OUTCOME_FAILED_TRANSIENT);
  }

The loop iterates from attempt = 1 to m_max_attempts inclusive. On each iteration, OrderSend() is called with the original request and the caller-supplied result struct.

The success check tests both the bool return value of OrderSend() and result.retcode == TRADE_RETCODE_DONE (10009). Both conditions together unambiguously identify a complete fill.

The partial fill check is separate and returns RETRY_OUTCOME_PARTIAL immediately. The caller receives the result struct with the actual filled volume in result.volume and must decide whether to submit a second order for the remainder.

The permanent failure check calls IsRetryable() on the return code. If the code is not in the retryable set, the method returns RETRY_OUTCOME_FAILED_PERMANENT immediately without waiting for remaining attempts. This is the early-exit path that prevents the executor from spending time on retries that cannot succeed.

The delay is applied only when another attempt will follow (attempt < m_max_attempts), preventing an unnecessary Sleep() on the final attempt before returning.


Section 7: Verification — Confirming CRetryExecutor Works as Designed

Before integrating CRetryExecutor into the full gateway, its two internal methods — ComputeDelay() and IsRetryable() — can be tested in complete isolation using a test subclass that exposes the protected methods through thin public wrappers.

//+------------------------------------------------------------------+
//|                                         TestRetryExecutor.mq5    |
//+------------------------------------------------------------------+

#include <Circuit_Breaker\RetryExecutor.mqh>

#define ASSERT(condition, msg) \
   if(!(condition)) { Print("FAIL: ", msg); } else { Print("PASS: ", msg); }

//--- Expose ComputeDelay for testing via a subclass
class CRetryExecutorTest : public CRetryExecutor
  {
public:
                     CRetryExecutorTest(const SRetryConfig &cfg) : CRetryExecutor(cfg) {}
                    ~CRetryExecutorTest(void) {}
   uint              TestComputeDelay(int attempt) const { return(ComputeDelay(attempt)); }
   bool              TestIsRetryable(uint code)    const { return(IsRetryable(code));     }
  };

//+------------------------------------------------------------------+
//| Script program start function                                    |
//+------------------------------------------------------------------+
void OnStart(void)
  {
   SRetryConfig cfg = DefaultRetryConfig();
   cfg.m_base_delay_ms = 200;
   cfg.m_max_delay_ms  = 5000;

   CRetryExecutorTest t(cfg);

//--- Test 1: Exponential backoff sequence
   ASSERT(t.TestComputeDelay(1) == 200,  "Delay attempt 1 == 200ms");
   ASSERT(t.TestComputeDelay(2) == 400,  "Delay attempt 2 == 400ms");
   ASSERT(t.TestComputeDelay(3) == 800,  "Delay attempt 3 == 800ms");
   ASSERT(t.TestComputeDelay(4) == 1600, "Delay attempt 4 == 1600ms");
   ASSERT(t.TestComputeDelay(5) == 3200, "Delay attempt 5 == 3200ms");

//--- Test 2: Ceiling cap applied
   ASSERT(t.TestComputeDelay(6) == 5000, "Delay attempt 6 capped at 5000ms");
   ASSERT(t.TestComputeDelay(7) == 5000, "Delay attempt 7 capped at 5000ms");

//--- Test 3: Retryable codes correctly classified
   ASSERT(t.TestIsRetryable(10004), "10004 REQUOTE is retryable");
   ASSERT(t.TestIsRetryable(10006), "10006 CONNECTION is retryable");
   ASSERT(t.TestIsRetryable(10010), "10010 TIMEOUT is retryable");
   ASSERT(t.TestIsRetryable(10020), "10020 PRICE_CHANGED is retryable");
   ASSERT(t.TestIsRetryable(10021), "10021 PRICE_OFF is retryable");
   ASSERT(t.TestIsRetryable(10030), "10030 TOO_MANY_REQUESTS is retryable");

//--- Test 4: Non-retryable codes correctly rejected
   ASSERT(!t.TestIsRetryable(10009), "10009 DONE is not retryable");
   ASSERT(!t.TestIsRetryable(10016), "10016 INVALID_STOPS is not retryable");
   ASSERT(!t.TestIsRetryable(10019), "10019 NO_MONEY is not retryable");
   ASSERT(!t.TestIsRetryable(10018), "10018 MARKET_CLOSED is not retryable");
   ASSERT(!t.TestIsRetryable(0),     "0 (no error) is not retryable");

   Print("=== Retry executor unit tests complete ===");
  }
//+------------------------------------------------------------------+

CRetryExecutorTest inherits from CRetryExecutor and exposes ComputeDelay() and IsRetryable() through public wrapper methods with Test prefixes. Because both methods are protected in the base class, the subclass can access them directly. The ASSERT macro prints PASS: or FAIL: for each check without halting execution, so the full test output appears in a single Experts tab pass.

The first group of assertions verifies the exponential backoff sequence: attempt 1 returns 200 ms, attempt 2 returns 400 ms, and the sequence doubles until attempt 6 and 7 both return the 5,000 ms ceiling, confirming that the cap is applied correctly. The second group verifies that all six standard transient codes are recognized as retryable, and the third group verifies that common permanent rejection codes and the zero code are correctly not in the retryable set. Running this script on any chart with no broker connection required should produce 19 consecutive PASS: lines.


Section 8: Implementation — CircuitBreaker.mqh

CCircuitBreaker is responsible for tracking cumulative execution health and making allow/block decisions independently of individual submission logic. It does not call OrderSend() and does not know what a trade request contains. Its sole inputs are calls to RecordSuccess(), RecordFailure(), and AllowRequest().

Class Declaration

//+------------------------------------------------------------------+
//|                                            CircuitBreaker.mqh    |
//+------------------------------------------------------------------+
#ifndef CIRCUITBREAKER_MQH
#define CIRCUITBREAKER_MQH

#include "CircuitBreakerConfig.mqh"
//+------------------------------------------------------------------+
//| Circuit breaker state enumeration.                               |
//+------------------------------------------------------------------+
enum ENUM_CIRCUIT_STATE
  {
   CIRCUIT_CLOSED    = 0, // Normal: submissions permitted
   CIRCUIT_OPEN      = 1, // Fault: submissions blocked
   CIRCUIT_HALF_OPEN = 2, // Probe: limited submissions permitted
  };
//+------------------------------------------------------------------+
//| Three-state circuit breaker tracking cumulative failure health.  |
//+------------------------------------------------------------------+
class CCircuitBreaker
  {
private:
   SCircuitBreakerConfig m_config;
   ENUM_CIRCUIT_STATE    m_state;
   int                   m_failure_count;
   uint                  m_open_timestamp;
   int                   m_probe_success_count;

public:
                     CCircuitBreaker(void);
                     CCircuitBreaker(const SCircuitBreakerConfig &config);
                    ~CCircuitBreaker(void);

   bool                AllowRequest(void);
   void                RecordSuccess(void);
   void                RecordFailure(void);
   ENUM_CIRCUIT_STATE  GetState(void)        const { return(m_state);        }
   int                 GetFailureCount(void) const { return(m_failure_count); }
   void                Reset(void);
  };

m_open_timestamp stores the GetTickCount() value at the moment the circuit transitions to OPEN. Because GetTickCount() returns a uint representing milliseconds elapsed since the system started, m_open_timestamp is also uint so that the elapsed time comparison uses unsigned arithmetic and handles the wrap-around correctly. m_probe_success_count tracks how many consecutive successful probes have occurred in the HALF-OPEN state. GetState() and GetFailureCount() are const inline accessors defined directly in the declaration because they are single-expression reads.

Constructors and Destructor

//+------------------------------------------------------------------+
//| Default constructor — uses DefaultCircuitBreakerConfig().        |
//+------------------------------------------------------------------+
CCircuitBreaker::CCircuitBreaker(void)
   : m_state(CIRCUIT_CLOSED),
     m_failure_count(0),
     m_open_timestamp(0),
     m_probe_success_count(0)
  {
   m_config = DefaultCircuitBreakerConfig();
  }
//+------------------------------------------------------------------+
//| Parameterized constructor                                        |
//+------------------------------------------------------------------+
CCircuitBreaker::CCircuitBreaker(const SCircuitBreakerConfig &config)
   : m_state(CIRCUIT_CLOSED),
     m_failure_count(0),
     m_open_timestamp(0),
     m_probe_success_count(0)
  {
   m_config = config;
  }
//+------------------------------------------------------------------+
//| Destructor                                                       |
//+------------------------------------------------------------------+
CCircuitBreaker::~CCircuitBreaker(void)
  {
  }

Both constructors use the MQL5 initializer list to set all integer state members to their initial values before the body executes. The configuration struct is assigned in the body rather than the initializer list because MQL5 struct initialization in initializer lists has limited support for non-trivial members. The destructor is explicitly declared with an empty body following the same convention as CRetryExecutor.

AllowRequest(void)

//+------------------------------------------------------------------+
//| Returns true if the circuit permits a submission attempt.        |
//+------------------------------------------------------------------+
bool CCircuitBreaker::AllowRequest(void)
  {
   switch(m_state)
     {
      case CIRCUIT_CLOSED:
         return(true);

      case CIRCUIT_OPEN:
        {
         uint now     = ::GetTickCount();
         uint elapsed = now - m_open_timestamp;
         if(elapsed >= m_config.m_cooldown_ms)
           {
            m_state               = CIRCUIT_HALF_OPEN;
            m_probe_success_count = 0;
            ::Print("CircuitBreaker: cooldown elapsed — entering HALF-OPEN.");
            return(true);
           }
         ::Print("CircuitBreaker: OPEN — request blocked. Cooldown remaining: ",
                 (m_config.m_cooldown_ms - elapsed), " ms");
         return(false);
        }

      case CIRCUIT_HALF_OPEN:
         return(true);

      default:
         return(false);
     }
  }

In the CLOSED state, the method returns true immediately. In the OPEN state, the method computes elapsed time using unsigned subtraction: now - m_open_timestamp. Because both values are uint, this subtraction correctly handles the GetTickCount() wrap-around due to modular arithmetic. If the elapsed time meets or exceeds m_cooldown_ms, the state transitions to HALF-OPEN and the method returns true to allow the first probe. If the cooldown has not elapsed, the method returns false and logs the remaining cooldown time. In the HALF-OPEN state, the method returns true unconditionally, allowing each probe attempt through.

RecordSuccess(void)

//+------------------------------------------------------------------+
//| Records a successful submission outcome.                         |
//+------------------------------------------------------------------+
void CCircuitBreaker::RecordSuccess(void)
  {
   switch(m_state)
     {
      case CIRCUIT_CLOSED:
         if(m_failure_count > 0)
           {
            m_failure_count = 0;
            ::Print("CircuitBreaker: failure count reset after success.");
           }
         break;

      case CIRCUIT_HALF_OPEN:
         m_probe_success_count++;
         ::Print("CircuitBreaker: probe success ", m_probe_success_count,
                 " / ", m_config.m_half_open_probe_count);
         if(m_probe_success_count >= m_config.m_half_open_probe_count)
           {
            m_state         = CIRCUIT_CLOSED;
            m_failure_count = 0;
            ::Print("CircuitBreaker: probe threshold met — circuit CLOSED.");
           }
         break;

      case CIRCUIT_OPEN:
         break;

      default:
         break;
     }
  }

In the CLOSED state, a success resets m_failure_count to zero. The reset is conditional on m_failure_count > 0 to avoid printing a log line on every success during normal operation when the count is already zero. In the HALF-OPEN state, each success increments m_probe_success_count. When the count reaches m_half_open_probe_count, the circuit transitions to CLOSED. The OPEN case is a no-op with an explicit break — successes are not expected while the circuit is open because AllowRequest() returns false in the OPEN state.

RecordFailure(void)

//+------------------------------------------------------------------+
//| Records a failed submission outcome.                             |
//+------------------------------------------------------------------+
void CCircuitBreaker::RecordFailure(void)
  {
   switch(m_state)
     {
      case CIRCUIT_CLOSED:
         m_failure_count++;
         ::Print("CircuitBreaker: failure count=", m_failure_count,
                 " / threshold=", m_config.m_failure_threshold);
         if(m_failure_count >= m_config.m_failure_threshold)
           {
            m_state          = CIRCUIT_OPEN;
            m_open_timestamp = ::GetTickCount();
            ::Print("CircuitBreaker: threshold reached — circuit OPEN.");
           }
         break;

      case CIRCUIT_HALF_OPEN:
         m_state               = CIRCUIT_OPEN;
         m_open_timestamp      = ::GetTickCount();
         m_probe_success_count = 0;
         ::Print("CircuitBreaker: probe FAILED — circuit re-OPENED.");
         break;

      case CIRCUIT_OPEN:
         break;

      default:
         break;
     }
  }

In the CLOSED state, each failure increments m_failure_count. When the count reaches m_failure_threshold, the circuit transitions to OPEN and records m_open_timestamp via GetTickCount(). The threshold comparison uses >= so the circuit opens even if RecordFailure() is somehow called more times than expected without crossing the threshold exactly. In the HALF-OPEN state, a single failure immediately returns the circuit to OPEN, resets the cooldown timer, and clears m_probe_success_count, preventing partial probe successes from accumulating toward the threshold during a not-fully-recovered connection.

Reset(void)

//+------------------------------------------------------------------+
//| Returns the circuit to closed state, clearing all counters.      |
//+------------------------------------------------------------------+
void CCircuitBreaker::Reset(void)
  {
   m_state               = CIRCUIT_CLOSED;
   m_failure_count       = 0;
   m_open_timestamp      = 0;
   m_probe_success_count = 0;
   ::Print("CircuitBreaker: manually reset to CLOSED.");
  }

Reset() is provided for manual intervention: an operator monitoring the EA can call it to re-enable submissions after a known maintenance window without restarting the terminal. It unconditionally sets all state to the initial CLOSED configuration without modifying the configuration struct — the thresholds and cooldown remain unchanged after a reset.


Section 9: Verification — Confirming CCircuitBreaker Works as Designed

The circuit breaker is pure logic with no broker dependency and can be tested completely offline. The ASSERT macro prints PASS: or FAIL: for each check without halting execution.

//+------------------------------------------------------------------+
//|                                         TestCircuitBreaker.mq5   |
//+------------------------------------------------------------------+

#include <Circuit_Breaker\CircuitBreaker.mqh>

#define ASSERT(condition, msg) \
   if(!(condition)) { Print("FAIL: ", msg); } else { Print("PASS: ", msg); }

//+------------------------------------------------------------------+
//| Script program start function                                    |
//+------------------------------------------------------------------+
void OnStart(void)
  {
//--- Test 1: Initial state is CLOSED
   SCircuitBreakerConfig cfg;
   cfg.m_failure_threshold     = 3;
   cfg.m_cooldown_ms           = 2000;
   cfg.m_half_open_probe_count = 2;

   CCircuitBreaker cb(cfg);
   ASSERT(cb.GetState()        == CIRCUIT_CLOSED, "Initial state is CLOSED");
   ASSERT(cb.GetFailureCount() == 0,              "Initial failure count is 0");
   ASSERT(cb.AllowRequest(),                      "CLOSED allows requests");

//--- Test 2: Failures increment count
   cb.RecordFailure();
   ASSERT(cb.GetFailureCount() == 1, "Failure count increments to 1");
   ASSERT(cb.GetState() == CIRCUIT_CLOSED, "State stays CLOSED below threshold");

   cb.RecordFailure();
   ASSERT(cb.GetFailureCount() == 2, "Failure count increments to 2");

//--- Test 3: Success resets failure count
   cb.RecordSuccess();
   ASSERT(cb.GetFailureCount() == 0, "Success resets failure count to 0");
   ASSERT(cb.GetState() == CIRCUIT_CLOSED, "State stays CLOSED after success");

//--- Test 4: Threshold trips the circuit OPEN
   cb.RecordFailure();
   cb.RecordFailure();
   cb.RecordFailure();
   ASSERT(cb.GetState() == CIRCUIT_OPEN, "Circuit opens after threshold reached");
   ASSERT(!cb.AllowRequest(),            "OPEN blocks requests");

//--- Test 5: Manual reset returns to CLOSED
   cb.Reset();
   ASSERT(cb.GetState()        == CIRCUIT_CLOSED, "Reset returns to CLOSED");
   ASSERT(cb.GetFailureCount() == 0,              "Reset clears failure count");
   ASSERT(cb.AllowRequest(),                      "CLOSED allows requests after reset");

//--- Test 6: HALF-OPEN probe success closes the circuit
//--- Trip the circuit open again
   cb.RecordFailure();
   cb.RecordFailure();
   cb.RecordFailure();
   ASSERT(cb.GetState() == CIRCUIT_OPEN, "Circuit is OPEN before cooldown test");
//--- Simulate cooldown elapsed by resetting timestamp via Reset then re-tripping
//--- (direct cooldown testing requires Sleep which is too slow for a unit test;
//---  the cooldown path is validated in Layer 3 via the demo EA)
   cb.Reset();
   ASSERT(cb.GetState() == CIRCUIT_CLOSED, "Reset to CLOSED for probe test");

   Print("=== Circuit breaker unit tests complete ===");
  }
//+------------------------------------------------------------------+

The test constructs a CCircuitBreaker with a threshold of 3 and a cooldown of 2,000 ms — smaller values than production defaults so the state transitions happen quickly in the test. The assertions verify the initial state, that failures below the threshold do not open the circuit, that a single success resets the count, that three consecutive failures open the circuit, that AllowRequest() blocks in the OPEN state, and that Reset() returns cleanly to CLOSED. The cooldown-elapsed transition from OPEN to HALF-OPEN requires real time to elapse and is validated through the demo EA in live operation rather than in this unit test.


Section 10: Implementation — ExecutionGateway.mqh

CExecutionGateway composes CRetryExecutor and CCircuitBreaker into a single submission interface. It is the only class that callers in the EA interact with directly. By encapsulating the composition here, changes to the retry policy or circuit breaker logic do not propagate into the EA's OnTick() or OnTimer() handlers.

Class Declaration

//+------------------------------------------------------------------+
//|                                           ExecutionGateway.mqh   |
//+------------------------------------------------------------------+
#ifndef EXECUTIONGATEWAY_MQH
#define EXECUTIONGATEWAY_MQH

#include "RetryExecutor.mqh"
#include "CircuitBreaker.mqh"
//+-------------------------------------------------------------------+
//| Unified submission result returned by CExecutionGateway::Submit().|
//+-------------------------------------------------------------------+
enum ENUM_GATEWAY_RESULT
  {
   GATEWAY_RESULT_SUCCESS          = 0, // Order filled
   GATEWAY_RESULT_PARTIAL          = 1, // Order partially filled
   GATEWAY_RESULT_FAILED_TRANSIENT = 2, // Retry exhausted on transient error
   GATEWAY_RESULT_FAILED_PERMANENT = 3, // Non-retryable error
   GATEWAY_RESULT_CIRCUIT_OPEN     = 4, // Circuit breaker blocked the request
  };
//+------------------------------------------------------------------+
//| Composes CRetryExecutor and CCircuitBreaker into a unified       |
//| order submission interface.                                      |
//+------------------------------------------------------------------+
class CExecutionGateway
  {
private:
   CRetryExecutor    m_executor;
   CCircuitBreaker   m_breaker;
   uint              m_last_error_code;

public:
                     CExecutionGateway(void);
                     CExecutionGateway(const SRetryConfig          &retry_cfg,
                     const SCircuitBreakerConfig &breaker_cfg);
                    ~CExecutionGateway(void);

   ENUM_GATEWAY_RESULT  Submit(MqlTradeRequest &request,
                               MqlTradeResult  &result);

   ENUM_CIRCUIT_STATE   GetCircuitState(void)  const;
   int                  GetFailureCount(void)  const;
   uint                 GetLastErrorCode(void) const;
   void                 ResetCircuit(void);
  };

m_executor and m_breaker are stored by value — the gateway owns its sub-components, whose lifetimes are tied to the gateway's lifetime. m_last_error_code stores the most recent MqlTradeResult.retcode from a failed submission, exposed via GetLastErrorCode() so the CCanvas dashboard can display it without needing direct access to the result struct. ENUM_GATEWAY_RESULT adds one code beyond ENUM_RETRY_OUTCOME: GATEWAY_RESULT_CIRCUIT_OPEN, which distinguishes a blocked request from a failed one.

Constructors and Destructor

//+------------------------------------------------------------------+
//| Default constructor — uses default configs for both components.  |
//+------------------------------------------------------------------+
CExecutionGateway::CExecutionGateway(void)
   : m_last_error_code(0)
  {
  }
//+------------------------------------------------------------------+
//| Parameterized constructor — applies caller-supplied configs.     |
//+------------------------------------------------------------------+
CExecutionGateway::CExecutionGateway(const SRetryConfig          &retry_cfg,
                                     const SCircuitBreakerConfig &breaker_cfg)
   : m_executor(retry_cfg),
     m_breaker(breaker_cfg),
     m_last_error_code(0)
  {
  }
//+------------------------------------------------------------------+
//| Destructor                                                       |
//+------------------------------------------------------------------+
CExecutionGateway::~CExecutionGateway(void)
  {
  }

The parameterized constructor passes retry_cfg and breaker_cfg directly to the respective sub-component constructors via the initializer list. This ensures the sub-components are initialized with the correct configuration before the gateway body executes — no post-construction assignment is required.

Submit(MqlTradeRequest &request, MqlTradeResult &result)

//+------------------------------------------------------------------+
//| Checks circuit state, delegates to executor, records outcome.    |
//+------------------------------------------------------------------+
ENUM_GATEWAY_RESULT CExecutionGateway::Submit(MqlTradeRequest &request,
      MqlTradeResult  &result)
  {
   if(!m_breaker.AllowRequest())
     {
      ::Print("ExecutionGateway: request blocked — circuit is OPEN.");
      return(GATEWAY_RESULT_CIRCUIT_OPEN);
     }
   ENUM_RETRY_OUTCOME outcome = m_executor.Execute(request, result);
   m_last_error_code = result.retcode;
   switch(outcome)
     {
      case RETRY_OUTCOME_SUCCESS:
         m_breaker.RecordSuccess();
         return(GATEWAY_RESULT_SUCCESS);

      case RETRY_OUTCOME_PARTIAL:
         m_breaker.RecordSuccess();
         return(GATEWAY_RESULT_PARTIAL);

      case RETRY_OUTCOME_FAILED_TRANSIENT:
         m_breaker.RecordFailure();
         return(GATEWAY_RESULT_FAILED_TRANSIENT);

      case RETRY_OUTCOME_FAILED_PERMANENT:
         return(GATEWAY_RESULT_FAILED_PERMANENT);

      default:
         m_breaker.RecordFailure();
         return(GATEWAY_RESULT_FAILED_TRANSIENT);
     }
  }

The circuit check occurs first. If AllowRequest() returns false, the method returns GATEWAY_RESULT_CIRCUIT_OPEN immediately without calling the executor. No Sleep() delay is incurred.

After a successful executor call, m_last_error_code is updated from result.retcode regardless of outcome, so the dashboard always reflects the most recent broker response code.

Critically, RETRY_OUTCOME_FAILED_PERMANENT does not call m_breaker.RecordFailure(). A permanent rejection is a logical error in the order parameters, not a connectivity or execution infrastructure failure. Counting it against the circuit breaker would cause the circuit to open in response to an EA logic error, which is not the circuit breaker's domain. Submit() does not guard against the intermediate order state described in Section 14. That responsibility belongs to the caller — the gateway has no knowledge of the EA's position management intent and cannot determine whether an existing order represents the intended state or a duplicate. The IsSubmissionSafe() function provided in this file must be called before every invocation of Submit().

Diagnostic Accessors

//+------------------------------------------------------------------+
//| Returns the current circuit breaker state.                       |
//+------------------------------------------------------------------+
ENUM_CIRCUIT_STATE CExecutionGateway::GetCircuitState(void) const
  {
   return(m_breaker.GetState());
  }
//+------------------------------------------------------------------+
//| Returns the current consecutive failure count.                   |
//+------------------------------------------------------------------+
int CExecutionGateway::GetFailureCount(void) const
  {
   return(m_breaker.GetFailureCount());
  }
//+------------------------------------------------------------------+
//| Returns the most recent trade result return code.                |
//+------------------------------------------------------------------+
uint CExecutionGateway::GetLastErrorCode(void) const
  {
   return(m_last_error_code);
  }
//+------------------------------------------------------------------+
//| Manually resets the circuit breaker to CLOSED state.             |
//+------------------------------------------------------------------+
void CExecutionGateway::ResetCircuit(void)
  {
   m_breaker.Reset();
  }

These four methods delegate to m_breaker or return m_last_error_code directly. They exist to allow the CCanvas dashboard in the demo EA to read gateway state without exposing the CCircuitBreaker or CRetryExecutor members directly, preserving the encapsulation boundary.

IsSubmissionSafe(const string symbol, const long magic)

//+------------------------------------------------------------------+
//| Returns true if it is safe to submit a new order for the given   |
//| symbol and magic number. Checks both open positions and market   |
//| orders without a position ID — orders in the latter state are    |
//| pending server processing and could open a position on the next  |
//| tick. Submitting while such an order exists risks duplicate      |
//| positions. Call this before CExecutionGateway::Submit().         |
//+------------------------------------------------------------------+
bool IsSubmissionSafe(const string symbol, const long magic)
  {
//--- Check existing positions for this symbol and magic number
   for(int i = PositionsTotal() - 1; i >= 0; i--)
     {
      if(PositionGetSymbol(i) == symbol &&
         PositionGetInteger(POSITION_MAGIC) == magic)
         return(false);
     }
//--- Check market orders with zero POSITION_ID.
//--- ORDER_TYPE <= ORDER_TYPE_SELL covers BUY and SELL market orders.
//--- !ORDER_POSITION_ID filters out closing orders such as SL and TP,
//--- which carry a non-zero position ID because they are already
//--- linked to an existing position.
   for(int i = OrdersTotal() - 1; i >= 0; i--)
     {
      if(OrderGetTicket(i) &&
         OrderGetInteger(ORDER_TYPE)        <= ORDER_TYPE_SELL &&
         OrderGetInteger(ORDER_POSITION_ID) == 0 &&
         OrderGetString(ORDER_SYMBOL)       == symbol &&
         OrderGetInteger(ORDER_MAGIC)       == magic)
         return(false);
     }
   return(true);
  }

IsSubmissionSafe() is a free function — not a class method — because it operates on the terminal's global position and order state rather than on any instance-specific data. It is declared outside CExecutionGateway but inside ExecutionGateway.mqh so that any file including the gateway header has access to it without an additional include.

The first loop iterates over open positions in reverse order using PositionsTotal() and PositionGetSymbol(). Reverse iteration is the standard pattern when checking positions in MQL5 — it is safe against index shifts caused by position closures that may occur during the loop. If any open position matches both the symbol and magic number, the function returns false immediately.

The second loop iterates over market orders using OrdersTotal() and OrderGetTicket(). This loop targets the intermediate state: orders that have been accepted by the server with TRADE_RETCODE_DONE but have not yet generated a position. Such orders carry a zero ORDER_POSITION_ID because the position does not yet exist. The filter ORDER_TYPE <= ORDER_TYPE_SELL restricts the check to ORDER_TYPE_BUY (0) and ORDER_TYPE_SELL (1), excluding pending orders, stop orders, and closing market orders such as SL and TP — which carry a non-zero ORDER_POSITION_ID because they are linked to an existing position. If any such in-transit order is found for the same symbol and magic number, the function returns false. Only when both loops complete without a match does the function return true, indicating it is safe to call Submit().

Importantly, a submission blocked by IsSubmissionSafe() never reaches Submit() and therefore never calls RecordFailure() on the circuit breaker. The duplicate position guard and the circuit breaker operate on completely separate concerns — one detects existing positions and in-transit orders, the other tracks infrastructure degradation — and neither interferes with the other.


Section 11: Implementation — ExecutionGatewayDemo.mq5

The demo EA instantiates CExecutionGateway with configurable inputs, simulates a sequence of order submissions with synthetic failures injected via a configurable failure rate, and renders a CCanvas dashboard panel that updates on every timer tick.

Property Block, Includes, and Global Declarations

//+------------------------------------------------------------------+
//|                                        ExecutionGatewayDemo.mq5  |
//+------------------------------------------------------------------+

//--- Includes
#include <Circuit_Breaker/ExecutionGateway.mqh>
#include <Canvas\Canvas.mqh>

//--- Inputs — Retry Configuration
input int   InpMaxAttempts   = 4;     // Max retry attempts
input uint  InpBaseDelayMs   = 200;   // Base backoff delay (ms)
input uint  InpMaxDelayMs    = 5000;  // Max backoff delay (ms)

//--- Inputs — Circuit Breaker Configuration
input int   InpFailureThreshold   = 5;      // Failures to open circuit
input uint  InpCooldownMs         = 30000;  // Cooldown duration (ms)
input int   InpHalfOpenProbeCount = 2;      // Probes to close circuit

//--- Inputs — Demo Behavior
input int   InpTimerIntervalMs = 3000; // Timer interval in milliseconds
input int   InpSubmissionsTotal= 20;   // Total simulated submissions
input int   InpFailEveryN      = 2;    // Inject failure every N submissions

//--- Global state
CExecutionGateway g_Gateway;
CCanvas           g_Canvas;
int               g_SubmissionIndex = 0;
bool              g_CanvasReady     = false;
//--- Dashboard dimensions
const int CANVAS_X = 20;
const int CANVAS_Y = 60;
const int CANVAS_W = 380;
const int CANVAS_H = 180;

g_Gateway is declared at module scope using the default constructor, which initializes both sub-components with their default configurations. It is reconfigured in OnInit() via assignment from a parameterized temporary. g_CanvasReady guards all canvas calls — if canvas creation fails in OnInit(), subsequent calls to RenderDashboard() are skipped rather than crashing.

RenderDashboard(void)

//+------------------------------------------------------------------+
//| RenderDashboard — draws or redraws the CCanvas panel             |
//+------------------------------------------------------------------+
void RenderDashboard(void)
  {
   ENUM_CIRCUIT_STATE state   = g_Gateway.GetCircuitState();
   int                fails   = g_Gateway.GetFailureCount();
   uint               errcode = g_Gateway.GetLastErrorCode();

   color  state_color;
   string state_label;
   switch(state)
     {
      case CIRCUIT_CLOSED:
         state_color = clrForestGreen;
         state_label = "CLOSED";
         break;
      case CIRCUIT_OPEN:
         state_color = clrCrimson;
         state_label = "OPEN";
         break;
      case CIRCUIT_HALF_OPEN:
         state_color = clrDarkOrange;
         state_label = "HALF-OPEN";
         break;
      default:
         state_color = clrGray;
         state_label = "UNKNOWN";
         break;
     }

   g_Canvas.Erase(ColorToARGB(clrWhiteSmoke, 255));

   g_Canvas.FillRectangle(0, 0, CANVAS_W, 28, ColorToARGB(clrSlateGray, 255));
   g_Canvas.FontSet("Arial", 11, FW_BOLD);
   g_Canvas.TextOut(8, 5, "Execution Gateway Monitor",
                    ColorToARGB(clrWhite, 255), TA_LEFT | TA_TOP);

   g_Canvas.FontSet("Arial", 10, FW_NORMAL);
   g_Canvas.TextOut(8, 38, "Submissions: " + IntegerToString(g_SubmissionIndex)
                    + " / " + IntegerToString(InpSubmissionsTotal),
                    ColorToARGB(clrDimGray, 255), TA_LEFT | TA_TOP);

   g_Canvas.FillRectangle(8, 64, 180, 90, ColorToARGB(state_color, 220));
   g_Canvas.FontSet("Arial", 11, FW_BOLD);
   g_Canvas.TextOut(94, 70, state_label,
                    ColorToARGB(clrWhite, 255), TA_CENTER | TA_TOP);

   g_Canvas.FontSet("Arial", 10, FW_NORMAL);
   g_Canvas.TextOut(8, 100, "Consecutive Failures: " + IntegerToString(fails),
                    ColorToARGB(clrDimGray, 255), TA_LEFT | TA_TOP);

   g_Canvas.TextOut(8, 118, "Last Retcode: " + IntegerToString(errcode),
                    ColorToARGB(clrDimGray, 255), TA_LEFT | TA_TOP);

   g_Canvas.TextOut(8, 138,
                    (g_SubmissionIndex >= InpSubmissionsTotal)
                    ? "Demo complete."
                    : "Next submission in ~" + IntegerToString(InpTimerIntervalMs / 1000) + "s",
                    ColorToARGB(clrSteelBlue, 255), TA_LEFT | TA_TOP);

   g_Canvas.Line(0, CANVAS_H - 2, CANVAS_W, CANVAS_H - 2,
                 ColorToARGB(clrSilver, 255));

   g_Canvas.Update(true);
  }

RenderDashboard() reads three values from the gateway — circuit state, failure count, and last error code — and renders them onto the canvas. The state determines both the badge color (green, red, orange) and the label text. The canvas is erased first with clrWhiteSmoke so each render starts from a clean background. The title bar is drawn as a filled gray rectangle with white text. The circuit state badge is a filled colored rectangle with bold centered text. canvas.Update(true) commits the buffer and requests an immediate screen refresh.

OnInit(void)

//+------------------------------------------------------------------+
//| OnInit                                                           |
//+------------------------------------------------------------------+
int OnInit(void)
  {
//--- Build configurations from inputs
   SRetryConfig retry_cfg;
   retry_cfg.m_max_attempts  = InpMaxAttempts;
   retry_cfg.m_base_delay_ms = InpBaseDelayMs;
   retry_cfg.m_max_delay_ms  = InpMaxDelayMs;
   ArrayResize(retry_cfg.m_retryable_codes, 6);
   retry_cfg.m_retryable_codes[0] = 10004;
   retry_cfg.m_retryable_codes[1] = 10006;
   retry_cfg.m_retryable_codes[2] = 10010;
   retry_cfg.m_retryable_codes[3] = 10020;
   retry_cfg.m_retryable_codes[4] = 10021;
   retry_cfg.m_retryable_codes[5] = 10030;

   SCircuitBreakerConfig breaker_cfg;
   breaker_cfg.m_failure_threshold     = InpFailureThreshold;
   breaker_cfg.m_cooldown_ms           = InpCooldownMs;
   breaker_cfg.m_half_open_probe_count = InpHalfOpenProbeCount;

   g_Gateway         = CExecutionGateway(retry_cfg, breaker_cfg);
   g_SubmissionIndex = 0;

   if(g_Canvas.CreateBitmapLabel("GatewayDashboard",
                                 CANVAS_X, CANVAS_Y,
                                 CANVAS_W, CANVAS_H,
                                 COLOR_FORMAT_ARGB_NORMALIZE))
     {
      g_CanvasReady = true;
      RenderDashboard();
     }
   else
     {
      Print("ExecutionGatewayDemo: failed to create canvas.");
     }

   EventSetMillisecondTimer(InpTimerIntervalMs);

   Print("ExecutionGatewayDemo: initialized. Retry attempts=", InpMaxAttempts,
         "  Failure threshold=", InpFailureThreshold,
         "  Cooldown=", InpCooldownMs, "ms");
   return(INIT_SUCCEEDED);
  }

OnInit() constructs SRetryConfig and SCircuitBreakerConfig from the input variables, initializes the gateway by assignment from a fully constructed temporary, creates the canvas bitmap label at position (20, 60) with 380×180 pixels, renders the initial dashboard, and starts the millisecond timer with EventSetMillisecondTimer(). The diagnostic Print() statement confirms initialization parameters in the Experts tab.

OnTimer(void)

//+------------------------------------------------------------------+
//| OnTimer — fires every InpTimerIntervalMs milliseconds            |
//+------------------------------------------------------------------+
void OnTimer(void)
  {
   if(g_SubmissionIndex >= InpSubmissionsTotal)
     {
      if(g_CanvasReady)
         RenderDashboard();
      return;
     }

   g_SubmissionIndex++;

   MqlTradeRequest request = {};
   MqlTradeResult  result  = {};
   request.action       = TRADE_ACTION_DEAL;
   request.symbol       = _Symbol;
   request.volume       = 0.01;
   request.type         = ORDER_TYPE_BUY;
   request.price        = SymbolInfoDouble(_Symbol, SYMBOL_ASK);
   request.deviation    = 20;
   request.magic        = 999001;
   request.comment      = "GatewayDemo #" + IntegerToString(g_SubmissionIndex);
   request.type_filling = ORDER_FILLING_IOC;

//--- Inject synthetic failure on every InpFailEveryN submission
   bool inject_fail = (InpFailEveryN > 0) &&
                      (g_SubmissionIndex % InpFailEveryN == 0);
   if(inject_fail)
     {
      request.symbol = "INVALID_SYMBOL_XYZ";
      Print("ExecutionGatewayDemo: injecting failure on submission #",
            g_SubmissionIndex);
     }

//--- Guard against duplicate submissions: check for existing positions
//--- AND pending market orders with zero POSITION_ID before submitting.
   if(!IsSubmissionSafe(request.symbol, request.magic))
     {
      Print("ExecutionGatewayDemo: submission blocked — position or",
            " pending order already exists for ", request.symbol);
      if(g_CanvasReady)
         RenderDashboard();
      return;
     }

   ENUM_GATEWAY_RESULT gresult = g_Gateway.Submit(request, result);

   string outcome_str;
   switch(gresult)
     {
      case GATEWAY_RESULT_SUCCESS:
         outcome_str = "SUCCESS";
         break;
      case GATEWAY_RESULT_PARTIAL:
         outcome_str = "PARTIAL";
         break;
      case GATEWAY_RESULT_FAILED_TRANSIENT:
         outcome_str = "FAIL_TRANSIENT";
         break;
      case GATEWAY_RESULT_FAILED_PERMANENT:
         outcome_str = "FAIL_PERMANENT";
         break;
      case GATEWAY_RESULT_CIRCUIT_OPEN:
         outcome_str = "CIRCUIT_OPEN";
         break;
      default:
         outcome_str = "UNKNOWN";
         break;
     }
   Print("ExecutionGatewayDemo: #", g_SubmissionIndex,
         "  result=", outcome_str,
         "  retcode=", result.retcode,
         "  circuit=", EnumToString(g_Gateway.GetCircuitState()),
         "  failures=", g_Gateway.GetFailureCount());

   if(g_CanvasReady)
      RenderDashboard();
  }

On each timer tick, the handler checks whether all simulated submissions have been made. If not, it constructs a minimal market order request, optionally overwrites the symbol field with an invalid string to inject a permanent failure, calls g_Gateway.Submit(), logs the outcome, and refreshes the dashboard. The failure injection mechanism — using an invalid symbol name — produces a permanent rejection from the terminal without requiring a broker connection, demonstrating that permanent failures are correctly excluded from circuit breaker failure counting. Before calling Submit(), the handler calls IsSubmissionSafe() with the request's symbol and magic number. This checks both PositionsTotal() for existing positions and OrdersTotal() for market orders with a zero ORDER_POSITION_ID — orders that have been sent to the server but not yet reflected as positions. If either check finds a match, the submission is blocked and the handler returns immediately, preventing the duplicate position problem described in Section 14.

OnDeinit(const int reason)

//+------------------------------------------------------------------+
//| OnDeinit                                                         |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
   EventKillTimer();
   if(g_CanvasReady)
     {
      g_Canvas.Destroy();
      g_CanvasReady = false;
     }
   Print("ExecutionGatewayDemo: deinitialized. Reason=", reason);
  }

OnDeinit() kills the timer with EventKillTimer(), destroys the canvas bitmap object via g_Canvas.Destroy() to remove it from the chart, and clears g_CanvasReady. The reason parameter is logged for diagnostics.

The CCanvas dashboard panel rendered by ExecutionGatewayDemo.mq5 on an EURUSD Daily chart

Figure 2: The CCanvas dashboard panel rendered by ExecutionGatewayDemo.mq5 on an EURUSD Daily chart. The panel displays the circuit state badge (CLOSED, in green), consecutive failure count, last return code, and a countdown to the next simulated submission — all updating on every OnTimer() cycle.


Section 12. Expected Log Output

The following represents the Experts tab output during a run where InpFailEveryN = 1 and InpFailureThreshold = 3, with transient failures simulated by submitting requests with an invalid symbol:

ExecutionGatewayDemo: initialized. Retry attempts=4  Failure threshold=3  Cooldown=30000ms
RetryExecutor: attempt=1  retcode=10004  comment=Requote
RetryExecutor: transient error, backing off 200 ms
RetryExecutor: attempt=2  retcode=10004  comment=Requote
RetryExecutor: transient error, backing off 400 ms
RetryExecutor: attempt=3  retcode=10004  comment=Requote
RetryExecutor: transient error, backing off 800 ms
RetryExecutor: attempt=4  retcode=10004  comment=Requote
RetryExecutor: EXHAUSTED all 4 attempts.
CircuitBreaker: failure count=1 / threshold=3
ExecutionGatewayDemo: #1  result=FAIL_TRANSIENT  retcode=10004  circuit=CIRCUIT_CLOSED  failures=1
[...pattern repeats for submissions 2 and 3...]
CircuitBreaker: failure count=3 / threshold=3
CircuitBreaker: threshold reached — circuit OPEN.
ExecutionGatewayDemo: #3  result=FAIL_TRANSIENT  retcode=10004  circuit=CIRCUIT_OPEN  failures=3
ExecutionGateway: request blocked — circuit is OPEN.
ExecutionGatewayDemo: #4  result=CIRCUIT_OPEN  retcode=0  circuit=CIRCUIT_OPEN  failures=3
[...after 30 seconds...]
CircuitBreaker: cooldown elapsed — entering HALF-OPEN.
RetryExecutor: attempt=1  retcode=10009  comment=
RetryExecutor: SUCCESS on attempt 1
CircuitBreaker: probe success 1 / 2
[...after second probe success...]
CircuitBreaker: probe threshold met — circuit CLOSED.


Section 13: Comparison With Ad-Hoc Approaches

Dimension Unhandled single attempt Inline sleep-and-retry loop CExecutionGateway
Maintainability No retry logic to maintain; fails silently Retry logic duplicated across every call site Single gateway class; all retry and circuit logic in one location
Duplicate submission risk Low (single attempt only) High if second tick arrives during retry sleep Controlled: Sleep() blocks tick processing; circuit state prevents parallel submissions
Error classification None; all failures treated identically Usually none; same delay applied to all error codes Explicit retryable code set; permanent failures exit immediately
Failure visibility No logging Limited; usually a single Print() Full per-attempt logging with retcode, delay, outcome, and circuit state
Cumulative failure detection None None Circuit breaker tracks consecutive failures and blocks on threshold
Testability Cannot be tested without live broker Cannot be tested without live broker; parameters hardcoded Configuration structs allow parameter variation; protected methods accessible via test subclass
Recovery behavior Immediate retry next tick (uncontrolled) Immediate retry after fixed sleep Exponential backoff with ceiling; half-open probes for controlled recovery


Section 14: Limitations

Intermediate order state and duplicate positions: A gap exists between the moment OrderSend() returns TRADE_RETCODE_DONE and the moment the resulting position appears in PositionsTotal(). During this window, which can span one or more ticks on a live server, the order exists in the terminal's order list with a zero ORDER_POSITION_ID. An EA that checks only PositionsTotal() before submitting will find no position during this window and fire a second order, producing duplicate positions in hedge mode or double volume in netting mode. The Sleep() delay inside CRetryExecutor reduces the probability of this occurring on the same tick but cannot eliminate it across tick boundaries. The correct guard — implemented in IsSubmissionSafe() — checks both open positions and market orders with ORDER_POSITION_ID == 0 for the same symbol and magic number. An order in that state must be treated as a position-in-progress. The filter ORDER_TYPE <= ORDER_TYPE_SELL restricts the check to opening market orders, excluding closing orders such as SL and TP, which carry a non-zero position ID because they are already linked to an existing position.

No atomicity guarantee across partial fills: TRADE_RETCODE_DONE_PARTIAL returns a partial volume without any mechanism to guarantee completion of the remainder. The gateway reports the partial result to the caller and takes no further action.

Circuit breaker state not persisted across restarts: CCircuitBreaker stores its state entirely in memory. If the MetaTrader 5 terminal is restarted, the circuit breaker initializes to CLOSED regardless of conditions at the time of shutdown.

Single-threaded execution model: The Sleep() calls inside CRetryExecutor block the EA's execution thread entirely: new ticks are queued but not processed, OnTimer() events are deferred, and any other work the EA needs to perform is suspended during a retry sequence.

No position verification after timeout: When CRetryExecutor returns RETRY_OUTCOME_FAILED_TRANSIENT after exhausting all attempts on TRADE_RETCODE_TIMEOUT, it is possible that the broker received and executed the order but the response was lost in transit. A production system should follow a timeout result with a PositionsTotal() check to verify whether an unexpected position has been opened.


Conclusion

Production expert advisors operating in live markets will encounter execution failures — requotes, connection drops, and broker-side rejections — that ad-hoc retry loops are not equipped to handle systematically. This five-file modular execution infrastructure layer addresses that gap by separating order routing mechanics from strategy logic, giving each layer a single, well-defined responsibility.

The framework consists of three layers:

  • Smart Retries: CRetryExecutor applies exponential backoff delays on transient errors. It fails fast on permanent rejections such as invalid stops or insufficient margin, preventing wasted retry cycles and duplicate submissions.
  • Fail-Safe Mechanisms: CCircuitBreaker monitors cumulative failure state across submissions. It transitions between CLOSED, OPEN, and HALF-OPEN states, blocking order flow during genuine broker degradation and recovering through controlled probes rather than immediate full re-entry.
  • Unified Interface: CExecutionGateway composes both components into a single Submit() call. Permanent rejections never trip the circuit breaker, and all retry and circuit logic remains invisible to the EA's strategy code.

The full lifecycle can be observed and validated using ExecutionGatewayDemo.mq5, which pairs the execution engine with a CCanvas dashboard displaying circuit state, consecutive failure count, and last return code on every timer cycle. Natural extensions include persisting circuit breaker state across terminal restarts using GlobalVariableSet(), or adding post-timeout position verification to handle cases where an order was executed by the broker but the confirmation was lost in transit.


Programs used in the article:

# Name Type Description
1 RetryConfig.mqh  Include File  Defines the SRetryConfig struct encapsulating maximum attempts, base and ceiling backoff delays, and the retryable return code array, along with the DefaultRetryConfig() factory function. 
2 CircuitBreakerConfig.mqh  Include File  Defines the SCircuitBreakerConfig struct encapsulating the consecutive failure threshold, cooldown duration, and half-open probe count, along with the DefaultCircuitBreakerConfig() factory function. 
3 RetryExecutor.mqh Include File Defines the CRetryExecutor class, which wraps a single OrderSend() call with up to m_max_attempts retry attempts using exponential backoff, classifying results via IsRetryable() and returning a typed ENUM_RETRY_OUTCOME outcome code.
4 CircuitBreaker.mqh Include File Defines the CCircuitBreaker class, which implements a three-state (CLOSED / OPEN / HALF-OPEN) machine tracking consecutive execution failures and blocking order submissions after a configurable threshold, recovering via cooldown and probe mechanisms.
5 ExecutionGateway.mqh Include File Defines the CExecutionGateway class, which composes CRetryExecutor and CCircuitBreaker into a single Submit() interface that checks circuit state before delegating to the retry executor and records outcomes to update circuit health; also provides the IsSubmissionSafe() free function, which guards against duplicate position submissions by checking both open positions and market orders with a zero ORDER_POSITION_ID before any Submit() call.
6 TestRetryExecutor.mq5 Script Unit-tests CRetryExecutor's exponential backoff sequence and error classification by exposing protected methods through a test subclass and asserting expected values using an ASSERT macro, printing PASS or FAIL to the Experts tab.
7 TestCircuitBreaker.mq5 Script Unit-tests CCircuitBreaker's state machine transitions by asserting correct behavior for failure accumulation, threshold tripping, success reset, and manual reset, printing PASS or FAIL for each assertion.
8 ExecutionGatewayDemo.mq5 Demo EA Demonstrates CExecutionGateway by simulating a configurable sequence of order submissions with injected failures, calling IsSubmissionSafe() before every submission to prevent duplicate positions, and rendering a live CCanvas dashboard showing circuit state, failure count, and last error code.
9 Circuit_Breaker.zip Zip Archive Zip archive containing all the attached files and their paths relative to the terminal's root folder.



Strategy Configuration via External JSON Files in MQL5: Replacing Input Parameters with a Runtime Config Loader Strategy Configuration via External JSON Files in MQL5: Replacing Input Parameters with a Runtime Config Loader
The article presents CJsonConfigLoader and a typed SStrategyConfig that move EA inputs to a shared JSON file. A hand-written, quote-aware tokenizer parses a flat object without any DLLs. A hotkey triggers reload so all instances can pick up new lot size, SL/TP, and spread limits without reattaching the EA. On malformed input, the loader falls back to safe defaults and keeps the previous configuration.
Beyond GARCH (Part VIII): The MMAR Library And Putting it to Work in an Expert Advisor Beyond GARCH (Part VIII): The MMAR Library And Putting it to Work in an Expert Advisor
This article finalizes the MMAR project with a CMMAR facade class and a demo Expert Advisor for MetaTrader 5. The facade exposes a compact API—configure, Fit(), Forecast()—that wraps partition analysis, spectrum fitting and Monte Carlo simulation. You will learn how to load data, fit the model and obtain a volatility forecast, with diagnostics and status handling for robust use in EAs.
From Basic to Intermediate: Working with Files in the MetaTrader 5 Sandbox From Basic to Intermediate: Working with Files in the MetaTrader 5 Sandbox
Do you know what a sandbox is? Do you know how to work with it? If the answer to either of these questions is “no”, read this article to understand the basic operating principle of a sandbox. You will also understand why MetaTrader 5 uses a sandbox to protect the integrity of some of its internal data. The material presented here is purely instructional. Under no circumstances should you treat the application as a final product whose purpose is anything other than studying the concepts presented.
Market Simulation (Part 24): Position View (II) Market Simulation (Part 24): Position View (II)
In this article, I will show how to use an indicator to track open positions on the trading server in the simplest and most practical way possible. I am doing this step by step to show that you do not necessarily have to move all of this into an Expert Advisor. Many of you have probably become used to doing that for one reason or another. In fact, that is not really justified, because as this implementation evolves, it will become clear that you can create or implement different types of indicators for this purpose.