日历交易

新闻交易策略多种多样:有使用市价单或挂单的,有分析财经指标(价格运动方向)的,也有不分析指标(捕捉波动性)的。此外,在许多其他交易系统中加入一个反新闻筛选器也很有用。由于 MQL5 日历在测试程序中不可用,因此优化和调试所有此类程序都很困难。然而,借助上一节中开发的缓存,我们可以纠正这种情况。

我们尝试创建一个 EA 交易,它将在新闻发布时,根据对其价格影响的评估进入市场。刚刚使用指标 CalendarMonitorCached.mq5 创建了 "xyz.cal" 缓存文件。

回想一下,缓存中的日历镜像始终对应于保存的时刻,并在读取时需要谨慎:对于较晚的事件,实际指标是未知的,而更久远的事件可能根本不存在。在下次优化或测试之前,你应该定期更新日历缓存文件。
 
如有必要,还应考虑一年中的 DST 时间设置:如果事件的 DST 模式与保存日历存档时的 DST 不同,你将需要将时间向前或向后调整 1 小时。通过选择没有 DST 的经纪人或在 H1 以上的时间范围上构建策略,可以避免这些困难。

EA 交易 CalendarTrading.mq5 将仅交易满足以下条件的新闻事件:

  • 与图表的工作交易品种相关
  • 类型为财经指标(即量化指标)
  • 重要性高
  • 刚刚收到指标的当前值

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

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

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

input double Volume;               // Volume (0 = minimal lot)
input int Distance2SLTP = 500;     // Distance to SL/TP in points (0 = no)
input uint MultiplePositions = 25;

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

可选地,EA 交易支持两种类型的筛选器:新闻类型标识符,按标题搜索文本。

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 处理程序中,我们加载日历缓存并按照如上所述配置筛选器。在线图表中允许不启用缓存:然后 EA 交易以实战模式工作,直接与日历交互。在测试程序中,缺少缓存文件将阻止 EA 交易启动。

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 a specific type of event is set, we look only at it
   if(EventID > 0) f.let(EventID);
   else
   {
      // otherwise follow the news on the currencies of the current symbol
      f.let(Base);
      if(Base != Profit)
      {
         f.let(Profit);
      }
      
      // financial indicators, high importance, actual value
      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);
   }
   // check the news filter and start trading on it by a second timer
   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)) // find changes that undergo filtering
   {
      // output properties of changed news to the log
      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; // no signal
      ...

通常会有几条新闻同时发布,因此有必要累积所有这些新闻的评级。这一操作在 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) // looking for a free slot for the position tracking object
            {
               trailing[i] = new TrailingStop(ticket, Distance2SLTP, Distance2SLTP / 50);
               break;
            }
         }
      }
   }
}

我们在每个分时报价到来时移动所有仓位的止损。

void OnTick()
{
   for(int i = 0; i < ArraySize(trailing); ++i)
   {
      if(trailing[i][])
      {
         if(!trailing[i][].trail()) // position was closed
         {
            trailing[i] = NULL; // release object and slot
         }
      }
   }
}

现在到了最有趣的部分。多亏了测试程序,不仅可以总体分析新闻策略的成功与否,还可以按特定新闻进行细分分析。相应的功能块在我们的 OnTester 处理程序中实现。数据收集是使用交易筛选器执行的。通过交易筛选器可以获得元组数组 trades,其中报告了每笔交易的利润、隔夜利息、佣金和魔术数字,此后,我们在三个 MapArray 对象中累积结果:它们分别为每个 magic 数字单独计算利润、亏损和交易数量。

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;
}

为了测试这个想法,我们在 EURUSD 货币对上运行 EA 交易,时间段从 2021 年初到 2022 年中。以下是从 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",筛选器将它们作为名称的子字符串进行搜索,区分大小写。