preview
Streaming MetaTrader 5 Trade Events to a Local HTTP Server Using WinINet in MQL5

Streaming MetaTrader 5 Trade Events to a Local HTTP Server Using WinINet in MQL5

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

Introduction

A trading system rarely operates in isolation. Risk dashboards, analytics pipelines, algorithmic bridges, and trade journals all need to know the moment a position opens or closes — without polling the terminal's History tab or scraping a log file after the fact. What they need is a push notification: the terminal itself announces each trade event to a waiting consumer the instant it occurs.

MQL5 provides OnTradeTransaction() for exactly this purpose — a callback that fires the moment a deal is confirmed on the account. The challenge is getting that notification out of the terminal and into another process in real time. MQL5's built-in WebRequest() function can send HTTP requests, but it blocks the thread until the request completes, which means a slow or temporarily unreachable server stalls the EA's entire event loop. It also requires the target URL to be whitelisted in the terminal's settings dialog, which is inconvenient for any deployment that needs to change endpoints.

This article takes a different approach: it calls Windows Internet (wininet.dll) directly from MQL5 using #import. This DLL ships with every version of Windows, requires no installation, and gives complete control over the HTTP pipeline. A persistent session handle is opened once and reused for the lifetime of the EA. A small in-memory queue decouples the trade callback from the delivery attempt, so OnTradeTransaction() always returns immediately regardless of what the server is doing. The result is an EA that serializes every position open and close as JSON and delivers it to a local HTTP server with retry on failure. It logs one clear message when the server goes down and another when it recovers. During an outage, it keeps the Experts tab readable by logging each newly queued trade instead of repeating the same error lines.

Architecture diagram

Figure 1: Architecture diagram. A six-stage pipeline shows trade events flowing from the MetaTrader 5 terminal through OnTradeTransaction(), TradeEventJson, EventQueue, and CHttpPoster (via WinINet) to the local HTTP server. The dashed retry path keeps failed events at the front of the queue until HTTP 200 is confirmed.


Why WinINet Instead of WebRequest

WebRequest() is synchronous and thread-blocking. When it is called inside OnTradeTransaction(), the terminal's event loop cannot process any further events until the HTTP exchange completes. If the receiving server is slow, temporarily unreachable, or accepting connections but not responding, the EA blocks for the full request timeout — potentially several seconds. On a demo account this is merely annoying; on a live account it can cause subsequent trade events to be missed.

WinINet via #import is also synchronous. However, the HTTP call runs in OnTimer(), not in OnTradeTransaction(), so the trade callback is never blocked. The callback builds a JSON payload and drops it into a queue — an operation that takes microseconds — and returns. The timer callback, firing independently every 500 milliseconds, drains the queue by attempting delivery. The trade callback and the HTTP delivery are completely decoupled.

Calling WinINet directly also means no URL whitelist is required. The terminal's DLL import permission is the only setting that must be enabled.


The WinINet Three-Handle Pipeline

WinINet organizes HTTP access as a hierarchy of three handle types, each created from its parent.

The session handle, created by InternetOpenW(), represents the logical network session and carries configuration that applies to all requests made through it, including the user-agent string. This handle is expensive to create because it initialises the underlying network stack; the EA creates it once in OnInit() and reuses it for every subsequent call.

The connection handle, created by InternetConnectW() for a specific hostname and port, establishes the target connection parameters. A fresh connection handle is opened for each POST because HTTP/1.0 servers — including the Python reference server used for validation — close the TCP connection after each response. Reusing a stale connection handle would cause a silent failure on the second request.

The request handle, created by HttpOpenRequestW() for a specific HTTP verb and resource path, carries the request itself. Calling HttpSendRequestW() on this handle puts the bytes on the wire. After the response arrives, HttpQueryInfoW() retrieves the numeric HTTP status code. All three handles must be closed with InternetCloseHandle() when they are no longer needed.


The Queue and the Logging Design

OnTradeTransaction() must return immediately: Calling PostJson() directly inside it would freeze the terminal if the server were slow. Instead, the callback serializes the event to JSON, enqueues it, and returns. OnTimer() drains the queue on each tick.

When a delivery fails, the EA needs to handle logging carefully: The naive approach of printing on every failed timer tick produces dozens of identical lines per minute with no new information, rendering the Experts tab unreadable during any outage. The approach used here has three distinct log states:

Server goes down: On the first failed delivery, the EA prints one line describing the failure and the number of events now waiting. It then sets an internal flag g_server_down and mutes CHttpPoster's own internal diagnostic prints by calling SetSilent(true) on it. All subsequent retry attempts are completely silent.

Trade arrives during outage: If OnTradeTransaction() fires while g_server_down is true, the event is enqueued and a single log line is printed showing the event type, symbol, ticket, and the running total of events now pending. This gives the trader a clear running count of activity accumulating in the queue.

Server recovers: The moment the first successful delivery is confirmed, a single recovery summary is printed: how many events were flushed in that timer tick and how many, if any, remain. The silent flag is cleared so future unexpected WinINet errors are visible again.


Implementation — WinINet.mqh

WinINet.mqh contains only #import declarations for the five WinINet functions used in this article. Placing them in a dedicated include file keeps the DLL import block separate from the application logic and allows all source files to share the same declarations.

//+------------------------------------------------------------------+
//|                                                      WinINet.mqh |
//+------------------------------------------------------------------+
#ifndef WININET_MQH
#define WININET_MQH

//+------------------------------------------------------------------+
//| DLL imports must be enabled in the terminal:                     |
//|   Tools > Options > Expert Advisors > Allow DLL imports          |
//+------------------------------------------------------------------+
#import "wininet.dll"

//--- opens a top-level internet session handle; must be the first
//--- WinINet call and its return value is passed to all subsequent
//--- calls. Returns NULL (0) on failure.
long InternetOpenW(
   string   lpszAgent,      // user-agent string shown to the server
   uint     dwAccessType,   // 1 = INTERNET_OPEN_TYPE_DIRECT
   string   lpszProxy,      // NULL for direct connections
   string   lpszProxyBypass,// NULL
   uint     dwFlags         // 0 = synchronous mode
);

//--- opens a connection to a specific HTTP host on a specific port.
//--- Returns NULL on failure; parent is the session handle from
//--- InternetOpenW.
long InternetConnectW(
   long     hInternet,      // session handle
   string   lpszServerName, // hostname, e.g. "127.0.0.1"
   ushort   nServerPort,    // port number, e.g. 8787
   string   lpszUserName,   // NULL = no authentication
   string   lpszPassword,   // NULL
   uint     dwService,      // 3 = INTERNET_SERVICE_HTTP
   uint     dwFlags,        // 0
   ulong    dwContext        // 0 = not used
);

//--- creates an HTTP request handle for a specific verb + resource.
//--- Returns NULL on failure; parent is the connection handle.
long HttpOpenRequestW(
   long     hConnect,       // connection handle
   string   lpszVerb,       // "POST"
   string   lpszObjectName, // resource path, e.g. "/trade_event"
   string   lpszVersion,    // NULL = HTTP/1.1
   string   lpszReferrer,   // NULL
   string   lplpszAcceptTypes, // NULL
   uint     dwFlags,        // 0x00800000 = INTERNET_FLAG_RELOAD (no cache)
   ulong    dwContext        // 0
);

//--- sends the open request. lpszHeaders contains the raw header
//--- block (including Content-Type and Content-Length). lpOptional
//--- contains the request body bytes.
bool HttpSendRequestW(
   long     hRequest,       // request handle
   string   lpszHeaders,    // additional headers
   uint     dwHeadersLength,// length of headers string in chars
   uchar   &lpOptional[],   // request body as a byte array
   uint     dwOptionalLength// length of request body in bytes
);

//--- queries a DWORD attribute of the last HTTP response.
//--- Used to retrieve the HTTP status code after sending.
bool HttpQueryInfoW(
   long     hRequest,       // request handle
   uint     dwInfoLevel,    // 32 | 0x20000000 = HTTP_QUERY_STATUS_CODE|AS_NUMBER
   uint    &lpBuffer,       // receives the status code
   uint    &lpdwBufferLength,// in: buffer size; out: bytes written
   uint    &lpdwIndex       // 0 = first header occurrence
);

//--- closes any WinINet handle (session, connection, or request).
//--- Must be called for every handle obtained above to avoid leaks.
bool InternetCloseHandle(long hInternet);

#import
#endif // WININET_MQH
//+------------------------------------------------------------------+

InternetOpenW() initialises the network session. dwAccessType = 1 is INTERNET_OPEN_TYPE_DIRECT, which bypasses any configured proxy and connects directly. dwFlags = 0 selects synchronous mode, where each WinINet call blocks until the network operation completes.

InternetConnectW() specifies the target host. dwService = 3 is INTERNET_SERVICE_HTTP. The username and password are NULL because the local server needs no authentication.

HttpOpenRequestW() creates the request. dwFlags = 0x80000000 is INTERNET_FLAG_RELOAD, which instructs WinINet to bypass its response cache and always contact the server rather than returning a cached response. Without this flag, a second POST to the same URL with the same headers could be silently served from cache.

HttpSendRequestW() transmits the request. The lpOptional parameter is the raw body byte array, and dwOptionalLength is its length. Additional headers are passed in lpszHeaders as a block of \r\n-terminated strings that WinINet appends to the standard HTTP/1.1 headers it generates internally.

HttpQueryInfoW() reads the response status. The constant 19 | 0x20000000 combines HTTP_QUERY_STATUS_CODE with HTTP_QUERY_FLAG_NUMBER, which writes the status code as a 32-bit integer into the uint buffer rather than as a string.

InternetCloseHandle() closes any type of handle. The same function covers session, connection, and request handles.


Implementation — HttpPoster.mqh

CHttpPoster wraps the three-handle WinINet pipeline behind a single PostJson() method. It owns the session handle, opening it once in Init() and closing it in Deinit(). Connection and request handles are created and destroyed inside each PostJson() call. A SetSilent() method allows the EA to suppress internal diagnostic prints during a known server outage, so that the Experts tab is only written to from the EA's own logging logic.

//+------------------------------------------------------------------+
//|                                                    HttpPoster.mqh|
//+------------------------------------------------------------------+
#ifndef HTTPPOSTER_MQH
#define HTTPPOSTER_MQH

#include "WinINet.mqh"
//+------------------------------------------------------------------+
//| Wraps the three-handle WinINet pipeline (session → connection →  |
//| request) behind a single PostJson() method. The session handle   |
//| is opened once in Init() and reused for every subsequent POST,   |
//| which avoids the overhead of re-negotiating a new session on     |
//| every trade event.                                               |
//+------------------------------------------------------------------+
class CHttpPoster
  {
private:
   string            m_host;        // hostname or IP, e.g. "127.0.0.1"
   ushort            m_port;        // TCP port, e.g. 8787
   string            m_path;        // resource path, e.g. "/trade_event"
   long              m_hsession;    // WinINet session handle, opened in Init()
   int               m_last_status; // HTTP status code from the last call
   bool              m_silent;      // when true, suppresses internal error prints

   bool              OpenHandles(long &hconn, long &hreq) const;

public:
                     CHttpPoster(void);
                    ~CHttpPoster(void);

   bool              Init(const string &host, ushort port, const string &path,
                          const string &user_agent);
   bool              PostJson(const string &json_body);
   int               LastStatus(void) const;
   void              SetSilent(bool silent);
   void              Deinit(void);
  };
//+------------------------------------------------------------------+
//| Constructor — sets all fields to safe initial values.            |
//+------------------------------------------------------------------+
CHttpPoster::CHttpPoster(void)
  {
   m_host        = "";
   m_port        = 8787;
   m_path        = "/trade_event";
   m_hsession    = 0;
   m_last_status = 0;
   m_silent      = false;
  }
//+------------------------------------------------------------------+
//| Destructor — closes the session handle if still open.            |
//+------------------------------------------------------------------+
CHttpPoster::~CHttpPoster(void)
  {
   Deinit();
  }

m_silent is the key addition for outage-period logging control. When true, every PrintFormat inside OpenHandles() and PostJson() is skipped. The return values are unaffected — the class still reports success or failure accurately to the caller; it simply stops writing to the Experts tab.

Init()

//+------------------------------------------------------------------+
//| Opens the WinINet session handle and stores configuration.       |
//| The session is kept open for the lifetime of the EA so that      |
//| subsequent PostJson() calls can reuse it without reopening.      |
//+------------------------------------------------------------------+
bool CHttpPoster::Init(const string &host, ushort port, const string &path,
                       const string &user_agent)
  {
   m_host = host;
   m_port = port;
   m_path = path;

//--- INTERNET_OPEN_TYPE_DIRECT = 1; bypasses any proxy for local connections
   m_hsession = ::InternetOpenW(user_agent, 1, NULL, NULL, 0);
   if(m_hsession == 0)
     {
      ::PrintFormat("CHttpPoster::Init: InternetOpenW failed (GetLastError=%d)",
                    ::GetLastError());
      return(false);
     }
   return(true);
  }

InternetOpenW() returns zero on failure. The most common cause at this stage is that DLL imports are not enabled in the terminal's security settings. GetLastError() retrieves the OS-level error code that WinINet recorded before returning.

SetSilent()

//+------------------------------------------------------------------+
//| Enables or disables suppression of internal diagnostic prints.   |
//| When silent is true, PostJson() and OpenHandles() do not write   |
//| to the Experts tab on failure; the caller handles all logging.   |
//+------------------------------------------------------------------+
void CHttpPoster::SetSilent(bool silent)
  {
   m_silent = silent;
  }

This method exists so the EA can keep all user-facing logging in TradeEventStreamer.mq5. It prevents internal class diagnostics from writing to the Experts tab independently. The EA calls SetSilent(true) at the moment it decides a server outage has occurred, so that every subsequent retry attempt inside PostJson() contributes nothing to the log, and then calls SetSilent(false) the moment delivery succeeds to restore visibility for any genuinely unexpected errors that might occur later.

OpenHandles()

//+------------------------------------------------------------------+
//| Opens a fresh connection handle and request handle for one POST. |
//| Returns false if either handle cannot be obtained.               |
//+------------------------------------------------------------------+
bool CHttpPoster::OpenHandles(long &hconn, long &hreq) const
  {
//--- INTERNET_SERVICE_HTTP = 3; credentials are NULL (no auth)
   hconn = ::InternetConnectW(m_hsession, m_host, m_port, NULL, NULL, 3, 0, 0);
   if(hconn == 0)
     {
      if(!m_silent)
         ::PrintFormat("CHttpPoster::OpenHandles: InternetConnectW failed (GetLastError=%d)",
                       ::GetLastError());
      return(false);
     }

//--- INTERNET_FLAG_RELOAD = 0x80000000: bypasses the WinINet cache so
//--- every POST actually reaches the server rather than being served
//--- from a cached response
   hreq = ::HttpOpenRequestW(hconn, "POST", m_path, NULL, NULL, NULL,
                             0x80000000, 0);
   if(hreq == 0)
     {
      if(!m_silent)
         ::PrintFormat("CHttpPoster::OpenHandles: HttpOpenRequestW failed (GetLastError=%d)",
                       ::GetLastError());
      ::InternetCloseHandle(hconn);
      hconn = 0;
      return(false);
     }
   return(true);
  }

If HttpOpenRequestW() fails, the connection handle that was successfully obtained is closed before returning so no handle is leaked on the partial-failure path. Both print calls are guarded by !m_silent.

PostJson()

//+-------------------------------------------------------------------+
//| Posts json_body to the configured endpoint as an HTTP POST with   |
//| Content-Type: application/json. Returns true if the server        |
//| replies with HTTP 200. The connection and request handles are     |
//| opened fresh and closed within this call; only the session handle |
//| persists across calls.                                            |
//+-------------------------------------------------------------------+
bool CHttpPoster::PostJson(const string &json_body)
  {
   if(m_hsession == 0)
     {
      if(!m_silent)
         ::Print("CHttpPoster::PostJson: not initialized — call Init() first");
      return(false);
     }

   long hconn = 0;
   long hreq  = 0;
   if(!OpenHandles(hconn, hreq))
      return(false);

//--- convert the JSON string to a UTF-8 byte array; StringToCharArray
//--- writes a null terminator at the end, which must be excluded from
//--- the byte count passed to HttpSendRequestW
   uchar body_bytes[];
   int body_len = ::StringToCharArray(json_body, body_bytes, 0, -1, CP_UTF8) - 1;
   if(body_len < 0)
      body_len = 0;

//--- build the header block: Content-Type and Content-Length in one
//--- CRLF-terminated string; HttpSendRequestW appends these to the
//--- standard HTTP/1.1 headers it generates internally
   string headers = "Content-Type: application/json\r\n"
                    + "Content-Length: " + ::IntegerToString(body_len) + "\r\n";

   bool sent = ::HttpSendRequestW(hreq, headers, ::StringLen(headers),
                                  body_bytes, (uint)body_len);

   m_last_status = 0;
   if(sent)
     {
      //--- HTTP_QUERY_STATUS_CODE = 19; combined with HTTP_QUERY_FLAG_NUMBER
      //--- (0x20000000) it writes the numeric status code into a DWORD buffer
      uint status = 0;
      uint status_len = 4;
      uint index = 0;
      if(::HttpQueryInfoW(hreq, 19 | 0x20000000, status, status_len, index))
         m_last_status = (int)status;
     }
   else
     {
      if(!m_silent)
         ::PrintFormat("CHttpPoster::PostJson: HttpSendRequestW failed (GetLastError=%d)",
                       ::GetLastError());
     }

//--- close per-request handles regardless of success or failure
   ::InternetCloseHandle(hreq);
   ::InternetCloseHandle(hconn);

   return(sent && m_last_status == 200);
  }

StringToCharArray() with CP_UTF8 converts the MQL5 Unicode string to a UTF-8 byte array. The function appends a null terminator and includes it in the returned count; subtracting one gives the actual body length to pass to HttpSendRequestW(). The method returns true only when both sent is true and m_last_status is exactly 200, treating any other status code as a delivery failure that should remain in the queue.

LastStatus() and Deinit()

//+------------------------------------------------------------------+
//| Returns the HTTP status code from the most recent PostJson call. |
//+------------------------------------------------------------------+
int CHttpPoster::LastStatus(void) const
  {
   return(m_last_status);
  }
//+------------------------------------------------------------------+
//| Closes the session handle. Called by the destructor and by the   |
//| EA's OnDeinit() to release WinINet resources cleanly.            |
//+------------------------------------------------------------------+
void CHttpPoster::Deinit(void)
  {
   if(m_hsession != 0)
     {
      ::InternetCloseHandle(m_hsession);
      m_hsession = 0;
     }
  }

LastStatus() lets the EA log the specific HTTP status code when delivery fails, distinguishing a network-level failure (status 0) from a server-side error (status 4xx or 5xx). Deinit() is guarded so it is safe to call multiple times.


Implementation — TradeEventJson.mqh

TradeEventJson.mqh provides two free functions that assemble the JSON payloads, plus the EscapeJsonString() helper they share. JSON is built manually because MQL5 has no standard JSON library, and the fixed schema is straightforward enough that manual construction is safer than a generic serializer.

//+------------------------------------------------------------------+
//|                                               TradeEventJson.mqh |
//+------------------------------------------------------------------+
#ifndef TRADEEVENTJSON_MQH
#define TRADEEVENTJSON_MQH

//+------------------------------------------------------------------+
//| Escapes double-quote and backslash characters in a string so it  |
//| can be safely embedded as a JSON string value. The backslash     |
//| must be replaced first; replacing it second would double-escape  |
//| the backslashes that were inserted during quote escaping.        |
//+------------------------------------------------------------------+
string EscapeJsonString(const string &s)
  {
   string r = s;
   ::StringReplace(r, "\\", "\\\\"); // backslash → \\ (must come first)
   ::StringReplace(r, "\"", "\\\""); // double-quote → \"
   return(r);
  }
//+------------------------------------------------------------------+
//| Builds the JSON body for a TRADE_OPEN event. Called from         |
//| OnTradeTransaction() when a new position is detected. All        |
//| numeric fields use enough decimal places to faithfully represent |
//| the values the terminal stores: 5 decimals for prices and        |
//| SL/TP, 2 for volume, and exact integers for ticket, magic, and   |
//| server time.                                                     |
//+------------------------------------------------------------------+
string BuildOpenJson(ulong ticket, const string &symbol,
                     ENUM_POSITION_TYPE ptype, double volume,
                     double price, double sl, double tp,
                     long magic, const string &comment,
                     datetime server_time)
  {
   string sym_esc = EscapeJsonString(symbol);
   string cmt_esc = EscapeJsonString(comment);
   string type_str = (ptype == POSITION_TYPE_BUY) ? "BUY" : "SELL";

   string j = "{";
   j += "\"event\":\"TRADE_OPEN\",";
   j += "\"ticket\":"       + ::IntegerToString((long)ticket)    + ",";
   j += "\"symbol\":\""     + sym_esc                            + "\",";
   j += "\"type\":\""       + type_str                           + "\",";
   j += "\"volume\":"       + ::DoubleToString(volume, 2)        + ",";
   j += "\"price\":"        + ::DoubleToString(price, 5)         + ",";
   j += "\"sl\":"           + ::DoubleToString(sl, 5)            + ",";
   j += "\"tp\":"           + ::DoubleToString(tp, 5)            + ",";
   j += "\"magic\":"        + ::IntegerToString(magic)           + ",";
   j += "\"comment\":\""    + cmt_esc                            + "\",";
   j += "\"server_time\":"  + ::IntegerToString((long)server_time);
   j += "}";
   return(j);
  }
//+------------------------------------------------------------------+
//| Builds the JSON body for a TRADE_CLOSE event. Called from        |
//| OnTradeTransaction() when a completed deal with DEAL_ENTRY_OUT   |
//| is detected in the history. Profit, swap, and commission are     |
//| included because the consuming application typically needs the   |
//| full net result, not just the raw price-movement profit.         |
//+------------------------------------------------------------------+
string BuildCloseJson(ulong deal_ticket, ulong position_ticket,
                      const string &symbol,
                      ENUM_DEAL_TYPE deal_type, double volume,
                      double open_price, double close_price,
                      double profit, double swap, double commission,
                      long magic, const string &comment,
                      datetime server_time)
  {
   string sym_esc = EscapeJsonString(symbol);
   string cmt_esc = EscapeJsonString(comment);
   string type_str = (deal_type == DEAL_TYPE_BUY) ? "BUY" : "SELL";

   string j = "{";
   j += "\"event\":\"TRADE_CLOSE\",";
   j += "\"deal_ticket\":"      + ::IntegerToString((long)deal_ticket)      + ",";
   j += "\"position_ticket\":"  + ::IntegerToString((long)position_ticket)  + ",";
   j += "\"symbol\":\""         + sym_esc                                   + "\",";
   j += "\"type\":\""           + type_str                                  + "\",";
   j += "\"volume\":"           + ::DoubleToString(volume, 2)               + ",";
   j += "\"open_price\":"       + ::DoubleToString(open_price, 5)           + ",";
   j += "\"close_price\":"      + ::DoubleToString(close_price, 5)          + ",";
   j += "\"profit\":"           + ::DoubleToString(profit, 2)               + ",";
   j += "\"swap\":"             + ::DoubleToString(swap, 2)                 + ",";
   j += "\"commission\":"       + ::DoubleToString(commission, 2)           + ",";
   j += "\"magic\":"            + ::IntegerToString(magic)                  + ",";
   j += "\"comment\":\""        + cmt_esc                                   + "\",";
   j += "\"server_time\":"      + ::IntegerToString((long)server_time);
   j += "}";
   return(j);
  }

#endif // TRADEEVENTJSON_MQH
//+------------------------------------------------------------------+


Implementation — EventQueue.mqh

CEventQueue is a fixed-capacity FIFO circular buffer that holds JSON strings pending delivery. It is allocated at a fixed size during Init() and thereafter operates without any dynamic memory allocation. On overflow, the oldest undelivered event is silently discarded to make room for the newest one, keeping recent activity visible at the cost of losing the oldest undelivered event.

//+------------------------------------------------------------------+
//|                                                    EventQueue.mqh|
//+------------------------------------------------------------------+
#ifndef EVENTQUEUE_MQH
#define EVENTQUEUE_MQH
//+------------------------------------------------------------------+
//| Fixed-capacity FIFO queue for JSON event strings pending         |
//| delivery to the HTTP server. When the server is temporarily      |
//| unreachable, events accumulate here and are retried in order on  |
//| each subsequent OnTimer() cycle. If the queue is full, the       |
//| oldest event is discarded to make room for the newest one,       |
//| preserving the most recent activity at the cost of losing the    |
//| oldest undelivered event.                                        |
//|                                                                  |
//| MQL5 is single-threaded, so no mutex is required: OnTimer() and  |
//| OnTradeTransaction() do not run concurrently.                    |
//+------------------------------------------------------------------+
class CEventQueue
  {
private:
   string            m_buf[];    // circular buffer of JSON strings
   int               m_capacity; // maximum number of events the buffer holds
   int               m_head;     // index of the oldest item (next to dequeue)
   int               m_tail;     // index where the next enqueue will write
   int               m_count;    // number of items currently in the queue

public:
                     CEventQueue(void);
                    ~CEventQueue(void);

   void              Init(int capacity);
   bool              Enqueue(const string &json);
   bool              Dequeue(string &json);
   bool              Peek(string &json) const;
   int               Count(void) const;
   bool              IsEmpty(void) const;
   bool              IsFull(void) const;
  };
//+------------------------------------------------------------------+
//| Constructor — creates an empty queue with a default capacity.    |
//+------------------------------------------------------------------+
CEventQueue::CEventQueue(void)
  {
   m_capacity = 0;
   m_head     = 0;
   m_tail     = 0;
   m_count    = 0;
  }
//+------------------------------------------------------------------+
//| Destructor — nothing to release; MQL5 manages string arrays.     |
//+------------------------------------------------------------------+
CEventQueue::~CEventQueue(void)
  {
//--- m_buf[] is an MQL5-managed string array; freed automatically
  }
//+------------------------------------------------------------------+
//| Allocates the circular buffer to the requested capacity.         |
//| Must be called once before any Enqueue or Dequeue operations.    |
//+------------------------------------------------------------------+
void CEventQueue::Init(int capacity)
  {
   m_capacity = capacity;
   m_head     = 0;
   m_tail     = 0;
   m_count    = 0;
   ::ArrayResize(m_buf, capacity);
  }
//+------------------------------------------------------------------+
//| Adds a JSON string to the back of the queue. If the queue is     |
//| already full, the oldest item is silently discarded to make room |
//| for the new one, and the method returns false to signal that     |
//| overflow occurred.                                               |
//+------------------------------------------------------------------+
bool CEventQueue::Enqueue(const string &json)
  {
   bool overflow = false;
   if(m_count == m_capacity)
     {
      //--- discard the oldest item to prevent blocking the newest events
      m_head    = (m_head + 1) % m_capacity;
      m_count--;
      overflow  = true;
      ::Print("CEventQueue::Enqueue: queue full — oldest event discarded");
     }

   m_buf[m_tail] = json;
   m_tail        = (m_tail + 1) % m_capacity;
   m_count++;
   return(!overflow);
  }
//+------------------------------------------------------------------+
//| Removes and returns the oldest JSON string from the front of the |
//| queue. Returns false if the queue is empty, in which case json   |
//| is left unchanged.                                               |
//+------------------------------------------------------------------+
bool CEventQueue::Dequeue(string &json)
  {
   if(m_count == 0)
      return(false);

   json    = m_buf[m_head];
   m_head  = (m_head + 1) % m_capacity;
   m_count--;
   return(true);
  }
//+------------------------------------------------------------------+
//| Returns the oldest JSON string without removing it from the      |
//| queue. Used to inspect the front item before attempting delivery,|
//| so a failed send does not lose the event.                        |
//+------------------------------------------------------------------+
bool CEventQueue::Peek(string &json) const
  {
   if(m_count == 0)
      return(false);
   json = m_buf[m_head];
   return(true);
  }
//+------------------------------------------------------------------+
//| Returns the number of events currently held in the queue.        |
//+------------------------------------------------------------------+
int CEventQueue::Count(void) const
  {
   return(m_count);
  }
//+------------------------------------------------------------------+
//| Returns true if the queue contains no events.                    |
//+------------------------------------------------------------------+
bool CEventQueue::IsEmpty(void) const
  {
   return(m_count == 0);
  }
//+------------------------------------------------------------------+
//| Returns true if the queue has reached its capacity limit.        |
//+------------------------------------------------------------------+
bool CEventQueue::IsFull(void) const
  {
   return(m_count == m_capacity);
  }

#endif // EVENTQUEUE_MQH
//+------------------------------------------------------------------+

Peek() is the key method for safe retry: OnTimer() calls Peek() to read the front item, then calls PostJson(). Only if the POST succeeds does OnTimer() call Dequeue() to advance the head pointer. A failed POST leaves the item in the queue untouched, so it will be retried on the next timer tick. This guarantees that no event is ever silently lost due to a failed HTTP send.

ArrayResize() in Init() allocates exactly capacity elements. The circular indices advance modulo m_capacity to wrap around the fixed-size array without any reallocation.


Implementation — TradeEventStreamer.mq5

The EA ties all four modules together. OnInit() opens the WinINet session and starts the millisecond timer. OnTradeTransaction() serializes trade events to JSON and enqueues them. OnTimer() drains the queue with clean, outage-aware logging.

Inputs and Module Instances

//+------------------------------------------------------------------+
//|                                          TradeEventStreamer.mq5  |
//+------------------------------------------------------------------+

#property description   "Streams position open and close events to a"
#property description   "local HTTP server as JSON via WinINet."

//--- Includes
#include <Tradeeventstreamer/WinINet.mqh>
#include <Tradeeventstreamer/HttpPoster.mqh>
#include <Tradeeventstreamer/TradeEventJson.mqh>
#include <Tradeeventstreamer/EventQueue.mqh>

//--- Inputs
input string InpHost        = "127.0.0.1";      // Server hostname or IP
input int    InpPort        = 8787;             // Server port
input string InpPath        = "/trade_event";   // HTTP resource path
input int    InpTimerMs     = 500;              // Drain timer interval (ms)
input int    InpQueueSize   = 512;              // Maximum queued events
input long   InpMagicFilter = 0;                // Magic filter (0 = all)
//+------------------------------------------------------------------+
//| Module-level instances                                           |
//+------------------------------------------------------------------+
CHttpPoster   g_poster;         // manages the WinINet session and HTTP POSTs
CEventQueue   g_queue;          // buffers events when the server is unavailable
bool          g_server_down  = false; // true after the first failure; reset on recovery
int           g_pending_at_down = 0;  // queue depth recorded when server went down

g_server_down and g_pending_at_down are module-level state variables that persist across OnTimer() and OnTradeTransaction() calls, allowing the EA to maintain a consistent view of the server's reachability across multiple callbacks.

OnInit()

//+------------------------------------------------------------------+
//| EA initialization: open the WinINet session and start the timer  |
//| that drains pending events at a fixed interval.                  |
//+------------------------------------------------------------------+
int OnInit()
  {
   if(!g_poster.Init(InpHost, (ushort)InpPort, InpPath, "MT5-TradeEventStreamer/1.0"))
     {
      ::Print("TradeEventStreamer: failed to initialize HTTP session. "
              "Ensure DLL imports are enabled.");
      return(INIT_FAILED);
     }

   g_queue.Init(InpQueueSize);

//--- EventSetMillisecondTimer fires OnTimer() every InpTimerMs ms;
//--- the timer drains queued events even when no new trades occur
   if(!::EventSetMillisecondTimer(InpTimerMs))
     {
      ::Print("TradeEventStreamer: EventSetMillisecondTimer failed.");
      return(INIT_FAILED);
     }

   ::PrintFormat("TradeEventStreamer: started — posting to http://%s:%d%s every %d ms",
                 InpHost, InpPort, InpPath, InpTimerMs);
   return(INIT_SUCCEEDED);
  }

EventSetMillisecondTimer() starts a repeating timer that fires OnTimer() at approximately the given interval. 500 ms is a practical balance: events are delivered within half a second of being enqueued, while the timer overhead on an idle account is negligible. Returning INIT_FAILED causes MetaTrader 5 to remove the EA from the chart immediately; INIT_SUCCEEDED allows it to continue running.

OnDeinit()

//+------------------------------------------------------------------+
//| EA deinitialization: stop the timer and release WinINet handles. |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
   ::EventKillTimer();
   g_poster.Deinit();
   ::PrintFormat("TradeEventStreamer: stopped (reason %d). "
                 "%d event(s) remaining in queue.", reason, g_queue.Count());
  }

EventKillTimer() stops the repeating timer before Deinit() releases the WinINet session, so no delivery attempt is made after the session handle is closed. The count of undelivered events is logged so the trader knows whether any events were lost when the EA was stopped during an outage.

OnTimer()

//+------------------------------------------------------------------+
//| Timer callback: attempts to deliver every queued event in order. |
//| On the first delivery failure the loop logs once and stops;      |
//| subsequent failures are completely silent so the Experts tab is  |
//| not flooded while the server is down. When delivery resumes, a   |
//| single recovery message reports how many events were flushed.    |
//+------------------------------------------------------------------+
void OnTimer()
  {
   if(g_queue.IsEmpty())
      return;

   int delivered = 0;

   while(!g_queue.IsEmpty())
     {
      string json;
      //--- Peek before consuming so the event is not lost on a send failure
      if(!g_queue.Peek(json))
         break;

      //--- suppress CHttpPoster's own prints on every attempt after the
      //--- first failure; SetSilent is called before PostJson so that even
      //--- the very first retry produces no internal diagnostic output
      if(g_server_down)
         g_poster.SetSilent(true);

      if(!g_poster.PostJson(json))
        {
         //--- log only on the first failure in each down episode
         if(!g_server_down)
           {
            g_pending_at_down = g_queue.Count();
            ::PrintFormat("TradeEventStreamer: server unreachable (HTTP %d) — "
                          "%d event(s) queued, retrying every %d ms",
                          g_poster.LastStatus(), g_pending_at_down, InpTimerMs);
            g_server_down = true;
            //--- mute for all subsequent retries in this down episode
            g_poster.SetSilent(true);
           }
         break; // retry on next timer tick; preserve queue order
        }

      //--- delivery succeeded: consume the item from the queue
      g_queue.Dequeue(json);
      delivered++;
     }

//--- if at least one event was delivered this tick and the server had
//--- previously been flagged as down, print a single recovery summary
   if(delivered > 0 && g_server_down)
     {
      ::PrintFormat("TradeEventStreamer: server back — flushed %d queued event(s), "
                    "%d remain", delivered, g_queue.Count());
      g_server_down = false;
      //--- restore verbose logging now that the server is reachable again
      g_poster.SetSilent(false);
     }
  }

The if(g_server_down) g_poster.SetSilent(true) check runs before PostJson() on each loop iteration. This ensures the first retry on the next timer tick produces no internal output from CHttpPoster. If this check were placed only inside the failure branch, SetSilent would take effect one tick too late, and one extra internal error line would appear at the start of each retry cycle.

OnTradeTransaction()

//+------------------------------------------------------------------+
//| Trade transaction callback: fires on every account state change. |
//| Detects position opens from TRADE_TRANSACTION_DEAL_ADD and       |
//| position closes from exit deals, builds the appropriate JSON,    |
//| and enqueues it for delivery by the next OnTimer() call.         |
//+------------------------------------------------------------------+
void OnTradeTransaction(const MqlTradeTransaction &trans,
                        const MqlTradeRequest    &request,
                        const MqlTradeResult     &result)
  {
//--- only deal additions carry complete position information
   if(trans.type != TRADE_TRANSACTION_DEAL_ADD)
      return;

   ulong deal_ticket = trans.deal;
   if(deal_ticket == 0)
      return;

//--- the deal must exist in history to read its properties
   if(!::HistoryDealSelect(deal_ticket))
      return;

   long deal_magic = ::HistoryDealGetInteger(deal_ticket, DEAL_MAGIC);

//--- apply the magic number filter when one is configured
   if(InpMagicFilter != 0 && deal_magic != InpMagicFilter)
      return;

   string   symbol      = ::HistoryDealGetString(deal_ticket,  DEAL_SYMBOL);
   double   volume      = ::HistoryDealGetDouble(deal_ticket,  DEAL_VOLUME);
   double   price       = ::HistoryDealGetDouble(deal_ticket,  DEAL_PRICE);
   long     deal_entry  = ::HistoryDealGetInteger(deal_ticket, DEAL_ENTRY);
   datetime deal_time   = (datetime)::HistoryDealGetInteger(deal_ticket, DEAL_TIME);
   string   deal_comment= ::HistoryDealGetString(deal_ticket,  DEAL_COMMENT);
   long     deal_type_i = ::HistoryDealGetInteger(deal_ticket, DEAL_TYPE);
   ulong    pos_ticket  = (ulong)::HistoryDealGetInteger(deal_ticket, DEAL_POSITION_ID);

   string json = "";

   if(deal_entry == DEAL_ENTRY_IN)
     {
      //--- position opening: read the SL and TP from the live position
      //--- rather than from the deal, because the deal record does not
      //--- carry SL/TP when they were set by a separate modify order
      double sl = 0.0;
      double tp = 0.0;
      if(::PositionSelectByTicket(pos_ticket))
        {
         sl = ::PositionGetDouble(POSITION_SL);
         tp = ::PositionGetDouble(POSITION_TP);
        }

      ENUM_POSITION_TYPE ptype = (deal_type_i == DEAL_TYPE_BUY)
                                 ? POSITION_TYPE_BUY
                                 : POSITION_TYPE_SELL;

      json = BuildOpenJson(pos_ticket, symbol, ptype, volume,
                           price, sl, tp, deal_magic, deal_comment, deal_time);
     }
   else
      if(deal_entry == DEAL_ENTRY_OUT || deal_entry == DEAL_ENTRY_INOUT)
        {
         //--- position closing: read profit components from the exit deal
         double profit     = ::HistoryDealGetDouble(deal_ticket, DEAL_PROFIT);
         double swap       = ::HistoryDealGetDouble(deal_ticket, DEAL_SWAP);
         double commission = ::HistoryDealGetDouble(deal_ticket, DEAL_COMMISSION);

         //--- recover the open price from the position history if available;
         //--- fall back to 0.0 when the position has already been removed
         double open_price = 0.0;
         if(::HistorySelectByPosition(pos_ticket))
           {
            int n = (int)::HistoryDealsTotal();
            for(int i = 0; i < n; i++)
              {
               ulong t = ::HistoryDealGetTicket(i);
               if(::HistoryDealGetInteger(t, DEAL_ENTRY) == DEAL_ENTRY_IN)
                 {
                  open_price = ::HistoryDealGetDouble(t, DEAL_PRICE);
                  break;
                 }
              }
           }

         ENUM_DEAL_TYPE dtype = (ENUM_DEAL_TYPE)deal_type_i;

         json = BuildCloseJson(deal_ticket, pos_ticket, symbol, dtype,
                               volume, open_price, price,
                               profit, swap, commission,
                               deal_magic, deal_comment, deal_time);
        }

   if(json != "")
     {
      if(!g_queue.Enqueue(json))
         ::Print("TradeEventStreamer: queue overflow — oldest event dropped");
      else
         if(g_server_down)
           {
            //--- log each event that arrives while the server is unreachable so
            //--- the trader can see activity accumulating in the queue; the
            //--- recovery summary in OnTimer() will confirm all were delivered
            string event_label = (deal_entry == DEAL_ENTRY_IN) ? "OPEN" : "CLOSE";
            ::PrintFormat("TradeEventStreamer: server down — queued %s %s ticket=%I64u "
                          "(%d total pending)",
                          event_label, symbol, pos_ticket, g_queue.Count());
           }
     }
  }

OnTradeTransaction() filters on TRADE_TRANSACTION_DEAL_ADD first, discarding the many other transaction types that carry no useful deal information. HistoryDealSelect() loads the deal into the history buffer so its properties can be read through HistoryDealGet*(). The DEAL_ENTRY property distinguishes entry deals (DEAL_ENTRY_IN, a new position opened) from exit deals (DEAL_ENTRY_OUT or DEAL_ENTRY_INOUT, a position fully or partially closed). DEAL_ENTRY_INOUT covers the case where a position closes and reverses in a single deal.

For entry deals, PositionSelectByTicket() and PositionGetDouble() read the live position's SL and TP, because the deal record itself does not carry these values when they were set by a subsequent modify order rather than being embedded in the original opening order.

For exit deals, HistorySelectByPosition() loads all deals belonging to the same position into the history buffer, and the loop scans for the entry deal to recover the open price. This allows the consuming application to compute the position's true price movement without needing to query the terminal's history separately.

The enqueue logging block fires only when g_server_down is true and the enqueue succeeded, producing a line for each trade that accumulates in the queue during the outage. These lines give the trader a running count of pending events and make it clear that trades are being captured even when the server is temporarily unreachable.

Expected Live EA Log — Normal Operation

When the server is running and events deliver immediately, the Experts tab shows only the startup line and the server's own terminal shows each event as it arrives:

TradeEventStreamer: started — posting to http://127.0.0.1:8787/trade_event every 500 ms

Expected Live EA Log — Outage and Recovery

When the server stops and then restarts:

TradeEventStreamer: started — posting to http://127.0.0.1:8787/trade_event every 500 ms
TradeEventStreamer: server unreachable (HTTP 0) — 1 event(s) queued, retrying every 500 ms
TradeEventStreamer: server down — queued OPEN ETHUSD ticket=911641270 (2 total pending)
TradeEventStreamer: server down — queued OPEN GOLD ticket=911641298 (3 total pending)
TradeEventStreamer: server back — flushed 3 queued event(s), 0 remain

The Experts tab is completely silent during the outage between the first failure message and the recovery message. Every trade that arrives during the outage produces exactly one line. The recovery message accounts for every event the opening failure message and the subsequent queued-event messages described.

Python server terminal output

Figure 2: Python server terminal output. trade_event_server.py receiving two live TRADE_OPEN events from the EA.


Implementation — trade_event_server.py

The Python server requires no third-party packages and runs from a single file. It accepts POST requests to /trade_event, parses the JSON body, prints a formatted summary, and always responds with {"status": "ok"}.

"""
trade_event_server.py
---------------------
A minimal HTTP server that receives JSON trade events posted by the
TradeEventStreamer EA and prints them to stdout.

Requirements: Python 3.7 or later (standard library only)
Usage:        python trade_event_server.py [port]

The server listens on 127.0.0.1 (loopback only) to accept connections
from MetaTrader 5 running on the same machine. Binding to 127.0.0.1
rather than 0.0.0.0 means the port is not reachable from the network,
which reduces exposure if the server is left running.

Every POST to /trade_event is expected to carry a JSON body. The body
is parsed and written to stdout with a timestamp prefix. A 200 OK
response is sent regardless of parse success so the EA's HTTP POST
always gets a completion signal rather than timing out.
"""

import json
import sys
import datetime
from http.server import HTTPServer, BaseHTTPRequestHandler


class TradeEventHandler(BaseHTTPRequestHandler):

    def do_POST(self):
        length = int(self.headers.get("Content-Length", 0))
        raw = self.rfile.read(length)

        timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]

        try:
            event = json.loads(raw.decode("utf-8"))
            event_type = event.get("event", "UNKNOWN")
            symbol     = event.get("symbol", "?")
            ticket     = event.get("ticket") or event.get("deal_ticket", "?")

            print(f"[{timestamp}] {event_type:12s}  symbol={symbol:<12s} ticket={ticket}")

            if event_type == "TRADE_OPEN":
                print(f"  type={event.get('type')}  volume={event.get('volume')}"
                      f"  price={event.get('price')}"
                      f"  sl={event.get('sl')}  tp={event.get('tp')}"
                      f"  magic={event.get('magic')}")
            elif event_type == "TRADE_CLOSE":
                net = (event.get('profit', 0) +
                       event.get('swap', 0) +
                       event.get('commission', 0))
                print(f"  type={event.get('type')}  volume={event.get('volume')}"
                      f"  open={event.get('open_price')}  close={event.get('close_price')}"
                      f"  profit={event.get('profit')}  net={net:.2f}"
                      f"  magic={event.get('magic')}")
        except (json.JSONDecodeError, UnicodeDecodeError) as e:
            print(f"[{timestamp}] PARSE ERROR: {e}  raw={raw!r}")

        # Always reply 200 so the EA does not treat a parse error as a delivery failure
        response = json.dumps({"status": "ok"}).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(response)))
        self.end_headers()
        self.wfile.write(response)

    def log_message(self, fmt, *args):
        # Suppress the default per-request access log
        pass


def main():
    port = int(sys.argv[1]) if len(sys.argv) > 1 else 8787
    server = HTTPServer(("127.0.0.1", port), TradeEventHandler)
    print(f"Trade event server listening on http://127.0.0.1:{port}/trade_event")
    print("Press Ctrl+C to stop.\n")
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\nServer stopped.")


if __name__ == "__main__":
    main()

The server always responds with HTTP 200 regardless of whether the JSON parsed successfully. A parse error on the server side is still a successful delivery from the EA's perspective — the bytes arrived intact — and a non-200 response would cause the EA to retain the event and retry it indefinitely for no useful purpose. log_message() is overridden to suppress the default per-request access log that BaseHTTPRequestHandler would otherwise print alongside the formatted event output.


Verification — TestHttpPoster.mq5

TestHttpPoster.mq5 verifies four independent things without requiring any live trading account: that EscapeJsonString() handles all edge cases correctly, that both JSON builder functions produce strings containing every required field, that CEventQueue behaves correctly including overflow, and that CHttpPoster can reach the running Python server and receive HTTP 200. The first 27 assertions require no network connection; only the final 5 require the server.

//+------------------------------------------------------------------+
//|                                               TestHttpPoster.mq5 |
//+------------------------------------------------------------------+

#property description   "Sends two synthetic trade events to the local"
#property description   "trade_event_server.py and checks for HTTP 200."

//--- Includes
#include <Tradeeventstreamer/WinINet.mqh>
#include <Tradeeventstreamer/HttpPoster.mqh>
#include <Tradeeventstreamer/TradeEventJson.mqh>
#include <Tradeeventstreamer/EventQueue.mqh>

//--- ASSERT: prints PASSED or FAILED with the test description
#define ASSERT(cond, msg) \
   if(!(cond)) { PrintFormat("ASSERT FAILED : %s", msg); } \
   else        { PrintFormat("ASSERT PASSED : %s", msg); }
//+------------------------------------------------------------------+
//| Script entry point: run all assertions and report results.       |
//+------------------------------------------------------------------+
void OnStart()
  {
//--- Test 1: JSON escaping — strings with embedded quotes and backslashes
//--- must produce parseable JSON without corrupting the surrounding syntax
   string plain    = EscapeJsonString("EURUSD");
   string withq    = EscapeJsonString("has \"quotes\"");
   string withbs   = EscapeJsonString("back\\slash");
   string combined = EscapeJsonString("both \\\"");

   ASSERT(plain    == "EURUSD",          "EscapeJsonString: plain string unchanged");
   ASSERT(withq    == "has \\\"quotes\\\"", "EscapeJsonString: double-quote escaped");
   ASSERT(withbs   == "back\\\\slash",   "EscapeJsonString: backslash escaped");
   ASSERT(combined == "both \\\\\\\"",   "EscapeJsonString: backslash then quote");

//--- Test 2: BuildOpenJson — check the JSON round-trip for a TRADE_OPEN event
   string open_json = BuildOpenJson(
                         902000001, "EURUSD", POSITION_TYPE_BUY,
                         0.10, 1.08500, 1.08000, 1.09000,
                         77777, "test open", (datetime)1720800000
                      );
//--- verify the required fields are present in the output string
   ASSERT(::StringFind(open_json, "\"event\":\"TRADE_OPEN\"")   >= 0, "OpenJson: event field");
   ASSERT(::StringFind(open_json, "\"ticket\":902000001")       >= 0, "OpenJson: ticket field");
   ASSERT(::StringFind(open_json, "\"symbol\":\"EURUSD\"")      >= 0, "OpenJson: symbol field");
   ASSERT(::StringFind(open_json, "\"type\":\"BUY\"")           >= 0, "OpenJson: type field");
   ASSERT(::StringFind(open_json, "\"volume\":0.10")            >= 0, "OpenJson: volume field");
   ASSERT(::StringFind(open_json, "\"magic\":77777")            >= 0, "OpenJson: magic field");
   ASSERT(::StringFind(open_json, "\"server_time\":1720800000") >= 0, "OpenJson: server_time");

//--- Test 3: BuildCloseJson — check the JSON round-trip for a TRADE_CLOSE event
   string close_json = BuildCloseJson(
                          902000099, 902000001, "EURUSD", DEAL_TYPE_BUY,
                          0.10, 1.08500, 1.08750,
                          25.00, -0.50, -0.70,
                          77777, "sl hit", (datetime)1720803600
                       );
   ASSERT(::StringFind(close_json, "\"event\":\"TRADE_CLOSE\"")         >= 0, "CloseJson: event field");
   ASSERT(::StringFind(close_json, "\"deal_ticket\":902000099")         >= 0, "CloseJson: deal_ticket");
   ASSERT(::StringFind(close_json, "\"position_ticket\":902000001")     >= 0, "CloseJson: position_ticket");
   ASSERT(::StringFind(close_json, "\"profit\":25.00")                  >= 0, "CloseJson: profit");
   ASSERT(::StringFind(close_json, "\"swap\":-0.50")                    >= 0, "CloseJson: swap");
   ASSERT(::StringFind(close_json, "\"commission\":-0.70")              >= 0, "CloseJson: commission");

//--- Test 4: CEventQueue — enqueue, peek, dequeue, and overflow behavior
   CEventQueue q;
   q.Init(3);

   ASSERT(q.IsEmpty(),              "Queue: empty on init");
   ASSERT(q.Count() == 0,           "Queue: count is 0 on init");

   q.Enqueue("{\"a\":1}");
   q.Enqueue("{\"b\":2}");
   q.Enqueue("{\"c\":3}");

   ASSERT(q.Count() == 3,           "Queue: count is 3 after three enqueues");
   ASSERT(q.IsFull(),               "Queue: IsFull() after reaching capacity");

   string peeked;
   q.Peek(peeked);
   ASSERT(peeked == "{\"a\":1}",    "Queue: Peek returns the oldest item");
   ASSERT(q.Count() == 3,           "Queue: Peek does not consume the item");

   string dequeued;
   q.Dequeue(dequeued);
   ASSERT(dequeued == "{\"a\":1}",  "Queue: Dequeue returns the oldest item");
   ASSERT(q.Count() == 2,           "Queue: count decrements after Dequeue");

//--- overflow: enqueue a 4th item into a capacity-3 queue with 2 items
   q.Enqueue("{\"d\":4}");  // queue is now [b, c, d]
   ASSERT(q.Count() == 3,           "Queue: count is 3 after overflow-safe enqueue");
   q.Enqueue("{\"e\":5}"); // overflow: b is dropped; queue becomes [c, d, e]
   string after_overflow;
   q.Peek(after_overflow);
   ASSERT(after_overflow == "{\"c\":3}", "Queue: overflow drops the oldest item");

//--- Test 5: CHttpPoster round-trip against trade_event_server.py
//--- The server must be running before this test is executed.
//--- Start it with: python trade_event_server.py 8787
   CHttpPoster poster;
   bool init_ok = poster.Init("127.0.0.1", 8787, "/trade_event",
                              "MT5-TestHttpPoster/1.0");
   ASSERT(init_ok, "HttpPoster: Init() succeeds (WinINet session opened)");

   if(init_ok)
     {
      bool open_ok = poster.PostJson(open_json);
      ASSERT(open_ok, "HttpPoster: TRADE_OPEN event delivered (HTTP 200)");
      ASSERT(poster.LastStatus() == 200, "HttpPoster: HTTP status is 200 for TRADE_OPEN");

      bool close_ok = poster.PostJson(close_json);
      ASSERT(close_ok, "HttpPoster: TRADE_CLOSE event delivered (HTTP 200)");
      ASSERT(poster.LastStatus() == 200, "HttpPoster: HTTP status is 200 for TRADE_CLOSE");

      poster.Deinit();
     }

   Print("TestHttpPoster: all assertions complete.");
  }
//+------------------------------------------------------------------+

Expected Experts Tab Output:

ASSERT PASSED : EscapeJsonString: plain string unchanged
ASSERT PASSED : EscapeJsonString: double-quote escaped
ASSERT PASSED : EscapeJsonString: backslash escaped
ASSERT PASSED : EscapeJsonString: backslash then quote
ASSERT PASSED : OpenJson: event field
ASSERT PASSED : OpenJson: ticket field
ASSERT PASSED : OpenJson: symbol field
ASSERT PASSED : OpenJson: type field
ASSERT PASSED : OpenJson: volume field
ASSERT PASSED : OpenJson: magic field
ASSERT PASSED : OpenJson: server_time
ASSERT PASSED : CloseJson: event field
ASSERT PASSED : CloseJson: deal_ticket
ASSERT PASSED : CloseJson: position_ticket
ASSERT PASSED : CloseJson: profit
ASSERT PASSED : CloseJson: swap
ASSERT PASSED : CloseJson: commission
ASSERT PASSED : Queue: empty on init
ASSERT PASSED : Queue: count is 0 on init
ASSERT PASSED : Queue: count is 3 after three enqueues
ASSERT PASSED : Queue: IsFull() after reaching capacity
ASSERT PASSED : Queue: Peek returns the oldest item
ASSERT PASSED : Queue: Peek does not consume the item
ASSERT PASSED : Queue: Dequeue returns the oldest item
ASSERT PASSED : Queue: count decrements after Dequeue
ASSERT PASSED : Queue: count is 3 after overflow-safe enqueue
CEventQueue::Enqueue: queue full — oldest event discarded
ASSERT PASSED : Queue: overflow drops the oldest item
ASSERT PASSED : HttpPoster: Init() succeeds (WinINet session opened)
ASSERT PASSED : HttpPoster: TRADE_OPEN event delivered (HTTP 200)
ASSERT PASSED : HttpPoster: HTTP status is 200 for TRADE_OPEN
ASSERT PASSED : HttpPoster: TRADE_CLOSE event delivered (HTTP 200)
ASSERT PASSED : HttpPoster: HTTP status is 200 for TRADE_CLOSE
TestHttpPoster: all assertions complete.


Extending the Implementation

The Python server currently writes to stdout. Replacing the print statements with inserts into a SQLite database, publishes to a Redis channel, or forwards to a downstream WebSocket connection requires only changes to do_POST(), with no changes to the MQL5 code.

The schema in BuildOpenJson() and BuildCloseJson() is fixed, but adding fields — for example an account login number, a strategy name, or a broker-level position identifier — requires only adding one j += ... line to the relevant function.

The magic number filter on InpMagicFilter restricts the stream to a single EA's trades. Removing the filter input entirely and replacing it with a list of multiple magic numbers would require changing the filter check to ArrayBsearch() against a sorted array, which is a straightforward extension.

The timer interval can be reduced to 100 milliseconds for latency-sensitive analytics with no change to any other parameter, bringing the worst-case delivery delay to a tenth of a second.


Limitations

WinINet is synchronous. During normal operation on a loopback connection, each PostJson() call completes in under a millisecond. If InpHost is changed to a remote server with higher latency, OnTimer() may take longer than the timer interval to complete, which causes timer ticks to queue up in the event loop. This does not affect OnTradeTransaction() since both callbacks run on the same single-threaded event loop, but it can slow queue draining during recovery from a prolonged outage against a distant endpoint.

The implementation detects opens and closes by monitoring DEAL_ENTRY_IN and DEAL_ENTRY_OUT on deal additions. Partial closes generate a DEAL_ENTRY_OUT deal for the closed portion; the remaining open portion is identified by the unchanged position ID. The current schema does not explicitly distinguish a partial close from a full close. Consuming applications that need to make this distinction should check whether the position ID still exists in the open positions list after receiving a TRADE_CLOSE event.

The queue is in-memory only and does not survive an EA restart or terminal crash. Events queued during an outage that coincides with an EA restart are permanently lost. Persisting the queue to a file between sessions would require file I/O in OnDeinit() and OnInit(), which is outside the scope of this article but is a direct extension.

The JSON schema uses plain ASCII string concatenation throughout. Non-ASCII characters in symbol names, comments, or any other string field are passed through without any Unicode-aware escaping. Most standard symbol names and broker comments are plain ASCII; brokers using non-Latin characters in symbol names should verify the JSON is valid at the server side before relying on the schema in production.


Conclusion

This article builds a complete, dependency-free pipeline for streaming MetaTrader 5 trade events to a local HTTP server using WinINet called directly from MQL5. WinINet.mqh provides the five DLL import declarations that constitute the minimal HTTP client. HttpPoster.mqh wraps the three-handle pipeline into a class with a persistent session, per-request connection and request handles, and a SetSilent() method that gives the EA full control over what appears in the Experts tab during a known outage. TradeEventJson.mqh serializes position opens and closes to JSON with correct character escaping. EventQueue.mqh is a fixed-capacity FIFO circular buffer that decouples OnTradeTransaction() from HTTP delivery and implements Peek-before-Dequeue to guarantee no event is silently dropped by a failed send. TradeEventStreamer.mq5 combines all four, with an OnTimer() drain loop that logs once at the start of each outage episode and once at recovery, and an OnTradeTransaction() callback that logs each individual trade that arrives while the server is down so the trader has complete visibility into what is accumulating in the queue.

The concrete operational guarantees are these: no event is silently dropped by a failed delivery, because the Peek-before-Dequeue pattern keeps every event in the queue until a confirmed HTTP 200 is received. Event delivery order is preserved across retries. The Experts tab during a server outage shows exactly one line at the moment the server goes down, one line per trade that arrives during the outage, and one line when the server recovers — nothing else. The honest limitations are that WinINet is synchronous, that partial closes are not distinguished from full closes in the schema, that the queue does not persist across EA restarts, and that non-ASCII characters in string fields are not Unicode-escaped.


Programs used in the article:

# Name Type Description
1 WinINet.mqh Include File DLL import declarations for the five WinINet functions used to open sessions, connect to hosts, send HTTP requests, read response status, and close handles.
2 HttpPoster.mqh  Include File  CHttpPoster class with a persistent session handle, per-request connection and request handles, and SetSilent() for suppressing internal diagnostic prints during known server outages. 
3 TradeEventJson.mqh Include File EscapeJsonString(), BuildOpenJson(), and BuildCloseJson(); assembles JSON payloads for the two trade event types with correct escaping of double-quotes and backslashes.
4 EventQueue.mqh Include File CEventQueue circular buffer with Peek-before-Dequeue guaranteeing no event is silently dropped on a failed delivery, and oldest-item overflow for bounded memory.
5 TradeEventStreamer.mq5 Demo EA Main EA: OnTimer() drains the queue with outage-aware logging; OnTradeTransaction() serializes and enqueues events and logs each trade that arrives during a server outage.
6 TestHttpPoster.mq5 MQL5 Script 34 assertions covering EscapeJsonString edge cases, JSON field presence, queue overflow, and live HTTP round-trip against the Python server.
7 trade_event_server.py Python Script Zero-dependency HTTP server that parses incoming JSON trade events and prints formatted summaries; serves as both the validation receiver and a reference consumer implementation.
8 Trade_Event_Streamer.zip Zip Archive Zip archive containing all the attached files and their paths relative to the terminal's root folder.
Attached files |
WinINet.mqh (3.36 KB)
HttpPoster.mqh (8.16 KB)
TradeEventJson.mqh (5.02 KB)
EventQueue.mqh (5.89 KB)
TestHttpPoster.mq5 (6.08 KB)
Building a Divergence System (Part III): The Adaptive SuperTrend EA Building a Divergence System (Part III): The Adaptive SuperTrend EA
The article implements a self-sufficient Adaptive SuperTrend EA with internal calculations on a selectable timeframe, avoiding external buffers and indicator files. It includes risk-based lot sizing, ATR stops, stepwise RR trailing, optional anti-repainting confirmation, and session control. Practitioners can reuse the structure for consistent new‑bar signal handling and broker‑compliant order validation.
Mathematical Models in Grid Strategies Mathematical Models in Grid Strategies
In this article, we will examine the application of mathematics to grid strategies. We will consider the basic principles of the strategy, as well as its advantages and disadvantages. You will learn how to build a trading grid, set optimal parameters, and manage risks effectively.
Features of Experts Advisors Features of Experts Advisors
Creation of expert advisors in the MetaTrader trading system has a number of features.
Building a Modular Fair Value Gap (FVG) Detection Engine in MQL5 Building a Modular Fair Value Gap (FVG) Detection Engine in MQL5
This article introduces a modular Fair Value Gap (FVG) detection engine for MQL5 packaged as a reusable include class, it evaluates imbalance zones on closed bars, applies a Simple True Range average filter to eliminate low-volatility noise, and supports wick-touch and close-through mitigation. A companion diagnostic indicator plots active gaps, and an Expert Advisor template demonstrates automated pullback entries with new-bar execution controls.