按国家/地区或货币跟踪事件变化

日历的基本概念一节所述,平台通过某些内部方式注册所有事件变更。每个状态都由一个变更标识符 (change_id) 来表征。在 MQL5 函数中,有两个函数允许你找到此标识符(在任意时间点),然后请求在此时间点之后发生变更的日历条目。其中一个函数是 CalendarValueLast,本节将对其进行讨论。第二个函数 CalendarValueLastByEvent 将在下一节讨论。

int CalendarValueLast(ulong &change_id, MqlCalendarValue &values[],
const string country = NULL, const string currency = NULL)

CalendarValueLast 函数设计用于两个目的:获取最后已知的日历变更标识符 change_id,并用自上次(由相同 change_id 中传入的 ID 指定)修改以来已修改的记录填充 values 数组。换句话说,change_id 参数既作为输入也作为输出。因此,它是一个引用,并且需要指定一个变量。

如果我们将等于 0 的 change_id 输入到函数中,则函数将用当前标识符填充该变量,但不会填充数组。

(可选)使用参数 countrycurrency,你可以按国家/地区和货币设置记录筛选。

该函数返回复制的日历项数量。由于在第一种操作模式下 (change_id = 0) 数组不会被填充,因此返回 0 不是错误。如果自指定的变更以来日历未被修改,我们也可能得到 0。因此,要检查错误,你应该分析 _LastError

因此,使用该函数的通常方法是循环检查日历的变更。

ulong change = 0;
MqlCalendarValue values[];
while(!IsStopped())
{
 // pass the last identifier known to us and get a new one if it appeared
   if(CalendarValueLast(changevalues))
   {
 // analysis of added and changed records
      ArrayPrint(values);
      ... 
   }
   Sleep(1000);
}

这可以在循环中、按计时器或按其他事件进行。

标识符不断增加,但它们可能会乱序,也就是说,可能会跳过几个值。

需要注意的是,每个日历条目始终仅以其最后一个状态可用:MQL5 中不提供变更历史。通常,这不是问题,因为每个新闻的生命周期都是标准的:提前足够长的时间添加到数据库,并在事件发生时补充相关数据。然而,在实践中,可能会发生各种偏差:编辑预测、调整时间或修正数值。无法通过 MQL5 API 从日历历史中准确查明记录中何时以及何处发生了更改。因此,那些根据即时情况做出决策的交易系统将需要独立保存变更历史并将其集成到 EA 交易中,以便在测试程序中运行。

使用 CalendarValueLast 函数,我们可以创建一个有用的服务 CalendarChangeSaver.mq5,它将按指定的时间间隔检查日历的变更,如果存在变更,则将变更标识符与当前服务器时间一起保存到文件中。这将允许后续使用文件信息对 EA 交易在日历历史上的测试进行更真实的模拟。当然,这将需要规划整个日历数据库的导出/导入,我们将逐步处理这个问题。

我们提供输入变量来指定文件名和轮询之间的时间段(以毫秒为单位)。

input string Filename = "calendar.chn";
input int PeriodMsc = 1000;

OnStart 处理程序的开头,我们打开二进制文件进行写入,或者更确切地说是追加(如果文件已存在)。此处未检查现有文件的格式,因此在嵌入到实际应用程序中时应添加保护。

void OnStart()
{
   ulong change = 0last = 0;
   int count = 0;
   int handle = FileOpen(Filename,
      FILE_WRITE | FILE_READ | FILE_SHARE_WRITE | FILE_SHARE_READ | FILE_BIN);
   if(handle == INVALID_HANDLE)
   {
      PrintFormat("Can't open file '%s' for writing"Filename);
      return;
   }
   
   const ulong p = FileSize(handle);
   if(p > 0)
   {
      PrintFormat("Resuming file %lld bytes"p);
      FileSeek(handle0SEEK_END);
   }
   
   Print("Requesting start ID...");
   ...

这里我们插入一个题外话。

每次日历更改时,至少必须将一对整数 8 字节数字写入文件:当前时间 (datetime) 和新闻 ID (ulong),但同时更改的记录可能不止一条。因此,除了日期之外,已更改记录的数量也打包到第一个数字中。这考虑到了日期可存储在 0x7FFFFFFFF 范围内,因此高位的 3 个字节未使用。而服务将在对应时间戳之后写入的标识符数量,就存储在这两个最高有效字节中(位于左移 48 位的偏移位置)。PACK_DATETIME_COUNTER 宏创建一个“扩展”日期,而另外两个宏 DATETIME 和 COUNTER,我们稍后在读取变更存档时(由另一个程序)会需要。

#define PACK_DATETIME_COUNTER(D,C) (D | (((ulong)(C)) << 48))
#define DATETIME(A) ((datetime)((A) & 0x7FFFFFFFF))
#define COUNTER(A)  ((ushort)((A) >> 48)) 

现在我们回到主要的服务代码。在一个每 PeriodMsc 毫秒激活一次的循环中,我们使用 CalendarValueLast 请求变更。如果存在变更,我们将当前服务器时间和接收到的标识符数组写入文件。

   while(!IsStopped())
   {
      if(!TerminalInfoInteger(TERMINAL_CONNECTED))
      {
         Print("Waiting for connection...");
         Sleep(PeriodMsc);
         continue;
      }
      
      MqlCalendarValue values[];
      const int n = CalendarValueLast(changevalues);
      if(n > 0)
      {
         string records = "[" + Description(values[0]);
         for(int i = 1i < n; ++i)
         {
            records += "," + Description(values[i]);
         }
         records += "]";
         Print("New change ID: "change" ",
            TimeToString(TimeTradeServer(), TIME_DATE | TIME_SECONDS), "\n"records);
         FileWriteLong(handlePACK_DATETIME_COUNTER(TimeTradeServer(), n));
         for(int i = 0i < n; ++i)
         {
            FileWriteLong(handlevalues[i].id);
         }
         FileFlush(handle);
         ++count;
      }
      else if(_LastError == 0)
      {
         if(!last && change)
         {
            Print("Start change ID obtained: "change);
         }
      }
      
      last = change;
      Sleep(PeriodMsc);
   }
   PrintFormat("%d records added"count);
   FileClose(handle);
}

为了方便地显示每个新闻事件的信息,我们编写了一个辅助函数 Description

string Description(const MqlCalendarValue &value)
{
   MqlCalendarEvent event;
   MqlCalendarCountry country;
   CalendarEventById(value.event_idevent);
   CalendarCountryById(event.country_idcountry);
   return StringFormat("%lld (%s/%s @ %s)",
      value.idcountry.codeevent.nameTimeToString(value.time));
}

因此,日志不仅会显示标识符,还会显示新闻的国家/地区代码、标题和预定时间。

假设该服务应该运行相当长的时间,以便收集足够测试期间(天、周、月)的信息。不过遗憾的是,就像订单簿一样,平台不提供现成的订单簿或日历编辑历史,因此这些内容的收集工作就完全留给了 MQL 程序的开发者。

我们来看看该服务的实际应用。在日志的下一段片段中(时间段为 2022.06.28 15:30 - 16:00),一些新闻事件与遥远的未来相关(它们包含 prev_value 字段的值,该字段也是同名当前事件的 actual_value 字段)。然而,更重要的是:新闻发布的实际时间有时可能与计划时间相差很大,有时甚至相差几分钟。

Requesting start ID...
Start change ID obtained: 86358784
New change ID: 86359040 2022.06.28 15:30:42
[155955 (US/Wholesale Inventories m/m @ 2022.06.28 15:30)]
New change ID: 86359296 2022.06.28 15:30:45
[155956 (US/Wholesale Inventories m/m @ 2022.07.08 17:00)]
New change ID: 86359552 2022.06.28 15:30:48
[156117 (US/Goods Trade Balance @ 2022.06.28 15:30)]
New change ID: 86359808 2022.06.28 15:30:51
[156118 (US/Goods Trade Balance @ 2022.07.27 15:30)]
New change ID: 86360064 2022.06.28 15:30:54
[156231 (US/Retail Inventories m/m @ 2022.06.28 15:30)]
New change ID: 86360320 2022.06.28 15:30:57
[156232 (US/Retail Inventories m/m @ 2022.07.15 17:00)]
New change ID: 86360576 2022.06.28 15:31:00
[156255 (US/Retail Inventories excl. Autos m/m @ 2022.06.28 15:30)]
New change ID: 86360832 2022.06.28 15:31:03
[156256 (US/Retail Inventories excl. Autos m/m @ 2022.07.15 17:00)]
New change ID: 86361088 2022.06.28 15:31:07
[155956 (US/Wholesale Inventories m/m @ 2022.07.08 17:00)]
New change ID: 86361344 2022.06.28 15:31:10
[156118 (US/Goods Trade Balance @ 2022.07.27 15:30)]
New change ID: 86361600 2022.06.28 15:31:13
[156232 (US/Retail Inventories m/m @ 2022.07.15 17:00)]
New change ID: 86362368 2022.06.28 15:36:47
[158534 (US/Challenger Job Cuts y/y @ 2022.07.07 14:30)]
New change ID: 86362624 2022.06.28 15:51:23
...
New change ID: 86364160 2022.06.28 16:01:39
[154531 (US/HPI m/m @ 2022.06.28 16:00)]
New change ID: 86364416 2022.06.28 16:01:42
[154532 (US/HPI m/m @ 2022.07.26 16:00)]
New change ID: 86364672 2022.06.28 16:01:46
[154543 (US/HPI y/y @ 2022.06.28 16:00)]
New change ID: 86364928 2022.06.28 16:01:49
[154544 (US/HPI y/y @ 2022.07.26 16:00)]
New change ID: 86365184 2022.06.28 16:01:54
[154561 (US/HPI @ 2022.06.28 16:00)]
New change ID: 86365440 2022.06.28 16:01:58
[154571 (US/HPI @ 2022.07.26 16:00)]
New change ID: 86365696 2022.06.28 16:02:01
[154532 (US/HPI m/m @ 2022.07.26 16:00)]
New change ID: 86365952 2022.06.28 16:02:05
[154544 (US/HPI y/y @ 2022.07.26 16:00)]
New change ID: 86366208 2022.06.28 16:02:09
[154571 (US/HPI @ 2022.07.26 16:00)]

当然,这种差异并非对所有类别的交易策略都重要,只对那些在市场上快速交易的策略重要。对于它们来说,创建的日历编辑存档可以为新闻 EA 交易提供更准确的测试。我们将在未来讨论如何将日历“连接”到测试程序,但现在,我们将展示如何读取接收到的文件。

我们将使用 CalendarChangeReader.mq5 脚本来演示所讨论的功能。在实践中,给定的源代码应该放在 EA 交易中。

输入变量允许你设置要读取的文件名和扫描的开始日期。如果服务继续工作(写入文件),你需要将文件复制为不同名称或复制到另一个文件夹中(在示例脚本中,文件被重命名)。如果 Start 参数为空,则新闻变更的读取将从当天的开始处开始。

input string Filename = "calendar2.chn";
input datetime Start;

ChangeState 结构体用于存储有关单个编辑的信息。

struct ChangeState
{
   datetime dt;
   ulong ids[];
   
   ChangeState(): dt(LONG_MAX) {}
   ChangeState(const datetime atulong &_ids[])
   {
      dt = at;
      ArraySwap(ids_ids);
   }
   
   void operator=(const ChangeState &other)
   {
      dt = other.dt;
      ArrayCopy(idsother.ids);
   }
};

它用在 ChangeFileReader 类中,该类完成了读取文件并将适合特定时间点的变更提供给调用者的大部分工作。

文件句柄作为参数传递给构造函数,测试的开始时间也是如此。读取文件并为一个日历编辑填充 ChangeState 结构体,这两个操作是在 readState 方法中执行的。

class ChangeFileReader
{
   const int handle;
   ChangeState current;
   const ChangeState zero;
   
public:
   ChangeFileReader(const int hconst datetime start = 0): handle(h)
   {
      if(readState())
      {
         if(start)
         {
            ulong dummy[];
            check(startdummytrue); // find the first edit after start 
         }
      }
   }
   
   bool readState()
   {
      if(FileIsEnding(handle)) return false;
      ResetLastError();
      const ulong v = FileReadLong(handle);
      current.dt = DATETIME(v);
      ArrayFree(current.ids);
      const int n = COUNTER(v);
      for(int i = 0i < n; ++i)
      {
         PUSH(current.idsFileReadLong(handle));
      }
      return _LastError == 0;
   }
   ...

check 方法读取文件,直到将来出现下一个编辑。在这种情况下,自上次方法调用以来的所有先前(按时间戳)编辑都放在输出数组 records 中。

   bool check(datetime nowulong &records[], const bool fastforward = false)
   {
      if(current.dt > nowreturn false;
      
      ArrayFree(records);
      
      if(!fastforward)
      {
         ArrayCopy(recordscurrent.ids);
         current = zero;
      }
      
      while(readState() && current.dt <= now)
      {
         if(!fastforwardArrayInsert(recordscurrent.idsArraySize(records));
      }
      
      return true;
   }
};

下文介绍了如何在 OnStart 中使用该类。

void OnStart()
{
   const long day = 60 * 60 * 24;
   datetime now = Start ? Start : (datetime)(TimeCurrent() / day * day);
   
   int handle = FileOpen(Filename,
      FILE_READ | FILE_SHARE_WRITE | FILE_SHARE_READ | FILE_BIN);
   if(handle == INVALID_HANDLE)
   {
      PrintFormat("Can't open file '%s' for reading"Filename);
      return;
   }
   
   ChangeFileReader reader(handlenow);
   
   // reading step by step, time now artificially increased in this demo
   while(!FileIsEnding(handle))
   {
      // in a real application, a call to reader.check can be made on every tick
      ulong records[];
      if(reader.check(nowrecords))
      {
         Print(now);          // output time
         ArrayPrint(records); // array of IDs of changed news
      }
      now += 60// add 1 minute at a time, can be per second
   }
   
   FileClose(handle);
}

以下是针对与前一个日志片段上下文中服务保存的相同日历变更的脚本结果。

2022.06.28 15:31:00
155955 155956 156117 156118 156231 156232 156255
2022.06.28 15:32:00
156256 155956 156118 156232
2022.06.28 15:37:00
158534
...
2022.06.28 16:02:00
154531 154532 154543 154544 154561 154571
2022.06.28 16:03:00
154532 154544 154571

相同的标识符在虚拟时间中以与在线时相同的延迟再现,尽管在这里你可以看到四舍五入到 1 分钟,这是因为我们在循环中设置了如此大小的人为步长。理论上,出于效率考虑,我们可以将检查推迟到存储在 ChangeState current 结构体中的时间。附件的源代码定义了 getState 方法来获取这个时间。