preview
Implementing a Trade Throttle and Rate Limiter in MQL5

Implementing a Trade Throttle and Rate Limiter in MQL5

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

Introduction

An EA triggered by a fast-moving signal during a volatile news release can submit dozens of orders within seconds. Most brokers enforce a maximum order frequency, but they rarely publish the exact limit. When an EA breaches it, the broker may return a retcode error, delay execution silently, or ignore the request outright, producing stale fills, partial fills, or quiet rejections. The EA has no visibility into how close it is to the limit until it has already breached it.

The obvious fix, which is adding a cooldown between orders, drops valid signals during the wait. A signal that arrives two seconds after another cannot be submitted; it is simply gone. That is not a rate limit; it is a signal filter, and the strategy never intended one.

This article builds CTradeThrottle, a class implementing the token bucket algorithm, a standard rate-limiting technique used throughout network engineering and API design. The bucket holds up to N tokens. Tokens refill continuously at a configurable rate per second. Each order attempt consumes exactly one token. When a token is available, the order passes through immediately. When the bucket is empty, the order is placed into a priority queue and released as tokens return. No request is discarded solely because of rate limiting — it is queued instead, and released as tokens become available, with higher-priority requests released before lower-priority ones. Whether a delayed request remains strategically valid is outside the throttle's scope. It depends on the signal's timing sensitivity (see Section 10).

This is an infrastructure throttle layer focused specifically on pacing and queuing. It is not a complete, production-ready execution engine. Sections 6, 9, and 10 state this explicitly. They also list responsibilities (order construction and lifecycle tracking) that belong to a separate execution layer.

Trade Throttle architectural diagram

Architectural diagram showing CTradeThrottle routing incoming order requests through CTokenBucket for immediate pass-through or CPriorityQueue for queued execution, with OnTimer() draining the queue back into OrderSend().


Section 1: The Token Bucket Algorithm

A fixed cooldown timer prevents any order within a fixed window after the previous one. A burst of ten signals produces one order and discards nine. The token bucket does something different: it allows a burst up to the bucket's capacity, then smoothly limits sustained throughput to the refill rate.

The refill formula is:

tokens = min(capacity, tokens + rate × elapsed_seconds)

This runs on every Acquire() call rather than on a fixed clock. If 0.3 seconds have passed since the last call and the rate is 2 tokens per second, 0.6 tokens accrue. Using a floating-point token count rather than an integer makes this work correctly: fractional tokens accumulate between calls, and a full token becomes available the moment they sum to 1.0 or above. No timer tick is wasted, and sub-second refill rates are accurate.

The relationship between capacity and rate is the system's key design decision. Capacity sets the maximum burst the throttle tolerates: a bucket with capacity 5 and rate 2 per second lets five orders through instantly, then releases at most two per second indefinitely. If the broker's real limit is three per second, configuring the throttle at two provides a safety margin.


Section 2: CThrottledRequest — the Queued Order

When the bucket is empty, an order cannot execute immediately but should not be discarded. It goes into a queue, and the queue needs to know everything necessary to submit that order later, including when to release it relative to other queued orders.

//+------------------------------------------------------------------+
//|                                         ThrottledRequest.mqh     |
//+------------------------------------------------------------------+
#ifndef THROTTLED_REQUEST_MQH
#define THROTTLED_REQUEST_MQH

//+------------------------------------------------------------------+
//| CThrottledRequest                                                |
//+------------------------------------------------------------------+
struct CThrottledRequest
  {
   ulong             request_id;          // unique identifier assigned at enqueue time
   string            symbol;              // symbol the order is for
   ENUM_ORDER_TYPE   order_type;          // BUY or SELL
   double            volume;              // requested lot size
   double            price;               // requested entry price
   double            sl;                  // stop loss price (0.0 if not set)
   double            tp;                  // take profit price (0.0 if not set)
   string            comment;             // order comment
   double            priority;            // caller-supplied score; higher value = released first
   datetime          enqueue_time;        // wall-clock time the request entered the queue
   ulong             enqueue_ms;          // millisecond timestamp for sub-second FIFO ordering

                     CThrottledRequest(void)
     {
      request_id   = 0;
      symbol       = "";
      order_type   = ORDER_TYPE_BUY;
      volume       = 0.0;
      price        = 0.0;
      sl           = 0.0;
      tp           = 0.0;
      comment      = "";
      priority     = 0.0;
      enqueue_time = 0;
      enqueue_ms   = 0;
     }

                    ~CThrottledRequest(void)
     {
     }
  };

#endif // THROTTLED_REQUEST_MQH
//+------------------------------------------------------------------+

priority and enqueue_ms work together to determine release order. When the queue drains, the highest-priority request goes first. When two requests share the same priority score, the one with the smaller enqueue_ms goes first — strictly FIFO among equals. enqueue_time is a datetime used for human-readable logging; enqueue_ms is the millisecond-precision value the queue actually sorts on.

request_id is assigned by the throttle at submission time and is the value Submit() returns to the caller, so the caller can later cancel a specific request from the queue, for example, if a signal reverses before the queued order fires. Section 6 explains exactly what this return value does and does not represent.


Section 3: CThrottleStatus — the Throttle State Snapshot

GetStatus() returns a CThrottleStatus rather than a set of individual values, so a dashboard renderer or a calling strategy can read the entire throttle state atomically in one call.

//+------------------------------------------------------------------+
//|                                             ThrottleStatus.mqh   |
//+------------------------------------------------------------------+

#ifndef THROTTLE_STATUS_MQH
#define THROTTLE_STATUS_MQH

//+------------------------------------------------------------------+
//| CThrottleStatus                                                  |
//+------------------------------------------------------------------+
struct CThrottleStatus
  {
   double            tokens_available;    // current token count, including fractional tokens
   double            token_capacity;      // maximum tokens the bucket can hold
   double            refill_rate;         // tokens added per second
   int               queue_depth;         // number of orders currently waiting in the queue
   int               queue_capacity;      // maximum orders the queue can hold
   int               executed_last_60s;   // orders dispatched to OrderSend in the last 60 seconds
   ulong             ms_until_next_token; // milliseconds until at least one full token is available
   bool              throttling_active;   // true when the bucket is empty and orders are being queued

                     CThrottleStatus(void)
     {
      tokens_available  = 0.0;
      token_capacity    = 0.0;
      refill_rate       = 0.0;
      queue_depth       = 0;
      queue_capacity    = 0;
      executed_last_60s = 0;
      ms_until_next_token = 0;
      throttling_active = false;
     }

                    ~CThrottleStatus(void)
     {
     }
  };

#endif // THROTTLE_STATUS_MQH
//+------------------------------------------------------------------+

ms_until_next_token is computed as (1.0 - fractional_tokens) / rate * 1000, giving the number of milliseconds until the fractional shortfall below 1.0 fills at the configured rate. When tokens_available >= 1.0, this is 0.

executed_last_60s uses a ring buffer of timestamps rather than an exact counter. The ring holds 200 entries, more than enough for the intended rates over 60 seconds, and CountExecutionsInLast60s() counts entries whose timestamp falls within the last 60,000 milliseconds. If a burst overwrites the ring within 60 seconds, older entries are lost and the count understates the true total. At the intended ring size and rates, this should not occur, but the failure mode should be stated explicitly.

The name executed_last_60s is worth reading carefully: it counts dispatched requests, not confirmed fills. RecordExecution() fires whenever ExecuteRequest() attempts a dispatch, including in dry-run mode where nothing reaches the broker. The field and method names are kept as-is for backward compatibility, but every mention in this article uses "dispatched" consistently to describe what the counter actually measures — see Section 6 for why OrderSend() succeeding is not the same as a trade filling. Callers that need confirmed-fill telemetry should track it separately through OnTradeTransaction().


Section 4: CTokenBucket — the Core Rate Limiter

The entire rate-limiting logic lives here. The class stores the current floating-point token count, the capacity, the rate, and the millisecond timestamp of the last refill call.

//+------------------------------------------------------------------+
//|                                               TokenBucket.mqh    |
//+------------------------------------------------------------------+

#ifndef TOKEN_BUCKET_MQH
#define TOKEN_BUCKET_MQH

//+------------------------------------------------------------------+
//| CTokenBucket                                                     |
//+------------------------------------------------------------------+
class CTokenBucket
  {
private:
   double            m_tokens;            // current token count, including fractional part
   double            m_capacity;          // maximum tokens the bucket can hold
   double            m_rate;              // tokens added per second
   ulong             m_last_refill_ms;    // GetTickCount64() at the last Refill() call
   bool              m_configured;        // true once Configure() has been called at least once

   void              Refill(void);

public:
                     CTokenBucket(void);
                    ~CTokenBucket(void);

   void              Configure(const double capacity,const double rate_per_second);
   bool              Acquire(void);
   double            TokensAvailable(void);
   ulong             MsUntilNextToken(void);
   double            GetCapacity(void) const { return(m_capacity); }
   double            GetRate(void) const { return(m_rate); }
  };

The constructor initializes every field to a safe starting state before Configure() is ever called, so a default-constructed CTokenBucket never behaves unpredictably.

//+------------------------------------------------------------------+
//| Constructor                                                      |
//+------------------------------------------------------------------+
CTokenBucket::CTokenBucket(void)
  {
   m_tokens         = 5.0;
   m_capacity       = 5.0;
   m_rate           = 2.0;
   m_last_refill_ms = ::GetTickCount64();
   m_configured     = false;
  }

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

Refill() is called automatically from Acquire() and TokensAvailable(). It reads the current millisecond clock, computes elapsed time since the last refill, adds rate × elapsed_seconds to the token count, and clamps at capacity.

//+------------------------------------------------------------------+
//| Refill                                                           |
//+------------------------------------------------------------------+
void CTokenBucket::Refill(void)
  {
   ulong now_ms         = ::GetTickCount64();
   ulong elapsed_ms     = now_ms - m_last_refill_ms;
   double elapsed_secs   = (double)elapsed_ms / 1000.0;

   m_tokens = ::MathMin(m_capacity, m_tokens + m_rate * elapsed_secs);
   m_last_refill_ms = now_ms;
  }

One environment-specific note: this timing model assumes a live terminal's monotonic tick counter (GetTickCount64()). Inside the Strategy Tester, GetTickCount64() and the simulated passage of time do not always advance at the same rate as they would live, particularly in visual or accelerated testing modes. The throttle's pacing logic is correct as written, but a trader backtesting it should not expect the queue-drain timing observed in the tester to exactly match live behavior.

Configure() also distinguishes between the first call and any later one. On first use, the normal case, called once from OnInit(), the bucket fills to the new capacity. On a second or subsequent call, the current token count is preserved rather than reset to full, and only clamped down if the new capacity is now smaller than what the bucket currently holds. This matters for the dynamic rate adjustment described in Section 9: without this distinction, calling Configure() again mid-session to change the rate would silently hand the bucket a fresh full burst, defeating the purpose of lowering the rate during a volatile period.

//+------------------------------------------------------------------+
//| Configure                                                        |
//+------------------------------------------------------------------+
void CTokenBucket::Configure(const double capacity,const double rate_per_second)
  {
   m_capacity = (capacity > 0.0 ? capacity : 5.0);
   m_rate     = (rate_per_second > 0.0 ? rate_per_second : 1.0);

   if(!m_configured)
     {
      m_tokens         = m_capacity;
      m_last_refill_ms = ::GetTickCount64();
      m_configured     = true;
     }
   else
     {
      if(m_tokens > m_capacity)
         m_tokens = m_capacity;
     }
  }

Acquire() refills, then checks whether at least one token is available. Consuming a token decrements by exactly 1.0.

//+------------------------------------------------------------------+
//| Acquire                                                          |
//+------------------------------------------------------------------+
bool CTokenBucket::Acquire(void)
  {
   Refill();

   if(m_tokens < 1.0)
      return(false);

   m_tokens -= 1.0;
   return(true);
  }

TokensAvailable() calls Refill() and returns the current token count, including any fractional part. This is safe to call for status display without consuming a token.

//+------------------------------------------------------------------+
//| TokensAvailable                                                  |
//+------------------------------------------------------------------+
double CTokenBucket::TokensAvailable(void)
  {
   Refill();
   return(m_tokens);
  }

MsUntilNextToken() computes the fractional shortfall and converts it to milliseconds, adding 1 millisecond to ensure the estimate is never rounded to zero when a token is genuinely still pending.

//+------------------------------------------------------------------+
//| MsUntilNextToken                                                 |
//+------------------------------------------------------------------+
ulong CTokenBucket::MsUntilNextToken(void)
  {
   Refill();

   if(m_tokens >= 1.0)
      return(0);

//--- tokens_needed is the fractional shortfall below 1.0
   double tokens_needed = 1.0 - m_tokens;
   double seconds_needed = tokens_needed / m_rate;
   ulong ms_needed = (ulong)(seconds_needed * 1000.0) + 1;

   return(ms_needed);
  }


Section 5: CPriorityQueue — the Pending Order Queue

When the bucket is empty, orders queue here rather than being discarded. A sorted array is used rather than a heap because the queue capacity is deliberately small — typically under 100 entries — and the simpler array makes Cancel() and iteration for status display straightforward without any meaningful performance cost at that scale.

//+------------------------------------------------------------------+
//|                                              PriorityQueue.mqh   |
//+------------------------------------------------------------------+

#ifndef PRIORITY_QUEUE_MQH
#define PRIORITY_QUEUE_MQH

#include "ThrottledRequest.mqh"

//--- tolerance used when comparing two priority scores for equality;
//--- avoids the fragility of a direct double == double comparison
#define PRIORITY_EPSILON 0.0000001

//+------------------------------------------------------------------+
//| CPriorityQueue                                                   |
//+------------------------------------------------------------------+
class CPriorityQueue
  {
private:
   CThrottledRequest m_items[];          // the backing array, sorted by priority desc
   int               m_count;            // number of items currently in the queue
   int               m_capacity;         // maximum items the queue will hold

   void              InsertSorted(const CThrottledRequest &req);
   bool              PriorityEquals(const double a,const double b) const;

public:
                     CPriorityQueue(void);
                    ~CPriorityQueue(void);

   void              Configure(const int capacity);
   bool              Push(const CThrottledRequest &req);
   bool              Pop(CThrottledRequest &out_req);
   bool              Peek(CThrottledRequest &out_req) const;
   bool              Cancel(const ulong request_id);
   int               Count(void) const { return(m_count); }
   int               Capacity(void) const { return(m_capacity); }
   bool              IsFull(void) const { return(m_count >= m_capacity); }
   bool              IsEmpty(void) const { return(m_count == 0); }
  };

Configure() sets the queue's maximum capacity and resets the array. Previously queued items are discarded — this is a deliberate but consequential behavior, since it means reconfiguring the queue mid-session drops whatever was pending, unlike CTokenBucket::Configure(), which now preserves state on reconfiguration (see Section 4). Section 10 documents this asymmetry as a limitation.

//+------------------------------------------------------------------+
//| Configure                                                        |
//+------------------------------------------------------------------+
void CPriorityQueue::Configure(const int capacity)
  {
   m_capacity = (capacity > 0 ? capacity : 50);
   m_count    = 0;
   ::ArrayResize(m_items,m_capacity);
  }

InsertSorted() walks the array from the front, looking for the first position where the new item should appear before the existing one. The comparison checks for higher priority first, then for an earlier enqueue_ms among equal-priority items. Because priority is a double, comparing two scores for equality with a direct == is fragile — a score computed from an expression rather than typed as a literal can carry tiny floating-point representation error that makes two logically equal values compare as unequal. PriorityEquals() compares within a small tolerance instead, so ties are detected reliably regardless of how the caller derives the score.

//+------------------------------------------------------------------+
//| PriorityEquals                                                   |
//+------------------------------------------------------------------+
bool CPriorityQueue::PriorityEquals(const double a,const double b) const
  {
   return(::MathAbs(a - b) < PRIORITY_EPSILON);
  }

//+------------------------------------------------------------------+
//| InsertSorted                                                     |
//+------------------------------------------------------------------+
void CPriorityQueue::InsertSorted(const CThrottledRequest &req)
  {
   int insert_pos = m_count;

   for(int i = 0; i < m_count; i++)
     {
      bool higher_priority = (req.priority > m_items[i].priority &&
                              !PriorityEquals(req.priority,m_items[i].priority));
      bool same_priority_earlier = (PriorityEquals(req.priority,m_items[i].priority) &&
                                    req.enqueue_ms < m_items[i].enqueue_ms);

      if(higher_priority || same_priority_earlier)
        {
         insert_pos = i;
         break;
        }
     }

   for(int i = m_count; i > insert_pos; i--)
      m_items[i] = m_items[i - 1];

   m_items[insert_pos] = req;
   m_count++;
  }

Push(), Pop(), Peek(), and Cancel() are the four methods that move requests in and out of the sorted array:

//+------------------------------------------------------------------+
//| Push                                                             |
//+------------------------------------------------------------------+
bool CPriorityQueue::Push(const CThrottledRequest &req)
  {
   if(m_count >= m_capacity)
      return(false);

   InsertSorted(req);
   return(true);
  }

//+------------------------------------------------------------------+
//| Pop                                                              |
//+------------------------------------------------------------------+
bool CPriorityQueue::Pop(CThrottledRequest &out_req)
  {
   if(m_count == 0)
      return(false);

   out_req = m_items[0];

//--- shift remaining items left by one position
   for(int i = 0; i < m_count - 1; i++)
      m_items[i] = m_items[i + 1];

   m_count--;
   return(true);
  }

//+------------------------------------------------------------------+
//| Peek                                                             |
//+------------------------------------------------------------------+
bool CPriorityQueue::Peek(CThrottledRequest &out_req) const
  {
   if(m_count == 0)
      return(false);

   out_req = m_items[0];
   return(true);
  }

//+------------------------------------------------------------------+
//| Cancel                                                           |
//+------------------------------------------------------------------+
bool CPriorityQueue::Cancel(const ulong request_id)
  {
   for(int i = 0; i < m_count; i++)
     {
      if(m_items[i].request_id == request_id)
        {
         //--- shift items after this position left by one
         for(int j = i; j < m_count - 1; j++)
            m_items[j] = m_items[j + 1];

         m_count--;
         return(true);
        }
     }

   return(false);
  }

Pop() removes and returns the item at index 0, which is always the highest-priority item. It then shifts all remaining items one position left.

Cancel() searches for a specific request_id and removes it, shifting subsequent items left. It returns false if no matching item exists, which is the expected result for a request that has already been executed.

The overflow policy is explicit: when Push() is called on a full queue, it logs the rejection and returns false. CTradeThrottle::Submit() checks m_queue.IsFull() before calling Push(), logs the rejection, and returns 0 to the caller to signal that the request was dropped due to queue saturation rather than rate limiting.


Section 6: CTradeThrottle — the Public Interface

CTradeThrottle owns both the bucket and the queue and exposes the four methods strategy code actually calls: Submit(), OnTimer(), Cancel(), and GetStatus(). The constructor sets safe starting values before Configure() is ever called, most importantly sizing the m_exec_timestamps ring buffer to 200 entries. The destructor releases that buffer with ArrayFree() when the throttle goes out of scope.

//+------------------------------------------------------------------+
//|                                             TradeThrottle.mqh    |
//+------------------------------------------------------------------+

#ifndef TRADE_THROTTLE_MQH
#define TRADE_THROTTLE_MQH

#include "ThrottledRequest.mqh"
#include "ThrottleStatus.mqh"
#include "TokenBucket.mqh"
#include "PriorityQueue.mqh"

//+------------------------------------------------------------------+
//| CTradeThrottle                                                   |
//+------------------------------------------------------------------+
class CTradeThrottle
  {
private:
   CTokenBucket              m_bucket;            // the rate limiter core
   CPriorityQueue            m_queue;             // pending orders awaiting a token
   ulong                     m_next_request_id;   // monotonically increasing ID counter
   ulong                     m_exec_timestamps[]; // ring buffer of recent execution timestamps (ms)
   int                       m_exec_write_pos;    // write head for the ring buffer
   int                       m_exec_ring_size;    // capacity of the ring buffer
   bool                      m_dry_run;           // when true, Submit() logs but does not call OrderSend
   ulong                     m_magic;             // magic number stamped on every order this throttle sends
   int                       m_deviation;         // maximum price deviation in points allowed on fill

   bool                      ExecuteRequest(const CThrottledRequest &req);
   void                      RecordExecution(void);
   int                       CountExecutionsInLast60s(void);
   ENUM_ORDER_TYPE_FILLING   ResolveFilling(const string symbol) const;

public:
                     CTradeThrottle(void);
                    ~CTradeThrottle(void);

   void              Configure(const double bucket_capacity,const double refill_rate,const int queue_capacity,const bool dry_run,
                               const ulong magic = 0,const int deviation = 10);
   ulong             Submit(const string symbol,const ENUM_ORDER_TYPE order_type,const double volume,const double price,const double sl,const double tp,const string comment,const double priority);
   void              OnTimer(void);
   bool              Cancel(const ulong request_id);
   CThrottleStatus   GetStatus(void);
  };

//+------------------------------------------------------------------+
//| Constructor                                                      |
//+------------------------------------------------------------------+
CTradeThrottle::CTradeThrottle(void)
  {
   m_next_request_id = 1;
   m_exec_write_pos  = 0;
   m_exec_ring_size  = 200; // more than enough for 60s at typical rates
   m_dry_run         = false;
   m_magic           = 0;
   m_deviation       = 10;

   ::ArrayResize(m_exec_timestamps,m_exec_ring_size);
   ::ArrayInitialize(m_exec_timestamps,0);
  }

//+------------------------------------------------------------------+
//| Destructor                                                       |
//+------------------------------------------------------------------+
CTradeThrottle::~CTradeThrottle(void)
  {
   ::ArrayFree(m_exec_timestamps);
  }

Submit() is the single entry point for strategy code, and it always returns a request_id, never a broker deal ticket. It calls m_bucket.Acquire(). If a token is available, the request executes immediately via ExecuteRequest() and the same request_id is returned. If the bucket is empty, a CThrottledRequest is built and pushed to m_queue, and that request_id is returned instead so the caller can later call Cancel() on it. A return value of 0 means the queue was full and the request was dropped rather than queued or executed. The result of ExecuteRequest() — the deal ticket in live mode — is not passed back through Submit(); a caller that needs it should track outcomes via OnTradeTransaction() matched against the comment field or a caller-side map keyed by request_id.

//+------------------------------------------------------------------+
//| Submit                                                           |
//+------------------------------------------------------------------+
ulong CTradeThrottle::Submit(const string symbol,const ENUM_ORDER_TYPE order_type,const double volume,const double price,const double sl,const double tp,const string comment,const double priority)
  {
   ulong request_id = m_next_request_id++;

   if(m_bucket.Acquire())
     {
      //--- token was available: execute immediately
      CThrottledRequest req;
      req.request_id   = request_id;
      req.symbol       = symbol;
      req.order_type   = order_type;
      req.volume       = volume;
      req.price        = price;
      req.sl           = sl;
      req.tp           = tp;
      req.comment      = comment;
      req.priority     = priority;
      req.enqueue_time = ::TimeCurrent();
      req.enqueue_ms   = ::GetTickCount64();

      ExecuteRequest(req);
      return(request_id);
     }

//--- bucket empty: build a request and enqueue it
   if(m_queue.IsFull())
     {
      ::PrintFormat("CTradeThrottle: queue full (%d/%d), request ID=%llu dropped",
                    m_queue.Count(),m_queue.Capacity(),request_id);
      return(0);
     }

   CThrottledRequest queued_req;
   queued_req.request_id   = request_id;
   queued_req.symbol       = symbol;
   queued_req.order_type   = order_type;
   queued_req.volume       = volume;
   queued_req.price        = price;
   queued_req.sl           = sl;
   queued_req.tp           = tp;
   queued_req.comment      = comment;
   queued_req.priority     = priority;
   queued_req.enqueue_time = ::TimeCurrent();
   queued_req.enqueue_ms   = ::GetTickCount64();

   m_queue.Push(queued_req);

   ::PrintFormat("CTradeThrottle: request ID=%llu queued (priority=%.1f, queue depth=%d)",
                 request_id,priority,m_queue.Count());

   return(request_id);
  }

OnTimer() is called from the EA's OnTimer() handler. It loops, attempting to acquire a token for the front-of-queue item. Each successful acquisition pops and executes one request. The loop continues until the bucket runs dry or the queue empties.

//+------------------------------------------------------------------+
//| OnTimer                                                          |
//+------------------------------------------------------------------+
void CTradeThrottle::OnTimer(void)
  {
   while(!m_queue.IsEmpty())
     {
      if(!m_bucket.Acquire())
         break; // no token available yet; try again on the next timer tick

      CThrottledRequest req;
      if(!m_queue.Pop(req))
         break;

      ::PrintFormat("CTradeThrottle: releasing queued request ID=%llu (priority=%.1f, queued=%ds ago)",
                    req.request_id,req.priority,(int)(::TimeCurrent() - req.enqueue_time));

      ExecuteRequest(req);
     }
  }

The "queued=%ds ago" figure comes from TimeCurrent() - req.enqueue_time, both one-second-resolution datetime values. This is fine for a log line, but it is not what the queue sorts on — enqueue_ms, captured via GetTickCount64(), is what InsertSorted() and the FIFO tiebreak use internally. A request queued and released within the same second shows "0s ago" whether it waited 50ms or 950ms. Treat this field as an approximate, human-facing timestamp, not a timing source.

ExecuteRequest() either submits an MqlTradeRequest via OrderSend(), or logs it in dry-run mode. It calls RecordExecution() either way, so the 60-second counter stays accurate. In live mode it also checks mql_res.retcode, since OrderSend() returning true only means the request reached the server, not that it filled.

Not every broker accepts every filling mode. type_filling cannot simply be set to ORDER_FILLING_FOK and left there, since some brokers reject it outright for certain symbols, returning retcode 10030 (TRADE_RETCODE_INVALID_FILL). ResolveFilling() reads SYMBOL_FILLING_MODE for the symbol and selects the first mode the broker actually supports, trying FOK, then IOC, then RETURN in that order. ExecuteRequest() calls this method rather than assuming a fixed mode.

//+-------------------------------------------------------------------+
//| ResolveFilling                                                    |
//| SYMBOL_FILLING_MODE bitmask: bit 0 = FOK supported,               |
//| bit 1 = IOC supported. RETURN is accepted as a universal fallback.|
//+-------------------------------------------------------------------+
ENUM_ORDER_TYPE_FILLING CTradeThrottle::ResolveFilling(const string symbol) const
  {
   uint filling = (uint)::SymbolInfoInteger(symbol,SYMBOL_FILLING_MODE);

   if((filling & 1) != 0)
      return(ORDER_FILLING_FOK);

   if((filling & 2) != 0)
      return(ORDER_FILLING_IOC);

   return(ORDER_FILLING_RETURN);
  }

The request also skips OrderCheck(), volume normalization, and stop-distance validation. CTradeThrottle never checks whether a submitted volume is valid for the symbol; it forwards whatever the caller passes. The demo EA works around this by reading SYMBOL_VOLUME_MIN before calling Submit(), but that check lives in the calling code, not the throttle, and a different caller could just as easily submit an invalid volume unchecked. Full validation belongs in the execution layer this throttle sits in front of.

One more gap: a queued request's price is captured at Submit() and never refreshed. If a request sits queued for a few hundred milliseconds, the market may move before OnTimer() releases it. This is usually fine for the entry price itself, but the SL and TP were calculated against the old price and may now be off. Strategies with tight stops should refresh price, sl, and tp before release — Section 9 covers adding this as an extension.

//+------------------------------------------------------------------+
//| ExecuteRequest                                                   |
//+------------------------------------------------------------------+
bool CTradeThrottle::ExecuteRequest(const CThrottledRequest &req)
  {
   RecordExecution();

   if(m_dry_run)
     {
      ::PrintFormat("CTradeThrottle [DRY RUN]: ID=%llu sym=%s type=%s vol=%.2f price=%.5f priority=%.1f magic=%llu",
                    req.request_id,req.symbol,::EnumToString(req.order_type),
                    req.volume,req.price,req.priority,m_magic);
      return(true);
     }

   MqlTradeRequest mql_req;
   MqlTradeResult  mql_res;
   ::ZeroMemory(mql_req);
   ::ZeroMemory(mql_res);

   mql_req.action       = TRADE_ACTION_DEAL;
   mql_req.symbol       = req.symbol;
   mql_req.volume       = req.volume;
   mql_req.type         = req.order_type;
   mql_req.price        = req.price;
   mql_req.sl           = req.sl;
   mql_req.tp           = req.tp;
   mql_req.comment      = req.comment;
   mql_req.magic        = m_magic;
   mql_req.deviation    = (ulong)m_deviation;
   mql_req.type_filling = ResolveFilling(req.symbol);

   bool sent_ok = ::OrderSend(mql_req,mql_res);

   if(!sent_ok)
     {
      ::PrintFormat("CTradeThrottle: OrderSend() call failed for request ID=%llu, retcode=%d",
                    req.request_id,mql_res.retcode);
      return(false);
     }

//--- OrderSend() returning true only means the request reached the
//--- trade server; it does not mean the trade filled. A non-success
//--- retcode here (anything other than TRADE_RETCODE_DONE or
//--- TRADE_RETCODE_DONE_PARTIAL) still indicates a problem worth
//--- logging even though OrderSend() itself returned true.
   if(mql_res.retcode != TRADE_RETCODE_DONE && mql_res.retcode != TRADE_RETCODE_DONE_PARTIAL)
     {
      ::PrintFormat("CTradeThrottle: OrderSend() returned true but retcode=%d is not a success code for request ID=%llu",
                    mql_res.retcode,req.request_id);
      return(false);
     }

   ::PrintFormat("CTradeThrottle: request ID=%llu dispatched, deal=%llu, retcode=%d",
                 req.request_id,mql_res.deal,mql_res.retcode);

   return(true);
  }

ExecuteRequest() returns a bool, not a ticket or ID, since neither Submit() nor OnTimer() ever use that return value.

It also checks mql_res.retcode explicitly, since OrderSend() returning true only means the request reached the trade server, not that it was accepted or filled. The function checks for TRADE_RETCODE_DONE or TRADE_RETCODE_DONE_PARTIAL and logs anything else as a mismatch, without classifying or retrying based on the specific retcode — that logic belongs in the execution layer this throttle sits in front of. Live testing on XM confirmed the path works: a successful dispatch logs dispatched, deal=..., retcode=0, confirming a genuine broker-side fill.

magic and deviation are configured once via Configure() and stamped on every request the throttle sends. magic lets the EA distinguish its own positions from manually placed trades or other EAs on the same account; deviation sets the maximum acceptable price deviation in points before the broker rejects the fill. Both have safe defaults (0 and 10 respectively) if not supplied, so existing calls to Configure() with only four arguments continue to compile without change.

GetStatus() assembles the CThrottleStatus struct from the bucket and queue state, computing the rolling dispatch count on demand.

//+------------------------------------------------------------------+
//| GetStatus                                                        |
//+------------------------------------------------------------------+
CThrottleStatus CTradeThrottle::GetStatus(void)
  {
   CThrottleStatus status;

   status.tokens_available    = m_bucket.TokensAvailable();
   status.token_capacity      = m_bucket.GetCapacity();
   status.refill_rate         = m_bucket.GetRate();
   status.queue_depth         = m_queue.Count();
   status.queue_capacity      = m_queue.Capacity();
   status.executed_last_60s   = CountExecutionsInLast60s();
   status.ms_until_next_token = m_bucket.MsUntilNextToken();
   status.throttling_active   = m_queue.Count() > 0;

   return(status);
  }

Cancel() removes a request from the queue before it is released.

//+------------------------------------------------------------------+
//| Cancel                                                           |
//+------------------------------------------------------------------+
bool CTradeThrottle::Cancel(const ulong request_id)
  {
   bool removed = m_queue.Cancel(request_id);

   if(removed)
      ::PrintFormat("CTradeThrottle: request ID=%llu cancelled from queue",request_id);

   return(removed);
  }

This delegates directly to CPriorityQueue::Cancel(), returning false for a request that has already executed or was never queued in the first place — the expected outcome for a cancel attempt on a request that already left the queue.

Correlating a throttled request with its trade outcome

Because Submit() only returns a request_id and ExecuteRequest() does not return a deal ticket, matching a specific throttled request to its eventual fill requires listening separately for OnTradeTransaction() and correlating by comment or magic. A minimal sketch:

//+------------------------------------------------------------------+
//| Correlating a Submit() request with its eventual trade outcome   |
//+------------------------------------------------------------------+
//--- module-level map: comment string -> request_id, populated at Submit() time
string            g_pending_comments[];
ulong             g_pending_ids[];

//+------------------------------------------------------------------+
//| SubmitAndTrack                                                   |
//| Wraps CTradeThrottle::Submit() with a unique comment tag so the  |
//| resulting deal can later be matched back to its request_id       |
//| inside OnTradeTransaction().                                     |
//+------------------------------------------------------------------+
ulong SubmitAndTrack(CTradeThrottle &throttle,const string symbol,const ENUM_ORDER_TYPE type,
                     const double volume,const double price,const double sl,const double tp,
                     const double priority)
  {
   string tag = "req_" + (string)::GetTickCount64();
   ulong  id  = throttle.Submit(symbol,type,volume,price,sl,tp,tag,priority);

   int n = ::ArraySize(g_pending_ids);
   ::ArrayResize(g_pending_ids,n + 1);
   ::ArrayResize(g_pending_comments,n + 1);
   g_pending_ids[n]      = id;
   g_pending_comments[n] = tag;

   return(id);
  }

//+------------------------------------------------------------------+
//| OnTradeTransaction                                               |
//| Matches a filled deal's comment against the pending tag list to  |
//| resolve which throttled request_id it corresponds to.            |
//+------------------------------------------------------------------+
void OnTradeTransaction(const MqlTradeTransaction &trans,
                        const MqlTradeRequest &request,
                        const MqlTradeResult &result)
  {
   if(trans.type != TRADE_TRANSACTION_DEAL_ADD)
      return;

   if(!::HistoryDealSelect(trans.deal))
      return;

//--- match the deal's comment against our pending tags
   string deal_comment = ::HistoryDealGetString(trans.deal,DEAL_COMMENT);

   for(int i = 0; i < ::ArraySize(g_pending_comments); i++)
     {
      if(g_pending_comments[i] == deal_comment)
        {
         ::PrintFormat("Throttled request_id=%llu filled as deal=%llu",
                       g_pending_ids[i],trans.deal);
         //--- remove from the pending list here
         break;
        }
     }
  }

This is a minimal illustration, not a production-ready implementation. A real version would need to remove stale entries from the pending arrays, handle partial fills, and account for the comment field potentially being truncated or altered by the broker. It demonstrates the shape of the correlation problem: the throttle hands out a request_id and a comment, and OnTradeTransaction() is where the two get reunited with an actual fill.


Section 7: ThrottleEA.mq5 — Integration and Dashboard

The demo EA has two jobs: fire a burst of 15 requests on the first tick to demonstrate throttling, and render a live dashboard on every timer tick showing the current bucket level, queue depth, and execution statistics.

//+------------------------------------------------------------------+
//|                                                   ThrottleEA.mq5 |
//+------------------------------------------------------------------+

#property strict

#include <TradeThrottle/TradeThrottle.mqh>

//--- Input parameters
input double InpBucketCapacity    = 5.0;    // maximum tokens in the bucket
input double InpRefillRate        = 2.0;    // tokens added per second
input int    InpQueueCapacity     = 50;     // maximum queued orders
input int    InpBurstSize         = 15;     // number of simultaneous submissions on startup
input int    InpTimerIntervalMs   = 250;    // timer interval in milliseconds
input bool   InpDryRun            = true;   // true = simulate only, do not call OrderSend

//--- Module-level state
CTradeThrottle g_throttle;
bool           g_burst_fired = false;
int            g_timer_ms    = 0;

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit(void)
  {
   g_throttle.Configure(InpBucketCapacity,InpRefillRate,InpQueueCapacity,InpDryRun);

   if(!::EventSetMillisecondTimer(InpTimerIntervalMs))
     {
      ::Print("ThrottleEA: failed to set millisecond timer");
      return(INIT_FAILED);
     }

   ::PrintFormat("ThrottleEA: initialized, bucket=%.0f cap, %.1f/s refill, queue=%d, dry_run=%s",
                 InpBucketCapacity,InpRefillRate,InpQueueCapacity,(InpDryRun?"true":"false"));

   return(INIT_SUCCEEDED);
  }

EventSetMillisecondTimer() is used instead of EventSetTimer() because queue drain behavior at 2 tokens per second requires timer resolution finer than one second. A 250ms interval provides four chances per second to drain queued items as tokens refill, which closely tracks the configured refill rate.

The throttle controls the dispatch rate to OrderSend(). Broker confirmation timing is outside its control. OnTick(), OnTimer(), and OnTradeTransaction() are separate MQL5 events, and a slow OrderSend() call inside OnTimer() can affect the timing of that handler's next invocation. This implementation may release multiple queued items within a single OnTimer() call. This is normal for a token bucket; it also means a drain can occur within one event handler rather than being distributed across the event loop.

The lot size for each order also cannot be a fixed constant across every symbol. SYMBOL_VOLUME_MIN varies by instrument, and a value that works on a forex pair, such as 0.01, can fail outright on a crypto CFD, where brokers often enforce a much higher minimum. Submitting below that minimum returns retcode 10014 (TRADE_RETCODE_INVALID_VOLUME). FireBurst() reads the minimum for the active symbol at runtime rather than assuming a fixed lot size.

FireBurst() submits 15 requests with alternating priority scores (5.0, 3.0, 1.0 cycling) so the priority queue ordering is visible in the Experts tab. The first 5 execute immediately against the initial bucket tokens; the remaining 10 queue.

//+------------------------------------------------------------------+
//| FireBurst                                                        |
//+------------------------------------------------------------------+
void FireBurst(void)
  {
   ::PrintFormat("ThrottleEA: firing burst of %d requests",InpBurstSize);

   string symbol = ::Symbol();
   double ask    = ::SymbolInfoDouble(symbol,SYMBOL_ASK);
   double point  = ::SymbolInfoDouble(symbol,SYMBOL_POINT);

//--- read the broker's minimum lot size for this symbol rather than
//--- assuming 0.01, since instruments like crypto CFDs often enforce
//--- a higher minimum
   double min_vol = ::SymbolInfoDouble(symbol,SYMBOL_VOLUME_MIN);
   if(min_vol <= 0.0)
      min_vol = 0.01; // fallback if the symbol has not populated properties yet

   for(int i = 0; i < InpBurstSize; i++)
     {
      double priority = (i % 3 == 0) ? 5.0 : (i % 3 == 1) ? 3.0 : 1.0;

      ulong id = g_throttle.Submit(symbol,ORDER_TYPE_BUY,min_vol,ask,
                                   ask - 200.0 * point,
                                   ask + 400.0 * point,
                                   "ThrottleEA burst #" + (string)(i + 1),
                                   priority);

      ::PrintFormat("ThrottleEA: Submit() returned id=%llu (request %d of %d, priority=%.1f, vol=%.2f)",
                    id,i + 1,InpBurstSize,priority,min_vol);
     }
  }

RenderDashboard() calls GetStatus() and formats the snapshot as a multi-line chart comment. A simple ASCII progress bar shows the token level visually.

//+------------------------------------------------------------------+
//| RenderDashboard                                                  |
//+------------------------------------------------------------------+
void RenderDashboard(void)
  {
   CThrottleStatus s = g_throttle.GetStatus();

   string bar_fill  = "";
   string bar_empty = "";
   int    bar_width = 20;
   int    filled    = (int)::MathRound((s.tokens_available / s.token_capacity) * bar_width);

   for(int i = 0; i < filled;     i++)
      bar_fill  += "|";
   for(int i = filled; i < bar_width; i++)
      bar_empty += ".";

   string dashboard =
      "===  TRADE THROTTLE  ===\n" +
      "\n" +
      "Tokens   : " + ::DoubleToString(s.tokens_available,2) + " / " + ::DoubleToString(s.token_capacity,1) + "\n" +
      "           [" + bar_fill + bar_empty + "]\n" +
      "\n" +
      "Queue    : " + (string)s.queue_depth + " / " + (string)s.queue_capacity + " pending\n" +
      "Last 60s : " + (string)s.executed_last_60s + " dispatched\n" +
      "Next tkn : " + (string)s.ms_until_next_token + " ms\n" +
      "Refill   : " + ::DoubleToString(s.refill_rate,1) + " / sec\n" +
      "\n" +
      (s.throttling_active ? "STATUS   : THROTTLING" : "STATUS   : PASS-THROUGH");

   ::ChartSetString(0,CHART_COMMENT,dashboard);
  }

Throttle dashboard mockup

Mockup of the live throttle dashboard during a throttling episode, showing 2.3 of 5 tokens remaining, 8 orders queued, 12 dispatched in the last 60 seconds, and 340 milliseconds until the next token.

How to tune parameters — Choosing capacity, rate, queue size, and timer interval

The mechanics of the throttle are only half the picture; picking reasonable values for a specific broker and strategy matters just as much. A few starting points:

Refill rate: If the broker's real limit is known or suspected, configure the throttle below the suspected limit to maintain a safety margin. A rate of roughly 60–70% of the suspected limit leaves margin for the broker's own measurement window not lining up exactly with this throttle's.

Bucket capacity: This is the maximum burst the throttle allows through instantly. Set it to the largest number of near-simultaneous orders the strategy could realistically generate in one signal event, not larger.

Queue capacity: This bounds how much backlog the throttle will hold before rejecting new requests outright. queue_capacity ÷ refill_rate gives the worst-case wait, in seconds, for the last item in a full queue. For latency-sensitive strategies, a small queue (5–10) paired with the TTL extension from Section 9 is usually a better fit than a large queue with no expiry.

Timer interval: EventSetMillisecondTimer() should fire meaningfully more often than the expected token interval, not once per token. A rough guide is 2–4 times the token interval: at 2 tokens/second (500ms between tokens), a 150–250ms timer interval gives the throttle several chances per token period to notice a newly available token and drain the queue promptly, rather than waiting for the next scheduled tick to catch up.

Putting it together for two common cases: A fast scalping strategy sensitive to entry timing is usually best served by a small bucket (2–3), a small queue (3–5) paired with a short TTL, and a tight timer interval (100–150ms), since a stale scalp entry is often worse than no entry at all. A portfolio-rebalancing EA touching many symbols on a schedule can tolerate a larger bucket (5–10), a larger queue (20–50), and a more relaxed timer interval (250–500ms), since a rebalancing leg executing a few seconds late rarely changes the outcome materially.


Section 8: Verification — TestTradeThrottle.mq5

The test script exercises all five source files in isolation. Ten test functions cover the token bucket, the priority queue, and the assembled throttle, including edge cases around invalid configuration, repeated cancellation, and runtime reconfiguration.

//+------------------------------------------------------------------+
//|                                         TestTradeThrottle.mq5    |
//|                        Verification script covering token bucket |
//|                        math, priority ordering, queue overflow,  |
//|                        and rate pacing with ASSERT macros        |
//+------------------------------------------------------------------+

#property script_show_inputs

#include <TradeThrottle/TokenBucket.mqh>
#include <TradeThrottle/PriorityQueue.mqh>
#include <TradeThrottle/TradeThrottle.mqh>

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

//+------------------------------------------------------------------+
//| ASSERT                                                           |
//+------------------------------------------------------------------+
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_CLOSE                                              |
//+------------------------------------------------------------------+
void ASSERT_DOUBLE_CLOSE(const double actual,const double expected,const double tolerance,const string test_name)
  {
   bool ok = (::MathAbs(actual - expected) <= tolerance);
   g_tests_run++;

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

//+------------------------------------------------------------------+
//| OnStart                                                          |
//+------------------------------------------------------------------+
void OnStart(void)
  {
   ::Print("=== TestTradeThrottle starting ===");

   TestTokenBucketRefill();
   TestTokenBucketAcquire();
   TestTokenBucketMsUntilNextToken();
   TestPriorityOrdering();
   TestFIFOTiebreak();
   TestQueueOverflow();
   TestBurstWithThrottle();
   TestZeroAndInvalidConfig();
   TestRepeatedCancel();
   TestDynamicReconfigure();

   ::PrintFormat("=== TestTradeThrottle 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");
  }

TestTokenBucketRefill() drains a bucket with a very high rate to empty it immediately, then confirms TokensAvailable() is below 1.0 and MsUntilNextToken() is positive.

//+------------------------------------------------------------------+
//| TestTokenBucketRefill                                            |
//+------------------------------------------------------------------+
void TestTokenBucketRefill(void)
  {
   ::Print("--- CTokenBucket refill tests ---");

   CTokenBucket bucket;
   bucket.Configure(5.0,1000.0); // 1000 tokens/sec to make refill nearly instant in tests

//--- drain the bucket completely
   bool got_all = true;
   for(int i = 0; i < 5; i++)
      got_all = got_all && bucket.Acquire();

   ASSERT(got_all,"bucket with 5 capacity yields 5 consecutive successful Acquire() calls");
   ASSERT(!bucket.Acquire(),"sixth Acquire() on an empty bucket returns false");

//--- tokens_available should be 0 or near-0 after drain
   double avail = bucket.TokensAvailable();
   ASSERT(avail < 1.0,"TokensAvailable() is below 1.0 after draining the bucket");

//--- MsUntilNextToken should be non-zero when bucket is empty
   ulong ms = bucket.MsUntilNextToken();
   ASSERT(ms > 0,"MsUntilNextToken() is positive when bucket is empty");
  }

TestTokenBucketAcquire() configures a bucket with a near-zero refill rate to prevent token recovery during the test, then confirms exactly three acquisitions succeed and the fourth correctly blocks.

//+------------------------------------------------------------------+
//| TestTokenBucketAcquire                                           |
//+------------------------------------------------------------------+
void TestTokenBucketAcquire(void)
  {
   ::Print("--- CTokenBucket acquire tests ---");

   CTokenBucket bucket;
   bucket.Configure(3.0,0.001); // very slow refill so we can test blocking

   ASSERT(bucket.Acquire(),"Acquire() 1 of 3 succeeds on fresh bucket");
   ASSERT(bucket.Acquire(),"Acquire() 2 of 3 succeeds");
   ASSERT(bucket.Acquire(),"Acquire() 3 of 3 succeeds");
   ASSERT(!bucket.Acquire(),"Acquire() 4 of 3 correctly returns false");
   ASSERT(!bucket.Acquire(),"Acquire() 5 of 3 still correctly returns false");
  }

TestTokenBucketMsUntilNextToken() configures a bucket with capacity 2 and a refill rate of 2 per second, then confirms MsUntilNextToken() returns 0 while tokens are still available. After draining both tokens with two Acquire() calls, it confirms the returned value is positive and no greater than 1,100 milliseconds — the expected wait for the next token at that refill rate, with a small margin for execution time.

//+------------------------------------------------------------------+
//| TestTokenBucketMsUntilNextToken                                  |
//+------------------------------------------------------------------+
void TestTokenBucketMsUntilNextToken(void)
  {
   ::Print("--- CTokenBucket MsUntilNextToken tests ---");

   CTokenBucket bucket;
   bucket.Configure(2.0,2.0); // 2 tokens capacity, 2 per second

//--- fresh bucket has tokens; should report 0 wait
   ulong ms_full = bucket.MsUntilNextToken();
   ASSERT(ms_full == 0,"MsUntilNextToken() is 0 when bucket has tokens");

//--- drain it
   bucket.Acquire();
   bucket.Acquire();

//--- now empty; at 2 tokens/sec, next token in ~500ms
   ulong ms_empty = bucket.MsUntilNextToken();
   ASSERT(ms_empty > 0 && ms_empty <= 1100,"MsUntilNextToken() is between 1 and 1100ms after draining at 2/s");
  }

TestPriorityOrdering() pushes requests with scores 5.0, 1.0, and 3.0 in that order and confirms Pop() yields 5.0, 3.0, 1.0.

//+------------------------------------------------------------------+
//| TestPriorityOrdering                                             |
//+------------------------------------------------------------------+
void TestPriorityOrdering(void)
  {
   ::Print("--- CPriorityQueue priority ordering tests ---");

   CPriorityQueue q;
   q.Configure(10);

   CThrottledRequest r1,r2,r3;
   r1.request_id = 1;
   r1.priority = 5.0;
   r1.enqueue_ms = 1000;
   r2.request_id = 2;
   r2.priority = 1.0;
   r2.enqueue_ms = 1001;
   r3.request_id = 3;
   r3.priority = 3.0;
   r3.enqueue_ms = 1002;

   q.Push(r1);
   q.Push(r2);
   q.Push(r3);

   ASSERT(q.Count() == 3,"queue holds 3 items after 3 Push() calls");

   CThrottledRequest out;
   q.Pop(out);
   ASSERT(out.priority == 5.0,"first Pop() yields priority 5.0");
   q.Pop(out);
   ASSERT(out.priority == 3.0,"second Pop() yields priority 3.0");
   q.Pop(out);
   ASSERT(out.priority == 1.0,"third Pop() yields priority 1.0");
   ASSERT(q.IsEmpty(),"queue is empty after three Pop() calls");
  }

TestFIFOTiebreak() pushes three equal-priority requests with enqueue_ms values 2000, 2002, and 2001 and confirms Pop() yields them in chronological order: 2000, 2001, 2002.

//+------------------------------------------------------------------+
//| TestFIFOTiebreak                                                 |
//+------------------------------------------------------------------+
void TestFIFOTiebreak(void)
  {
   ::Print("--- CPriorityQueue FIFO tiebreak tests ---");

   CPriorityQueue q;
   q.Configure(10);

   CThrottledRequest ra,rb,rc;
   ra.request_id = 10;
   ra.priority = 3.0;
   ra.enqueue_ms = 2000;
   rb.request_id = 11;
   rb.priority = 3.0;
   rb.enqueue_ms = 2002;
   rc.request_id = 12;
   rc.priority = 3.0;
   rc.enqueue_ms = 2001;

//--- push in non-chronological order
   q.Push(ra);
   q.Push(rb);
   q.Push(rc);

   CThrottledRequest out;
   q.Pop(out);
   ASSERT(out.request_id == 10,"FIFO: first pop is earliest enqueue_ms (id=10)");
   q.Pop(out);
   ASSERT(out.request_id == 12,"FIFO: second pop is middle enqueue_ms (id=12)");
   q.Pop(out);
   ASSERT(out.request_id == 11,"FIFO: third pop is latest enqueue_ms (id=11)");
  }

TestQueueOverflow() configures a queue with capacity 3, pushes four items, and confirms the fourth Push() returns false while the count stays at 3.

//+------------------------------------------------------------------+
//| TestQueueOverflow                                                |
//+------------------------------------------------------------------+
void TestQueueOverflow(void)
  {
   ::Print("--- CPriorityQueue overflow tests ---");

   CPriorityQueue q;
   q.Configure(3); // deliberately tiny

   CThrottledRequest r;
   r.priority   = 1.0;
   r.enqueue_ms = 1000;

   r.request_id = 20;
   ASSERT(q.Push(r),"Push() 1 of 3 succeeds");
   r.request_id = 21;
   r.enqueue_ms = 1001;
   ASSERT(q.Push(r),"Push() 2 of 3 succeeds");
   r.request_id = 22;
   r.enqueue_ms = 1002;
   ASSERT(q.Push(r),"Push() 3 of 3 succeeds");
   r.request_id = 23;
   r.enqueue_ms = 1003;
   ASSERT(!q.Push(r),"Push() 4 of 3 returns false (overflow)");

   ASSERT(q.Count() == 3,"queue count stays at 3 after overflow attempt");
  }

TestBurstWithThrottle() submits 15 requests through a dry-run throttle with capacity 5, then confirms queue_depth == 10, executed_last_60s (dispatched count) == 5, and throttling_active == true.

//+------------------------------------------------------------------+
//| TestBurstWithThrottle                                            |
//+------------------------------------------------------------------+
void TestBurstWithThrottle(void)
  {
   ::Print("--- CTradeThrottle burst test ---");

   CTradeThrottle throttle;
   throttle.Configure(5.0,2.0,50,true); // dry run, cap=5, rate=2/s

   string symbol = ::Symbol();
   double price  = ::SymbolInfoDouble(symbol,SYMBOL_ASK);

//--- submit 15 requests in rapid succession
   for(int i = 0; i < 15; i++)
      throttle.Submit(symbol,ORDER_TYPE_BUY,0.01,price,0.0,0.0,"test",1.0);

   CThrottleStatus s = throttle.GetStatus();

   ASSERT(s.queue_depth == 10,"after burst of 15 with capacity 5, queue holds 10");
   ASSERT(s.executed_last_60s == 5,"5 requests dispatched immediately (one per initial token)");
   ASSERT(s.throttling_active,"throttling_active is true while queue is non-empty");
  }

TestZeroAndInvalidConfig() confirms that passing zero or negative values to Configure() falls back to safe positive defaults instead of producing a broken bucket or queue.

//+------------------------------------------------------------------+
//| TestZeroAndInvalidConfig                                         |
//+------------------------------------------------------------------+
void TestZeroAndInvalidConfig(void)
  {
   ::Print("--- Zero/invalid configuration tests ---");

   CTokenBucket bucket_zero;
   bucket_zero.Configure(0.0,0.0); // both invalid
   ASSERT(bucket_zero.GetCapacity() > 0.0,"zero capacity falls back to a positive default");
   ASSERT(bucket_zero.GetRate() > 0.0,"zero rate falls back to a positive default");

   CTokenBucket bucket_neg;
   bucket_neg.Configure(-5.0,-2.0); // both negative
   ASSERT(bucket_neg.GetCapacity() > 0.0,"negative capacity falls back to a positive default");
   ASSERT(bucket_neg.GetRate() > 0.0,"negative rate falls back to a positive default");

   CPriorityQueue queue_zero;
   queue_zero.Configure(0); // invalid capacity
   ASSERT(queue_zero.Capacity() > 0,"zero queue capacity falls back to a positive default");

//--- a bucket configured with a fallback capacity should still accept at least one Acquire()
   ASSERT(bucket_zero.Acquire(),"bucket with fallback config still yields at least one Acquire()");
  }

TestRepeatedCancel() confirms that cancelling the same request_id twice is safe. The first call removes the item, the second returns false without disturbing the rest of the queue.

//+------------------------------------------------------------------+
//| TestRepeatedCancel                                               |
//+------------------------------------------------------------------+
void TestRepeatedCancel(void)
  {
   ::Print("--- Repeated cancel tests ---");

   CPriorityQueue q;
   q.Configure(10);

   CThrottledRequest r1,r2;
   r1.request_id = 100;
   r1.priority = 1.0;
   r1.enqueue_ms = 5000;
   r2.request_id = 101;
   r2.priority = 1.0;
   r2.enqueue_ms = 5001;

   q.Push(r1);
   q.Push(r2);

   ASSERT(q.Count() == 2,"queue holds 2 items before any cancellation");

   bool first_cancel = q.Cancel(100);
   ASSERT(first_cancel,"first Cancel() on request 100 returns true");
   ASSERT(q.Count() == 1,"queue count drops to 1 after first cancel");

   bool second_cancel = q.Cancel(100);
   ASSERT(!second_cancel,"repeated Cancel() on the same request_id returns false");
   ASSERT(q.Count() == 1,"queue count is unchanged after the repeated cancel attempt");

//--- confirm the remaining item is still intact and correct
   CThrottledRequest remaining;
   q.Peek(remaining);
   ASSERT(remaining.request_id == 101,"the untouched request remains correctly in the queue");
  }

TestDynamicReconfigure() confirms that calling Configure() a second time preserves the current token count rather than refilling to full, and that the count clamps correctly when the new capacity is smaller than what the bucket currently holds.

//+------------------------------------------------------------------+
//| TestDynamicReconfigure                                           |
//+------------------------------------------------------------------+
void TestDynamicReconfigure(void)
  {
   ::Print("--- Dynamic reconfigure tests ---");

   CTokenBucket bucket;
   bucket.Configure(5.0,1000.0); // first configuration: fills to 5.0

//--- drain to a known partial level
   bucket.Acquire();
   bucket.Acquire();
   bucket.Acquire(); // 3 consumed, ~2 remain (at high rate, may have partially refilled)

   double tokens_before_reconfig = bucket.TokensAvailable();

//--- reconfigure with a slower rate but same capacity; tokens should not jump back to 5.0
   bucket.Configure(5.0,0.0001);
   double tokens_after_reconfig = bucket.TokensAvailable();

   ASSERT(tokens_after_reconfig < 4.9,
          "reconfiguring with the same capacity does not refill tokens to full");
   ASSERT(::MathAbs(tokens_after_reconfig - tokens_before_reconfig) < 1.0,
          "token count is approximately preserved across reconfiguration");

//--- reconfigure with a smaller capacity; tokens should clamp down, not exceed it
   bucket.Configure(1.0,0.0001);
   double tokens_clamped = bucket.TokensAvailable();
   ASSERT(tokens_clamped <= 1.0,
          "reconfiguring with a smaller capacity clamps the current token count down to it");
  }

TestCancelOnExecutedOrNeverQueued() confirms that Cancel() returns false both for a request_id that was never queued at all, and for one that executed immediately and never entered the queue in the first place.

//+------------------------------------------------------------------+
//| TestCancelOnExecutedOrNeverQueued                                |
//+------------------------------------------------------------------+
void TestCancelOnExecutedOrNeverQueued(void)
  {
   ::Print("--- Cancel on non-queued request tests ---");

   CTradeThrottle throttle;
   throttle.Configure(5.0,2.0,50,true); // dry run, cap=5, rate=2/s

   string symbol = ::Symbol();
   double price  = ::SymbolInfoDouble(symbol,SYMBOL_ASK);

//--- this request has a token available and executes immediately, never queued
   ulong executed_id = throttle.Submit(symbol,ORDER_TYPE_BUY,0.01,price,0.0,0.0,"test",1.0);

   bool cancel_executed = throttle.Cancel(executed_id);
   ASSERT(!cancel_executed,"Cancel() on an already-executed request_id returns false");

//--- this request_id was never issued by Submit() at all
   bool cancel_unknown = throttle.Cancel(999999);
   ASSERT(!cancel_unknown,"Cancel() on a request_id that was never submitted returns false");
  }

TestQueueReconfigureDropsPending() confirms the asymmetry documented in Section 10: reconfiguring CPriorityQueue mid-session clears whatever was queued, unlike CTokenBucket::Configure(), which preserves token state.

//+------------------------------------------------------------------+
//| TestQueueReconfigureDropsPending                                 |
//+------------------------------------------------------------------+
void TestQueueReconfigureDropsPending(void)
  {
   ::Print("--- Queue reconfigure drops pending tests ---");

   CPriorityQueue q;
   q.Configure(10);

   CThrottledRequest r1,r2,r3;
   r1.request_id = 200;
   r1.priority   = 1.0;
   r1.enqueue_ms = 9000;
   r2.request_id = 201;
   r2.priority   = 1.0;
   r2.enqueue_ms = 9001;
   r3.request_id = 202;
   r3.priority   = 1.0;
   r3.enqueue_ms = 9002;

   q.Push(r1);
   q.Push(r2);
   q.Push(r3);

   ASSERT(q.Count() == 3,"queue holds 3 items before reconfiguration");

//--- reconfiguring the queue mid-session should clear pending items
   q.Configure(10);
   ASSERT(q.Count() == 0,"reconfiguring the queue drops all previously pending items");
   ASSERT(q.IsEmpty(),"queue reports empty immediately after reconfiguration");
  }

TestPriorityEqualsTolerance() confirms that two priority scores which differ only by floating-point representation error, rather than by a meaningful margin, are still correctly treated as tied and broken by FIFO order.

//+------------------------------------------------------------------+
//| TestPriorityEqualsTolerance                                      |
//+------------------------------------------------------------------+
void TestPriorityEqualsTolerance(void)
  {
   ::Print("--- Priority epsilon tolerance tests ---");

   CPriorityQueue q;
   q.Configure(10);

//--- two priorities that are mathematically equal but may not compare
//--- equal with a direct == due to floating-point arithmetic
   double p1 = 0.1 + 0.2;       // classic floating-point imprecision case
   double p2 = 0.3;

   CThrottledRequest ra,rb;
   ra.request_id = 300;
   ra.priority = p1;
   ra.enqueue_ms = 7000;
   rb.request_id = 301;
   rb.priority = p2;
   rb.enqueue_ms = 7001;

   q.Push(ra);
   q.Push(rb);

//--- if PriorityEquals() works correctly, these are treated as tied
//--- and the earlier enqueue_ms (ra) is released first
   CThrottledRequest out;
   q.Pop(out);
   ASSERT(out.request_id == 300,
          "near-equal priorities (0.1+0.2 vs 0.3) are treated as tied via FIFO, not as distinct scores");
  }


Section 9: Extending the Throttle

Per-symbol token buckets: The current implementation applies one rate limit across all symbols. An extension worth considering is a CSymbolThrottle that owns a CTradeThrottle per symbol, so EURUSD and XAUUSD each have independent rate limits. This matters when a broker enforces separate order frequency limits per instrument.

Dynamic rate adjustment based on volatility: The configured refill rate is static. An EA could call Configure() again during volatile sessions to lower the rate, then restore it later, without granting itself a fresh burst, since repeated calls preserve token state (see Section 4). Reconfiguring the queue, however, still clears any pending requests.

Persisting queue state across restarts: m_exec_timestamps and the queue contents live in memory and are lost on EA restart. Saving the ring buffer to a file in OnDeinit() and restoring it in OnInit() would give the 60-second dispatch counter continuity across restarts — useful if the EA is restarted during a throttling episode.

Request expiry (TTL): CThrottledRequest currently has no expiration. A queued request sits until it is released or explicitly cancelled, however stale it has become. Adding an expire_after_ms field, checked immediately before ExecuteRequest() runs, would let a caller mark time-sensitive signals to be dropped automatically rather than executed late. This is the single most important extension for latency-sensitive strategies; see Section 10 for why.

Pre-release revalidation: A callback invoked immediately before ExecuteRequest() — for example CanRelease(request) or RefreshRequest(request) — would let the strategy layer confirm a queued signal is still valid, update its price against current market conditions, or veto the release entirely. This keeps the throttle focused purely on pacing while delegating "is this still worth sending" back to the strategy that generated the signal.

Richer metrics: CThrottleStatus currently reports only point-in-time state: tokens available, queue depth, and a rolling dispatch count. A production deployment often wants historical metrics as well — average and maximum queue wait time, a running count of requests dropped due to queue overflow, and a count of OrderSend() failures versus successes. These would require a small amount of additional bookkeeping in CTradeThrottle, most naturally as running counters updated inside Submit() and ExecuteRequest(), and exposed as new fields on CThrottleStatus alongside the existing ones.

Deduplication and merging: If several identical or near-identical requests arrive in quick succession, for example ten separate 0.01-lot BUY signals for the same symbol within one second, a caller may prefer to merge them into a single larger order rather than queue and release ten small fills sequentially. This throttle treats every Submit() call as an independent request and does not attempt to detect or coalesce duplicates. Adding this would require comparing incoming requests against the current queue contents, by symbol, direction, and price proximity, before deciding whether to merge volumes or queue as a new entry — a meaningfully more complex feature better suited to a dedicated CRequestDeduplicator layered on top of this throttle rather than built into it directly.


Section 10: Limitations

The throttle enforces the configured rate, not the broker's undisclosed limit. There is no mechanism for the throttle to discover what the broker actually enforces. Setting the rate too high still risks broker-side rejection; setting it too low means leaving capacity unused. The right value requires empirical testing with the specific broker.

Queuing is not appropriate for every signal type. A breakout entry, a momentary arbitrage window, or a fast news-driven momentum trade can lose its edge within a few milliseconds. Queuing such a signal behind a throttle delay may result in an execution that no longer matches the conditions that triggered it. This throttle is best suited to signals with some tolerance for delay: position sizing adjustments, non-time-critical hedges, or systems generating several correlated signals per tick where only the aggregate rate matters. For latency-sensitive entries, a "drop if stale" policy is a better fit than queuing — see the TTL extension in Section 9.

The priority queue has a fixed capacity. If more orders arrive than the queue can hold, the overflow policy rejects the excess and returns 0 to the caller. Callers must check the return value of Submit() for 0 if they need to know whether an order was dropped rather than queued.

The executed_last_60s dispatch counter is a bounded history, not a simple approximation. The ring buffer holds 200 entries, sufficient at typical rates, but a burst large enough to fill it within 60 seconds overwrites older entries, producing a genuine undercount. This has no effect on rate limiting itself, which is driven entirely by the token bucket. See Section 3 for what the field actually measures.

Timer resolution limits sub-100ms pacing accuracy. EventSetMillisecondTimer() in MetaTrader 5 is subject to OS timer resolution, which on most Windows systems is 15–16 milliseconds. A configured refill rate of 10 tokens per second (one token per 100ms) works correctly; a rate of 100 tokens per second (one token per 10ms) would not drain the queue smoothly because the timer cannot fire that frequently.

Reconfiguring the throttle at runtime affects the bucket and queue differently. CTokenBucket::Configure() preserves the current token count on reconfiguration (see Section 4), while CPriorityQueue::Configure() still resets its count to zero on every call, discarding any queued items. A caller reconfiguring mid-session should expect the queue to drop pending items even though the bucket does not.

This throttle does not track the full lifecycle of a submitted trade. Once ExecuteRequest() calls OrderSend() successfully, it has no further visibility into whether the order filled, partially filled, or was later rejected. Matching a request_id to its eventual outcome requires listening to OnTradeTransaction() separately, as shown in Section 6.


Conclusion

CTradeThrottle gives an EA a configurable rate limit with memory. A burst of orders up to the bucket's capacity passes through immediately; everything beyond that queues, sorted by caller-supplied priority, and releases as tokens refill. The throttle never drops a request purely for rate-limiting reasons — it holds orders rather than discarding them — but whether a held request is still market-valid by the time it executes is a decision the throttle does not make on its own; that responsibility sits with the strategy, or with an added revalidation step as described in Section 9. The dashboard gives the running EA visibility into how close it is to the limit at any moment. TestTradeThrottle.mq5 verifies all the pieces in isolation without requiring a live account or a broker connection.

It does not determine the broker's actual limit; that must be measured empirically. It also does not validate that a queued signal is still worth executing by the time it is released, and does not track a submitted order's full lifecycle through to fill or rejection. The demonstration-level trading layer skips OrderCheck() and stop-distance normalization deliberately, though filling mode resolution and minimum volume lookup are both handled, since even a demo EA needs to place valid orders across different brokers. A queue with a fixed capacity is also not an unbounded buffer; sustained bursts beyond the configured rate will eventually exhaust it and begin dropping overflow. The rate limit, queue size, and expiry policy are design parameters, not magic constants, and should be chosen deliberately for the specific broker, strategy, and signal type.


Programs used in the article:

# Name Type Description
1 ThrottledRequest.mqh Include File CThrottledRequest struct holding one queued order's parameters, priority score, enqueue timestamp, and request ID
2 ThrottleStatus.mqh Include File CThrottleStatus struct returned by GetStatus(): token count, queue depth, recent dispatch count, and ms until next token
3 TokenBucket.mqh Include File CTokenBucket class: Acquire(), Refill(), TokensAvailable(), MsUntilNextToken()
4 PriorityQueue.mqh Include File CPriorityQueue class: fixed-capacity sorted array with FIFO tiebreak and Cancel() by request ID
5 TradeThrottle.mqh Include File CTradeThrottle class: Submit(), OnTimer(), Cancel(), GetStatus(), Configure() with magic/deviation support, ResolveFilling() for broker-safe filling mode selection
6 ThrottleEA.mq5 Demo EA Demo EA simulating a burst of 15 requests with a live chart comment dashboard.
7 TestTradeThrottle.mq5 Script Verification script with 48 assertions across thirteen test functions, covering token bucket math, priority ordering, queue overflow, invalid configuration, repeated cancellation, runtime reconfiguration, and priority-epsilon tolerance.
8 TradeThrottle.zip Zip Archive Zip archive containing all the attached files and their paths relative to the terminal's root folder. 
Attached files |
ThrottleStatus.mqh (1.71 KB)
TokenBucket.mqh (5.35 KB)
PriorityQueue.mqh (6.57 KB)
TradeThrottle.mqh (12.58 KB)
ThrottleEA.mq5 (5.86 KB)
TradeThrottle.zip (15.13 KB)
Price Action Analysis Toolkit Development (Part 79): Extending the Indicator Search Panel with Dynamic Input Parameter Configuration Price Action Analysis Toolkit Development (Part 79): Extending the Indicator Search Panel with Dynamic Input Parameter Configuration
We integrate parameter configuration into the indicator search workflow in MQL5. A central repository describes each indicator's inputs, a dynamic dialog renders controls from those definitions, and the dialog validates entries and converts them to MqlParam. The chart launcher then creates the indicator with IndicatorCreate using the provided values. This streamlines attaching indicators with custom settings on the chosen symbol.
Larry Williams Market Secrets (Part 17) : Detecting Oops Signals Using a Custom Indicator Larry Williams Market Secrets (Part 17) : Detecting Oops Signals Using a Custom Indicator
This article implements an MQL5 custom indicator that detects Larry Williams Oops gap reversals and marks bullish and bearish arrows on the chart. It details configurable gap and validity thresholds, same-bar or later confirmation, first-fill-only logic, historical backfilling, and incremental updates so signals remain consistent on both history and newly completed bars.
Features of Experts Advisors Features of Experts Advisors
Creation of expert advisors in the MetaTrader trading system has a number of features.
Building a Gold Volatility Regime Monitor from Options Data in MQL5 Building a Gold Volatility Regime Monitor from Options Data in MQL5
A practical bridge from the options market into MetaTrader 5 for gold. We compute near-the-money implied volatility by solving Black-Scholes from quoted prices, compare it with 30-day realized volatility, and use the ratio as a regime proxy. A Python feed publishes the value, an MQL5 script consumes it with WebRequest, and a background service keeps a panel current and alerts on changes. Source code for all parts is provided.