Warrior_EA/Scripts/CalendarRecorder.mq5
AnimateDread 0c2b025c16 fix(research): calendar recorder - separate LIVE from BACKFILL, fix seen-set key
First live run exposed both problems at once. It logged "+83 release(s)
recorded", and every one of those rows shared a single observed_time up to 30
hours after its event_time: they were the startup backfill, not release-time
observations. Their actual figures are whatever the terminal holds NOW - the
post-revision values this recorder exists to avoid - and the very first batch
proved that is not hypothetical: a Retail Sales row came back previous 3.5 /
revised_prev 3.4, and a Core CPI row already carried revision=1.

Backfill is still worth keeping (a fine snapshot of the revised series, and it
carries the event metadata) but must never be silently mixed with release-time
observations. Every row now records lag_sec and a capture class, so the
distinction cannot be lost by whoever loads the CSV later:

  LIVE      observed within InpLiveLagSeconds (default 600s) of release
  BACKFILL  seen long after the fact - MUST NOT be used for surprise research

The log now reports the split per poll and says so explicitly when a poll is
entirely backfill.

Second and worse, in LoadSeen: the FILE_CSV field walk was off by one and keyed
the seen-set on event_id instead of value_id. event_id identifies the event TYPE,
not the release, so after any restart every future release of every event already
in the file would have been skipped - permanently, and silently, exactly for the
recurring high-importance events (NFP, CPI) that matter most. Now reads whole
lines and indexes a split array by a NAME-CHECKED column position, which cannot
drift when the schema changes. Refuses to guess if value_id is absent.

Schema change is handled by rotating any file with a non-matching header to
<name>.<timestamp>.old rather than appending, since mixing layouts mis-parses
every old row.

Compiles 0 errors, 0 warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 20:22:12 -04:00

350 lines
14 KiB
MQL5

//+------------------------------------------------------------------+
//| CalendarRecorder.mq5 |
//| AnimateDread |
//| https://www.mql5.com |
//+------------------------------------------------------------------+
#property copyright "AnimateDread"
#property link "https://www.mql5.com"
#property version "1.00"
#property description "Records economic calendar releases AT RELEASE TIME, write-once."
#property description "Standalone: attach to any spare chart. Does not touch the trading EA."
//--- WHY THIS EXISTS, AND WHY IT CANNOT BE A BACKTEST
//---
//--- The calendar is the only non-price data source this project found that MQL5 actually
//--- carries with content (swap has no history, TICK_FLAG_BUY/SELL are empty on FX, DOM is
//--- absent on retail FX and never in the tester). But it CANNOT be researched from
//--- history, because MqlCalendarValue.actual_value returns the CURRENT figure - the one
//--- after every subsequent revision. Reading 2019's NFP today gives a number that nobody
//--- could have known in 2019. Any "surprise = actual - forecast" feature built from
//--- history is therefore contaminated by lookahead, and it is the flattering kind: it
//--- makes the model look prescient exactly on the events that were revised most.
//---
//--- The only sound way to get an honest surprise series is to write down what the
//--- terminal reported at the moment of release and never touch that row again. That is
//--- all this does. It buys a dataset that starts empty and grows in real time - months
//--- before it is usable, which is precisely why it should start running now rather than
//--- when someone wants it.
//---
//--- WRITE-ONCE IS THE WHOLE CONTRACT. A row is appended the first time a value_id is seen
//--- with an actual figure attached, and is never rewritten. Re-recording on a later poll
//--- would silently import the revision this file exists to avoid.
//--- Deliberately NO includes from the EA tree. This is a data-collection side-task that
//--- may run for months on a spare chart; coupling it to the trading system's headers
//--- would mean a refactor there can stop the recorder, and a silent gap in a write-once
//--- series cannot be backfilled by definition. The one helper it needs is eight lines.
input int InpPollSeconds = 30; // Poll interval (seconds)
input int InpLookbackHours = 48; // How far back each poll looks for newly-filled values
input int InpLookaheadHours = 4; // Upcoming window (captures scheduled-but-unreleased)
input int InpLiveLagSeconds = 600; // Max release->observation lag still counted LIVE
input bool InpAllCountries = true; // Record every country (false = this symbol's only)
input int InpMinImportance = 1; // 0=none 1=low 2=moderate 3=high
input string InpFileName = "Warrior_EA/Research/calendar_live.csv";
//--- LONG_MIN is the terminal's "this field has no value" sentinel on MqlCalendarValue.
#define CAL_NO_VALUE LONG_MIN
#define CAL_SCALE 1000000.0
//--- CAPTURE CLASS - the single most important column in this file.
//---
//--- Every poll looks BACK as well as forward, so it necessarily re-reads events that were
//--- released before the recorder was watching: the first poll after attaching picked up 83
//--- of them. Those rows carry whatever the terminal holds NOW, which is the revised figure
//--- - the exact contamination this recorder exists to avoid. The very first batch proved
//--- it was not hypothetical: a "Retail Sales y/y" row came back with previous 3.5 and
//--- revised_prev 3.4, and a "Core CPI m/m" row already had revision=1.
//---
//--- Backfill is still worth keeping (it is a fine snapshot of the revised series, and it
//--- carries the event metadata), but it must never be silently mixed with release-time
//--- observations. So the lag is written explicitly and classified, rather than left to be
//--- re-derived by whoever loads the CSV later and may not think to.
//---
//--- LIVE observed within InpLiveLagSeconds of the release -> usable as a surprise
//--- BACKFILL seen long after the fact -> MUST NOT be used for surprise research
#define CSV_HEADER "observed_time,capture,lag_sec,value_id,event_id,event_time,country," \
"currency,importance,name,actual,forecast,previous,revised_prev," \
"revision,impact,unit,digits,multiplier,symbol,bid,ask,spread_pts"
ulong g_seen[]; // value_ids already written, kept SORTED for binary search
string g_countries[];
datetime g_lastPoll = 0;
int g_live = 0; // release-time captures this session (the only usable rows)
//+------------------------------------------------------------------+
//| Sorted insert into the seen-set |
//+------------------------------------------------------------------+
bool SeenContains(const ulong id)
{
int n = ArraySize(g_seen);
if(n == 0)
return false;
int lo = 0, hi = n - 1;
while(lo <= hi)
{
int mid = (lo + hi) >> 1;
if(g_seen[mid] == id)
return true;
if(g_seen[mid] < id)
lo = mid + 1;
else
hi = mid - 1;
}
return false;
}
void SeenInsert(const ulong id)
{
int n = ArraySize(g_seen);
int pos = n;
for(int i = 0; i < n; i++)
if(g_seen[i] > id)
{
pos = i;
break;
}
ArrayResize(g_seen, n + 1);
for(int i = n; i > pos; i--)
g_seen[i] = g_seen[i - 1];
g_seen[pos] = id;
}
//+------------------------------------------------------------------+
//| Rebuild the seen-set from the file so a restart never duplicates |
//| or, worse, re-records a value that has since been revised. |
//+------------------------------------------------------------------+
void LoadSeen()
{
ArrayResize(g_seen, 0);
//--- Read whole LINES and split, rather than walking FILE_CSV field by field with a
//--- counter. The counter version had an off-by-one and was keying the seen-set on
//--- event_id instead of value_id - and because event_id identifies the event TYPE, not
//--- the release, that would have made every restart permanently skip all future
//--- releases of every event already in the file. Indexing a split array by name-checked
//--- position cannot drift like that when the schema changes.
//--- FILE_SHARE_READ|FILE_SHARE_WRITE is mandatory in this codebase: an exclusive open
//--- fails 5004 whenever anything else holds the file, and looks like "no history yet".
int h = FileOpen(InpFileName, FILE_READ | FILE_TXT | FILE_ANSI | FILE_COMMON |
FILE_SHARE_READ | FILE_SHARE_WRITE);
if(h == INVALID_HANDLE)
{
PrintFormat("%s: no existing file (%s) - starting fresh", __FUNCTION__, InpFileName);
return;
}
string header = FileIsEnding(h) ? "" : FileReadString(h);
StringTrimRight(header);
StringTrimLeft(header);
if(StringLen(header) > 0 && header != CSV_HEADER)
{
//--- Old schema. Rotate rather than append: mixing layouts silently mis-parses every
//--- old row, and the existing rows are backfill that will be re-derived anyway.
FileClose(h);
string bak = InpFileName + "." + IntegerToString((int)TimeCurrent()) + ".old";
if(FileMove(InpFileName, FILE_COMMON, bak, FILE_COMMON))
PrintFormat("%s: schema changed - previous file rotated to %s", __FUNCTION__, bak);
else
PrintFormat("%s: schema changed but rotate FAILED (error %d) - "
"move %s aside by hand", __FUNCTION__, GetLastError(), InpFileName);
return;
}
int vcol = -1;
string hf[];
if(StringSplit(header, ',', hf) > 0)
for(int i = 0; i < ArraySize(hf); i++)
if(hf[i] == "value_id")
vcol = i;
if(vcol < 0)
{
FileClose(h);
Print(__FUNCTION__ + ": header has no value_id column - refusing to guess");
return;
}
while(!FileIsEnding(h))
{
string line = FileReadString(h);
if(StringLen(line) < 5)
continue;
string f[];
if(StringSplit(line, ',', f) > vcol)
SeenInsert((ulong)StringToInteger(f[vcol]));
}
FileClose(h);
PrintFormat("%s: loaded %d previously recorded values from %s",
__FUNCTION__, ArraySize(g_seen), InpFileName);
}
//+------------------------------------------------------------------+
//| Append one row. Header written only when the file is created. |
//+------------------------------------------------------------------+
bool AppendRow(const string row)
{
int h = FileOpen(InpFileName, FILE_READ | FILE_WRITE | FILE_TXT | FILE_ANSI | FILE_COMMON |
FILE_SHARE_READ | FILE_SHARE_WRITE);
if(h == INVALID_HANDLE)
{
PrintFormat("%s: cannot open %s, error %d", __FUNCTION__, InpFileName, GetLastError());
return false;
}
if(FileSize(h) == 0)
FileWriteString(h, CSV_HEADER + "\r\n");
FileSeek(h, 0, SEEK_END);
FileWriteString(h, row + "\r\n");
FileFlush(h);
FileClose(h);
return true;
}
//+------------------------------------------------------------------+
double CalValue(const long v)
{
return (v == CAL_NO_VALUE) ? EMPTY_VALUE : (double)v / CAL_SCALE;
}
string CalField(const long v)
{
return (v == CAL_NO_VALUE) ? "" : DoubleToString((double)v / CAL_SCALE, 6);
}
//+------------------------------------------------------------------+
int OnInit()
{
if(InpAllCountries)
{
MqlCalendarCountry countries[];
int total = CalendarCountries(countries);
ArrayResize(g_countries, 0);
for(int i = 0; i < total; i++)
{
int n = ArraySize(g_countries);
ArrayResize(g_countries, n + 1);
g_countries[n] = countries[i].code;
}
}
else
{
//--- this symbol's base+quote currencies only
string baseCcy = SymbolInfoString(_Symbol, SYMBOL_CURRENCY_BASE);
string profCcy = SymbolInfoString(_Symbol, SYMBOL_CURRENCY_PROFIT);
MqlCalendarCountry countries[];
int total = CalendarCountries(countries);
ArrayResize(g_countries, 0);
for(int i = 0; i < total; i++)
if(countries[i].currency == baseCcy || countries[i].currency == profCcy)
{
int n = ArraySize(g_countries);
ArrayResize(g_countries, n + 1);
g_countries[n] = countries[i].code;
}
}
if(ArraySize(g_countries) == 0)
{
Print(__FUNCTION__ + ": no countries resolved - calendar unavailable on this terminal?");
return INIT_FAILED;
}
LoadSeen();
EventSetTimer(MathMax(5, InpPollSeconds));
PrintFormat("CalendarRecorder: watching %d countries, poll %ds, file %s",
ArraySize(g_countries), InpPollSeconds, InpFileName);
Poll();
return INIT_SUCCEEDED;
}
void OnDeinit(const int reason)
{
EventKillTimer();
}
void OnTimer()
{
Poll();
}
//+------------------------------------------------------------------+
//| One sweep. Cheap enough to run every 30s: the terminal serves the |
//| calendar from a local cache, and the window is a couple of days. |
//+------------------------------------------------------------------+
void Poll()
{
datetime now = TimeCurrent();
datetime from = now - (datetime)InpLookbackHours * 3600;
datetime to = now + (datetime)InpLookaheadHours * 3600;
double bid = SymbolInfoDouble(_Symbol, SYMBOL_BID);
double ask = SymbolInfoDouble(_Symbol, SYMBOL_ASK);
long spr = SymbolInfoInteger(_Symbol, SYMBOL_SPREAD);
int added = 0;
int liveBefore = g_live;
for(int c = 0; c < ArraySize(g_countries); c++)
{
MqlCalendarValue values[];
if(!CalendarValueHistory(values, from, to, g_countries[c]))
continue; // country simply has nothing in the window
for(int i = 0; i < ArraySize(values); i++)
{
//--- Only record once the ACTUAL figure exists. A scheduled-but-unreleased row has
//--- no surprise to measure, and recording it early would burn the value_id in the
//--- seen-set so the real release is never captured.
if(values[i].actual_value == CAL_NO_VALUE)
continue;
if(SeenContains(values[i].id))
continue;
MqlCalendarEvent ev;
if(!CalendarEventById(values[i].event_id, ev))
continue;
if((int)ev.importance < InpMinImportance)
{
SeenInsert(values[i].id); // remember, so it is not re-examined every poll
continue;
}
MqlCalendarCountry ctry;
string currency = "";
if(CalendarCountryById(ev.country_id, ctry))
currency = ctry.currency;
string name = ev.name;
StringReplace(name, ",", ";"); // keep the CSV parseable
long lag = (long)now - (long)values[i].time;
string cap = (lag >= 0 && lag <= InpLiveLagSeconds) ? "LIVE" : "BACKFILL";
if(cap == "LIVE")
g_live++;
string row = StringFormat(
"%s,%s,%I64d,%I64u,%I64u,%s,%s,%s,%d,%s,%s,%s,%s,%s,%d,%d,%d,%d,%.6f,%s,%.5f,%.5f,%d",
TimeToString(now, TIME_DATE | TIME_SECONDS), cap, lag,
values[i].id, values[i].event_id,
TimeToString(values[i].time, TIME_DATE | TIME_SECONDS),
g_countries[c], currency, (int)ev.importance, name,
CalField(values[i].actual_value),
CalField(values[i].forecast_value),
CalField(values[i].prev_value),
CalField(values[i].revised_prev_value),
values[i].revision, (int)values[i].impact_type,
(int)ev.unit, (int)ev.digits, (double)ev.multiplier,
_Symbol, bid, ask, (int)spr);
if(AppendRow(row))
{
SeenInsert(values[i].id);
added++;
}
}
}
if(added > 0)
{
int live = g_live - liveBefore;
PrintFormat("CalendarRecorder: +%d row(s): %d LIVE, %d BACKFILL (usable this session: "
"%d, file total: %d)", added, live, added - live, g_live, ArraySize(g_seen));
if(live == 0)
Print("CalendarRecorder: all BACKFILL - post-revision figures, "
"NOT usable for surprise research. Only LIVE rows are.");
}
g_lastPoll = now;
}
//+------------------------------------------------------------------+