preview
From Polling to Events: Building a Reactive MQL5 Trading System with Python and Redis Streams

From Polling to Events: Building a Reactive MQL5 Trading System with Python and Redis Streams

MetaTrader 5 — Trading |
122 0
Stanley Kimathi Kibaara
Stanley Kimathi Kibaara

Introduction

An Expert Advisor can poll an external service for signals, and at low volume nothing feels wrong. A timer, an HTTP request, and a list of pending actions are enough, and the code is easy to follow.

The trouble starts when the system has to survive a crash, a retry, or a broker reply that is neither a clear yes nor a clear no. Was this signal already handled? Did that order reach the broker before the restart? Should this message ever be read again?

This article follows that problem from a simple polling loop to a small prototype built on Redis Streams. MQL5 publishes market events, Python turns them into trading ideas, and the EA decides what happens next. The EA still polls on a timer. What changes is not the mechanism, but the semantics: every message gets a stable identity, an explicit acknowledgment, and a way out when it cannot be used.

One rule runs through the design. Python is allowed to suggest a trade. It is not allowed to place one. The final decision always stays inside MQL5.


When polling stops being simple

In its simplest form, OnTimer() calls WebRequest() on a Python endpoint every few seconds, and the EA tries an order for every signal returned. With one producer, one consumer, and at most one signal per bar, this works. The real problem appears one crash later.

Messages can be processed twice: if the EA crashes after sending an order but before recording it, the signal comes back looking brand new. Without an acknowledgment, "finished" and "never seen" look the same. Invalid records can get stuck: a message the EA cannot parse returns on every poll. And broker outcomes are rarely binary: a loop that only knows "filled" retries a partial fill that already traded, and retries a margin rejection forever.

None of this is caused by polling itself. Polling works when the protocol behind it is explicit: stable IDs, acknowledgments, a rejection path, and processing that gives the same result when it runs twice. Part of that comes ready-made from Redis Streams. The rest has to be built in the EA.


Redis Streams help, but only with delivery

A Redis stream is an append-only log. Producers add entries with XADD, and every entry gets a transport ID such as 1792622276177-0. Consumers read through a consumer group, which keeps a pending entries list (PEL): an entry handed to a consumer stays pending until the consumer confirms it with XACK, and after a restart the consumer reads its own pending entries again with XREADGROUP and ID 0, and new ones with ID >.

That sounds minor, but it changes the whole failure model. A crash no longer loses a message, and a message can no longer disappear quietly: every entry is either acknowledged, still pending, or explicitly moved somewhere else.

The wrapper uses only the commands it needs. Creating the group comes first:

    # ensure_group - create a consumer group if it does not exist
    def ensure_group(self, stream_name: str, group_name: str) -> None:
        """Create a consumer group if it does not already exist.

        A new group starts at '$': it receives only messages published after
        it was created, so a newly started EA does not replay the whole stream
        history (those signals would be stale anyway).

        Only BUSYGROUP (the group already exists) is ignored. Any other Redis
        error, such as WRONGTYPE when the key is not a stream, is logged and
        raised to the caller.
        """
        try:
            self.redis.xgroup_create(stream_name,                             # target stream
                                     group_name,                              # group name
                                     id='$',                                  # only messages after creation
                                     mkstream=True)                           # create stream if absent
        except redis.exceptions.ResponseError as e:
            if str(e).startswith('BUSYGROUP'):                                # group already exists?
                return                                                        # expected: nothing to do
            logging.error(f'XGROUP CREATE {group_name} on {stream_name} failed: {e}') # unexpected Redis error
            raise                                                             # let the caller decide

XGROUP CREATE fails with BUSYGROUP when the group already exists, which is normal on every restart, so only that error is ignored. Anything else, such as WRONGTYPE, is logged and raised; hiding it would leave an EA that silently receives nothing. A new group starts at $, so an EA does not replay old signals, and the strategy must start before the EA, or its group misses the first bars.

Reading new entries has a trap of its own:

    # consume_new_events - fetch never-delivered messages
    def consume_new_events(self, stream_name: str,                            # source stream
                           group_name: str,                                   # consumer group
                           consumer_name: str,                                # consumer instance
                           count: int = 10,                                   # max messages
                           block_ms: Optional[int] = None                     # None = never block
                           ) -> List[Tuple[str, Dict]]:
        """Return messages that have never been delivered to this consumer group.

        block_ms=None  -> no BLOCK argument: the call returns immediately.
        block_ms=N > 0 -> wait at most N milliseconds for new messages.
        BLOCK 0 is deliberately never sent: in Redis it means "wait forever".
        """
        if block_ms is not None and block_ms <= 0:                            # refuse an unbounded wait
            raise ValueError('block_ms must be None or a positive number of milliseconds') # BLOCK 0 = wait forever
        self.ensure_group(stream_name, group_name)                            # group must exist
        response = self.redis.xreadgroup(group_name,                          # consumer group
                                         consumer_name,                       # consumer name
                                         {stream_name: '>'},                  # never-delivered only
                                         count=count,                         # max messages
                                         block=block_ms)                      # None = do not block
        return self._flatten(response)                                        # return the list

In Redis, BLOCK 0 does not mean "do not wait". It means "wait forever". The gateway is single-threaded, so one blocked read would stall every request, including the EA's publication of the very bar that would produce the awaited signal. The wrapper therefore never sends it.

So far, so good. But delivery is only half of the story.

XACK tells Redis that an entry no longer needs to stay pending. It does not tell us whether an order was sent, accepted, or filled before a restart. Treat an ACK as proof of execution, and the first crash in the wrong place produces a duplicate trade. To close that gap, the EA needs three things of its own: a durable journal that recognizes a redelivered signal even after a restart, a link from every order and deal back to its signal, and a decision for every broker reply: accepted, worth retrying, or final.


Why MQL5 keeps the final say

Business correctness starts with a question of authority. A signal carries a direction and a reference to its bar, but no volume, no price, and no stop levels, and nothing in it can switch a check off. Every check that decides whether an order is valid, safe, and sendable runs inside the EA, in ExecutionGuard.

Why be this strict? Because the boundary has to hold even when Python is wrong. A strategy bug, a misconfigured deployment, or a local process writing to Redis can do no more than a correct signal could. The worst a bad signal can do is what the guard would allow a good one to do. Note what this promises: every order conforms to the EA's rules, not that the trade is a good idea.

A signal that fails any check in Table 1 is rejected, journaled, and acknowledged.

Check Why
The event is a SIGNAL_GENERATED for the chart symbol, with direction BUY or SELL Anything else is a malformed signal, not an opinion
Age on the trade-server clock between zero and MaxSignalAgeSeconds A decision based on old market data is stale
Terminal connected; trading allowed in the terminal, for the program, for the account, and for Expert Advisors on the account Trading can be switched off at each level
Symbol not disabled, and its trade mode allows the direction Long-only, short-only, and close-only symbols exist
No EA-owned position (hedging) or no position at all (netting) on the symbol On a netting account, a new order would modify a position the EA may not own
No EA-owned order still active on the symbol An accepted but unfilled order has no position yet
Spread at most MaxSpreadPoints Avoids rollover and news spikes
Configured volume valid after normalization Python never supplies a volume

Table 1. What the guard checks, and why.

Normalization clamps the configured volume into the broker's range and rounds it down to whole volume steps, taking the decimals from the step itself, so 0.125 keeps three. A final check in NormalizeVolume() rejects anything still invalid. A stricter risk policy could refuse an out-of-range volume instead of clamping it. After the guard, OrderCheck() validates margin and the full request.

Python keeps only the soft decision. Here an up bar means BUY and anything else SELL. The confidence of 0.8 in the signal is decorative: nothing reads it.

The age check needs one clock. TimeCurrent() is server time, Python's time.time() is local time, and the two differ by the broker's time-zone offset. So the EA stamps BAR_CLOSED with TimeCurrent(), the strategy copies it unchanged into source_event_time, and the guard measures on that same clock:

//--- 4. confirm freshness on ONE clock: the trade server clock.
//---    source_event_time is the TimeCurrent() value the EA stamped on the
//---    BAR_CLOSED event; Python copies it into the signal payload unchanged.
//---    The Python-side event_time (local system clock) is audit data only.
   long source_time = GetPayloadLong(signal.payload, "source_event_time");                  // server-clock origin time
   if(source_time <= 0)                                                                     // field missing or invalid?
     {
      reason = "Signal carries no source_event_time";                                       // describe the anomaly
      return(false);                                                                        // reject
     }
   long age = (long)TimeCurrent() - source_time;                                            // age on the same clock (seconds)
   if(age < 0)                                                                              // future-dated signal?
     {
      reason = "Signal has a future timestamp";                                             // describe the anomaly
      return(false);                                                                        // reject
     }
   if(age > m_max_age_seconds)                                                              // exceeds maximum age?
     {
      reason = "Signal is stale (age = " + IntegerToString(age) + " s)";                    // describe the staleness
      return(false);                                                                        // reject
     }

This measures the age of the market data, so any delay in Python, Redis, or the gateway counts against the signal. That is deliberate: a decision based on old data is stale, however recently it was computed. Python's event_time and Redis stream IDs play no part in freshness.


The event model

The guard can only check what an event carries, and acknowledgments need a stable envelope:

//+------------------------------------------------------------------+
//| Enum: ENUM_EVENT_TYPE - all event categories in the system       |
//+------------------------------------------------------------------+
enum ENUM_EVENT_TYPE
  {
   EVENT_BAR_CLOSED       = 0,   // a chart bar has closed
   EVENT_SIGNAL_GENERATED = 1,   // Python has produced a trading signal
   EVENT_ORDER_ATTEMPTED  = 2,   // the EA is about to send an order
   EVENT_DEAL_EXECUTED    = 3,   // the broker has filled a deal
   EVENT_POSITION_UPDATED = 4    // an open position has changed
  };

//+------------------------------------------------------------------+
//| Struct: EventEnvelope - compact container for one event          |
//+------------------------------------------------------------------+
struct EventEnvelope
  {
   string            event_id;      // application-level unique identifier
   string            stream_id;     // Redis transport ID (empty when publishing)
   ENUM_EVENT_TYPE   event_type;    // category of this event
   string            source;        // module that produced this event
   datetime          event_time;    // creation timestamp (Unix seconds)
   string            symbol;        // trading symbol related to this event
   string            payload;       // JSON string carrying extra fields
  };

The event_id is the logical identity: what the event is about. The stream_id is where Redis stored this copy. They sit next to each other on purpose, because mixing them up is the classic mistake. A redelivery keeps the stream_id; a republished copy gets a new one but keeps its event_id. Deduplication uses the event_id, acknowledgment the stream_id, never the other way around.

Field Gateway check
event_id, source, symbol, event_type Required text; event_type must be known; a missing event_id is never replaced by the Redis ID
event_time Positive integer (Unix seconds); for signals, audit data only
payload A flat JSON object; values are finite numbers or text without quotes, backslashes, braces, or control characters; booleans, null, arrays, and nested objects are refused

Table 2. Envelope fields and gateway checks.

The payload rules look fussy, and they exist for one reason. The EA parses JSON with a small, constrained parser, so the gateway refuses anything it cannot read instead of repairing it. A boolean is valid JSON, but the parser does not handle it, so it is refused before it is stored. Inside the EA the payload is a JSON string that the serializer maps to and from the object on the wire.

Every event_id is built from durable facts, never from a counter (Table 3), so the same bar announced twice gives the same signal ID and the journal recognizes it. Redis does not deduplicate; the EA does, per signal.

Event Producer and stream event_id Payload fields
BAR_CLOSED MQL5, market.events BAR_CLOSED:{symbol}:{timeframe}:{bar_open_time} bar_time, open, high, low, close, tick_volume
SIGNAL_GENERATED Python, signal.events SIGNAL:{source_event_id}:{strategy_version} direction, confidence, strategy_version, source_event_id, source_event_time
ORDER_ATTEMPTED MQL5, trade.events ORDER_ATTEMPTED:{signal_event_id}:{attempt} signal_event_id, attempt, direction, volume, magic, order_tag
DEAL_EXECUTED MQL5, trade.events DEAL_EXECUTED:{deal_ticket} deal, order, signal_event_id, direction, entry, volume, price
POSITION_UPDATED MQL5, trade.events Reserved, not yet published None

Table 3. Event types, identities, and payloads.

ORDER_ATTEMPTED records what the EA decided to submit; DEAL_EXECUTED records what the broker executed. One attempt can produce no deal, or several partial ones.

The strategy enforces the BAR_CLOSED payload in validate_bar_event() instead of trusting it:

# validate_bar_event - check required fields and the complete OHLC bar
def validate_bar_event(event: dict) -> bool:
    """Check that the event is a valid BAR_CLOSED with a complete, consistent OHLC bar.

    open, high, low, and close are all required, must be finite and positive,
    and the high-low range must contain both the open and the close.
    """
    if event.get('event_type') != 'BAR_CLOSED':                   # must be the right type
        return False                                              # reject wrong events
    if not event.get('event_id'):                                 # must have an ID
        return False                                              # reject missing ID
    if not event.get('symbol'):                                   # must have a symbol
        return False                                              # reject missing symbol
    try:                                                          # must have an integer timestamp
        if int(event.get('event_time')) <= 0:                     # positive Unix seconds (server clock)
            return False                                          # reject non-positive time
    except (TypeError, ValueError):                               # missing or non-numeric
        return False                                              # reject missing time

    # decode the payload and require all four prices
    try:
        payload = json.loads(event.get('payload', '{}'))          # decode payload JSON
        bar = {k: float(payload[k]) for k in ('open', 'high', 'low', 'close')} # all four required
    except (json.JSONDecodeError, KeyError, TypeError, ValueError): # malformed or missing price
        return False                                              # reject incomplete payload

    # reject NaN, Infinity, zero, and negative prices
    if not all(math.isfinite(p) and p > 0 for p in bar.values()): # float('nan') does not raise
        return False                                              # reject invalid numbers

    # OHLC consistency: the high-low range must contain the open and the close
    if bar['high'] < max(bar['open'], bar['close']):              # high below the body?
        return False                                              # reject inconsistent bar
    if bar['low'] > min(bar['open'], bar['close']):               # low above the body?
        return False                                              # reject inconsistent bar
    return True                                                   # event is valid

All four prices must be finite, positive, and consistent. The isfinite() check matters because float("nan") does not raise. An invalid bar is acknowledged and discarded; a failed publication of the signal leaves the bar pending for a retry.


From market event to trade attempt

The EA publishes BAR_CLOSED through the gateway. The strategy, which talks only to Redis, publishes SIGNAL_GENERATED. On each timer tick the EA fetches pending and new signals, filters duplicates through its journal, runs the guard and OrderCheck(), publishes ORDER_ATTEMPTED, and calls OrderSend(). Deals arrive later as DEAL_EXECUTED (Figure 1).

Reactive event flow from market event to confirmed trade.

Figure 1. Reactive event flow from market event to confirmed trade.

The MQL5 side is timer-driven, and that is not a stylistic choice: MQL5 has neither a Redis client nor an asynchronous HTTP client. Figure 2 shows how each event crosses the stream boundaries.

Event lifecycle across stream boundaries.

Figure 2. Event lifecycle across stream boundaries.


One bar, end to end

This cycle comes from the demo test described later. At 17:03:00 an M1 bar of EURUSD# closed and the EA published BAR_CLOSED. The strategy published a SELL signal with the bar's server time and acknowledged the bar; the EA never acknowledges BAR_CLOSED, since it only publishes it. About a second later the EA received the signal, found no journal entry, passed the guard and OrderCheck(), published ORDER_ATTEMPTED, wrote ATTEMPTING, and sent the order. The broker answered 10009 (DONE); the EA journaled ACCEPTED with the ticket and acknowledged the signal in its own group, mql5-EURUSD_-123456, where "#" became "_" because group names travel in a URL.

17:03:00.305  Published BAR_CLOSED event: BAR_CLOSED:EURUSD#:M1:1792602120
17:03:01.281  OrderCheck passed for SIGNAL:BAR_CLOSED:EURUSD#:M1:1792602120:simple-1: retcode=0 (Done)
17:03:01.997  Order accepted: EURUSD#, volume=0.1, type=SELL, order=999271786, retcode=10009
17:03:02.069  Published DEAL_EXECUTED: DEAL_EXECUTED:1187726132 (signal SIGNAL:BAR_CLOSED:EURUSD#:M1:1792602120:simple-1)

Event identity across the round trip.

Figure 3. Event identity across the round trip.

The link from deal to signal rests on ordering. The EA writes result.order into the journal before OnTimer() returns, and trade transactions arrive only after that, so OnTradeTransaction() finds the signal through DEAL_ORDER. A partial fill produces several DEAL_EXECUTED events with the same IDs. The order comment carries a second link, an 11-character tag: RS- plus the FNV-1a hash of the signal ID, which lets the EA find its order after a restart even before any deal. The signal is acknowledged on the OrderSend() result, not on the first deal, because the ACK answers "did this become an accepted order?", not "what was executed?".


Reading and acknowledging signals

The gateway is the only component that speaks both HTTP and Redis. It exposes /publish, /consume, /ack, and /reject, and contains no trading logic. This is how it answers a poll:

    # handle_consume - return pending and new events for a consumer
    def handle_consume(self):
        """Return up to `count` events: pending entries first, then new ones.

        New entries are only requested for the remaining capacity, so the
        gateway never claims messages it does not return. Neither read
        blocks. Records that violate the contract are dead-lettered and
        acknowledged here instead of being returned.
        """
        # parse query parameters from the URL
        params   = parse_qs(urlparse(self.path).query)                        # parse query string
        stream   = params.get('stream',   ['signal.events'])[0]               # stream name
        group    = params.get('group',    [CONSUMER_GROUP])[0]                # group name
        consumer = params.get('consumer', [CONSUMER_NAME])[0]                 # consumer name
        try:
            count = max(1, min(int(params.get('count', ['10'])[0]), MAX_COUNT)) # bounded batch size
        except ValueError:                                                    # non-numeric count?
            self.send_json(400, {'error': 'count must be an integer'})        # bad request
            return                                                            # abort handling

        try:
            # recover messages already assigned to this consumer
            pending = redis_stream.consume_pending_events(stream, group, consumer, count=count) # XREADGROUP 0

            # fetch never-delivered messages only for the remaining capacity
            remaining = count - len(pending)                                  # free slots in this response
            new = redis_stream.consume_new_events(stream, group, consumer,
                                                  count=remaining) if remaining > 0 else [] # XREADGROUP >

            # validate each record; dead-letter the ones that break the contract
            result = []                                                       # response accumulator
            for msg_id, record in pending + new:                              # pending first, then new
                envelope, error = normalize_envelope(record)                  # validate and normalize
                if error:                                                     # poison message?
                    reject_record(stream, group, msg_id, record, error)       # dead-letter and ACK
                    continue                                                  # do not return it
                result.append({'stream_id': msg_id, **envelope})              # transport ID emitted first

            # return the compact JSON array to the caller
            self.send_json(200, result)                                       # success
        except Exception as e:                                                # catch any error
            logging.error(f'Consume error: {e}')                              # log server-side
            self.send_json(500, {'error': str(e)})                            # server error

Pending entries come first, and new ones fill only the remaining capacity, so the gateway never claims an entry it does not return. The same normalize_envelope() check runs on publication, where a violation gets HTTP 400, and here, where a bad record is copied to signal.events.dlq with its reason and acknowledged. The value check is short:

# parser_safe - True if a value can be read by the MQL5 parser
def parser_safe(value):
    """Strings must not contain quotes, backslashes, braces, or control characters;
    numbers must be finite. Booleans, nested objects, arrays, and null are not allowed."""
    if isinstance(value, bool):                                               # true / false?
        return False                                                          # not in the contract; checked first because a bool is also an int
    if isinstance(value, (int, float)):                                       # numeric value
        return math.isfinite(value)                                           # NaN and Infinity are not JSON
    if isinstance(value, str):                                                # text value
        return not any(c in UNSAFE_CHARS or ord(c) < 32 for c in value)       # no breaking characters
    return False                                                              # dict, list, None

The bool test comes first because in Python a bool is also an int. If a record still fails to parse in the EA, its stream_id goes to /reject, and the gateway dead-letters and acknowledges it. That second line of defense was never needed in the tests.

Acknowledging is where an earlier version trusted too much. The gateway returns HTTP 200 whenever it handled the request, but XACK reports how many entries it removed, and zero means the entry was not pending: already acknowledged, or a wrong group or ID. Redis does not say which. So the EA counts an ACK only when the reply says "acknowledged":true:

//+------------------------------------------------------------------+
//| Acknowledged - did XACK actually remove the entry?               |
//| HTTP 200 only means that the gateway answered. XACK returns 0    |
//| both for an entry that was already acknowledged and for a wrong  |
//| group or ID, so "acknowledged":false never counts as success.    |
//+------------------------------------------------------------------+
bool EventConsumer::Acknowledged(const string reply,                           // gateway reply body
                                 string &error_msg) const                      // error message if any
  {
   if(StringFind(reply, "\"acknowledged\":true") >= 0)                         // XACK removed one entry?
     {
      error_msg = "";                                                          // no error to report
      return(true);                                                            // acknowledgment confirmed
     }
   error_msg = "entry was not pending (already acknowledged, or wrong group or ID)"; // describe the outcome
   return(false);                                                              // not confirmed
  }

//+------------------------------------------------------------------+
//| AckEvent - acknowledge a processed event via Redis stream ID     |
//+------------------------------------------------------------------+
bool EventConsumer::AckEvent(const string stream_id,                           // Redis stream entry ID
                             string &error_msg)                                // error message if any
  {
//--- acknowledge in the SAME group that consumed the message
   string json = "{\"stream_id\":\"" + EscapeJSON(stream_id) +                 // JSON body: stream_id
                 "\",\"stream\":\"" + STREAM_SIGNAL +                          // JSON body: stream name
                 "\",\"group\":\"" + EscapeJSON(m_consumer_group) + "\"}";     // JSON body: consuming group
   string reply;                                                               // gateway reply body
   if(!PostJSON("/ack", json, reply, error_msg))                               // POST /ack
      return(false);                                                           // transport or HTTP failure
   return(Acknowledged(reply, error_msg));                                     // XACK must have removed the entry
  }

An unconfirmed ACK leaves the entry pending; it returns on the next poll and the journal recognizes it. It costs a round trip, never a second order.

One more line came from testing:

    # send_json - write a JSON response with a status code
    def send_json(self, status, body):
        """Serialize body as compact JSON and send it with the given status."""
        data = json.dumps(body, separators=COMPACT).encode('utf-8')           # compact UTF-8 body
        self.send_response(status)                                            # status line
        self.send_header('Content-Type', 'application/json')                  # JSON content
        self.send_header('Content-Length', str(len(data)))                    # body length
        self.send_header('Connection', 'close')                               # new connection per request; MT5 under Wine fails on reuse
        self.end_headers()                                                    # finish headers
        self.wfile.write(data)                                                # write body

Python's HTTPServer closes the connection after every reply. MetaTrader 5 under Wine appeared to reuse it, so every second request failed with error 1003 before reaching the gateway. Connection: close fixes that.


Surviving restarts: the journal and reconciliation

Redis remembers what is pending, not what the EA already did. That memory is SignalJournal, an append-only file per symbol and magic number, where every transition is flushed before the EA acts on it:

//+------------------------------------------------------------------+
//| Append - write one line and flush it to disk                     |
//+------------------------------------------------------------------+
bool SignalJournal::Append(const JournalEntry &entry)                                       // entry to persist
  {
//--- open the file for appending (created if absent)
   int h = FileOpen(m_file, FILE_READ | FILE_WRITE | FILE_TXT | FILE_ANSI | FILE_SHARE_READ); // file handle
   if(h == INVALID_HANDLE)                                                                  // open failed?
     {
      PrintFormat("SignalJournal: cannot open %s, error=%d", m_file, GetLastError());       // log failure
      return(false);                                                                        // report failure
     }

//--- write the line at the end and make it durable
   FileSeek(h, 0, SEEK_END);                                                                // move to end of file
   bool ok = (FileWriteString(h, Format(entry) + "\r\n") > 0);                              // write one line
   FileFlush(h);                                                                            // durable before the caller acts
   FileClose(h);                                                                            // release the handle
   return(ok);                                                                              // report the write result
  }

MarkAttempting(), MarkAccepted(), and MarkRejected() all go through Transition(), which updates memory only if Append() succeeded and returns that result. Each line reads event_id|state|updated|attempts|order. On start-up, entries older than a day are dropped; if the journal cannot be loaded, OnInit() fails and the EA refuses to trade.

State Written when On redelivery
ATTEMPTING (1) After ORDER_ATTEMPTED, before OrderSend() Reconcile with the broker first
ACCEPTED (2) The broker accepted; the ticket is stored Acknowledge only; not proof of a fill
REJECTED (3) Guard or broker refused for good Acknowledge only

Table 4. Journal states.

The final state is always written before the ACK:

//+------------------------------------------------------------------+
//| AckSignal - acknowledge in the same group the signal came from   |
//+------------------------------------------------------------------+
void AckSignal(const string stream_id)                                                      // Redis stream entry ID
  {
   string error;                                                                            // error message holder
   if(!consumer.AckEvent(stream_id, error))                                                 // ACK not confirmed?
      PrintFormat("ACK not confirmed for %s: %s (a pending entry is redelivered and deduplicated)",
                  stream_id, error);                                                        // log; the journal makes redelivery harmless
  }

//+------------------------------------------------------------------+
//| FinishSignal - acknowledge only after the final state is durable |
//| The journal flushes every transition to disk before it returns.  |
//| If that write failed, the signal stays pending, and its next     |
//| delivery is resolved from the journal and the broker's orders.   |
//+------------------------------------------------------------------+
void FinishSignal(const EventEnvelope &signal,                                              // signal being finished
                  const bool written)                                                       // result of the journal write
  {
   if(written)                                                                              // final state on disk?
      AckSignal(signal.stream_id);                                                          // now it is safe to acknowledge
   else
      PrintFormat("Journal write failed for %s; left pending without ACK", signal.event_id); // retried on redelivery
  }

If the write fails, there is no ACK and the signal stays pending. Acknowledging first would let a crash erase the only record of a real trade. ProcessSignal() puts it all together:

//+------------------------------------------------------------------+
//| ProcessSignal - one signal through dedup, guard, and execution   |
//+------------------------------------------------------------------+
void ProcessSignal(const EventEnvelope &signal)                                             // signal to process
  {
   string id = signal.event_id;                                                             // application identity

//--- 0. a signal for another symbol belongs to another EA, which receives its
//---    own copy in its own consumer group: acknowledge it here and skip it
   if(signal.symbol != _Symbol)                                                             // not for this EA?
     {
      AckSignal(signal.stream_id);                                                          // ACK in this EA's group only
      return;                                                                               // no journal entry needed
     }

   ENUM_SIGNAL_STATE state = journal.State(id);                                             // durable state of this signal

//--- 1. final states: this is a duplicate delivery, only the ACK is missing
   if(state == SIGNAL_STATE_ACCEPTED || state == SIGNAL_STATE_REJECTED)                     // already final?
     {
      PrintFormat("Duplicate signal ignored: %s", id);                                      // log duplicate
      AckSignal(signal.stream_id);                                                          // complete the missing ACK
      return;                                                                               // nothing else to do
     }

//--- 2. an attempt was started earlier but its outcome was never recorded
//---    (crash, restart, or an ambiguous broker reply): reconcile first
   if(state == SIGNAL_STATE_ATTEMPTING)                                                     // outcome unknown?
     {
      ulong found_order = 0;                                                                // order found at the broker
      ENUM_RECONCILE found = ReconcileAttempt(signal.symbol, id,
                                              journal.LastUpdate(id), found_order);         // search orders and history
      if(found == RECONCILE_UNKNOWN)                                                        // history not trustworthy now?
        {
         PrintFormat("Cannot reconcile %s yet; left pending", id);                          // log and wait
         return;                                                                            // no ACK: retried next tick
        }
      if(found == RECONCILE_FOUND)                                                          // order already exists?
        {
         PrintFormat("Signal %s already reached the broker (order %I64u); not resending",
                     id, found_order);                                                      // log
         FinishSignal(signal, journal.MarkAccepted(id, found_order));                       // final state first, then ACK
         return;                                                                            // never resend
        }
      PrintFormat("Previous attempt for %s left no order; re-validating", id);              // log and continue
     }

//--- 3. hard constraints (volume is normalized inside the guard)
   double volume = 0.0;                                                                     // output volume from guard
   string reason;                                                                           // textual rejection reason
   if(!guard.ValidateSignal(signal, volume, reason))                                        // run all safety checks
     {
      PrintFormat("Signal rejected: %s | Reason: %s", id, reason);                          // log
      FinishSignal(signal, journal.MarkRejected(id));                                       // final state first, then ACK
      return;                                                                               // move to next signal
     }

//--- 4. execution with explicit outcome classification
   ulong order = 0;                                                                         // broker order ticket
   ENUM_EXEC_OUTCOME outcome = ExecuteOrder(signal, volume, order, reason);                 // attempt submission
   if(outcome == EXEC_ACCEPTED)                                                             // broker accepted the request?
     {
      //--- TEST ONLY: simulate a crash between OrderSend() and the final journal write
      if(TestStopAfterOrderSend)                                                            // test switch on?
        {
         PrintFormat("TEST: stopping after OrderSend() for %s (order %I64u); journal stays ATTEMPTING, no ACK",
                     id, order);                                                            // log the simulated crash
         test_stopping = true;                                                              // skip the rest of this batch
         ExpertRemove();                                                                    // unload the EA, as a crash would
         return;                                                                            // no journal write, no ACK
        }
      FinishSignal(signal, journal.MarkAccepted(id, order));                                // persist the order -> signal link, then ACK
     }
   else
      if(outcome == EXEC_PERMANENT_FAILURE)                                                 // can never succeed?
        {
         PrintFormat("Order permanently rejected for %s: %s", id, reason);                  // log
         FinishSignal(signal, journal.MarkRejected(id));                                    // final state first, then ACK
        }
      else                                                                                  // transient failure
         PrintFormat("Transient failure for %s: %s (left pending; bounded by MaxSignalAgeSeconds)",
                     id, reason);                                                           // leave in the PEL for retry
  }

A signal for another symbol is acknowledged only in this EA's group, because every EA reads every signal in its own group; two EAs on one symbol therefore need different magic numbers. The TestStopAfterOrderSend block is a test switch, off by default, that stops the EA right after an accepted OrderSend() so a crash can be reproduced.


The window between OrderSend() and the journal

ATTEMPTING marks an attempt whose result was never recorded: the EA stopped just before or just after OrderSend(), or the reply was ambiguous, such as TIMEOUT or CONNECTION. A deal is no proof either way, since a PLACED order may not have filled. On redelivery, ReconcileAttempt() searches active orders, then the order history, for this signal's tag, magic number, and symbol. Some brokers replace order comments, so an order without any tag is matched by its setup time; an order with another signal's tag never matches. There are three answers:

  • RECONCILE_FOUND: the order exists and was not rejected, so the signal becomes ACCEPTED and is never resent;
  • RECONCILE_NONE: the history is trustworthy and empty, so the signal goes through the guard again, freshness included;
  • RECONCILE_UNKNOWN: the terminal is disconnected or HistorySelect() failed, so the signal waits; guessing "no order" would cause a duplicate.

This is still a heuristic, and the last section says where it can fail.


Sending the order

ExecuteOrder() runs in a fixed order, so that recovery always finds a journal entry and a tag:

//--- 3. let the terminal check the complete request before anything is recorded;
//---    the call must succeed AND report a success code (0, or DONE)
   MqlTradeCheckResult check = {};                                                          // zero-initialize check result
   bool checked = OrderCheck(request, check);                                               // margin, volume, stops, trade mode
   if(!checked || (check.retcode != 0 && check.retcode != TRADE_RETCODE_DONE))              // call or check failed?
     {
      reason = StringFormat("OrderCheck %s, retcode=%u (%s)",
                            checked ? "returned true" : "failed",
                            check.retcode, check.comment);                                  // describe the outcome
      //--- same rules as OrderSend(): a transient code stays pending, the rest is final
      return(ClassifyRetcode(check.retcode) == EXEC_TRANSIENT_FAILURE ?
             EXEC_TRANSIENT_FAILURE : EXEC_PERMANENT_FAILURE);                              // never "accepted" here
     }
   PrintFormat("OrderCheck passed for %s: retcode=%u (%s)",
               signal.event_id, check.retcode, check.comment);                              // record the success code in the log

//--- 4. audit: no ORDER_ATTEMPTED record, no order (fail closed)
   EventEnvelope ev;                                                                        // build the attempt event
   ev.event_id   = "ORDER_ATTEMPTED:" + signal.event_id + ":" + IntegerToString(attempt);   // deterministic ID
   ev.stream_id  = "";                                                                      // assigned by Redis, never sent
   ev.event_type = EVENT_ORDER_ATTEMPTED;                                                   // tag as attempt event
   ev.source     = "MQL5_EA";                                                               // identify the producer
   ev.event_time = TimeCurrent();                                                           // trade-server clock
   ev.symbol     = signal.symbol;                                                           // carry over symbol
   string payload = "{}";                                                                   // start with empty JSON
   SetPayloadField(payload, "signal_event_id", signal.event_id);                            // causal link to the signal
   SetPayloadField(payload, "attempt", IntegerToString(attempt));                           // attempt number
   SetPayloadField(payload, "direction", direction);                                        // intended direction
   SetPayloadField(payload, "volume", LotsToString(volume));                                // intended size
   SetPayloadField(payload, "magic", IntegerToString((long)MagicNumber));                   // ownership tag
   SetPayloadField(payload, "order_tag", tag);                                              // correlation tag in the order comment
   ev.payload = payload;                                                                    // attach payload to event
   string pub_error;                                                                        // error holder for publish
   if(!publisher.PublishEvent(ev, pub_error))                                               // audit record not confirmed?
     {
      reason = "ORDER_ATTEMPTED could not be published: " + pub_error;                      // describe the block
      return(EXEC_TRANSIENT_FAILURE);                                                       // no order without an audit record
     }

//--- 5. write-ahead: the attempt is durable BEFORE the order leaves the terminal
   if(!journal.MarkAttempting(signal.event_id, attempt))                                    // journal write failed?
     {
      reason = "journal write failed before OrderSend";                                     // describe the block
      return(EXEC_TRANSIENT_FAILURE);                                                       // no order without durable state
     }

//--- 6. refresh the price: publishing may have taken up to two seconds
   if(SymbolInfoTick(signal.symbol, tick))                                                  // fresh tick available?
      request.price = (order_type == ORDER_TYPE_BUY) ? tick.ask : tick.bid;                 // keep the checked price otherwise

//--- 7. send the order to the broker
   ResetLastError();                                                                        // clear any stale error
   bool sent = OrderSend(request, result);                                                  // submit the trade
   order = result.order;                                                                    // broker order ticket (0 if none)

//--- 8. classify by retcode, whatever OrderSend() returned
   ENUM_EXEC_OUTCOME outcome = ClassifyRetcode(result.retcode);                             // accepted, transient or permanent
   if(outcome != EXEC_ACCEPTED)                                                             // not accepted?
     {
      reason = StringFormat("OrderSend %s, retcode=%u (%s), last_error=%d",
                            sent ? "returned true" : "failed", result.retcode,
                            result.comment, GetLastError());                                // describe the outcome
      return(outcome);                                                                      // report the classification
     }

OrderCheck() must both succeed and report a success code. The documentation does not name that code, so the EA accepts 0 or DONE and logs it; the demo run returned 0. A failed check is classified like an OrderSend() retcode but never counts as accepted, and it happens before anything is recorded.

ORDER_ATTEMPTED is fail-closed: if the gateway does not confirm it, no order is sent. An audit trail with gaps could not be trusted to show what the EA sent. Then ATTEMPTING is written, the price refreshed, and the order sent. ClassifyRetcode() sorts the reply:

//+------------------------------------------------------------------+
//| ClassifyRetcode - transient vs permanent broker outcomes         |
//+------------------------------------------------------------------+
ENUM_EXEC_OUTCOME ClassifyRetcode(const uint retcode)                                       // trade server return code
  {
   switch(retcode)                                                                          // dispatch on the return code
     {
      case TRADE_RETCODE_DONE:                                                              // full fill
      case TRADE_RETCODE_DONE_PARTIAL:                                                      // partial fill
      case TRADE_RETCODE_PLACED:                                                            // order accepted, fill pending
         return(EXEC_ACCEPTED);                                                             // final: ACK
      case TRADE_RETCODE_REQUOTE:                                                           // price moved
      case TRADE_RETCODE_PRICE_CHANGED:                                                     // price moved
      case TRADE_RETCODE_PRICE_OFF:                                                         // no quotes right now
      case TRADE_RETCODE_TOO_MANY_REQUESTS:                                                 // throttled
      case TRADE_RETCODE_LOCKED:                                                            // request locked for processing
      case TRADE_RETCODE_FROZEN:                                                            // order or position frozen
      case TRADE_RETCODE_TIMEOUT:                                                           // outcome unknown: reconciled on the next pass
      case TRADE_RETCODE_CONNECTION:                                                        // outcome unknown: reconciled on the next pass
      case TRADE_RETCODE_MARKET_CLOSED:                                                     // session closed: retried until the signal is stale
         return(EXEC_TRANSIENT_FAILURE);                                                    // leave pending
      default:                                                                              // NO_MONEY, INVALID_VOLUME, INVALID_PRICE,
         return(EXEC_PERMANENT_FAILURE);                                                    // TRADE_DISABLED, INVALID_STOPS, REJECT, 0, ...
     }
  }

DONE, DONE_PARTIAL, and PLACED are accepted: accepted by the broker, not necessarily filled, and while a PLACED order is active the guard blocks new entries. Price and load errors are retried; TIMEOUT and CONNECTION stay pending and are reconciled before any resend.

MARKET_CLOSED is transient, because a closed session is a temporary condition: the signal is retried until it is older than MaxSignalAgeSeconds, then rejected as stale, so a signal from a closed session is dropped after a minute, not executed at the reopen. Everything else, such as NO_MONEY, is final. The same age limit bounds every retry, which is why one day of journal history is plenty.


Setting up the environment

By now the design is complete on paper. Running it is short work, but the order in which the components start matters more than any version. Redis must be 5.0 or later, where consumer groups appeared; redis-cli ping should answer PONG. Python needs one package, installed with pip install redis. In MetaTrader 5, add http://127.0.0.1:8000 under Tools > Options > Expert Advisors > Allow WebRequest for listed URL, and switch Algo Trading on.

Then start python gateway.py, which prints "Gateway running on port 8000", and python strategy_consumer.py, before attaching the EA, because the strategy's group starts at $ and would miss earlier bars. Finally, redis-cli XINFO GROUPS signal.events should list mql5-{symbol}-{magic}, and XINFO GROUPS market.events the group strategy.

My own first run still failed, for a configuration reason: plain EURUSD was not tradeable on the demo account, and the guard said so in the log until the EA moved to EURUSD#.

The defaults deserve a closer look, because several of them are design decisions in disguise. Every setting is an EA input or a code constant. Each default is a deliberate constraint, a localhost or demo example, a broker-specific value, or a safe placeholder (Table 5).

Input Default Kind Why
GatewayURL http://127.0.0.1:8000 Localhost example Loopback only; any port works if the WebRequest list matches
TimerIntervalSeconds 2 Deliberate constraint Signals arrive within about two seconds of the bar; faster polling only adds load
MaxSignalAgeSeconds 60 Deliberate constraint One M1 bar; also bounds every retry; raise it on higher timeframes
MaxSpreadPoints 50 Broker-specific value Five pips on a five-digit major; tune per instrument
DefaultVolume 0.10 Demo example Normalized to the broker's step anyway
MagicNumber 123456 Safe placeholder Must be unique per EA: ownership, group, and journal depend on it

Table 5. EA inputs, the kind of each default, and why.

The code constants follow the same logic. The 2000 ms WebRequest() timeout assumes a local gateway, where a slower reply means a failure. The batch of 10 signals per tick bounds the work per tick. The 10 points of slippage are broker-specific, one pip on a five-digit symbol. The one-day JOURNAL_RETENTION far exceeds any signal's useful life.

On the Python side, port 8000 and localhost:6379 (REDIS_HOST, REDIS_PORT) are local defaults, NEW_READ_BLOCK_MS of 1000 keeps the strategy loop responsive without BLOCK 0, and STRATEGY_VERSION, simple-1, is part of every signal ID so a changed strategy never reuses old IDs. TestStopAfterOrderSend stays off outside testing. The journal itself is state, not configuration: deleting it only removes protection for signals that are still fresh.


Testing on a demo account

With that in place, the prototype was run for real. The tests ran on a demo hedging account, on M1, with MetaTrader 5 under Wine on Linux Mint and Redis and Python on the same machine. The first run was supposed to show the normal path. Instead, the log repeated two lines every two seconds:

16:46:37.850  Duplicate signal ignored: SIGNAL:BAR_CLOSED:EURUSD:M1:1792593480:simple-1
16:46:37.850  ACK not confirmed for 1792582741224-0: HTTP error code: 1003 (a pending entry is redelivered and deduplicated)
16:46:39.879  Duplicate signal ignored: SIGNAL:BAR_CLOSED:EURUSD:M1:1792593480:simple-1
16:46:39.880  ACK not confirmed for 1792582741224-0: HTTP error code: 1003 (a pending entry is redelivered and deduplicated)

A signal handled in an earlier run was in the journal, but its ACK never got through, so Redis returned it more than 60 times in just over two minutes. The journal caught it every time, nothing was processed twice, and no failed ACK was counted as a success. The duplicate protection held under a transport fault the design had not anticipated.

The cause turned out to be more interesting than the symptom. Comparing the Experts log with the gateway's request log second by second showed that none of the failing requests reached the gateway, and that failures alternated exactly with successes. That pattern points to connection reuse: the terminal kept a connection the gateway had already closed, the first request on it failed, and the next one succeeded on a fresh connection. After Connection: close, no 1003 error appeared again.

The next run, on EURUSD#, was the clean 17:03 cycle shown earlier. The gateway's request log, switched on for that run and condensed here, confirms the order of events:

17:03:00  POST /publish   BAR_CLOSED
17:03:01  GET  /consume   the signal is returned
17:03:01  POST /publish   ORDER_ATTEMPTED
17:03:02  POST /ack       the signal is acknowledged
17:03:02  POST /publish   DEAL_EXECUTED

ORDER_ATTEMPTED left before the order did, and the ACK came only after the broker's accepted reply.

The following night, a position from an earlier session was still open when the EA started, so the guard refused every signal. It recognized that position purely by its magic number:

01:21:00.928  Published BAR_CLOSED event: BAR_CLOSED:EURUSD#:M1:1792632000
01:21:01.046  Signal rejected: SIGNAL:BAR_CLOSED:EURUSD#:M1:1792632000:simple-1 | Reason: EA-owned position already exists on EURUSD#

Each refusal was journaled as state 3 before the ACK, and XINFO GROUPS showed pending 0 throughout:

SIGNAL:BAR_CLOSED:EURUSD#:M1:1792632000:simple-1|3|1792632060|0|0
SIGNAL:BAR_CLOSED:EURUSD#:M1:1792632060:simple-1|3|1792632120|0|0
SIGNAL:BAR_CLOSED:EURUSD#:M1:1792632120:simple-1|3|1792632186|0|0

Finally, I added two broken records with redis-cli, one without an event_id and one with braces in it:

XADD signal.events * event_type SIGNAL_GENERATED source manual_test event_time 1 symbol EURUSD# payload {}
XADD signal.events * event_id BAD{ID} event_type SIGNAL_GENERATED source manual_test event_time 1 symbol EURUSD# payload {}

The gateway turned both away on the EA's next poll:

WARNING Rejected signal.events/1792622276177-0: missing or empty event_id
WARNING Rejected signal.events/1792622309263-0: event_id contains characters the MQL5 parser cannot read

Both went to the dead-letter stream with their reason and original content, as XRANGE signal.events.dlq - + shows:

1) 1) "1792622277040-0"
   2) 1) "original_stream"
      2) "signal.events"
      3) "original_stream_id"
      4) "1792622276177-0"
      5) "reason"
      6) "missing or empty event_id"
      7) "record"
      8) "{\"event_type\":\"SIGNAL_GENERATED\",\"source\":\"manual_test\",\"event_time\":\"1\",\"symbol\":\"EURUSD#\",\"payload\":\"{}\"}"

The EA's group had nothing pending afterwards, and the Experts log has no line for either record:

1)  1) "name"
    2) "mql5-EURUSD_-123456"
    3) "consumers"
    4) (integer) 1
    5) "pending"
    6) (integer) 0


Crash recovery and the paths not seen in testing

The crash between OrderSend() and the journal write was not staged live. It was checked with unit tests that run the EA's own ProcessSignal(), ExecuteOrder(), and ReconcileAttempt(), code unchanged, outside the terminal, with OrderCheck(), OrderSend(), and the order history replaced by a simulation. They cover the crash (ATTEMPTING, no ACK), the restart that finds the order (ACCEPTED, then the ACK, no second order), an unreadable history (UNKNOWN, not "no order"), and a failed write after an accepted order (no ACK).

To see it live, set TestStopAfterOrderSend to true with no EA position open and wait for a signal. The EA sends the order, logs "TEST: stopping after OrderSend()", and removes itself, leaving the journal at ATTEMPTING and the signal pending. Attach it again with the switch off, and the log should report that the signal "already reached the broker" with the same order ticket, and exactly one position should be open.

Partial fills, OrderCheck() refusals, and temporary broker refusals such as MARKET_CLOSED follow from the code above but did not occur in these runs; each leaves its own line in the Experts log. Behind every path is one rule: a failure is either final, and then it is written down and acknowledged, or it is not, and the signal stays pending until it succeeds, goes stale, or is reconciled. Nothing is acknowledged on hope.


Guarantees and limitations of the prototype

The tests show what the prototype does. The remaining question is what it promises, and as a research prototype, some of its guarantees are intentionally limited.

Under four assumptions (a magic number no other program uses, an available order history, an intact journal, and at most one EA-owned position or active order per symbol), the prototype:

  • never acts again on a signal whose final state is journaled, and reconciles an interrupted attempt with the broker before any resend;
  • acknowledges only after the final state is on disk, and only counts an ACK that Redis confirms;
  • dead-letters every record that breaks the contract, with its reason;
  • publishes ORDER_ATTEMPTED before every order, and sends no order without it;
  • keeps volume, checks, and ownership in MQL5.

The limitations matter just as much:

  • Delivery is at-least-once; the EA, not Redis, filters repeats.
  • Reconciliation is a heuristic: an order the server accepts late, or the terminal shows late after a reconnect, can be missed and resent. The EA does not reconcile while disconnected, and the active-order check blocks a resend whenever the first order is already visible, but neither closes the gap completely. The tag-less time fallback can mistake another EA-owned order for the attempt, which is safe because nothing is resent.
  • ACCEPTED is not a fill: a cancelled PLACED order is not retried, and only the missing DEAL_EXECUTED shows it.
  • Without the journal, a restart loses protection for signals that are still fresh.
  • Fail-closed auditing costs availability: no ORDER_ATTEMPTED, no order.
  • Nothing times out while the EA or gateway is stopped, and pending entries are resolved when next read; there is no XAUTOCLAIM or XCLAIM, and no DLQ tooling beyond XRANGE.
  • XACK does not say why it removed nothing, so the EA treats every such case as unconfirmed.
  • WebRequest() is synchronous, so a slow gateway blocks OnTimer() for up to two seconds per request, and the single-threaded gateway suits a few EAs on one machine.
  • On netting accounts the EA cannot share its symbol; each extra EA reads every signal again, and a new group sees only later signals.
  • There is no authentication. A local process can forge signals; each is treated like a real one and can open at most one EA-owned position or order of the configured size at a time, and another once that position is closed or that order is cancelled. Binding to 127.0.0.1 keeps out the network, not local processes. Production needs Redis ACLs, gateway authentication, and signed signals.
  • The live tests ran only under Wine on Linux, not on a Windows terminal.

These limits are deliberate scope choices for a research prototype, not unnoticed defects.


Conclusion

The prototype does not replace polling. What it replaces is ambiguity. The EA still polls every two seconds, but every answer now has a defined meaning: a signal has an identity that survives restarts, an ACK counts only when Redis confirms it and the EA has written down what it did, unusable messages leave through the dead-letter stream, and every broker reply is accepted, retried, or final.

Redis Streams gives the system an explicit delivery model, while MQL5 remains the only component allowed to decide whether a trade is valid, safe, and actually sendable. That boundary is the point of the design. The next steps are cross-consumer recovery with XAUTOCLAIM, signed and authenticated signals, an asynchronous transport, and a complete JSON layer.

Project Files:

# Name Type Description
1 EventTypes.mqh Include Event types and the EventEnvelope struct
2 EventSerializer.mqh Include Constrained JSON serializer and payload helpers
3 EventPublisher.mqh Include Posts events to the gateway through WebRequest()
4 EventConsumer.mqh Include Consumes, acknowledges, and rejects signals in the EA's own group; counts an ACK only on "acknowledged":true
5 ExecutionGuard.mqh Include Hard checks before execution, including freshness, ownership, spread, and volume normalization
6 SignalJournal.mqh Include Append-only, flushed journal of signal states and the order-to-signal link
7 ReactiveEA.mq5 Expert Main EA: deduplication, ACK after the journal write, reconciliation, OrderCheck() before the fail-closed ORDER_ATTEMPTED, retcode classification, DEAL_EXECUTED, and the test switch
8 gateway.py Python HTTP gateway for /publish, /consume, /ack, and /reject; enforces the contract, dead-letters violations, reports XACK results, sends Connection: close
9 redis_stream.py Python Redis Streams wrapper; ignores only BUSYGROUP, never sends BLOCK 0
10 strategy_consumer.py Python Validates complete OHLC bars and publishes SIGNAL_GENERATED with the bar's server time
Attached files |
MQL5.zip (40.27 KB)
Testing Random Walk vs Mean Reversion in MQL5: Implementing the Lo-MacKinlay Variance Ratio Test Testing Random Walk vs Mean Reversion in MQL5: Implementing the Lo-MacKinlay Variance Ratio Test
Implements the Lo–MacKinlay Variance Ratio Test in MQL5 to assess serial dependence via random‑walk variance scaling. Provides a reusable CVarianceRatio class with classical (homoskedasticity) and heteroskedasticity‑robust statistics, plus a script that scans multiple horizons and logs VR and Z‑values. Shows how to embed the test in an Expert Advisor as a statistical gate for mean‑reversion logic.
From Evidence to Execution (Part 1): How to Log, Measure, and Decode a Trading Strategy Inside MetaTrader 5 AI From Evidence to Execution (Part 1): How to Log, Measure, and Decode a Trading Strategy Inside MetaTrader 5 AI
An evidence-first workflow for Range Breakout in MQL5: a non-trading logger captures session/time windows and records 61 fields per breakout to CSV, with a strict split between entry features and hindsight labels. The MT5 AI Assistant processes the dataset in nine phases, helping validate data, quantify baselines, and locate stable, entry-usable thresholds for subsequent EA coding.
From Basic to Intermediate: Subwindows (II) From Basic to Intermediate: Subwindows (II)
This article examines the use of subwindows in MetaTrader 5 in detail and shows how the calculation direction in OnCalculate affects indicator buffers and the plotting of averages. Practical examples explain how indicators are linked using FIRST INDICATOR’S DATA and PREVIOUS INDICATOR’S DATA, the effect of cascading removal of dependent indicators, and how indicator_separate_window behaves. Readers will learn how to diagnose display issues and create indicators that work correctly in subwindows.
Archimedes Optimization Algorithm (AOA) Archimedes Optimization Algorithm (AOA)
The article examines the Archimedes Optimization Algorithm — a metaheuristic in which an agent is represented as a physical object with density, volume, and acceleration, and the search itself is reinterpreted as the tendency of bodies submerged in a fluid to reach equilibrium. The balance between exploration and exploitation is not dictated by an external schedule here, but rather stems from the physics of decaying turbulence. We will implement the algorithm in MQL5, test it on a standard test bench, and analyze where this approach works.