preview
Building a Type-Safe Event Bus in MQL5: Decoupling EA Components Without Global Variables

Building a Type-Safe Event Bus in MQL5: Decoupling EA Components Without Global Variables

MetaTrader 5Expert Advisors |
107 0
Ushana Kevin Iorkumbul
Ushana Kevin Iorkumbul

Introduction

When an Expert Advisor grows beyond a single trading-logic block, developers often reach for the most readily available coordination mechanism: global variables. A risk manager needs to know the current drawdown, so a double named g_current_drawdown appears at file scope. A signal engine fires, and the order manager is informed through a bool g_signal_active flag. Within weeks, a moderately complex EA accumulates dozens of these state bridges, each one representing a direct, invisible dependency between components that were designed to be independent.

The structural consequence of this pattern is not just messiness; it is behavioral coupling. When the risk manager reads g_current_drawdown, it assumes the right module wrote it at the right time within the correct tick cycle. If the execution engine is refactored to update that value asynchronously, or if a new component also writes to it, the risk manager's behavior changes silently. There is no contract enforced at the language level. The global variable is a shared mutable state channel with no access control, no type guarantee beyond the primitive, and no record of who produced the value or when.

Consider a realistic three-component architecture: a signal generator, an order manager, and a drawdown monitor. In the naïve global-variable design, their relationships look like this:

Figure 1: Architectural coupling of a three-component Expert Advisor

Figure 1: Architectural coupling of a three-component Expert Advisor, where independent modules inadvertently depend on one another through unmanaged read and write operations within a centralized global variables pool.

Every component reads and writes to a shared pool. Adding a fourth component means auditing all existing globals to understand which ones it must respect and which ones it may corrupt. Removing a component means tracing all references manually before deletion. Unit testing any single component in isolation is effectively impossible because its behavior depends on the global state being pre-configured correctly.

The publish-subscribe pattern, implemented here as a type-safe event bus, eliminates this shared state channel entirely. Components do not read each other's variables. They publish events describing what has happened and subscribe to the events they need to respond to. The bus mediates all communication. No component holds a pointer to any other component. The dependency graph collapses from an N-to-N mesh into a star topology, with the bus at the center.

Figure 2: Decoupled star topology architecture where EA components interact strictly through publish/subscribe channels mediated by a central event bus.

Figure 2: Decoupled star topology architecture where EA components interact strictly through publish/subscribe channels mediated by a central event bus.


Architectural Overview

The implementation consists of four primary elements: an abstract listener interface (IEventListener), a typed event structure hierarchy, the bus itself (CEventBus), and a subscription slot wrapper struct (SListenerSlot). This wrapper addresses an MQL5 compiler limitation with multidimensional pointer arrays. A fifth element, an event type enumeration, provides the compile-time taxonomy that makes the routing type-safe.

The design deliberately avoids heavy framework dependencies. MQL5 provides templates, interfaces (via abstract classes), dynamic arrays, and pointer management. These primitives are sufficient to build a correct and efficient bus without external libraries.

Event Type Taxonomy

All events in the system are identified by a value from a single enumeration. This enumeration serves as the routing key the bus uses to match published events to registered subscribers.

enum ENUM_EA_EVENT
{
   EA_EVENT_SIGNAL_LONG    = 0,  // Signal: Market Long Condition Detected
   EA_EVENT_SIGNAL_SHORT   = 1,  // Signal: Market Short Condition Detected
   EA_EVENT_SIGNAL_FLAT    = 2,  // Signal: No Active Directional Bias
   EA_EVENT_ORDER_FILL     = 3,  // Execution: Order Fill Confirmed
   EA_EVENT_ORDER_REJECT   = 4,  // Execution: Order Rejected by Broker
   EA_EVENT_DRAWDOWN_WARN  = 5,  // Risk: Drawdown Warning Threshold Crossed
   EA_EVENT_DRAWDOWN_HALT  = 6,  // Risk: Hard Drawdown Limit Reached — Trading Halted
   EA_EVENT_SESSION_OPEN   = 7,  // Session: Market Session Begin
   EA_EVENT_SESSION_CLOSE  = 8   // Session: Market Session End
};

Using an enum rather than string identifiers provides two concrete advantages. First, the compiler enforces valid event types at the call site, making it impossible to publish or subscribe to a misspelled or undefined event. Second, integer enum values support direct array indexing, which reduces subscriber lookup from a string comparison loop to an O(1) array access, a meaningful difference when the bus processes hundreds of events per second during volatile sessions.

Event Payload Structure

Every event carries a standardized payload structure. The design goal here is a fixed-size, stack-allocatable record that can be passed by const reference throughout the dispatch chain without heap allocation.

struct SEventPayload
{
   ENUM_EA_EVENT  event_type;     // Routing key identifying the event class
   datetime       timestamp;      // Server time at point of publication
   long           order_ticket;   // Ticket number for fill/reject events (0 if unused)
   double         value_primary;  // Primary numeric payload (price, drawdown %, PnL)
   double         value_secondary;// Secondary numeric payload (SL level, TP level, lot size)
   string         message;        // Human-readable diagnostic string for logging
};

The message field is the only heap-allocated member of the structure because MQL5 strings are reference-counted heap objects. For high-frequency dispatch scenarios where message allocation becomes measurable overhead, the field can be left as an empty string literal without affecting routing behavior. The numeric fields cover the majority of financial event payloads: a fill event uses order_ticket for the position identifier, value_primary for the fill price, and value_secondary for the executed volume. A drawdown event uses value_primary for the current drawdown percentage and leaves the ticket field at zero.

The table below maps each event type to its expected payload field usage:

Event Typeorder_ticketvalue_primaryvalue_secondarymessage
EA_EVENT_SIGNAL_LONG0Entry reference priceATR-derived SL offsetSignal source ID
EA_EVENT_SIGNAL_SHORT0Entry reference priceATR-derived SL offsetSignal source ID
EA_EVENT_SIGNAL_FLAT00.00.0Reason for flat bias
EA_EVENT_ORDER_FILLPosition ticketFill priceExecuted volume (lots)Symbol name
EA_EVENT_ORDER_REJECTAttempted ticketRequested priceRequested volumeBroker error description
EA_EVENT_DRAWDOWN_WARN0Current drawdown %Warning threshold %Session equity
EA_EVENT_DRAWDOWN_HALT0Current drawdown %Hard limit %Trading halt reason
EA_EVENT_SESSION_OPEN00.00.0Session name identifier
EA_EVENT_SESSION_CLOSE00.00.0Session name identifier

The Listener Interface

MQL5 does not support true interface types in the Java or C# sense, but abstract classes with pure virtual methods serve the same structural role. Any component that wishes to receive events from the bus must inherit from IEventListener and implement its single dispatch method.

//--- IEventListener
class IEventListener
{
public:
   virtual void OnEvent(const SEventPayload &payload) = 0;
   virtual     ~IEventListener() {}
};

The OnEvent method accepts its payload by const reference. This is not a stylistic choice; it is a performance and safety contract. Passing the 80-byte SEventPayload structure by value would copy it onto each listener's stack frame on every dispatch call. With ten registered listeners and one hundred events per second, that is one thousand unnecessary structure copies per second. The const qualifier enforces that no listener implementation may modify the shared payload, preventing one listener from corrupting the event data seen by listeners dispatched later in the same publish cycle.

The pure virtual destructor ensures that the bus can safely delete listener objects through base-class pointers during cleanup, without invoking undefined behavior from a non-virtual destructor in a polymorphic hierarchy.

The CEventBus Class

The bus is the sole mediator between publishers and subscribers. Its internal data model is a fixed-length array of SListenerSlot wrapper objects, indexed directly by the event type enum value. MQL5 does not support two-dimensional dynamic arrays of object pointers, so an intermediary struct is required. Each SListenerSlot stores a dynamic IEventListener* array and an active-subscriber count. The bus declares SListenerSlot m_slots[EA_EVENT_COUNT = 9], which is a fixed-length array of plain structs and is fully valid MQL5. This structure enables O(1) subscriber lookup per event type. It requires a small, predictable allocation per subscription slot.

Figure 3: Memory layout of CEventBus showing the fixed-length m_slots array of size 9

Figure 3: Memory layout of CEventBus showing the fixed-length m_slots array of size 9, containing structural SListenerSlot wrapper boundaries to isolate internal tracking counts from dynamic pointer array collections via O(1) enum indexing.



Subscription Slot Wrapper

MQL5 does not permit jagged or two-dimensional dynamic arrays of object pointers. A declaration of the form IEventListener *m_subscribers[EA_EVENT_COUNT][] is illegal syntax in the MQL5 compiler and produces array access errors at every call site that attempts to resize or index the inner dimension. The SListenerSlot struct resolves this by wrapping the per-event dynamic array into a named aggregate that the compiler can handle correctly:

struct SListenerSlot
{
   IEventListener *listeners[];  // Dynamic array of subscriber pointers for one event type
   int             count;        // Active subscriber count in this slot

   SListenerSlot() : count(0) { ArrayResize(listeners, 0); }
};

The bus then declares SListenerSlot m_slots[EA_EVENT_COUNT], which is a fixed-length array of plain structs. All ArrayResize calls and index operations target m_slots[slot].listeners and m_slots[slot].count rather than a two-dimensional array. The SListenerSlot default constructor initializes the count to zero and sets the inner array to zero capacity, so every slot is in a valid empty state the moment the bus is constructed without requiring an explicit initialization loop over the enum range.

Subscription Management

Subscriber registration stores a raw pointer to the listener object. The bus does not own these objects; it does not manage their lifecycle. The component that creates a listener is responsible for ensuring the listener remains valid for the duration of its registration. This is a deliberate design choice: ownership inversion (where the bus owns all listeners) would require the bus to manage heterogeneous object lifetimes, which conflicts with the typical EA structure where components are stack-allocated members of the top-level EA class.

The bus must validate registered pointers before dispatch. MQL5's CheckPointer() function returns POINTER_INVALID for dangling or deleted heap objects, and POINTER_DYNAMIC for valid heap allocations. This validation occurs during every dispatch cycle, not only during registration, because a listener may be deleted between registration and the next publish call.

Publish Mechanics

When Publish() is called, the bus retrieves the subscriber array for the event's type, iterates it, validates each pointer, and calls OnEvent() synchronously. The dispatch is synchronous by default, meaning the publisher's execution thread blocks until all listeners have returned from their OnEvent() implementations. This model is correct for single-threaded MQL5 EAs, where all execution occurs on the EA's thread within a tick handler. If a listener performs expensive computation (database writes, HTTP requests via WebRequest), that cost is paid synchronously during the tick, which must be considered when designing listener implementations.

The class interface surface is small by design:

MethodSignatureAccessArchitectural Purpose
Subscribebool Subscribe(ENUM_EA_EVENT type, IEventListener *listener)publicRegisters a listener for a specific event type
Unsubscribebool Unsubscribe(ENUM_EA_EVENT type, IEventListener *listener)publicRemoves a specific listener registration by pointer identity
Publishvoid Publish(const SEventPayload &payload)publicDispatches an event to all registered subscribers for its type
SubscriberCountint SubscriberCount(ENUM_EA_EVENT type)publicReturns the number of active subscribers for a type (diagnostic)
Clearvoid Clear()publicRemoves all subscriptions without deleting listener objects
m_slotsSListenerSlot m_slots[EA_EVENT_COUNT]privateSubscription table indexed by event type



Overhead Analysis and Practical Constraints

The event bus introduces measurable overhead relative to direct function calls. Understanding this overhead at the architectural level is necessary for deciding when the pattern is appropriate and when it is not.

Dispatch latency: Each Publish() call performs one array index dereference, a bounds check, a loop over subscribers, one CheckPointer() call per subscriber, and one virtual function call per subscriber. On modern hardware, the virtual dispatch itself contributes roughly 2 to 5 nanoseconds per call due to the vtable indirection. For an EA with five subscribers per event and one hundred published events per tick, the total added overhead is under a microsecond, which is negligible relative to network latency for order transmission. For ultra-low-latency scalping strategies where tick handler execution time is itself a performance variable, this overhead should be benchmarked explicitly.

Memory footprint: The subscription table allocates an IEventListener* pointer array per event type. With nine event types and a maximum of ten subscribers each, the maximum table size is 90 pointer slots, consuming 720 bytes on a 64-bit platform. The SEventPayload structure occupies approximately 80 bytes on the stack during dispatch. Neither figure is significant in the context of a long-running EA.

Pointer safety window: The CheckPointer() guard protects against dispatching to deleted objects, but it does not protect against objects that have been destructed without deallocation (i.e., local objects on a scope that has been unwound). If a listener is a stack-allocated local variable in a function that has returned, CheckPointer() will not detect the invalidation. All listeners that register with the bus must have lifetimes that span the bus's operational lifetime. A safer pattern is to allocate listeners on the heap and store them in the EA's main class. Delete them in OnDeinit() after clearing the bus.

No queuing, no thread safety: MQL5's execution model is single-threaded per EA. The bus implementation below does not include a message queue, deferred dispatch, or any concurrency primitive. If a future MQL5 runtime were to introduce multi-threaded execution contexts, the bus would require a mutex around m_subscribers access and a lock-free ring buffer for the publish queue. No such provision is made here because the current MQL5 runtime specification does not warrant it.

Recursive publish risk: If a listener's OnEvent() implementation calls Publish() on the same bus, a recursive dispatch cycle begins. The bus does not detect or prevent this. An EA_EVENT_ORDER_FILL listener that publishes EA_EVENT_DRAWDOWN_WARN in response is safe only if no EA_EVENT_DRAWDOWN_WARN listener publishes a fill event. Publish cycles that form a loop can exhaust the MQL5 call stack and terminate the EA. Mitigation relies on architecture, not runtime guards: handlers should publish only for external state changes, not as reactions to other bus events.

Integrating the Bus: A Three-Component EA

The following design scenario connects three independent EA components through the bus without any direct cross-references between them:

  • CSignalEngine monitors price action and publishes EA_EVENT_SIGNAL_LONG, EA_EVENT_SIGNAL_SHORT, and EA_EVENT_SIGNAL_FLAT events. It holds no pointer to the order manager or risk monitor.
  • COrderManager subscribes to signal events and publishes EA_EVENT_ORDER_FILL and EA_EVENT_ORDER_REJECT events after communicating with the broker. It holds no pointer to the signal engine or risk monitor.
  • CDrawdownMonitor subscribes to fill events to track position exposure and publishes EA_EVENT_DRAWDOWN_WARN and EA_EVENT_DRAWDOWN_HALT events when risk thresholds are crossed. It holds no pointer to either the signal engine or the order manager.

The EA's top-level OnInit() function constructs all three components, constructs the bus, and wires subscriptions. OnTick() drives the signal engine, which internally calls Publish() on the bus reference it holds. The entire tick-to-execution chain flows through the bus without any direct module coupling.

Figure 4: Sequence execution flow of a single event-driven market tick, tracing how signals and execution events route through the central bus without coupling components.

Figure 4: Sequence execution flow of a single event-driven market tick, tracing how signals and execution events route through the central bus without coupling components.



Conclusion

The CEventBus architecture replaces an implicit, fragile network of global variables with an explicit, compiler-enforced communication contract. Components declare what they emit and what they consume at registration time, making the dependency structure of a complex EA visible, auditable, and refactorable without cross-module side effects.

The core trade-offs of this approach are well-defined. Synchronous dispatch maintains deterministic execution order within a tick but means listener performance directly affects tick handler latency. Pointer-based subscription management requires disciplined lifetime governance from the caller; the bus is an intentional non-owner of its subscribers. The O(1) subscription table lookup scales cleanly with subscriber count per event type, though the fixed enum-indexed table does constrain the event taxonomy to the compile-time enumeration, making runtime event type registration impossible without architectural changes.

For the majority of institutional-grade EA designs, these constraints are engineering boundaries, not limitations. The pattern provides the decoupling necessary to build, test, and evolve large component sets independently, and the overhead it introduces is well below the noise floor of any latency measurement that includes broker network round-trips.


Programs used in the article

#NameTypeDescription
1EventBusSystem.mqhInclude FileConsolidated single-file include containing all six original modules in dependency order: ENUM_EA_EVENT taxonomy, SEventPayload structure, IEventListener abstract base, CEventBus routing engine, CSignalEngine, COrderManager, and CDrawdownMonitor
2EventBusDemo.mq5Demo EADemonstration EA referencing  "EventBusSystem.mqh." Wires all three components through CEventBus  and handles OnInit, OnDeinit, OnTick, and OnTradeTransaction
Attached files |
EventBusSystem.mqh (25.46 KB)
EventBusDemo.mq5 (7.77 KB)
Engineering a Self-Healing Expert Advisor in MQL5 (Part 2): Restart-Safe Virtual Trade Protection Engineering a Self-Healing Expert Advisor in MQL5 (Part 2): Restart-Safe Virtual Trade Protection
Build a restart-aware virtual protection layer on top of the SQLite persistence from Part 1. The EA reconstructs hidden stop-loss and take-profit after restart, verifies current price against recovered exits, and closes or continues positions accordingly. The result is a consistent recovery path that detects managed positions and sustains safe runtime management.
Extremal Optimization (EO) Extremal Optimization (EO)
The article discusses the Extremal Optimization (EO) algorithm, an optimization method inspired by the Bak-Sneppen self-organized criticality model, where evolution occurs through the elimination of the worst-case components of the system. The modified population version of the algorithm demonstrates a shift away from theoretical principles in favor of practical efficiency, leading to the creation of powerful computational tools.
Implementing a Fluent Interface Builder Pattern for MQL5 Order Construction Implementing a Fluent Interface Builder Pattern for MQL5 Order Construction
Manual population of MqlTradeRequest leaves cross-field rules unchecked, creating silent misconfigurations at execution time. A fluent COrderBuilder for MQL5 adds pointer-based method chaining, per-field validation, and directional SL/TP checks against broker stop‑level constraints. Its Send() method runs a four-stage gate—flag completeness, cross-field consistency, OrderCheck(), then OrderSend()—so configuration errors are caught early and order code stays clear and reusable.
Neural Networks in Trading: Actor—Director—Critic Neural Networks in Trading: Actor—Director—Critic
We invite you to explore the Actor-Director-Critic framework, which combines hierarchical learning and a multi-component architecture for creating adaptive trading strategies. In this article, we take a detailed look at how using the Director to classify the Actor's actions helps to effectively optimize trading decisions and improve the robustness of models in financial market conditions.