Skip to content

经济日历

在制定交易策略时,最好考虑那些会影响市场的基本面因素。MetaTrader 5 有一个内置的经济日历,在程序界面中,它作为工具栏里的一个独立选项卡可供使用,同时也可以选择以标签的形式直接显示在图表上。可以通过终端设置对话框中 “社区” 选项卡上的一个单独标志来启用该日历(无需登录社区)。

由于 MetaTrader 5 支持算法交易,经济日历中的事件也可以通过 MQL5 API 以编程方式进行访问。在本章中,我们将介绍一些函数和数据结构,它们能够实现对经济事件的读取、筛选以及对其变化的监控。

经济日历包含了许多国家的宏观经济指标的描述、发布时间安排以及历史数值。对于每一个事件,都明确知晓其计划发布的确切时间、重要程度、对特定货币的影响、预测数值以及其他属性。宏观经济指标的实际数值会在发布之时立即传达到 MetaTrader 5 中。

经济日历的存在使得你能够自动分析传入的事件,并在智能交易系统(EA)中以多种方式对其做出反应。例如,作为突破策略的一部分进行交易,或者在区间内针对波动性波动进行交易。另一方面,了解市场即将出现的波动情况,可以让你在时间安排中找到市场平稳的时段,并暂时关闭那些因价格剧烈波动可能导致损失而面临风险的交易机器人。

所有与经济日历相关的函数和数据结构所使用的日期时间(datetime)类型的值,都与交易服务器时间(TimeTradeServer)一致,包括其所在的时区以及夏令时(Daylight Saving Time,DST)设置。换句话说,为了正确测试基于新闻的交易智能交易系统,其开发者必须在每年中大约半年的夏令时模式与当前不同的时间段内,自行修改历史新闻的时间。

日历函数不能在测试器中使用:当尝试调用其中任何一个函数时,我们会得到 “FUNCTION_NOT_ALLOWED(4014)” 错误。在这方面,测试基于日历的策略需要先在在线图表上运行 MQL 程序时,将日历条目保存到外部存储设备中(例如,保存到文件中),然后再从在测试器中运行的 MQL 程序中加载并读取这些条目。

日历的基本概念

在使用日历时,我们会涉及几个概念,MQL5 为了对这些概念进行形式化描述,定义了特殊的结构体类型。

首先,日历事件与特定国家相关,每个国家都使用 MqlCalendarCountry 结构体来描述:

c
struct MqlCalendarCountry
{ 
   ulong  id;              // 根据 ISO 3166-1 标准的国家标识符 
   string name;            // 国家的文本名称(采用当前终端的编码) 
   string code;            // 根据 ISO 3166-1 alpha-2 标准的两个字母的国家代码 
   string currency;        // 国际通用的国家货币代码 
   string currency_symbol; // 国家货币的符号 
   string url_name;        // 在 mql5.com 网站的 URL 中使用的国家名称 
};

如何获取日历中可用国家的列表以及它们的属性(以 MqlCalendarCountry 结构体数组的形式),我们将在下一节中介绍。

目前,我们只需关注 id 字段。这个字段很重要,因为它是确定日历事件是否属于某个特定国家的关键。在每个国家(或已注册的国家联盟,如欧盟)中,都有一个特定的、国际公认的经济指标和影响市场的信息事件类型列表,这些事件因此被纳入日历中。

每个事件类型由 MqlCalendarEvent 结构体定义,其中的 country_id 字段将事件唯一地与某个国家关联起来。我们将在下面介绍所使用的枚举类型。

c
struct MqlCalendarEvent
{ 
   ulong                          id;         // 事件 ID 
   ENUM_CALENDAR_EVENT_TYPE       type;       // 事件类型 
   ENUM_CALENDAR_EVENT_SECTOR     sector;     // 事件所属的经济领域 
   ENUM_CALENDAR_EVENT_FREQUENCY  frequency;  // 事件的频率(周期性) 
   ENUM_CALENDAR_EVENT_TIMEMODE   time_mode;  // 事件的时间模式 
   ulong                          country_id; // 国家标识符 
   ENUM_CALENDAR_EVENT_UNIT       unit;       // 指标单位 
   ENUM_CALENDAR_EVENT_IMPORTANCE importance; // 事件的重要性 
   ENUM_CALENDAR_EVENT_MULTIPLIER multiplier; // 指标乘数 
   uint                           digits;     // 小数位数 
   string                         source_url; // 事件发布来源的 URL 
   string                         event_code; // 事件代码 
   string                         name;       // 以终端语言显示的事件文本名称 
};

重要的是要理解,MqlCalendarEvent 结构体描述的是事件的类型(例如,消费者物价指数,CPI 的发布),而不是可能按季度、每月或其他时间安排发生的特定事件。它包含了事件的一般特征,包括重要性、频率、与经济领域的关系、度量单位、名称和信息来源。至于实际指标和预测指标,这些将在该类型的每个特定事件的日历条目中提供:这些条目存储为 MqlCalendarValue 结构体,我们将在后面讨论。查询支持的事件类型的函数将在后面的章节中介绍。

type 字段中的事件类型指定为 ENUM_CALENDAR_EVENT_TYPE 枚举值之一:

标识符描述
CALENDAR_TYPE_EVENT事件(会议、演讲等)
CALENDAR_TYPE_INDICATOR经济指标
CALENDAR_TYPE_HOLIDAY节假日(周末)

事件所属的经济领域从 ENUM_CALENDAR_EVENT_SECTOR 枚举中选择:

标识符描述
CALENDAR_SECTOR_NONE未设置领域
CALENDAR_SECTOR_MARKET市场,交易所
CALENDAR_SECTOR_GDP国内生产总值(GDP)
CALENDAR_SECTOR_JOBS劳动力市场
CALENDAR_SECTOR_PRICES价格
CALENDAR_SECTOR_MONEY货币
CALENDAR_SECTOR_TRADE贸易
CALENDAR_SECTOR_GOVERNMENT政府
CALENDAR_SECTOR_BUSINESS商业
CALENDAR_SECTOR_CONSUMER消费
CALENDAR_SECTOR_HOUSING住房
CALENDAR_SECTOR_TAXES税收
CALENDAR_SECTOR_HOLIDAYS节假日

事件的频率在 frequency 字段中使用 ENUM_CALENDAR_EVENT_FREQUENCY 枚举来表示:

标识符描述
CALENDAR_FREQUENCY_NONE未设置发布频率
CALENDAR_FREQUENCY_WEEK每周
CALENDAR_FREQUENCY_MONTH每月
CALENDAR_FREQUENCY_QUARTER每季度
CALENDAR_FREQUENCY_YEAR每年
CALENDAR_FREQUENCY_DAY每天

事件持续时间(time_mode)可以用 ENUM_CALENDAR_EVENT_TIMEMODE 枚举的元素之一来描述:

标识符描述
CALENDAR_TIMEMODE_DATETIME事件的准确时间已知
CALENDAR_TIMEMODE_DATE事件持续一整天
CALENDAR_TIMEMODE_NOTIME未公布时间
CALENDAR_TIMEMODE_TENTATIVE仅提前知道日期,但不知道事件的准确时间(事后指定时间)

事件的重要性在 importance 字段中使用 ENUM_CALENDAR_EVENT_IMPORTANCE 枚举来指定:

标识符描述
CALENDAR_IMPORTANCE_NONE未设置
CALENDAR_IMPORTANCE_LOW
CALENDAR_IMPORTANCE_MODERATE中等
CALENDAR_IMPORTANCE_HIGH

事件值的度量单位在 unit 字段中定义,为 ENUM_CALENDAR_EVENT_UNIT 枚举的成员:

标识符描述
CALENDAR_UNIT_NONE未设置单位
CALENDAR_UNIT_PERCENT百分比(%)
CALENDAR_UNIT_CURRENCY本国货币
CALENDAR_UNIT_HOUR小时数
CALENDAR_UNIT_JOB工作岗位数量
CALENDAR_UNIT_RIG钻机数量
CALENDAR_UNIT_USD美元
CALENDAR_UNIT_PEOPLE人数
CALENDAR_UNIT_MORTGAGE抵押贷款数量
CALENDAR_UNIT_VOTE投票数
CALENDAR_UNIT_BARREL桶数
CALENDAR_UNIT_CUBICFEET立方英尺体积
CALENDAR_UNIT_POSITION合约中投机头寸的净交易量
CALENDAR_UNIT_BUILDING建筑物数量

在某些情况下,经济指标的值需要根据 ENUM_CALENDAR_EVENT_MULTIPLIER 枚举的元素之一使用乘数:

标识符描述
CALENDAR_MULTIPLIER_NONE未设置乘数
CALENDAR_MULTIPLIER_THOUSANDS
CALENDAR_MULTIPLIER_MILLIONS百万
CALENDAR_MULTIPLIER_BILLIONS十亿
CALENDAR_MULTIPLIER_TRILLIONS万亿

这样,我们已经考虑了用于描述 MqlCalendarEvent 结构体中事件类型的所有特殊数据类型。

一个单独的日历条目由 MqlCalendarValue 结构体构成。下面给出了它的详细描述,但目前重要的是要注意以下细微之处。MqlCalendarValue 有一个 event_id 字段,它指向事件类型的标识符,即包含 MqlCalendarEvent 结构体中已有的 id 之一。

正如我们上面看到的,MqlCalendarEvent 结构体又通过 country_id 字段与 MqlCalendarCountry 相关联。因此,一旦将关于特定国家或事件类型的信息输入到日历数据库中,就可以为它们注册任意数量的类似事件。当然,填充数据库是信息提供者的责任,而不是开发者的责任。

让我们总结一下:系统分别存储三个内部表:

  1. 用于描述国家的 MqlCalendarCountry 结构体表。
  2. 包含事件类型描述的 MqlCalendarEvent 结构体表。
  3. 包含各种类型特定事件指标的 MqlCalendarValue 结构体表。

通过引用事件类型标识符,从特定事件的记录中消除了信息的重复。例如,CPI 值的每月发布仅引用具有该事件类型一般特征的相同 MqlCalendarEvent 结构体。如果没有不同的表,就需要在每个 CPI 日历条目中重复相同的属性。这种使用标识符字段在数据表格之间建立关系的方法称为关系型方法,我们将在关于 SQLite 的章节中再次讨论它。所有这些都在下面的图表中进行了说明:

按标识符字段的结构体之间的链接方案

所有表格都存储在内部日历数据库中,当终端连接到服务器时,该数据库会不断更新以保持最新状态。

日历条目(特定事件)是 MqlCalendarValue 结构体。它们也通过 id 字段中的自己的唯一编号进行标识(三个表中的每一个都有自己的 id 字段):

c
struct MqlCalendarValue 
{ 
   ulong      id;                 // 条目 ID 
   ulong      event_id;           // 事件类型 ID 
   datetime   time;               // 事件的时间和日期 
   datetime   period;             // 事件的报告期 
   int        revision;           // 与报告期相关的已发布指标的修订版 
   long       actual_value;       // 以百万分之一为单位的实际值或 LONG_MIN 
   long       prev_value;         // 以百万分之一为单位的上一个值或 LONG_MIN 
   long       revised_prev_value; // 以百万分之一为单位的修订后的上一个值或 LONG_MIN 
   long       forecast_value;     // 以百万分之一为单位的预测值或 LONG_MIN 
   ENUM_CALENDAR_EVENT_IMPACT impact_type;  // 对汇率的潜在影响
    
 // 用于检查值的函数
   bool HasActualValue(void) const;     // 如果 actual_value 字段已填充,则为 true 
   bool HasPreviousValue(void) const;   // 如果 prev_value 字段已填充,则为 true 
   bool HasRevisedValue(void) const;    // 如果 revised_prev_value 字段已填充,则为 true 
   bool HasForecastValue(void) const;   // 如果 forecast_value 字段已填充,则为 true
    
   // 用于获取值的函数 
   double GetActualValue(void) const;   // actual_value 或如果值未设置则为 nan 
   double GetPreviousValue(void) const; // prev_value 或如果值未设置则为 nan 
   double GetRevisedValue(void) const;  // revised_prev_value 或如果值未设置则为 nan 
   double GetForecastValue(void) const; // forecast_value 或如果值未设置则为 nan 
};

对于每个事件,除了其发布时间(time)之外,还存储以下四个值:

  1. 实际值(actual_value),在新闻发布后立即得知。
  2. 上一个值(prev_value),在上一次发布相同新闻时得知。
  3. 上一个指标的修订值,revised_prev_value(如果自上次发布以来已被修改)。
  4. 预测值(forecast_value)。

显然,并非所有字段都必须填充。因此,对于未来事件,当前值不存在(尚未得知),并且对过去值的修订也并非总是会发生。此外,所有四个字段仅对定量指标有意义,而日历还会反映监管机构的演讲、会议和节假日。

一个空字段(无值)由常量 LONG_MIN(-9223372036854775808)表示。如果字段中的值已指定(不等于 LONG_MIN),那么它对应于实际指标值的一百万倍,也就是说,要获得通常(实际)形式的指标,需要将字段值除以 1,000,000。

为了方便程序员,该结构体定义了 4 个 Has 方法来检查字段是否已填充,以及 4 个 Get 方法,这些方法返回相应字段的值,并已转换为实数,如果字段未填充,则该方法将返回 NaN(非数字)。

有时,为了获得绝对值(如果算法需要),额外分析 MqlCalendarEvent 结构体中的 multiplier 属性很重要,因为根据 ENUM_CALENDAR_EVENT_MULTIPLIER 枚举,一些值是以倍数单位指定的。此外,MqlCalendarEvent 有一个 digits 字段,它指定了接收到的值中的有效数字位数,以便后续进行正确的格式化(例如,在调用 NormalizeDouble 时)。

报告期(计算已发布指标的时期)在 period 字段中设置为其第一天。例如,如果指标是按月计算的,那么日期 '2022.05.01 00:00:00' 表示 5 月。时期的持续时间(例如,月、季度、年)在相关结构体 MqlCalendarEventfrequency 字段中定义:该字段的类型是上面描述的特殊 ENUM_CALENDAR_EVENT_FREQUENCY 枚举,以及其他枚举。

特别有趣的是 impact_type 字段,在新闻发布后,通过比较当前值和预测值,会自动设置相应货币对汇率的影响方向。这种影响可以是正面的(预计货币升值)或负面的(预计货币贬值)。例如,销售额下降幅度超过预期将被标记为具有负面影响,而失业率下降幅度较大则为正面影响。但并非所有事件的这个特征都能明确解释(一些经济指标被认为是相互矛盾的),此外,还应注意变化的相对数值。

事件对本国货币汇率的潜在影响使用 ENUM_CALENDAR_EVENT_IMPACT 枚举来表示:

标识符描述
CALENDAR_IMPACT_NA未说明影响
CALENDAR_IMPACT_POSITIVE正面影响
CALENDAR_IMPACT_NEGATIVE负面影响

日历的另一个重要概念是其变化的事实。不幸的是,没有用于表示变化的特殊结构体。变化唯一的属性是其唯一 ID,这是每次内部日历库发生变化时系统分配的一个整数。

如你所知,信息提供者会不断修改日历:向其中添加新的即将发生的事件,并修正已发布的指标和预测。因此,跟踪任何编辑非常重要,编辑的发生使得能够检测到周期性增加的变化编号。

在 MQL5 中无法获取带有特定标识符的编辑时间及其本质。如果有必要,MQL 程序应该自己实现对日历状态的定期查询和记录分析。

一组 MQL5 函数允许获取有关国家、事件类型和特定日历条目的信息,以及它们的变化。我们将在以下章节中讨论这个问题。

注意! 首次访问日历时(如果之前未打开终端工具栏中的“日历”选项卡),内部日历数据库与服务器同步可能需要几秒钟时间。

获取可用国家的列表和描述

你可以使用 CalendarCountries 函数获取财经日历中会播报事件的完整国家列表。

cpp
int CalendarCountries(MqlCalendarCountry &countries[])

该函数会用 MqlCalendarCountry 结构体填充通过引用传递的 countries 数组。这个数组可以是动态数组,也可以是有足够大小的固定数组。

若操作成功,函数会返回从服务器获取到的国家描述的数量;若出错,则返回 0。在 _LastError 中可能出现的错误代码里,特别要注意 5401(ERR_CALENDAR_TIMEOUT,请求超时)或者 5400(ERR_CALENDAR_MORE_DATA,若固定数组大小不足以获取所有国家的描述)。在后面这种情况下,系统只会复制能装下的部分。

下面我们来编写一个简单的脚本 CalendarCountries.mq5,它会获取完整的国家列表并将其记录下来。

cpp
void OnStart()
{
   MqlCalendarCountry countries[];
   PRTF(CalendarCountries(countries));
   ArrayPrint(countries);
}

以下是一个示例结果:

plaintext
CalendarCountries(countries)=23 / ok
     [id]           [name] [code] [currency] [currency_symbol]       [url_name] [reserved]
[ 0]  554 "New Zealand"    "NZ"   "NZD"      "$"               "new-zealand"           ...
[ 1]  999 "European Union" "EU"   "EUR"      "€"               "european-union"        ...
[ 2]  392 "Japan"          "JP"   "JPY"      "¥"               "japan"                 ...
[ 3]  124 "Canada"         "CA"   "CAD"      "$"               "canada"                ...
[ 4]   36 "Australia"      "AU"   "AUD"      "$"               "australia"             ...
[ 5]  156 "China"          "CN"   "CNY"      "¥"               "china"                 ...
[ 6]  380 "Italy"          "IT"   "EUR"      "€"               "italy"                 ...
[ 7]  702 "Singapore"      "SG"   "SGD"      "R$"              "singapore"             ...
[ 8]  276 "Germany"        "DE"   "EUR"      "€"               "germany"               ...
[ 9]  250 "France"         "FR"   "EUR"      "€"               "france"                ...
[10]   76 "Brazil"         "BR"   "BRL"      "R$"              "brazil"                ...
[11]  484 "Mexico"         "MX"   "MXN"      "Mex$"            "mexico"                ...
[12]  710 "South Africa"   "ZA"   "ZAR"      "R"               "south-africa"          ...
[13]  344 "Hong Kong"      "HK"   "HKD"      "HK$"             "hong-kong"             ...
[14]  356 "India"          "IN"   "INR"      "₹"               "india"                 ...
[15]  578 "Norway"         "NO"   "NOK"      "Kr"              "norway"                ...
[16]    0 "Worldwide"      "WW"   "ALL"      ""                "worldwide"             ...
[17]  840 "United States"  "US"   "USD"      "$"               "united-states"         ...
[18]  826 "United Kingdom" "GB"   "GBP"      "£"               "united-kingdom"        ...
[19]  756 "Switzerland"    "CH"   "CHF"      "₣"               "switzerland"           ...
[20]  410 "South Korea"    "KR"   "KRW"      "₩"               "south-korea"           ...
[21]  724 "Spain"          "ES"   "EUR"      "€"               "spain"                 ...
[22]  752 "Sweden"         "SE"   "SEK"      "Kr"              "sweden"                ...

需要着重注意的是,标识符 0(代码为 "WW",伪货币为 "ALL")对应的是全球性事件(涉及多个国家,例如 G7、G20 会议),并且货币 "EUR" 与财经日历中可用的多个欧盟国家相关联(可以看到,并非整个欧元区的国家都有展示)。此外,欧盟本身有一个通用标识符 999。

如果你对某个特定国家感兴趣,可以依据 ISO 3166 - 1 标准通过数字代码来查看其是否可用。特别是在上面的日志中,这些代码显示在第一列(id 字段)。

要通过 id 参数中指定的 ID 获取某个国家的描述,你可以使用 CalendarCountryById 函数。

cpp
bool CalendarCountryById(const long id, MqlCalendarCountry &country)

如果操作成功,函数会返回 true 并填充 country 结构体的各个字段。

如果未找到该国家,函数会返回 false,并且在 _LastError 中会得到错误代码 5402(ERR_CALENDAR_NO_DATA)。

关于该函数的使用示例,请参考“按国家或货币获取事件记录”部分。

按国家和货币查询事件类型

每个国家的经济事件和节假日日历都有其自身特点。MQL 程序可以查询特定国家内的事件类型,以及与特定货币相关的事件类型。当几个国家使用同一种货币时,查询与货币相关的事件类型就显得很重要,例如,大多数欧盟成员国就是这种情况。

cpp
int CalendarEventByCountry(const string country, MqlCalendarEvent &events[])

CalendarEventByCountry 函数会用日历中针对由两位字母国家代码(根据 ISO 3166-1 alpha-2 标准)指定的国家的所有事件类型描述,填充通过引用传递的 MqlCalendarEvent 结构数组。我们在上一节的日志中看到过这样的代码示例:EU 代表欧盟,US 代表美国,DE 代表德国,CN 代表中国,等等。

接收数组可以是动态数组,也可以是大小足够的固定数组。

该函数返回接收到的描述数量,如果出错则返回 0。特别是,如果固定数组无法容纳所有事件,该函数会用可用数据中能容纳的部分填充数组,并设置 _LastError 代码,其值等于 CALENDAR_MORE_DATA(5400)。也可能出现内存分配错误(4004,ERR_NOT_ENOUGH_MEMORY)或来自服务器的日历请求超时(5401,ERR_CALENDAR_TIMEOUT)。

如果具有给定代码的国家不存在,将会出现 INTERNAL_ERROR(4001)。

通过指定 NULL 或空字符串 "" 来代替 country,你可以获取所有国家的完整事件列表。

我们使用简单的脚本 CalendarEventKindsByCountry.mq5 来测试该函数的性能。它只有一个输入参数,即我们感兴趣的国家的代码。

cpp
input string CountryCode = "HK";

接下来,通过调用 CalendarEventByCountry 来请求事件类型,如果成功,会将得到的数组记录到日志中。

cpp
void OnStart()
{
   MqlCalendarEvent events[];
   if(PRTF(CalendarEventByCountry(CountryCode, events)))
   {
      Print("Event kinds for country: ", CountryCode);
      ArrayPrint(events);
   }
}

以下是一个结果示例(由于行较长,为了在书中发布,人为地将其分为 2 个块:第一个块包含 MqlCalendarEvent 结构的数字字段,第二个块包含字符串字段)。

CalendarEventByCountry(CountryCode,events)=26 / ok

Event kinds for country: HK

          [id] [type] [sector] [frequency] [time_mode] [country_id] [unit] [importance] [multiplier] [digits] »

[ 0] 344010001      1        5           2           0          344      6            1            3        1 »

[ 1] 344010002      1        5           2           0          344      1            1            0        1 »

[ 2] 344020001      1        4           2           0          344      1            1            0        1 »

[ 3] 344020002      1        2           3           0          344      1            3            0        1 »

[ 4] 344020003      1        2           3           0          344      1            2            0        1 »

[ 5] 344020004      1        6           2           0          344      1            1            0        1 »

[ 6] 344020005      1        6           2           0          344      1            1            0        1 »

[ 7] 344020006      1        6           2           0          344      2            2            3        3 »

[ 8] 344020007      1        9           2           0          344      1            1            0        1 »

[ 9] 344020008      1        3           2           0          344      1            2            0        1 »

[10] 344030001      2       12           0           1          344      0            0            0        0 »

[11] 344030002      2       12           0           1          344      0            0            0        0 »

[12] 344030003      2       12           0           1          344      0            0            0        0 »

[13] 344030004      2       12           0           1          344      0            0            0        0 »

[14] 344030005      2       12           0           1          344      0            0            0        0 »

[15] 344030006      2       12           0           1          344      0            0            0        0 »

[16] 344030007      2       12           0           1          344      0            0            0        0 »

[17] 344030008      2       12           0           1          344      0            0            0        0 »

[18] 344030009      2       12           0           1          344      0            0            0        0 »

[19] 344030010      2       12           0           1          344      0            0            0        0 »

[20] 344030011      2       12           0           1          344      0            0            0        0 »

[21] 344030012      2       12           0           1          344      0            0            0        0 »

[22] 344030013      2       12           0           1          344      0            0            0        0 »

[23] 344030014      2       12           0           1          344      0            0            0        0 »

[24] 344030015      2       12           0           1          344      0            0            0        0 »

[25] 344500001      1        8           2           0          344      0            1            0        1 »

日志的后续部分(右侧片段)。

    »                      [source_url]                        [event_code]                                  [name]

[ 0]» "https://www.hkma.gov.hk/eng/"    "foreign-exchange-reserves"         "Foreign Exchange Reserves"            

[ 1]» "https://www.hkma.gov.hk/eng/"    "hkma-m3-money-supply-yy"           "HKMA M3 Money Supply y/y"             

[ 2]» "https://www.censtatd.gov.hk/en/" "cpi-yy"                            "CPI y/y"                              

[ 3]» "https://www.censtatd.gov.hk/en/" "gdp-qq"                            "GDP q/q"                              

[ 4]» "https://www.censtatd.gov.hk/en/" "gdp-yy"                            "GDP y/y"                              

[ 5]» "https://www.censtatd.gov.hk/en/" "exports-mm"                        "Exports y/y"                          

[ 6]» "https://www.censtatd.gov.hk/en/" "imports-mm"                        "Imports y/y"                          

[ 7]» "https://www.censtatd.gov.hk/en/" "trade-balance"                     "Trade Balance"                        

[ 8]» "https://www.censtatd.gov.hk/en/" "retail-sales-yy"                   "Retail Sales y/y"                     

[ 9]» "https://www.censtatd.gov.hk/en/" "unemployment-rate-3-months"        "Unemployment Rate 3-Months"           

[10]» "https://publicholidays.hk/"      "new-years-day"                     "New Year's Day"                       

[11]» "https://publicholidays.hk/"      "lunar-new-year"                    "Lunar New Year"                       

[12]» "https://publicholidays.hk/"      "ching-ming-festival"               "Ching Ming Festival"                  

[13]» "https://publicholidays.hk/"      "good-friday"                       "Good Friday"                          

[14]» "https://publicholidays.hk/"      "easter-monday"                     "Easter Monday"                        

[15]» "https://publicholidays.hk/"      "birthday-of-buddha"                "The Birthday of the Buddha"           

[16]» "https://publicholidays.hk/"      "labor-day"                         "Labor Day"                            

[17]» "https://publicholidays.hk/"      "tuen-ng-festival"                  "Tuen Ng Festival"                     

[18]» "https://publicholidays.hk/"      "hksar-establishment-day"           "HKSAR Establishment Day"              

[19]» "https://publicholidays.hk/"      "day-following-mid-autumn-festival" "The Day Following Mid-Autumn Festival"

[20]» "https://publicholidays.hk/"      "national-day"                      "National Day"                         

[21]» "https://publicholidays.hk/"      "chung-yeung-festival"              "Chung Yeung Festival"                 

[22]» "https://publicholidays.hk/"      "christmas-day"                     "Christmas Day"                        

[23]» "https://publicholidays.hk/"      "first-weekday-after-christmas-day" "The First Weekday After Christmas Day"

[24]» "https://publicholidays.hk/"      "day-following-good-friday"         "The Day Following Good Friday"        

[25]» "https://www.markiteconomics.com" "nikkei-pmi"                        "S&P Global PMI"
cpp
int CalendarEventByCurrency(const string currency, MqlCalendarEvent &events[])

CalendarEventByCurrency 函数会用日历中与指定货币相关的所有事件类型描述,填充传递的 events 数组。所有外汇交易员都知道货币的三字母标识。

如果指定了无效的货币代码,该函数将返回 0(无错误)并返回一个空数组。

通过指定 NULL 或空字符串 "" 来代替 currency,你可以获取日历事件的完整列表。

我们使用脚本 CalendarEventKindsByCurrency.mq5 来测试该函数。输入参数指定货币代码。

cpp
input string Currency = "CNY";

OnStart 处理函数中,我们请求事件并将它们输出到日志中。

cpp
void OnStart()
{
   MqlCalendarEvent events[];
   if(PRTF(CalendarEventByCurrency(Currency, events)))
   {
      Print("Event kinds for currency: ", Currency);
      ArrayPrint(events);
   }
}

以下是一个结果示例(带有缩写)。

CalendarEventByCurrency(Currency,events)=40 / ok

Event kinds for currency: CNY

          [id] [type] [sector] [frequency] [time_mode] [country_id] [unit] [importance] [multiplier] [digits] »

[ 0] 156010001      1        4           2           0          156      1            2            0        1 »

[ 1] 156010002      1        4           2           0          156      1            1            0        1 »

[ 2] 156010003      1        4           2           0          156      1            1            0        1 »

[ 3] 156010004      1        2           3           0          156      1            3            0        1 »

[ 4] 156010005      1        2           3           0          156      1            2            0        1 »

[ 5] 156010006      1        9           2           0          156      1            2            0        1 »

[ 6] 156010007      1        8           2           0          156      1            2            0        1 »

[ 7] 156010008      1        8           2           0          156      0            3            0        1 »

[ 8] 156010009      1        8           2           0          156      0            3            0        1 »

[ 9] 156010010      1        8           2           0          156      1            2            0        1 »

[10] 156010011      0        5           0           0          156      0            2            0        0 »

[11] 156010012      1        3           2           0          156      1            2            0        1 »

[12] 156010013      1        8           2           0          156      1            1            0        1 »

[13] 156010014      1        8           2           0          156      1            1            0        1 »

[14] 156010015      1        8           2           0          156      0            3            0        1 »

[15] 156010016      1        8           2           0          156      1            2            0        1 »

[16] 156010017      1        9           2           0          156      1            2            0        1 »

[17] 156010018      1        2           3           0          156      1            2            0        1 »

[18] 156020001      1        6           2           3          156      6            2            3        2 »

[19] 156020002      1        6           2           3          156      1            1            0        1 »

[20] 156020003      1        6           2           3          156      1            1            0        1 »

[21] 156020004      1        6           2           3          156      2            2            3        2 »

[22] 156020005      1        6           2           3          156      1            1            0        1 »

[23] 156020006 1 6 2 3 156 1 1 0 1 »

...

Right fragment.

»                        [source_url]                                 [event_code]                                       [name]

[ 0]» "http://www.stats.gov.cn/english/" "cpi-mm" "CPI m/m"

[ 1]» "http://www.stats.gov.cn/english/" "cpi-yy" "CPI y/y"

[ 2]» "http://www.stats.gov.cn/english/" "ppi-yy" "PPI y/y"

[ 3]» "http://www.stats.gov.cn/english/" "gdp-qq" "GDP q/q"

[ 4]» "http://www.stats.gov.cn/english/" "gdp-yy" "GDP y/y"

[ 5]» "http://www.stats.gov.cn/english/" "retail-sales-yy" "Retail Sales y/y"

[ 6]» "http://www.stats.gov.cn/english/" "industrial-production-yy" "Industrial Production y/y"

[ 7]» "http://www.stats.gov.cn/english/" "manufacturing-pmi" "Manufacturing PMI"

[ 8]» "http://www.stats.gov.cn/english/" "non-manufacturing-pmi" "Non-Manufacturing PMI"

[ 9]» "http://www.stats.gov.cn/english/" "fixed-asset-investment-yy" "Fixed Asset Investment y/y"

[10]» "http://www.stats.gov.cn/english/" "nbs-press-conference-on-economic-situation" "NBS Press Conference on Economic Situation"

[11]» "http://www.stats.gov.cn/english/" "unemployment-rate" "Unemployment Rate"

[12]» "http://www.stats.gov.cn/english/" "industrial-profit-yy" "Industrial Profit y/y"

[13]» "http://www.stats.gov.cn/english/" "industrial-profit-ytd-yy" "Industrial Profit YTD y/y"

[14]» "http://www.stats.gov.cn/english/" "composite-pmi" "Composite PMI"

[15]» "http://www.stats.gov.cn/english/" "industrial-production-ytd-yy" "Industrial Production YTD y/y"

[16]» "http://www.stats.gov.cn/english/" "retail-sales-ytd-yy" "Retail Sales YTD y/y"

[17]» "http://www.stats.gov.cn/english/" "gdp-ytd-yy" "GDP YTD y/y"

[18]» "http://english.customs.gov.cn/" "trade-balance-usd" "Trade Balance USD"

[19]» "http://english.customs.gov.cn/" "imports-usd-yy" "Imports USD y/y"

[20]» "http://english.customs.gov.cn/" "exports-usd-yy" "Exports USD y/y"

[21]» "http://english.customs.gov.cn/" "trade-balance" "Trade Balance"

[22]» "http://english.customs.gov.cn/" "imports-yy" "Imports y/y"

[23]» "http://english.customs.gov.cn/" "exports-yy" "Exports y/y"

...


细心的读者会注意到,事件类型标识符包含国家代码、新闻来源编号以及在该来源内的序列号(编号从 1 开始)。所以,事件类型标识符的一般格式是:`CCCSSNNNN`,其中 `CCC` 是国家代码,`SS` 是来源,`NNNN` 是编号。例如,`156020001` 是中国的第二个来源的第一条新闻,`344030010` 是中国香港的第三个来源的第十条新闻。唯一的例外是全球新闻,对于全球新闻,“国家” 代码不是 `000` 而是 `1000`。

按 ID 获取事件描述

通常情况下,实际的 MQL 程序会请求当前或即将发生的日历事件,并按时间范围、国家、货币或其他标准进行筛选。我们尚未讨论的用于此目的的 API 函数会返回 MqlCalendarValue 结构,该结构中仅存储事件标识符,而不是事件描述。因此,如果你需要提取完整的信息,CalendarEventById 函数可能会很有用。

bool CalendarEventById(ulong id, MqlCalendarEvent &event)

CalendarEventById 函数通过事件的 ID 获取该事件的描述。该函数会返回一个表示成功或错误的指示信息。

下一节将给出使用此函数的一个示例。

按国家或货币获取事件记录

在日历中针对给定的日期范围查询各种特定事件,并按国家或货币进行筛选。

c
int CalendarValueHistory(MqlCalendarValue &values[], datetime from, datetime to = 0,
  const string country = NULL, const string currency = NULL)

CalendarValueHistory 函数会将时间范围在 fromto 之间的日历条目填充到通过引用传递的 values 数组中。这两个参数都可以包含日期和时间。from 值包含在时间间隔内,但 to 值不包含。换句话说,该函数选择 MqlCalendarValue 结构体类型的日历条目,其 time 属性满足以下复合条件:from <= time < to

必须指定开始时间 from,而结束时间 to 是可选的:如果省略 to 或将其设置为 0,则所有未来事件都会被复制到数组中。

除了 to 为 0 的情况,to 的时间应该大于 from。当 fromto 都为 0 时,是用于查询所有可用事件(包括过去和未来事件)的特殊组合。

如果接收数组是动态数组,将自动为其分配内存。如果数组是固定大小的,复制的条目数量将不会超过数组的大小。

countrycurrency 参数允许按国家或货币对记录进行额外的筛选。country 参数接受两个字母的 ISO 3166-1 alpha-2 国家代码(例如,"DE"、"FR"、"EU"),currency 参数接受三个字母的货币名称(例如,"EUR"、"CNY")。

任何参数的默认值 NULL 或空字符串 "" 都等同于不存在相应的筛选条件。

如果同时指定了两个筛选条件,则仅选择那些同时满足国家和货币这两个条件的事件值。如果日历中包含有多种货币的国家,并且每种货币也在多个国家流通,这种筛选方式就会很有用。目前日历中没有这样的事件。要获取欧元区国家的事件,只需指定特定国家的代码或 "EU",并假设货币为 "EUR" 即可。

该函数返回复制的元素数量,并可能设置错误代码。特别是,如果超过了从服务器请求的超时时间,_LastError 中会得到错误代码 5401(ERR_CALENDAR_TIMEOUT)。如果固定数组无法容纳所有记录,代码将等于 5400(ERR_CALENDAR_MORE_DATA),但数组仍会被填充。在为动态数组分配内存时,有可能出现错误 4004(ERR_NOT_ENOUGH_MEMORY)。

注意! 数组中元素的顺序可能与时间顺序不同,你必须按时间对记录进行排序。

使用 CalendarValueHistory 函数,我们可以像这样查询即将发生的事件:

c
   MqlCalendarValue values[];
   if(CalendarValueHistory(values, TimeCurrent()))
   {
      ArrayPrint(values);
   }

然而,使用这段代码,我们得到的表格信息不足,其中事件名称、重要性和货币代码隐藏在 MqlCalendarValue::event_id 字段的事件 ID 后面,并且间接隐藏在 MqlCalendarEvent::country_id 字段的国家标识符后面。为了使信息输出对用户更友好,应该通过事件代码请求事件的描述,从该描述中获取国家代码,并获取其属性。让我们在示例脚本 CalendarForDates.mq5 中展示这一点。

在输入参数中,我们将提供输入国家代码和货币以进行筛选的功能。默认情况下,查询欧盟的事件。

c
input string CountryCode = "EU";
input string Currency = "";

事件的日期范围将自动计算为过去和未来的一段时间。这段“时间”也将由用户从三个选项中选择:一天、一周或一个月。

c
#define DAY_LONG   60 * 60 * 24
#define WEEK_LONG  DAY_LONG * 7
#define MONTH_LONG DAY_LONG * 30
#define YEAR_LONG  MONTH_LONG * 12
   
enum ENUM_CALENDAR_SCOPE
{
   SCOPE_DAY = DAY_LONG,
   SCOPE_WEEK = WEEK_LONG,
   SCOPE_MONTH = MONTH_LONG,
   SCOPE_YEAR = YEAR_LONG,
};
   
input ENUM_CALENDAR_SCOPE Scope = SCOPE_DAY;

让我们定义自己的结构体 MqlCalendarRecord,它派生自 MqlCalendarValue,并向其中添加字段,以便通过相关结构体的链接(标识符)填充属性,从而方便展示。

c
struct MqlCalendarRecord: public MqlCalendarValue
{
   static const string importances[];
   
   string importance;
   string name;
   string currency;
   string code;
   double actual, previous, revised, forecast;
   ...
};
   
static const string MqlCalendarRecord::importances[] = {"None", "Low", "Medium", "High"};

在添加的字段中,有表示重要性的字符串(importances 静态数组中的一个值)、事件名称、国家和货币,以及四个 double 格式的值。实际上,这是为了在打印时进行可视化展示而对信息的重复。稍后我们将为日历准备一个更高级的“包装器”。

为了填充对象,我们需要一个参数化构造函数,它接受原始的 MqlCalendarValue 结构体。在通过 = 运算符将所有继承的字段隐式复制到新对象后,我们调用专门准备的 extend 方法。

c
   MqlCalendarRecord() { }
   
   MqlCalendarRecord(const MqlCalendarValue &value)
   {
      this = value;
      extend();
   }

extend 方法中,我们通过事件标识符获取事件的描述。然后,根据事件描述中的国家标识符,获取包含国家属性的结构体。之后,我们可以从接收到的 MqlCalendarEventMqlCalendarCountry 结构体中填充添加字段的前半部分。

c
   void extend()
   {
      MqlCalendarEvent event;
      CalendarEventById(event_id, event);
      
      MqlCalendarCountry country;
      CalendarCountryById(event.country_id, country);
      
      importance = importances[event.importance];
      name = event.name;
      currency = country.currency;
      code = country.code;
      
      MqlCalendarValue value = this;
      
      actual = value.GetActualValue();
      previous = value.GetPreviousValue();
      revised = value.GetRevisedValue();
      forecast = value.GetForecastValue();
   }

接下来,我们调用内置的 Get 方法来填充四个 double 类型的字段,这些字段用于存储金融指标。

现在我们可以在主 OnStart 处理程序中使用新的结构体。

c
void OnStart()
{
   MqlCalendarValue values[];
   MqlCalendarRecord records[];
   datetime from = TimeCurrent() - Scope;
   datetime to = TimeCurrent() + Scope;
   if(PRTF(CalendarValueHistory(values, from, to, CountryCode, Currency)))
   {
      for(int i = 0; i < ArraySize(values); ++i)
      {
         PUSH(records, MqlCalendarRecord(values[i]));
      }
      Print("Near past and future calendar records (extended): ");
      ArrayPrint(records);
   }
}

在这里,通过调用 CalendarValueHistory,根据输入参数中设置的当前条件填充标准的 MqlCalendarValue 结构体数组。接下来,将所有元素转移到 MqlCalendarRecord 数组中。此外,在创建对象时,它们会用额外的信息进行扩展。最后,将事件数组输出到日志中。

日志记录相当长。首先,让我们展示左边一半的内容,如果我们打印标准的 MqlCalendarValue 结构体数组,看到的就是这些内容。

CalendarValueHistory(values,from,to,CountryCode,Currency)=6 / ok

Near past and future calendar records (extended): 

      [id] [event_id]              [time]            [period] [revision] [actual_value]         [prev_value] [revised_prev_value]     [forecast_value] [impact_type]

[0] 162723  999020003 2022.06.23 03:00:00 1970.01.01 00:00:00    0 -9223372036854775808 -9223372036854775808 -9223372036854775808 -9223372036854775808             0

[1] 162724  999020003 2022.06.24 03:00:00 1970.01.01 00:00:00    0 -9223372036854775808 -9223372036854775808 -9223372036854775808 -9223372036854775808             0

[2] 168518  999010034 2022.06.24 11:00:00 1970.01.01 00:00:00    0 -9223372036854775808 -9223372036854775808 -9223372036854775808 -9223372036854775808             0

[3] 168515  999010031 2022.06.24 13:10:00 1970.01.01 00:00:00    0 -9223372036854775808 -9223372036854775808 -9223372036854775808 -9223372036854775808             0

[4] 168509  999010014 2022.06.24 14:30:00 1970.01.01 00:00:00    0 -9223372036854775808 -9223372036854775808 -9223372036854775808 -9223372036854775808             0

[5] 161014  999520001 2022.06.24 22:30:00 2022.06.21 00:00:00    0 -9223372036854775808             -6000000 -9223372036854775808 -9223372036854775808             0

这是带有名称、重要性和含义“解码”的后半部分内容。

CalendarValueHistory(values,from,to,CountryCode,Currency)=6 / ok

Near past and future calendar records (extended):

     [importance]                                                [name] [currency] [code] [actual] [previous] [revised] [forecast]

[0]  "High"       "EU Leaders Summit"                                   "EUR"      "EU"        nan        nan       nan        nan

[1]  "High"       "EU Leaders Summit"                                   "EUR"      "EU"        nan        nan       nan        nan

[2]  "Medium"     "ECB Supervisory Board Member McCaul Speech"          "EUR"      "EU"        nan        nan       nan        nan

[3]  "Medium"     "ECB Supervisory Board Member Fernandez-Bollo Speech" "EUR"      "EU"        nan        nan       nan        nan

[4]  "Medium"     "ECB Vice President de Guindos Speech"                "EUR"      "EU"        nan        nan       nan        nan

[5]  "Low"        "CFTC EUR Non-Commercial Net Positions"               "EUR"      "EU"        nan   -6.00000       nan        nan

获取特定类型的事件记录

如果有必要,MQL 程序能够请求特定类型的事件:要做到这一点,只需提前知道事件标识符即可,例如,使用“按国家和货币查询事件类型”部分中介绍的 CalendarEventByCountryCalendarEventByCurrency 函数。

cpp
int CalendarValueHistoryByEvent(ulong id, MqlCalendarValue &values[], datetime from, datetime to = 0)

CalendarValueHistoryByEvent 函数会用由 id 标识符所指示的特定类型的事件记录来填充通过引用传递的数组。fromto 参数允许你限制搜索事件的日期范围。

如果未指定可选参数 to,那么从 from 时间开始往后直到未来的所有日历条目都将被放入数组中。要查询所有过去的事件,将 from 设置为 0。如果 fromto 参数都为 0,则会返回所有的历史事件和已安排的事件。在所有其他情况下,当 to 不等于 0 时,它必须大于 from

values 数组可以是动态数组(此时函数会根据数据量自动扩展或缩小它),也可以是固定大小的数组(此时只有能容纳的部分会被复制到数组中)。

该函数返回复制的元素数量。

作为一个示例,考虑脚本 CalendarStatsByEvent.mq5,它会计算在给定的时间范围内,对于给定的国家或货币,不同类型事件的统计信息(出现频率)。

分析条件在输入变量中指定。

cpp
input string CountryOrCurrency = "EU";
input ENUM_CALENDAR_SCOPE Scope = SCOPE_YEAR;

根据 CountryOrCurrency 字符串的长度,它会被解释为国家代码(2 个字符)或货币代码(3 个字符)。

为了收集统计信息,我们将声明一个结构体;它的字段将存储事件类型的标识符和名称、其重要性以及此类事件的计数器。

cpp
struct CalendarEventStats
{
   static const string importances[];
   ulong id;
   string name;
   string importance;
   int count;
};
   
static const string CalendarEventStats::importances[] = {"None", "Low", "Medium", "High"};

OnStart 函数中,我们首先使用 CalendarEventByCountryCalendarEventByCurrency 函数请求指定历史深度以及未来的所有各类事件,然后,在遍历 events 数组中接收到的事件描述的循环中,针对每个事件 ID 调用 CalendarValueHistoryByEvent 函数。在这个应用中,我们对 values 数组的内容不感兴趣,因为我们只需要知道它们的数量。

cpp
void OnStart()
{
   MqlCalendarEvent events[];
   MqlCalendarValue values[];
   CalendarEventStats stats[];
   
   const datetime from = TimeCurrent() - Scope;
   const datetime to = TimeCurrent() + Scope;
   
   if(StringLen(CountryOrCurrency) == 2)
   {
      PRTF(CalendarEventByCountry(CountryOrCurrency, events));
   }
   else
   {
      PRTF(CalendarEventByCurrency(CountryOrCurrency, events));
   }
   
   for(int i = 0; i < ArraySize(events); ++i)
   {
      if(CalendarValueHistoryByEvent(events[i].id, values, from, to))
      {
         CalendarEventStats event = {events[i].id, events[i].name,
            CalendarEventStats::importances[events[i].importance], ArraySize(values)};
         PUSH(stats, event);
      }
   }
   
   SORT_STRUCT(CalendarEventStats, stats, count);
   ArrayReverse(stats);
   ArrayPrint(stats);
}

函数调用成功后,我们填充 CalendarEventStats 结构体并将其添加到结构体数组 stats 中。接下来,我们按照我们已经知道的方式对结构体进行排序(SORT_STRUCT 宏在“数组中的比较、排序和搜索”部分有描述)。

使用默认设置运行该脚本会在日志中生成类似如下的内容(已缩写)。

plaintext
CalendarEventByCountry(CountryOrCurrency,events)=82 / ok
          [id]                                                [name] [importance] [count]
[ 0] 999520001 "CFTC EUR Non-Commercial Net Positions"               "Low"             79
[ 1] 999010029 "ECB President Lagarde Speech"                        "High"            69
[ 2] 999010035 "ECB Executive Board Member Elderson Speech"          "Medium"          37
[ 3] 999030027 "Core CPI"                                            "Low"             36
[ 4] 999030026 "CPI"                                                 "Low"             36
[ 5] 999030025 "CPI excl. Energy and Unprocessed Food y/y"           "Low"             36
[ 6] 999030024 "CPI excl. Energy and Unprocessed Food m/m"           "Low"             36
[ 7] 999030010 "Core CPI m/m"                                        "Medium"          36
[ 8] 999030013 "CPI y/y"                                             "Low"             36
[ 9] 999030012 "Core CPI y/y"                                        "Low"             36
[10] 999040006 "Consumer Confidence Index"                           "Low"             36
[11] 999030011 "CPI m/m"                                             "Medium"          36
...
[65] 999010008 "ECB Economic Bulletin"                               "Medium"           8
[66] 999030023 "Wage Costs y/y"                                      "Medium"           6
[67] 999030009 "Labour Cost Index"                                   "Low"              6
[68] 999010025 "ECB Bank Lending Survey"                             "Low"              6
[69] 999010030 "ECB Supervisory Board Member af Jochnick Speech"     "Medium"           4
[70] 999010022 "ECB Supervisory Board Member Hakkarainen Speech"     "Medium"           3
[71] 999010028 "ECB Financial Stability Review"                      "Medium"           3
[72] 999010009 "ECB Targeted LTRO"                                   "Medium"           2
[73] 999010036 "ECB Supervisory Board Member Tuominen Speech"        "Medium"           1

请注意,总共接收到了 82 种类型的事件,然而,在统计数组中,我们只有 74 种。这是因为如果在指定的日期范围内没有任何类型的事件,CalendarValueHistoryByEvent 函数会返回 false(失败),并且 _LastError 中的错误代码为 0。在上述测试中,有 8 个这样的条目,从理论上讲它们是存在的,但在这一年中从未出现过。

按 ID 读取事件记录

了解了近期的事件日程安排后,交易员可以相应地调整他们的交易机器人。日历 API 中没有用于自动跟踪新闻发布的函数或事件(这里的 “事件” 是指类似于 OnTick 那样用于处理新金融信息的函数,比如 OnCalendar)。算法必须以任何选定的频率自行完成这项任务。特别是,你可以使用前面讨论过的函数之一(例如 CalendarValueHistoryByEventCalendarValueHistory)来找出所需事件的标识符,然后调用 CalendarValueById 来获取 MqlCalendarValue 结构中字段的当前状态。

cpp
bool CalendarValueById(ulong id, MqlCalendarValue &value)

该函数用关于特定事件的当前信息填充通过引用传递的结构。

该函数的结果表示成功(true)或错误(false)。

我们创建一个简单的无缓冲区指标 CalendarRecordById.mq5,它将在未来找到最近的 “金融指标” 类型(即数值指标)的事件,并在定时器上轮询其状态。当新闻发布时,数据将会改变(指标的 “实际” 值将变得已知),并且该指标将显示一个警报。

轮询日历的频率在输入变量中设置。

cpp
input uint TimerSeconds = 5;

我们在 OnInit 中启动定时器。

cpp
void OnInit()
{
   EventSetTimer(TimerSeconds);
}

为了方便地将事件描述输出到日志中,我们使用 MqlCalendarRecord 结构,我们在脚本 CalendarForDates.mq5 的示例中已经了解过这个结构。

为了存储新闻信息的初始状态,我们描述 track 结构。

cpp
MqlCalendarValue track;

当该结构为空时(并且 id 字段中为 0),程序必须查询即将发生的事件,并在其中找到最接近的 CALENDAR_TYPE_INDICATOR 类型的事件,并且该事件的当前值尚不可知。

cpp
void OnTimer()
{
   if(!track.id)
   {
      MqlCalendarValue values[];
      if(PRTF(CalendarValueHistory(values, TimeCurrent(), TimeCurrent() + DAY_LONG * 3)))
      {
         for(int i = 0; i < ArraySize(values); ++i)
         {
            MqlCalendarEvent event;
            CalendarEventById(values[i].event_id, event);
            if(event.type == CALENDAR_TYPE_INDICATOR &&!values[i].HasActualValue())
            {
               track = values[i];
               PrintFormat("Started monitoring %lld", track.id);
               StructPrint(MqlCalendarRecord(track), ARRAYPRINT_HEADER);
               return;
            }
         }
      }
   }
   ...

找到的事件被复制到 track 并输出到日志中。在那之后,每次调用 OnTimer 都归结为将关于该事件的更新信息获取到 update 结构中,该结构使用 track.id 标识符传递给 CalendarValueById。接下来,使用辅助函数 StructCompare(基于 StructToCharArrayArrayCompare,请参阅完整的源代码)比较原始结构和新结构。任何差异都会导致打印出新的状态(预测可能已经改变),并且如果出现当前值,定时器将停止。要开始等待下一条新闻,需要重新初始化这个指标:这个指标是用于演示的,为了根据新闻列表控制情况,我们稍后将开发一个更实用的过滤器类。

cpp
   else
   {
      MqlCalendarValue update;
      if(CalendarValueById(track.id, update))
      {
         if(fabs(StructCompare(track, update)) == 1)
         {
            Alert(StringFormat("News %lld changed", track.id));
            PrintFormat("New state of %lld", track.id);
            StructPrint(MqlCalendarRecord(update), ARRAYPRINT_HEADER);
            if(update.HasActualValue())
            {
               Print("Timer stopped");
               EventKillTimer();
            }
            else
            {
               track = update;
            }
         }
      }
      
      if(TimeCurrent() <= track.time)
      {
         Comment("Forthcoming event time: ", track.time,
            ", remaining: ", Timing::stringify((uint)(track.time - TimeCurrent())));
      }
      else
      {
         Comment("Forthcoming event time: ", track.time,
            ", late for: ", Timing::stringify((uint)(TimeCurrent() - track.time)));
      }
   }
}

在等待事件发生时,该指标会显示一条注释,其中包含新闻发布的预期时间以及距离发布还有多长时间(或者已经延迟了多长时间)。

对即将发布的新闻的预期或过期情况的注释

关于等待下一条新闻或新闻延迟的注释

需要注意的是,新闻可能会比预定日期早一点或晚一点发布。在对历史数据进行新闻策略测试时,这会带来一些问题,因为终端中以及通过 MQL5 API 更新日历条目的时间并未提供。我们将在下一节中尝试部分解决这个问题。

以下是该指标产生的带有间隔的日志输出片段:

CalendarValueHistory(values,TimeCurrent(),TimeCurrent()+(60*60*24)*3)=186 / ok

Started monitoring 156045

  [id] [event_id]              [time]            [period] [revision] »

156045  840020013 2022.06.27 15:30:00 2022.05.01 00:00:00          0 »

»       [actual_value] [prev_value] [revised_prev_value] [forecast_value] [impact_type] »

» -9223372036854775808       400000 -9223372036854775808                0             0 »

» [importance]                     [name] [currency] [code] [actual] [previous] [revised] [forecast]

» "Medium"     "Durable Goods Orders m/m" "USD"      "US"        nan    0.40000       nan    0.00000

...

Alert: News 156045 changed

New state of 156045

  [id] [event_id]              [time]            [period] [revision] »

156045  840020013 2022.06.27 15:30:00 2022.05.01 00:00:00          0 »

» [actual_value] [prev_value] [revised_prev_value] [forecast_value] [impact_type] »

»         700000       400000 -9223372036854775808                0             1 »

» [importance]                     [name] [currency] [code] [actual] [previous] [revised] [forecast]

» "Medium"     "Durable Goods Orders m/m" "USD"      "US"    0.70000    0.40000       nan    0.00000

Timer stopped

更新后的新闻具有 actual_value 值。

为了在测试期间不用等待太长时间,建议在主要市场的工作时间内运行这个指标,此时新闻发布的密度较高。

CalendarValueById 函数不是唯一可以用于监控日历变化的函数,而且可能也不是最灵活的函数。在接下来的部分中,我们将研究其他几种方法。

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

正如在日历基本概念部分所提到的,平台通过一些内部方式记录所有事件的变化情况。每种状态都由一个变化标识符(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 参数既作为输入也作为输出。这就是为什么它是一个引用,并且需要指定一个变量。

如果我们向函数中输入的 change_id 等于 0,那么该函数会用当前的标识符填充这个变量,但不会填充数组。

你可以选择使用参数 countrycurrency,通过国家和货币来设置对记录的筛选条件。

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

所以使用这个函数的常见方式是循环检查日历的变化情况。

ulong change = 0;
MqlCalendarValue values[];
while(!IsStopped())
{
 // 传入我们已知的最后一个标识符,如果出现新的标识符则获取它
   if(CalendarValueLast(change, values))
   {
 // 分析新增和已更改的记录
      ArrayPrint(values);
      ... 
   }
   Sleep(1000);
}

这可以在一个循环中进行,也可以通过定时器或其他事件触发。

标识符在不断增加,但它们可能会不按顺序,也就是说,会跳过一些值。

需要注意的是,每个日历条目始终只存在于一个最新状态中:MQL5 不提供变化历史记录。通常情况下,这不是一个问题,因为每条新闻的生命周期是标准的:提前很长时间添加到数据库中,并在事件发生时补充相关数据。然而,在实际情况中,可能会出现各种偏差:修改预测值、调整时间或修改数值。通过 MQL5 API 从日历历史记录中无法确切知道记录在什么时间以及哪些内容发生了更改。因此,那些基于即时情况做出决策的交易系统将需要独立保存变化历史记录,并将其整合到智能交易系统中以便在测试器中运行。

使用 CalendarValueLast 函数,我们可以创建一个有用的服务程序 CalendarChangeSaver.mq5,它将以指定的时间间隔检查日历是否有变化,如果有变化,则将变化标识符与当前服务器时间一起保存到文件中。这将允许进一步使用文件中的信息,以便更真实地在日历历史记录上测试智能交易系统。当然,这将需要组织整个日历数据库的导出/导入,我们将逐步处理这个问题。

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

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

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

void OnStart()
{
   ulong change = 0, last = 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(handle, 0, SEEK_END);
   }
   
   Print("Requesting start ID...");
   ...

这里我们需要稍微岔开一下话题。

每次日历发生变化时,至少必须将一对 8 字节的整数写入文件中:当前时间(datetime)和新闻 ID(ulong),但可能会同时有多个记录发生变化。因此,除了日期之外,已更改记录的数量会被打包到第一个数字中。考虑到日期值适合存储在 0x7FFFFFFFF 范围内,因此高 3 个字节未被使用。正是在两个最高有效字节(左偏移 48 位)中放置了该服务在相应时间戳之后将写入的标识符数量。PACK_DATETIME_COUNTER 宏创建一个 “扩展” 日期,另外两个宏 DATETIMECOUNTER,我们在以后(由另一个程序)读取变化存档时会用到。

#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(change, values);
      if(n > 0)
      {
         string records = "[" + Description(values[0]);
         for(int i = 1; i < n; ++i)
         {
            records += "," + Description(values[i]);
         }
         records += "]";
         Print("New change ID: ", change, " ",
            TimeToString(TimeTradeServer(), TIME_DATE | TIME_SECONDS), "\n", records);
         FileWriteLong(handle, PACK_DATETIME_COUNTER(TimeTradeServer(), n));
         for(int i = 0; i < n; ++i)
         {
            FileWriteLong(handle, values[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_id, event);
   CalendarCountryById(event.country_id, country);
   return StringFormat("%lld (%s/%s @ %s)",
      value.id, country.code, event.name, TimeToString(value.time));
}

这样,日志不仅会显示标识符,还会显示国家代码、新闻标题和计划发布时间。

假设该服务应该运行相当长的时间,以便收集足够用于测试的信息(几天、几周、几个月)。不幸的是,就像订单簿一样,平台没有提供现成的订单簿或日历编辑历史记录,所以它们的收集完全取决于 MQL 程序的开发者。

让我们看看该服务的实际运行情况。在日志的下一个片段(时间范围为 2022 年 6 月 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)]

当然,这并不是对所有类型的交易策略都很重要,而只是对那些在市场中快速交易的策略重要。对于它们来说,创建的日历编辑存档可以为新闻智能交易系统提供更准确的测试。我们将在以后讨论如何将日历 “连接” 到测试器,但现在,我们将展示如何读取接收到的文件。

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

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

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

描述了 ChangeState 结构,用于存储有关个别编辑的信息。

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

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

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

class ChangeFileReader
{
   const int handle;
   ChangeState current;
   const ChangeState zero;
   
public:
   ChangeFileReader(const int h, const datetime start = 0): handle(h)
   {
      if(readState())
      {
         if(start)
         {
            ulong dummy[];
            check(start, dummy, true); // 找到开始时间之后的第一次编辑 
         }
      }
   }
   
   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 = 0; i < n; ++i)
      {
         PUSH(current.ids, FileReadLong(handle));
      }
      return _LastError == 0;
   }
   ...

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

   bool check(datetime now, ulong &records[], const bool fastforward = false)
   {
      if(current.dt > now) return false;
      
      ArrayFree(records);
      
      if(!fastforward)
      {
         ArrayCopy(records, current.ids);
         current = zero;
      }
      
      while(readState() && current.dt <= now)
      {
         if(!fastforward) ArrayInsert(records, current.ids, ArraySize(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(handle, now);
   
   // 逐步读取,在这个演示中人为地增加了时间`now`
   while(!FileIsEnding(handle))
   {
      // 在实际应用中,每次`tick`都可以调用`reader.check`
      ulong records[];
      if(reader.check(now, records))
      {
         Print(now);          // 输出时间
         ArrayPrint(records); // 已更改新闻的ID数组
      }
      now += 60; // 每次增加1分钟,可以按秒增加
   }
   
   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`方法来获取这个时间。

按事件类型跟踪事件变化

MQL5 API 允许你不仅可以按整个日历的总体情况、按国家或货币来请求最近的变化,还能在更窄的范围内,确切地说,针对特定的事件类型来进行请求。

从理论上讲,可以说内置函数提供了根据几个基本条件对事件进行筛选的功能:时间、国家、货币或事件类型。对于其他属性,例如重要性或经济领域,你需要实现自己的筛选方法,我们稍后会处理这个问题。目前,让我们先介绍 CalendarValueLastByEvent 函数。

c
int CalendarValueLastByEvent(ulong id, ulong &change_id, MqlCalendarValue &values[])

该函数会将自 change_id 以来发生的、具有 id 标识符的特定类型的事件记录填充到通过引用传递的 values 数组中。change_id 参数既是输入参数也是输出参数:调用代码在其中传入日历过去状态的标识,之后请求变化;当控制权返回时,该函数会将日历数据库当前状态的标识写入 change_id。下次调用该函数时应使用这个值。

如果在 change_id 中传入 null,则该函数不会填充数组,而只是通过 change_id 参数返回数据库的当前状态。

数组可以是动态的(此时它将自动根据数据量进行调整),也可以是固定大小的(如果其大小不足,将仅复制能容纳的数据)。

该函数的输出值等于复制到 values 数组中的元素数量。如果没有变化或者指定了 change_id = 0,该函数将返回 0。

要检查是否有错误,需分析内置的 _LastError 变量。一些可能的错误代码如下:

  • 4004 - ERR_NOT_ENOUGH_MEMORY(内存不足,无法完成请求)。
  • 5401 - ERR_CALENDAR_TIMEOUT(请求超时)。
  • 5400 - ERR_CALENDAR_MORE_DATA(固定数组的大小不足以获取所有值)。

我们不会为 CalendarValueLastByEvent 给出单独的示例。相反,让我们转向一个更复杂但有需求的任务,即根据新闻属性的任意条件查询和筛选日历条目,在这个任务中会涉及到所有的“日历” API 函数。这将是下一节的主题。

按多个条件过滤事件

从本章前面的部分我们了解到,MQL5 API 允许根据多个条件来请求财经日历事件:

  • 按国家(CalendarValueHistoryCalendarValueLast
  • 按频率(CalendarValueHistoryCalendarValueLast
  • 按事件类型 ID(CalendarValueHistoryByEventCalendarValueLastByEvent
  • 按时间范围(CalendarValueHistoryCalendarValueHistoryByEvent
  • 按自上次日历轮询以来的变化(CalendarValueLastCalendarValueLastByEvent
  • 按特定新闻的 ID(CalendarValueById

这可以总结为以下函数表(在所有 CalendarValue 函数中,这里仅缺少用于获取一个特定值的 CalendarValueById 函数):

条件时间范围上次变化国家货币事件
函数CalendarValueHistoryCalendarValueLastCalendarValueHistoryCalendarValueHistoryCalendarValueHistoryByEvent
CalendarValueLastCalendarValueLastCalendarValueLastByEvent

这样的工具包涵盖了主要的,但并非所有流行的日历分析场景。因此,在实践中,经常需要在 MQL5 中实现自定义的过滤机制,特别是包括按以下条件请求事件:

  • 多个国家
  • 多种货币
  • 多种类型的事件
  • 事件的任意属性值(重要性、经济部门、报告期、类型、是否有预测、对汇率的估计影响、事件名称中的子字符串等)

为了解决这些问题,我们创建了 CalendarFilter 类(CalendarFilter.mqh)。

由于内置 API 函数的特性,一些新闻属性比其他属性具有更高的优先级。这包括国家、货币和日期范围。它们可以在类的构造函数中指定,然后相应的属性不能在过滤条件中动态更改。

这是因为后续该过滤类将扩展新闻缓存功能,以便能够从测试器中读取数据,而构造函数的初始条件实际上定义了缓存上下文,在该上下文中可以进行进一步的过滤。例如,如果我们在创建对象时指定国家代码“EU”,那么显然通过它请求关于美国或巴西的新闻是没有意义的。日期范围也是类似的情况:在构造函数中指定它将使得无法接收该范围之外的新闻。

我们也可以在没有初始条件的情况下创建一个对象(因为所有构造函数参数都是可选的),然后它将能够在整个日历数据库(截至保存时)中缓存和过滤新闻。

此外,由于现在国家和货币几乎是唯一显示的(欧盟和欧元除外),它们通过单个参数 context 传递给构造函数:如果指定一个长度为 2 个字符的字符串,则表示国家代码(或国家组合),如果长度为 3 个字符,则表示货币代码。对于代码“EU”和“EUR”,欧元区是“EU”的一个子集(在有正式条约的国家范围内)。在特殊情况下,如果对非欧元区的欧盟国家感兴趣,也可以用“EU”上下文来描述它们。如果需要,可以使用我们稍后将介绍的方法动态地向过滤器添加关于这些国家(保加利亚列弗、匈牙利福林、丹麦克朗、冰岛克朗、波兰兹罗提、罗马尼亚列伊、克罗地亚库纳、捷克克朗、瑞典克朗)货币新闻的更窄条件。然而,由于一些特殊情况,不能保证这样的新闻会进入日历。

让我们开始研究这个类:

cpp
class CalendarFilter
{
protected:
   // 构造函数中设置的初始(可选)条件,不变量
   string context;    // 国家和货币
   datetime from, to; // 日期范围
   bool fixedDates;   // 如果在构造函数中传递了“from”/“to”,则它们不能更改
   
   // 专用选择器(国家/货币/事件类型标识符)
   string country[], currency[];
   ulong ids[];
   
   MqlCalendarValue values[]; // 过滤结果
   
   virtual void init()
   {
      fixedDates = from != 0 || to != 0;
      if(StringLen(context) == 3)
      {
         PUSH(currency, context);
      }
      else
      {
         // 即使 context 为 NULL,我们也将其用于轮询整个日历基础
         PUSH(country, context);
      }
   }
   ...
public:
   CalendarFilter(const string _context = NULL,
      const datetime _from = 0, const datetime _to = 0):
      context(_context), from(_from), to(_to)
   {
      init();
   }
   ...

为国家和货币分配了两个数组:countrycurrency。如果在创建对象时它们没有从 context 中填充,那么 MQL 程序将能够添加多个国家或货币的条件,以便对它们执行组合新闻查询。

为了存储所有其他新闻属性的条件,在 CalendarFilter 对象中描述了 selectors 数组,其第二维等于 3。我们可以说这是一种表,其中每行有 3 列。

cpp
   long selectors[][3];   // [0] - 属性, [1] - 值, [2] - 条件

在第 0 个索引处,将存储新闻属性标识符。由于这些属性分布在三个基础表(MqlCalendarCountryMqlCalendarEventMqlCalendarValue)中,它们使用广义枚举 ENUM_CALENDAR_PROPERTYCalendarDefines.mqh)的元素来描述。

cpp
enum ENUM_CALENDAR_PROPERTY
{                                      // +/- 表示支持字段过滤
   CALENDAR_PROPERTY_COUNTRY_ID,       // -ulong
   CALENDAR_PROPERTY_COUNTRY_NAME,     // -string
   CALENDAR_PROPERTY_COUNTRY_CODE,     // +string (2 个字符)
   CALENDAR_PROPERTY_COUNTRY_CURRENCY, // +string (3 个字符)
   CALENDAR_PROPERTY_COUNTRY_GLYPH,    // -string (1 个字符)
   CALENDAR_PROPERTY_COUNTRY_URL,      // -string
   
   CALENDAR_PROPERTY_EVENT_ID,         // +ulong (事件类型 ID)
   CALENDAR_PROPERTY_EVENT_TYPE,       // +ENUM_CALENDAR_EVENT_TYPE
   CALENDAR_PROPERTY_EVENT_SECTOR,     // +ENUM_CALENDAR_EVENT_SECTOR
   CALENDAR_PROPERTY_EVENT_FREQUENCY,  // +ENUM_CALENDAR_EVENT_FREQUENCY
   CALENDAR_PROPERTY_EVENT_TIMEMODE,   // +ENUM_CALENDAR_EVENT_TIMEMODE
   CALENDAR_PROPERTY_EVENT_UNIT,       // +ENUM_CALENDAR_EVENT_UNIT
   CALENDAR_PROPERTY_EVENT_IMPORTANCE, // +ENUM_CALENDAR_EVENT_IMPORTANCE
   CALENDAR_PROPERTY_EVENT_MULTIPLIER, // +ENUM_CALENDAR_EVENT_MULTIPLIER
   CALENDAR_PROPERTY_EVENT_DIGITS,     // -uint
   CALENDAR_PROPERTY_EVENT_SOURCE,     // +string ("http[s]://")
   CALENDAR_PROPERTY_EVENT_CODE,       // -string
   CALENDAR_PROPERTY_EVENT_NAME,       // +string (4+ 个字符或通配符 '*')
   
   CALENDAR_PROPERTY_RECORD_ID,        // -ulong
   CALENDAR_PROPERTY_RECORD_TIME,      // +datetime
   CALENDAR_PROPERTY_RECORD_PERIOD,    // +datetime (类似 long)
   CALENDAR_PROPERTY_RECORD_REVISION,  // +int
   CALENDAR_PROPERTY_RECORD_ACTUAL,    // +long
   CALENDAR_PROPERTY_RECORD_PREVIOUS,  // +long
   CALENDAR_PROPERTY_RECORD_REVISED,   // +long
   CALENDAR_PROPERTY_RECORD_FORECAST,  // +long
   CALENDAR_PROPERTY_RECORD_IMPACT,    // +ENUM_CALENDAR_EVENT_IMPACT
   
   CALENDAR_PROPERTY_RECORD_PREVISED,  // +非标准 (如果有则为 previous 或 revised)
   
   CALENDAR_PROPERTY_CHANGE_ID,        // -ulong (保留)
};

索引 1 将存储与新闻记录选择条件进行比较的值。例如,如果你想按经济部门设置过滤器,那么我们在 selectors[i][0] 中写入 CALENDAR_PROPERTY_EVENT_SECTOR,并在 selectors[i][1] 中写入标准枚举 ENUM_CALENDAR_EVENT_SECTOR 的一个值。

最后,最后一列(第 2 个索引下)保留用于将选择器值与新闻中的属性值进行比较的操作:所有支持的操作都总结在 IS 枚举中。

cpp
enum IS
{
   EQUAL,
   NOT_EQUAL,
   GREATER,
   LESS,
   OR_EQUAL,
   ...
};

我们在 TradeFilter.mqh 中看到过类似的方法。因此,我们不仅能够设置值的相等条件,还能设置不等式或大于/小于关系的条件。例如,很容易想象对 CALENDAR_PROPERTY_EVENT_IMPORTANCE 字段的过滤,它应该大于 CALENDAR_IMPORTANCE_LOW(这是标准 ENUM_CALENDAR_EVENT_IMPORTANCE 枚举的一个元素),这意味着选择中等和高重要性的新闻。

专门为日历定义的下一个枚举是 ENUM_CALENDAR_SCOPE。由于日历过滤通常与时间跨度相关,这里列出了最常被请求的时间跨度。

cpp
#define DAY_LONG     (60 * 60 * 24)
#define WEEK_LONG    (DAY_LONG * 7)
#define MONTH_LONG   (DAY_LONG * 30)
#define QUARTER_LONG (MONTH_LONG * 3)
#define YEAR_LONG    (MONTH_LONG * 12)
   
enum ENUM_CALENDAR_SCOPE
{
   SCOPE_DAY = DAY_LONG,         // 天
   SCOPE_WEEK = WEEK_LONG,       // 周
   SCOPE_MONTH = MONTH_LONG,     // 月
   SCOPE_QUARTER = QUARTER_LONG, // 季度
   SCOPE_YEAR = YEAR_LONG,       // 年
};

所有枚举都放在一个单独的头文件 CalendarDefines.mqh 中。

但让我们回到 CalendarFilter 类。selectors 数组的类型是 long,这适合存储几乎所有涉及类型的值:枚举、日期和时间、标识符、整数,甚至经济指标值,因为它们在日历中以长整数形式存储(为实际值的百万分之一)。然而,对于字符串属性该怎么办呢?

这个问题通过使用字符串数组 stringCache 来解决,过滤条件中提到的所有字符串都将添加到该数组中。

cpp
class CalendarFilter
{
protected:
   ...
   string stringCache[];  // “selectors”中所有行的缓存
   ...

然后,我们可以轻松地在 selectors[i][1] 中存储 stringCache 数组中元素的索引,而不是字符串值。

为了用过滤条件填充 selectors 数组,提供了几个 let 方法,特别是对于枚举类型:

cpp
class CalendarFilter
{
...
public:
   // 这里处理所有枚举类型的字段
   template<typename E>
   CalendarFilter *let(const E e, const IS c = EQUAL)
   {
      const int n = EXPAND(selectors);
      selectors[n][0] = resolve(e); // 根据类型 E,返回 ENUM_CALENDAR_PROPERTY 元素
      selectors[n][1] = e;
      selectors[n][2] = c;
      return &this;
   }
   ...

对于指标的实际值:

cpp
   // 这里处理以下字段:
   // CALENDAR_PROPERTY_RECORD_ACTUAL, CALENDAR_PROPERTY_RECORD_PREVIOUS,
   // CALENDAR_PROPERTY_RECORD_REVISED, CALENDAR_PROPERTY_RECORD_FORECAST,
   // 以及 CALENDAR_PROPERTY_RECORD_PERIOD (作为 long)
   CalendarFilter *let(const long value, const ENUM_CALENDAR_PROPERTY property, const IS c = EQUAL)
   {
      const int n = EXPAND(selectors);
      selectors[n][0] = property;
      selectors[n][1] = value;
      selectors[n][2] = c;
      return &this;
   }
   ...

对于字符串:

cpp
   // 这里可以找到所有字符串属性的条件(缩写)
   CalendarFilter *let(const string find, const IS c = EQUAL)
   {
      const int wildcard = (StringFind(find, "*") + 1) * 10;
      switch(StringLen(find) + wildcard)
      {
      case 2:
         // 如果初始上下文与国家不同,我们可以用国家补充它,
         // 否则过滤器将被忽略
         if(StringLen(context) != 2)
         {
            if(ArraySize(country) == 1 && StringLen(country[0]) == 0)
            {
               country[0] = find; // 将“所有国家”缩小到一个(可能会添加更多)
            }
            else
            {
               PUSH(country, find);
            }
         }
         break;
      case 3:
         // 只有当初始上下文中没有该货币时,我们才能设置货币过滤器
         if(StringLen(context) != 3)
         {
            PUSH(currency, find);
         }
         break;
      default:
         {
            const int n = EXPAND(selectors);
            PUSH(stringCache, find);
            if(StringFind(find, "http://") == 0 || StringFind(find, "https://") == 0)
            {
               selectors[n][0] = CALENDAR_PROPERTY_EVENT_SOURCE;
            }
            else
            {
               selectors[n][0] = CALENDAR_PROPERTY_EVENT_NAME;
            }
            selectors[n][1] = ArraySize(stringCache) - 1;
            selectors[n][2] = c;
            break;
         }
      }
      
      return &this;
   }

在字符串的方法重载中,请注意,长度为 2 或 3 个字符的字符串(如果它们没有模板星号“”,“” 是任意字符序列的替代)分别落入国家和符号数组中,所有其他字符串都被视为名称或新闻来源的片段,并且这两个字段都涉及 stringCacheselectors

该类还以特殊方式支持按事件类型(标识符)进行过滤。

cpp
protected:
   ulong ids[];           // 过滤后的事件类型
   ...
public:
   CalendarFilter *let(const ulong event)
   {
      PUSH(ids, event);
      return &this;
   }
   ...

因此,优先级过滤器(在 selectors 数组之外处理)的数量不仅包括国家、货币和日期范围,还包括事件类型标识符。这样的设计决策是因为这些参数可以作为输入传递给某些日历 API 函数。我们将所有其他新闻属性作为结构体数组(MqlCalendarValueMqlCalendarEventMqlCalendarCountry)中的输出字段值获取。正是根据它们,我们将按照 selectors 数组中的规则进行额外的过滤。

所有 let 方法都返回一个指向对象的指针,这允许对它们的调用进行链式操作。例如,像这样:

cpp
CalendarFilter f;
f.let(CALENDAR_IMPORTANCE_LOW, GREATER) // 重要和中等重要的新闻
  .let(CALENDAR_TIMEMODE_DATETIME) // 仅具有确切时间的事件
  .let("DE").let("FR") // 几个国家,或者,从中选择...
  .let("USD").let("GBP") // ...几种货币(但两个条件不会同时生效)
  .let(TimeCurrent() - MONTH_LONG, TimeCurrent() + WEEK_LONG) // 围绕当前时间的日期范围
  .let(LONG_MIN, CALENDAR_PROPERTY_RECORD_FORECAST, NOT_EQUAL) // 有预测
  .let("farm"); // 按新闻标题进行全文搜索

国家和货币条件理论上可以组合。然而,请注意,只能为国家或货币设置多个值,而不能同时为两者设置。在当前实现中,上下文的这两个方面之一(两者中的任意一个)仅支持一个值或不支持任何值(即对其没有过滤)。例如,如果选择了货币 EUR,则只能在德国和法国(国家代码“DE”和“FR”)中缩小新闻的搜索上下文。结果,欧洲央行和欧盟统计局的新闻将被丢弃,特别是意大利和西班牙的新闻也会被丢弃。然而,在这种情况下,指示 EUR 是多余的,因为德国和法国没有其他货币。

由于该类使用内置函数,其中参数国家和货币是通过逻辑与操作应用于新闻的,所以要检查过滤条件的一致性。

在调用代码设置过滤条件后,需要根据这些条件选择新闻。这就是公共方法 select 要做的事情(简化后给出)。

cpp
public:
   bool select(MqlCalendarValue &result[])
   {
      int count = 0;
      ArrayFree(result);
      if(ArraySize(ids)) // 事件类型标识符
      {
         for(int i = 0; i < ArraySize(ids); ++i)
         {
            MqlCalendarValue temp[];
            if(PRTF(CalendarValueHistoryByEvent(ids[i], temp, from, to)))

      {
         ArrayCopy(result, temp, ArraySize(result));
         ++count;
      }
   }
   else
   {
      // 多个国家或货币,选择数量较多的那个作为基础,
      // 仅使用较小数组的第一个元素
      if(ArraySize(country) > ArraySize(currency))
      {
         const string c = ArraySize(currency) > 0? currency[0] : NULL;
         for(int i = 0; i < ArraySize(country); ++i)
         {
            MqlCalendarValue temp[];
            if(PRTF(CalendarValueHistory(temp, from, to, country[i], c)))
            {
               ArrayCopy(result, temp, ArraySize(result));
               ++count;
            }
         }
      }
      else
      {
         const string c = ArraySize(country) > 0? country[0] : NULL;
         for(int i = 0; i < ArraySize(currency); ++i)
         {
            MqlCalendarValue temp[];
            if(PRTF(CalendarValueHistory(temp, from, to, c, currency[i])))
            {
               ArrayCopy(result, temp, ArraySize(result));
               ++count;
            }
         }
      }
   }
   
   if(ArraySize(result) > 0)
   {
      filter(result);
   }
   
   if(count > 1 && ArraySize(result) > 1)
   {
      SORT_STRUCT(MqlCalendarValue, result, time);
   }
   
   return ArraySize(result) > 0;
}

根据填充了哪个优先级属性数组,该方法会调用不同的 API 函数来轮询日历:

  • 如果 ids 数组被填充,则针对所有标识符循环调用 CalendarValueHistoryByEvent
  • 如果 country 数组被填充且其大小大于 currency 数组,则调用 CalendarValueHistory 并循环遍历各个国家。
  • 如果 currency 数组被填充且其大小大于或等于 country 数组,则调用 CalendarValueHistory 并循环遍历各个货币。

每次函数调用都会填充一个 MqlCalendarValue 结构体的临时数组 temp[],该数组会依次累加到 result 参数数组中。在根据主要条件(日期、国家、货币、标识符)将所有相关新闻写入 result 数组后(如果有的话),辅助方法 filter 就会发挥作用,它会根据 selectors 中的条件对数组进行过滤。在 select 方法的最后,新闻项会按时间顺序排序,这可能会因组合“日历”函数的多个查询结果而被打乱。排序是使用 SORT_STRUCT 宏实现的,该宏在“数组中的比较、排序和搜索”部分有讨论。

对于新闻数组的每个元素,filter 方法会调用工作方法 match,该方法返回一个布尔值,指示新闻是否符合过滤条件。如果不符合,该元素将从数组中移除。

cpp
protected:
   void filter(MqlCalendarValue &result[])
   {
      for(int i = ArraySize(result) - 1; i >= 0; --i)
      {
         if(!match(result[i]))
         {
            ArrayRemove(result, i, 1);
         }
      }
   }
   ...

最后,match 方法会分析我们的 selectors 数组,并将其与传入的 MqlCalendarValue 结构体的字段进行比较。以下是简化后的代码:

cpp
bool match(const MqlCalendarValue &v)
{
   MqlCalendarEvent event;
   if(!CalendarEventById(v.event_id, event)) return false;
   
   // 遍历所有过滤条件,除了国家、货币、日期、ID,
   // 这些在调用 Calendar 函数时已经使用过了
   for(int j = 0; j < ArrayRange(selectors, 0); ++j)
   {
      long field = 0;
      string text = NULL;
      
      // 从新闻或其描述中获取字段值
      switch((int)selectors[j][0])
      {
      case CALENDAR_PROPERTY_EVENT_TYPE:
         field = event.type;
         break;
      case CALENDAR_PROPERTY_EVENT_SECTOR:
         field = event.sector;
         break;
      case CALENDAR_PROPERTY_EVENT_TIMEMODE:
         field = event.time_mode;
         break;
      case CALENDAR_PROPERTY_EVENT_IMPORTANCE:
         field = event.importance;
         break;
      case CALENDAR_PROPERTY_EVENT_SOURCE:
         text = event.source_url;
         break;
      case CALENDAR_PROPERTY_EVENT_NAME:
         text = event.name;
         break;
      case CALENDAR_PROPERTY_RECORD_IMPACT:
         field = v.impact_type;
         break;
      case CALENDAR_PROPERTY_RECORD_ACTUAL:
         field = v.actual_value;
         break;
      case CALENDAR_PROPERTY_RECORD_PREVIOUS:
         field = v.prev_value;
         break;
      case CALENDAR_PROPERTY_RECORD_REVISED:
         field = v.revised_prev_value;
         break;
      case CALENDAR_PROPERTY_RECORD_PREVISED: // 之前的值或修订后的值(如果有)
         field = v.revised_prev_value != LONG_MIN? v.revised_prev_value : v.prev_value;
         break;
      case CALENDAR_PROPERTY_RECORD_FORECAST:
         field = v.forecast_value;
         break;
      ...
      }
      
      // 将值与过滤条件进行比较
      if(text == NULL) // 数值字段
      {
         switch((IS)selectors[j][2])
         {
         case EQUAL:
            if(!equal(field, selectors[j][1])) return false;
            break;
         case NOT_EQUAL:
            if(equal(field, selectors[j][1])) return false;
            break;
         case GREATER:
            if(!greater(field, selectors[j][1])) return false;
            break;
         case LESS:
            if(greater(field, selectors[j][1])) return false;
            break;
         }
      }
      else // 字符串字段
      {
         const string find = stringCache[(int)selectors[j][1]];
         switch((IS)selectors[j][2])
         {
         case EQUAL:
            if(!equal(text, find)) return false;
            break;
         case NOT_EQUAL:
            if(equal(text, find)) return false;
            break;
         case GREATER:
            if(!greater(text, find)) return false;
            break;
         case LESS:
            if(greater(text, find)) return false;
            break;
         }
      }
   }
   
   return true;
}

equalgreater 方法几乎完全复制了我们之前使用过滤类开发中的那些方法。

至此,过滤问题基本得到了解决,即 MQL 程序可以按以下方式使用 CalendarFilter 对象:

cpp
CalendarFilter f;
f.let()... // 一系列对 let 方法的调用以设置过滤条件
MqlCalendarValue records[]; 
if(f.select(records))
{
   ArrayPrint(records);
}

实际上,select 方法还可以做一些其他重要的事情,我们将其留作独立的可选研究内容。

首先,在得到的新闻列表中,最好以某种方式在过去和未来的新闻之间插入一个分隔符(定界符),以便用户能够注意到它。从理论上讲,这个功能对于日历来说极其重要,但由于某种原因,在 MetaTrader 5 用户界面和 mql5.com 网站上都不可用。我们的实现能够在过去和未来的新闻之间插入一个空结构体,我们应该直观地显示它(我们将在下面处理这个问题)。

其次,结果数组的大小可能会相当大(特别是在选择设置的初始阶段),因此 select 方法还额外提供了限制数组大小(limit)的功能。这是通过移除离当前时间最远的元素来实现的。

所以,完整的方法原型如下:

cpp
bool select(MqlCalendarValue &result[],
   const bool delimiter = false, const int limit = -1);

默认情况下,不插入分隔符且数组不会被截断。

在前面几段中,我们提到了过滤的一个额外子任务,即对结果数组进行可视化。CalendarFilter 类有一个特殊的方法 format,它将传入的 MqlCalendarValue &data[] 结构体数组转换为人类可读的字符串数组 string &result[]。该方法的代码可以在附加文件 CalendarFilter.mqh 中找到。

cpp
bool format(const MqlCalendarValue &data[],
   const ENUM_CALENDAR_PROPERTY &props[], string &result[],
   const bool padding = false, const bool header = false);

我们想要显示的 MqlCalendarValue 字段在 props 数组中指定。回想一下,ENUM_CALENDAR_PROPERTY 枚举包含来自所有三个相关日历结构体的字段,因此 MQL 程序不仅可以自动显示特定事件记录中的经济指标,还可以显示其名称、特征、国家或货币代码。所有这些都是由 format 方法实现的。

输出结果数组中的每一行都包含一个字段值(数字、描述、枚举元素)的文本表示。结果数组的大小等于输入(data 中)的结构体数量与显示的字段数量(props 中)的乘积。可选参数 header 允许在输出数组的开头添加一行字段(列)名称。padding 参数控制在文本中生成额外的空格,以便在等宽字体中方便地显示表格(例如,在杂志中)。

CalendarFilter 类还有另一个重要的公共方法:update

cpp
bool update(MqlCalendarValue &result[]);

它的结构几乎完全与 select 方法相同。然而,该方法不是调用 CalendarValueHistoryByEventCalendarValueHistory 函数,而是调用 CalendarValueLastByEventCalendarValueLast 函数。该方法的目的很明显:它向日历查询符合过滤条件的最新更改。但为了使其正常运行,它需要一个更改 ID。这个类中确实定义了这样一个字段:第一次填充它是在 select 方法内部。

cpp
class CalendarFilter
{
protected:
   ...
   ulong change;
   ...
public:
   bool select(MqlCalendarValue &result[],
      const bool delimiter = false, const int limit = -1)
   {
      ...
      change = 0;
      MqlCalendarValue dummy[];
      CalendarValueLast(change, dummy);
      ...
   }

CalendarFilter 类的一些细微之处仍然“隐藏在幕后”,但我们将在接下来的部分中讨论其中的一些内容。

让我们测试一下过滤器的实际效果:首先在一个简单的脚本 CalendarFilterPrint.mq5 中进行测试,然后在一个更实用的指标 CalendarMonitor.mq5 中进行测试。

在脚本的输入参数中,你可以设置上下文(国家代码或货币)、时间范围、用于按事件名称进行全文搜索的字符串,以及限制得到的新闻表格的大小。

cpp
input string Context; // 上下文(国家 - 2 个字符,货币 - 3 个字符,空 - 无过滤)
input ENUM_CALENDAR_SCOPE Scope = SCOPE_MONTH;
input string Text = "farm";
input int Limit = -1;

根据这些参数,创建一个全局过滤对象。

cpp
CalendarFilter f(Context, TimeCurrent() - Scope, TimeCurrent() + Scope);

然后,在 OnStart 中,我们配置一些额外的常量条件(事件的中等和高重要性)以及存在预测(该字段不等于 LONG_MIN),并将搜索字符串传递给该对象。

cpp
void OnStart()
{
   f.let(CALENDAR_IMPORTANCE_LOW, GREATER)
      .let(LONG_MIN, CALENDAR_PROPERTY_RECORD_FORECAST, NOT_EQUAL)
      .let(Text); // 支持使用 '*' 进行替换
      // 注意:长度为 2 或 3 且没有 '*' 的字符串将分别被视为国家或货币代码

接下来,调用 select 方法,并使用 format 方法将得到的 MqlCalendarValue 结构体数组格式化为一个有 9 列的表格。

cpp
MqlCalendarValue records[];
   // 应用过滤条件并获取结果
   if(f.select(records, true, Limit))
   {
      static const ENUM_CALENDAR_PROPERTY props[] =
      {
         CALENDAR_PROPERTY_RECORD_TIME,
         CALENDAR_PROPERTY_COUNTRY_CURRENCY,
         CALENDAR_PROPERTY_EVENT_NAME,
         CALENDAR_PROPERTY_EVENT_IMPORTANCE,
         CALENDAR_PROPERTY_RECORD_ACTUAL,
         CALENDAR_PROPERTY_RECORD_FORECAST,
         CALENDAR_PROPERTY_RECORD_PREVISED,
         CALENDAR_PROPERTY_RECORD_IMPACT,
         CALENDAR_PROPERTY_EVENT_SECTOR,
      };
      static const int p = ArraySize(props);
      
      // 输出格式化后的结果
      string result[];
      if(f.format(records, props, result, true, true))
      {
         for(int i = 0; i < ArraySize(result) / p; ++i)
         {
            Print(SubArrayCombine(result, " | ", i * p, p));
         }
      }
   }
}

表格的单元格被连接成行并输出到日志中。

使用默认设置(即对于所有国家和货币,事件名称中包含“farm”部分且为中等和高重要性),你可以得到类似这样的日程安排。

plaintext
Selecting calendar records...

country[i]= / ok

calendarValueHistory(temp,from,to,country[i],c)=2372 / ok

Filtering 2372 records

Got 9 records

            TIME | CUR⁞ |                          NAME | IMPORTAN⁞ | ACTU⁞ | FORE⁞ | PREV⁞ |   IMPACT | SECT⁞

2022.06.02 15:15 |  USD | ADP Nonfarm Employment Change |      HIGH |  +128 |  -225 |  +202 | POSITIVE |  JOBS

2022.06.02 15:30 |  USD |      Nonfarm Productivity q/q |  MODERATE |  -7.3 |  -7.5 |  -7.5 | POSITIVE |  JOBS

2022.06.03 15:30 |  USD |              Nonfarm Payrolls |      HIGH |  +390 |   -19 |  +436 | POSITIVE |  JOBS

2022.06.03 15:30 |  USD |      Private Nonfarm Payrolls |  MODERATE |  +333 |    +8 |  +405 | POSITIVE |  JOBS

2022.06.09 08:30 |  EUR |          Nonfarm Payrolls q/q |  MODERATE |  +0.3 |  +0.3 |  +0.3 |       NA |  JOBS

               – |    – |                             – |         – |     – |     – |     – |        – |     –

2022.07.07 15:15 |  USD | ADP Nonfarm Employment Change |      HIGH |  +nan |  -263 |  +128 |       NA |  JOBS

2022.07.08 15:30 |  USD |              Nonfarm Payrolls |      HIGH |  +nan |  -229 |  +390 |       NA |  JOBS

2022.07.08 15:30 |  USD |      Private Nonfarm Payrolls |  MODERATE |  +nan |   +51 |  +333 |       NA |  JOBS

现在让我们来看一下指标 CalendarMonitor.mq5。它的目的是根据指定的过滤器向用户在图表上显示当前选择的事件。为了可视化表格,我们将使用已经熟悉的记分牌类(Tableau.mqh,请参阅“未来订单的保证金计算”部分)。该指标没有缓冲区和图表。

输入参数允许你设置时间窗口的范围(scope),以及 CalendarFilter 对象的全局上下文,即 Context 中的货币或国家代码(默认为空,即无限制),或者使用布尔标志 UseChartCurrencies。它默认是启用的,建议使用它以便自动接收构成图表工作工具的那些货币的新闻。

cpp
input string Context; // 上下文(国家 - 2 个字符,货币 - 3 个字符,空 - 所有)
input ENUM_CALENDAR_SCOPE Scope = SCOPE_WEEK;
input bool UseChartCurrencies = true;

可以对事件类型、部门和严重程度应用额外的过滤器。

cpp
input ENUM_CALENDAR_EVENT_TYPE_EXT Type = TYPE_ANY;
input ENUM_CALENDAR_EVENT_SECTOR_EXT Sector = SECTOR_ANY;
input ENUM_CALENDAR_EVENT_IMPORTANCE_EXT Importance = IMPORTANCE_MODERATE; // 重要性(至少)

Importance 设置选择的下限,而不是精确匹配。因此,IMPORTANCE_MODERATE 的默认值将不仅捕获中等重要性,还会捕获高重要性。

细心的读者会注意到,这里使用了一些不熟悉的枚举:ENUM_CALENDAR_EVENT_TYPE_EXTENUM_CALENDAR_EVENT_SECTOR_EXTENUM_CALENDAR_EVENT_IMPORTANCE_EXT。它们在前面提到的 CalendarDefines.mqh 文件中,并且几乎与类似的内置枚举一一对应。唯一的区别是,它们添加了一个表示“任意”值的元素。我们需要定义这些枚举,以便简化条件的输入:现在每个字段的过滤器都可以通过下拉列表进行配置,在下拉列表中可以选择其中一个值,或者关闭过滤器。如果没有添加这个枚举元素,我们就必须在界面中为每个字段输入一个逻辑“开/关”标志。

此外,输入参数还允许通过事件中是否存在实际指标、预测指标和先前指标来查询事件,以及通过搜索文本字符串(Text)来查询事件。

cpp
input string Text;
input ENUM_CALENDAR_HAS_VALUE HasActual = HAS_ANY;
input ENUM_CALENDAR_HAS_VALUE HasForecast = HAS_ANY;
input ENUM_CALENDAR_HAS_VALUE HasPrevious = HAS_ANY;
input ENUM_CALENDAR_HAS_VALUE HasRevised = HAS_ANY;
input int Limit = 30;

CalendarFilter 对象和 tableau 对象在全局级别进行声明。

cpp
CalendarFilter f(Context);
AutoPtr<Tableau> t;

请注意,过滤器只创建一次,而表格由一个自动选择器表示,并将根据接收到的数据大小动态重新创建。

OnInit 中,根据输入参数通过连续调用 let 方法来进行过滤器设置。

cpp
int OnInit()
{
    if(!f.isLoaded()) return INIT_FAILED;
    
    if(UseChartCurrencies)
    {
        const string base = SymbolInfoString(_Symbol, SYMBOL_CURRENCY_BASE);
        const string profit = SymbolInfoString(_Symbol, SYMBOL_CURRENCY_PROFIT);
        f.let(base);
        if(base != profit)
        {
            f.let(profit);
        }
    }
    
    if(Type != TYPE_ANY)
    {
        f.let((ENUM_CALENDAR_EVENT_TYPE)Type);
    }
    
    if(Sector != SECTOR_ANY)
    {
        f.let((ENUM_CALENDAR_EVENT_SECTOR)Sector);
    }
    
    if(Importance != IMPORTANCE_ANY)
    {
        f.let((ENUM_CALENDAR_EVENT_IMPORTANCE)(Importance - 1), GREATER);
    }
    
    if(StringLen(Text))
    {
        f.let(Text);
    }
    
    if(HasActual != HAS_ANY)
    {
        f.let(LONG_MIN, CALENDAR_PROPERTY_RECORD_ACTUAL,
            HasActual == HAS_SET? NOT_EQUAL : EQUAL);
    }
   ...
    
    EventSetTimer(1);
    
    return INIT_SUCCEEDED;
}

最后,启动一个秒级定时器。所有工作都在 OnTimer 中实现。

cpp
void OnTimer()
{
    static const ENUM_CALENDAR_PROPERTY props[] = // 表格列
    {
        CALENDAR_PROPERTY_RECORD_TIME,
        CALENDAR_PROPERTY_COUNTRY_CURRENCY,
        CALENDAR_PROPERTY_EVENT_NAME,
        CALENDAR_PROPERTY_EVENT_IMPORTANCE,
        CALENDAR_PROPERTY_RECORD_ACTUAL,
        CALENDAR_PROPERTY_RECORD_FORECAST,
        CALENDAR_PROPERTY_RECORD_PREVISED,
        CALENDAR_PROPERTY_RECORD_IMPACT,
        CALENDAR_PROPERTY_EVENT_SECTOR,
    };
    static const int p = ArraySize(props);
    
    MqlCalendarValue records[];
    
    f.let(TimeCurrent() - Scope, TimeCurrent() + Scope); // 每次都移动时间窗口
    
    const ulong trackID = f.getChangeID();
    if(trackID) // 如果状态已经被移除,检查是否有变化
    {
        if(f.update(records)) // 按过滤器请求变化
        {
            // 如果有变化,通知用户
            string result[];
            f.format(records, props, result);
            for(int i = 0; i < ArraySize(result) / p; ++i)
            {
                Alert(SubArrayCombine(result, " | ", i * p, p));
            }
            // 继续往下执行以更新表格
        }
        else if(trackID == f.getChangeID())
        {
            return; // 日历没有变化
        }
    }
    
    // 按过滤器请求完整的新闻集
    f.select(records, true, Limit);
    
    // 在图表上显示新闻表格
    string result[];
    f.format(records, props, result, true, true);
    
    if(t[] == NULL || t[].getRows() != ArraySize(records) + 1)
    {
        t = new Tableau("CALT", ArraySize(records) + 1, p,
            TBL_CELL_HEIGHT_AUTO, TBL_CELL_WIDTH_AUTO,
            Corner, Margins, FontSize, FontName, FontName + " Bold",
            TBL_FLAG_ROW_0_HEADER,
            BackgroundColor, BackgroundTransparency);
    }
    const string hints[] = {};
    t[].fill(result, hints);
}

如果我们在 EURUSD 图表上以默认设置运行该指标,我们可以得到以下画面。

将日历数据库传输到测试器中

日历仅在线上对 MQL 程序可用,因此测试新闻交易策略会带来一些困难。其中一个解决方案是自主创建日历的特定镜像,也就是缓存,然后在测试器中使用它。缓存存储技术有多种,例如文件或嵌入式 SQLite 数据库。在本节中,我们将展示使用文件的实现方式。

在任何情况下,使用日历缓存时,请记住它对应于特定的时间点 X。在 X 之前发生的所有 “旧” 事件(财务报告)中,实际值已经设置好了;而在之后的事件(相对于 X 的 “未来” 事件)中,没有实际值,并且在出现新的、更新的缓存副本之前也不会有实际值。换句话说,在时间点 X 之后测试指标和智能交易系统是没有意义的。至于 X 之前的部分,应该避免提前查看,也就是说,在每条特定新闻发布时间之前不要读取当前指标。

注意!在终端中请求日历数据时,所有事件的时间都是根据服务器的当前时区来报告的,包括可能的 “夏令时” 调整(通常这意味着时间戳增加 1 小时)。这使得新闻发布时间与在线报价时间同步。然而,过去的时钟调整(半年前、一年前或更久以前)仅在报价中显示,而不在日历事件中显示。整个日历数据库是根据服务器的当前时区通过 MQL5 读取的。因此,任何创建的日历存档将包含那些在存储时处于相同夏令时模式(开启或关闭)下发生的事件的正确时间戳。对于处于 “相反” 半年中的事件,在读取存档后需要自行进行一小时的调整。在下面的示例中,省略了这种情况。

我们将缓存类命名为 CalendarCache 并将其放在名为 CalendarCache.mqh 的文件中。我们需要将日历数据库的所有 3 个表(MqlCalendarCountryMqlCalendarEventMqlCalendarValue)保存到文件中。MQL5 提供了 FileWriteArrayFileReadArray 函数(请参阅 “写入和读取数组”),它们可以直接将简单结构的数组写入文件或从文件中读取。然而,在我们的情况中,3 个结构中有 2 个不是简单结构,因为它们有字符串字段。因此,我们需要一种单独存储字符串的机制,类似于我们在 CalendarFilter 类中已经使用过的机制(那里有一个字符串数组 stringCache,并且在过滤器中指明了来自该数组的所需字符串的索引)。

为了避免在一个 “字典” 中混淆来自不同 “日历” 结构的字符串,我们将准备一个模板类 StringRef:类型参数 T 将是任何一种 MqlCalendar 结构。这将为我们提供一个用于国家的单独字符串缓存,以及一个用于事件类型的单独字符串缓存。

cpp
template<typename T>
struct StringRef
{
   static string cache[];
   int index;
   StringRef(): index(-1) { }
   
   void operator=(const string s)
   {
      if(index == -1)
      {
         PUSH(cache, s);
         index = ArraySize(cache) - 1;
      }
      else
      {
         cache[index] = s;
      }
   }
   
   string operator[](int x = 0) const
   {
      if(index != -1)
      {
         return cache[index];
      }
      return NULL;
   }
   
   static bool save(const int handle)
   {
      FileWriteInteger(handle, ArraySize(cache));
      for(int i = 0; i < ArraySize(cache); ++i)
      {
         FileWriteInteger(handle, StringLen(cache[i]));
         FileWriteString(handle, cache[i]);
      }
      return true;
   }
   
   static bool load(const int handle)
   {
      const int n = FileReadInteger(handle);
      for(int i = 0; i < n; ++i)
      {
         PUSH(cache, FileReadString(handle, FileReadInteger(handle)));
      }
      return true;
   }
};
   
template<typename T>
static string StringRef::cache[];

字符串通过使用 operator= 存储在缓存数组中,并使用 operator[] 从中提取(带有一个始终被省略的虚拟索引)。每个对象仅存储字符串在数组中的索引。缓存数组被声明为 static,因此它将累积一个 T 结构的所有字符串字段。有需要的人可以更改缓存方法,使得结构的每个字段都有自己的数组,但这对我们来说并不重要。

将数组写入文件和从文件中读取是由一对静态方法 saveload 来执行的:这两个方法都将文件句柄作为参数。

考虑到 StringRef 类,让我们描述一些结构,这些结构复制了标准日历结构,但使用 StringRef 对象代替了字符串字段。例如,对于 MqlCalendarCountry,我们得到 MqlCalendarCountryRef。标准结构和修改后的结构通过重载的 =[] 操作符以类似的方式相互复制。

cpp
struct MqlCalendarCountryRef
{
   ulong id;
   StringRef<MqlCalendarCountry> name;
   StringRef<MqlCalendarCountry> code;
   StringRef<MqlCalendarCountry> currency;
   StringRef<MqlCalendarCountry> currency_symbol;
   StringRef<MqlCalendarCountry> url_name;
   
   void operator=(const MqlCalendarCountry &c)
   {
      id = c.id;
      name = c.name;
      code = c.code;
      currency = c.currency;
      currency_symbol = c.currency_symbol;
      url_name = c.url_name;
   }
   
   MqlCalendarCountry operator[](int x = 0) const
   {
      MqlCalendarCountry r;
      r.id = id;
      r.name = name[];
      r.code = code[];
      r.currency = currency[];
      r.currency_symbol = currency_symbol[];
      r.url_name = url_name[];
      return r;
   }
};

请注意,第一种方法的赋值操作符有来自 StringRef 的重载 =,由于这个原因,所有字符串都进入了数组 StringRef<MqlCalendarCountry>::cache。在第二种方法中,[] 操作符无形地调用以获取字符串的地址,并直接从 StringRef 返回存储在缓存数组中该地址处的字符串。

MqlCalendarEventRef 结构以类似的方式定义,但其中只有 3 个字段(source_urlevent_codename)需要将 string 类型替换为 StringRef<MqlCalendarEvent>MqlCalendarValue 结构不需要这样的转换,因为它里面没有字符串字段。

准备阶段到此结束,现在可以进入主要的缓存类 CalendarCache

从一般考虑以及为了与已经开发的 CalendarFilter 类兼容,让我们描述缓存中的字段,这些字段指定了上下文(国家或货币)、存储事件的日期范围以及缓存生成的时刻(时间 X,变量 t)。

cpp
class CalendarCache
{
   string context;
   datetime from, to;
   datetime t;
   ...
   
public:
   CalendarCache(const string _context = NULL,
      const datetime _from = 0, const datetime _to = 0):
      context(_context), from(_from), to(_to), t(0)
   {
      ...
   }

实际上,在从日历创建缓存时设置限制并没有太大意义。完整的缓存可能更实用,因为它的大小并非关键因素,截至 2022 年年中,其大小约为几十兆字节(这包括 2007 年以来的历史数据以及计划到 2024 年的事件)。然而,对于功能经过人为简化的演示程序,限制可能会很有用。

显然,在缓存中应该提供日历结构的数组来存储所有数据。

cpp
   MqlCalendarValue values[];
   MqlCalendarEvent events[];
   MqlCalendarCountry countries[];
   ...

最初,它们通过 update 方法从日历数据库中填充。

cpp
   bool update()
   {
      string country = NULL, currency = NULL;
      if(StringLen(context) == 3)
      {
         currency = context;
      }
      else if(StringLen(context) == 2)
      {
         country = context;
      }
      
      Print("Reading online calendar base...");
      
      if(!PRTF(CalendarValueHistory(values, from, to, country, currency))
         || (currency != NULL ?
            !PRTF(CalendarEventByCurrency(currency, events)) :
            !PRTF(CalendarEventByCountry(country, events)))
         || !PRTF(CalendarCountries(countries)))
      {
         // object is not ready, t = 0
      }
      else
      {
         t = TimeTradeServer();
      }
      return (bool)t;
   }

t 字段是缓存是否有效的标志,包含填充数组的时间。

填充后的缓存对象可以使用 save 方法写入文件。在文件开头,有一个头 CALENDAR_CACHE_HEADER —— 即字符串 "MQL5 Calendar Cache\r\nv.1.0\r\n",这使得在读取时可以确保格式正确。接下来,该方法保存 contextfromtot 变量,以及 values 数组 “原样” 保存。在数组本身之前,我们写下它的大小以便在读取时恢复它。

cpp
   bool save(string filename = NULL)
   {
      if(!t) return false;
      
      MqlDateTime mdt;
      TimeToStruct(t, mdt);
      if(filename == NULL) filename = "calendar-" +
         StringFormat("%04d-%02d-%02d-%02d-%02d.cal",
         mdt.year, mdt.mon, mdt.day, mdt.hour, mdt.min);
      int handle = PRTF(FileOpen(filename, FILE_WRITE | FILE_BIN));
      if(handle == INVALID_HANDLE) return false;
      
      FileWriteString(handle, CALENDAR_CACHE_HEADER);
      FileWriteString(handle, context, 4);
      FileWriteLong(handle, from);
      FileWriteLong(handle, to);
      FileWriteLong(handle, t);
      FileWriteInteger(handle, ArraySize(values));
      FileWriteArray(handle, values);
      ...

对于 eventscountries 数组,我们使用带有 “Ref” 后缀的包装结构。辅助方法 storeevents 数组转换为简单结构 erefs 的数组,在这个数组中,字符串被替换为字符串字典 StringRef<MqlCalendarEvent> 中的数字。这样的简单结构已经可以用通常的方式写入文件,但为了后续读取,还需要保存字典中的所有字符串(调用 StringRef<MqlCalendarEvent> ::save(handle))。国家结构以相同的方式转换并保存到文件中。

cpp
      MqlCalendarEventRef erefs[];
      store(erefs, events);
      FileWriteInteger(handle, ArraySize(erefs));
      FileWriteArray(handle, erefs);
      StringRef<MqlCalendarEvent>::save(handle);
      
      MqlCalendarCountryRef crefs[];
      store(crefs, countries);
      FileWriteInteger(handle, ArraySize(crefs));
      FileWriteArray(handle, crefs);
      StringRef<MqlCalendarCountry>::save(handle);
      
      FileClose(handle);
      return true;
   }

前面提到的 store 方法相当简单:在其中,通过对元素的循环,在 MqlCalendarEventRefMqlCalendarCountryRef 结构中执行重载的赋值操作符。

cpp
   template<typename T1,typename T2>
   void static store(T1 &array[], T2 &origin[])
   {
      ArrayResize(array, ArraySize(origin));
      for(int i = 0; i < ArraySize(origin); ++i)
      {
         array[i] = origin[i];
      }
   }

为了将接收到的文件加载到缓存对象中,编写了一个镜像方法 load。它以相同的顺序将文件中的数据读取到变量和数组中,同时对事件类型和国家的字符串字段进行反向转换。

cpp
   bool load(const string filename)
   {
      Print("Loading calendar cache ", filename);
      t = 0;
      int handle = PRTF(FileOpen(filename, FILE_READ | FILE_BIN));
      if(handle == INVALID_HANDLE) return false;
      
      const string header = FileReadString(handle, StringLen(CALENDAR_CACHE_HEADER));
      if(header != CALENDAR_CACHE_HEADER) return false; // not our format
      
      context = FileReadString(handle, 4);
      if(!StringLen(context)) context = NULL;
      from = (datetime)FileReadLong(handle);
      to = (datetime)FileReadLong(handle);
      t = (datetime)FileReadLong(handle);
      Print("Calendar cache interval: ", from, "-", to);
      Print("Calendar cache saved at: ", t);
      int n = FileReadInteger(handle);
      FileReadArray(handle, values, 0, n);
      
      MqlCalendarEventRef erefs[];
      n = FileReadInteger(handle);
      FileReadArray(handle, erefs, 0, n);
      StringRef<MqlCalendarEvent>::load(handle);
      restore(events, erefs);
      
      MqlCalendarCountryRef crefs[];
      n = FileReadInteger(handle);
      FileReadArray(handle, crefs, 0, n);
      StringRef<MqlCalendarCountry>::load(handle);
      restore(countries, crefs);
      
      FileClose(handle);
      ... // something else will be here
   }

辅助方法 restore 使用 MqlCalendarEventRefMqlCalendarCountryRef 结构中 [] 操作符的重载,通过行号逐行获取字符串本身,并将其赋值给标准的 MqlCalendarEventMqlCalendarCountry 结构。

cpp
   template<typename T1,typename T2>
   void static restore(T1 &array[], T2 &origin[])
   {
      ArrayResize(array, ArraySize(origin));
      for(int i = 0; i < ArraySize(origin); ++i)
      {
         array[i] = origin[i][];
      }
   }

在这个阶段,我们已经可以编写一个基于 CalendarCache 类的简单测试指标,在在线图表上运行它,并将其与日历缓存一起保存到文件中。然后,可以从测试器中的指标副本加载该文件,并获取完整的事件集。然而,对于实际开发来说,这还不够。

事实上,为了快速访问数据,需要提供索引,这是编程中一个众所周知的概念,我们将在后面关于数据库的章节中讨论。理论上,我们可以使用内置的 SQLite 引擎来存储缓存,然后我们将 “免费” 获得索引,但这一点我们后面再谈。

如果我们想象如何在我们的缓存中有效地实现标准日历函数的类似功能,就很容易理解索引的要点。例如,在 CalendarValueById 函数中传递了事件 ID。直接枚举 values 数组中的记录会非常耗时。因此,需要用一些 “数据结构” 来补充数组,以便优化搜索。“数据结构” 加了引号,因为这里不是指编程语言意义上的 struct,而是一般意义上的数据构造架构。它可以由不同的部分组成,并基于不同的组织原则。当然,额外的数据会占用内存,但用内存换取速度是编程中常见的方法。

最简单的索引解决方案是一个单独的二维数组,按升序排序,以便可以使用 ArrayBsearch 函数快速搜索。第二维有两个元素就足够了:索引为 [i][0] 的值(用于排序)包含标识符,索引为 [i][1] 的值包含在结构数组中的序号位置。

另一个常用的概念是哈希,它是将初始值转换为一些键(哈希值,整数)的过程,这样可以使碰撞(不同初始数据的键匹配)的数量最小化。键的基本属性是其值接近均匀随机分布,因此它们可以用作预分配数组中的索引。为原始数据的单个元素计算哈希函数是一个快速的过程,实际上会得出元素本身的地址。例如,著名的哈希表数据结构就遵循这个原则。

如果两个原始值确实得到了相同的哈希值(尽管这种情况很少见),它们会在其键的列表中排列,并且会在列表中进行顺序搜索。然而,由于哈希函数的选择使得匹配的数量很少,通常在计算完哈希值后搜索就能命中目标。

为了演示,我们将在 CalendarCache 类中使用两种方法:哈希和二分搜索。

MetaTrader 5 软件包包括一组用于创建哈希表的类(MQL5/Include/Generic/HashMap.mqh),但我们将使用自己更简单的实现,其中只保留了使用哈希函数的原理。

通过哈希进行数据索引的方案

通过哈希进行数据索引的方案

在我们的情况下,只对日历对象的标识符进行哈希就足够了。我们选择的哈希函数必须将标识符转换为特殊数组内的索引:标识符在 “日历” 结构数组中的位置将存储在具有该索引的单元格中。对于国家、事件类型和特定新闻,会根据其自身的

日历交易

有许多新闻交易策略:包括使用市价单或挂单的策略,有对金融指标(价格走势方向)进行分析的策略,也有不进行此类分析的策略(捕捉波动性)。此外,在许多其他交易系统中插入反新闻过滤器也是很有用的。由于 MQL5 日历在测试器中不可用,所以很难对所有这样的程序进行优化和调试。然而,借助上一节开发的缓存,我们可以改善这种情况。

让我们尝试创建一个智能交易系统(EA),它将根据新闻发布对价格的影响评估来进入市场。刚刚使用指标 CalendarMonitorCached.mq5 创建了缓存文件 “xyz.cal”。

请记住,缓存中的日历数据总是对应于保存时的状态,在读取时需要谨慎:对于未来的事件,实际指标是未知的,而且更遥远的事件甚至可能根本不存在。在下一次优化或测试之前,你应该定期更新日历缓存文件。

如果有必要,还需要考虑一年中的夏令时(DST)时间设置:如果事件的夏令时模式与保存日历存档时的夏令时不同,你将需要把时间向前或向后调整 1 小时。你可以通过选择不采用夏令时的经纪商,或者构建时间框架大于 H1 的策略来避免这些麻烦。

智能交易系统 CalendarTrading.mq5 将只对以下新闻事件进行交易:

  • 与图表的工作品种相关;
  • 属于金融指标类型(即定量指标);
  • 具有高度重要性;
  • 刚刚收到指标的当前值。

最后一点很重要,因为对于有预测值和实际值的指标,系统会相应地设置 impact_type 字段的值:它将作为交易信号(指示进入市场的方向)。

新闻发布的确切时间,通常与 MqlCalendarValue::time 字段中输入的计划时间不一致。日历不会记录这个时间,并且在缓存中也无法获取。在这方面,新闻策略测试的准确性可能会受到影响。如果你想让分析和决策更接近在线交易过程,可以使用 CalendarChangeSaver.mq5 这样的服务来积累新闻发布统计数据,并将其嵌入到缓存中。

默认情况下,交易以最小手数进行,止损和止盈水平设置为指定的点数距离。所有这些都反映在输入参数中。

input double Volume;               // 交易量(0 = 最小手数)
input int Distance2SLTP = 500;     // 到止损/止盈的点数距离(0 = 无)
input uint MultiplePositions = 25;

对于对冲账户,我们允许同时存在多个头寸,默认值是 25。这是推荐的测试环境,因为它允许你独立评估对不同类型新闻进行并行交易的盈利能力(每个头寸是独立创建的,不会导致因其他新闻而平仓)。另一方面,只持有一个头寸会自动平衡不同新闻的冲突信号。

可选地,该智能交易系统支持对新闻类型标识符的过滤,以及通过标题进行文本搜索。

sinput ulong EventID;
sinput string Text;

这对于未来对特定新闻的研究可能会很有用。

在全局层面,通过对新闻的分析处理和头寸跟踪来描述对象指针。

AutoPtr<CalendarFilter> fptr;
AutoPtr<CalendarCache> cache;
AutoPtr<TrailingStop> trailing[];

当前工作品种的操作模式和货币对存储在相应的变量中。为了简化示例,假设它用于外汇市场(在其他市场上,你将进行单一货币的交易 —— 即行情代码的报价货币)。

const bool Hedging =
   AccountInfoInteger(ACCOUNT_MARGIN_MODE) == ACCOUNT_MARGIN_MODE_RETAIL_HEDGING;
const string Base = SymbolInfoString(_Symbol, SYMBOL_CURRENCY_BASE);
const string Profit = SymbolInfoString(_Symbol, SYMBOL_CURRENCY_PROFIT);

OnInit 处理程序中,我们加载日历缓存并按上述方式配置过滤器。在线图表上允许不存在缓存:此时智能交易系统以实战模式运行,直接与日历交互。在测试器中,如果没有缓存文件,智能交易系统将无法启动。

int OnInit()
{
   cache = new CalendarCache("xyz.cal", true);
   if(cache[].isLoaded())
   {
      fptr = new CalendarFilterCached(cache[]);
   }
   else
   {
      if(!MQLInfoInteger(MQL_TESTER))
      {
         Print("Calendar cache file not found, fall back to online mode");
         fptr = new CalendarFilter();
      }
      else
      {
         Print("Can't proceed in the tester without calendar cache file");
         return INIT_FAILED;
      }
   }
   CalendarFilter *f = fptr[];
   
   if(!f.isLoaded()) return INIT_FAILED;
   
   // 如果设置了特定类型的事件,我们只关注它
   if(EventID > 0) f.let(EventID);
   else
   {
      // 否则关注当前品种货币相关的新闻
      f.let(Base);
      if(Base != Profit)
      {
         f.let(Profit);
      }
      
      // 金融指标、高重要性、实际值
      f.let(CALENDAR_TYPE_INDICATOR);
      f.let(LONG_MIN, CALENDAR_PROPERTY_RECORD_FORECAST, NOT_EQUAL);
      f.let(CALENDAR_IMPORTANCE_HIGH);
   
      if(StringLen(Text)) f.let(Text);
   }
   
   f.describe();
   
   if(Distance2SLTP)
   {
      ArrayResize(trailing, Hedging && MultiplePositions? MultiplePositions : 1);
   }
   // 检查新闻过滤器并通过第二个定时器开始基于它进行交易
   EventSetTimer(1);
   return INIT_SUCCEEDED;
}

OnTimer 处理程序中,我们根据配置的过滤器请求新闻的变化。

void OnTimer()
{
   CalendarFilter *f = fptr[];
   MqlCalendarValue records[];
   
   f.let(TimeTradeServer() - SCOPE_DAY, TimeTradeServer() + SCOPE_DAY);
   
   if(f.update(records)) // 找到经过过滤的变化
   {
      // 将已更改新闻的属性输出到日志
      static const ENUM_CALENDAR_PROPERTY props[] =
      {
         CALENDAR_PROPERTY_RECORD_TIME,
         CALENDAR_PROPERTY_COUNTRY_CURRENCY,
         CALENDAR_PROPERTY_COUNTRY_CODE,
         CALENDAR_PROPERTY_EVENT_NAME,
         CALENDAR_PROPERTY_EVENT_IMPORTANCE,
         CALENDAR_PROPERTY_RECORD_ACTUAL,
         CALENDAR_PROPERTY_RECORD_FORECAST,
         CALENDAR_PROPERTY_RECORD_PREVISED,
         CALENDAR_PROPERTY_RECORD_IMPACT,
      };
      static const int p = ArraySize(props);
      string result[];
      f.format(records, props, result);
      for(int i = 0; i < ArraySize(result) / p; ++i)
      {
         Print(SubArrayCombine(result, " | ", i * p, p));
      }
      ...

当检测到合适的变化时,它们会按如下方式记录到日志中(下面是实际日志的一个片段),记录了时间、货币、国家、名称、当前值和预测值、先前值以及信号的理论解释:

...

Filtering 5 records

2021.02.16 13:00 | EUR | EU | Employment Change q/q | HIGH | +0.3 | -0.4 | +1.0 | POSITIVE

2021.02.16 13:00 | EUR | EU | GDP q/q | HIGH | -0.6 | -0.7 | -0.7 | POSITIVE

instant buy 0.01 EURUSD at 1.21638 sl: 1.21138 tp: 1.22138 (1.21637 / 1.21638 / 1.21637)

deal #64 buy 0.01 EURUSD at 1.21638 done (based on order #64)

...

Filtering 3 records

2021.07.06 12:05 | EUR | DE | ZEW Economic Sentiment Indicator | HIGH | +63.3 | +84.1 | +79.8 | NEGATIVE

instant sell 0.01 EURUSD at 1.18473 sl: 1.18973 tp: 1.17973 (1.18473 / 1.18474 / 1.18473)

deal #265 sell 0.01 EURUSD at 1.18473 done (based on order #265)

...

新闻对价格的潜在影响应该根据 impact_type 字段的评估来计算。这里需要注意的是,我们有两种货币:基础货币和报价货币。当新闻对基础货币有积极影响时,预计汇率会上升,如果是负面影响,汇率将会下降。对于报价货币,情况则相反:积极影响应该会增加货币对中第二种货币的价格,这意味着汇率下降,而负面影响会导致汇率上升。下面的代码片段使用 sign 变量计算了这种标准化的价格运动方向。

      static const int impacts[3] = {0, +1, -1};
      int impact = 0;
      string about = "";
      ulong lasteventid = 0;
      for(int i = 0; i < ArraySize(records); ++i)
      {
         int sign = result[i * p + 1] == Profit? -1 : +1;
         impact += sign * impacts[records[i].impact_type];
         about += StringFormat("%+lld ", sign * (long)records[i].event_id);
         lasteventid = records[i].event_id;
      }
      
      if(impact == 0) return; // 无信号
      ...

通常会同时出现多个新闻发布,所以有必要累积所有新闻的评级。这是在 impact 变量中完成的。由于我们的策略只过滤单个、最高重要性的新闻,所以来自这些新闻的所有单个信号只是简单相加,没有权重系数。about 字符串变量用于为即将进行的交易评论准备文本:在那里会提到导致该交易的事件标识符。

如果机器人在净值账户上启动,或者达到了允许的最大头寸数量,我们将关闭一个头寸。

      PositionFilter positions;
      ulong tickets[];
      positions.let(POSITION_SYMBOL, _Symbol).select(tickets);
      const int n = ArraySize(tickets);
      
      if(n >= (int)(Hedging? MultiplePositions : 1))
      {
         MqlTradeRequestSync position;
         position.close(_Symbol) && position.completed();
      }
      ...

现在可以根据信号开一个新头寸。事件标识符被设置为 “魔术” 数字,这将使我们以后能够在不同类型新闻的背景下分析交易的财务表现。

      MqlTradeRequestSync request;
      request.magic = lasteventid;
      request.comment = about;
      const double ask = SymbolInfoDouble(_Symbol, SYMBOL_ASK);
      const double bid = SymbolInfoDouble(_Symbol, SYMBOL_BID);
      const double point = SymbolInfoDouble(_Symbol, SYMBOL_POINT);
      ulong ticket = 0;
      
      if(impact > 0)
      {
         ticket = request.buy(Lot, 0,
            Distance2SLTP? ask - point * Distance2SLTP : 0,
            Distance2SLTP? ask + point * Distance2SLTP : 0);
      }
      else if(impact < 0)
      {
         ticket = request.sell(Lot, 0,
            Distance2SLTP? bid + point * Distance2SLTP : 0,
            Distance2SLTP? bid - point * Distance2SLTP : 0);
      }
      
      if(ticket && request.completed() && Distance2SLTP)
      {
         for(int i = 0; i < ArraySize(trailing); ++i)
         {
            if(trailing[i][] == NULL) // 寻找用于头寸跟踪对象的空闲槽位
            {
               trailing[i] = new TrailingStop(ticket, Distance2SLTP, Distance2SLTP / 50);
               break;
            }
         }
      }
   }
}

当报价数据更新(tick)时,我们为所有头寸移动止损位。

void OnTick()
{
   for(int i = 0; i < ArraySize(trailing); ++i)
   {
      if(trailing[i][])
      {
         if(!trailing[i][].trail()) // 头寸已平仓
         {
            trailing[i] = NULL; // 释放对象和槽位
         }
      }
   }
}

现在到了最有趣的部分。多亏了测试器,不仅可以总体上分析新闻策略的成功与否,还可以按特定新闻进行细分分析。相应的模块在我们的 OnTester 处理程序中实现。数据收集是使用交易过滤器完成的。从它那里得到交易元组数组,该数组报告了每笔交易的利润、掉期、佣金和魔术数字,我们在三个 MapArray 对象中累积结果:它们分别为每个魔术数字计算利润、亏损和交易数量。

double OnTester()
{
   Print("Trade profits by calendar events:");
   HistorySelect(0, LONG_MAX);
   DealFilter filter;
   int props[] = {DEAL_PROFIT, DEAL_SWAP, DEAL_COMMISSION, DEAL_MAGIC};
   filter.let(DEAL_TYPE, (1 << DEAL_TYPE_BUY) | (1 << DEAL_TYPE_SELL), IS::OR_BITWISE)
      .let(DEAL_ENTRY, (1 << DEAL_ENTRY_OUT) | (1 << DEAL_ENTRY_INOUT) | (1 << DEAL_ENTRY_OUT_BY),
      IS::OR_BITWISE);
   Tuple4<double, double, double, ulong> trades[];
   MapArray<ulong,double> profits;
   MapArray<ulong,double> losses;
   MapArray<ulong,int> counts;
   if(filter.select(props, trades))
   {
      for(int i = 0; i < ArraySize(trades); ++i)
      {
         counts.inc((ulong)trades[i]._4);
         const double payout = trades[i]._1 + trades[i]._2 + trades[i]._3;
         if(payout >= 0)
         {
            profits.inc((ulong)trades[i]._4, payout);
            losses.inc((ulong)trades[i]._4, 0);
         }
         else
         {
            profits.inc((ulong)trades[i]._4, 0);
            losses.inc((ulong)trades[i]._4, payout);
         }
      }
      ...

结果,我们得到一个表格,逐行显示每种类型事件的统计信息:其标识符、国家、货币、总利润或亏损、交易数量(新闻数量)、利润系数和事件名称。

      for(int i = 0; i < profits.getSize(); ++i)
      {
         MqlCalendarEvent event;
         MqlCalendarCountry country;
         const ulong keyId = profits.getKey(i);
         if(cache[].calendarEventById(keyId, event)
            && cache[].calendarCountryById(event.country_id, country))
         {
            PrintFormat("%lld %s %s %+.2f [%d] (PF:%.2f) %s",
               event.id, country.code, country.currency,
               profits[keyId] + losses[keyId], counts[keyId],
               profits[keyId] / (losses[keyId] != 0? -losses[keyId] : DBL_MIN),
               event.name);
         }
         else
         {
            Print("undefined ", DoubleToString(profits.getValue(i), 2));
         }
      }
   }
   return 0;
}

为了测试这个想法,让我们在 2021 年初(到 2022 年年中)期间,对 EURUSD 货币对运行这个智能交易系统。下面是一个带有 OnTester 打印输出的日志片段。

Trade profits by calendar events:
840040001 US USD -21.81 [17] (PF:0.53) ISM Manufacturing PMI
840190001 US USD -10.95 [17] (PF:0.69) ADP Nonfarm Employment Change
840200001 US USD -67.09 [78] (PF:0.60) EIA Crude Oil Stocks Change
999030003 EU EUR +14.13 [19] (PF:1.46) Retail Sales m/m
840040003 US USD -17.12 [18] (PF:0.59) ISM Non-Manufacturing PMI
840030016 US USD -1.20 [19] (PF:0.97) Nonfarm Payrolls
840030021 US USD +5.25 [14] (PF:1.21) JOLTS Job Openings
840020010 US USD -14.63 [17] (PF:0.63) Retail Sales m/m
276070001 DE EUR -22.71 [17] (PF:0.47) ZEW Economic Sentiment Indicator
840020005 US USD +10.76 [18] (PF:1.37) Building Permits
840120001 US USD -20.78 [17] (PF:0.49) Existing Home Sales
276030003 DE EUR +18.57 [17] (PF:1.87) Ifo Business Climate
840180002 US USD -3.22 [14] (PF:0.89) CB Consumer Confidence Index
840020014 US USD -8.74 [16] (PF:0.74) Core Durable Goods Orders m/m
840020008 US USD -14.54 [16] (PF:0.63) New Home Sales
250010005 FR EUR +0.66 [10] (PF:1.03) GDP q/q
840010007 US USD +0.99 [15] (PF:1.04) GDP q/q
840120003 US USD +4.53 [18] (PF:1.15) Pending Home Sales m/m
276010008 DE EUR -0.72 [10] (PF:0.97) GDP q/q
999030016 EU EUR -14.04 [14] (PF:0.59) GDP q/q
999030001 EU EUR +1.30 [2] (PF:1.35) Employment Change q/q

结果并不是非常令人印象深刻。不过,新闻交易充满了主观性。首先,对新闻实际价值对汇率影响的理论评估,可能与市场大众的情绪预期或额外的信息背景(这些信息不在日历范围内且无法量化)有所不同。其次,我们已经提到过新闻实际发布时间的不准确性。第三,我们的策略是以最简单的形式实现的,没有分析价格的前期走势(可能存在消息泄露,并且新闻在之前就已经被市场消化了)。

总体而言,这次测试发现,交易员们青睐的非农就业报告或国内生产总值(GDP)报告并不能保证交易成功,至少在我们的默认设置下是这样。接下来,需要按照常规方式分析每一笔交易,找出问题所在,选择合适的参数,并改进算法,特别是要添加一个时间调整模块,用于处理服务器时区夏令时的切换。

同时,这种技术本身运行良好,我们可以先尝试选择最有可能成功的新闻。例如,我们选取新闻 276030003(德国 IFO 商业景气指数)。将其设置到 EventID 中,我们将得到以下报告,与我们计算的指标相符。

基于德国 IFO 商业景气指数新闻的测试器交易报告

基于德国 IFO 商业景气指数新闻的测试器交易报告

你也可以尝试对一组类似的事件进行交易。特别是,如果只想对(不同国家的)GDP 新闻做出反应,可以在 Text 变量中输入字符串 “GDP”。添加星号是因为如果不添加,一个 3 字符的字符串会被过滤器类视为一种货币。除了 2 字符(国家代码)或 3 字符(货币代码)之外,任何长度的字符串都可以按原样指定,例如 “farm”、“Nonfarm”、“Sales” —— 过滤器会将它们作为名称的子字符串进行搜索,并且区分大小写。