preview
Building a News Filter Engine in MQL5 Using a Local Economic Calendar File

Building a News Filter Engine in MQL5 Using a Local Economic Calendar File

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

Introduction

Every developer who has run an Expert Advisor through a Nonfarm Payrolls release knows the pattern: the spread widens from one point to fifteen, the stop loss fills forty points away from where it was placed, and a strategy that backtested cleanly gives back a week of profit in ninety seconds. High-impact economic releases are the single most predictable source of execution damage in retail forex, and they are predictable precisely because their schedule is published days in advance. The engineering problem is not the event times; it is getting the schedule into the EA reliably.

The two common solutions both introduce fragility. Web scraping works until the site changes its HTML; then EAs depending on the scraper silently stop filtering and trade through the next release. Paid calendar APIs are more stable, but they add a subscription cost and a hard requirement for a live internet connection at the exact moment the EA needs the data. Neither approach has a reliable offline fallback, and an EA running on a VPS with a flaky outbound connection is exactly the environment where a fallback matters most.

This article builds the third option: a file-based news filter. The trader downloads the economic calendar once, in the standard Forex Factory CSV export format, and drops it into the terminal's MQL5/Files/ directory. At startup, the engine reads the file and parses each row into a structured event record. On demand, it answers one question: is the current server time inside a news window for this symbol? There is no network dependency at runtime, no HTML to break, and no subscription. The trade-off is that the file must be refreshed manually (or by a scheduled WebRequest(), which Section 10 covers), and that trade-off is examined honestly in the Limitations section.

By the end of the article you will have a complete, compilable engine. It includes a CNewsFilter class (CSV parser, time-window checker with a currency filter, and a chart drawer), plus a demo EA and a verification script that proves the boundary logic with assertions.

Architecture of the news filter engine

The news filter engine in four stages: the calendar file is read by CCalendarParser, feeds the CNewsFilter engine, which tells NewsFilterEA whether to allow or block the trade.


Section 1: The Economic Calendar File Format

The engine reads the Forex Factory weekly CSV export, which is the most widely used free calendar format among retail traders. Each row of the economic calendar file is one event record with eight columns:

Date,Time,Currency,Impact,Event,Actual,Forecast,Previous
"Jul 14, 2026","8:30am",USD,High,Core CPI m/m,0.3%,0.2%,0.4%
"Jul 14, 2026","8:30am",USD,High,CPI m/m,0.4%,0.3%,0.3%
"Jul 14, 2026","10:00am",USD,Medium,Prelim UoM Sentiment,68.5,67.2,65.6
"Jul 15, 2026","12:30pm",GBP,High,CPI y/y,2.8%,2.7%,3.1%

The columns are:

  • Date: the event date in the form "Jul 14, 2026". The field is wrapped in double quotes because it contains a comma, which means a naive StringSplit() on commas would break this field into two pieces. The parser must respect quotes when splitting.
  • Time: the event time in 12-hour am/pm format, such as "8:30am" or "12:30pm". The am/pm conversion has two traps: 12:00am is midnight (hour 0) and 12:00pm is noon (hour 12). A Python simulation run before writing this article confirmed the conversion logic for both.
  • Currency: the three-letter currency code the event affects, such as USD or GBP. This is what the currency filter matches against the symbol's base or quote currency.
  • Impact: the impact level as a string: High, Medium, or Low. The parser maps this to an enum immediately so the hot path never compares strings.
  • Event: the event title, used in the reason string and in log output.
  • Actual, Forecast, Previous: the published figures. These may be empty strings for future events, and the parser must tolerate that. The filter itself never uses these three columns, but the splitter must still handle them without failing.

To install the file, the weekly export from Forex Factory, save it as sample_calendar.csv (or any name you pass to Init()), and place it in the terminal's data folder under MQL5/Files/. You can open that folder from the terminal via File, Open Data Folder. Files outside MQL5/Files/ are not readable by FileOpen() under the terminal's sandbox, so this location is mandatory.

One caveat belongs here and returns in the Limitations section with more weight. The Forex Factory export stamps its times in US Eastern Time by default, while TimeCurrent() gives you broker server time. Before those two can be compared meaningfully you must either pick your broker's timezone on the export page or apply a fixed offset yourself. The engine assumes the file already speaks server time; making that true is the operator's job.


Section 2 — CNewsEvent and ENUM_NEWS_IMPACT

The first structural choice is to keep parsed data apart from the code that parses it. Once a line of text becomes a typed record, the parser is finished with it, and everything downstream works only with the typed form. That is why the event record lives in its own header with zero dependencies: every other component includes it, and none of them ever needs to remember that a CSV file was involved.

The struct carries four fields, each chosen for how it will be used later rather than how it looked in the file. event_time is a datetime, which in MQL5 is just seconds since 1970, so the whole news window calculation becomes ordinary integer arithmetic on this one value. currency stores the three-letter code verbatim. impact is the severity as an enum instead of a string. title keeps the readable name around purely so a log line can say NFP rather than something like event number seven.

The enum needs a short explanation. The filter runs on every tick and scans the event array. Using an enum avoids repeated string comparisons and reduces the impact check to a single integer test: events[i].impact < min_impact. The numeric order is deliberate. Low is zero, Medium is one, High is two, so "at least this severe" is nothing more than a >=. The one string-to-enum translation happens exactly once, at parse time.

//+------------------------------------------------------------------+
//|                                                    NewsEvent.mqh |
//+------------------------------------------------------------------+
#ifndef NEWSEVENT_MQH
#define NEWSEVENT_MQH

//+------------------------------------------------------------------+
//| ENUM_NEWS_IMPACT                                                 |
//| Severity classification of an event record. The numeric order    |
//| is deliberate so that a minimum impact level can be applied with |
//| a single integer comparison at evaluation time.                  |
//+------------------------------------------------------------------+
enum ENUM_NEWS_IMPACT
  {
   NEWS_IMPACT_LOW    = 0,  // low impact level
   NEWS_IMPACT_MEDIUM = 1,  // medium impact level
   NEWS_IMPACT_HIGH   = 2   // high impact level
  };

//+------------------------------------------------------------------+
//| CNewsEvent                                                       |
//| One parsed event record from the economic calendar file. Holds   |
//| the event time as a datetime, the affected currency code, the    |
//| impact level as an enum, and the event title for log output.     |
//+------------------------------------------------------------------+
struct CNewsEvent
  {
   datetime          event_time;  // event date and time in server time
   string            currency;    // three-letter currency code, e.g. "USD"
   ENUM_NEWS_IMPACT  impact;      // impact level mapped from the CSV string
   string            title;       // event title, e.g. "Core CPI m/m"
  };

#endif // NEWSEVENT_MQH
//+------------------------------------------------------------------+


Section 3 — CCalendarParser: Reading and Parsing the CSV

The parser has exactly one responsibility: turn the economic calendar file into an array of event records. Its public surface is small. Parse() loads the file, MapImpact() is exposed so the verification script can test the mapping directly, and SkippedRows() reports how many bad lines were dropped along the way. The private helpers below carry the real machinery.

Here is the class declaration in full, so the method definitions that follow have a shape to hang on.

//+------------------------------------------------------------------+
//|                                               CalendarParser.mqh |
//+------------------------------------------------------------------+
#ifndef CALENDARPARSER_MQH
#define CALENDARPARSER_MQH

#include "NewsEvent.mqh"

//+------------------------------------------------------------------+
//| CCalendarParser                                                  |
//| Opens the economic calendar file, reads it line by line, and     |
//| converts each valid row into a CNewsEvent record. Malformed rows |
//| are logged and skipped without aborting the parse.               |
//+------------------------------------------------------------------+
class CCalendarParser
  {
private:
   int               m_skipped_rows;

   bool              ParseLine(const string line, CNewsEvent &event);
   int               SplitCsvLine(const string line, string &fields[]);
   bool              ParseEventTime(const string date_field, const string time_field, datetime &result);
   int               MonthNumber(const string month_name);
   bool              ParseClockTime(const string time_field, int &hour, int &minute);

public:
                     CCalendarParser(void);
                    ~CCalendarParser(void);

   int               Parse(const string filename, CNewsEvent &events[]);
   ENUM_NEWS_IMPACT  MapImpact(const string impact_text);
   int               SkippedRows(void) const { return(m_skipped_rows); }
  };

The constructor zeroes the skipped-row counter so a fresh parser starts from a known state, and the destructor has nothing to release because the class owns no handles or memory of its own.

//+------------------------------------------------------------------+
//| Constructor: Resets the skipped-row counter.                     |
//+------------------------------------------------------------------+
CCalendarParser::CCalendarParser(void)
  {
//--- start with no skipped rows recorded
   m_skipped_rows = 0;
  }

//+------------------------------------------------------------------+
//| Destructor: No resources are owned, so nothing to release.       |
//+------------------------------------------------------------------+
CCalendarParser::~CCalendarParser(void)
  {
  }

Parse() opens the file in text mode with FILE_READ | FILE_TXT | FILE_ANSI, which makes each FileReadString() hand back one complete line. The header line is read and thrown away. From there every line runs through ParseLine(); a good line appends an event record to the output array, and a bad line increments the skipped counter, prints the offending text, and moves on.

That skip-and-continue behavior is the single most important policy in the class. A truncated download, a stray blank line, or a typo from a hand edit must never abort the whole parse, because a parse that aborts leaves the EA holding zero events, and zero events means the filter waves every trade through in silence. Returning a partial-but-usable array is strictly safer than returning nothing.

//+------------------------------------------------------------------+
//| Parse                                                            |
//+------------------------------------------------------------------+
int CCalendarParser::Parse(const string filename, CNewsEvent &events[])
  {
//--- reset state from any previous parse
   m_skipped_rows = 0;
   ::ArrayResize(events, 0);
//--- open the economic calendar file in text mode for line reading
   int handle = ::FileOpen(filename, FILE_READ | FILE_TXT | FILE_ANSI);
   if(handle == INVALID_HANDLE)
     {
      ::PrintFormat("CCalendarParser: cannot open '%s', error %d", filename, ::GetLastError());
      return(-1);
     }
//--- read and discard the header row
   if(!::FileIsEnding(handle))
      ::FileReadString(handle);
//--- read every remaining line and parse it into an event record
   int count = 0;
   while(!::FileIsEnding(handle))
     {
      string line = ::FileReadString(handle);
      //--- ignore blank lines silently, they carry no data
      if(::StringLen(line) == 0)
         continue;
      //--- attempt to parse the line; log and skip on failure
      CNewsEvent event;
      if(!ParseLine(line, event))
        {
         m_skipped_rows++;
         ::PrintFormat("CCalendarParser: skipping malformed row: %s", line);
         continue;
        }
      //--- append the parsed event record to the output array
      ::ArrayResize(events, count + 1);
      events[count] = event;
      count++;
     }
//--- release the file handle and report the total
   ::FileClose(handle);
   return(count);
  }

ParseLine() is the gatekeeper for a single row. It splits the line into fields, then insists on the five leading columns it actually needs: Date, Time, Currency, Impact, and Event. The trailing three columns are welcome to be empty or missing. If the date and time cannot be assembled into a valid datetime, or if the currency code is not exactly three characters after trimming, the row is rejected and Parse() treats it as malformed.

//+------------------------------------------------------------------+
//| ParseLine                                                        |
//+------------------------------------------------------------------+
bool CCalendarParser::ParseLine(const string line, CNewsEvent &event)
  {
//--- split the row into fields while respecting quoted commas
   string fields[];
   int    field_count = SplitCsvLine(line, fields);
   if(field_count < 5)
      return(false);
//--- convert the quoted date and am/pm time into a datetime
   datetime event_time = 0;
   if(!ParseEventTime(fields[0], fields[1], event_time))
      return(false);
//--- reject rows with a missing currency code
   string currency = fields[2];
   ::StringTrimLeft(currency);
   ::StringTrimRight(currency);
   if(::StringLen(currency) != 3)
      return(false);
//--- populate the event record
   event.event_time = event_time;
   event.currency   = currency;
   event.impact     = MapImpact(fields[3]);
   event.title      = fields[4];
   return(true);
  }

SplitCsvLine exists because StringSplit() has no concept of quoting, and the date column depends on it. SplitCsvLine() walks the line one character at a time and keeps a single boolean, in_quotes. A double quote flips that boolean and is itself discarded. A comma ends the current field only when the boolean is off; a comma seen while inside quotes is treated as ordinary text. The upshot is that "Jul 14, 2026" emerges as the field Jul 14, 2026, comma intact and quotes gone.

//+------------------------------------------------------------------+
//| SplitCsvLine                                                     |
//+------------------------------------------------------------------+
int CCalendarParser::SplitCsvLine(const string line, string &fields[])
  {
//--- prepare an empty output array and scanning state
   ::ArrayResize(fields, 0);
   string current   = "";
   bool   in_quotes = false;
   int    length    = ::StringLen(line);
   int    count     = 0;
//--- walk the line character by character
   for(int i = 0; i < length; i++)
     {
      ushort ch = ::StringGetCharacter(line, i);
      //--- a quote toggles the in-quotes state and is not stored
      if(ch == '"')
        {
         in_quotes = !in_quotes;
         continue;
        }
      //--- an unquoted comma closes the current field
      if(ch == ',' && !in_quotes)
        {
         ::ArrayResize(fields, count + 1);
         fields[count] = current;
         count++;
         current = "";
         continue;
        }
      //--- any other character is appended to the current field
      current += ::ShortToString(ch);
     }
//--- store the final field after the last separator
   ::ArrayResize(fields, count + 1);
   fields[count] = current;
   count++;
   return(count);
  }

With the fields separated, ParseEventTime() fuses the date and time columns into one datetime. It trims the date, deletes the embedded comma, and splits on spaces, where StringSplit() is now perfectly safe because no commas remain. The month name goes through MonthNumber(), the day and year are read as integers and sanity-checked, and the clock time is handed to ParseClockTime(). The pieces are then formatted into the canonical yyyy.mm.dd hh:mi string that StringToTime() understands, and the result is only accepted if it converts to a positive value.

//+------------------------------------------------------------------+
//| ParseEventTime                                                   |
//+------------------------------------------------------------------+
bool CCalendarParser::ParseEventTime(const string date_field, const string time_field, datetime &result)
  {
//--- normalize the date field and drop the embedded comma
   string date_clean = date_field;
   ::StringTrimLeft(date_clean);
   ::StringTrimRight(date_clean);
   ::StringReplace(date_clean, ",", "");
//--- split "Jul 14 2026" into month, day, and year tokens
   string tokens[];
   if(::StringSplit(date_clean, ' ', tokens) != 3)
      return(false);
//--- resolve the month name to its number
   int month = MonthNumber(tokens[0]);
   if(month == 0)
      return(false);
//--- convert the day and year tokens to integers
   int day  = (int)::StringToInteger(tokens[1]);
   int year = (int)::StringToInteger(tokens[2]);
   if(day < 1 || day > 31 || year < 2000)
      return(false);
//--- convert the am/pm clock time to 24-hour components
   int hour   = 0;
   int minute = 0;
   if(!ParseClockTime(time_field, hour, minute))
      return(false);
//--- assemble the canonical datetime string and convert it
   string stamp = ::StringFormat("%04d.%02d.%02d %02d:%02d", year, month, day, hour, minute);
   result = ::StringToTime(stamp);
   return(result > 0);
  }

A small lookup translates the three-letter English month abbreviation the export uses into a number from one to twelve. An unrecognized name returns zero, which ParseEventTime() reads as a failure and rejects.

//+------------------------------------------------------------------+
//| MonthNumber                                                      |
//+------------------------------------------------------------------+
int CCalendarParser::MonthNumber(const string month_name)
  {
//--- compare against each abbreviation used by the export format
   if(month_name == "Jan")
      return(1);
   if(month_name == "Feb")
      return(2);
   if(month_name == "Mar")
      return(3);
   if(month_name == "Apr")
      return(4);
   if(month_name == "May")
      return(5);
   if(month_name == "Jun")
      return(6);
   if(month_name == "Jul")
      return(7);
   if(month_name == "Aug")
      return(8);
   if(month_name == "Sep")
      return(9);
   if(month_name == "Oct")
      return(10);
   if(month_name == "Nov")
      return(11);
   if(month_name == "Dec")
      return(12);
//--- unknown month name
   return(0);
  }

ParseClockTime() parses am/pm time values. It lowercases the input, peels off the last two characters as the am/pm marker, and splits the rest on the colon into hour and minute. Then it applies the rules that trip people up: an hour of twelve in the am half becomes zero, and any pm hour other than twelve gains twelve. A 12:00am reads as 00:00, a 12:00pm stays at 12:00, and everything in between falls out correctly.

//+------------------------------------------------------------------+
//| ParseClockTime                                                   |
//+------------------------------------------------------------------+
bool CCalendarParser::ParseClockTime(const string time_field, int &hour, int &minute)
  {
//--- normalize case and whitespace
   string clock = time_field;
   ::StringTrimLeft(clock);
   ::StringTrimRight(clock);
   ::StringToLower(clock);
   int length = ::StringLen(clock);
   if(length < 6)
      return(false);
//--- separate the am/pm marker from the numeric part
   string marker  = ::StringSubstr(clock, length - 2, 2);
   string numeric = ::StringSubstr(clock, 0, length - 2);
   if(marker != "am" && marker != "pm")
      return(false);
//--- split the numeric part into hour and minute
   string parts[];
   if(::StringSplit(numeric, ':', parts) != 2)
      return(false);
   hour   = (int)::StringToInteger(parts[0]);
   minute = (int)::StringToInteger(parts[1]);
   if(hour < 1 || hour > 12 || minute < 0 || minute > 59)
      return(false);
//--- apply the 12-hour conversion rules
   if(marker == "am" && hour == 12)
      hour = 0;
   if(marker == "pm" && hour != 12)
      hour += 12;
   return(true);
  }

The last helper turns the impact string into the enum. High, Medium, and Low map to their obvious counterparts, and anything the parser does not recognize falls back to NEWS_IMPACT_LOW. That default is chosen on purpose: a low impact level never triggers a block by itself, so an unfamiliar or corrupted severity string fails toward permissiveness rather than blocking trades for no reason.

//+------------------------------------------------------------------+
//| MapImpact                                                        |
//+------------------------------------------------------------------+
ENUM_NEWS_IMPACT CCalendarParser::MapImpact(const string impact_text)
  {
//--- normalize the input before comparing
   string impact = impact_text;
   ::StringTrimLeft(impact);
   ::StringTrimRight(impact);
//--- map the three recognized impact level strings
   if(impact == "High")
      return(NEWS_IMPACT_HIGH);
   if(impact == "Medium")
      return(NEWS_IMPACT_MEDIUM);
   if(impact == "Low")
      return(NEWS_IMPACT_LOW);
//--- safe default for anything unrecognized
   return(NEWS_IMPACT_LOW);
  }



Section 4 — CCurrencyExtractor: Symbol Currency Identification

An event record names a currency; a chart names a symbol. The currency filter is the bridge across that gap, and the tempting shortcut of slicing StringSubstr(symbol, 0, 3) and StringSubstr(symbol, 3, 3) collapses the moment a broker suffix shows up. Live symbol names look like EURUSDm, EURUSD.c, GBPJPY.pro, or USDJPYmicro. If the filter cannot recognize USD inside EURUSDm, the EA sails through NFP on precisely the account where the trader believed they were covered, and the only trace is a line in the account history after the damage is done.

The class is stateless and its public surface has three methods. Here is the declaration:

//+------------------------------------------------------------------+
//|                                            CurrencyExtractor.mqh |
//+------------------------------------------------------------------+
#ifndef CURRENCYEXTRACTOR_MQH
#define CURRENCYEXTRACTOR_MQH

//+------------------------------------------------------------------+
//| CCurrencyExtractor                                               |
//| Implements the currency filter primitives: extracts the base and |
//| quote currencies from a symbol name after stripping any broker   |
//| suffix, and matches an event currency against either side.       |
//+------------------------------------------------------------------+
class CCurrencyExtractor
  {
private:
   string            StripSuffix(const string symbol) const;

public:
                     CCurrencyExtractor(void);
                    ~CCurrencyExtractor(void);

   string            GetBaseCurrency(const string symbol) const;
   string            GetQuoteCurrency(const string symbol) const;
   bool              MatchesCurrency(const string symbol, const string currency) const;
  };

//+------------------------------------------------------------------+
//| Constructor: The class is stateless, so nothing to initialize.   |
//+------------------------------------------------------------------+
CCurrencyExtractor::CCurrencyExtractor(void)
  {
  }

//+------------------------------------------------------------------+
//| Destructor: No resources are owned, so nothing to release.       |
//+------------------------------------------------------------------+
CCurrencyExtractor::~CCurrencyExtractor(void)
  {
  }

StripSuffix() does the heavy lifting behind the whole filter. It scans the symbol from the left and keeps characters only as long as they are letters, so a separator-style suffix like the .c in EURUSD.c stops the scan and leaves EURUSD behind. That rule alone does not deal with EURUSDm, where the suffix is a bare letter with no separator, so a second rule truncates any all-alphabetic result longer than six characters back down to six. Finally the result is uppercased so it compares cleanly against the codes in the CSV.

//+------------------------------------------------------------------+
//| StripSuffix                                                      |
//+------------------------------------------------------------------+
string CCurrencyExtractor::StripSuffix(const string symbol) const
  {
//--- collect leading alphabetic characters only
   string clean  = "";
   int    length = ::StringLen(symbol);
   for(int i = 0; i < length; i++)
     {
      ushort ch = ::StringGetCharacter(symbol, i);
      //--- stop at the first non-alphabetic character
      bool is_alpha = (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z');
      if(!is_alpha)
         break;
      clean += ::ShortToString(ch);
     }
//--- truncate alphabetic suffixes such as the "m" in "EURUSDm"
   if(::StringLen(clean) > 6)
      clean = ::StringSubstr(clean, 0, 6);
//--- uppercase for comparison against CSV currency codes
   ::StringToUpper(clean);
   return(clean);
  }

GetBaseCurrency() and GetQuoteCurrency()

These two are thin readers on top of the cleaned symbol, and they share enough logic to explain together. Each first calls StripSuffix(), and each bails out with an empty string if what comes back is shorter than six characters, since a shorter core cannot hold a valid pair. From the six-character core, the base is characters zero through two and the quote is characters three through five.

//+------------------------------------------------------------------+
//| GetBaseCurrency                                                  |
//+------------------------------------------------------------------+
string CCurrencyExtractor::GetBaseCurrency(const string symbol) const
  {
//--- strip any broker suffix before extracting
   string clean = StripSuffix(symbol);
   if(::StringLen(clean) < 6)
      return("");
//--- the base currency is characters 0 through 2
   return(::StringSubstr(clean, 0, 3));
  }

//+------------------------------------------------------------------+
//| GetQuoteCurrency                                                 |
//+------------------------------------------------------------------+
string CCurrencyExtractor::GetQuoteCurrency(const string symbol) const
  {
//--- strip any broker suffix before extracting
   string clean = StripSuffix(symbol);
   if(::StringLen(clean) < 6)
      return("");
//--- the quote currency is characters 3 through 5
   return(::StringSubstr(clean, 3, 3));
  }

MatchesCurrency() answers the only question the rest of the engine ever asks of this class: does a given event currency touch either side of this symbol? It uppercases the incoming code and returns true if it equals the base or the quote. This is the decision that both the window checker and the chart drawer lean on.

//+------------------------------------------------------------------+
//| MatchesCurrency                                                  |
//+------------------------------------------------------------------+
bool CCurrencyExtractor::MatchesCurrency(const string symbol, const string currency) const
  {
//--- normalize the event currency for comparison
   string wanted = currency;
   ::StringToUpper(wanted);
//--- match against either side of the pair
   if(GetBaseCurrency(symbol) == wanted)
      return(true);
   if(GetQuoteCurrency(symbol) == wanted)
      return(true);
   return(false);
  }


Section 5 — CNewsWindowChecker: Evaluating the Time Window

The checker is where the engine actually decides. For each event record it runs three filters in cheapest-first order, and only a record that clears all three produces a news window. The impact filter comes first because events[i].impact < min_impact is a single integer test that discards most rows instantly. The currency filter comes second, handed off to CCurrencyExtractor. The time check comes last: the news window is the closed interval from event_time - minutes_before * 60 to event_time + minutes_after * 60, and the moment being tested is inside when it is at or after the start and at or before the end. Both ends are inclusive, a detail the verification script pins down deliberately.

When a record matches, the checker composes a reason string and returns without looking further. The format is a contract the EA and the tests both depend on: BLOCKED: NFP in 23 minutes while the event is still ahead, and BLOCKED: NFP ended 7 minutes ago once it has passed. The minute figures come from integer division of the signed second delta, so partial minutes truncate toward zero, which is how a countdown reads to a human anyway. With no match at all, the reason is set to CLEAR.

One decision in this class is about testability. The public Check() uses TimeCurrent() as production demands, but a script cannot move TimeCurrent(), and a boundary that is never evaluated exactly on its edge is a boundary you are only guessing about. So the comparison lives in CheckAt(), which accepts the evaluation time as a parameter, and Check() is a one-line wrapper that feeds it TimeCurrent(). Production calls Check(); the tests call CheckAt() with synthetic times placed right on, just inside, and just outside each edge.

Here is the declaration and the constructor and destructor:

//+------------------------------------------------------------------+
//|                                            NewsWindowChecker.mqh |
//+------------------------------------------------------------------+
#ifndef NEWSWINDOWCHECKER_MQH
#define NEWSWINDOWCHECKER_MQH

#include "NewsEvent.mqh"
#include "CurrencyExtractor.mqh"

//+------------------------------------------------------------------+
//| CNewsWindowChecker                                               |
//| Evaluates whether a given time falls inside the news window of   |
//| any event record that passes the impact filter and the currency  |
//| filter, and produces the human-readable reason string.           |
//+------------------------------------------------------------------+
class CNewsWindowChecker
  {
private:
   string             m_reason;
   CCurrencyExtractor m_extractor;

public:
                     CNewsWindowChecker(void);
                    ~CNewsWindowChecker(void);

   bool              Check(const CNewsEvent &events[], int count, const string symbol,
                           int minutes_before, int minutes_after, ENUM_NEWS_IMPACT min_impact);
   bool              CheckAt(const CNewsEvent &events[], int count, const string symbol,
                             int minutes_before, int minutes_after, ENUM_NEWS_IMPACT min_impact,
                             const datetime now);
   string            GetReason(void) const { return(m_reason); }
  };

//+------------------------------------------------------------------+
//| Constructor: Starts with a clear reason string.                  |
//+------------------------------------------------------------------+
CNewsWindowChecker::CNewsWindowChecker(void)
  {
//--- no evaluation has happened yet
   m_reason = "CLEAR";
  }

//+------------------------------------------------------------------+
//| Destructor: No resources are owned, so nothing to release.       |
//+------------------------------------------------------------------+
CNewsWindowChecker::~CNewsWindowChecker(void)
  {
  }

Check()

The production entry point is a single delegating line. It exists so callers have a clean signature that always means "evaluate right now," while the real logic stays in the parameterized version below it.

//+------------------------------------------------------------------+
//| Check                                                            |
//+------------------------------------------------------------------+
bool CNewsWindowChecker::Check(const CNewsEvent &events[], int count, const string symbol,
                               int minutes_before, int minutes_after, ENUM_NEWS_IMPACT min_impact)
  {
//--- evaluate at the current server time
   return(CheckAt(events, count, symbol, minutes_before, minutes_after, min_impact, ::TimeCurrent()));
  }

CheckAt() holds the whole decision. It loops the array, drops any record below the minimum impact level, then drops any record whose currency does not touch the symbol. For the survivors it computes the inclusive window and skips the record if the evaluation time falls outside it. On the first record that passes every test, it measures the signed distance in seconds to build the appropriate reason string and returns true. If the loop ends with no match, it sets the reason to CLEAR and returns false.

//+------------------------------------------------------------------+
//| CheckAt                                                          |
//+------------------------------------------------------------------+
bool CNewsWindowChecker::CheckAt(const CNewsEvent &events[], int count, const string symbol,
                                 int minutes_before, int minutes_after, ENUM_NEWS_IMPACT min_impact,
                                 const datetime now)
  {
//--- scans every event record until the first match
   for(int i = 0; i < count; i++)
     {
      //--- impact filter: cheapest test first
      if(events[i].impact < min_impact)
         continue;
      //--- currency filter: the event must touch the base or quote
      if(!m_extractor.MatchesCurrency(symbol, events[i].currency))
         continue;
      //--- computes the inclusive news window boundaries
      datetime window_start = events[i].event_time - (datetime)(minutes_before * 60);
      datetime window_end   = events[i].event_time + (datetime)(minutes_after * 60);
      if(now < window_start || now > window_end)
         continue;
      //--- inside the news window: build the reason string
      long delta_seconds = (long)events[i].event_time - (long)now;
      if(delta_seconds >= 0)
        {
         //--- pre-event case: the event is still ahead
         int minutes_ahead = (int)(delta_seconds / 60);
         m_reason = ::StringFormat("BLOCKED: %s in %d minutes", events[i].title, minutes_ahead);
        }
      else
        {
         //--- post-event case: the event has already passed
         int minutes_ago = (int)(-delta_seconds / 60);
         m_reason = ::StringFormat("BLOCKED: %s ended %d minutes ago", events[i].title, minutes_ago);
        }
      return(true);
     }
//--- no event record matched: trading is allowed
   m_reason = "CLEAR";
   return(false);
  }


Section 6 — CChartZoneDrawer: Visualizing No-Trade Zones

A filter that only writes to the Experts tab is hard to trust at a glance. A filter that paints its windows onto the chart is easy to check with a single look. The drawer builds one pair of shaded OBJ_RECTANGLE objects for every qualifying event of the current day: a pre-event buffer in a warm tone, clrMistyRose, and a post-event buffer in a cool tone, clrLavender. The two colors let the trader see not only where a window sits but which side of the event they are on. Only high-impact events that clear the currency filter for the chart symbol get drawn, matching what the filter blocks by default.

Rectangles in MQL5 are anchored by two time-and-price corners. The time corners come from the same window arithmetic used everywhere else, and the price corners come from ChartGetDouble() with CHART_PRICE_MAX and CHART_PRICE_MIN, so each zone fills the full visible height of the chart. The design that ties the class together is a naming convention. Every object name begins with the fixed prefix NFZ_, followed by the event index and a _PRE or _POST tail, giving names like NFZ_3_PRE. Because that prefix is unique and constant, cleanup can find and remove exactly the drawer's own objects and nothing else.

Here is the declaration, constructor, and destructor. Note that the destructor clears the zones, so an owning object that goes out of scope leaves the chart tidy.

//+------------------------------------------------------------------+
//|                                              ChartZoneDrawer.mqh |
//+------------------------------------------------------------------+
#ifndef CHARTZONEDRAWER_MQH
#define CHARTZONEDRAWER_MQH

#include "NewsEvent.mqh"
#include "CurrencyExtractor.mqh"

//+------------------------------------------------------------------+
//| CChartZoneDrawer                                                 |
//| Draws shaded rectangle zones on the chart for every high-impact  |
//| event window of the current trading day, using distinct colors   |
//| for the pre-event buffer and the post-event buffer, and removes  |
//| them cleanly by prefix.                                          |
//+------------------------------------------------------------------+
class CChartZoneDrawer
  {
private:
   string             m_prefix;
   color              m_pre_color;
   color              m_post_color;
   CCurrencyExtractor m_extractor;

   bool              SameDay(const datetime a, const datetime b) const;
   void              DrawZone(const string name, const datetime t1, const datetime t2,
                              const double p1, const double p2, const color zone_color);

public:
                     CChartZoneDrawer(void);
                    ~CChartZoneDrawer(void);

   void              DrawDayZones(const CNewsEvent &events[], int count, const string symbol,
                                  int minutes_before, int minutes_after);
   void              ClearZones(void);
  };

//+------------------------------------------------------------------+
//| Constructor: Fixes the object name prefix and the zone colors.   |
//+------------------------------------------------------------------+
CChartZoneDrawer::CChartZoneDrawer(void)
  {
//--- the prefix keeps our objects separable from everything else
   m_prefix     = "NFZ_";
   m_pre_color  = clrMistyRose;
   m_post_color = clrLavender;
  }

//+------------------------------------------------------------------+
//| Destructor: Removes all zones this drawer created.               |
//+------------------------------------------------------------------+
CChartZoneDrawer::~CChartZoneDrawer(void)
  {
//--- leaves the chart clean when the owner is destroyed
   ClearZones();
  }

DrawDayZones() is the public workhorse. It begins by calling ClearZones(), which makes the method idempotent: calling it twice never stacks duplicate rectangles. It reads the visible price range once, then loops the event array. A record is drawn only if it is high impact, only if its currency touches the symbol, and only if it falls on the current day as decided by SameDay(). For each survivor it computes the window boundaries and draws two rectangles, the warm pre-event zone and the cool post-event zone, then asks the chart to repaint so the zones appear right away.

//+------------------------------------------------------------------+
//| DrawDayZones                                                     |
//+------------------------------------------------------------------+
void CChartZoneDrawer::DrawDayZones(const CNewsEvent &events[], int count, const string symbol,
                                    int minutes_before, int minutes_after)
  {
//--- redrawing must never stack duplicates
   ClearZones();
//--- span the full visible price range of the chart
   double   price_max = ::ChartGetDouble(0, CHART_PRICE_MAX);
   double   price_min = ::ChartGetDouble(0, CHART_PRICE_MIN);
   datetime today     = ::TimeCurrent();
//--- draws a zone pair for each qualifying event record
   for(int i = 0; i < count; i++)
     {
      //--- only high-impact events are drawn
      if(events[i].impact != NEWS_IMPACT_HIGH)
         continue;
      //--- currency filter for the chart symbol
      if(!m_extractor.MatchesCurrency(symbol, events[i].currency))
         continue;
      //--- restricts to the current trading day
      if(!SameDay(events[i].event_time, today))
         continue;
      //--- computes the news window boundaries
      datetime window_start = events[i].event_time - (datetime)(minutes_before * 60);
      datetime window_end   = events[i].event_time + (datetime)(minutes_after * 60);
      //--- pre-event buffer zone in the warm color
      string pre_name = ::StringFormat("%s%d_PRE", m_prefix, i);
      DrawZone(pre_name, window_start, events[i].event_time, price_min, price_max, m_pre_color);
      //--- post-event buffer zone in the cool color
      string post_name = ::StringFormat("%s%d_POST", m_prefix, i);
      DrawZone(post_name, events[i].event_time, window_end, price_min, price_max, m_post_color);
     }
//--- requests a repaint so the zones appear immediately
   ::ChartRedraw(0);
  }

ClearZones()

Cleanup walks the object list backward, because deleting an object reindexes the list and a forward loop would skip entries. For each object it tests the name with StringFind() and deletes it only when the prefix sits at position zero. That test is what keeps the drawer from touching trendlines, arrows, or anything else the trader placed by hand.

//+------------------------------------------------------------------+
//| ClearZones                                                       |
//+------------------------------------------------------------------+
void CChartZoneDrawer::ClearZones(void)
  {
//--- walks backward because deletion reindexes the object list
   int total = ::ObjectsTotal(0, -1, -1);
   for(int i = total - 1; i >= 0; i--)
     {
      string name = ::ObjectName(0, i, -1, -1);
      //--- delete only objects that carry our prefix
      if(::StringFind(name, m_prefix) == 0)
         ::ObjectDelete(0, name);
     }
//--- requests a repaint so the removal is visible
   ::ChartRedraw(0);
  }

SameDay()

A short predicate decides whether two datetime values land on the same calendar day. It decomposes each into an MqlDateTime and compares year, month, and day. DrawDayZones() uses it to keep the chart focused on today's events rather than the whole file.

//+------------------------------------------------------------------+
//| SameDay                                                          |
//| Returns true when two datetimes fall on the same calendar day.   |
//+------------------------------------------------------------------+
bool CChartZoneDrawer::SameDay(const datetime a, const datetime b) const
  {
//--- decomposes both datetimes into calendar components
   MqlDateTime da;
   MqlDateTime db;
   ::TimeToStruct(a, da);
   ::TimeToStruct(b, db);
//--- compares year, month, and day
   return(da.year == db.year && da.mon == db.mon && da.day == db.day);
  }

DrawZone()

The last helper creates a single rectangle and styles it. It anchors the object between the two time-and-price corners, then sets it to fill, pushes it to the background so candles stay readable on top, and marks it non-selectable and hidden so it never gets in the way of manual chart work or clutters the object list.

//+------------------------------------------------------------------+
//| DrawZone                                                         |
//+------------------------------------------------------------------+
void CChartZoneDrawer::DrawZone(const string name, const datetime t1, const datetime t2,
                                const double p1, const double p2, const color zone_color)
  {
//--- creates the rectangle anchored by time and price
   if(!::ObjectCreate(0, name, OBJ_RECTANGLE, 0, t1, p1, t2, p2))
      return;
//--- styles it as a filled background zone
   ::ObjectSetInteger(0, name, OBJPROP_COLOR, zone_color);
   ::ObjectSetInteger(0, name, OBJPROP_FILL, true);
   ::ObjectSetInteger(0, name, OBJPROP_BACK, true);
   ::ObjectSetInteger(0, name, OBJPROP_SELECTABLE, false);
   ::ObjectSetInteger(0, name, OBJPROP_HIDDEN, true);
  }


Section 7 — CNewsFilter: The Public Interface

Everything up to now has been a part. CNewsFilter is the whole. The EA includes one header, builds one object, and calls a handful of methods. Inside, the filter holds a parser, a checker, and a drawer as plain member objects, so there are no pointers to manage and no lifetime bookkeeping to get wrong. Alongside them sits the event array that the parser fills once and the other two only read. Because that array is loaded at Init() and never changed afterward, the checker and drawer can safely accept it as const on every call.

Here is the declaration, followed by the constructor and destructor:

//+------------------------------------------------------------------+
//|                                                   NewsFilter.mqh |
//+------------------------------------------------------------------+
#ifndef NEWSFILTER_MQH
#define NEWSFILTER_MQH

#include "NewsEvent.mqh"
#include "CalendarParser.mqh"
#include "NewsWindowChecker.mqh"
#include "ChartZoneDrawer.mqh"

//+------------------------------------------------------------------+
//| CNewsFilter                                                      |
//| The public interface of the engine. Owns the parser, the window  |
//| checker, and the zone drawer, holds the loaded event records,    |
//| and exposes the block decision, the reason string, and the chart |
//| zone drawing to the calling program.                             |
//+------------------------------------------------------------------+
class CNewsFilter
  {
private:
   CCalendarParser    m_parser;
   CNewsWindowChecker m_checker;
   CChartZoneDrawer   m_drawer;
   CNewsEvent         m_events[];
   int                m_event_count;
   ENUM_NEWS_IMPACT   m_min_impact;

public:
                     CNewsFilter(void);
                    ~CNewsFilter(void);

   bool              Init(const string filename);
   bool              IsNewsWindow(const string symbol, int minutes_before, int minutes_after);
   string            GetLastReason(void) const;
   void              DrawChartZones(const string symbol, int minutes_before, int minutes_after);
   void              ClearChartZones(void);
   void              SetMinImpact(const ENUM_NEWS_IMPACT min_impact);
   int               GetEventCount(void) const { return(m_event_count); }
  };

//+------------------------------------------------------------------+
//| Constructor: Starts empty with the default minimum impact level  |
//| of NEWS_IMPACT_HIGH.                                             |
//+------------------------------------------------------------------+
CNewsFilter::CNewsFilter(void)
  {
//--- no events are loaded until Init() runs
   m_event_count = 0;
   m_min_impact  = NEWS_IMPACT_HIGH;
  }

//+------------------------------------------------------------------+
//| Destructor: Member objects clean up after themselves; the drawer |
//| removes its chart zones in its own destructor.                   |
//+------------------------------------------------------------------+
CNewsFilter::~CNewsFilter(void)
  {
  }

The constructor starts empty with the default minimum impact level set to High, and the destructor leaves cleanup to the member objects, since the drawer already wipes its own zones when it is destroyed.

Init() runs the parser, records the event count, and then applies a sanity gate. A failed file open or a count of zero returns false, and that return is meaningful: an empty filter behaves identically to a working one right up until the first news spike proves otherwise. Handing the caller a clean false lets the EA refuse to start rather than run with a filter that silently permits everything.

//+------------------------------------------------------------------+
//| Init                                                             |
//+------------------------------------------------------------------+
bool CNewsFilter::Init(const string filename)
  {
//--- parse the economic calendar file into the event array
   m_event_count = m_parser.Parse(filename, m_events);
//--- an empty filter must be treated as a startup failure
   if(m_event_count <= 0)
     {
      ::PrintFormat("CNewsFilter: no event records loaded from '%s'", filename);
      m_event_count = 0;
      return(false);
     }
//--- report the load result including any skipped rows
   ::PrintFormat("CNewsFilter: loaded %d event records from '%s' (%d malformed rows skipped)",
                 m_event_count, filename, m_parser.SkippedRows());
   return(true);
  }

IsNewsWindow() and GetLastReason()

These two form the call site the EA cares about, and they read best as a pair. IsNewsWindow() forwards to the checker's Check() with the filter's configured minimum impact level, and the checker stores its reason internally. GetLastReason() then exposes that reason, so the EA's guard stays a clean one-liner while its log line still carries the full explanation.

//+------------------------------------------------------------------+
//| IsNewsWindow                                                     |
//+------------------------------------------------------------------+
bool CNewsFilter::IsNewsWindow(const string symbol, int minutes_before, int minutes_after)
  {
//--- delegate to the checker at the current server time
   return(m_checker.Check(m_events, m_event_count, symbol,
                          minutes_before, minutes_after, m_min_impact));
  }

//+------------------------------------------------------------------+
//| GetLastReason                                                    |
//+------------------------------------------------------------------+
string CNewsFilter::GetLastReason(void) const
  {
//--- the checker owns the reason from its last evaluation
   return(m_checker.GetReason());
  }

DrawChartZones() and ClearChartZones()

The two chart methods are pure delegation to the drawer, and they belong together for the same reason. One forwards the loaded events and the buffer sizes so the drawer can paint the day's zones; the other asks the drawer to remove them. Keeping them on the facade means the EA never has to reach past CNewsFilter to reach the drawer.

//+------------------------------------------------------------------+
//| DrawChartZones                                                   |
//+------------------------------------------------------------------+
void CNewsFilter::DrawChartZones(const string symbol, int minutes_before, int minutes_after)
  {
//--- delegate to the drawer
   m_drawer.DrawDayZones(m_events, m_event_count, symbol, minutes_before, minutes_after);
  }

//+------------------------------------------------------------------+
//| ClearChartZones                                                  |
//+------------------------------------------------------------------+
void CNewsFilter::ClearChartZones(void)
  {
//--- delegate to the drawer
   m_drawer.ClearZones();
  }

SetMinImpact()

The final method is the one configuration hook. The default threshold is High, and a caller who also wants medium-impact events to block simply lowers it to Medium. Every check after that call uses the new threshold.

//+------------------------------------------------------------------+
//| SetMinImpact                                                     |
//+------------------------------------------------------------------+
void CNewsFilter::SetMinImpact(const ENUM_NEWS_IMPACT min_impact)
  {
//--- store the threshold used by every subsequent check
   m_min_impact = min_impact;
  }


Section 8 — NewsFilterEA.mq5: Integration Demo

The demo EA is meant to show the integration pattern, not to trade a strategy. It leans on three handlers, and each is worth reading on its own.

The inputs and globals come first. The buffer sizes, the file name, and the two safety switches are all inputs, and the module keeps a small amount of state between ticks: the last block decision, the last day drawn, and the last bar seen.

//+------------------------------------------------------------------+
//|                                                 NewsFilterEA.mq5 |
//+------------------------------------------------------------------+

#include <Trade\Trade.mqh>
#include <NewsFilterEngine/NewsFilter.mqh>

//--- Inputs
input string           InpCalendarFile      = "NewsFilterEngine/sample_calendar.csv"; // economic calendar file path
input int              InpMinutesBefore     = 30;                                     // pre-event buffer, minutes
input int              InpMinutesAfter      = 15;                                     // post-event buffer, minutes
input bool             InpBlockMediumImpact = false;                                  // also block medium impact level
input bool             InpEnableTrading     = false;                                  // actually submit demo orders
input double           InpLotSize           = 0.01;                                   // demo order volume

//--- Globals
CNewsFilter g_filter;
CTrade      g_trade;
bool        g_last_blocked = false;
int         g_last_day     = -1;
datetime    g_last_bar     = 0;

OnInit

Startup constructs the filter, calls Init() with the configured file, and returns INIT_FAILED the moment the load fails. That early exit is the point: a missing or empty economic calendar file stops the EA at the door instead of letting it run unprotected. On success it applies the medium-impact option when requested, draws the day's zones, and records today so the zones can be refreshed once per day.

//+------------------------------------------------------------------+
//| OnInit                                                           |
//+------------------------------------------------------------------+
int OnInit(void)
  {
//--- load the economic calendar file; refuse to start on failure
   if(!g_filter.Init(InpCalendarFile))
     {
      Print("NewsFilterEA: filter initialization failed, EA will not start");
      return(INIT_FAILED);
     }
//--- optionally lower the minimum impact level to medium
   if(InpBlockMediumImpact)
      g_filter.SetMinImpact(NEWS_IMPACT_MEDIUM);
//--- draw the news window zones for the current trading day
   g_filter.DrawChartZones(_Symbol, InpMinutesBefore, InpMinutesAfter);
   PrintFormat("NewsFilterEA: started with %d event records", g_filter.GetEventCount());
//--- remember today so zones are redrawn once per day
   MqlDateTime now;
   TimeToStruct(TimeCurrent(), now);
   g_last_day = now.day;
   return(INIT_SUCCEEDED);
  }

OnDeinit

Teardown is one line. When the EA unloads, it clears the chart zones so nothing is left behind.

//+------------------------------------------------------------------+
//| OnDeinit                                                         |
//| Removes the chart zones when the EA is unloaded.                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
//--- leave the chart clean
   g_filter.ClearChartZones();
  }

OnTick

The tick handler carries the pattern worth copying. It redraws the zones once when the day rolls over, then evaluates the news window a single time and caches the boolean. It logs only the transitions, so the Experts tab shows one blocked line when a window opens and one clear line when it closes rather than a flood of identical messages. The demo signal is intentionally trivial, an attempt at each new bar, and the gate is the one line every real EA would reuse: inside a news window, the order attempt is logged as suppressed and skipped. Real submission through CTrade sits behind an input that defaults to false, so the EA can be dropped on a demo chart and watched safely.

//+------------------------------------------------------------------+
//| OnTick                                                           |
//| Redraws the zones on a day change, evaluates the news window     |
//| once per tick, logs state transitions with the reason string,    |
//| and gates the demo order attempt at every new bar open.          |
//+------------------------------------------------------------------+
void OnTick(void)
  {
//--- redraw the chart zones once when the trading day changes
   MqlDateTime now;
   TimeToStruct(TimeCurrent(), now);
   if(now.day != g_last_day)
     {
      g_last_day = now.day;
      g_filter.DrawChartZones(_Symbol, InpMinutesBefore, InpMinutesAfter);
     }
//--- evaluate the news window once for this tick
   bool blocked = g_filter.IsNewsWindow(_Symbol, InpMinutesBefore, InpMinutesAfter);
//--- log only the transitions so the Experts tab stays readable
   if(blocked != g_last_blocked)
     {
      g_last_blocked = blocked;
      Print("NewsFilterEA: ", g_filter.GetLastReason());
     }
//--- demo signal: one order attempt at the open of every new bar
   datetime bar_time = iTime(_Symbol, PERIOD_CURRENT, 0);
   if(bar_time == g_last_bar)
      return;
   g_last_bar = bar_time;
//--- the gate every real EA copies: no orders inside a news window
   if(blocked)
     {
      Print("NewsFilterEA: order attempt suppressed, ", g_filter.GetLastReason());
      return;
     }
//--- outside the news window: submit the demo order if enabled
   if(!InpEnableTrading)
     {
      Print("NewsFilterEA: CLEAR, order would be submitted here (trading disabled by input)");
      return;
     }
//--- only one demo position at a time
   if(PositionSelect(_Symbol))
      return;
   if(!g_trade.Buy(InpLotSize, _Symbol))
      PrintFormat("NewsFilterEA: order failed, retcode %d", g_trade.ResultRetcode());
  }

Mock-up EURUSD M5 chart with CChartZoneDrawer news window zones

EURUSD M5 with two high-impact USD events. Each event has a misty-rose pre-event zone to its left and a lavender post-event zone to its right, spanning the full price range.


Section 9 — Verification: TestNewsFilter.mq5

A boundary that has never been evaluated exactly on its edge is an assumption, not a fact. The verification script drives every decision the engine makes through ASSERT macros and prints a pass/fail tally at the end. The setup is a small counter pair and the macro itself.

//+------------------------------------------------------------------+
//|                                               TestNewsFilter.mq5 |
//+------------------------------------------------------------------+
#property script_show_inputs false

#include <NewsFilterEngine/NewsEvent.mqh>
#include <NewsFilterEngine/CalendarParser.mqh>
#include <NewsFilterEngine/CurrencyExtractor.mqh>
#include <NewsFilterEngine/NewsWindowChecker.mqh>

//--- Test counters
int g_pass = 0;
int g_fail = 0;

//--- Assertion macro: logs the outcome and updates the counters
#define ASSERT(cond, msg) if(cond) { g_pass++; Print("PASS: ", msg); } else { g_fail++; Print("FAIL: ", msg); }

WriteTestCalendar

Rather than depend on an external file, the script writes its own. WriteTestCalendar() lays down a header, six valid rows, and one deliberately broken row. The valid rows are chosen to exercise the awkward cases in one pass: quoted dates with embedded commas, the midnight and noon am/pm edges, empty result columns, and an unrecognized impact string.

//+------------------------------------------------------------------+
//| WriteTestCalendar                                                |
//+------------------------------------------------------------------+
bool WriteTestCalendar(const string filename)
  {
//--- create the file fresh in MQL5/Files
   int handle = FileOpen(filename, FILE_WRITE | FILE_TXT | FILE_ANSI);
   if(handle == INVALID_HANDLE)
      return(false);
//--- header row plus six data rows and one malformed row
   FileWriteString(handle, "Date,Time,Currency,Impact,Event,Actual,Forecast,Previous\n");
   FileWriteString(handle, "\"Jul 14, 2026\",\"8:30am\",USD,High,Core CPI m/m,0.3%,0.2%,0.4%\n");
   FileWriteString(handle, "\"Jul 14, 2026\",\"12:00am\",JPY,Low,Midnight Edge Case,,,\n");
   FileWriteString(handle, "\"Jul 14, 2026\",\"12:00pm\",EUR,Medium,Noon Edge Case,,,\n");
   FileWriteString(handle, "\"Jul 15, 2026\",\"12:30pm\",GBP,High,CPI y/y,2.8%,2.7%,3.1%\n");
   FileWriteString(handle, "\"Jul 15, 2026\",\"8:30am\",USD,High,NFP,,,\n");
   FileWriteString(handle, "\"Jul 15, 2026\",\"3:45pm\",CAD,Weird,Unknown Impact Row,,,\n");
   FileWriteString(handle, "this row is malformed and must be skipped\n");
   FileClose(handle);
   return(true);
  }

TestParsing

This group parses the temporary file and checks the results end to end. It asserts that six rows load and exactly one is skipped, then reads back the first row's date and time to confirm the quoted-date split and the 8:30am conversion. It nails the two traps directly: 12:00am must land on hour zero and 12:00pm on hour twelve. It confirms that fields survived the quoted split, that the impact strings mapped correctly including the unrecognized one falling to Low, and it exercises MapImpact() on its own for good measure.

//+------------------------------------------------------------------+
//| TestParsing                                                      |
//+------------------------------------------------------------------+
void TestParsing(void)
  {
//--- write and parse the temporary economic calendar file
   string filename = "test_calendar_tmp.csv";
   ASSERT(WriteTestCalendar(filename), "test calendar file written");
   CCalendarParser parser;
   CNewsEvent      events[];
   int             count = parser.Parse(filename, events);
//--- six valid rows must load, one malformed row must be skipped
   ASSERT(count == 6, "parser loaded 6 event records");
   ASSERT(parser.SkippedRows() == 1, "parser skipped exactly 1 malformed row");
//--- verify the quoted date and am/pm conversion of the first row
   MqlDateTime dt;
   TimeToStruct(events[0].event_time, dt);
   ASSERT(dt.year == 2026 && dt.mon == 7 && dt.day == 14, "row 1 date parsed as 2026.07.14");
   ASSERT(dt.hour == 8 && dt.min == 30, "row 1 time 8:30am parsed as 08:30");
//--- verify the midnight edge case: 12:00am is hour 0
   TimeToStruct(events[1].event_time, dt);
   ASSERT(dt.hour == 0 && dt.min == 0, "12:00am parsed as 00:00 midnight");
//--- verify the noon edge case: 12:00pm is hour 12
   TimeToStruct(events[2].event_time, dt);
   ASSERT(dt.hour == 12 && dt.min == 0, "12:00pm parsed as 12:00 noon");
//--- verify a pm time with minutes
   TimeToStruct(events[3].event_time, dt);
   ASSERT(dt.hour == 12 && dt.min == 30, "12:30pm parsed as 12:30");
//--- verify field contents survived the quoted split
   ASSERT(events[0].currency == "USD", "row 1 currency is USD");
   ASSERT(events[0].title == "Core CPI m/m", "row 1 title preserved");
   ASSERT(events[0].impact == NEWS_IMPACT_HIGH, "row 1 impact level is high");
   ASSERT(events[2].impact == NEWS_IMPACT_MEDIUM, "noon row impact level is medium");
   ASSERT(events[1].impact == NEWS_IMPACT_LOW, "midnight row impact level is low");
//--- unrecognized impact string defaults to low
   ASSERT(events[5].impact == NEWS_IMPACT_LOW, "unrecognized impact string defaults to low");
//--- direct mapping checks through the public method
   ASSERT(parser.MapImpact("High") == NEWS_IMPACT_HIGH, "MapImpact High -> NEWS_IMPACT_HIGH");
   ASSERT(parser.MapImpact("Medium") == NEWS_IMPACT_MEDIUM, "MapImpact Medium -> NEWS_IMPACT_MEDIUM");
   ASSERT(parser.MapImpact("Low") == NEWS_IMPACT_LOW, "MapImpact Low -> NEWS_IMPACT_LOW");
   ASSERT(parser.MapImpact("garbage") == NEWS_IMPACT_LOW, "MapImpact unknown -> NEWS_IMPACT_LOW");
//--- remove the temporary file
   FileDelete(filename);
  }

TestCurrencyExtraction

This group aims squarely at the suffix problem. It confirms base and quote extraction for a clean six-character symbol, for an alphabetic suffix, and for two dotted suffixes of different lengths, then checks that MatchesCurrency() accepts both sides of a pair and rejects an unrelated currency.

//+------------------------------------------------------------------+
//| TestCurrencyExtraction                                           |
//+------------------------------------------------------------------+
void TestCurrencyExtraction(void)
  {
   CCurrencyExtractor extractor;
//--- clean six-character symbol
   ASSERT(extractor.GetBaseCurrency("EURUSD") == "EUR", "EURUSD base is EUR");
   ASSERT(extractor.GetQuoteCurrency("EURUSD") == "USD", "EURUSD quote is USD");
//--- alphabetic suffix
   ASSERT(extractor.GetBaseCurrency("EURUSDm") == "EUR", "EURUSDm base is EUR");
   ASSERT(extractor.GetQuoteCurrency("EURUSDm") == "USD", "EURUSDm quote is USD");
//--- dotted suffix
   ASSERT(extractor.GetBaseCurrency("EURUSD.c") == "EUR", "EURUSD.c base is EUR");
   ASSERT(extractor.GetQuoteCurrency("EURUSD.c") == "USD", "EURUSD.c quote is USD");
//--- longer dotted suffix on a different pair
   ASSERT(extractor.GetBaseCurrency("GBPJPY.pro") == "GBP", "GBPJPY.pro base is GBP");
   ASSERT(extractor.GetQuoteCurrency("GBPJPY.pro") == "JPY", "GBPJPY.pro quote is JPY");
//--- the currency filter decision
   ASSERT(extractor.MatchesCurrency("EURUSDm", "USD"), "currency filter matches USD on EURUSDm");
   ASSERT(extractor.MatchesCurrency("EURUSDm", "EUR"), "currency filter matches EUR on EURUSDm");
   ASSERT(!extractor.MatchesCurrency("EURUSDm", "GBP"), "currency filter rejects GBP on EURUSDm");
  }

TestWindowBoundaries

Here is where CheckAt() earns its existence. Using a fixed synthetic event and a 25-minute pre-event buffer with a 15-minute post-event buffer, the group asserts the four edges: exactly 25 minutes before is inside, 26 minutes before is outside, exactly 15 minutes after is inside, and 16 minutes after is outside. A final assertion confirms that a non-matching symbol never blocks at all.

//+------------------------------------------------------------------+
//| TestWindowBoundaries                                             |
//+------------------------------------------------------------------+
void TestWindowBoundaries(void)
  {
//--- one synthetic high-impact USD event at a fixed time
   CNewsEvent events[1];
   events[0].event_time = StringToTime("2026.07.15 08:30");
   events[0].currency   = "USD";
   events[0].impact     = NEWS_IMPACT_HIGH;
   events[0].title      = "NFP";
   CNewsWindowChecker checker;
   datetime event_time = events[0].event_time;
//--- exactly 25 minutes before is inside (inclusive start)
   bool inside_start = checker.CheckAt(events, 1, "EURUSD", 25, 15, NEWS_IMPACT_HIGH,
                                       event_time - 25 * 60);
   ASSERT(inside_start, "exactly 25 minutes before is inside the news window");
//--- 26 minutes before is outside
   bool outside_start = checker.CheckAt(events, 1, "EURUSD", 25, 15, NEWS_IMPACT_HIGH,
                                        event_time - 26 * 60);
   ASSERT(!outside_start, "26 minutes before is outside the news window");
//--- exactly 15 minutes after is inside (inclusive end)
   bool inside_end = checker.CheckAt(events, 1, "EURUSD", 25, 15, NEWS_IMPACT_HIGH,
                                     event_time + 15 * 60);
   ASSERT(inside_end, "exactly 15 minutes after is inside the news window");
//--- 16 minutes after is outside
   bool outside_end = checker.CheckAt(events, 1, "EURUSD", 25, 15, NEWS_IMPACT_HIGH,
                                      event_time + 16 * 60);
   ASSERT(!outside_end, "16 minutes after is outside the news window");
//--- a non-matching symbol never blocks
   bool wrong_symbol = checker.CheckAt(events, 1, "AUDNZD", 25, 15, NEWS_IMPACT_HIGH, event_time);
   ASSERT(!wrong_symbol, "currency filter prevents a block on AUDNZD");
  }

TestImpactFilter

A short group confirms that the threshold behaves. A medium-impact event must not block when the minimum is set to High, and the same event must block once the minimum drops to Medium.

//+------------------------------------------------------------------+
//| TestImpactFilter                                                 |
//+------------------------------------------------------------------+
void TestImpactFilter(void)
  {
//--- one synthetic medium-impact EUR event
   CNewsEvent events[1];
   events[0].event_time = StringToTime("2026.07.15 10:00");
   events[0].currency   = "EUR";
   events[0].impact     = NEWS_IMPACT_MEDIUM;
   events[0].title      = "German ZEW";
   CNewsWindowChecker checker;
   datetime event_time = events[0].event_time;
//--- at a high threshold the medium event must not block
   bool blocked_high = checker.CheckAt(events, 1, "EURUSD", 30, 15, NEWS_IMPACT_HIGH, event_time);
   ASSERT(!blocked_high, "medium impact level does not block at a high threshold");
//--- at a medium threshold the same event must block
   bool blocked_medium = checker.CheckAt(events, 1, "EURUSD", 30, 15, NEWS_IMPACT_MEDIUM, event_time);
   ASSERT(blocked_medium, "medium impact level blocks at a medium threshold");
  }

TestReasonStrings

The last group holds the reason contract to the letter. It asserts the exact pre-event string at 23 minutes before, the exact post-event string at 7 minutes after, and the CLEAR state well outside the window. If any of these strings drift, the EA's log output and any downstream parsing of it would break, so the exact-match test is deliberate.

//+------------------------------------------------------------------+
//| TestReasonStrings                                                |
//+------------------------------------------------------------------+
void TestReasonStrings(void)
  {
//--- one synthetic high-impact USD event titled NFP
   CNewsEvent events[1];
   events[0].event_time = StringToTime("2026.07.15 08:30");
   events[0].currency   = "USD";
   events[0].impact     = NEWS_IMPACT_HIGH;
   events[0].title      = "NFP";
   CNewsWindowChecker checker;
   datetime event_time = events[0].event_time;
//--- 23 minutes before: the pre-event format
   checker.CheckAt(events, 1, "EURUSD", 30, 15, NEWS_IMPACT_HIGH, event_time - 23 * 60);
   ASSERT(checker.GetReason() == "BLOCKED: NFP in 23 minutes",
          "pre-event reason string format is exact");
//--- 7 minutes after: the post-event format
   checker.CheckAt(events, 1, "EURUSD", 30, 15, NEWS_IMPACT_HIGH, event_time + 7 * 60);
   ASSERT(checker.GetReason() == "BLOCKED: NFP ended 7 minutes ago",
          "post-event reason string format is exact");
//--- far outside the window: the CLEAR state
   checker.CheckAt(events, 1, "EURUSD", 30, 15, NEWS_IMPACT_HIGH, event_time + 3600);
   ASSERT(checker.GetReason() == "CLEAR", "reason string is CLEAR outside the news window");
  }

OnStart

The entry point runs every group in turn and prints the summary. A clean run reports every assertion as passed and closes with an all-clear line.

//+------------------------------------------------------------------+
//| OnStart                                                          |
//| Runs every test group and prints the final pass/fail summary.    |
//+------------------------------------------------------------------+
void OnStart(void)
  {
//--- run all test groups
   Print("=== TestNewsFilter: starting ===");
   TestParsing();
   TestCurrencyExtraction();
   TestWindowBoundaries();
   TestImpactFilter();
   TestReasonStrings();
//--- print the summary
   PrintFormat("=== TestNewsFilter: %d passed, %d failed ===", g_pass, g_fail);
   if(g_fail == 0)
      Print("=== ALL TESTS PASSED ===");
  }


Section 10 — Extending the Filter

Four extensions follow naturally from the structure already in place, and none of them require tearing anything down.

Medium-impact filtering is already wired as an option. SetMinImpact(NEWS_IMPACT_MEDIUM) lowers the threshold, and the demo EA surfaces it as an input. A further step would be per-currency thresholds, say blocking medium-impact events only for the account currency, which would mean swapping the single stored threshold for a small lookup keyed by currency code inside the facade.

Automatic daily refresh through WebRequest() fits the offline design without breaking it. The design forbids depending on the network at runtime; it does not forbid touching the network at all. A once-a-day request in OnTimer() can pull the fresh weekly export, write it to MQL5/Files/, and call Init() again. If the download fails, the engine simply keeps running on yesterday's file, which is exactly the fallback the scraping approach never had. The target URL must be whitelisted in the terminal's Expert Advisors options for the request to go through.

Minimum event count validation closes a gap that the empty-file check leaves open. Init() already refuses a file with zero rows, but a truncated download can slip through with only two or three. Comparing GetEventCount() against a configurable floor, since a full week usually carries dozens of records, turns a quietly weakened filter into a loud startup failure.

Caching the day's events at midnight is a performance option for large archives. The checker scans the whole array on every tick, which is nothing for a week-sized file but wasteful against a year of history. Building a small cache once at the first tick after midnight, holding only today's records for the chart symbol, shrinks the per-tick loop to a handful of entries. The day-change detection already sitting in OnTick() for the zone redraw is the natural place to trigger it.


Section 11 — Limitations

The engine is only as trustworthy as its inputs and its environment, and four limits deserve to be stated plainly.

First, the filter trusts the file completely. A trader who forgets to refresh it will get a confident CLEAR through every release of the new week, because the file carries no expiry metadata and the engine cannot tell a genuinely quiet week from a stale download. The minimum event count check from the previous section catches truncation, but nothing here catches staleness.

Second, the timezone mismatch is real and easy to miss. The default export is US Eastern Time while TimeCurrent() returns server time, and without aligning the two every window shifts by the difference. For a common GMT+2 broker that means blocking hours away from the actual release. This is the single most frequent misconfiguration for file-based filters, and it should be verified once against a known event before any live use.

Third, unusual symbol names can defeat the currency filter. The suffix logic handles trailing letters and separator-delimited tails, but a broker prefix such as mEURUSD, a synthetic name such as EURUSD_i2, or a decorated metals symbol can produce a wrong or empty extraction, and a failed match means no block. The extraction is worth verifying per broker using the test script's pattern.

Fourth, the chart zones are session-only. They are created on the running chart and removed on cleanup, but after a terminal restart they are gone until the EA reinitializes and redraws them. The zones visualize the filter's state; they are not a persistent record of it.


Conclusion

This engine grew from one commitment: because the schedule of high-impact events is public in advance, it can live in a local file, and an EA's protection should not hinge on a website's markup or a network connection at 8:29am. The result is seven source files. A typed event record and its impact enum, a quote-aware parser that survives bad rows, a suffix-tolerant currency filter, an inclusive-boundary window checker with an exact reason contract, a chart drawer with a clean prefix-based lifecycle, and a public facade that ties them together, plus a demo EA that shows the one-line gate and a verification script that pins every boundary with assertions.

What it promises: fully offline evaluation after startup, deterministic inclusive boundaries proven by the tests, a currency filter that survives the usual broker suffixes, and a reason string that names the blocking event and the minutes involved. What it does not pretend to do: it cannot notice a stale file, it will not convert timezones for you, it cannot match every exotic naming scheme, and it filters scheduled events only. Unscheduled news, flash crashes, and surprise central bank statements pass through it untouched. It is a scheduled-event filter with a reliability guarantee, not a volatility shield.


Programs used in the article:

# Name Type Description
1 NewsEvent.mqh Include File The CNewsEvent record struct and the ENUM_NEWS_IMPACT enum shared by every component
2 CalendarParser.mqh Include File CCalendarParser: reads the economic calendar file line by line, splits quoted rows, converts am/pm times, and skips malformed rows with a warning
3 CurrencyExtractor.mqh Include File CCurrencyExtractor: the currency filter primitives, extracting base and quote currencies with broker suffix stripping
4 NewsWindowChecker.mqh Include File CNewsWindowChecker: evaluates the inclusive news window with the impact and currency filters and formats the reason string
5 ChartZoneDrawer.mqh Include File CChartZoneDrawer: draws and clears the shaded pre-event and post-event rectangle zones on the chart
6 NewsFilter.mqh Include File CNewsFilter: the public interface owning the parser, checker, and drawer
7 NewsFilterEA.mq5 Demo EA Demo EA that loads the filter at startup, gates order submission during news windows with logged reasons, and redraws zones daily
8 sample_calendar.csv Data File Sample economic calendar file with twelve event records across two trading days, four currencies, and all three impact levels
9 TestNewsFilter.mq5 Script Verification script asserting parsing, am/pm edges, currency extraction with suffixes, inclusive boundaries, the impact filter, and the exact reason formats
10 NewsFilterEngine.zip Zip Archive Zip archive containing all the attached files and their paths relative to the terminal's root folder.
Attached files |
NewsEvent.mqh (1.7 KB)
CalendarParser.mqh (11.86 KB)
NewsFilter.mqh (6.49 KB)
NewsFilterEA.mq5 (4.73 KB)
TestNewsFilter.mq5 (11.91 KB)
Neural Networks in Trading: Heterogeneity-Informed Meta-Parameter Learning (Key Components) Neural Networks in Trading: Heterogeneity-Informed Meta-Parameter Learning (Key Components)
In this article, we take a detailed look at the algorithms used to implement the key components of the HimNet framework. We demonstrate how, with a minimal number of trainable components, a high degree of consistency and controllability can be achieved throughout the entire system. The presented implementation is compact and transparent, which makes it easier to adapt to real-world market tasks.
Automating Classic Market Methods in MQL5 (Part 8): Ed Seykota's Trend Following System Automating Classic Market Methods in MQL5 (Part 8): Ed Seykota's Trend Following System
The article presents a full MQL5 implementation of a multi-symbol trend system: dual EMA crossovers for entries, ADX to avoid ranges, ATR to normalize position size, and a heat monitor to cap total portfolio risk. We explain the architecture, calculation details, and entry/exit logic on daily bars. The result is a practical EA template for systematic, risk-aware portfolio trading.
From Option Chain to Risk-Neutral Density: The Market's Own Probability Distribution From Option Chain to Risk-Neutral Density: The Market's Own Probability Distribution
The article builds an MQL5 indicator that recovers the risk-neutral density from an option chain via the Breeden–Litzenberger identity. Quotes are inverted to implied volatilities, the smile is smoothed and priced back to arbitrage‑free calls, and the second derivative yields the density. The tool reports probabilities above any level, the expected move, skew and kurtosis, and overlays the realized-return distribution for comparison.
Developing a Multi-Currency Expert Advisor (Part 31): Secrets of the Optimization Project Creation Step (I) Developing a Multi-Currency Expert Advisor (Part 31): Secrets of the Optimization Project Creation Step (I)
The article examines two practical aspects of the Adwizard-based optimization pipeline: diagnostics and recovery after failures when generating the final Expert Advisor database, as well as preliminary selection of strategy parameter ranges before project creation. It is shown how analyzing the stages/jobs/tasks tables in SQLite and restarting stages based on their statuses help restore the process, while trial optimization narrows the search space, eliminates redundant parameters, and reduces the risk of getting stuck at local maxima.