forked from mnbvc188199/Warrior_EA
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>
350 lines
14 KiB
MQL5
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;
| |
}
| |
//+------------------------------------------------------------------+
|