创建多交易品种 EA 交易

迄今为止,在本书的框架内,我们主要分析了在图表的当前工作交易品种上进行的 EA 交易示例。但是,MQL5 允许你为任何 Market Watch 交易品种生成交易订单,而与图表的工作交易品种无关。

事实上,前面几节中的许多示例都有一个 symbol 输入参数,你可以在其中指定一个任意交易品种。默认情况下,有一个空字符串,视为图表的当前交易品种。所以,我们已经考虑过下面的示例:

你可以尝试使用不同的交易品种运行这些示例,并确保交易操作的执行与本地交易品种完全相同。

此外,正如我们在 OnBookEven OnTradeTransaction 事件的说明中所看到的,它们是通用的,用于告知有关任意交易品种的交易环境变化。但对于 OnTick 事件而言,情况并非如此,仅在当前交易品种的新价格发生变化时才会生成该事件。通常这没有问题,但高频多币种交易需要采取额外的技术步骤,例如为其他交易品种订阅 OnBookEvent 事件或设置高频计时器。另一个绕过这种限制的选项是间谍指标 EventTickSpy.mq5,我们曾经在 生成自定义事件中介绍过。

在讨论多交易品种交易支持的上下文中,应注意的是,多时间范围 EA 交易的类似概念并不完全正确。在新的柱线打开时间进行交易只是根据任意时段对分时报价进行分组的特例,不一定是标准交易。当然,由于 iTime(_Symbol, PERIOD_XX, 0) 之类的函数,系统内核简化了对特定时间范围内出现的新柱线分析,但这种分析无论如何都是基于分时报价的。

你可以在你的 EA 交易中按分时报价数量,价格范围等构建虚拟柱线。在某些情况下,包括为了清楚起见,在 EA 交易之外以 自定义交易品种显式生成此类“时间范围”也是合理的。但是这种方法也有其局限性:我们将在本书的下一部分讨论。

但是,如果交易系统仍然需要基于柱线开盘价的报价分析或者使用多币种指标,那么就应等待所有相关工具上的柱线同步。我们在 跟踪柱线形成一节中提供了一个能执行次任务的类的示例。

开发多交易品种 EA 交易时,当务之急是将通用的交易算法分成不同的块。这些块可以随后应用于具有不同设置的各种交易品种。实现这一点的最合理的方法是在面向对象的编程 (OOP) 概念的框架内阐明一个或多个类。

我们用一个 EA 交易示例来说明这个方法,EA 交易采用了众所周知的鞅策略。众所周知,鞅策略本身是有风险的,因为它在每次交易失败后都会翻倍,以弥补之前的损失。降低这种风险至关重要,一个有效的方法是同时交易多个交易品种,最好是相关性弱的交易品种。通过这种方式,一种金融工具的暂时亏损可能会被其他工具的收益所抵消。

将各种金融工具(或单个交易系统中的不同设置,或甚至不同的交易系统)合并到 EA 交易中,有助于减少单个交易失败的整体影响。本质上,工具或系统越是多样化,其交易组成的孤立失败对最终结果的影响就越小。

我们调用一个新的 EA 交易 MultiMartingale.mq5。交易算法设置包括:

  • UseTime UseTime - 启用/禁用预定交易的逻辑标志
  • HourStart HourStart 和 Hour End - 如果 UseTime 为真,允许交易的时间范围(小时) true
  • Lots Lots - 系列中第一笔交易的交易量
  • Factor Factor - 损失后后续交易量增加的系数
  • Limit Limit - 交易量倍增时亏损系列的最大交易量(之后,返回初始手数)
  • Stop Loss Stop Loss 和 Take Profit - 到保护水平的距离(点数)
  • StartType StartType - 第一笔交易的类型(买入或卖出)
  • Trailing Trailing - 跟踪止损的指示

在源代码中,其说明如上所示。

input bool UseTime = true;      // UseTime (hourStart and hourEnd)
input uint HourStart = 2;       // HourStart (0...23)
input uint HourEnd = 22;        // HourEnd (0...23)
input double Lots = 0.01;       // Lots (initial)
input double Factor = 2.0;      // Factor (lot multiplication)
input uint Limit = 5;           // Limit (max number of multiplications)
input uint StopLoss = 500;      // StopLoss (points)
input uint TakeProfit = 500;    // TakeProfit (points)
input ENUM_POSITION_TYPE StartType = 0; // StartType (first order type: BUY or SELL)
input bool Trailing = true;     // Trailing

理论上,合理的做法是建立保护水平,不是用点数,而是用平均真实范围指标(ATR)的份额。但目前这并不是首要任务。

此外,EA 交易还集成了一种机制,用于在出现错误的情况下,在用户指定的时间内(由参数 SkipTimeOnError 控制)暂停交易操作。我们在此处省略这方面的详细讨论,因为它可以在源代码中引用。

为了将整个配置集合并到一个统一的实体中,定义了一个名为Settings 的结构体。该结构体具有镜像输入变量的字段。此外,该结构体包括 symbol 字段,解决了其策略的多币种特性问题。换言之,交易品种可以是任意的,可与图表上的工作交易品种不同。

struct Settings
{
   bool useTime;
   uint hourStart;
   uint hourEnd;
   double lots;
   double factor;
   uint limit;
   uint stopLoss;
   uint takeProfit;
   ENUM_POSITION_TYPE startType;
   ulong magic;
   bool trailing;
   string symbol;
   ...
};

在最初的开发阶段,我们用输入变量填充该结构体。但是,这只够交易单一的交易品种。随后,当我们扩展算法以包含多个交易品种时,就需要读取不同的设置集(使用不同的方法)并将它们附加到一个结构体数组中。

该结构体还包含几种有益的方法。具体而言,validate 方法用于验证设置的正确性,确认指定交易品种的存在,并返回一个成功指示符 (true)。

struct Settings
{
   ...
   bool validate()
   {
 ...// checking the lot size and protective levels (see the source code)
      
      double rates[1];
      const bool success = CopyClose(symbolPERIOD_CURRENT01rates) > -1;
      if(!success)
      {
         Print("Unknown symbol: "symbol);
      }
      return success;
   }
   ...
};

调用 CopyClose 不仅可以检查 Market Watch 中的交易品种是否在线,还会在测试程序中加载其报价(所需时间范围的)和分时报价。如果不这样做,默认情况下,测试程序中仅能提供当前所选工具和时间范围的报价和分时报价(在真实分时报价模式下)。由于我们正在编写一个多币种 EA 交易,将需要第三方报价和分时报价。

struct Settings
{
   ...
   void print() const
   {
      Print(symbol, (startType == POSITION_TYPE_BUY ? "+" : "-"), (float)lots,
        "*", (float)factor,
        "^"limit,
        "("stopLoss","takeProfit")",
        useTime ? "[" + (string)hourStart + "," + (string)hourEnd + "]""");
   }
};

print 方法可在一行中以缩写形式将所有字段输出到日志中。例如,

EURUSD+0.01*2.0^5(500,1000)[2,22]
|     | |   |   |  |    |   |  |
|     | |   |   |  |    |   |  `until this hour trading is allowed
|     | |   |   |  |    |   `from this hour trading is allowed
|     | |   |   |  |    `take profit in points
|     | |   |   |  `stop loss in points
|     | |   |   `maximum size of a series of losing trades (after '^')
|     | |   `lot multiplication factor (after '*')
|     | `initial lot in series
|     `+ start with Buy
|     `- start with Sell
`instrument

当我们转向多币种时,需要 Settings 结构体中的其他方法。现在,我们想象一个简化版的 EA 交易在一个交易品种上进行交易的处理程序 OnInit

int OnInit()
{
   Settings settings =
   {
      UseTimeHourStartHourEnd,
      LotsFactorLimit,
      StopLossTakeProfit,
      StartTypeMagicSkipTimeOnErrorTrailing_Symbol
   };
   
   if(settings.validate())
   {
      settings.print();
      ...
      // here you will need to initialize the trading algorithm with these settings
   }
   ...
}

遵循面向对象的程序设计,广义的交易系统应描述为一个软件接口。同样,为了简化该示例,我们将在这个接口上仅使用一种方法:trade

interface TradingStrategy
{
   virtual bool trade(void);
};

该算法的主要任务是交易,我们决定从何处调用这个方法并不重要:在 OnTick 的每个分时报价处,在柱线打开时,或者可能在计时器上。

你的工作 EA 交易很可能需要额外的接口方法来设置和支持各种模式。但是在这个示例中不需要。

我们开始创建一个基于接口的特定交易系统的类。在我们的示例中,所有的实例都是 SimpleMartingale 类。但是,也可以在一个 EA 交易中实现许多继承接口的不同类,然后按照任意组合方式统一使用它们。策略组合(最好在特性上区别很大)通常以财务表现的稳定性增加为特性。

class SimpleMartingalepublic TradingStrategy
{
protected:
   Settings settings;
   SymbolMonitor symbol;
   AutoPtr<PositionStateposition;
   AutoPtr<TrailingStoptrailing;
   ...
};

在这个类中,我们看到了一个熟悉的 Settings 结构体和工作交易品种监视器 SymbolMonitor。此外,我们还需要控制仓位的存在,并遵循其止损水平,为此我们引入了带有自动指针的变量,这些自动指针指向对象 PositionState TrailingStop 。使用自动指针,我们在代码中不必担心对象的显式删除,因为当控件退出该范围时,或者当新的指针被赋给自动指针时,可自动完成。

TrailingStop 类是一个基础类,具有最简单的价格跟踪实现,从中可以继承许多更复杂的算法,我们认为其是一个派生的 TrailingStopByMA。因此,为了让程序在将来具有灵活性,最好确保调用代码可以传递自己特定的、定制的跟踪对象,该对象是从 TrailingStop 派生的。例如,这可以通过向构造函数传递一个指针或者将 SimpleMartingale 转换成一个模板类(然后跟踪类将由模版参数设置)来实现。
 
OOP 的这一原则被称为 dependency injection,其和许多其他原则一起被广泛使用(我们在 OOP 的理论基础:组合中简要介绍过)。

这些设置作为构造函数参数传递给策略类。基于此,我们可为所有内部变量赋值。

class SimpleMartingalepublic TradingStrategy
{
   ...
   double lotsStep;
   double lotsLimit;
   double takeProfitstopLoss;
public:
   SimpleMartingale(const Settings &state) : symbol(state.symbol)
   {
      settings = state;
      const double point = symbol.get(SYMBOL_POINT);
      takeProfit = settings.takeProfit * point;
      stopLoss = settings.stopLoss * point;
      lotsLimit = settings.lots;
      lotsStep = symbol.get(SYMBOL_VOLUME_STEP);
      
      // calculate the maximum lot in the series (after a given number of multiplications)
      for(int pos = 0pos < (int)settings.limitpos++)
      {
         lotsLimit = MathFloor((lotsLimit * settings.factor) / lotsStep) * lotsStep;
      }
      
      double maxLot = symbol.get(SYMBOL_VOLUME_MAX);
      if(lotsLimit > maxLot)
      {
         lotsLimit = maxLot;
      }
      ...

接下来,我们使用 PositionFilter 对象来搜索现有的“自己的”仓位(通过魔术编号和交易品种)。如果找到了此类仓位,即可创建 PositionState 对象,如果需要,还可以创建 TrailingStop 对象。

      PositionFilter positions;
      ulong tickets[];
      positions.let(POSITION_MAGICsettings.magic).let(POSITION_SYMBOLsettings.symbol)
         .select(tickets);
      const int n = ArraySize(tickets);
      if(n > 1)
      {
         Alert(StringFormat("Too many positions: %d"n));
      }
      else if(n > 0)
      {
         position = new PositionState(tickets[0]);
         if(settings.stopLoss && settings.trailing)
         {
           trailing = new TrailingStop(tickets[0], settings.stopLoss,
              ((int)symbol.get(SYMBOL_SPREAD) + 1) * 2);
         }
      }
   }

时间表操作将暂时留在 trade 方法(useTimehourStarthourEnd 参数字段)的“幕后”。我们直接来看交易算法。

如果不存在并且还没有任何仓位,PositionState 指针将为零,需要根据选择的方向 startType 建立多头或空头仓位。

   virtual bool trade() override
   {
      ...
      ulong ticket = 0;
      
      if(position[] == NULL)
      {
         if(settings.startType == POSITION_TYPE_BUY)
         {
            ticket = openBuy(settings.lots);
         }
         else
         {
            ticket = openSell(settings.lots);
         }
      }
      ...

此处使用了辅助方法 openBuyopenSell。我们将在几个段落中进行讨论。现在,我们只需要知道他们是否在成功时返回订单号,失败时返回 0。

如果 position 对象已经包含关于被跟踪仓位的信息,可通过调用 refresh 来检查其是否是活动的。如果成功 (true),则通过调用 update 更新仓位信息,还可以根据设置的要求来跟踪止损。

      else // position[] != NULL
      {
         if(position[].refresh()) // does position still exists?
         {
            position[].update();
            if(trailing[]) trailing[].trail();
         }
         ...

如果仓位被平仓,refresh 将返回 false,我们可在另一个 if 分支中打开一个新的仓位:如果利润是固定的,则在相同的方向上打开;如果发生了亏损,则在相反的方向上打开。请注意,我们在缓存中仍然有先前仓位的快照。

         else // the position is closed - you need to open a new one
         {
            if(position[].get(POSITION_PROFIT) >= 0.0
            {
              // keep the same direction:
              // BUY in case of profitable previous BUY
              // SELL in case of profitable previous SELL
               if(position[].get(POSITION_TYPE) == POSITION_TYPE_BUY)
                  ticket = openBuy(settings.lots);
               else
                  ticket = openSell(settings.lots);
            }
            else
            {
               // increase the lot within the specified limits
               double lots = MathFloor((position[].get(POSITION_VOLUME) * settings.factor) / lotsStep) * lotsStep;
   
               if(lotsLimit < lots)
               {
                  lots = settings.lots;
               }
             
               // change the trade direction:
               // SELL in case of previous unprofitable BUY
               // BUY in case of previous unprofitable SELL
               if(position[].get(POSITION_TYPE) == POSITION_TYPE_BUY)
                  ticket = openSell(lots);
               else
                  ticket = openBuy(lots);
            }
         }
      }
      ...

在这个最后阶段出现非零订单号意味着我们必须开始用新对象 PositionStateTrailingStop 进行控制。

      if(ticket > 0)
      {
         position = new PositionState(ticket);
         if(settings.stopLoss && settings.trailing)
         {
            trailing = new TrailingStop(ticketsettings.stopLoss,
               ((int)symbol.get(SYMBOL_SPREAD) + 1) * 2);
         }
      }
  
      return true;
    }

我们现在用一些缩写来表示 openBuy 方法(openSell 也是一样的)。有三个步骤:

  • 使用 prepare 方法准备 MqlTradeRequestSync 结构体(此处未显示,其填充了 deviationmagic
  • 使用 request.buy 方法调用发送订单
  • postprocess 方法检查结果(此处未显示,其调用 request.completed,如果出现错误,交易暂停期并从预期的更好条件开始);

   ulong openBuy(double lots)
   {
      const double price = symbol.get(SYMBOL_ASK);
      
      MqlTradeRequestSync request;
      prepare(request);
      if(request.buy(settings.symbollotsprice,
         stopLoss ? price - stopLoss : 0,
         takeProfit ? price + takeProfit : 0))
      {
         return postprocess(request);
      }
      return 0;
   }

通常情况下,仓位会被止损或止盈平仓。但是,我们支持可能导致平仓的预定操作。我们回到预定工作的 trade 方法的开始。

   virtual bool trade() override
   {
      if(settings.useTime && !scheduled(TimeCurrent())) // time out of schedule?
      {
         // if there is an open position, close it
         if(position[] && position[].isReady())
         {
            if(close(position[].get(POSITION_TICKET)))
            {
                                // at the request of the designer:
               position = NULL// clear the cache or we could...
               // do not do this zeroing, that is, save the position in the cache,
               // to transfer the direction and lot of the next trade to a new series
            }
            else
            {
               position[].refresh(); // guaranteeing reset of the 'ready' flag
            }
         }
         return false;
      }
      ...// opening positions (given above)
   }

工作方法 closeopenBuy 高度相似,所以我们此处不予考虑。另一种方法 scheduled 仅返回 truefalse,具体取决于当前时间是否在指定的工作时间范围内(hourStarthourEnd)。

到此,交易类准备就绪。但是对于多币种工作,你需要创建几个副本。TradingStrategyPool 类负责对其管理,其中我们说明了指向 TradingStrategy 的指针数组及其补充方法:参数型构造函数 和 push

class TradingStrategyPoolpublic TradingStrategy
{
private:
   AutoPtr<TradingStrategypool[];
public:
   TradingStrategyPool(const int reserve = 0)
   {
      ArrayResize(pool0reserve);
   }
   
   TradingStrategyPool(TradingStrategy *instance)
   {
      push(instance);
   }
   
   void push(TradingStrategy *instance)
   {
      int n = ArraySize(pool);
      ArrayResize(pooln + 1);
      pool[n] = instance;
   }
   
   virtual bool trade() override
   {
      for(int i = 0i < ArraySize(pool); i++)
      {
         pool[i][].trade();
      }
      return true;
   }
};

没有必要使策略池从 TradingStrategy 接口派生,但是如果我们这样做,则允许将来将策略池打包到其他更大的策略池中。trade 方法仅为在所有数组对象上调用相同的方法。

在全局上下文中,我们向交易池添加一个自动指针,在 OnInit 处理程序中,我们应确保其填充。我们可以从一个单一的策略开始(稍后我们将处理多币种问题)。

AutoPtr<TradingStrategyPoolpool;
   
int OnInit()
{
   ... // settings initialization was given earlier
   if(settings.validate())
   {
      settings.print();
      pool = new TradingStrategyPool(new SimpleMartingale(settings));
      return INIT_SUCCEEDED;
   }
   else
   {
      return INIT_FAILED;
   }
   ...
}

要开始交易,我们只需要编写以下小处理程序 OnTick

void OnTick()
{
   if(pool[] != NULL)
   {
      pool[].trade();
   }
}

但是多币种支持怎么处理呢?

当前的输入参数集仅适用于一种金融工具。我们可以利用这一点来测试并优化单个交易品种的 EA 交易,但在为所有交易品种找到最佳设置后,需要以某种方式将它们组合起来并传递给算法。

在这种情况下,我们应用最简单的解决方案。上面的代码包含一行由 Settings 结构体生成的 print 方法形成的设置。我们可在 parse 结构体中实现该方法,该结构体可执行相反操作:通过行说明恢复字段的状态。此外,由于我们需要连接不同字符的几个设置,因此同意可以通过一个特殊的分隔符将它们连接成一个长字符串,例如 ';'。这样一来,编写 parseAll 静态方法来读取合并后的设置集就很简单了,该方法将调用 parse 来填充通过引用传递的 Settings 结构体数组。这些方法的完整源代码可以在附件中找到。

struct Settings
{
   ...
   bool parse(const string &line);
   void static parseAll(const string &lineSettings &settings[])
   ...
};  

例如,以下串联字符串包含三个交易品种的设置。

EURUSD+0.01*2.0^7(500,500)[2,22];AUDJPY+0.01*2.0^8(300,500)[2,22];GBPCHF+0.01*1.7^8(1000,2000)[2,22]

parseAll 方法可以解析的就是这类行。为了将此类字符串输入到 EA 交易中,我们定义了输入变量 WorkSymbols

input string WorkSymbols = ""// WorkSymbols (name±lots*factor^limit(sl,tp)[start,stop];...)

如果为空,EA 交易将使用之前提供的各个输入变量的设置。如果指定了字符串,OnInit 处理程序将根据解析该行的结果填充交易系统池。

int OnInit()
{
   if(WorkSymbols == "")
   {
      ...// work with the current single character, as before
   }
   else
   {
      Print("Parsed settings:");
      Settings settings[];
      Settings::parseAll(WorkSymbolssettings);
      const int n = ArraySize(settings);
      pool = new TradingStrategyPool(n);
      for(int i = 0i < ni++)
      {
         settings[i].trailing = Trailing;
         // support multiple systems on one symbol for hedging accounts
         settings[i].magic = Magic + i;  // different magic numbers for each subsystem
         pool[].push(new SimpleMartingale(settings[i]));
      }
   }
   return INIT_SUCCEEDED;
}

需要注意的是,在 MQL5 中,输入字符串的长度被限制为 250 个字符。此外,在测试程序的优化过程中,字符串被进一步截断到最多 63 个字符。因此,为了优化跨多个交易品种的同步交易,必须设计一种替代方法来加载该设置,比如从文本文件中检索设置。如果用文件名而不是包含设置的字符串来指定输入变量,这可以通过使用相同的输入变量轻松实现。

这种方法在上面提到的 Settings::parseAll 方法中实现。根据适用于所有类似情况的通用原则来设置文本文件的名称,在该文本文件中,输入字符串可无长度限制地传递给 EA 交易:文档名以 EA 交易名称开始,然后,在连字符之后,必须有文件包含其数据的变量名称。例如,就我们的情况而言,在 WorkSymbols 输入变量中,你可以选择指定文件名 "MultiMartingale-WorkSymbols.txt"。然后 parseAll 方法将尝试从该文件中读取文本(其应在标准 MQL5/Files 沙箱中)。

在输入参数中传递文件名需要采取额外的步骤来进一步测试和优化此类 EA 交易:应将 #property tester_file "MultiMartingale-WorkSymbols.txt" 指令添加到源代码中。这将在 测试程序预处理器指令一节中详细讨论。添加该指令后,EA 交易将要求该文件存在,并且不会在测试程序中没有该文件的情况下启动!

EA 交易准备就绪。我们可以分别针对不同的交易品种进行测试,为每个交易品种选择最佳设置,并建立一个交易组合。在下一章中,我们将研究测试程序的 API,包括优化,这个 EA 交易马上就能使用了。同时,我们检查一下其多币种运算。

WorkSymbols=EURUSD+0.01*1.2^4(300,600)[9,11];GBPCHF+0.01*2.0^7(300,400)[14,16];AUDJPY+0.01*2.0^6(500,800)[18,16]

在 2022 年第一季度,我们将收到以下报告(MetaTrader 5 报告不提供按交易品种细分的统计数据,因此仅通过交易/订单/仓位表即可区分单币种报告和多币种报告)。

多币种鞅策略 EA 交易的测试程序报告

多币种鞅策略 EA 交易的测试程序报告

需要注意的是,由于策略是从 OnTick 处理程序中启动的,所以在不同的主交易品种(即在测试程序设置下拉菜单中选择的交易品种)上运行将会产生略微不同的结果。在我们的测试中,我们简单地将 EURUSD 作为流动性最强、最常用的分时报价金融工具,这对于大多数应用而言已经足够了。但是,如果你想要对所有工具的分时报价做出反应,你可以使用类似 EventTickSpy.mq5 的指标。或者,你可以在计时器上运行交易逻辑,而不必绑定到特定工具的分时报价。

这是一个单一交易品种的交易策略,在这个示例中为 AUDJPY。

多币种鞅策略 EA 交易测试图

多币种鞅策略 EA 交易测试图

顺便说一下,对于所有的多币种 EA 交易而言,还有一个重要的问题没有被注意到。我们讨论的是选择手数大小的方法,例如,基于存款或风险的负载。之前,我们在非交易性 EA 交易 LotMarginExposureTable.mq5 中演示过此类计算的例子。在 MultiMartingale.mq5 中,我们通过选择并在每个交易品种的设置中显示固定手数来简化任务。但是,在进行多币种 EA 交易中,根据工具价值(通过保证金或波动性)按比例选择手数是有意义的。

总之,我想指出的是,多币种策略可能需要不同的优化原则。所考虑的策略使得有可能分别找到交易品种的参数,然后将它们组合起来。但是,一些套利和集群策略(例如,配对交易)是基于所有工具的同步分析来制定交易决策的。在这种情况下,与所有交易品种相关的设置均应单独包含在输入参数中。