Building a News Filter Engine in MQL5 Using a Local Economic Calendar File
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.

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()); }

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. |
Warning: All rights to these materials are reserved by MetaQuotes Ltd. Copying or reprinting of these materials in whole or in part is prohibited.
This article was written by a user of the site and reflects their personal views. MetaQuotes Ltd is not responsible for the accuracy of the information presented, nor for any consequences resulting from the use of the solutions, strategies or recommendations described.
Neural Networks in Trading: Heterogeneity-Informed Meta-Parameter Learning (Key Components)
Automating Classic Market Methods in MQL5 (Part 8): Ed Seykota's Trend Following System
From Option Chain to Risk-Neutral Density: The Market's Own Probability Distribution
Developing a Multi-Currency Expert Advisor (Part 31): Secrets of the Optimization Project Creation Step (I)
- Free trading apps
- Over 8,000 signals for copying
- Economic news for exploring financial markets
You agree to website policy and terms of use