838 lines
26 KiB
MQL5
838 lines
26 KiB
MQL5
|
|
//+------------------------------------------------------------------+
|
||
|
|
//| MT5_Economic_Calendar_Live_Exporter.mq5 |
|
||
|
|
//| Exports MT5 Economic Calendar events to a bot-compatible CSV. |
|
||
|
|
//| |
|
||
|
|
//| The destination is one ordinary, editable Windows full path. |
|
||
|
|
//| Copying outside the MQL5 file sandbox uses Windows CopyFileW, |
|
||
|
|
//| so "Allow DLL imports" must be enabled for this EA. |
|
||
|
|
//+------------------------------------------------------------------+
|
||
|
|
#property strict
|
||
|
|
#property version "2.01"
|
||
|
|
#property description "Exports a rolling 21-day MT5 calendar to timetable.csv every four hours"
|
||
|
|
|
||
|
|
#import "kernel32.dll"
|
||
|
|
int CopyFileW(string existing_file,string new_file,int fail_if_exists);
|
||
|
|
int MoveFileExW(string existing_file,string new_file,uint flags);
|
||
|
|
int DeleteFileW(string file_name);
|
||
|
|
uint GetLastError();
|
||
|
|
#import
|
||
|
|
|
||
|
|
#define MOVEFILE_REPLACE_EXISTING 0x00000001
|
||
|
|
#define MOVEFILE_WRITE_THROUGH 0x00000008
|
||
|
|
|
||
|
|
//--- The only export-location field. Type a complete Windows path including
|
||
|
|
//--- the CSV filename. In the EA Inputs window, use ordinary single slashes:
|
||
|
|
//--- C:\Users\3dstudio\Desktop\Backup-2\XAU\timetable.csv
|
||
|
|
input string InpTimetableFullPath = "C:\\Users\\3dstudio\\Desktop\\Backup-2\\XAU\\timetable.csv";
|
||
|
|
input bool InpWriteDetailedFile = false;
|
||
|
|
|
||
|
|
//--- Calendar selection.
|
||
|
|
// Multiple currencies are separated by semicolons.
|
||
|
|
input string InpCurrencies = "USD;EUR";
|
||
|
|
input ENUM_CALENDAR_EVENT_IMPORTANCE InpMinimumImportance = CALENDAR_IMPORTANCE_HIGH;
|
||
|
|
input bool InpOnlyExactTimeEvents = true;
|
||
|
|
input int InpDaysAhead = 21;
|
||
|
|
|
||
|
|
//--- Optional event-name filtering (case-insensitive, semicolon-separated).
|
||
|
|
// Blank IncludeKeywords means include every event passing the other filters.
|
||
|
|
input string InpIncludeKeywords = "";
|
||
|
|
input string InpExcludeKeywords = "";
|
||
|
|
|
||
|
|
//--- Live-trading default: exported rows are enabled immediately.
|
||
|
|
//--- Set this to false only when intentionally producing a preview timetable.
|
||
|
|
input bool InpRowsEnabled = true;
|
||
|
|
|
||
|
|
//--- A successful export is followed by the next export four hours later.
|
||
|
|
//--- A failed export retries after five minutes without replacing the old CSV.
|
||
|
|
input int InpRefreshHours = 4;
|
||
|
|
input int InpRetryMinutes = 5;
|
||
|
|
input int InpQueryChunkDays = 7;
|
||
|
|
input int InpRequestRetries = 4;
|
||
|
|
input int InpRetryDelayMilliseconds = 1250;
|
||
|
|
input bool InpRequireValuesForEveryCurrency = true;
|
||
|
|
|
||
|
|
//--- Calendar timestamps use trade-server time. 999999 enables RoboForex
|
||
|
|
//--- EET/EEST conversion for each future event (UTC+2 winter, UTC+3 summer).
|
||
|
|
//--- A numeric value forces one fixed offset for every event.
|
||
|
|
input int InpManualServerUtcOffsetMinutes = 999999;
|
||
|
|
input int InpWinterServerUtcOffsetMinutes = 120;
|
||
|
|
input int InpSummerServerUtcOffsetMinutes = 180;
|
||
|
|
|
||
|
|
struct ExportRow
|
||
|
|
{
|
||
|
|
datetime utc_minute;
|
||
|
|
datetime server_minute;
|
||
|
|
string names;
|
||
|
|
string source_event_ids;
|
||
|
|
string event_codes;
|
||
|
|
string source_urls;
|
||
|
|
string currencies;
|
||
|
|
int max_importance;
|
||
|
|
};
|
||
|
|
|
||
|
|
ExportRow g_rows[];
|
||
|
|
long g_server_utc_offset_seconds = 0;
|
||
|
|
ulong g_next_export_tick = 0;
|
||
|
|
bool g_export_running = false;
|
||
|
|
string g_timetable_full_path = "";
|
||
|
|
string g_detailed_full_path = "";
|
||
|
|
|
||
|
|
const string STAGING_FOLDER = "__MT5_Calendar_Exporter";
|
||
|
|
const string STAGING_TIMETABLE = "timetable_export.csv";
|
||
|
|
const string STAGING_DETAILED = "mt5_calendar_detailed.csv";
|
||
|
|
const string DETAILED_OUTPUT_NAME = "mt5_calendar_detailed.csv";
|
||
|
|
|
||
|
|
//+------------------------------------------------------------------+
|
||
|
|
//| String helpers |
|
||
|
|
//+------------------------------------------------------------------+
|
||
|
|
string TrimCopy(string value)
|
||
|
|
{
|
||
|
|
StringTrimLeft(value);
|
||
|
|
StringTrimRight(value);
|
||
|
|
return value;
|
||
|
|
}
|
||
|
|
|
||
|
|
string LowerCopy(string value)
|
||
|
|
{
|
||
|
|
StringToLower(value);
|
||
|
|
return value;
|
||
|
|
}
|
||
|
|
|
||
|
|
string CleanCsvText(string value)
|
||
|
|
{
|
||
|
|
StringReplace(value,"\r"," ");
|
||
|
|
StringReplace(value,"\n"," ");
|
||
|
|
StringReplace(value,",",";");
|
||
|
|
value=TrimCopy(value);
|
||
|
|
return value;
|
||
|
|
}
|
||
|
|
|
||
|
|
bool IsBlank(const string value)
|
||
|
|
{
|
||
|
|
return StringLen(TrimCopy(value))==0;
|
||
|
|
}
|
||
|
|
|
||
|
|
bool MatchesAnyKeyword(const string text,const string keyword_list)
|
||
|
|
{
|
||
|
|
string cleaned=TrimCopy(keyword_list);
|
||
|
|
if(StringLen(cleaned)==0)
|
||
|
|
return false;
|
||
|
|
|
||
|
|
string parts[];
|
||
|
|
ushort separator=(ushort)StringGetCharacter(";",0);
|
||
|
|
int count=StringSplit(cleaned,separator,parts);
|
||
|
|
string haystack=LowerCopy(text);
|
||
|
|
|
||
|
|
for(int i=0;i<count;i++)
|
||
|
|
{
|
||
|
|
string needle=LowerCopy(TrimCopy(parts[i]));
|
||
|
|
if(StringLen(needle)>0 && StringFind(haystack,needle)>=0)
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
bool PassesNameFilter(const string event_name,const string event_code)
|
||
|
|
{
|
||
|
|
string searchable=event_name+" "+event_code;
|
||
|
|
|
||
|
|
if(!IsBlank(InpIncludeKeywords) && !MatchesAnyKeyword(searchable,InpIncludeKeywords))
|
||
|
|
return false;
|
||
|
|
if(!IsBlank(InpExcludeKeywords) && MatchesAnyKeyword(searchable,InpExcludeKeywords))
|
||
|
|
return false;
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
void AppendUnique(string &target,const string value,const string delimiter)
|
||
|
|
{
|
||
|
|
string cleaned=CleanCsvText(value);
|
||
|
|
if(StringLen(cleaned)==0)
|
||
|
|
return;
|
||
|
|
|
||
|
|
string wrapped_delimiter=delimiter;
|
||
|
|
if(StringLen(target)==0)
|
||
|
|
{
|
||
|
|
target=cleaned;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Exact token check using the same delimiter prevents duplicate names/IDs.
|
||
|
|
string probe=wrapped_delimiter+target+wrapped_delimiter;
|
||
|
|
string token=wrapped_delimiter+cleaned+wrapped_delimiter;
|
||
|
|
if(StringFind(probe,token)<0)
|
||
|
|
target+=wrapped_delimiter+cleaned;
|
||
|
|
}
|
||
|
|
|
||
|
|
//+------------------------------------------------------------------+
|
||
|
|
//| Path helpers |
|
||
|
|
//+------------------------------------------------------------------+
|
||
|
|
string NormalizeAbsolutePath(string value)
|
||
|
|
{
|
||
|
|
value=TrimCopy(value);
|
||
|
|
StringReplace(value,"/","\\");
|
||
|
|
|
||
|
|
if(StringLen(value)>=2 && StringSubstr(value,0,1)=="\"" &&
|
||
|
|
StringSubstr(value,StringLen(value)-1,1)=="\"")
|
||
|
|
value=StringSubstr(value,1,StringLen(value)-2);
|
||
|
|
|
||
|
|
// Keep drive roots such as C:\ intact; remove only superfluous trailing slashes.
|
||
|
|
while(StringLen(value)>3 && StringSubstr(value,StringLen(value)-1,1)=="\\")
|
||
|
|
value=StringSubstr(value,0,StringLen(value)-1);
|
||
|
|
|
||
|
|
return value;
|
||
|
|
}
|
||
|
|
|
||
|
|
bool IsAbsoluteWindowsFilePath(const string value)
|
||
|
|
{
|
||
|
|
int length=StringLen(value);
|
||
|
|
if(length>=3 && StringSubstr(value,1,1)==":" && StringSubstr(value,2,1)=="\\")
|
||
|
|
return true;
|
||
|
|
if(length>=2 && StringSubstr(value,0,2)=="\\\\")
|
||
|
|
return true; // UNC path
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
string ParentFolder(const string full_path)
|
||
|
|
{
|
||
|
|
for(int i=StringLen(full_path)-1;i>=0;i--)
|
||
|
|
if(StringSubstr(full_path,i,1)=="\\")
|
||
|
|
return StringSubstr(full_path,0,i);
|
||
|
|
return "";
|
||
|
|
}
|
||
|
|
|
||
|
|
string JoinWindowsPath(string folder,const string name)
|
||
|
|
{
|
||
|
|
folder=NormalizeAbsolutePath(folder);
|
||
|
|
if(StringLen(folder)==0)
|
||
|
|
return name;
|
||
|
|
return folder+"\\"+name;
|
||
|
|
}
|
||
|
|
|
||
|
|
string StagingRelativePath(const string file_name)
|
||
|
|
{
|
||
|
|
return STAGING_FOLDER+"\\"+file_name;
|
||
|
|
}
|
||
|
|
|
||
|
|
string StagingFullPath(const string relative_path)
|
||
|
|
{
|
||
|
|
return TerminalInfoString(TERMINAL_DATA_PATH)+"\\MQL5\\Files\\"+relative_path;
|
||
|
|
}
|
||
|
|
|
||
|
|
bool PublishToAbsolutePath(const string staging_relative,const string target_full_path)
|
||
|
|
{
|
||
|
|
string source_full_path=StagingFullPath(staging_relative);
|
||
|
|
string staged_target=target_full_path+".new";
|
||
|
|
|
||
|
|
// A previous interrupted attempt may have left a .new file.
|
||
|
|
DeleteFileW(staged_target);
|
||
|
|
|
||
|
|
ResetLastError();
|
||
|
|
int copied=CopyFileW(source_full_path,staged_target,0); // overwrite .new
|
||
|
|
if(copied!=1)
|
||
|
|
{
|
||
|
|
uint windows_error=kernel32::GetLastError();
|
||
|
|
PrintFormat("CopyFileW failed: %s -> %s; Windows error=%u. "
|
||
|
|
"Confirm that the destination folder exists and is writable.",
|
||
|
|
source_full_path,staged_target,windows_error);
|
||
|
|
DeleteFileW(staged_target);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
uint move_flags=MOVEFILE_REPLACE_EXISTING|MOVEFILE_WRITE_THROUGH;
|
||
|
|
for(int attempt=1;attempt<=20;attempt++)
|
||
|
|
{
|
||
|
|
ResetLastError();
|
||
|
|
if(MoveFileExW(staged_target,target_full_path,move_flags)!=0)
|
||
|
|
return true;
|
||
|
|
|
||
|
|
if(attempt<20)
|
||
|
|
Sleep(250);
|
||
|
|
}
|
||
|
|
|
||
|
|
uint move_error=kernel32::GetLastError();
|
||
|
|
PrintFormat("MoveFileExW failed after retries: %s -> %s; Windows error=%u. "
|
||
|
|
"The previous timetable.csv remains unchanged.",
|
||
|
|
staged_target,target_full_path,move_error);
|
||
|
|
DeleteFileW(staged_target);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
//+------------------------------------------------------------------+
|
||
|
|
//| Time helpers |
|
||
|
|
//+------------------------------------------------------------------+
|
||
|
|
bool IsLeapYear(const int year)
|
||
|
|
{
|
||
|
|
if(year%400==0)
|
||
|
|
return true;
|
||
|
|
if(year%100==0)
|
||
|
|
return false;
|
||
|
|
return year%4==0;
|
||
|
|
}
|
||
|
|
|
||
|
|
int DaysInMonth(const int year,const int month)
|
||
|
|
{
|
||
|
|
if(month==2)
|
||
|
|
return IsLeapYear(year) ? 29 : 28;
|
||
|
|
if(month==4 || month==6 || month==9 || month==11)
|
||
|
|
return 30;
|
||
|
|
return 31;
|
||
|
|
}
|
||
|
|
|
||
|
|
int LastSundayDay(const int year,const int month)
|
||
|
|
{
|
||
|
|
MqlDateTime dt;
|
||
|
|
ZeroMemory(dt);
|
||
|
|
dt.year=year;
|
||
|
|
dt.mon=month;
|
||
|
|
dt.day=DaysInMonth(year,month);
|
||
|
|
dt.hour=12;
|
||
|
|
datetime last_day=StructToTime(dt);
|
||
|
|
TimeToStruct(last_day,dt);
|
||
|
|
return DaysInMonth(year,month)-dt.day_of_week;
|
||
|
|
}
|
||
|
|
|
||
|
|
long DetectServerUtcOffsetSeconds()
|
||
|
|
{
|
||
|
|
if(InpManualServerUtcOffsetMinutes!=999999)
|
||
|
|
return (long)InpManualServerUtcOffsetMinutes*60;
|
||
|
|
|
||
|
|
datetime server_now=TimeTradeServer();
|
||
|
|
datetime utc_now=TimeGMT();
|
||
|
|
if(server_now<=0 || utc_now<=0)
|
||
|
|
{
|
||
|
|
Print("Could not auto-detect the current server UTC offset; using zero for diagnostics.");
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
long raw=(long)server_now-(long)utc_now;
|
||
|
|
return (long)MathRound((double)raw/60.0)*60;
|
||
|
|
}
|
||
|
|
|
||
|
|
long ServerUtcOffsetSecondsAt(const datetime server_time)
|
||
|
|
{
|
||
|
|
if(InpManualServerUtcOffsetMinutes!=999999)
|
||
|
|
return (long)InpManualServerUtcOffsetMinutes*60;
|
||
|
|
|
||
|
|
MqlDateTime current;
|
||
|
|
TimeToStruct(server_time,current);
|
||
|
|
|
||
|
|
int march_sunday=LastSundayDay(current.year,3);
|
||
|
|
int october_sunday=LastSundayDay(current.year,10);
|
||
|
|
|
||
|
|
MqlDateTime start_dt;
|
||
|
|
ZeroMemory(start_dt);
|
||
|
|
start_dt.year=current.year;
|
||
|
|
start_dt.mon=3;
|
||
|
|
start_dt.day=march_sunday;
|
||
|
|
start_dt.hour=3; // 03:00 broker winter time
|
||
|
|
|
||
|
|
MqlDateTime end_dt;
|
||
|
|
ZeroMemory(end_dt);
|
||
|
|
end_dt.year=current.year;
|
||
|
|
end_dt.mon=10;
|
||
|
|
end_dt.day=october_sunday;
|
||
|
|
end_dt.hour=4; // 04:00 broker summer time
|
||
|
|
|
||
|
|
datetime summer_start=StructToTime(start_dt);
|
||
|
|
datetime summer_end=StructToTime(end_dt);
|
||
|
|
|
||
|
|
if(server_time>=summer_start && server_time<summer_end)
|
||
|
|
return (long)InpSummerServerUtcOffsetMinutes*60;
|
||
|
|
return (long)InpWinterServerUtcOffsetMinutes*60;
|
||
|
|
}
|
||
|
|
|
||
|
|
string FormatIsoUtc(const datetime value)
|
||
|
|
{
|
||
|
|
MqlDateTime dt;
|
||
|
|
TimeToStruct(value,dt);
|
||
|
|
return StringFormat("%04d-%02d-%02dT%02d:%02d:%02dZ",
|
||
|
|
dt.year,dt.mon,dt.day,dt.hour,dt.min,dt.sec);
|
||
|
|
}
|
||
|
|
|
||
|
|
string FormatServerTime(const datetime value)
|
||
|
|
{
|
||
|
|
MqlDateTime dt;
|
||
|
|
TimeToStruct(value,dt);
|
||
|
|
return StringFormat("%04d-%02d-%02d %02d:%02d:%02d",
|
||
|
|
dt.year,dt.mon,dt.day,dt.hour,dt.min,dt.sec);
|
||
|
|
}
|
||
|
|
|
||
|
|
string BuildSyntheticEventId(const datetime utc_minute)
|
||
|
|
{
|
||
|
|
MqlDateTime dt;
|
||
|
|
TimeToStruct(utc_minute,dt);
|
||
|
|
return StringFormat("MT5_%04d%02d%02d_%02d%02d",
|
||
|
|
dt.year,dt.mon,dt.day,dt.hour,dt.min);
|
||
|
|
}
|
||
|
|
|
||
|
|
//+------------------------------------------------------------------+
|
||
|
|
//| Row aggregation |
|
||
|
|
//+------------------------------------------------------------------+
|
||
|
|
int FindRowByUtcMinute(const datetime utc_minute)
|
||
|
|
{
|
||
|
|
int count=ArraySize(g_rows);
|
||
|
|
for(int i=0;i<count;i++)
|
||
|
|
if(g_rows[i].utc_minute==utc_minute)
|
||
|
|
return i;
|
||
|
|
return -1;
|
||
|
|
}
|
||
|
|
|
||
|
|
int AddRow(const datetime utc_minute,const datetime server_minute)
|
||
|
|
{
|
||
|
|
int index=ArraySize(g_rows);
|
||
|
|
if(ArrayResize(g_rows,index+1)!=index+1)
|
||
|
|
return -1;
|
||
|
|
|
||
|
|
g_rows[index].utc_minute=utc_minute;
|
||
|
|
g_rows[index].server_minute=server_minute;
|
||
|
|
g_rows[index].names="";
|
||
|
|
g_rows[index].source_event_ids="";
|
||
|
|
g_rows[index].event_codes="";
|
||
|
|
g_rows[index].source_urls="";
|
||
|
|
g_rows[index].currencies="";
|
||
|
|
g_rows[index].max_importance=(int)CALENDAR_IMPORTANCE_NONE;
|
||
|
|
return index;
|
||
|
|
}
|
||
|
|
|
||
|
|
void SortRowsByTime()
|
||
|
|
{
|
||
|
|
int count=ArraySize(g_rows);
|
||
|
|
for(int i=1;i<count;i++)
|
||
|
|
{
|
||
|
|
ExportRow key=g_rows[i];
|
||
|
|
int j=i-1;
|
||
|
|
while(j>=0 && g_rows[j].utc_minute>key.utc_minute)
|
||
|
|
{
|
||
|
|
g_rows[j+1]=g_rows[j];
|
||
|
|
j--;
|
||
|
|
}
|
||
|
|
g_rows[j+1]=key;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
bool ProcessCalendarValue(const MqlCalendarValue &value,const string currency)
|
||
|
|
{
|
||
|
|
MqlCalendarEvent event;
|
||
|
|
ResetLastError();
|
||
|
|
if(!CalendarEventById(value.event_id,event))
|
||
|
|
{
|
||
|
|
PrintFormat("CalendarEventById failed for event_id=%I64u, error=%d",value.event_id,::GetLastError());
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
if((int)event.importance<(int)InpMinimumImportance)
|
||
|
|
return true;
|
||
|
|
if(InpOnlyExactTimeEvents && event.time_mode!=CALENDAR_TIMEMODE_DATETIME)
|
||
|
|
return true;
|
||
|
|
if(!PassesNameFilter(event.name,event.event_code))
|
||
|
|
return true;
|
||
|
|
|
||
|
|
long event_offset_seconds=ServerUtcOffsetSecondsAt(value.time);
|
||
|
|
datetime utc_time=(datetime)((long)value.time-event_offset_seconds);
|
||
|
|
datetime utc_minute=(datetime)(((long)utc_time/60)*60);
|
||
|
|
datetime server_minute=(datetime)(((long)value.time/60)*60);
|
||
|
|
|
||
|
|
int row_index=FindRowByUtcMinute(utc_minute);
|
||
|
|
if(row_index<0)
|
||
|
|
row_index=AddRow(utc_minute,server_minute);
|
||
|
|
if(row_index<0)
|
||
|
|
{
|
||
|
|
Print("ArrayResize failed while aggregating calendar rows.");
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
AppendUnique(g_rows[row_index].names,event.name," + ");
|
||
|
|
AppendUnique(g_rows[row_index].source_event_ids,StringFormat("%I64u",event.id),"|");
|
||
|
|
AppendUnique(g_rows[row_index].event_codes,event.event_code,"|");
|
||
|
|
AppendUnique(g_rows[row_index].source_urls,event.source_url,"|");
|
||
|
|
AppendUnique(g_rows[row_index].currencies,currency,"|");
|
||
|
|
if((int)event.importance>g_rows[row_index].max_importance)
|
||
|
|
g_rows[row_index].max_importance=(int)event.importance;
|
||
|
|
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
int RequestCalendarRange(const string currency,
|
||
|
|
const datetime server_from,
|
||
|
|
const datetime server_to,
|
||
|
|
MqlCalendarValue &values[])
|
||
|
|
{
|
||
|
|
for(int attempt=1;attempt<=MathMax(1,InpRequestRetries);attempt++)
|
||
|
|
{
|
||
|
|
ArrayFree(values);
|
||
|
|
ResetLastError();
|
||
|
|
int count=CalendarValueHistory(values,server_from,server_to,NULL,currency);
|
||
|
|
if(count>=0)
|
||
|
|
return count;
|
||
|
|
|
||
|
|
int error=::GetLastError();
|
||
|
|
PrintFormat("CalendarValueHistory failed: currency=%s from=%s to=%s "
|
||
|
|
"attempt=%d/%d error=%d",
|
||
|
|
currency,FormatServerTime(server_from),FormatServerTime(server_to),
|
||
|
|
attempt,MathMax(1,InpRequestRetries),error);
|
||
|
|
if(attempt<MathMax(1,InpRequestRetries))
|
||
|
|
Sleep(MathMax(100,InpRetryDelayMilliseconds));
|
||
|
|
}
|
||
|
|
return -1;
|
||
|
|
}
|
||
|
|
|
||
|
|
bool CollectCurrency(const string currency,const datetime server_from,const datetime server_to)
|
||
|
|
{
|
||
|
|
long chunk_seconds=(long)MathMax(1,InpQueryChunkDays)*86400;
|
||
|
|
datetime chunk_from=server_from;
|
||
|
|
int total_count=0;
|
||
|
|
|
||
|
|
while(chunk_from<server_to)
|
||
|
|
{
|
||
|
|
datetime chunk_to=(datetime)((long)chunk_from+chunk_seconds);
|
||
|
|
if(chunk_to>server_to)
|
||
|
|
chunk_to=server_to;
|
||
|
|
|
||
|
|
MqlCalendarValue values[];
|
||
|
|
int count=RequestCalendarRange(currency,chunk_from,chunk_to,values);
|
||
|
|
if(count<0)
|
||
|
|
return false;
|
||
|
|
|
||
|
|
total_count+=count;
|
||
|
|
for(int i=0;i<count;i++)
|
||
|
|
if(!ProcessCalendarValue(values[i],currency))
|
||
|
|
return false;
|
||
|
|
|
||
|
|
if(chunk_to>=server_to)
|
||
|
|
break;
|
||
|
|
chunk_from=(datetime)((long)chunk_to+1);
|
||
|
|
}
|
||
|
|
|
||
|
|
PrintFormat("Calendar query currency=%s returned %d value(s).",currency,total_count);
|
||
|
|
if(InpRequireValuesForEveryCurrency && total_count==0)
|
||
|
|
{
|
||
|
|
PrintFormat("No calendar values were returned for %s; previous timetable will be retained.",currency);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
bool CollectCalendarRows()
|
||
|
|
{
|
||
|
|
ArrayResize(g_rows,0);
|
||
|
|
g_server_utc_offset_seconds=DetectServerUtcOffsetSeconds();
|
||
|
|
|
||
|
|
datetime server_now=TimeTradeServer();
|
||
|
|
if(server_now<=0)
|
||
|
|
{
|
||
|
|
Print("TimeTradeServer() returned zero; calendar export aborted.");
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
int safe_days=MathMax(1,MathMin(InpDaysAhead,366));
|
||
|
|
datetime server_to=(datetime)((long)server_now+(long)safe_days*86400);
|
||
|
|
|
||
|
|
string currency_list=TrimCopy(InpCurrencies);
|
||
|
|
if(StringLen(currency_list)==0)
|
||
|
|
{
|
||
|
|
MqlCalendarValue values[];
|
||
|
|
ResetLastError();
|
||
|
|
int count=CalendarValueHistory(values,server_now,server_to,NULL,NULL);
|
||
|
|
if(count<0)
|
||
|
|
{
|
||
|
|
PrintFormat("CalendarValueHistory failed without a currency filter, error=%d",::GetLastError());
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
for(int i=0;i<count;i++)
|
||
|
|
if(!ProcessCalendarValue(values[i],"ALL"))
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
string currencies[];
|
||
|
|
ushort separator=(ushort)StringGetCharacter(";",0);
|
||
|
|
int currency_count=StringSplit(currency_list,separator,currencies);
|
||
|
|
bool queried_any=false;
|
||
|
|
|
||
|
|
for(int i=0;i<currency_count;i++)
|
||
|
|
{
|
||
|
|
string currency=TrimCopy(currencies[i]);
|
||
|
|
StringToUpper(currency);
|
||
|
|
if(StringLen(currency)==0)
|
||
|
|
continue;
|
||
|
|
queried_any=true;
|
||
|
|
if(!CollectCurrency(currency,server_now,server_to))
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
if(!queried_any)
|
||
|
|
{
|
||
|
|
Print("InpCurrencies contains no usable currency codes.");
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
SortRowsByTime();
|
||
|
|
if(ArraySize(g_rows)==0)
|
||
|
|
{
|
||
|
|
Print("No events passed the filters; previous timetable will be retained.");
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
PrintFormat("Aggregated %d unique UTC event minute(s); current server offset=%.2f hours.",
|
||
|
|
ArraySize(g_rows),(double)g_server_utc_offset_seconds/3600.0);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
//+------------------------------------------------------------------+
|
||
|
|
//| Atomic CSV writing |
|
||
|
|
//+------------------------------------------------------------------+
|
||
|
|
bool ReplaceWithTemporaryFile(const string temp_relative,const string final_relative)
|
||
|
|
{
|
||
|
|
int destination_flags=FILE_REWRITE;
|
||
|
|
for(int attempt=1;attempt<=5;attempt++)
|
||
|
|
{
|
||
|
|
ResetLastError();
|
||
|
|
if(FileMove(temp_relative,0,final_relative,destination_flags))
|
||
|
|
return true;
|
||
|
|
int error=::GetLastError();
|
||
|
|
if(attempt==5)
|
||
|
|
{
|
||
|
|
PrintFormat("FileMove failed after %d attempts: %s -> %s, error=%d",
|
||
|
|
attempt,temp_relative,final_relative,error);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
Sleep(100);
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
bool WriteTimetableCsv()
|
||
|
|
{
|
||
|
|
string final_relative=StagingRelativePath(STAGING_TIMETABLE);
|
||
|
|
string temp_relative=final_relative+".tmp";
|
||
|
|
int flags=FILE_WRITE|FILE_CSV|FILE_ANSI|FILE_SHARE_READ;
|
||
|
|
|
||
|
|
ResetLastError();
|
||
|
|
int handle=FileOpen(temp_relative,flags,',',CP_UTF8);
|
||
|
|
if(handle==INVALID_HANDLE)
|
||
|
|
{
|
||
|
|
PrintFormat("FileOpen failed for %s, error=%d",temp_relative,::GetLastError());
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
FileWrite(handle,
|
||
|
|
"event_id","event_name","utc_datetime","enabled",
|
||
|
|
"source_status","source_url","notes");
|
||
|
|
|
||
|
|
int count=ArraySize(g_rows);
|
||
|
|
for(int i=0;i<count;i++)
|
||
|
|
{
|
||
|
|
string event_id=BuildSyntheticEventId(g_rows[i].utc_minute);
|
||
|
|
string importance=EnumToString((ENUM_CALENDAR_EVENT_IMPORTANCE)g_rows[i].max_importance);
|
||
|
|
string notes=StringFormat(
|
||
|
|
"MT5 calendar; importance=%s; currencies=%s; server_time=%s; server_utc_offset_minutes=%d; source_event_ids=%s; event_codes=%s",
|
||
|
|
importance,
|
||
|
|
g_rows[i].currencies,
|
||
|
|
FormatServerTime(g_rows[i].server_minute),
|
||
|
|
(int)(ServerUtcOffsetSecondsAt(g_rows[i].server_minute)/60),
|
||
|
|
g_rows[i].source_event_ids,
|
||
|
|
g_rows[i].event_codes);
|
||
|
|
|
||
|
|
FileWrite(handle,
|
||
|
|
event_id,
|
||
|
|
CleanCsvText(g_rows[i].names),
|
||
|
|
FormatIsoUtc(g_rows[i].utc_minute),
|
||
|
|
(InpRowsEnabled ? "1" : "0"),
|
||
|
|
"MT5 economic calendar",
|
||
|
|
CleanCsvText(g_rows[i].source_urls),
|
||
|
|
CleanCsvText(notes));
|
||
|
|
}
|
||
|
|
|
||
|
|
FileFlush(handle);
|
||
|
|
FileClose(handle);
|
||
|
|
|
||
|
|
if(!ReplaceWithTemporaryFile(temp_relative,final_relative))
|
||
|
|
return false;
|
||
|
|
if(!PublishToAbsolutePath(final_relative,g_timetable_full_path))
|
||
|
|
return false;
|
||
|
|
|
||
|
|
PrintFormat("Timetable exported: %s (%d row(s), enabled=%s)",
|
||
|
|
g_timetable_full_path,count,(InpRowsEnabled ? "true" : "false"));
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
bool WriteDetailedCsv()
|
||
|
|
{
|
||
|
|
if(!InpWriteDetailedFile)
|
||
|
|
return true;
|
||
|
|
|
||
|
|
string final_relative=StagingRelativePath(STAGING_DETAILED);
|
||
|
|
string temp_relative=final_relative+".tmp";
|
||
|
|
int flags=FILE_WRITE|FILE_CSV|FILE_ANSI|FILE_SHARE_READ;
|
||
|
|
|
||
|
|
ResetLastError();
|
||
|
|
int handle=FileOpen(temp_relative,flags,',',CP_UTF8);
|
||
|
|
if(handle==INVALID_HANDLE)
|
||
|
|
{
|
||
|
|
PrintFormat("FileOpen failed for %s, error=%d",temp_relative,::GetLastError());
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
FileWrite(handle,
|
||
|
|
"group_event_id","event_name","utc_datetime","server_datetime",
|
||
|
|
"server_utc_offset_minutes","importance","currencies",
|
||
|
|
"source_event_ids","event_codes","source_urls");
|
||
|
|
|
||
|
|
int count=ArraySize(g_rows);
|
||
|
|
for(int i=0;i<count;i++)
|
||
|
|
{
|
||
|
|
FileWrite(handle,
|
||
|
|
BuildSyntheticEventId(g_rows[i].utc_minute),
|
||
|
|
CleanCsvText(g_rows[i].names),
|
||
|
|
FormatIsoUtc(g_rows[i].utc_minute),
|
||
|
|
FormatServerTime(g_rows[i].server_minute),
|
||
|
|
(int)(ServerUtcOffsetSecondsAt(g_rows[i].server_minute)/60),
|
||
|
|
EnumToString((ENUM_CALENDAR_EVENT_IMPORTANCE)g_rows[i].max_importance),
|
||
|
|
CleanCsvText(g_rows[i].currencies),
|
||
|
|
CleanCsvText(g_rows[i].source_event_ids),
|
||
|
|
CleanCsvText(g_rows[i].event_codes),
|
||
|
|
CleanCsvText(g_rows[i].source_urls));
|
||
|
|
}
|
||
|
|
|
||
|
|
FileFlush(handle);
|
||
|
|
FileClose(handle);
|
||
|
|
|
||
|
|
if(!ReplaceWithTemporaryFile(temp_relative,final_relative))
|
||
|
|
return false;
|
||
|
|
if(!PublishToAbsolutePath(final_relative,g_detailed_full_path))
|
||
|
|
return false;
|
||
|
|
|
||
|
|
PrintFormat("Detailed calendar exported: %s",g_detailed_full_path);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
bool ExportCalendar()
|
||
|
|
{
|
||
|
|
if(!CollectCalendarRows())
|
||
|
|
return false;
|
||
|
|
if(!WriteTimetableCsv())
|
||
|
|
return false;
|
||
|
|
if(!WriteDetailedCsv())
|
||
|
|
return false;
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
//+------------------------------------------------------------------+
|
||
|
|
//| Scheduling |
|
||
|
|
//+------------------------------------------------------------------+
|
||
|
|
void ScheduleNextExport(const bool success)
|
||
|
|
{
|
||
|
|
long wait_seconds;
|
||
|
|
if(success)
|
||
|
|
wait_seconds=(long)MathMax(1,InpRefreshHours)*3600;
|
||
|
|
else
|
||
|
|
wait_seconds=(long)MathMax(1,InpRetryMinutes)*60;
|
||
|
|
g_next_export_tick=GetTickCount64()+(ulong)wait_seconds*1000;
|
||
|
|
}
|
||
|
|
|
||
|
|
bool RunExport(const string reason)
|
||
|
|
{
|
||
|
|
if(g_export_running)
|
||
|
|
return false;
|
||
|
|
|
||
|
|
g_export_running=true;
|
||
|
|
PrintFormat("Calendar export started: %s",reason);
|
||
|
|
bool success=ExportCalendar();
|
||
|
|
if(success)
|
||
|
|
PrintFormat("Calendar export completed; next regular export in %d hour(s).",
|
||
|
|
MathMax(1,InpRefreshHours));
|
||
|
|
else
|
||
|
|
PrintFormat("Calendar export failed; previous timetable retained; retry in %d minute(s).",
|
||
|
|
MathMax(1,InpRetryMinutes));
|
||
|
|
g_export_running=false;
|
||
|
|
return success;
|
||
|
|
}
|
||
|
|
|
||
|
|
//+------------------------------------------------------------------+
|
||
|
|
//| Expert lifecycle |
|
||
|
|
//+------------------------------------------------------------------+
|
||
|
|
int OnInit()
|
||
|
|
{
|
||
|
|
if(!MQLInfoInteger(MQL_DLLS_ALLOWED))
|
||
|
|
{
|
||
|
|
Print("DLL imports are disabled. Enable 'Allow DLL imports' in this EA's Dependencies tab.");
|
||
|
|
return INIT_FAILED;
|
||
|
|
}
|
||
|
|
|
||
|
|
g_timetable_full_path=NormalizeAbsolutePath(InpTimetableFullPath);
|
||
|
|
if(StringLen(g_timetable_full_path)==0 || !IsAbsoluteWindowsFilePath(g_timetable_full_path))
|
||
|
|
{
|
||
|
|
PrintFormat("InpTimetableFullPath must be a complete absolute Windows file path, for example: "
|
||
|
|
"C:\\Users\\3dstudio\\Desktop\\Backup-2\\XAU\\timetable.csv. Received: %s",
|
||
|
|
g_timetable_full_path);
|
||
|
|
return INIT_PARAMETERS_INCORRECT;
|
||
|
|
}
|
||
|
|
|
||
|
|
string output_folder=ParentFolder(g_timetable_full_path);
|
||
|
|
if(StringLen(output_folder)==0)
|
||
|
|
{
|
||
|
|
PrintFormat("Could not determine the destination folder from: %s",g_timetable_full_path);
|
||
|
|
return INIT_PARAMETERS_INCORRECT;
|
||
|
|
}
|
||
|
|
g_detailed_full_path=JoinWindowsPath(output_folder,DETAILED_OUTPUT_NAME);
|
||
|
|
|
||
|
|
if(InpDaysAhead<1 || InpDaysAhead>60)
|
||
|
|
{
|
||
|
|
Print("InpDaysAhead must be between 1 and 60.");
|
||
|
|
return INIT_PARAMETERS_INCORRECT;
|
||
|
|
}
|
||
|
|
if(InpRefreshHours<1 || InpRefreshHours>24)
|
||
|
|
{
|
||
|
|
Print("InpRefreshHours must be between 1 and 24.");
|
||
|
|
return INIT_PARAMETERS_INCORRECT;
|
||
|
|
}
|
||
|
|
if(InpRetryMinutes<1 || InpRetryMinutes>60)
|
||
|
|
{
|
||
|
|
Print("InpRetryMinutes must be between 1 and 60.");
|
||
|
|
return INIT_PARAMETERS_INCORRECT;
|
||
|
|
}
|
||
|
|
if(InpQueryChunkDays<1 || InpQueryChunkDays>14)
|
||
|
|
{
|
||
|
|
Print("InpQueryChunkDays must be between 1 and 14.");
|
||
|
|
return INIT_PARAMETERS_INCORRECT;
|
||
|
|
}
|
||
|
|
|
||
|
|
// The timer checks once a minute. Actual successful exports are four hours apart.
|
||
|
|
if(!EventSetTimer(60))
|
||
|
|
{
|
||
|
|
PrintFormat("EventSetTimer failed, error=%d",::GetLastError());
|
||
|
|
return INIT_FAILED;
|
||
|
|
}
|
||
|
|
|
||
|
|
PrintFormat("Calendar exporter started. Target: %s",g_timetable_full_path);
|
||
|
|
PrintFormat("Selection: currencies=%s, minimum importance=%s, horizon=%d days.",
|
||
|
|
InpCurrencies,EnumToString(InpMinimumImportance),InpDaysAhead);
|
||
|
|
Print("The destination folder must already exist and Allow DLL imports must remain enabled.");
|
||
|
|
|
||
|
|
bool initial_success=RunExport("startup");
|
||
|
|
ScheduleNextExport(initial_success);
|
||
|
|
return INIT_SUCCEEDED;
|
||
|
|
}
|
||
|
|
|
||
|
|
void OnDeinit(const int reason)
|
||
|
|
{
|
||
|
|
EventKillTimer();
|
||
|
|
PrintFormat("Calendar exporter stopped, reason=%d",reason);
|
||
|
|
}
|
||
|
|
|
||
|
|
void OnTimer()
|
||
|
|
{
|
||
|
|
if(g_export_running)
|
||
|
|
return;
|
||
|
|
if(g_next_export_tick==0 || GetTickCount64()>=g_next_export_tick)
|
||
|
|
{
|
||
|
|
bool success=RunExport("scheduled");
|
||
|
|
ScheduleNextExport(success);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
void OnTick()
|
||
|
|
{
|
||
|
|
// No trading logic. Exporting is timer-driven.
|
||
|
|
}
|
||
|
|
//+------------------------------------------------------------------+
|