Skip to content

创建智能交易系统

在本章中,我们开始学习用于实现智能交易系统的 MQL5 交易 API。就无差错编码以及所涉及技术的数量和多样性而言,这种类型的程序可能是最为复杂且要求最高的。特别是,我们将需要运用在前几章中所学到的许多技能,从面向对象编程(OOP)到处理图形对象、指标、交易品种以及软件环境设置的应用方面的技能。

根据所选择的交易策略,智能交易系统的开发者可能需要特别关注以下方面:

  • 决策和订单发送速度(对于高频交易,即 HFT)
  • 基于交易品种的相关性和波动率选择最优的交易品种投资组合(对于聚类交易)
  • 动态计算手数以及订单之间的距离(对于马丁格尔策略和网格策略)
  • 对新闻或外部数据源的分析(这将在本书的第七部分进行讨论)

开发者应该将所有这些特性以最优的方式应用于 MQL5 API 所提供的上述交易机制中。

接下来,我们将详细探讨用于管理交易活动的内置函数、智能交易系统的事件模型以及特定的数据结构,并回顾终端与服务器之间交互的基本原则,还有 MetaTrader 5 中算法交易的基本概念:订单、交易和头寸。

同时,由于相关内容的通用性,智能交易系统开发中的许多重要细节,比如测试和优化,将在下一章中重点阐述。

我们之前已经考虑过各种类型的 MQL 程序(包括智能交易系统)的设计,以及程序启动和停止的特性。尽管智能交易系统是在特定的图表上启动的,并且为该图表定义了一个工作交易品种,但对任意一组金融交易品种的交易进行集中管理并没有障碍。传统上,这类智能交易系统被称为多货币智能交易系统,不过实际上,它们的投资组合可能包括差价合约(CFD)、股票、大宗商品以及其他市场的行情代码。

在智能交易系统以及指标中,都有关键事件 OnInit 和 OnDeinit。它们并非是必需的,但通常情况下,为了程序的准备和正常结束,代码中都会包含它们:我们在示例中使用了这些事件,并且在后续的示例中还会继续使用。在单独的一个章节中,我们对所有事件处理函数进行了概述:到目前为止,我们已经详细学习了其中的一些函数(例如,指标的 OnCalculate 事件和 OnTimer 定时器)。本章将描述智能交易系统特定的事件(OnTick、ontrade、OnTradeTransaction)。

智能交易系统可以使用范围极为广泛的源数据作为交易信号:行情报价、报价数据、市场深度、交易账户历史记录或指标读数。对于最后一种情况,创建指标实例以及从其缓冲区读取值的原则与在 “从 MQL 程序中使用现成指标” 一章中所讨论的原则并无不同。在接下来的章节中的智能交易系统示例里,我们将展示其中的大部分技巧。

需要注意的是,交易函数不仅可以在智能交易系统中使用,也可以在脚本中使用。我们将看到这两种情况的示例。

智能交易系统的主要事件:OnTick

当包含智能交易系统(Expert Advisors)所运行的当前图表的交易品种价格的新报价(tick)出现时,终端会为智能交易系统生成 OnTick 事件。为了处理这个事件,必须在智能交易系统的代码中定义 OnTick 函数,其原型如下:

c
void OnTick(void)

如你所见,该函数没有参数。如果需要,新价格的具体值以及其他报价特征应该通过调用 SymbolInfoTick 来获取。

从对新报价事件的响应角度来看,这个处理函数类似于指标中的 OnCalculate 函数。然而,OnCalculate 只能在指标中定义,而 OnTick 只能在智能交易系统中定义(更准确地说,指标、脚本或服务代码中的 OnTick 函数将被简单地忽略)。

同时,智能交易系统不一定要包含 OnTick 处理函数。除了这个事件之外,智能交易系统还可以处理 OnTimerOnBookEventOnChartEvent 事件,并从这些事件中执行所有必要的交易操作。

智能交易系统中的所有事件都是按照它们到达的顺序依次处理的,因为智能交易系统和所有其他 MQL 程序一样,是单线程的。如果队列中已经有一个 OnTick 事件,或者正在处理这样一个事件,那么新的 OnTick 事件将不会被排队。

无论自动交易是禁用还是启用(终端界面中的算法交易按钮),都会生成 OnTick 事件。禁用自动交易仅意味着限制智能交易系统发送交易请求,但不会阻止智能交易系统的运行。

应该记住,报价事件仅针对一个交易品种生成,即当前图表的交易品种。如果智能交易系统是多货币的,那么从其他交易品种获取报价应该通过一些替代方式来组织,例如,使用间谍指标 EventTickSpy.mq5 或像 MarketBookQuasiTicks.mq5 中那样订阅市场深度事件。

作为一个简单的示例,我们来看智能交易系统 ExpertEvents.mq5。它定义了通常用于启动交易算法的所有事件的处理函数。我们将在后面研究一些其他事件(OnTradeOnTradeTransaction 以及测试器事件)。

所有处理函数都调用显示辅助函数,该函数在多行注释中输出当前时间(毫秒系统计数器标签)和处理函数名称。

c
#define N_LINES 25
#include <MQL5Book/Comments.mqh>

void Display(const string message)
{
    ChronoComment((string)GetTickCount() + ": " + message);
}

OnTick 事件将在新报价到达时自动调用。对于定时器和订单簿事件,需要在 OnInit 中使用 EventSetTimerMarketBookAdd 调用来激活相应的处理函数。

c
void OnInit()
{
    Print(__FUNCTION__);
    EventSetTimer(2);
    if(!MarketBookAdd(_Symbol))
    {
        Print("MarketBookAdd failed:", _LastError);
    }
}

void OnTick()
{
    Display(__FUNCTION__);
}

void OnTimer()
{
    Display(__FUNCTION__);
}

void OnBookEvent(const string &symbol)
{
    if(symbol == _Symbol) // 仅对“我们的”交易品种的订单簿做出反应
    {
        Display(__FUNCTION__);
    }
}

图表变化事件也是可用的:它可以用于基于图形对象的标记进行交易,通过按下按钮或热键,以及在从其他程序(例如像 EventTickSpy.mq5 这样的指标)接收到自定义事件时进行交易。

c
void OnChartEvent(const int id, const long &lparam, const double &dparam, const string &sparam)
{
    Display(__FUNCTION__);
}

void OnDeinit(const int)
{
    Print(__FUNCTION__);
    MarketBookRelease(_Symbol);
    Comment("");
}

以下屏幕截图显示了智能交易系统在图表上的操作结果。

智能交易系统中各种类型事件的注释

请注意,OnBookEvent 事件(如果针对某个交易品种广播)的到达频率比 OnTick 事件更高。

基本原则与概念:订单、交易和持仓

在开始学习 MQL5 中智能交易系统(EA)的开发之前,让我们回顾一下平台的总体架构以及规范交易活动的基本概念。

MetaTrader 5 是一个客户端终端,它连接到由经纪商、交易商或交易所的计算机组成的多层服务器部分。一旦用户填写了执行交易的订单,该订单会经过多个转发和验证阶段,之后由交易商或交易所进行登记或拒绝。然后,在市场中登记的订单可能会根据诸如流动性、价格变化率、品种交易暂停或技术问题等情况,被执行或不被执行。

交易请求处理的一般方案

此处,绿色箭头表示交易操作从终端到市场的成功执行过程,红色箭头表示潜在的拒绝情况。

由 MQL 程序生成的订单也会经历类似的过程。如果结果不理想,MQL5 API 将允许我们通过错误代码了解失败的原因。

这整个过程通过三个基本术语来表达(并记录在报告中):订单、交易和持仓。

  • 订单:订单是交易者向经纪公司发出的买入或卖出金融工具的指令。MetaTrader 5 支持多种类型的订单,但简化来说,它们可以有条件地分为市价订单、挂单以及特殊的保护水平(止盈和止损)。
  • 交易:订单成功执行的结果是在交易系统中产生一笔交易。具体而言,对于市价订单,交易可以按当前价格成交;对于挂单,则在价格达到订单中指定的值时触发成交。换句话说,交易是买入或卖出特定金融工具的事实。需要注意的是,在某些情况下,一笔订单的执行可能会产生多笔交易。例如,如果订单簿中某品种的流动性不足,那么买入订单可能会通过各种对手订单执行,其中包括价格略有不同的订单。
  • 持仓:根据交易买入或卖出的金融工具分别形成多头或空头持仓,这会反映在交易账户的资产/负债中。由于持仓工具价格的后续变化,账户上会形成浮动盈亏,这可以通过反向交易操作(订单和交易)平仓来锁定盈亏。根据交易账户的类型(轧差或对冲),同一工具的交易要么修改单个净持仓,要么创建/删除独立的持仓。

更多信息可在终端用户手册中找到。

所有的订单、交易和持仓都包含在账户的交易历史记录中。

接下来,我们将研究软件 API,它包括发送交易订单的函数、获取账户投资组合的当前状态、检查保证金负荷和潜在的盈亏,以及分析交易历史记录的功能。

交易操作类型

在 MQL5 中,交易是通过使用 OrderSend 函数发送订单来实现的。我们将在后续的一个章节中对其进行学习,因为对该函数的说明需要你首先熟悉几个概念。

第一个新概念就是交易操作类型。每个交易请求都包含所请求交易类型的指示,并且允许执行诸如开仓和平仓,以及挂单、修改和删除挂单等操作。所有交易操作类型都在 ENUM_TRADE_REQUEST_ACTIONS 枚举中进行了描述。

标识符描述
TRADE_ACTION_DEAL下达一个具有指定参数的即时交易订单(下达市价单)
TRADE_ACTION_PENDING下达一个在指定条件下执行交易的订单(挂单)
TRADE_ACTION_SLTP修改已开仓位的止损(Stop Loss)和止盈(Take Profit)值
TRADE_ACTION_MODIFY修改先前下达订单的参数
TRADE_ACTION_REMOVE删除先前下达的挂单
TRADE_ACTION_CLOSE_BY用相反方向的仓位平仓

当请求 TRADE_ACTION_DEALTRADE_ACTION_PENDING 时,程序将需要指定具体的订单类型。这是另一个重要的概念,在 MQL5 API 中有其相应的体现,我们将在下一个章节中对其进行探讨。

订单类型

如你所知,MetaTrader 5支持多种订单类型:两种用于按当前价格进行买卖的市价订单,以及六种带有预设激活价位(高于和低于市场价格)的挂单。所有这些类型在MQL5 API中均可用,并由 ENUM_ORDER_TYPE 枚举元素来描述。之后我们会探讨如何在程序中创建特定类型的订单。现在,让我们先了解一下这个枚举。

标识符描述
ORDER_TYPE_BUY市价买入订单
ORDER_TYPE_SELL市价卖出订单
ORDER_TYPE_BUY_LIMIT买入限价挂单
ORDER_TYPE_SELL_LIMIT卖出限价挂单
ORDER_TYPE_BUY_STOP买入止损挂单
ORDER_TYPE_SELL_STOP卖出止损挂单
ORDER_TYPE_BUY_STOP_LIMIT当价格达到指定的较高价位时执行的买入限价挂单
ORDER_TYPE_SELL_STOP_LIMIT当价格达到指定的较低价位时执行的卖出限价挂单
ORDER_TYPE_CLOSE_BY用反向持仓来平仓的订单

最后一个元素对应的是平仓反向持仓的操作:这种操作仅在对冲账户上以及那些允许此类操作的金融工具(SYMBOL_ORDER_CLOSEBY)中可行。

下面这幅图或许能让你想起挂单的一般激活原则。图中用灰色表示预期的未来价格走势。但在当前时刻,我们并不清楚哪个预测会是正确的。

挂单激活示意图

挂单激活示意图

买入止损和卖出止损挂单遵循价位突破原则:对于买入止损挂单,该价位应高于当前价格;对于卖出止损挂单,该价位应低于当前价格。换句话说,在给定的价位上,我们期望执行买入或卖出操作,以顺应后续的趋势行情。

买入限价和卖出限价挂单采用的是价位反弹策略,在这种情况下,买入激活价格低于当前价格,卖出激活价格高于当前价格。这意味着趋势可能会反转或者价格会在区间内波动。在上面的示意图中,使用相同的较高(较高价格)和较低(较低价格)挂单激活价位来同时说明突破和反弹两种情况。

挂单可以设置在当前价格,这种情况下它们很可能会立即执行。此外,这种应用于限价订单的方法能保证成交价格不劣于所要求的价格,这一点与市价订单不同。

买入止损限价和卖出止损限价订单在激活后不会直接发送到市场,而是在原始订单中指定的额外价位上设置买入限价或卖出限价挂单。

对于交易所交易工具,限价订单(买入限价、卖出限价)通常会直接显示在订单簿中,并且其他市场参与者可以看到。

相反,止损和止损限价订单(买入止损、卖出止损、买入止损限价和卖出止损限价)不会直接输出到外部交易系统。在达到止损价格之前,这些类型的订单会在MetaTrader 5平台内部处理。当买入止损或卖出止损订单中指定的止损价格被触及,相应的市场操作就会执行。当买入止损限价或卖出止损限价订单中指定的止损价格被触及,就会下达相应的限价订单。

在交易所执行模式下,下达限价订单时指定的价格不会被检查。可以将其指定为高于当前的卖价(对于买入订单)和低于当前的买价(对于卖出订单)。当以这样的价格下达订单时,订单几乎会立即被触发并转变为市价订单。

请注意,并非所有类型的订单都允许用于特定的金融工具:SYMBOL_ORDER_MODE 属性描述了允许的订单类型标志。

按价格和成交量的订单执行模式

在发送交易请求时,我们需要在算法中以特定方式指定买卖价格和成交量。同时,需要考虑到在金融市场中,无法保证在某一时刻,金融工具能以期望的价格提供所需的全部成交量。因此,交易操作由价格和成交量的执行模式(或策略)来规范,这些模式定义了在发送请求过程中价格发生变化,或者订单无法完全成交时的处理规则。

订单执行设置

在交易品种相关章节的“交易条件和订单执行模式”部分,我们已经讨论过由经纪商设置的按价格执行订单(SYMBOL_TRADE_EXEMODE)和按成交量填充订单(SYMBOL_FILLING_MODE)的设置。根据可用的 SYMBOL_FILLING_MODE 模式,MQL 程序必须在新形成的订单的特殊结构 MqlTradeRequest 中选择合适的填充模式(稍后我们会看到实际操作)。

订单填充模式枚举

ENUM_ORDER_TYPE_FILLING 枚举提供了不同的执行策略,其标识符与 SYMBOL_FILLING_MODE 相对应:

执行策略(值)描述
ORDER_FILLING_FOK (0)全部成交或取消(Fill or Kill)
ORDER_FILLING_IOC (1)立即成交或取消(Immediate or Cancel)
ORDER_FILLING_RETURN (2)剩余继续(Return)

各执行策略说明

  • ORDER_FILLING_FOK 策略:订单只能以指定的成交量成交。如果当前市场上金融工具的成交量不足,订单将不会被执行。所需的成交量可以由市场上当前可用的多个报价组成。能否使用 FOK 订单取决于是否有 SYMBOL_FILLING_FOK 权限。
  • ORDER_FILLING_IOC 策略:交易者同意在订单指定的范围内,以市场上可用的最大成交量进行交易。如果无法完全成交,订单将以可用的成交量执行,未成交的部分将被取消。能否使用 IOC 订单取决于是否有 SYMBOL_FILLING_IOC 权限。
  • ORDER_FILLING_RETURN 策略:在部分成交的情况下,带有剩余成交量的订单不会被取消,而是继续有效。这是默认模式,并且始终可用。不过有一个例外:在市场执行模式(SYMBOL_TRADE_EXECUTION_MARKET,在 SYMBOL_TRADE_EXEMODE 交易品种属性中)下,不允许使用 Return 订单。

执行模式与填充策略的对应关系

在发送市价(非挂单)订单之前,MQL 程序应根据相应金融工具的 SYMBOL_FILLING_MODE 属性正确设置 ORDER_TYPE_FILLING 策略之一,该属性包含允许模式的位标志组合。对于挂单,无论 SYMBOL_TRADE_EXEMODE 执行模式如何,都必须使用 ORDER_FILLING_RETURN 策略,因为此类订单将在稍后根据经纪商当时设定的规则以一定成交量成交。

与成交量填充策略不同,按价格的订单执行模式不能由用户选择,因为它是由经纪商为每个交易品种预先确定的,这会影响在提交交易请求之前 MqlTradeRequest 结构的哪些字段需要填充。执行模式与填充策略的对应关系可以用以下表格表示(+ 表示允许,- 表示禁用,± 表示取决于交易品种设置):

填充策略 \ 执行模式ORDER_FILLING_FOKORDER_FILLING_IOCORDER_FILLING_RETURN
SYMBOL_TRADE_EXECUTION_INSTANT+++
SYMBOL_TRADE_EXECUTION_REQUEST+++
SYMBOL_TRADE_EXECUTION_MARKET±±-
SYMBOL_TRADE_EXECUTION_EXCHANGE±±+
挂单--+

SYMBOL_TRADE_EXECUTION_INSTANTSYMBOL_TRADE_EXECUTION_REQUEST 执行模式下,所有成交量填充策略都是允许的。

期货订单的保证金计算:OrderCalcMargin

在将交易请求发送到服务器之前,MQL程序可以使用OrderCalcMargin函数来计算计划交易所需的保证金。建议始终执行此操作,以避免过度的保证金占用。

c
bool OrderCalcMargin(ENUM_ORDER_TYPE action, const string symbol,
  double volume, double price, double &margin)

该函数根据指定的订单类型和金融工具品种以及交易量(手数)来计算所需的保证金。这与当前账户的设置一致,但不考虑现有的挂单和持仓头寸。ENUM_ORDER_TYPE枚举类型在“订单类型”部分中介绍。

保证金值(以账户货币计)会写入通过引用传递的margin参数中。

需要强调的是,这只是对单个新头寸或订单的保证金估算,并非执行后保证金的总值。此外,这种估算是假设当前账户上没有其他挂单和持仓头寸的情况下进行的。实际上,保证金的值取决于许多因素,包括其他订单和头寸,并且可能会随着市场环境(如杠杆率)的变化而变化。

该函数返回一个表示成功(true)或错误(false)的指示符。错误代码可以通过通常的方式从变量_LastError中获取。

OrderCalcMargin函数只能在智能交易系统和脚本中使用。要在指标中计算保证金,需要实现一种替代方法,例如在图表对象中启动一个辅助智能交易系统,向其传递参数,并通过事件机制获取结果,或者使用MQL5根据金融工具的类型自行描述计算公式。在下一部分中,我们将给出这样一个实现示例,以及对潜在利润/亏损的估算。

我们可以编写一个简单的脚本,调用OrderCalcMargin函数来计算市场报价中各个品种的保证金,并比较它们的保证金值。不过,我们让任务稍微复杂一些,考虑头文件LotMarginExposure.mqh,它可以在以预定风险水平开仓后评估保证金占用和保证金水平。稍后我们将讨论OrderCheck函数,它能够提供类似的信息。然而,我们的算法还能够解决根据给定的保证金占用或风险水平来选择手数的反向问题。

新功能的使用在非交易型智能交易系统LotMarginExposureTable.mq5中进行了演示。

从理论上讲,MQL程序被实现为智能交易系统并不意味着必须在其中执行交易操作。很多时候,就像我们的例子一样,各种实用工具是以智能交易系统的形式创建的。它们相对于脚本的优势在于,它们会保留在图表上,并且可以在响应某些事件时无限期地执行其功能。

在新的智能交易系统中,我们运用创建交互式图形界面的技能来使用对象。简单来说,对于给定的品种列表,智能交易系统将在图表上显示一个包含几列保证金指标的表格,并且该表格可以按每一列进行排序。我们稍后会列出列的清单。

由于对手数、保证金和保证金占用的分析是一个常见的任务,我们将把实现部分分离到一个单独的头文件LotMarginExposure.mqh中。

所有文件函数都被分组在一个命名空间中,以避免冲突并提高代码的清晰度(在调用内部函数之前指明命名空间,可以表明该函数的来源和位置)。

c
namespace LEMLR
{
   ...
};

缩写LEMLR的意思是“手数、风险暴露、保证金水平、风险”。

主要计算在Estimate函数中执行。考虑到内置的OrderCalcMargin函数的原型,在Estimate函数的参数中,我们需要传递品种名称、订单类型、手数和价格。但这还不是我们所需的全部。

c
bool Estimate(const ENUM_ORDER_TYPE type, const string symbol, const double lot,
      const double price,...)

我们打算评估交易操作的几个指标,这些指标相互关联,并且可以根据用户输入的初始数据以及他们想要计算的内容,从不同方向进行计算。例如,使用上述参数,很容易找到新的保证金水平和账户保证金占用。它们的公式正好相反:

Ml = money / margin * 100
Ex = margin / money * 100

这里的margin变量表示保证金金额,调用OrderCalcMargin函数就足以得到这个值。

然而,交易者通常更喜欢从预定的保证金占用或保证金水平开始,然后计算相应的交易量。此外,还有一种同样流行的基于风险的手数计算方法。风险被理解为在价格不利变动情况下交易的潜在损失金额,其结果是上述公式中另一个变量(即money)的值会减少。

为了计算损失,了解交易期间(策略持续时间)金融工具的波动性或用户假定的止损距离非常重要。

因此,Estimate函数的参数列表有所扩展。

c
bool Estimate(const ENUM_ORDER_TYPE type, const string symbol, const double lot,
      const double price,
      const double exposure, const double riskLevel, const int riskPoints,
      const ENUM_TIMEFRAMES riskPeriod, double money,...)

在exposure参数中,我们指定所需的保证金占用百分比,在riskLevel参数中,我们指明愿意承担风险的保证金比例(同样以百分比表示)。对于基于风险的计算,可以在riskPoints参数中传递止损点数。当riskPoints等于0时,riskPeriod参数就会起作用:它指定算法将自动计算品种报价点数范围的时间段。最后,在money参数中,我们可以指定用于手数评估的任意可用保证金金额。一些交易者有条件地将保证金分配给几个智能交易系统。当money为0时,函数将使用AccountInfoDouble(ACCOUNT_MARGIN_FREE)属性来填充这个变量。

现在我们需要决定如何返回函数的结果。由于它能够评估许多交易指标和几种手数选项,因此定义SymbolLotExposureRisk结构是有意义的。

c
struct SymbolLotExposureRisk
{
      double lot;                      // 请求的交易量(或最小交易量)
      int atrPointsNormalized;         // 按点值归一化后的价格范围
      double atrValue;                 // 作为1手盈利/亏损金额的价格范围
      double lotFromExposureRaw;       // 未归一化(可能小于最小手数)
      double lotFromExposure;          // 根据保证金占用归一化后的手数
      double lotFromRiskOfStopLossRaw; // 未归一化(可能小于最小手数)
      double lotFromRiskOfStopLoss;    // 根据风险归一化后的手数
      double exposureFromLot;          // 基于“lot”交易量的保证金占用
      double marginLevelFromLot;       // 基于“lot”交易量的保证金水平
      int lotDigits;                   // 归一化手数的小数位数
   };

如果传递给Exposure函数的手数lot不等于0,结构中的lot字段将包含传递的手数。如果传递的手数为零,则会用品种属性SYMBOL_VOLUME_MIN的值代替。

为基于保证金占用和风险计算的手数值分配了两个字段:带有后缀Raw(lotFromExposureRaw,lotFromRiskOfStopLossRaw)的字段和没有后缀(lotFromExposure,lotFromRiskOfStopLoss)的字段。Raw字段包含“纯算术”结果,可能与品种规格不匹配。在没有后缀的字段中,手数会根据最小、最大和步长进行归一化。这种重复是有用的,特别是在计算结果小于最小手数的情况下(例如,lotFromExposureRaw等于0.023721,而最小手数为0.1,因此lotFromExposure会被归为零):这样,从Raw字段的内容中,你可以评估需要增加多少资金或增加多少风险才能达到最小手数。

让我们将Estimate函数的最后一个输出参数描述为对这个结构的引用。我们将在函数体中逐步填充所有字段。首先,我们通过调用OrderCalcMargin函数获取1手的保证金,并将其保存到局部变量lot1margin中。

c
bool Estimate(const ENUM_ORDER_TYPE type, const string symbol, const double lot,
      const double price, const double exposure,
      const double riskLevel, const int riskPoints, const ENUM_TIMEFRAMES riskPeriod,
      double money, SymbolLotExposureRisk &r)
   {
      double lot1margin;
      if(!OrderCalcMargin(type, symbol, 1.0,
         price == 0 ? GetCurrentPrice(symbol, type) : price,
         lot1margin))
      {
         Print("OrderCalcMargin ", symbol, " failed: ", _LastError);
         return false;
      }
      if(lot1margin == 0)
      {
         Print("Margin ", symbol, " is zero, ", _LastError);
         return false;
      }
      ...

如果未指定入场价格,即price等于0,辅助函数GetCurrentPrice将根据订单类型返回一个合适的价格:对于买入订单,将采用品种属性SYMBOL_ASK的值,对于卖出订单,将采用SYMBOL_BID的值。这里省略了这个和其他辅助函数,它们的内容可以在附带的源代码中找到。

如果保证金计算失败,或者得到的值为零,Estimate函数将返回false。

请记住,零保证金可能是正常的,但也可能是一个错误,这取决于金融工具和订单类型。例如,对于交易所的交易品种,挂单需要占用保证金,但对于场外交易(OTC)品种则不需要(即保证金为0是正确的)。在调用代码中应该考虑到这一点:它应该只对那些有意义并且预期保证金不为零的品种和操作类型请求保证金。

有了1手的保证金,我们就可以计算出确保给定保证金占用所需的手数。

c
double usedMargin = 0;
if(money == 0)
{
    money = AccountInfoDouble(ACCOUNT_MARGIN_FREE);
    usedMargin = AccountInfoDouble(ACCOUNT_MARGIN);
}

r.lotFromExposureRaw = money * exposure / 100.0 / lot1margin;
r.lotFromExposure = NormalizeLot(symbol, r.lotFromExposureRaw);
...

辅助函数NormalizeLot如下所示。

为了根据风险和波动性得到手数,需要进行更多的计算。

c
const double tickValue = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_VALUE);
const int pointsInTick = (int)(SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_SIZE)
   / SymbolInfoDouble(symbol, SYMBOL_POINT));
const double pointValue = tickValue / pointsInTick;
const int atrPoints = (riskPoints > 0) ? (int)riskPoints :
   (int)(((MathMax(iHigh(symbol, riskPeriod, 1), iHigh(symbol, riskPeriod, 0))
   -  MathMin(iLow(symbol, riskPeriod, 1), iLow(symbol, riskPeriod, 0)))
   / SymbolInfoDouble(symbol, SYMBOL_POINT)));
// 按点值进行四舍五入 
r.atrPointsNormalized = atrPoints / pointsInTick * pointsInTick;
r.atrValue = r.atrPointsNormalized * pointValue;

r.lotFromRiskOfStopLossRaw = money * riskLevel / 100.0
   / (pointValue * r.atrPointsNormalized);
r.lotFromRiskOfStopLoss = NormalizeLot(symbol, r.lotFromRiskOfStopLossRaw);
...

在这里,我们先找到该金融工具一个点的价值以及它在指定时间段内的变化范围,然后再计算手数。

最后,我们得到给定手数下的账户保证金占用和保证金水平。

c
r.lot = lot <= 0 ? SymbolInfoDouble(symbol, SYMBOL_VOLUME_MIN) : lot;
double margin = r.lot * lot1margin;

r.exposureFromLot = (margin + usedMargin) / money * 100.0;
r.marginLevelFromLot = margin > 0 ? money / (margin + usedMargin) * 100.0 : 0;
r.lotDigits = (int)MathLog10(1.0 / SymbolInfoDouble(symbol, SYMBOL_VOLUME_MIN));

return true;

如果计算成功,函数将返回true。

以下是NormalizeLot函数的简化版本(为简单起见,省略了所有对0的检查)。关于相应属性的详细信息,可以在“允许的交易操作交易量”部分中找到。

c
double NormalizeLot(const string symbol, const double lot)
{
    const double stepLot = SymbolInfoDouble(symbol, SYMBOL_VOLUME_STEP);
    const double newLotsRounded = MathFloor(lot / stepLot) * stepLot;
    const double minLot = SymbolInfoDouble(symbol, SYMBOL_VOLUME_MIN);
    if(newLotsRounded < minLot) return 0;
    const double maxLot = SymbolInfoDouble(symbol, SYMBOL_VOLUME_MAX);
    if(newLotsRounded > maxLot) return maxLot;
    return newLotsRounded;
}

上述Estimate函数的实现没有考虑到对重叠头寸的调整。通常,这些调整会导致保证金减少,因此当前对账户保证金占用和保证金水平的估算可能比实际情况更悲观,但这提供了额外的保护。有兴趣的人可以添加代码来分析已经冻结的账户资金构成(其总金额包含在ACCOUNT_MARGIN账户属性中),并按头寸和订单进行细分:这样就可以考虑新订单对保证金的潜在影响(例如,只考虑相反头寸中最大的头寸,或者应用降低的对冲保证金率,详见“保证金要求”部分)。

现在是时候在LotMarginExposureTable.mq5中实际应用保证金和手数估算了。考虑到只有在对计算结果进行归一化导致手数为零的情况下才会显示Raw字段,最终的指标表格总共有8列。

c
#include <MQL5Book/LotMarginExposure.mqh>
#define TBL_COLUMNS 8

在输入参数中,我们将提供指定订单类型、要分析的品种列表(用逗号分隔的列表)、可用资金以及手数、目标保证金占用、保证金水平和风险的可能性。

c
input ENUM_ORDER_TYPE Action = ORDER_TYPE_BUY;
input string WorkList = "";                   // 品种(用逗号分隔的列表)
input double Money = 0;                       // 资金(0表示可用保证金)
input double Lot = 0;                         // 手数(0表示最小手数)
input double Exposure = 5.0;                  // 保证金占用(%)
input double RiskLevel = 5.0;                 // 风险水平(%)
input int RiskPoints = 0;                     // 风险点数/止损(0表示自动计算RiskPeriod内的价格范围)
input ENUM_TIMEFRAMES RiskPeriod = PERIOD_W1;

对于挂单类型,必须选择股票品种,因为对于其他品种,将得到零保证金,这将导致Estimate函数出现错误。如果品种列表留空,智能交易系统将只处理当前图表的品种。参数Money和Lot的默认值为0,分别表示账户上当前的可用资金和每个品种的最小手数。

RiskPoints参数中的值为0表示在RiskPeriod(默认为一周)内获取价格范围。

输入参数UpdateFrequency设置重新计算的频率(以秒为单位)。如果将其保留为零,则在每个新K线柱出现时进行重新计算。

c
input int UpdateFrequency = 0; // 更新频率(秒,0表示每根K线柱计算一次)

在全局上下文中描述了:一个品种数组(稍后通过解析输入参数WorkList进行填充)和上次成功计算的时间戳。

c
string symbols[];
datetime lastTime;

在启动时,我们开启第二个定时器。

c
void OnInit()
{
    Comment("Starting...");
    lastTime = 0;
    EventSetTimer(1);
}

在定时器处理函数中,如果在K线柱到来时OnTick函数尚未被调用,我们会在OnTick函数中进行首次主要计算调用。例如,这种情况可能发生在周末或市场平静的时候。此外,OnTimer是按照给定频率进行重新计算的入口点。

c
void OnTimer()
{
    if(lastTime == 0) // 首次计算(如果OnTick还没有来得及触发)
    {
        OnTick();
        Comment("Started");
    }
    else if(lastTime != -1)
    {
        if(UpdateFrequency <= 0) // 如果没有设置频率,我们在OnTick中处理新K线柱
        {
            EventKillTimer();     // 并且不再需要定时器
        }
        else if(TimeCurrent() - lastTime >= UpdateFrequency)
        {
            lastTime = LONG_MAX; // 防止再次进入这个“if”分支 
            OnTick();
            if(lastTime != -1)   // 没有错误地完成计算
            {
                lastTime = TimeCurrent();// 更新时间戳
            }
        }
        Comment("");
    }
}

在OnTick处理函数中,我们首先检查输入参数,并将品种列表转换为字符串数组。如果发现问题,错误标志将写入lastTime:值为-1,并且后续K线柱的处理将在一开始就中断。

c
void OnTick()
{
    if(lastTime == -1) return; // 已经出现过错误,退出 
  
    if(UpdateFrequency <= 0)   // 如果没有设置更新速率
    {
        if(lastTime == iTime(NULL, 0, 0)) return; // 等待新的K线柱
    }
    else if(TimeCurrent() - lastTime < UpdateFrequency)
    {
        return;
    }
      
    const int ns = StringSplit((WorkList == "" ? _Symbol : WorkList), ',', symbols);
    if(ns <= 0)
    {
        Print("Empty symbols");
        lastTime = -1;
        return;
    }
   
    if(Exposure > 100 || Exposure <= 0)
    {
        Print("Percent of Exposure is incorrect: ", Exposure);
        lastTime = -1;
        return;
    }

    if(RiskLevel > 100 || RiskLevel <= 0)
    {
        Print("Percent of RiskLevel is incorrect: ", RiskLevel);
        lastTime = -1;
        return;
    }
   ...

    // 特别是,如果输入值 Exposure 和 Risk Level 超出 0 到 100 的范围(百分比应在此范围内),则视为错误。
    // 如果输入数据正常,我们更新时间戳,描述 LEMLR::SymbolLotExposureRisk 结构以从 LEMLR::Estimate 函数接收计算指标(每个符号一个),
    // 以及一个二维数组 LME(来自“Lot Margin Exposure”)以收集所有符号的指标。

    lastTime = UpdateFrequency > 0? TimeCurrent() : iTime(NULL, 0, 0);

    LEMLR::SymbolLotExposureRisk r = {};

    double LME[][13];
    ArrayResize(LME, ns);
    ArrayInitialize(LME, 0);
   ...

    // 在遍历符号的循环中,我们调用 LEMLR::Estimate 函数并填充 LME 数组。

    for(int i = 0; i < ns; i++)
    {
        if(!LEMLR::Estimate(Action, symbols[i], Lot, 0,
            Exposure, RiskLevel, RiskPoints, RiskPeriod, Money, r))
        {
            Print("Calc failed (will try on the next bar, or refresh manually)");
            return;
        }

        LME[i][eLot] = r.lot;
        LME[i][eAtrPointsNormalized] = r.atrPointsNormalized;
        LME[i][eAtrValue] = r.atrValue;
        LME[i][eLotFromExposureRaw] = r.lotFromExposureRaw;
        LME[i][eLotFromExposure] = r.lotFromExposure;
        LME[i][eLotFromRiskOfStopLossRaw] = r.lotFromRiskOfStopLossRaw;
        LME[i][eLotFromRiskOfStopLoss] = r.lotFromRiskOfStopLoss;
        LME[i][eExposureFromLot] = r.exposureFromLot;
        LME[i][eMarginLevelFromLot] = r.marginLevelFromLot;
        LME[i][eLotDig] = r.lotDigits;
        LME[i][eMinLot] = SymbolInfoDouble(symbols[i], SYMBOL_VOLUME_MIN);
        LME[i][eContract] = SymbolInfoDouble(symbols[i], SYMBOL_TRADE_CONTRACT_SIZE);
        LME[i][eSymbol] = pack2double(symbols[i]);
    }
   ...

    // 特殊枚举 LME_FIELDS 的元素用作数组索引,它同时为结构中的指标提供名称和编号。

    enum LME_FIELDS // 10 个字段 + 3 个额外的符号属性
    {
        eLot,
        eAtrPointsNormalized,
        eAtrValue,
        eLotFromExposureRaw,
        eLotFromExposure,
        eLotFromRiskOfStopLossRaw,
        eLotFromRiskOfStopLoss,
        eExposureFromLot,
        eMarginLevelFromLot,
        eLotDig,
        eMinLot,
        eContract,
        eSymbol
    };

    // 添加 SYMBOL_VOLUME_MIN 和 SYMBOL_TRADE_CONTRACT_SIZE 属性以供参考。符号名称使用 pack2double 函数“打包”为 double 类型的近似值,
    // 以便随后实现按任何字段(包括名称)进行统一排序。

    double pack2double(const string s)
    {
        double r = 0;
        for(int i = 0; i < StringLen(s); i++)
        {
            r = (r * 255) + (StringGetCharacter(s, i) % 255);
        }
        return r;
    }

    // 在这个阶段,我们已经可以运行智能交易系统并在日志中打印结果,如下所示。

    ArrayPrint(LME);

但一直查看日志并不方便。此外,对不同列的值进行统一格式化,更不用说将“打包”的行以双倍形式呈现,都不能说是用户友好的。因此,开发了计分板类(Tableau.mqh),用于在图表上显示任意表格。除了在准备表格时,我们可以自行控制每个字段的格式(未来还能以不同颜色突出显示)之外,这个类还允许通过任意列对表格进行交互式排序:第一次鼠标点击按一个方向排序,第二次点击按相反方向排序,第三次点击则取消排序。

这里我们不会详细描述这个类,不过你可以研究它的源代码。需要着重指出的是,该界面基于图形对象。实际上,表格单元格由 OBJ_LABEL 类型的对象构成,其所有属性读者应该已经熟悉。不过,计分板源代码中采用的一些技术,尤其是处理图形资源和测量显示文本的技术,将在第七部分进行介绍。

tableau 类的构造函数接收多个参数:

  • prefix — 所创建图形对象名称的前缀
  • rows — 行数
  • cols — 列数
  • height — 行高(以像素为单位,-1 表示字体大小的两倍)
  • width — 单元格宽度(以像素为单位)
  • c — 图表上用于固定对象的角度
  • g — 单元格之间的间距(以像素为单位)
  • f — 字体大小
  • font — 普通单元格的字体名称
  • bold — 标题的粗体字体名称
  • bgc — 背景颜色
  • bgt — 背景透明度
cpp
class Tableau
{
public:
   Tableau(const string prefix, const int rows, const int cols,
      const int height = 16, const int width = 100,
      const ENUM_BASE_CORNER c = CORNER_RIGHT_LOWER, const int g = 8,
      const int f = 8, const string font = "Consolas", const string bold = "Arial Black",
      const int mask = TBL_FLAG_COL_0_HEADER,
      const color bgc = 0x808080, const uchar bgt = 0xC0)
      ...
};

这些参数中的大部分可以由用户在 LotMarginExposureTable.mq5 智能交易系统的输入变量中进行设置。

cpp
input ENUM_BASE_CORNER Corner = CORNER_RIGHT_LOWER;
input int Gap = 16;
input int FontSize = 8;
input string DefaultFontName = "Consolas";
input string TitleFontName = "Arial Black";
input string MotoTypeFontsHint = "Consolas/Courier/Courier New/Lucida Console/Lucida Sans Typewriter";
input color BackgroundColor = 0x808080;
input uchar BackgroundTransparency = 0xC0; // BackgroundTransparency (255 - 不透明, 0 - 透明)

表格中的列数是预先确定的,行数等于交易品种的数量加上带有标题的顶行。

需要注意的是,表格的字体应选择非比例字体,因此在 MotoTypeFontsHint 变量中提供了一个提示,列出了一组标准的 Windows 等宽字体。

创建的图形对象通过 Tableau 类的 fill 方法进行填充。

cpp
bool fill(const string &data[], const string &hint[]) const;

我们的智能交易系统会传递一个字符串数据数组,这些字符串是通过一系列 StringFormat 转换从 LME 数组中获取的,同时还会传递一个包含标题工具提示的提示数组。

下图展示了运行中的智能交易系统在默认设置下,但指定了交易品种列表 "EURUSD,USDRUB,USDCNH,XAUUSD,XPDUSD" 的部分图表。

每个交易品种的存款和保证金加载水平(最小手数)

交易品种名称显示在左列。作为第一列的标题,显示的是资金金额(在这种情况下,是当前账户上的可用资金,因为输入参数 Money 设为 0)。当鼠标悬停在列名上时,可以看到带有解释的工具提示。

在后续列中:

  • L(E) — 交易后按 5% 存款加载水平 E 计算的手数
  • L(R) — 交易失败后按 5% 存款风险 R 计算的手数(点数范围和风险金额在最后一列)
  • E% — 以最小手数入场后的存款加载百分比
  • M% — 以最小手数入场后的保证金水平百分比
  • MinL — 每个交易品种的最小手数
  • Contract — 每个交易品种的合约规模(1 手)
  • Risk — 交易 1 手时的盈亏金额以及相同的点数范围

E%M% 列中,由于输入参数 Lot 为 0(默认值),因此使用的是最小手数。

当存款加载为 5% 时,除了 "XPDUSD" 之外,所有选定的交易品种都可以进行交易。对于 "XPDUSD",计算出的交易量为 0.03272,小于最小手数 0.1,因此结果用括号括起来。如果允许 20% 的加载(在参数 Exposure 中输入 20),则 "XPDUSD" 的最小手数为 0.1。

如果在 Lot 参数中输入值 1,我们将在表格的 E%M% 列中看到更新后的值(加载会增加,保证金水平会下降)。

每个交易品种单手持仓的存款和保证金加载水平

最后一张展示智能交易系统运行情况的截图显示,对俄罗斯莫斯科证券交易所(MOEX)的大量蓝筹股按照 5% 存款加载计算的交易量(第二列)进行了排序。在非标准设置中,可以注意到 Lot = 10,计算价格范围和风险的周期为 MN1。背景设置为半透明白色,固定在图表的左上角。

估算交易操作的利润:OrderCalcProfit 函数

MQL5 API 中的 OrderCalcProfit 函数可以在预期条件满足的情况下,预先评估交易操作的财务结果。例如,使用这个函数,你可以得知当达到止盈水平时的盈利金额,以及止损被触发时的亏损金额。

c
bool OrderCalcProfit(ENUM_ORDER_TYPE action, const string symbol, double volume,
  double openPrice, double closePrice, double &profit)

该函数根据传入的参数,为当前市场环境计算以账户货币表示的盈利或亏损。

订单类型在 action 参数中指定。只允许使用 ENUM_ORDER_TYPE 枚举中的市价单类型,即 ORDER_TYPE_BUY(买入)或 ORDER_TYPE_SELL(卖出)。金融工具的名称及其交易量分别通过 symbolvolume 参数传入。市场的入场价格和出场价格分别由 openPriceclosePrice 参数设置。profit 变量作为最后一个参数通过引用传递,计算得到的盈利值将写入该变量中。

该函数返回一个表示成功(true)或错误(false)的指示。

OrderCalcProfit 函数内部使用的计算财务结果的公式取决于交易品种的类型。

标识符公式
SYMBOL_CALC_MODE_FOREX(ClosePrice - OpenPrice) * ContractSize * Lots
SYMBOL_CALC_MODE_FOREX_NO_LEVERAGE(ClosePrice - OpenPrice) * ContractSize * Lots
SYMBOL_CALC_MODE_CFD(ClosePrice - OpenPrice) * ContractSize * Lots
SYMBOL_CALC_MODE_CFDINDEX(ClosePrice - OpenPrice) * ContractSize * Lots
SYMBOL_CALC_MODE_CFDLEVERAGE(ClosePrice - OpenPrice) * ContractSize * Lots
SYMBOL_CALC_MODE_EXCH_STOCKS(ClosePrice - OpenPrice) * ContractSize * Lots
SYMBOL_CALC_MODE_EXCH_STOCKS_MOEX(ClosePrice - OpenPrice) * ContractSize * Lots
SYMBOL_CALC_MODE_FUTURES(ClosePrice - OpenPrice) * Lots * TickPrice / TickSize
SYMBOL_CALC_MODE_EXCH_FUTURES(ClosePrice - OpenPrice) * Lots * TickPrice / TickSize
SYMBOL_CALC_MODE_EXCH_FUTURES_FORTS(ClosePrice - OpenPrice) * Lots * TickPrice / TickSize
SYMBOL_CALC_MODE_EXCH_BONDSLots * ContractSize * (ClosePrice * FaceValue + AccruedInterest)
SYMBOL_CALC_MODE_EXCH_BONDS_MOEXLots * ContractSize * (ClosePrice * FaceValue + AccruedInterest)
SYMBOL_CALC_MODE_SERV_COLLATERALLots * ContractSize * MarketPrice * LiqudityRate

公式中使用的符号含义如下:

  • Lots — 以手(合约份额)为单位的头寸交易量
  • ContractSize — 合约规模(一手,即 SYMBOL_TRADE_CONTRACT_SIZE
  • TickPrice — 点值(SYMBOL_TRADE_TICK_VALUE
  • TickSize — 最小价格变动单位(SYMBOL_TRADE_TICK_SIZE
  • MarketPrice — 根据交易类型,最后已知的买入价/卖出价
  • OpenPrice — 头寸开仓价格
  • ClosePrice — 头寸平仓价格
  • FaceValue — 债券的面值(SYMBOL_TRADE_FACE_VALUE
  • LiqudityRate — 流动性比率(SYMBOL_TRADE_LIQUIDITY_RATE
  • AccruedInterest — 累计息票收益(SYMBOL_TRADE_ACCRUED_INTEREST

OrderCalcProfit 函数只能在智能交易系统(Expert Advisors)和脚本中使用。要在指标中计算潜在的盈利/亏损,需要实现一种替代方法,例如使用公式进行独立计算。

为了绕过在指标中使用 OrderCalcProfitOrderCalcMargin 函数的限制,我们开发了一组函数,这些函数使用本节以及“保证金要求”部分中的公式进行计算。这些函数位于头文件 MarginProfitMeter.mqh 中,在公共命名空间 MPM(来自“Margin Profit Meter”)内。

特别是,要计算财务结果,了解特定交易品种的一个价格点的值很重要。在上述公式中,它间接参与了开仓价格和平仓价格之间的差值计算(ClosePrice - OpenPrice)。

以下函数用于计算一个价格点的值 PointValue

c++
namespace MPM
{
   double PointValue(const string symbol, const bool ask = false,
      const datetime moment = 0)
   {
      const double point = SymbolInfoDouble(symbol, SYMBOL_POINT);
      const double contract = SymbolInfoDouble(symbol, SYMBOL_TRADE_CONTRACT_SIZE);
      const ENUM_SYMBOL_CALC_MODE m =
         (ENUM_SYMBOL_CALC_MODE)SymbolInfoInteger(symbol, SYMBOL_TRADE_CALC_MODE);
      ...

      // 函数开始时,请求计算所需的所有交易品种属性。然后,根据交易品种类型,以该工具的盈利货币获取盈利/亏损。
      // 请注意,这里不包括债券,因为债券的公式会考虑票面价格和息票收益。
      double result = 0;
      switch(m)
      {
      case SYMBOL_CALC_MODE_FOREX_NO_LEVERAGE:
      case SYMBOL_CALC_MODE_FOREX:
      case SYMBOL_CALC_MODE_CFD:
      case SYMBOL_CALC_MODE_CFDINDEX:
      case SYMBOL_CALC_MODE_CFDLEVERAGE:
      case SYMBOL_CALC_MODE_EXCH_STOCKS:
      case SYMBOL_CALC_MODE_EXCH_STOCKS_MOEX:
         result = point * contract;
         break;
   
      case SYMBOL_CALC_MODE_FUTURES:
      case SYMBOL_CALC_MODE_EXCH_FUTURES:
      case SYMBOL_CALC_MODE_EXCH_FUTURES_FORTS:
         result = point * SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_VALUE)
            / SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_SIZE);
         break;
      default:
         PrintFormat("Unsupported symbol %s trade mode: %s", symbol, EnumToString(m));
      }
      ...

      // 最后,如果账户货币与交易品种盈利货币不同,则将金额转换为账户货币。
      string account = AccountInfoString(ACCOUNT_CURRENCY);
      string current = SymbolInfoString(symbol, SYMBOL_CURRENCY_PROFIT);
   
      if(current != account)
      {
         if(!Convert(current, account, ask, result, moment)) return 0;
      }
     
      return result;
   }
   ...
};

辅助函数 Convert 用于转换金额。它又依赖于 FindExchangeRate 函数,FindExchangeRate 函数在所有可用的交易品种中搜索,找到一个包含从当前货币到账户货币汇率的交易品种。

c++
   bool Convert(const string current, const string account,
      const bool ask, double &margin, const datetime moment = 0)
   {
      string rate;
      int dir = FindExchangeRate(current, account, rate);
      if(dir == +1)
      {
         margin *= moment == 0 ?
            SymbolInfoDouble(rate, ask ? SYMBOL_BID : SYMBOL_ASK) :
            GetHistoricPrice(rate, moment, ask);
      }
      else if(dir == -1)
      {
         margin /= moment == 0 ?
            SymbolInfoDouble(rate, ask ? SYMBOL_ASK : SYMBOL_BID) :
            GetHistoricPrice(rate, moment, ask);
      }
      else
      {
         static bool once = false;
         if(!once)
         {
            Print("Can't convert ", current, " -> ", account);
            once = true;
         }
      }
      return true;
   }

FindExchangeRate 函数在市场报价窗口中查找交易品种,并在 result 参数中返回第一个匹配的外汇交易品种的名称(如果有多个匹配项)。如果报价对应于货币的直接顺序 “当前货币/账户货币”,函数将返回 +1;如果是相反顺序,即 “账户货币/当前货币”,则返回 -1

c++
   int FindExchangeRate(const string current, const string account, string &result)
   {
      for(int i = 0; i < SymbolsTotal(true); i++)
      {
         const string symbol = SymbolName(i, true);
         const ENUM_SYMBOL_CALC_MODE m =
            (ENUM_SYMBOL_CALC_MODE)SymbolInfoInteger(symbol, SYMBOL_TRADE_CALC_MODE);
         if(m == SYMBOL_CALC_MODE_FOREX || m == SYMBOL_CALC_MODE_FOREX_NO_LEVERAGE)
         {
            string base = SymbolInfoString(symbol, SYMBOL_CURRENCY_BASE);
            string profit = SymbolInfoString(symbol, SYMBOL_CURRENCY_PROFIT);
            if(base == current && profit == account)
            {
               result = symbol;
               return +1;
            }
            else
            if(base == account && profit == current)
            {
               result = symbol;
               return -1;
            }
         }
      }
      return 0;
   }

这些函数的完整代码可以在附件文件 MarginProfitMeter.mqh 中找到。

让我们使用测试脚本 ProfitMeter.mq5 来检查 OrderCalcProfit 函数以及 MPM 函数组的性能:我们将为市场报价窗口中的所有交易品种计算虚拟交易的盈利/亏损估算值,并且将使用两种方法进行计算:内置方法和我们自己开发的方法。

在脚本的输入参数中,你可以选择操作类型 Action(买入或卖出)、手数 Lot 以及以柱线为单位的持仓时间 Duration。财务结果是根据当前时间框架内最后 Duration 根柱线的报价计算得出的。

c++
#property script_show_inputs
   
input ENUM_ORDER_TYPE Action = ORDER_TYPE_BUY; // 操作类型(仅允许买入/卖出)
input float Lot = 1;
input int Duration = 20; // 持仓时间(过去的柱线数量)

在脚本主体中,我们连接头文件并显示带有参数的标题。

c++
#include <MQL5Book/MarginProfitMeter.mqh>
#include <MQL5Book/Periods.mqh>
   
void OnStart()
{
   // 确保操作类型仅为买入或卖出
   ENUM_ORDER_TYPE type = (ENUM_ORDER_TYPE)(Action % 2);
   const string text[] = {"buying", "selling"};
   PrintFormat("Profits/Losses for %s %s lots"
      " of %d symbols in Market Watch on last %d bars %s",
      text[type], (string)Lot, SymbolsTotal(true),
      Duration, PeriodToString(_Period));
   ...

   // 然后,通过循环遍历交易品种,我们用两种方式进行计算并打印结果以便比较。
   for(int i = 0; i < SymbolsTotal(true); i++)
   {
      const string symbol = SymbolName(i, true);
      const double enter = iClose(symbol, _Period, Duration);
      const double exit = iClose(symbol, _Period, 0);
      
      double profit1, profit2; // 两个用于存储结果的变量
      
      // 标准方法 
      if(!OrderCalcProfit(type, symbol, Lot, enter, exit, profit1))
      {
         PrintFormat("OrderCalcProfit(%s) failed: %d", symbol, _LastError);
         continue;
      }
      
      // 我们自己的方法 
      const int points = (int)MathRound((exit - enter)
         / SymbolInfoDouble(symbol, SYMBOL_POINT));
      profit2 = Lot * points * MPM::PointValue(symbol);
      profit2 = NormalizeDouble(profit2,
         (int)AccountInfoInteger(ACCOUNT_CURRENCY_DIGITS));
      if(type == ORDER_TYPE_SELL) profit2 *= -1;
      
      // 输出到日志以便比较
      PrintFormat("%s: %f %f", symbol, profit1, profit2);
   }
}

尝试在不同的账户和交易品种组合下运行该脚本。

plaintext
Profits/Losses for buying 1.0 lots of 13 symbols in Market Watch on last 20 bars H1
EURUSD: 390.000000 390.000000
GBPUSD: 214.000000 214.000000
USDCHF: -254.270000 -254.270000
USDJPY: -57.930000 -57.930000
USDCNH: -172.570000 -172.570000
USDRUB: 493.360000 493.360000
AUDUSD: 84.000000 84.000000
NZDUSD: 13.000000 13.000000
USDCAD: -97.480000 -97.480000
USDSEK: -682.910000 -682.910000
XAUUSD: -1706.000000 -1706.000000
SP500m: 5300.000000 5300.000000
XPDUSD: -84.030000 -84.030000

理想情况下,每一行的数字应该是匹配的。

MqlTradeRequest 结构体

MQL5 API 的交易函数,特别是 OrderCheckOrderSend,会操作几个内置结构体。因此,在介绍这些函数本身之前,我们需要先了解这些结构体。

让我们从 MqlTradeRequest 结构体开始,它包含了执行交易所需的所有字段。

cpp
struct MqlTradeRequest 
{ 
   ENUM_TRADE_REQUEST_ACTIONS action;       // 要执行的操作类型 
   ulong                      magic;        // 智能交易系统的唯一编号 
   ulong                      order;        // 订单编号 
   string                     symbol;       // 交易品种名称 
   double                     volume;       // 请求的交易手数 
   double                     price;        // 价格  
   double                     stoplimit;    // 止损限价订单的价位 
   double                     sl;           // 止损订单的价位 
   double                     tp;           // 止盈订单的价位 
   ulong                      deviation;    // 与给定价格的最大偏差
   ENUM_ORDER_TYPE            type;         // 订单类型 
   ENUM_ORDER_TYPE_FILLING    type_filling; // 订单执行类型 
   ENUM_ORDER_TYPE_TIME       type_time;    // 订单有效期类型 
   datetime                   expiration;   // 订单到期日期 
   string                     comment;      // 订单备注 
   ulong                      position;     // 持仓编号 
   ulong                      position_by;  // 反向持仓编号 
};

你不必担心字段数量众多:这个结构体的设计目的是满足所有可能的交易请求类型,不过,在每个具体案例中,通常只使用少数几个字段。

在填充字段之前,建议通过在定义时显式初始化,或者调用 ZeroMemory 函数将结构体置零。

cpp
MqlTradeRequest request = {};
...
ZeroMemory(request);

这样可以避免在那些未显式赋值的字段中,将随机值传递给 API 函数而可能导致的错误和副作用。

以下表格简要描述了这些字段。在介绍交易操作时,我们将了解如何填充这些字段。

字段描述
actionENUM_TRADE_REQUEST_ACTIONS 中的交易操作类型
magic智能交易系统 ID(可选)
order请求修改的挂单编号
symbol交易品种名称
volume请求的交易手数
price订单必须执行的价格
stoplimitORDER_TYPE_BUY_STOP_LIMITORDER_TYPE_SELL_STOP_LIMIT 订单激活时,限价订单将被放置的价格
sl当价格朝不利方向移动时,止损订单将触发的价格
tp当价格朝有利方向移动时,止盈订单将触发的价格
deviation与请求价格的最大可接受偏差(点数)
typeENUM_ORDER_TYPE 中的订单类型
type_fillingENUM_ORDER_TYPE_FILLING 中的订单成交类型
type_timeENUM_ORDER_TYPE_TIME 中的挂单到期类型
expiration挂单到期日期
comment订单备注
position持仓编号
position_byTRADE_ACTION_CLOSE_BY 操作的反向持仓编号

要发送交易操作的订单,需要根据操作的性质填充不同的字段集。有些字段是必需的,有些是可选的(填写时可以省略)。接下来,我们将在具体操作的上下文中更仔细地研究字段要求。

程序可以使用 OrderCheck 函数检查已形成的 MqlTradeRequest 结构体的正确性,或者使用 OrderSend 函数将其发送到服务器。如果成功,将执行请求的操作。

action 字段是所有交易活动唯一必需的字段。

magic 字段中的唯一编号通常仅在市价买入/卖出请求或创建新的挂单时指定。这会导致后续已完成的交易和持仓用该编号标记,从而可以对交易操作进行分析处理。在修改持仓或挂单的价格水平以及删除它们时,该字段没有影响。

当从 MetaTrader 5 界面手动执行交易操作时,无法设置 magic 标识符,因此它等于零。这提供了一种流行但不完全可靠的方法,在分析交易历史时区分手动交易和自动交易。实际上,智能交易系统也可以使用零标识符。因此,要了解是谁以及如何执行了特定的交易操作,请使用订单(ORDER_REASON)、成交(DEAL_REASON)和持仓(POSITION_REASON)的相应属性。

每个智能交易系统都可以设置自己的唯一 ID,甚至可以为不同的目的使用多个 ID(按交易策略、信号等划分)。持仓的 magic 编号对应于参与形成该持仓的最后一笔成交的 magic 编号。

symbol 字段中的交易品种名称仅在开仓或加仓以及挂单时重要。在修改和关闭订单及持仓的情况下,它将被忽略,但这里有一个小例外。由于在净额账户上每个交易品种只能存在一个持仓,因此在请求更改其保护价格水平(止损和止盈)时,symbol 字段可用于识别持仓。

volume 字段的使用方式相同:它在即时买入/卖出订单或创建挂单时需要。应考虑到操作中的实际手数将取决于执行模式,可能与请求的不同。

price 字段也有一些限制:当为执行模式为 SYMBOL_TRADE_EXECUTION_MARKETSYMBOL_TRADE_EXECUTION_EXCHANGE 的交易品种发送市价订单(actionTRADE_ACTION_DEAL)时,该字段将被忽略。

stoplimit 字段仅在设置止损限价订单时才有意义,即当 type 字段包含 ORDER_TYPE_BUY_STOP_LIMITORDER_TYPE_SELL_STOP_LIMIT 时。它指定了当价格达到 price 值时,挂单限价订单将被放置的价格(这一事实由 MetaTrader 5 服务器跟踪,在此之前,挂单不会显示在交易系统中)。

在挂单时,其到期规则在一对字段中设置:type_timeexpiration。后者包含 datetime 类型的值,仅当 type_time 等于 ORDER_TIME_SPECIFIEDORDER_TIME_SPECIFIED_DAY 时才会考虑该值。

最后,最后一对字段与查询中持仓的识别有关。基于订单(手动或通过程序)创建的每个新持仓都会获得系统分配的一个编号,这是一个唯一的数字。通常,它对应于开仓订单的编号,但可能会因服务器上的服务操作而更改,例如通过重新开仓计算掉期。

我们将在单独的章节中讨论获取持仓、成交和订单的属性。目前,对我们来说重要的是,在更改和关闭持仓时,应填充 position 字段以明确识别它。理论上,在净额账户上,在 symbol 字段中指定持仓的交易品种就足够了,但为了统一算法,最好还是使用 position 字段。

position_by 字段用于关闭反向持仓(TRADE_ACTION_CLOSE_BY)。它应指定为同一交易品种但与 position 方向相反的持仓(这仅在对冲账户上可能)。

deviation 字段仅在即时执行和请求执行模式下影响市价订单的执行。

每种类型交易操作填充该结构体的示例将在相关章节给出。

MqlTradeCheckResult 结构

在向交易服务器发送交易操作请求之前,建议先检查请求是否存在格式错误。此检查通过 OrderCheck 函数完成,我们需要将 MqlTradeRequest 结构中的请求和 MqlTradeCheckResult 结构类型的接收变量传递给该函数。

这个结构不仅能检查请求的正确性,还能用于评估交易操作执行后账户的状态,特别是余额、资金和保证金情况。

结构定义

c
struct MqlTradeCheckResult 
{ 
    uint   retcode;      // 响应代码
    double balance;      // 交易后的余额
    double equity;       // 交易后的自有资金
    double profit;       // 浮动盈亏
    double margin;       // 保证金要求
    double margin_free;  // 交易操作执行后剩余的可用自有资金
    double margin_level; // 交易操作执行后设置的保证金水平
    string comment;      // 响应代码的注释(错误描述)
};

字段说明

字段描述
retcode假定的返回代码
balance交易操作执行后将呈现的余额值
equity交易操作执行后将呈现的自有资金值
profit交易操作执行后将呈现的浮动盈亏值
margin交易后锁定的总保证金
margin_free交易操作执行后剩余的可用自有资金量
margin_level交易操作执行后设置的保证金水平
comment响应代码的注释,即错误描述

返回代码

在调用 OrderCheck 填充的结构中,retcode 字段将包含平台支持的用于处理实际交易请求的结果代码。调用交易函数 OrderSendOrderSendAsync 后,类似的 retcode 字段会被放入 MqlTradeResult 结构中。

返回代码常量在 MQL5 文档中有介绍。为了在调试智能交易系统时更直观地将这些代码输出到日志中,在 TradeRetcode.mqh 文件中定义了应用枚举 TRADE_RETCODE。其中所有元素的标识符与内置常量匹配,但没有通用的 TRADE_RETCODE_ 前缀。例如:

c
enum TRADE_RETCODE
{
    OK_0           = 0,      // 无标准常量
    REQUOTE        = 10004,  // TRADE_RETCODE_REQUOTE
    REJECT         = 10006,  // TRADE_RETCODE_REJECT
    CANCEL         = 10007,  // TRADE_RETCODE_CANCEL
    PLACED         = 10008,  // TRADE_RETCODE_PLACED
    DONE           = 10009,  // TRADE_RETCODE_DONE
    DONE_PARTIAL   = 10010,  // TRADE_RETCODE_DONE_PARTIAL
    ERROR          = 10011,  // TRADE_RETCODE_ERROR
    TIMEOUT        = 10012,  // TRADE_RETCODE_TIMEOUT
    INVALID        = 10013,  // TRADE_RETCODE_INVALID
    INVALID_VOLUME = 10014,  // TRADE_RETCODE_INVALID_VOLUME
    INVALID_PRICE  = 10015,  // TRADE_RETCODE_INVALID_PRICE
    INVALID_STOPS  = 10016,  // TRADE_RETCODE_INVALID_STOPS
    TRADE_DISABLED = 10017,  // TRADE_RETCODE_TRADE_DISABLED
    MARKET_CLOSED  = 10018,  // TRADE_RETCODE_MARKET_CLOSED
    ...
};

#define TRCSTR(X) EnumToString((TRADE_RETCODE)(X))

这样,使用 TRCSTR(r.retcode)(其中 r 是一个结构)将能对数字代码给出简要描述。

在下一节关于 OrderCheck 函数的内容中,我们将看到应用宏和分析该结构的示例。

请求验证:OrderCheck

要执行任何交易操作,MQL程序必须首先使用必要的数据填充MqlTradeRequest结构。在使用交易函数将其发送到服务器之前,对其进行格式正确性检查并评估请求的后果是很有必要的,特别是所需的保证金金额以及剩余的可用资金。这项检查由OrderCheck函数来执行。

c
bool OrderCheck(const MqlTradeRequest &request, MqlTradeCheckResult &result)

如果资金不足或者参数填写不正确,该函数将返回false。此外,当整个终端或特定程序的交易功能被禁用时,该函数也会拒绝执行。要查看错误代码,可以检查result结构的retcode字段。

对request结构和交易环境的成功检查会以返回状态true结束,然而,这并不保证如果使用OrderSend或OrderSendAsync函数重复执行请求操作就一定能成功。在调用之间交易条件可能会发生变化,或者服务器上的经纪商可能对特定的外部交易系统应用了一些设置,而这些设置在OrderCheck执行的形式验证算法中无法得到满足。

要获取预期财务结果的描述,应该分析result结构的字段。

与仅计算一个提议头寸或订单所需估算保证金的OrderCalcMargin函数不同,OrderCheck函数虽然是以简化模式,但会考虑交易账户的总体状态。因此,它会用订单执行后将形成的累计变量来填充MqlTradeCheckResult结构中的margin字段以及其他相关字段(margin_free、margin_level)。例如,如果在调用OrderCheck函数时,对于任何金融工具已经有一个头寸处于开仓状态,并且正在检查的请求会增加该头寸,那么margin字段将反映保证金金额,包括先前的保证金负债。如果新订单包含相反方向的操作,保证金将不会增加(实际上,它应该减少,因为在净额结算账户上一个头寸应该完全平仓,而在对冲账户上应该应用对冲保证金;然而,该函数不会执行如此精确的计算)。

首先,OrderCheck函数对于刚开始熟悉交易API的程序员很有用,以便在不将请求发送到服务器的情况下对请求进行试验。

让我们使用一个简单的非交易型智能交易系统CustomOrderCheck.mq5来测试fOrderCheck函数的性能。我们将其设计为智能交易系统而不是脚本,是为了便于使用:这样,在使用当前设置启动后,它将保留在图表上,并且可以通过更改各个输入参数轻松地进行编辑。如果是脚本,我们每次都必须从默认值开始设置字段。

为了运行检查,我们在OnInit函数中设置一个定时器。

c
void OnInit()
{
   // 启动待执行操作
   EventSetTimer(1);
}

至于定时器处理函数,主要算法将在其中实现。在最开始,我们取消定时器,因为我们只需要代码执行一次,然后等待用户更改参数。

c
void OnTimer()
{
   // 执行一次代码并等待新的用户设置
   EventKillTimer();
   ...
}

智能交易系统的输入参数完全重复了交易请求结构的字段集。

c
input ENUM_TRADE_REQUEST_ACTIONS Action = TRADE_ACTION_DEAL;
input ulong Magic;
input ulong Order;
input string Symbol;    // 品种(空 = 当前 _Symbol)
input double Volume;    // 交易量(0 = 最小手数)
input double Price;     // 价格(0 = 当前卖出价)
input double StopLimit;
input double SL;
input double TP;
input ulong Deviation;
input ENUM_ORDER_TYPE Type;
input ENUM_ORDER_TYPE_FILLING Filling;
input ENUM_ORDER_TYPE_TIME ExpirationType;
input datetime ExpirationTime;
input string Comment;
input ulong Position;
input ulong PositionBy;

其中许多参数不会影响检查和财务绩效,但保留这些参数可以让你对此更有把握。

默认情况下,变量的状态对应于以当前金融工具的最小手数开仓的请求。特别是,Type参数在没有显式初始化的情况下将获得值0,这等于ENUM_ORDER_TYPE结构中ORDER_TYPE_BUY成员的值。在Action参数中,我们指定了显式初始化,因为0不对应于ENUM_TRADE_REQUEST_ACTIONS枚举的任何元素(TRADE_ACTION_DEAL的第一个元素是1)。

c
void OnTimer()
{
   ...
   // 用零初始化结构
   MqlTradeRequest request = {};
   MqlTradeCheckResult result = {};
   
   // 默认值
   const bool kindOfBuy = (Type & 1) == 0;
   const string symbol = StringLen(Symbol) == 0? _Symbol : Symbol;
   const double volume = Volume == 0?
      SymbolInfoDouble(symbol, SYMBOL_VOLUME_MIN) : Volume;
   const double price = Price == 0?
      SymbolInfoDouble(symbol, kindOfBuy? SYMBOL_ASK : SYMBOL_BID) : Price;
   ...

让我们填充结构。真正的智能交易机器人通常只需要分配几个字段,但由于这个测试是通用的,我们必须确保用户输入的任何参数都能被传递。

c
   request.action = Action;
   request.magic = Magic;
   request.order = Order;
   request.symbol = symbol;
   request.volume = volume;
   request.price = price;
   request.stoplimit = StopLimit;
   request.sl = SL;
   request.tp = TP;
   request.deviation = Deviation;
   request.type = Type;
   request.type_filling = Filling;
   request.type_time = ExpirationType;
   request.expiration = ExpirationTime;
   request.comment = Comment;
   request.position = Position;
   request.position_by = PositionBy;
   ...

请注意,这里我们还没有对价格和手数进行归一化处理,尽管在实际程序中这是必需的。因此,这个测试使得输入“不规则”的值成为可能,并确保这些值会导致错误。在以下示例中,将启用归一化处理。

然后我们调用OrderCheck函数,并将请求和结果结构记录到日志中。我们只对后者的retcode字段感兴趣,因此会使用宏TRCSTR(TradeRetcode.mqh)以文本形式“解密”后额外打印该字段。你也可以分析字符串字段comment,但它的格式可能会改变,以便更适合显示给用户。

c
   ResetLastError();
   PRTF(OrderCheck(request, result));
   StructPrint(request, ARRAYPRINT_HEADER);
   Print(TRCSTR(result.retcode));
   StructPrint(result, ARRAYPRINT_HEADER, 2);
   ...

结构的输出由基于ArrayPrint的辅助函数StructPrint提供。因此,我们仍然会得到数据的“原始”显示。特别是,枚举的元素将按“原样”以数字形式表示。稍后我们将开发一个函数,用于更清晰(用户友好)地输出MqlTradeRequest结构(请参阅TradeUtils.mqh)。

为了便于分析结果,在OnTimer函数的开头,我们将显示账户的当前状态,并且在最后,为了进行比较,我们将使用OrderCalcMargin函数计算给定交易操作的保证金。

c
void OnTimer()
{
   PRTF(AccountInfoDouble(ACCOUNT_EQUITY));
   PRTF(AccountInfoDouble(ACCOUNT_PROFIT));
   PRTF(AccountInfoDouble(ACCOUNT_MARGIN));
   PRTF(AccountInfoDouble(ACCOUNT_MARGIN_FREE));
   PRTF(AccountInfoDouble(ACCOUNT_MARGIN_LEVEL));
   ...
   // 填充 MqlTradeRequest 结构
   // 调用 OrderCheck 并打印结果
   ...
   double margin = 0;
   ResetLastError();
   PRTF(OrderCalcMargin(Type, symbol, volume, price, margin));
   PRTF(margin);
}

以下是使用默认设置对XAUUSD进行测试的日志示例。

AccountInfoDouble(ACCOUNT_EQUITY)=15565.22 / 正常

AccountInfoDouble(ACCOUNT_PROFIT)=0.0 / 正常

AccountInfoDouble(ACCOUNT_MARGIN)=0.0 / 正常

AccountInfoDouble(ACCOUNT_MARGIN_FREE)=15565.22 / 正常

AccountInfoDouble(ACCOUNT_MARGIN_LEVEL)=0.0 / 正常

OrderCheck(request,result)=true / 正常

[action] [magic] [order] [symbol] [volume] [price] [stoplimit] [sl] [tp] [deviation] [type] »

       1       0       0 "XAUUSD"     0.01 1899.97        0.00 0.00 0.00           0      0 »

 » [type_filling] [type_time]        [expiration] [comment] [position] [position_by] [reserved]

 »             0           0 1970.01.01 00:00:00 ""                 0             0          0

OK_0

[retcode] [balance] [equity] [profit] [margin] [margin_free] [margin_level] [comment] [reserved]

        0  15565.22 15565.22     0.00    19.00      15546.22       81922.21 "Done"             0

OrderCalcMargin(Type,symbol,volume,price,margin)=true / 正常

margin=19.0 / 正常

下一个示例显示了对账户上预期保证金增加的估算,其中已经有一个开仓头寸,我们打算将其翻倍。

AccountInfoDouble(ACCOUNT_EQUITY)=9999.540000000001 / 正常

AccountInfoDouble(ACCOUNT_PROFIT)=-0.83 / 正常

AccountInfoDouble(ACCOUNT_MARGIN)=79.22 / 正常

AccountInfoDouble(ACCOUNT_MARGIN_FREE)=9920.32 / 正常

AccountInfoDouble(ACCOUNT_MARGIN_LEVEL)=12622.49431961626 / 正常

OrderCheck(request,result)=true / 正常

[action] [magic] [order]  [symbol] [volume] [price] [stoplimit] [sl] [tp] [deviation] [type] »

       1       0       0 "PLZL.MM"      1.0 12642.0         0.0  0.0  0.0           0      0 »

 » [type_filling] [type_time]        [expiration] [comment] [position] [position_by] [reserved]

 »              0           0 1970.01.01 00:00:00 ""                 0             0          0

OK_0

[retcode] [balance] [equity] [profit] [margin] [margin_free] [margin_level] [comment] [reserved]

        0  10000.87  9999.54    -0.83   158.26       9841.28        6318.43 "Done"             0

OrderCalcMargin(Type,symbol,volume,price,margin)=true / 正常

margin=79.04000000000001 / 正常

尝试更改任何请求参数,看看请求是否成功。不正确的参数组合将导致标准列表中的错误代码,但由于无效选项比保留选项(最常见的错误)多得多,该函数经常会返回通用代码TRADE_RETCODE_INVALID(10013)。在这方面,建议实现你自己的结构检查,以进行更深入的诊断。

当向服务器发送实际请求时,在各种不可预见的情况下也会使用相同的TRADE_RETCODE_INVALID代码,例如,当试图重新编辑一个订单,而该订单的修改操作已经在外部交易系统中启动(但尚未完成)时。

请求发送结果:MqlTradeResult 结构体

当使用 OrderSendOrderSendAsync 函数(我们将在下一节介绍)执行交易请求时,服务器会返回请求处理结果。为此,使用了一个特殊的预定义结构体 MqlTradeResult

c
struct MqlTradeResult 
{ 
   uint     retcode;          // 操作结果代码 
   ulong    deal;             // 若交易完成,为交易单号 
   ulong    order;            // 若订单已下达,为订单单号 
   double   volume;           // 经纪商确认的交易手数 
   double   price;            // 经纪商确认的交易价格 
   double   bid;              // 当前市场买入价 
   double   ask;              // 当前市场卖出价 
   string   comment;          // 经纪商对该操作的注释 
   uint     request_id;       // 发送时由终端设置的请求标识符 
   uint     retcode_external; // 外部交易系统的响应代码 
};

以下表格对其字段进行了详细描述:

字段描述
retcode交易服务器返回的代码
deal若交易执行(在 TRADE_ACTION_DEAL 交易操作期间),为交易单号
order若订单已下达(在 TRADE_ACTION_PENDING 交易操作期间),为订单单号
volume经纪商确认的交易手数(取决于订单执行模式)
price经纪商确认的交易价格(取决于交易请求中的偏差字段、执行模式和交易操作)
bid当前市场买入价
ask当前市场卖出价
comment经纪商对该交易的注释(默认情况下,会填充交易服务器返回代码的解密信息)
request_id终端在将请求发送到交易服务器时设置的请求 ID
retcode_external外部交易系统返回的错误代码

在后续进行交易操作时可以看到,MqlTradeResult 类型的变量会作为第二个参数通过引用传递给 OrderSendOrderSendAsync 函数,该函数会返回处理结果。

当向服务器发送交易请求时,终端会将 request_id 标识符设置为一个唯一值。如果使用异步函数 OrderSendAsync,这对于后续交易事件的分析是必要的。这个标识符可以将发送的请求与传递给 OnTradeTransaction 事件处理程序的处理结果关联起来。

retcode_external 字段中错误的存在和类型取决于经纪商以及交易操作转发到的外部交易系统。

根据不同的交易操作及其发送方式,会以不同的方式分析请求结果。我们将在后续关于具体操作(如市价买卖、挂单和删除挂单、修改和平仓)的章节中处理这个问题。

交易请求的发送:OrderSend 和 OrderSendAsync

为了执行交易操作,MQL5 API 提供了两个函数:OrderSendOrderSendAsync。和 OrderCheck 一样,它们会对以 MqlTradeRequest 结构体形式传入的请求参数进行形式检查,若检查通过,就会向服务器发送请求。

这两个函数的区别如下:OrderSend 会等待订单被加入服务器的处理队列,并将有意义的数据存入作为第二个函数参数传入的 MqlTradeResult 结构体的字段中。而 OrderSendAsync 会立即将控制权交还给调用代码,不管服务器的响应如何。同时,在 MqlTradeResult 结构体中,除了 retcode 字段,只有 request_id 字段会被填入重要信息。MQL 程序可以使用这个请求标识符,在 OnTradeTransaction 事件中获取该请求处理进度的更多信息。另一种方法是定期分析订单、成交和持仓列表,也可以在循环中进行,若出现通信问题可设置一些超时机制。

需要注意的是,尽管第二个函数名称中有 “Async” 后缀,但没有该后缀的第一个函数也并非完全同步。实际上,服务器处理订单的结果,特别是成交(或者可能基于一个订单产生多笔成交)和开仓操作,通常是在外部交易系统中异步发生的。所以 OrderSend 函数也需要延迟收集和分析请求执行的结果,必要时 MQL 程序必须自行实现这一点。后面我们会看一个真正同步发送请求并获取其所有结果的示例(见 MqlTradeSync.mqh)。

cpp
bool OrderSend(const MqlTradeRequest &request, MqlTradeResult &result)

如果在终端对请求结构体进行的基本检查以及在服务器上进行的一些额外检查都成功,该函数会返回 true。但这仅表明服务器接受了订单,并不保证交易操作能成功执行。

如果服务器在格式化对 OrderSend 调用的响应时已知相关数据,交易服务器会在返回的结果结构体中填充 dealorder 字段的值。不过一般情况下,成交执行或与订单对应的限价单挂单事件可能在服务器向终端中的 MQL 程序发送响应之后才发生。因此,对于任何类型的交易请求,在接收 OrderSend 执行结果时,都必须检查交易服务器返回码 retcode 和外部交易系统响应码 retcode_external(如有必要),这些信息可在返回的结果结构体中获取。根据这些信息,你应该决定是等待服务器上的待处理操作,还是采取自己的行动。

每个被接受的订单都会存储在交易服务器上等待处理,直到发生以下任何一个影响其生命周期的事件:

  1. 当出现反向请求时执行。
  2. 当执行价格到达时触发。
  3. 到达到期日期。
  4. 被用户或 MQL 程序取消。
  5. 被经纪商移除(例如,在清算或资金不足、爆仓的情况下)。

OrderSendAsync 的原型与 OrderSend 完全相同。

cpp
bool OrderSendAsync(const MqlTradeRequest &request, MqlTradeResult &result)

该函数用于高频交易,当算法条件不允许浪费时间等待服务器响应时使用。使用 OrderSendAsync 并不能加快服务器对请求的处理速度,也不能加快向外部交易系统发送请求的速度。

注意:在策略测试器中,OrderSendAsync 函数的行为与 OrderSend 相同,这给异步请求的待处理操作调试带来了困难。

如果请求成功发送到 MetaTrader 5 服务器,该函数会返回 true。但这并不意味着请求已到达服务器并被接受处理。同时,接收结果结构体中的响应码包含 TRADE_RETCODE_PLACED(10008)值,即 “订单已挂单”。

在处理接收到的请求时,服务器会向终端发送一条关于持仓、订单和成交当前状态变化的响应消息,这会在 MQL 程序中触发 OnTrade 事件。在该事件中,程序可以分析新的交易环境和账户历史。下面我们会看相关示例。

此外,还可以使用 OnTradeTransaction 处理程序跟踪服务器上交易请求的执行细节。同时,需要考虑到,执行一个交易请求可能会多次调用 OnTradeTransaction 处理程序。例如,发送一个市价买入请求时,服务器会接受该请求进行处理,为账户创建一个相应的 “买入” 订单,执行该订单并完成成交,之后该订单会从未平仓订单列表中移除并添加到订单历史中。然后,成交会被添加到历史记录中,并创建一个新的持仓。对于这些事件中的每一个,OnTradeTransaction 函数都会被调用。

让我们从一个简单的智能交易系统示例 CustomOrderSend.mq5 开始。它允许在输入参数中设置请求的所有字段,这与 CustomOrderCheck.mq5 类似,但不同之处在于,它会向服务器发送请求,而不是仅在终端中进行简单检查。在你的模拟账户上运行这个智能交易系统。完成测试后,别忘了从图表中移除该智能交易系统或关闭图表,以免每次启动终端时都发送测试请求。

这个新示例还有其他一些改进。首先,添加了输入参数 Async

cpp
input bool Async = false;

这个选项允许选择将请求发送到服务器的函数。默认情况下,该参数为 false,使用 OrderSend 函数。如果将其设置为 true,则会调用 OrderSendAsync 函数。

此外,通过这个示例,我们将开始在头文件 TradeUtils.mqh 中描述并完善一组特殊的函数,这些函数将有助于简化智能交易系统的编码。所有函数都放在命名空间 TU(取自 “Trade Utilities”)中,首先,我们引入一些函数,用于方便地将 MqlTradeRequestMqlTradeResult 结构体信息输出到日志中。

cpp
namespace TU
{
   string StringOf(const MqlTradeRequest &r)
   {
      SymbolMetrics p(r.symbol);
      
      // 主要部分:操作、类型、交易品种
      string text = EnumToString(r.action);
      if(r.symbol != NULL) text += ", " + r.symbol;
      text += ", " + EnumToString(r.type);
      // 手数部分
      if(r.volume != 0) text += ", V=" + p.StringOf(r.volume, p.lotDigits);
      text += ", " + EnumToString(r.type_filling);
      // 所有价格部分
      if(r.price != 0) text += ", @ " + p.StringOf(r.price);
      if(r.stoplimit != 0) text += ", X=" + p.StringOf(r.stoplimit);
      if(r.sl != 0) text += ", SL=" + p.StringOf(r.sl);
      if(r.tp != 0) text += ", TP=" + p.StringOf(r.tp);
      if(r.deviation != 0) text += ", D=" + (string)r.deviation;
      // 挂单到期部分
      if(IsPendingType(r.type)) text += ", " + EnumToString(r.type_time);
      if(r.expiration != 0) text += ", " + TimeToString(r.expiration);
      // 修改部分
      if(r.order != 0) text += ", #=" + (string)r.order;
      if(r.position != 0) text += ", #P=" + (string)r.position;
      if(r.position_by != 0) text += ", #b=" + (string)r.position_by;
      // 辅助数据
      if(r.magic != 0) text += ", M=" + (string)r.magic;
      if(StringLen(r.comment)) text += ", " + r.comment;
      
      return text;
   }
   
   string StringOf(const MqlTradeResult &r)
   {
      string text = TRCSTR(r.retcode);
      if(r.deal != 0) text += ", D=" + (string)r.deal;
      if(r.order != 0) text += ", #=" + (string)r.order;
      if(r.volume != 0) text += ", V=" + (string)r.volume;
      if(r.price != 0) text += ", @ " + (string)r.price; 
      if(r.bid != 0) text += ", Bid=" + (string)r.bid; 
      if(r.ask != 0) text += ", Ask=" + (string)r.ask; 
      if(StringLen(r.comment)) text += ", " + r.comment;
      if(r.request_id != 0) text += ", Req=" + (string)r.request_id;
      if(r.retcode_external != 0) text += ", Ext=" + (string)r.retcode_external;
      
      return text;
   }
   // ...
};

这些函数的目的是以简洁且方便的形式提供所有重要(非空)字段:它们会在一行中显示,每个字段都有唯一的标识。

可以看到,StringOf(const MqlTradeRequest &r) 函数使用了 SymbolMetrics 类。它有助于对同一交易品种的多个价格或手数进行规范化处理。别忘了,对价格和手数进行规范化处理是准备正确交易请求的前提条件。

cpp
class SymbolMetrics
{
public:
   const string symbol;
   const int digits;
   const int lotDigits;
   
   SymbolMetrics(const string s): symbol(s),
      digits((int)SymbolInfoInteger(s, SYMBOL_DIGITS)),
      lotDigits((int)MathLog10(1.0 / SymbolInfoDouble(s, SYMBOL_VOLUME_STEP)))
   { }
      
   double price(const double p)
   {
      return TU::NormalizePrice(p, symbol);
   }
   
   double volume(const double v)
   {
      return TU::NormalizeLot(v, symbol);
   }

   string StringOf(const double v, const int d = INT_MAX)
   {
      return DoubleToString(v, d == INT_MAX ? digits : d);
   }
};

值的直接规范化处理由辅助函数 NormalizePriceNormalizeLot 完成(后者的实现方案与我们在 LotMarginExposure.mqh 文件中看到的相同)。

cpp
double NormalizePrice(const double price, const string symbol = NULL)
{
   const double tick = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_SIZE);
   return MathRound(price / tick) * tick;
}

如果包含 TradeUtils.mqh,示例 CustomOrderSend.mq5 具有以下形式(省略的代码片段 ...CustomOrderCheck.mq5 中保持不变)。

cpp
void OnTimer()
{
   // ...
   MqlTradeRequest request = {};
   MqlTradeCheckResult result = {};
   
   TU::SymbolMetrics sm(symbol);
   
   // 填充请求结构体
   request.action = Action;
   request.magic = Magic;
   request.order = Order;
   request.symbol = symbol;
   request.volume = sm.volume(volume);
   request.price = sm.price(price);
   request.stoplimit = sm.price(StopLimit);
   request.sl = sm.price(SL);
   request.tp = sm.price(TP);
   request.deviation = Deviation;
   request.type = Type;
   request.type_filling = Filling;
   request.type_time = ExpirationType;
   request.expiration = ExpirationTime;
   request.comment = Comment;
   request.position = Position;
   request.position_by = PositionBy;
   
   // 发送请求并显示结果
   ResetLastError();
   if(Async)
   {
      PRTF(OrderSendAsync(request, result));
   }
   else
   {
      PRTF(OrderSend(request, result));
   }
   Print(TU::StringOf(request));
   Print(TU::StringOf(result));
}

由于现在价格和手数已经规范化,你可以尝试在相应的输入参数中输入非标准的值。这些值通常是程序在计算过程中得到的,我们的代码会根据交易品种的规格对它们进行转换。

在默认设置下,智能交易系统会创建一个按市价买入当前交易品种最小手数的请求,并使用 OrderSend 函数执行该请求。

plaintext
OrderSend(request,result)=true / ok
TRADE_ACTION_DEAL, EURUSD, ORDER_TYPE_BUY, V=0.01, ORDER_FILLING_FOK, @ 1.12462
DONE, D=1250236209, #=1267684253, V=0.01, @ 1.12462, Bid=1.12456, Ask=1.12462, Request executed, Req=1

通常情况下,如果允许交易,这个操作应该会成功完成(状态为 DONE,备注为 “Request executed”)。在结果结构体中,我们立即得到了成交编号 D

如果我们打开智能交易系统设置,将参数 Async 的值替换为 true,我们将使用 OrderSendAsync 函数发送类似的请求。

plaintext
OrderSendAsync(request,result)=true / ok
TRADE_ACTION_DEAL, EURUSD, ORDER_TYPE_BUY, V=0.01, ORDER_FILLING_FOK, @ 1.12449
PLACED, Order placed, Req=2

在这种情况下,状态为 PLACED,函数返回时还不知道成交编号。我们只知道唯一的请求 ID Req=2。要获取成交和持仓编号,需要在 OnTradeTransaction 处理程序中拦截具有相同请求 ID 的 TRADE_TRANSACTION_REQUEST 消息,在那里会将填充好的结构体作为 MqlTradeResult 参数接收。

从用户的角度来看,这两个请求的速度应该是一样的。

在 MQL 程序的代码中,可以通过另一个智能交易系统示例(见同步和异步请求部分)直接比较这两个函数的性能,我们将在研究交易事件模型之后再讨论这个示例。

需要注意的是,无论使用 OrderSend 还是 OrderSendAsync 函数发送请求,交易事件都会发送到 OnTradeTransaction 处理程序(如果代码中存在该处理程序)。情况如下:使用 OrderSend 时,关于订单执行的部分或全部信息会立即在接收的 MqlTradeResult 结构体中可用。但一般情况下,结果会在时间和手数上分散,例如,一个订单可能会拆分成多笔成交。此时,可以从交易事件中或通过分析交易和订单历史来获取完整信息。

如果你尝试发送一个明显错误的请求,例如,将订单类型更改为挂单 ORDER_TYPE_BUY_STOP,你会收到一条错误消息,因为对于此类订单,应该使用 TRADE_ACTION_PENDING 操作。此外,它们应该与当前价格保持一定距离(默认情况下我们使用市价)。在进行此测试之前,重要的是不要忘记将查询模式改回同步模式(Async=false),以便在 OrderSend 调用结束后立即在 MqlTradeResult 结构体中看到错误信息。否则,OrderSendAsync 会返回 true,但订单仍不会被设置,程序只能在 OnTradeTransaction 中获取相关信息,而我们目前还没有实现该处理程序。

plaintext
OrderSend(request,result)=false / TRADE_SEND_FAILED(4756)
TRADE_ACTION_DEAL, EURUSD, ORDER_TYPE_BUY_STOP, V=0.01, ORDER_FILLING_FOK, @ 1.12452, ORDER_TIME_GTC
REQUOTE, Bid=1.12449, Ask=1.12452, Requote, Req=5

在这种情况下,错误报告价格重新报价无效。

后续章节将给出使用这些函数执行特定交易操作的示例。

买卖操作

在本节中,我们终于开始研究 MQL5 函数在特定交易任务中的应用。这些函数的目的是以特殊方式填充 MqlTradeRequest 结构体,并调用 OrderSendOrderSendAsync 函数。

我们要学习的第一个操作是按当前市场价格买卖金融工具。执行此操作的步骤包括:

  1. 根据提交的订单创建一个市价单。
  2. 根据订单执行一笔或多笔交易。
  3. 结果应该是一个已开仓的头寸。

正如我们在交易操作类型部分中所看到的,即时买入/卖出对应于 ENUM_TRADE_REQUEST_ACTIONS 枚举中的 TRADE_ACTION_DEAL 元素。因此,在填充 MqlTradeRequest 结构体时,应在 action 字段中写入 TRADE_ACTION_DEAL

交易方向使用 type 字段设置,该字段应包含以下订单类型之一:ORDER_TYPE_BUYORDER_TYPE_SELL

当然,要进行买卖操作,需要在 symbol 字段中指定交易品种的名称,并在 volume 字段中指定所需的交易量。

type_filling 字段必须使用 ENUM_ORDER_TYPE_FILLING 枚举中的一种成交策略填充,该策略是根据交易品种属性 SYMBOL_FILLING_MODE 中允许的策略来选择的。

程序可以选择性地在字段中填充保护性价格水平(止损 sl 和止盈 tp)、注释(comment)和智能交易系统 ID(magic)。

其他字段的内容根据所选交易品种的价格执行模式而有所不同。在某些模式下,某些字段不起作用。例如,在请求执行和即时执行模式下,price 字段必须用合适的价格填充(买入时为最后已知的卖价 Ask,卖出时为买价 Bid),deviation 字段可以包含为使交易成功执行,价格与设定价格之间的最大允许偏差。在交易所执行和市场执行模式下,这些字段将被忽略。为了简化源代码,你可以在所有模式下统一填充 priceslippage(滑点,此处 deviation 可理解为滑点相关概念),但在最后两种模式中,价格仍将由交易服务器根据模式规则选择并替换。

此处未提及的 MqlTradeRequest 结构体的其他字段在这些交易操作中不使用。

下表总结了不同执行模式下填充字段的规则。必填字段用星号(*)标记,可选字段用加号(+)标记。

字段请求执行(Request)即时执行(Instant)交易所执行(Exchange)市场执行(Market)
action****
symbol****
volume****
type****
type_filling****
price**
sl++++
tp++++
deviation++
magic++++
comment++++

根据服务器设置,在开仓时可能禁止填充保护性止损 sl 和止盈 tp 水平的字段。在交易所执行或市场执行模式下通常是这种情况,但 MQL5 API 并未提供预先明确这种情况的属性。在这种情况下,应通过修改已开仓的头寸来设置止损和止盈。顺便说一下,这种方法适用于所有执行模式,因为这是唯一一种可以准确地根据实际开仓价格来设置保护性水平的方法。另一方面,分两步创建和设置头寸可能会导致这样一种情况:头寸已开仓,但设置保护性水平的第二个请求由于某种原因失败了。

无论交易方向(买入/卖出)如何,止损订单始终设置为止损单(ORDER_TYPE_BUY_STOPORDER_TYPE_SELL_STOP),止盈订单设置为限价单(ORDER_TYPE_BUY_LIMITORDER_TYPE_SELL_LIMIT)。此外,止损单始终由 MetaTrader 5 服务器控制,只有当价格达到指定水平时,才会将其发送到外部交易系统。相比之下,限价单可以直接输出到外部交易系统。具体来说,对于交易所交易的品种通常是这种情况。

为了简化交易操作(不仅是买卖操作,还有所有其他操作)的编码,从本节开始,我们将开发类,确切地说是结构体,这些结构体将自动且正确地填充交易请求的字段,并真正同步等待结果。后者尤为重要,因为 OrderSendOrderSendAsync 函数在交易操作完全完成之前就会将控制权返回给调用代码。特别是对于市价买入和卖出操作,算法通常需要知道的不是在服务器上创建的订单号,而是头寸是否已开仓。根据这一情况,例如,如果头寸已开仓,算法可以通过设置止损和止盈来修改头寸;如果订单被拒绝,则可以重试开仓。

稍后我们将学习 OnTradeOnTradeTransaction 交易事件,这些事件会通知程序账户状态的变化,包括订单、交易和头寸的状态。然而,将算法分为两个部分 —— 分别根据某些信号或规则生成订单,以及在事件处理程序中分别分析情况 —— 会使代码的可读性和可维护性降低。

从理论上讲,异步编程范式在速度和编码简易性方面并不逊色于同步编程范式。然而,其实现方式可能有所不同,例如,基于对回调函数的直接指针(这是 Java、JavaScript 和许多其他语言中的基本技术)或事件(如在 MQL5 中),这决定了一些特性,我们将在 OnTradeTransaction 部分中讨论这些特性。异步模式允许通过延迟对请求执行的控制来加快请求的发送速度。但迟早仍需要在同一线程中进行这种控制,因此这些电路的平均性能是相同的。

所有新的结构体都将放在 MqlTradeSync.mqh 文件中。为了避免 “重复造轮子”,我们以 MQL5 内置的结构体为起点,并将我们的结构体描述为子结构体。例如,为了获取查询结果,我们定义 MqlTradeResultSync,它派生自 MqlTradeResult。在这里,我们将添加有用的字段和方法,特别是 position 字段,用于存储市价买入或卖出操作结果中已开仓头寸的订单号。

cpp
struct MqlTradeResultSync: public MqlTradeResult
{
   ulong position;
   ...
};

第二个重要的改进是一个构造函数,它会重置所有字段(这使我们在描述结构体类型的变量时无需指定显式初始化)。

cpp
MqlTradeResultSync()
{
   ZeroMemory(this);
}

接下来,我们将引入一种通用的同步机制,即等待请求的结果(每种类型的请求都将有自己的检查就绪规则)。

让我们定义条件回调函数的类型。这种类型的函数必须接受 MqlTradeResultSync 结构体参数,如果操作成功(即接收到操作结果),则返回 true

cpp
typedef bool (*condition)(MqlTradeResultSync &ref);

像这样的函数将被传递给 wait 方法,该方法在预定义的超时时间(以毫秒为单位)内实现对结果就绪状态的循环检查。

cpp
bool wait(condition p, const ulong msc = 1000)
{
   const ulong start = GetTickCount64();
   bool success;
   while(!(success = p(this)) && GetTickCount64() - start < msc);
   return success;
}

需要立即说明的是,超时时间是最大等待时间:即使将其设置为非常大的值,一旦接收到结果,循环也会立即结束,而这可能会立即发生。当然,有意义的超时时间不应超过几秒。

让我们看一个方法示例,该方法将用于同步等待服务器上出现订单(订单状态如何并不重要:状态分析是调用代码的任务)。

cpp
static bool orderExist(MqlTradeResultSync &ref)
{
   return OrderSelect(ref.order) || HistoryOrderSelect(ref.order);
}

这里应用了两个 MQL5 API 内置函数 OrderSelectHistoryOrderSelect:它们在终端的内部交易环境中按订单号搜索并逻辑选择订单。首先,这确认了订单的存在(如果其中一个函数返回 true),其次,它允许使用其他函数读取订单的属性,这对我们目前来说并不重要。我们将在单独的章节中介绍所有这些特性。之所以同时使用这两个函数,是因为市价单可能会被快速成交,以至于其活跃阶段(属于 OrderSelect 的范畴)会立即进入历史记录(HistoryOrderSelect)。

请注意,该方法被声明为 static。这是因为 MQL5 不支持指向对象方法的指针。如果支持的话,我们可以将该方法声明为非 static,同时使用指向条件回调函数的指针原型,而无需引用 MqlTradeResultSync 的参数(因为所有字段都存在于 this 对象内部)。

等待机制可以如下启动:

cpp
if(wait(orderExist))
{
   // 有订单
}
else
{
   // 超时
}

当然,这个代码片段必须在我们从服务器接收到状态为 TRADE_RETCODE_DONETRADE_RETCODE_DONE_PARTIAL 的结果,并且 MqlTradeResultSync 结构体中的 order 字段保证包含订单号之后执行。请注意,由于系统的分布式特性,服务器上的订单可能不会立即显示在终端环境中。这就是为什么需要等待时间。

只要 orderExist 函数向 wait 方法返回 falsewait 循环就会一直运行,直到超时时间结束。在正常情况下,我们几乎会立即在终端环境中找到订单,并且循环将以成功标志(true)结束。

positionExist 函数以类似但稍微复杂一点的方式检查是否存在已开仓的头寸。由于之前的 orderExist 函数已经完成了对订单的检查,结构体 ref.order 字段中包含的订单号已被确认为有效。

cpp
static bool positionExist(MqlTradeResultSync &ref)
{
   ulong posid, ticket;
   if(HistoryOrderGetInteger(ref.order, ORDER_POSITION_ID, posid))
   {
      // 在大多数情况下,头寸 ID 等于订单号,
      // 但并非总是如此:完整的代码实现了通过 ID 获取订单号的功能,
      // 而 MQL5 没有内置的工具来实现这一点
      ticket = posid;
      
      if(HistorySelectByPosition(posid))
      {
         ref.position = ticket;
         ...
         return true;
      }
   }
   return false;
}

使用内置的 HistoryOrderGetIntegerHistorySelectByPosition 函数,我们根据订单获取头寸的 ID 和订单号。

稍后我们将看到在验证买入/卖出请求时使用 orderExistpositionExist 的情况,但现在让我们来看另一个结构体:MqlTradeRequestSync。它也是从内置结构体继承而来,并包含额外的字段,特别是一个包含结果的结构体(这样就无需在调用代码中描述它)以及同步请求的超时时间。

cpp
struct MqlTradeRequestSync: public MqlTradeRequest
{
   MqlTradeResultSync result;
   ulong timeout;
   ...

由于新结构体的继承字段是公共的,MQL 程序可以像对标准 MqlTradeRequest 结构体那样显式地为它们赋值。我们将添加的执行交易操作的方法将考虑、检查这些值,并在必要时将其更正为有效值。

在构造函数中,我们重置所有字段,并在省略参数的情况下将交易品种设置为默认值。

cpp
MqlTradeRequestSync(const string s = NULL, const ulong t = 1000): timeout(t)
{
   ZeroMemory(this);
   symbol = s == NULL ? _Symbol : s;
}

从理论上讲,由于结构体的所有字段都是公共的,从技术上讲可以直接赋值,但对于那些需要验证的字段以及我们为其实现了设置方法的字段,不建议这样做:在执行交易操作之前将调用这些设置方法。其中第一个方法是 setSymbol

它填充 symbol 字段,确保传输的交易品种代码存在,并启动后续的交易量填充模式设置。

cpp
bool setSymbol(const string s)
{
   if(s == NULL)
   {
      if(symbol == NULL)
      {
         Print("symbol is NULL, defaults to " + _Symbol);
         symbol = _Symbol;
         setFilling();
      }
      else
      {
         Print("new symbol is NULL, current used " + symbol);
      }
   }
   else
   {
      if(SymbolInfoDouble(s, SYMBOL_POINT) == 0)
      {
         Print("incorrect symbol " + s);
         return false;
      }
      if(symbol != s)
      {
         symbol = s;
         setFilling();
      }
   }
   return true;
}

因此,使用 setSymbol 更改交易品种将通过嵌套调用 setFilling 自动选择正确的填充模式。

setFilling 方法根据 SYMBOL_FILLING_MODESYMBOL_TRADE_EXEMODE 交易品种属性自动指定交易量填充方法(请参阅交易条件和订单执行模式部分)。

cpp
private:
   void setFilling()
   {
      const int filling = (int)SymbolInfoInteger(symbol, SYMBOL_FILLING_MODE);
      const bool market = SymbolInfoInteger(symbol, SYMBOL_TRADE_EXEMODE)
         == SYMBOL_TRADE_EXECUTION_MARKET;
      
      // 该字段可能已经被填充
      // 并且位匹配意味着是有效模式
      if(((type_filling + 1) & filling) != 0
         || (type_filling == ORDER_FILLING_RETURN && !market)) return;
      
      if((filling & SYMBOL_FILLING_FOK) != 0)
      {
         type_filling = ORDER_FILLING_FOK;
      }
      else if((filling & SYMBOL_FILLING_IOC) != 0)
      {
         type_filling = ORDER_FILLING_IOC;
      }
      else
      {
         type_filling = ORDER_FILLING_RETURN;
      }
   }

如果智能交易系统错误地设置了 type_filling 字段,此方法会隐式地(无错误和消息)更正该字段。如果你的算法需要一种有保证的特定填充方法,没有这种方法就无法进行交易,请进行适当的编辑以中断该过程。

对于正在开发的结构体集,假设除了 type_filling 字段外,你只能直接设置没有特定内容要求的可选字段,例如 magiccomment

在接下来的内容中,为了简洁起见,许多方法都以更简短的形式提供。它们包含了我们稍后将研究的操作类型的部分内容,以及分支错误检查。

对于买入和卖出操作,我们需要 pricevolume 字段;这两个值都应该进行归一化处理,并检查是否在可接受的范围内。这是由 setVolumePrices 方法完成的。

cpp
bool setVolumePrices(const double v, const double p,
      const double stop, const double take)
{
   TU::SymbolMetrics sm(symbol);
   volume = sm.volume(v);
   
   if(p != 0) price = sm.price(p);
   else price = sm.price(TU::GetCurrentPrice(type, symbol));
   
   return setSLTP(stop, take);
}

如果未设置交易价格(p == 0),程序将根据交易方向,自动获取正确类型的当前价格,该价格从 type 字段读取。

尽管止损和止盈水平不是必需的,但如果存在,也应该进行归一化处理,这就是为什么将它们添加到该方法的参数中的原因。

缩写 TU 我们已经熟悉了。它代表 TradeUtilits.mqh 文件中的命名空间,其中包含许多有用的函数,包括用于价格和交易量归一化的函数。

sltp 字段的处理由单独的 setSLTP 方法执行,因为这不仅在买入和卖出操作中需要,在修改现有头寸时也需要。

cpp
bool setSLTP(const double stop, const double take)
{
   TU::SymbolMetrics sm(symbol);
   TU::TradeDirection dir(type);
  
   if(stop != 0)
   {
      sl = sm.price(stop);
      if(!dir.worse(sl, price))
      {
         PrintFormat("wrong SL (%s) against price (%s)",
            TU::StringOf(sl), TU::StringOf(price));
         return false;
      }
   }
   else
   {
      sl = 0; // 移除止损
   }
   
   if(take != 0)
   {
      tp = sm.price(take);
      if(!dir.better(tp, price))
      {
         PrintFormat("wrong TP (%s) against price (%s)",
            TU::StringOf(tp), TU::StringOf(price));
         return false;
      }
   }
   else
   {
      tp = 0; // 移除止盈
   }
   return true;
}

除了对 sltp 字段进行归一化和赋值外,

修改持仓的止损和/或止盈水平

MQL程序能够为已开仓的持仓更改保护性止损(Stop Loss)和止盈(Take Profit)价格水平。ENUM_TRADE_REQUEST_ACTIONS枚举中的TRADE_ACTION_SLTP元素就是为此目的而设计的。也就是说,在填充MqlTradeRequest结构时,我们应在action字段中写入TRADE_ACTION_SLTP。

这是唯一必需的字段。其他字段是否需要填充取决于账户操作模式ENUM_ACCOUNT_MARGIN_MODE。在对冲账户中,你需要填充symbol字段,但可以省略持仓单号。而在净额结算账户中,情况则相反,必须指明持仓单号,但可以省略品种名称。这是由不同类型账户上持仓识别的特性所导致的。在净额结算时,每个品种只能存在一个持仓。

为了统一代码,若有相关信息,建议同时填充这两个字段。

保护性价格水平在sl和tp字段中设置。可以只设置其中一个字段。若要移除保护性水平,可将它们赋值为零。

下表总结了根据不同计数模式填充字段的要求。必需字段用星号标记,可选字段用加号标记。

字段净额结算对冲
action**
symbol*+
position+*
sl++
tp++

为了执行修改保护性水平的操作,我们在MqlTradeRequestSync结构中引入了adjust方法的几个重载版本。

c
struct MqlTradeRequestSync: public MqlTradeRequest
{
   ...
   bool adjust(const ulong pos, const double stop = 0, const double take = 0);
   bool adjust(const string name, const double stop = 0, const double take = 0);
   bool adjust(const double stop = 0, const double take = 0);
   ...
};

正如我们上面所看到的,根据环境的不同,修改操作可以仅通过单号或仅通过持仓品种来完成。前两个原型考虑了这些选项。

此外,由于该结构可能已经用于之前的请求,其position和symbols字段可能已经被填充。那么你可以使用最后一个原型调用该方法。

我们暂时不展示这三个方法的实现,因为显然它们必须有一个与发送请求相关的通用主体。这部分被封装为一个私有辅助方法_adjust,它具有完整的选项集。下面给出其代码,有一些不影响工作逻辑的缩写。

c
private:
   bool _adjust(const ulong pos, const string name,
      const double stop = 0, const double take = 0)
   {
      action = TRADE_ACTION_SLTP;
      position = pos;
      type = (ENUM_ORDER_TYPE)PositionGetInteger(POSITION_TYPE);
      if(!setSymbol(name)) return false;
      if(!setSLTP(stop, take)) return false;
      ZeroMemory(result);
      return OrderSend(this, result);
   }

我们根据上述规则填充结构的所有字段,调用之前描述的setSymbol和setSLTP方法,然后向服务器发送请求。结果是成功状态(true)或错误(false)。

每个重载的adjust方法都会单独为请求准备源参数。在有持仓单号的情况下,实现方式如下。

c
public:
   bool adjust(const ulong pos, const double stop = 0, const double take = 0)
   {
      if(!PositionSelectByTicket(pos))
      {
         Print("No position: P=" + (string)pos);
         return false;
      }
      return _adjust(pos, PositionGetString(POSITION_SYMBOL), stop, take);
   }

在这里,我们使用内置的PositionSelectByTicket函数检查持仓是否存在,并在终端的交易环境中选择该持仓,这对于后续读取其属性(在这种情况下是品种名称,即PositionGetString(POSITION_SYMBOL))是必要的。然后调用通用版本的adjust方法。

当通过品种名称修改持仓时(这仅在净额结算账户上可用),可以使用另一个adjust选项。

c
   bool adjust(const string name, const double stop = 0, const double take = 0)
   {
      if(!PositionSelect(name))
      {
         Print("No position: " + s);
         return false;
      }
      
      return _adjust(PositionGetInteger(POSITION_TICKET), name, stop, take);
   }

在这里,使用内置的PositionSelect函数选择持仓,并从其属性中获取单号(PositionGetInteger(POSITION_TICKET))。

所有这些功能将在关于处理持仓和持仓属性的相应章节中详细讨论。

参数最少的adjust方法版本,即仅包含止损和止盈水平的版本如下。

c
   bool adjust(const double stop = 0, const double take = 0)
   {
      if(position != 0)
      {
         if(!PositionSelectByTicket(position))
         {
            Print("No position with ticket P=" + (string)position);
            return false;
         }
         const string s = PositionGetString(POSITION_SYMBOL);
         if(symbol != NULL && symbol != s)
         {
            Print("Position symbol is adjusted from " + symbol + " to " + s);
         }
         symbol = s;
      }
      else if(AccountInfoInteger(ACCOUNT_MARGIN_MODE)
         != ACCOUNT_MARGIN_MODE_RETAIL_HEDGING
         && StringLen(symbol) > 0)
      {
         if(!PositionSelect(symbol))
         {
            Print("Can't select position for " + symbol);
            return false;
         }
         position = PositionGetInteger(POSITION_TICKET);
      }
      else
      {
         Print("Neither position ticket nor symbol was provided");
         return false;
      }
      return _adjust(position, symbol, stop, take);
   }

这段代码确保在各种模式下正确填充position和symbols字段,或者在日志中输出错误信息并提前退出。最后,调用私有版本的_adjust方法,通过OrderSend发送请求。

与买入/卖出方法类似,adjust方法集是“异步”工作的:完成这些方法后,仅知道请求发送状态,而没有水平修改的确认信息。我们知道,对于交易所而言,止盈水平可以作为限价订单转发。因此,在MqlTradeResultSync结构中,我们应该提供一个“同步”等待机制,直到更改生效。

作为MqlTradeResultSync::wait方法形成的通用等待机制已经就绪,并已用于等待持仓的开仓。wait方法的第一个参数是一个指向具有预定义原型condition的另一个方法的指针,该方法会在循环中进行轮询,直到满足所需条件或超时。在这种情况下,这个与条件兼容的方法应该对持仓中的止损水平进行实际检查。

让我们添加一个名为adjusted的新方法。

c
struct MqlTradeResultSync: public MqlTradeResult
{
   ...
   bool adjusted(const ulong msc = 1000)
   {
      if(retcode != TRADE_RETCODE_DONE || retcode != TRADE_RETCODE_PLACED)
      {
         return false;
      }
   
      if(!wait(checkSLTP, msc))
      {
         Print("SL/TP modification timeout: P=" + (string)position);
         return false;
      }
      
      return true;
   }

首先,当然要检查retcode字段中的状态。如果是标准状态,我们继续检查水平本身,将辅助方法checkSLTP传递给wait方法。

c
struct MqlTradeResultSync: public MqlTradeResult
{
   ...
   static bool checkSLTP(MqlTradeResultSync &ref)
   {
      if(PositionSelectByTicket(ref.position))
      {
         return TU::Equal(PositionGetDouble(POSITION_SL), /*.?.*/)
            && TU::Equal(PositionGetDouble(POSITION_TP), /*.?.*/);
      }
      else
      {
         Print("PositionSelectByTicket failed: P=" + (string)ref.position);
      }
      return false;
   }

这段代码确保使用PositionSelectByTicket在终端的交易环境中按单号选择持仓,并读取持仓属性POSITION_SL和POSITION_TP,这些属性应与请求中的内容进行比较。问题在于,这里我们无法访问请求对象,必须以某种方式将用'.?. '标记的位置的两个值传递到这里。

基本上,由于我们正在设计MqlTradeResultSync结构,我们可以向其中添加sl和tp字段,并在发送请求之前用MqlTradeRequestSync中的值填充它们(内核“不知道”我们添加的字段,在OrderSend调用期间会保持它们不变)。但为了简单起见,我们将使用现有的字段。MqlTradeResultSync结构中的bid和ask字段仅用于报告重新报价价格(TRADE_RETCODE_REQUOTE状态),这与TRADE_ACTION_SLTP请求无关,因此我们可以将已完成的MqlTradeRequestSync中的sl和tp存储在其中。

在MqlTradeRequestSync结构的completed方法中进行这一操作是合理的,该方法会以预定义的超时时间开始阻塞等待交易操作结果。到目前为止,其代码仅包含一个针对TRADE_ACTION_DEAL操作的分支。接下来,我们为TRADE_ACTION_SLTP添加一个分支。

c
struct MqlTradeRequestSync: public MqlTradeRequest
{
   ...
   bool completed()
   {
      if(action == TRADE_ACTION_DEAL)
      {
         const bool success = result.opened(timeout);
         if(success) position = result.position;
         return success;
      }
      else if(action == TRADE_ACTION_SLTP)
      {
         // 传递原始请求数据,用于与持仓属性进行比较,
         // 默认情况下,这些数据不在结果结构中
         result.position = position;
         result.bid = sl; // 在这种结果类型中,bid字段是空闲的,用于存储止损
         result.ask = tp; // 在这种结果类型中,ask字段是空闲的,用于存储止盈
         return result.adjusted(timeout);
      }
      return false;
   }

如你所见,在从请求中设置持仓单号和价格水平后,我们调用上面讨论过的adjusted方法,该方法会检查wait(checkSLTP)。现在我们可以回到MqlTradeResultSync结构中的辅助方法checkSLTP,并将其完善。

c
struct MqlTradeResultSync: public MqlTradeResult
{
   ...
   static bool checkSLTP(MqlTradeResultSync &ref)
   {
      if(PositionSelectByTicket(ref.position))
      {
         return TU::Equal(PositionGetDouble(POSITION_SL), ref.bid) // 请求中的止损
            && TU::Equal(PositionGetDouble(POSITION_TP), ref.ask); // 请求中的止盈
      }
      else
      {
         Print("PositionSelectByTicket failed: P=" + (string)ref.position);
      }
      return false;
   }

至此,完成了对MqlTradeRequestSync和MqlTradeResultSync结构功能的扩展,以支持止损和止盈修改操作。

考虑到这一点,让我们继续之前章节开始的智能交易系统MarketOrderSend.mq5的示例。为其添加一个输入参数Distance2SLTP,该参数允许指定到止损和止盈水平的点数距离。

c
input int Distance2SLTP = 0; // 到止损/止盈的点数距离 (0 = 不设置)

当该参数为零时,将不设置保护水平。

在工作代码中,在收到开仓确认后,我们计算SL和TP变量中的水平值,并执行同步修改:request.adjust(SL, TP) && request.completed()。

c
   ...
   const ulong order = (wantToBuy ?
      request.buy(symbol, volume, Price) :
      request.sell(symbol, volume, Price));
   if(order != 0)
   {
      Print("OK Order: #=", order);
      if(request.completed()) // 等待持仓开仓
      {
         Print("OK Position: P=", request.result.position);
         if(Distance2SLTP != 0)
         {
            // 在 'complete' 内部,持仓已在终端的交易环境中 “选定”,
            // 因此无需在单号上显式执行此操作
            // PositionSelectByTicket(request.result.position);
            
            // 选定持仓后,可以了解其属性,但我们需要价格,
            // 以便从该价格后退指定的点数
            const double price = PositionGetDouble(POSITION_PRICE_OPEN);
            const double point = SymbolInfoDouble(symbol, SYMBOL_POINT);
            // 我们使用辅助类 TradeDirection 计算水平
            TU::TradeDirection dir((ENUM_ORDER_TYPE)Type);
            // 止损总是价格 “更差” 的方向,止盈总是价格 “更好” 的方向:买入和卖出的代码相同
            const double SL = dir.negative(price, Distance2SLTP * point);
            const double TP = dir.positive(price, Distance2SLTP * point);
            if(request.adjust(SL, TP) && request.completed())
            {
               Print("OK Adjust");
            }
         }
      }
   }
   Print(TU::StringOf(request));
   Print(TU::StringOf(request.result));
}

在成功的买入或卖出操作后第一次调用completed时,持仓单号会保存在请求结构的position字段中。因此,修改止损时,仅需要价格水平,而持仓的品种和单号已经存在于request中。

让我们尝试使用默认设置但将Distance2SLTP设置为500点的智能交易系统执行买入操作。

OK Order: #=1273913958
Waiting for position for deal D=1256506526
OK Position: P=1273913958
OK Adjust
TRADE_ACTION_SLTP, EURUSD, ORDER_TYPE_BUY, V=0.01, ORDER_FILLING_FOK, @ 1.10889, »
»  SL=1.10389, TP=1.11389, P=1273913958
DONE, Bid=1.10389, Ask=1.11389, Request executed, Req=26

最后两行对应于函数末尾发起的请求和request.result结构内容的调试输出到日志。在这两行中,有趣的是字段中存储了两个请求的值的组合:首先开仓,然后修改持仓。特别是,请求中包含交易量(0.01)和价格(1.10889)的字段在TRADE_ACTION_DEAL之后保留下来,但并不妨碍TRADE_ACTION_SLTP的执行。理论上,通过在两个请求之间重置结构很容易消除这种情况,但我们更愿意保持原样,因为在填充的字段中也有有用的字段:position字段获得了我们修改请求所需的单号。如果我们重置结构,就需要引入一个变量来临时存储单号。

在一般情况下,当然最好遵循严格的数据初始化策略,但了解如何在特定场景(如预定义类型的两个或多个相关请求)中使用它们可以优化代码。

此外,在结果结构中,我们在Bid和Ask价格字段中看到请求的止损和止盈水平,这不应令人惊讶:它们是由MqlTradeRequestSync::completed方法写入的,目的是与实际的持仓更改进行比较。在执行请求时,系统内核仅在结果结构中填充了retcode(DONE)、comment(“Request executed”)和request_id(26)。

接下来,我们将考虑另一个实现追踪止损的水平修改示例。

跟踪止损

利用改变保护性价格水平这一能力的最常见任务之一,是在有利趋势持续时,以更优的价格依次移动止损价位。这就是跟踪止损。我们使用前几节中介绍的新结构体 MqlTradeRequestSyncMqlTradeResultSync 来实现它。

为了能够将该机制连接到任何智能交易系统(Expert Advisor),我们将其声明为 Trailing Stop 类(见文件 TrailingStop.mqh)。我们会在类的私有变量中存储受控制头寸的单号、其交易品种、价格点的大小、止损水平与当前价格所需的距离,以及水平变化的步长。

c++
#include <MQL5Book/MqlTradeSync.mqh>
   
class TrailingStop
{
   const ulong ticket;  // 受控制头寸的单号
   const string symbol; // 头寸的交易品种
   const double point;  // 交易品种价格的点值大小
   const uint distance; // 止损点与当前价格的距离(以点数计)
   const uint step;     // 移动步长(敏感度)(以点数计)
   ...

这个距离仅对基类提供的标准头寸跟踪算法有用。派生类将能够根据其他原则移动保护性水平,例如移动平均线、通道、抛物线转向指标(SAR)等。在熟悉了基类之后,我们将给出一个基于移动平均线的派生类示例。

让我们为当前的止损价格水平创建 level 变量。在 ok 变量中,我们将维护头寸的当前状态:如果头寸仍然存在,则为 true;如果发生错误且头寸已平仓,则为 false

c++
protected:
   double level;
   bool ok;
   virtual double detectLevel() 
   {
      return DBL_MAX;  
   }

虚方法 detectLevel 旨在被子类重写,在子类中应根据任意算法计算止损价格。在这个实现中,返回一个特殊值 DBL_MAX,表示按照标准算法工作(见下文)。

在构造函数中,用相应参数的值填充所有字段。PositionSelectByTicket 函数检查具有给定单号的头寸是否存在,并在程序环境中分配它,以便后续调用 PositionGetString 能返回其包含交易品种名称的字符串属性。

c++
public:
   TrailingStop(const ulong t, const uint d, const uint s = 1) :
      ticket(t), distance(d), step(s),
      symbol(PositionSelectByTicket(t) ? PositionGetString(POSITION_SYMBOL) : NULL),
      point(SymbolInfoDouble(symbol, SYMBOL_POINT))
   {
      if(symbol == NULL)
      {
         Print("Position not found: " + (string)t);
         ok = false;
      }
      else
      {
         ok = true;
      }
   }
   
   bool isOK() const
   {
      return ok;
   }

现在让我们来看看 trail 类的主要公共方法。MQL 程序需要在每个报价时刻(tick)或通过定时器调用它,以跟踪头寸。当头寸存在时,该方法返回 true

c++
   virtual bool trail()
   {
      if(!PositionSelectByTicket(ticket))
      {
         ok = false;
         return false; // 头寸已平仓
      }
   
      // 找出用于计算的价格:当前报价和止损水平
      const double current = PositionGetDouble(POSITION_PRICE_CURRENT);
      const double sl = PositionGetDouble(POSITION_SL);
      ...

      // 在这里和下面,我们使用头寸属性读取函数。它们将在单独的部分中详细讨论。
      // 特别是,我们需要找出交易方向——买入还是卖出——以便知道止损水平应该设置在哪个方向。
      // POSITION_TYPE_BUY  = 0 (false)
      // POSITION_TYPE_SELL = 1 (true)
      const bool sell = (bool)PositionGetInteger(POSITION_TYPE);
      TU::TradeDirection dir(sell);
      ...

      // 为了进行计算和检查,我们将使用辅助类 TU::TradeDirection 及其对象 dir。
      // 例如,它的 negative 方法允许计算在亏损方向上距离当前价格指定距离的价格,而不管操作类型如何。
      // 这简化了代码,因为否则对于买入和卖出操作,你将不得不进行“镜像”计算。
      level = detectLevel();
      // 如果没有止损水平,我们就无法进行跟踪止损:删除止损水平的操作必须由调用代码完成
      if(level == 0) return true;
      // 如果存在默认值,则从当前价格进行标准偏移
      if(level == DBL_MAX) level = dir.negative(current, point * distance);
      level = TU::NormalizePrice(level, symbol);
      
      if(!dir.better(current, level))
      {
         return true; // 不能在盈利一侧设置止损水平
      }
      ...

      // TU::TradeDirection 类的 better 方法检查收到的止损水平是否位于价格的正确一侧。
      // 没有这个方法,我们将需要再次编写两次检查(分别针对买入和卖出)。
      // 我们可能会得到不正确的止损水平值,因为 detectLevel 方法可以在派生类中被重写。
      // 使用标准计算时,这个问题会被消除,因为水平是由 dir 对象计算的。
      // 最后,当计算出水平后,有必要将其应用于头寸。
      // 如果头寸还没有设置止损,任何有效的水平都可以。
      // 如果已经设置了止损,那么新值应该比前一个更好,并且差值应大于指定的步长。
      if(sl == 0)
      {
         PrintFormat("Initial SL: %f", level);
         move(level);
      }
      else
      {
         if(dir.better(level, sl) && fabs(level - sl) >= point * step)
         {
            PrintFormat("SL: %f -> %f", sl, level);
            move(level);
         }
      }
      
      return true; // 成功
   }

发送头寸修改请求是在 move 方法中实现的,该方法使用了我们熟悉的 MqlTradeRequestSync 结构体的 adjust 方法(见“修改止损和/或止盈水平”部分)。

c++
   bool move(const double sl)
   {
      MqlTradeRequestSync request;
      request.position = ticket;
      if(request.adjust(sl, 0) && request.completed())
      {
         Print("OK Trailing: ", TU::StringOf(sl));
         return true;
      }
      return false;
   }
};

现在,一切准备就绪,可以将跟踪止损添加到测试智能交易系统 TrailingStop.mq5 中。在输入参数中,你可以指定交易方向、止损水平的距离(以点数计)和步长(以点数计)。TrailingDistance 参数默认值为 0,这意味着自动计算报价的每日波动范围,并使用其一半作为距离。

c++
#include <MQL5Book/MqlTradeSync.mqh>
#include <MQL5Book/TrailingStop.mqh>
   
enum ENUM_ORDER_TYPE_MARKET
{
   MARKET_BUY = ORDER_TYPE_BUY,   // ORDER_TYPE_BUY
   MARKET_SELL = ORDER_TYPE_SELL  // ORDER_TYPE_SELL
};
   
input int TrailingDistance = 0;   // 止损点距离(以点数计)(0 = 自动检测)
input int TrailingStep = 10;      // 跟踪止损步长(以点数计)
input ENUM_ORDER_TYPE_MARKET Type;
input string Comment;
input ulong Deviation;
input ulong Magic = 1234567890;

当启动时,智能交易系统将检查当前交易品种上是否存在具有指定 Magic 数字的头寸,如果不存在则创建它。

跟踪止损将由一个 TrailingStop 类的对象来执行,该对象被包装在一个智能指针 AutoPtr 中。多亏了智能指针,当需要用一个新的跟踪对象替换它来跟踪新创建的头寸时,我们不需要手动删除旧对象。当为智能指针分配一个新对象时,旧对象会自动被删除。回想一下,解引用智能指针,即访问存储在其中的工作对象,是通过重载的 [] 运算符完成的。

c++
#include <MQL5Book/AutoPtr.mqh>
   
AutoPtr<TrailingStop> tr;

OnTick 处理程序中,我们检查是否存在一个对象。如果存在,检查头寸是否存在(该属性由 trail 方法返回)。程序刚启动时,对象不存在,指针为 NULL。在这种情况下,应该要么创建一个新头寸,要么找到一个已经开仓的头寸,并为其创建一个 Trailing Stop 对象。这是由 Setup 函数完成的。在后续调用 OnTick 时,对象开始并继续跟踪,防止程序在头寸“存活”时进入 if 块内部。

c++
void OnTick()
{
   if(tr[] == NULL || !tr[].trail())
   {
      // 如果还没有跟踪止损,创建或找到一个合适的头寸
      Setup();
   }
}

下面是 Setup 函数。

c++
void Setup()
{
   int distance = 0;
   const double point = SymbolInfoDouble(_Symbol, SYMBOL_POINT);
   
   if(TrailingDistance == 0) // 自动检测价格的每日波动范围
   {
      distance = (int)((iHigh(_Symbol, PERIOD_D1, 1) - iLow(_Symbol, PERIOD_D1, 1))
         / point / 2);
      Print("Autodetected daily distance (points): ", distance);
   }
   else
   {
      distance = TrailingDistance;
   }
   
   // 仅处理当前交易品种且具有我们指定 Magic 数字的头寸
   if(GetMyPosition(_Symbol, Magic))
   {
      const ulong ticket = PositionGetInteger(POSITION_TICKET);
      Print("The next position found: ", ticket);
      tr = new TrailingStop(ticket, distance, TrailingStep);
   }
   else // 没有我们的头寸
   {
      Print("No positions found, lets open it...");
      const ulong ticket = OpenPosition();
      if(ticket)
      {
         tr = new TrailingStop(ticket, distance, TrailingStep);
      }
   }
   
   if(tr[] != NULL)
   {
      // 在创建或找到头寸后立即首次执行跟踪止损
      tr[].trail();
   }
}

寻找合适的已开仓头寸是在 GetMyPosition 函数中实现的,而开一个新头寸是由 OpenPosition 函数完成的。两者如下所示。无论如何,我们得到一个头寸单号,并为其创建一个跟踪止损对象。

c++
bool GetMyPosition(const string s, const ulong m)
{
   for(int i = 0; i < PositionsTotal(); ++i)
   {
      if(PositionGetSymbol(i) == s && PositionGetInteger(POSITION_MAGIC) == m)
      {
         return true;
      }
   }
   return false;
}

从内置函数的名称中应该可以清楚该算法的目的和一般含义。在遍历所有已开仓头寸(PositionsTotal)的循环中,我们使用 PositionGetSymbol 依次选择每个头寸,并获取其交易品种。如果交易品种与请求的匹配,我们读取并比较头寸属性 POSITION_MAGIC 与传入的“魔术”数字。所有用于处理头寸的函数将在单独的部分中讨论。

一旦找到第一个匹配的头寸,该函数将返回 true。同时,该头寸将在终端的交易环境中保持选中状态,这使得代码的其余部分在必要时可以读取其其他属性。

我们已经知道开仓的算法。

c++
ulong OpenPosition()
{
   MqlTradeRequestSync request;
   
   // 默认值
   const bool wantToBuy = Type == MARKET_BUY;
   const double volume = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN);
   // 可选字段直接在结构体中填充
   request.magic = Magic;
   request.deviation = Deviation;
   request.comment = Comment;
   ResetLastError();
   // 执行选定的交易操作并等待其确认
   if((bool)(wantToBuy ? request.buy(volume) : request.sell(volume))
      && request.completed())
   {
      Print("OK Order/Deal/Position");
   }
   
   return request.position; // 非零值 - 表示成功
}

为了更清楚地了解这个程序在测试器(tester)可视化模式下的工作方式。

编译后,在终端的“查看”(Review)选项卡中打开策略测试器面板,并选择第一个选项:单次测试(Single test)。

在“设置”(Settings)选项卡中,选择以下内容:

  • 在下拉列表“智能交易系统”(Expert Advisor)中:MQL5Book\p6\TralingStop
  • 交易品种(Symbol):EURUSD
  • 时间框架(Timeframe):H1
  • 时间间隔(Interval):去年、上个月或自定义
  • 向前测试(Forward):否
  • 延迟(Delays):禁用
  • 建模方式(Modeling):基于真实或生成的报价(ticks)
  • 优化(Optimization):禁用
  • 可视化模式(Visual mode):启用

一旦按下“开始”(Start),你将在一个单独的测试器窗口中看到类似以下内容:

测试器中的标准跟踪止损

日志将显示如下条目:

2022.01.10 00:02:00   Autodetected daily distance (points): 373

2022.01.10 00:02:00   No positions found, let's open it...

2022.01.10 00:02:00   instant buy 0.01 EURUSD at 1.13612 (1.13550 / 1.13612 / 1.13550)

2022.01.10 00:02:00   deal #2 buy 0.01 EURUSD at 1.13612 done (based on order #2)

2022.01.10 00:02:00   deal performed [#2 buy 0.01 EURUSD at 1.13612]

2022.01.10 00:02:00   order performed buy 0.01 at 1.13612 [#2 buy 0.01 EURUSD at 1.13612]

2022.01.10 00:02:00   Waiting for position for deal D=2

2022.01.10 00:02:00   OK Order/Deal/Position

2022.01.10 00:02:00   Initial SL: 1.131770

2022.01.10 00:02:00   position modified [#2 buy 0.01 EURUSD 1.13612 sl: 1.13177]

2022.01.10 00:02:00   OK Trailing: 1.13177

2022.01.10 00:06:13   SL: 1.131770 -> 1.131880

2022.01.10 00:06:13   position modified [#2 buy 0.01 EURUSD 1.13612 sl: 1.13188]

2022.01.10 00:06:13   OK Trailing: 1.13188

2022.01.10 00:09:17   SL: 1.131880 -> 1.131990

2022.01.10 00:09:17   position modified [#2 buy 0.01 EURUSD 1.13612 sl: 1.13199]

2022.01.10 00:09:17   OK Trailing: 1.13199

2022.01.10 00:09:26   SL: 1.131990 -> 1.132110

2022.01.10 00:09:26   position modified [#2 buy 0.01 EURUSD 1.13612 sl: 1.13211]

2022.01.10 00:09:26   OK Trailing: 1.13211

2022.01.10 00:09:35 SL: 1.132110 -> 1.132240

2022.01.10 00:09:35 position modified [#2 buy 0.01 EURUSD 1.13612 sl: 1.13224]

2022.01.10 00:09:35 OK Trailing: 1.13224

2022.01.10 10:06:38 stop loss triggered #2 buy 0.01 EURUSD 1.13612 sl: 1.13224 [#3 sell 0.01 EURUSD at 1.13224]

2022.01.10 10:06:38 deal #3 sell 0.01 EURUSD at 1.13221 done (based on order #3)

2022.01.10 10:06:38 deal performed [#3 sell 0.01 EURUSD at 1.13221]

2022.01.10 10:06:38 order performed sell 0.01 at 1.13221 [#3 sell 0.01 EURUSD at 1.13224]

2022.01.10 10:06:38 Autodetected daily distance (points): 373

2022.01.10 10:06:38 No positions found, let's open it...


看看该算法是如何随着价格的有利变动向上移动止损水平的,直到头寸因止损而平仓。在平仓后,程序会立即开一个新的头寸。

为了检验使用非标准跟踪机制的可能性,我们实现一个基于移动平均线的算法示例。为此,让我们回到 `TrailingStop.mqh` 文件,并描述派生类 `TrailingStopByMA`。

```c++
class TrailingStopByMA: public TrailingStop
{
   int handle;
   
public:
   TrailingStopByMA(const ulong t, const int period,
      const int offset = 1,
      const ENUM_MA_METHOD method = MODE_SMA,
      const ENUM_APPLIED_PRICE type = PRICE_CLOSE): TrailingStop(t, 0, 1)
   {
      handle = iMA(_Symbol, PERIOD_CURRENT, period, offset, method, type);
   }
   
   virtual double detectLevel() override
   {
      double array[1];
      ResetLastError();
      if(CopyBuffer(handle, 0, 0, 1, array) != 1)
      {
         Print("CopyBuffer error: ", _LastError);
         return 0;
      }
      return array[0];
   }
};

它在构造函数中创建 iMA 指标实例:周期、平均方法和价格类型通过参数传递。

在重写的 detectLevel 方法中,我们从指标缓冲区读取值,默认情况下,这是在偏移 1 根K线的情况下完成的,也就是说,这根K线已收盘,并且当报价(ticks)到达时其读数不会改变。如果有人希望,可以从第 0 根K线获取值,但对于除 PRICE_OPEN(开盘价)之外的所有价格类型,这样的信号都不稳定。

为了在同一个测试智能交易系统 TrailingStop.mq5 中使用新类,让我们添加另一个输入参数 MATrailingPeriod,用于设置移动平均线的周期(我们将保持指标的其他参数不变)。

c++
input int MATrailingPeriod = 0;   // 基于移动平均线跟踪止损的周期(0 = 禁用)

此参数的值为 0 时将禁用基于移动平均线的跟踪止损。如果启用,TrailingDistance 参数中的距离设置将被忽略。

根据此参数,我们将创建一个标准的跟踪止损对象 TrailingStop,或者是从 iMA 派生的对象 TrailingStopByMA

c++
      ...
      tr = MATrailingPeriod > 0 ?
         new TrailingStopByMA(ticket, MATrailingPeriod) :
         new TrailingStop(ticket, distance, TrailingStep);
      ...

让我们看看更新后的程序在测试器中的表现。在智能交易系统设置中,为移动平均线设置一个非零周期,例如 10。

测试器中基于移动平均线的跟踪止损

请注意,在移动平均线接近价格的那些时刻,会出现频繁触发止损和平仓的情况。当移动平均线在报价上方时,根本不会设置保护水平,因为对于买入操作来说,这样设置是不正确的。这是因为我们的智能交易系统没有任何策略,并且总是开相同类型的头寸,而不考虑市场情况。对于卖出操作,当移动平均线低于价格时,偶尔也会出现同样矛盾的情况,这意味着市场在上涨,而机器人却“固执地”进入了空头头寸。

在实际使用的策略中,通常会考虑市场的走势来选择头寸的方向,并且移动平均线会位于当前价格的合适一侧,在该侧允许设置止损。

### 平仓:全部平仓和部分平仓
从技术层面来讲,平仓可以被视为一种与开仓操作相反的交易操作。例如,要平掉一个多头仓位,就需要进行卖出操作(`type` 字段中的 `ORDER_TYPE_SELL`);要平掉一个空头仓位,则需要进行买入操作(`type` 字段中的 `ORDER_TYPE_BUY`)。

`MqlTradeTransaction` 结构体中 `action` 字段的交易操作类型保持不变:`TRADE_ACTION_DEAL`。

在对冲账户上,必须使用 `position` 字段中的持仓编号来指定要平仓的仓位。对于净额账户,由于每个交易品种只能有一个仓位,所以可以只在 `symbol` 字段中指定交易品种的名称。不过,在净额账户上也可以通过持仓编号来平仓。

为了统一代码,无论账户类型如何,同时填充 `position` 和 `symbol` 字段是有意义的。

此外,一定要在 `volume` 字段中设置手数。如果该值等于持仓的手数,那么仓位将被全部平仓。然而,通过指定一个较小的值,就可以只平掉部分仓位。

在下面的表格中,所有必填的结构体字段都用星号标记,可选字段用加号标记。

| 字段 | 净额账户 | 对冲账户 |
| --- | --- | --- |
| `action` | * | * |
| `symbol` | * | + |
| `position` | + | * |
| `type` | * | * |
| `type_filling` | * | * |
| `volume` | * | * |
| `price` | *' | *' |
| `deviation` | ± | ± |
| `magic` | + | + |
| `comment` | + | + |

标记为带勾星号的 `price` 字段,是因为它仅对采用请求执行模式和即时执行模式的交易品种是必需的,而对于交易所执行模式和市价执行模式,结构体中的价格将不会被考虑。

出于类似的原因,`deviation` 字段标记为 “±”。它仅对即时执行模式和请求执行模式有效。

为了简化平仓的编程实现,让我们回到 `MqlTradeSync.mqh` 文件中扩展后的结构体 `MqlTradeRequestSync`。通过持仓编号平仓的方法有如下代码:
```cpp
struct MqlTradeRequestSync: public MqlTradeRequest
{
   double partial; // 部分平仓后的手数
   // ...
   bool close(const ulong ticket, const double lot = 0)
   {
      if(!PositionSelectByTicket(ticket)) return false;
      
      position = ticket;
      symbol = PositionGetString(POSITION_SYMBOL);
      type = (ENUM_ORDER_TYPE)(PositionGetInteger(POSITION_TYPE) ^ 1);
      price = 0; 
      // ...

在这里,我们首先通过调用 PositionSelectByTicket 函数检查仓位是否存在。此外,这个调用会在终端的交易环境中选中该仓位,这样就可以使用后续函数读取其属性。具体来说,我们从 POSITION_SYMBOL 属性中获取仓位的交易品种,并将其 POSITION_TYPE 类型 “反转” 为相反的类型,以得到所需的订单类型。

ENUM_POSITION_TYPE 枚举中的仓位类型为 POSITION_TYPE_BUY(值为 0)和 POSITION_TYPE_SELL(值为 1)。在订单类型枚举 ENUM_ORDER_TYPE 中,市价操作也正好占用相同的值:ORDER_TYPE_BUYORDER_TYPE_SELL。这就是为什么我们可以将第一个枚举转换为第二个枚举,并且要得到相反的交易方向,只需使用异或操作(^)切换零位即可:0 变为 1,1 变为 0。

price 字段清零意味着在发送请求之前自动选择正确的当前价格(卖价或买价):这会在稍后,在辅助方法 setVolumePrices 中完成,该方法会在算法的后续部分从 _market 方法中调用。

_market 方法的调用在下面几行。_market 方法会根据结构体中已填充的所有字段,生成一个全额或部分的市价订单。

cpp
      const double total = lot == 0 ? PositionGetDouble(POSITION_VOLUME) : lot;
      partial = PositionGetDouble(POSITION_VOLUME) - total;
      return _market(symbol, total);
   }

与当前的源代码相比,这段代码片段稍微进行了简化。完整的代码包含了对一种罕见但可能情况的处理,即当仓位手数超过每个交易品种在一个订单中允许的最大手数(SYMBOL_VOLUME_MAX 属性)时。在这种情况下,必须通过多个订单分部分平仓。

还需注意的是,由于仓位可以部分平仓,我们必须在结构体中添加 partial 字段,用于存放操作后计划的手数余额。当然,对于全部平仓,该值将为 0。这个信息对于进一步验证操作是否完成是必需的。

对于净额账户,有一个 close 方法的版本,它通过交易品种名称来识别仓位。它会按交易品种选择仓位,获取其持仓编号,然后调用之前版本的 close 方法。

cpp
   bool close(const string name, const double lot = 0)
   {
      if(!PositionSelect(name)) return false;
      return close(PositionGetInteger(POSITION_TICKET), lot);
   }

MqlTradeRequestSync 结构体中,我们有一个完整的方法,必要时可以同步等待操作完成。现在我们需要对其进行补充,以便在 action 等于 TRADE_ACTION_DEAL 的分支中平仓。我们将通过 position 字段中的零值来区分开仓和平仓:开仓时 position 字段没有持仓编号,而平仓时有持仓编号。

cpp
   bool completed()
   {
      if(action == TRADE_ACTION_DEAL)
      {
         if(position == 0)
         {
            const bool success = result.opened(timeout);
            if(success) position = result.position;
            return success;
         }
         else
         {
            result.position = position;
            result.partial = partial;
            return result.closed(timeout);
         }
      }

为了检查仓位是否实际平仓,我们在 MqlTradeResultSync 结构体中添加了 closed 方法。在调用它之前,我们将持仓编号写入 result.position 字段,以便结果结构体能够跟踪相应持仓编号从终端交易环境中消失的时刻,或者在部分平仓的情况下,跟踪手数等于 result.partial 的时刻。

下面是 closed 方法。它基于一个常见的原则构建:首先检查服务器返回码是否成功,然后使用 wait 方法等待某个条件满足。

cpp
struct MqlTradeResultSync: public MqlTradeResult
{
   // ...
   bool closed(const ulong msc = 1000)
   {
      if(retcode != TRADE_RETCODE_DONE)
      {
         return false;
      }
      if(!wait(positionRemoved, msc))
      {
         Print("Position removal timeout: P=" + (string)position);
      }
      
      return true;
   }

在这种情况下,为了检查仓位消失的条件,我们必须实现一个新函数 positionRemoved

cpp
   static bool positionRemoved(MqlTradeResultSync &ref)
   {
      if(ref.partial)
      {
         return PositionSelectByTicket(ref.position)
            && TU::Equal(PositionGetDouble(POSITION_VOLUME), ref.partial);
      }
      return !PositionSelectByTicket(ref.position);
   }

我们将使用智能交易系统 TradeClose.mq5 来测试平仓操作,它实现了一个简单的交易策略:如果有两根连续的同向K线,就进入市场;一旦下一根K线的收盘价与之前的趋势方向相反,就退出市场。在连续趋势中重复的信号将被忽略,也就是说,市场中最多会有一个仓位(最小手数)或者没有仓位。

这个智能交易系统没有任何可调整的参数:只有(Deviation)和一个唯一编号(Magic)。隐含的参数是图表的时间框架和工作交易品种。

为了跟踪已经开仓的仓位是否存在,我们使用上一个示例 TradeTrailing.mq5 中的 GetMyPosition 函数:它会按交易品种和智能交易系统编号在仓位中搜索,如果找到合适的仓位则返回逻辑 true

我们还使用了几乎不变的 OpenPosition 函数:它会根据单个参数中传入的市价订单类型开仓。在这里,这个参数将来自趋势检测算法,而之前(在 TrailingStop.mq5 中)订单类型是由用户通过输入变量设置的。

实现平仓的新函数是 ClosePosition。由于头文件 MqlTradeSync.mqh 接管了整个例程,我们只需要对提交的持仓编号调用 request.close(ticket) 方法,并通过 request.completed() 等待平仓完成。

理论上,如果智能交易系统在每个报价点(tick)都分析情况,那么可以避免后者。在这种情况下,平仓时潜在的问题会在下一个报价点迅速显现出来,智能交易系统可以尝试再次平仓。然而,这个智能交易系统的交易逻辑是基于K线的,因此分析每个报价点没有意义。接下来,我们实现了一个特殊的逐K线工作机制,在这方面,我们同步控制平仓操作,否则仓位将在一整根K线期间一直处于 “悬而未决” 的状态。

cpp
ulong LastErrorCode = 0;
   
ulong ClosePosition(const ulong ticket)
{
   MqlTradeRequestSync request; // 空结构体
   
   // 可选字段直接在结构体中填充
   request.magic = Magic;
   request.deviation = Deviation;
   
   ResetLastError();
   // 执行平仓并等待确认
   if(request.close(ticket) && request.completed())
   {
      Print("OK Close Order/Deal/Position");
   }
   else // 出现问题时打印诊断信息
   {
      Print(TU::StringOf(request));
      Print(TU::StringOf(request.result));
      LastErrorCode = request.result.retcode;
      return 0; // 错误,错误码在LastErrorCode中解析
   }
   
   return request.position; // 非零值 - 成功
}

我们可以强制让 ClosePosition 函数在仓位成功平仓时返回 0,否则返回错误码。这种看似高效的方法会使 OpenPositionClosePosition 这两个函数的行为不同:在调用代码中,有必要将这些函数的调用嵌套在含义相反的逻辑表达式中,这会带来混淆。此外,无论如何我们都需要全局变量 LastErrorCode,以便在 OpenPosition 函数中添加错误信息。而且,if(条件) 检查比 if(!条件) 更自然地被解释为成功。

根据上述策略生成交易信号的函数称为 GetTradeDirection

cpp
ENUM_ORDER_TYPE GetTradeDirection()
{
   if(iClose(_Symbol, _Period, 1) > iClose(_Symbol, _Period, 2)
      && iClose(_Symbol, _Period, 2) > iClose(_Symbol, _Period, 3))
   {
      return ORDER_TYPE_BUY; // 开多头仓位
   }
   
   if(iClose(_Symbol, _Period, 1) < iClose(_Symbol, _Period, 2)
      && iClose(_Symbol, _Period, 2) < iClose(_Symbol, _Period, 3))
   {
      return ORDER_TYPE_SELL; // 开空头仓位
   }
   
   return (ENUM_ORDER_TYPE)-1; // 平仓
}

该函数返回 ENUM_ORDER_TYPE 类型的值,其中有两个标准元素(ORDER_TYPE_BUYORDER_TYPE_SELL),分别触发买入和卖出操作。特殊值 -1(不在枚举中)将用作平仓信号。

为了基于交易算法激活智能交易系统,我们使用 OnTick 处理程序。如我们所知,其他策略适合使用其他选项,例如用于新闻交易的定时器或用于成交量交易的市场深度事件。

首先,让我们以简化形式分析这个函数,不处理潜在的错误。在最开始,有一个代码块确保只有在新K线开盘时,后续算法才会被触发。

cpp
void OnTick()
{
   static datetime lastBar = 0;
   if(iTime(_Symbol, _Period, 0) == lastBar) return;
   lastBar = iTime(_Symbol, _Period, 0);
   // ...

接下来,我们从 GetTradeDirection 函数获取当前信号。

cpp
   const ENUM_ORDER_TYPE type = GetTradeDirection();

如果有仓位,我们检查是否收到了平仓信号,必要时调用 ClosePosition。如果还没有仓位并且有进入市场的信号,我们调用 OpenPosition

cpp
   if(GetMyPosition(_Symbol, Magic))
   {
      if(type != ORDER_TYPE_BUY && type != ORDER_TYPE_SELL)
      {
         ClosePosition(PositionGetInteger(POSITION_TICKET));
      }
   }
   else if(type == ORDER_TYPE_BUY || type == ORDER_TYPE_SELL)
   {
      OpenPosition(type);
   }
}

为了分析错误,你需要将 OpenPositionClosePosition 的调用包含在条件语句中,并采取一些措施恢复程序的工作状态。在最简单的情况下,在下一个报价点重复请求就足够了,但最好将重复次数限制在一定范围内。因此,我们将创建带有计数器和错误限制的静态变量。

cpp
void OnTick()
{
   static int errors = 0;
   static const int maxtrials = 10; // 每根K线最多尝试10次
   
   // 如果没有错误,等待新K线出现
   static datetime lastBar = 0;
   if(iTime(_Symbol, _Period, 0) == lastBar && errors == 0) return;
   lastBar = iTime(_Symbol, _Period, 0);
   // ...

如果出现错误,逐K线机制会暂时禁用,因为我们希望尽快克服这些错误。

错误在围绕 ClosePositionOpenPosition 的条件语句中计数。

cpp
   const ENUM_ORDER_TYPE type = GetTradeDirection();
   
   if(GetMyPosition(_Symbol, Magic))
   {
      if(type != ORDER_TYPE_BUY && type != ORDER_TYPE_SELL)
      {
         if(!ClosePosition(PositionGetInteger(POSITION_TICKET)))
         {
            ++errors;
         }
         else
         {
            errors = 0;
         }
      }
   }
   else if(type == ORDER_TYPE_BUY || type == ORDER_TYPE_SELL)
   {
      if(!OpenPosition(type))
      {
         ++errors;
      }
      else
      {
         errors = 0;
      }
   }
 // 每根K线错误次数过多
   if(errors >= maxtrials) errors = 0;
 // 错误严重到需要暂停
   if(IS_TANGIBLE(LastErrorCode)) errors = 0;
}

errors 变量设置为 0 会再次开启逐K线机制,并停止重复请求的尝试,直到下一根K线。

TradeRetcode.mqh 中定义的宏 IS_TANGIBLE 如下:

cpp
#define IS_TANGIBLE(T) ((T) >= TRADE_RETCODE_ERROR)

代码较小的错误是操作性错误,从某种意义上来说是正常的。较大的错误代码需要根据问题的原因进行分析并采取不同的措施:请求参数不正确、交易环境中的永久或临时禁令、资金不足等等。我们将在挂单修改部分介绍一个改进的错误分类器。

让我们在策略测试器中对 2022 年初以来的 XAUUSD(小时图 H1)运行这个智能交易系统,模拟真实报价点。下一张拼贴画展示了带有成交的图表片段以及资金曲线。

在 XAUUSD,H1 上的 TradeClose 测试结果

根据报告和日志,我们可以看到,我们简单的交易逻辑与开仓和平仓这两个操作的组合运行正常。

除了简单的平仓操作外,平台还支持在对冲账户上相互平仓两个反向仓位的可能性。

反向平仓:全部平仓与部分平仓(对冲)

在对冲账户中,允许同时开设多个头寸,并且在大多数情况下,这些头寸可以是相反方向的。在某些司法管辖区,对冲账户受到限制:一次只能持有一个方向的头寸。在这种情况下,当尝试执行反向交易操作时,你将收到 TRADE_RETCODE_HEDGE_PROHIBITED 错误代码。此外,这种限制通常与将账户属性 ACCOUNT_FIFO_CLOSE 设置为 true 相关。

当同时开设两个反向头寸时,平台支持使用 TRADE_ACTION_CLOSE_BY 操作来同时相互平仓的机制。要执行此操作,除了 action 字段外,还应在 MqlTradeTransaction 结构体中填充另外两个字段:positionposition_by 必须包含要平仓的头寸订单号。

此功能的可用性取决于金融工具的 SYMBOL_ORDER_MODE 属性:在允许的标志位掩码中必须存在 SYMBOL_ORDER_CLOSEBY(64)。

此操作不仅简化了平仓过程(一次操作代替两次),还节省了一次点差。

如你所知,任何新头寸在开始交易时都会有等于点差的亏损。例如,当买入金融工具时,交易以卖价(Ask)成交,但对于平仓交易,即卖出时,实际价格是买价(Bid)。对于空头头寸,情况则相反:在以买价(Bid)建仓后,我们立即开始关注卖价(Ask)以寻找潜在的平仓时机。

如果你以常规方式同时平仓,它们的平仓价格将相差当前点差。然而,如果你使用 TRADE_ACTION_CLOSE_BY 操作,那么两个头寸将在不考虑当前价格的情况下平仓。头寸对冲的价格等于 position_by 头寸(在请求结构体中)的开仓价格。它在由 TRADE_ACTION_CLOSE_BY 请求生成的 ORDER_TYPE_CLOSE_BY 订单中指定。

不幸的是,在交易和头寸相关的报告中,反向头寸/交易的平仓价格和开仓价格以成对的相同值显示,且方向相反,这给人一种双倍盈利或亏损的印象。实际上,该操作的财务结果(根据手数调整后的价格差)仅记录在第一个平仓交易(请求结构体中的 position 字段)中。无论价格差是多少,第二个平仓交易的结果始终为 0。

这种不对称性的另一个后果是,通过交换 positionposition_by 字段中的订单号,交易报告中多头和空头交易的盈亏统计数据会发生变化,例如,盈利的多头交易增加的数量恰好等于盈利的空头交易减少的数量。但从理论上讲,如果我们假设订单执行的延迟不取决于订单号传输的顺序,这不应影响总体结果。

以下图表对该过程进行了图形化解释(故意夸大了点差)。

平仓盈利头寸时点差的计算

这是一对盈利头寸的情况。如果头寸方向相反且处于亏损状态,那么当它们分别平仓时,点差将被计算两次(每次平仓都计算)。反向平仓可以使亏损减少一个点差。

平仓亏损头寸时点差的计算

反向头寸的规模不一定要相等。反向平仓操作将以两个交易量中的较小值为准。

MqlTradeSync.mqh 文件中,反向平仓操作是通过 closeby 方法实现的,该方法有两个头寸订单号参数。

cpp
struct MqlTradeRequestSync: public MqlTradeRequest
{
   ...
   bool closeby(const ulong ticket1, const ulong ticket2)
   {
      if(!PositionSelectByTicket(ticket1)) return false;
      double volume1 = PositionGetDouble(POSITION_VOLUME);
      if(!PositionSelectByTicket(ticket2)) return false;
      double volume2 = PositionGetDouble(POSITION_VOLUME);
   
      action = TRADE_ACTION_CLOSE_BY;
      position = ticket1;
      position_by = ticket2;
      
      ZeroMemory(result);
      if(volume1 != volume2)
      {
         // 记住哪个头寸应该消失
         if(volume1 < volume2)
            result.position = ticket1;
         else
            result.position = ticket2;
      }
      return OrderSend(this, result);
   }

为了控制平仓结果,我们将较小头寸的订单号存储在 result.position 变量中。completed 方法和 MqlTradeResultSync 结构体中的所有内容都已准备好用于同步跟踪平仓头寸:正常平仓头寸时也使用相同的算法。

cpp
struct MqlTradeRequestSync: public MqlTradeRequest
{
   ...
   bool completed()
   {
      ...
      else if(action == TRADE_ACTION_CLOSE_BY)
      {
         return result.closed(timeout);
      }
      return false;
   }

反向头寸通常用作止损订单的替代方案,或者在保持持仓并跟随主要趋势的同时,尝试在短期回调中获利。使用伪止损订单的选项允许将实际平仓的决策推迟一段时间,继续分析市场走势,期望价格向正确的方向反转。然而,应该记住,“锁定” 的头寸需要增加保证金,并且会涉及到掉期费用。这就是为什么很难想象一个纯粹基于反向头寸构建的交易策略能作为本节的示例。

让我们拓展上一个示例中概述的基于价格走势柱线的策略思路。新的智能交易系统是 TradeCloseBy.mq5

我们将使用之前的信号,即在检测到两个连续同向收盘的蜡烛图时入场。负责生成该信号的函数仍然是 GetTradeDirection。然而,我们允许在趋势持续时再次入场。允许的头寸总数上限将在输入变量 PositionLimit 中设置,默认值为 5。

GetMyPositions 函数将进行一些更改:它将有两个参数,这两个参数将是指向接受头寸订单号数组的引用:分别为多头和空头头寸订单号数组。

cpp
#define PUSH(A,V) (A[ArrayResize(A, ArraySize(A) + 1, ArraySize(A) * 2) - 1] = V)
   
int GetMyPositions(const string s, const ulong m,
   ulong &ticketsLong[], ulong &ticketsShort[])
{
   for(int i = 0; i < PositionsTotal(); ++i)
   {
      if(PositionGetSymbol(i) == s && PositionGetInteger(POSITION_MAGIC) == m)
      {
         if((ENUM_POSITION_TYPE)PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY)
            PUSH(ticketsLong, PositionGetInteger(POSITION_TICKET));
         else
            PUSH(ticketsShort, PositionGetInteger(POSITION_TICKET));
      }
   }
   
   const int min = fmin(ArraySize(ticketsLong), ArraySize(ticketsShort));
   if(min == 0) return -fmax(ArraySize(ticketsLong), ArraySize(ticketsShort));
   return min;
}

该函数返回两个数组中较小数组的大小。当该值大于 0 时,我们就有机会平仓反向头寸。

如果最小的数组大小为 0,该函数将返回另一个数组的大小,但带有负号,只是为了让调用代码知道所有头寸都在同一方向。

如果两个方向都没有头寸,该函数将返回 0。

开仓操作仍由 OpenPosition 函数控制,这里没有变化。

平仓将仅在新函数 CloseByPosition 中以两个反向头寸的模式进行。换句话说,这个智能交易系统无法以通常的方式一次平仓一个头寸。当然,在一个真正的交易机器人中,这种原则不太可能出现,但作为反向平仓的示例,它非常合适。如果我们需要平仓一个单独的头寸,只需为其开设一个反向头寸(此时浮动盈亏被锁定),然后对这两个头寸调用 CloseByPosition 函数即可。

cpp
bool CloseByPosition(const ulong ticket1, const ulong ticket2)
{
   MqlTradeRequestSync request;
   request.magic = Magic;
   
   ResetLastError();
   // 发送请求并等待其完成
   if(request.closeby(ticket1, ticket2))
   {
      Print("Positions collapse initiated");
      if(request.completed())
      {
         Print("OK CloseBy Order/Deal/Position");
         return true; // 成功
      }
   }
   
   Print(TU::StringOf(request));
   Print(TU::StringOf(request.result));
   
   return false; // 错误
}

这段代码使用了上述的 request.closeby 方法。填充 positionposition_by 字段并调用 OrderSend 函数。

交易逻辑在 OnTick 处理程序中描述,它仅在新柱线形成时分析价格形态,并从 GetTradeDirection 函数接收信号。

cpp
void OnTick()
{
   static bool error = false;
   // 如果没有错误,等待新柱线的形成
   static datetime lastBar = 0;
   if(iTime(_Symbol, _Period, 0) == lastBar && !error) return;
   lastBar = iTime(_Symbol, _Period, 0);
   
   const ENUM_ORDER_TYPE type = GetTradeDirection();
   ...

接下来,我们用有效交易品种和给定 `Magic` 数的头寸订单号填充 `ticketsLong` 和 `ticketsShort` 数组。如果 `GetMyPositions` 函数返回的值大于 0,它给出了已形成的反向头寸对的数量。可以使用 `CloseByPosition` 函数在循环中平仓这些头寸对。在这种情况下,头寸对的组合是随机选择的(按照终端环境中头寸的顺序),然而,在实际中,按交易量选择头寸对或者先平仓最盈利的头寸对可能很重要。

   ulong ticketsLong[], ticketsShort[];
   const int n = GetMyPositions(_Symbol, Magic, ticketsLong, ticketsShort);
   if(n > 0)
   {
      for(int i = 0; i < n; ++i)
      {
         error = !CloseByPosition(ticketsShort[i], ticketsLong[i]) && error;
      }
   }
   ...

对于 `n` 的任何其他值,应该检查是否有入场信号(可能是重复的),并通过调用 `OpenPosition` 函数来执行该信号。

   else if(type == ORDER_TYPE_BUY || type == ORDER_TYPE_SELL)
   {
      error = !OpenPosition(type);
   }
   ...

最后,如果仍然有未平仓头寸,但它们都在同一方向,我们检查它们的数量是否达到了限制,在这种情况下,我们开设一个反向头寸,以便在下一根柱线上 “对冲” 其中两个头寸(从而平仓旧头寸中的任意一个)。

   else if(n < 0)
   {
      if(-n >= (int)PositionLimit)
      {
         if(ArraySize(ticketsLong) > 0)
         {
            error = !OpenPosition(ORDER_TYPE_SELL);
         }
         else // (ArraySize(ticketsShort) > 0)
         {
            error = !OpenPosition(ORDER_TYPE_BUY);
         }
      }
   }
}

让我们在 2022 年初开始,在 XAUUSD(黄金兑美元)、H1(1 小时图)上使用默认设置在测试器中运行这个智能交易系统。下面是程序运行过程中的头寸图表以及资金曲线。

TradeCloseBy 在 XAUUSD,H1 上的测试结果

在日志中很容易找到一个趋势结束的时刻(从订单号 #2 到 #4 的买入操作),然后开始生成相反方向的交易(卖出操作 #5),之后触发了反向平仓。

2022.01.03 01:05:00   instant buy 0.01 XAUUSD at 1831.13 (1830.63 / 1831.13 / 1830.63)
2022.01.03 01:05:00   deal #2 buy 0.01 XAUUSD at 1831.13 done (based on order #2)
2022.01.03 01:05:00   deal performed [#2 buy 0.01 XAUUSD at 1831.13]
2022.01.03 01:05:00   order performed buy 0.01 at 1831.13 [#2 buy 0.01 XAUUSD at 1831.13]
2022.01.03 01:05:00   Waiting for position for deal D=2
2022.01.03 01:05:00   OK New Order/Deal/Position
2022.01.03 02:00:00   instant buy 0.01 XAUUSD at 1828.77 (1828.47 / 1828.77 / 1828.47)
2022.01.03 02:00:00   deal #3 buy 0.01 XAUUSD at 1828.77 done (based on order #3)
2022.01.03 02:00:00   deal performed [#3 buy 0.01 XAUUSD at 1828.77]
2022.01.03 02:00:00   order performed buy 0.01 at 1828.77 [#3 buy 0.01 XAUUSD at 1828.77]
2022.01.03 02:00:00   Waiting for position for deal D=3
2022.01.03 02:00:00   OK New Order/Deal/Position
2022.01.03 03:00:00   instant buy 0.01 XAUUSD at 1830.40 (1830.16 / 1830.40 / 1830.16)
2022.01.03 03:00:00   deal #4 buy 0.01 XAUUSD at 1830.40 done (based on order #4)
2022.01.03 03:00:00   deal performed [#4 buy 0.01 XAUUSD at 1830.40]
2022.01.03 03:00:00   order performed buy 0.01 at 1830.40 [#4 buy 0.01 XAUUSD at 1830.40]
2022.01.03 03:00:00   Waiting for position for deal D=4
2022.01.03 03:00:00   OK New Order/Deal/Position
2022.01.03 05:00:00   instant sell 0.01 XAUUSD at 1826.22 (1826.22 / 1826.45 / 1826.22)
2022.01.03 05:00:00   deal #5 sell 0.01 XAUUSD at 1826.22 done (based on order #5)
2022.01.03 05:00:00   deal performed [#5 sell 0.01 XAUUSD at 1826.22]
2022.01.03 05:00:00   order performed sell 0.01 at 1826.22 [#5 sell 0.01 XAUUSD at 1826.22]
2022.01.03 05:00:00   Waiting for position for deal D=5
2022.01.03 05:00:00   OK New Order/Deal/Position
2022.01.03 06:00:00   close position #5 sell 0.01 XAUUSD by position #2 buy 0.01 XAUUSD (1825.64 / 1825.86 / 1825.64)
2022.01.03 06:00:00   deal #6 buy 0.01 XAUUSD at 1831.13 done (based on order #6)
2022.01.03 06:00:00   deal #7 sell 0.01 XAUUSD at 1826.22 done (based on order #6)
2022.01.03 06:00:00   Positions collapse initiated
2022.01.03 06:00:00   OK CloseBy Order/Deal/Position

交易 #3 是一个有趣的情况。细心的读者会注意到它的开仓价格比前一个低, 似乎违反了我们的策略。实际上,这里并没有错误,这是因为信号条件被写得尽可能简单:仅仅基于柱线的收盘价。因此,一根看跌反转蜡烛图(D),它跳空高开并且收盘价高于前一根看涨蜡烛图(C)的结束位置,却产生了一个买入信号。这种情况在下面的截图中有所说明。

基于收盘价的上升趋势中的交易

依次排列的蜡烛图 A、B、C、D 和 E,它们的收盘价都高于前一根蜡烛图,这促使继续买入。为了排除这样的情况,应该额外分析柱线本身的方向。

在这个例子中最后需要注意的是 OnInit 函数。由于这个智能交易系统使用了 TRADE_ACTION_CLOSE_BY 操作,在这里会对相关的账户和有效交易品种的设置进行检查。

cpp
int OnInit()
{
   ...
   if(AccountInfoInteger(ACCOUNT_MARGIN_MODE) != ACCOUNT_MARGIN_MODE_RETAIL_HEDGING)
   {
      Alert("这个智能交易系统需要一个对冲账户!");
      return INIT_FAILED;
   }
   
   if((SymbolInfoInteger(_Symbol, SYMBOL_ORDER_MODE) & SYMBOL_ORDER_CLOSEBY) == 0)
   {
      Alert(_Symbol + "不支持‘反向平仓’模式");
      return INIT_FAILED;
   }
   
   return INIT_SUCCEEDED;
}

如果其中一个属性不支持交叉平仓,这个智能交易系统将无法继续运行。在创建实际可用的交易机器人时,通常会在交易算法内部进行这些检查,并使程序切换到替代模式,特别是切换到单个头寸平仓模式,以及在进行净值计算时维持总头寸。

下挂单

在“订单类型”中,我们从理论上探讨了该平台支持的下挂单的所有选项。从实践角度来看,订单是使用OrderSend/OrderSendAsync函数创建的,对于这些函数,请求结构MqlTradeRequest需根据特殊规则预先填充。具体来说,action字段必须包含ENUM_TRADE_REQUEST_ACTIONS枚举中的TRADE_ACTION_PENDING值。考虑到这一点,以下字段是必填的:

  • action
  • symbol
  • volume
  • price
  • type(默认值0对应ORDER_TYPE_BUY)
  • type_filling(默认值0对应ORDER_FILLING_FOK)
  • type_time(默认值0对应ORDER_TIME_GTC)
  • expiration(默认值0,对于ORDER_TIME_GTC不使用)

如果零默认值适合任务,最后四个字段中的一些可以跳过。

stoplimit字段仅对于ORDER_TYPE_BUY_STOP_LIMIT和ORDER_TYPE_SELL_STOP_LIMIT类型的订单是必填的。

以下字段是可选的:

  • sl
  • tp
  • magic
  • comment

sl和tp中的零值表示没有保护水平。

让我们在MqlTradeSync.mqh文件的结构中添加检查值和填充字段的方法。所有类型订单的形成原则是相同的,所以让我们考虑一下下买入限价单和卖出限价单的几个特殊情况。其余类型仅在type字段的值上有所不同。具有全套必填字段以及保护水平的公共方法根据类型命名:buyLimit和sellLimit。

c
ulong buyLimit(const string name, const double lot, const double p,
      const double stop = 0, const double take = 0,
      ENUM_ORDER_TYPE_TIME duration = ORDER_TIME_GTC, datetime until = 0)
   {
      type = ORDER_TYPE_BUY_LIMIT;
      return _pending(name, lot, p, stop, take, duration, until);
   }
   
   ulong sellLimit(const string name, const double lot, const double p,
      const double stop = 0, const double take = 0,
      ENUM_ORDER_TYPE_TIME duration = ORDER_TIME_GTC, datetime until = 0)
   {
      type = ORDER_TYPE_SELL_LIMIT;
      return _pending(name, lot, p, stop, take, duration, until);
   }

由于结构中包含在构造函数中可选初始化的symbol字段,所以有一些没有name参数的类似方法:它们通过将symbol作为第一个参数传递来调用上述方法。因此,要轻松创建订单,可编写以下代码:

c
MqlTradeRequestSync request; // 默认使用当前图表品种
request.buyLimit(volume, price);

检查传递的值、对其进行归一化、将其保存在结构字段中以及创建挂单的代码的通用部分已移至辅助方法_pending。如果成功,它将返回订单单号,否则返回0。

c
ulong _pending(const string name, const double lot, const double p,
      const double stop = 0, const double take = 0,
      ENUM_ORDER_TYPE_TIME duration = ORDER_TIME_GTC, datetime until = 0,
      const double origin = 0)
   {
      action = TRADE_ACTION_PENDING;
      if(!setSymbol(name)) return 0;
      if(!setVolumePrices(lot, p, stop, take, origin)) return 0;
      if(!setExpiration(duration, until)) return 0;
      if((SymbolInfoInteger(name, SYMBOL_ORDER_MODE) & (1 << (type / 2))) == 0)
      {
         Print(StringFormat("pending orders %s not allowed for %s",
            EnumToString(type), name));
         return 0;
      }
      ZeroMemory(result);
      if(OrderSend(this, result)) return result.order;
      return 0;
   }

我们已经知道如何填充action字段,以及如何从之前的交易操作中调用setSymbol和setVolumePrices方法。

多字符串if运算符确保准备进行的操作在SYMBOL_ORDER_MODE属性中指定的允许的品种操作之中。对type进行整数类型除法(将其除以2并将结果值左移1位),会在允许的订单类型掩码中设置正确的位。这是由于ENUM_ORDER_TYPE枚举中的常量与SYMBOL_ORDER_MODE属性的组合导致的。例如,ORDER_TYPE_BUY_STOP和ORDER_TYPE_SELL_STOP的值分别为4和5,除以2后都得到2(去掉小数部分)。1 << 2的运算结果为4,等于SYMBOL_ORDER_STOP。

挂单的一个特殊之处是对有效期的处理。setExpiration方法负责处理这个问题。在这个方法中,应该确保指定的有效期模式ENUM_ORDER_TYPE_TIME对于该品种是允许的,并且until中的日期和时间填写正确。

c
bool setExpiration(ENUM_ORDER_TYPE_TIME duration = ORDER_TIME_GTC, datetime until = 0)
   {
      const int modes = (int)SymbolInfoInteger(symbol, SYMBOL_EXPIRATION_MODE);
      if(((1 << duration) & modes) != 0)
      {
         type_time = duration;
         if((duration == ORDER_TIME_SPECIFIED || duration == ORDER_TIME_SPECIFIED_DAY)
            && until == 0)
         {
            Print(StringFormat("datetime is 0, "
               "but it's required for order expiration mode %s",
               EnumToString(duration)));
            return false;
         }
         if(until > 0 && until <= TimeTradeServer())
         {
            Print(StringFormat("expiration datetime %s is in past, server time is %s",
               TimeToString(until), TimeToString(TimeTradeServer())));
            return false;
         }
         expiration = until;
      }
      else
      {
         Print(StringFormat("order expiration mode %s is not allowed for %s",
            EnumToString(duration), symbol));
         return false;
      }
      return true;
   }

允许的模式的位掩码可在SYMBOL_EXPIRATION_MODE属性中获取。掩码中的位与ENUM_ORDER_TYPE_TIME常量的组合使得我们只需计算表达式1 << duration并将其叠加到掩码上:非零值表示该模式存在。

对于ORDER_TIME_SPECIFIED和ORDER_TIME_SPECIFIED_DAY模式,包含特定日期时间值的expiration字段不能为空。此外,指定的日期和时间不能是过去的。

由于前面介绍的_pending方法最终使用OrderSend向服务器发送请求,我们的程序必须确保收到单号的订单确实已创建(这对于可能输出到外部交易系统的限价单尤为重要)。因此,在用于“阻塞”控制结果的completed方法中,我们将为TRADE_ACTION_PENDING操作添加一个分支。

c
bool completed()
   {
      // 旧的处理代码
      // TRADE_ACTION_DEAL
      // TRADE_ACTION_SLTP
      // TRADE_ACTION_CLOSE_BY
      ...
      else if(action == TRADE_ACTION_PENDING)
      {
         return result.placed(timeout);
      }
      ...
      return false;
   }

在MqlTradeResultSync结构中,我们添加placed方法。

c
bool placed(const ulong msc = 1000)
   {
      if(retcode != TRADE_RETCODE_DONE
         && retcode != TRADE_RETCODE_DONE_PARTIAL)
      {
         return false;
      }
      
      if(!wait(orderExist, msc))
      {
         Print("Waiting for order: #" + (string)order);
         return false;
      }
      return true;
   }

它的主要任务是使用orderExist函数中的wait等待订单出现:它已经在持仓开仓验证的第一阶段使用过。

为了测试新功能,让我们实现智能交易系统PendingOrderSend.mq5。它允许使用输入变量选择挂单类型及其所有属性,然后执行确认请求。

c
enum ENUM_ORDER_TYPE_PENDING
{                                                        // UI界面字符串
   PENDING_BUY_STOP = ORDER_TYPE_BUY_STOP,               // ORDER_TYPE_BUY_STOP
   PENDING_SELL_STOP = ORDER_TYPE_SELL_STOP,             // ORDER_TYPE_SELL_STOP
   PENDING_BUY_LIMIT = ORDER_TYPE_BUY_LIMIT,             // ORDER_TYPE_BUY_LIMIT
   PENDING_SELL_LIMIT = ORDER_TYPE_SELL_LIMIT,           // ORDER_TYPE_SELL_LIMIT
   PENDING_BUY_STOP_LIMIT = ORDER_TYPE_BUY_STOP_LIMIT,   // ORDER_TYPE_BUY_STOP_LIMIT
   PENDING_SELL_STOP_LIMIT = ORDER_TYPE_SELL_STOP_LIMIT, // ORDER_TYPE_SELL_STOP_LIMIT
};
 
input string Symbol;             // 品种(空 = 当前 _Symbol)
input double Volume;             // 交易量(0 = 最小手数)
input ENUM_ORDER_TYPE_PENDING Type = PENDING_BUY_STOP;
input int Distance2SLTP = 0;     // 到止损/止盈的点数距离 (0 = 不设置)
input ENUM_ORDER_TYPE_TIME Expiration = ORDER_TIME_GTC;
input datetime Until = 0;
input ulong Magic = 1234567890;
input string Comment;

每次启动智能交易系统或更改参数时,它都会创建一个新订单。目前尚未提供自动删除订单的功能。我们稍后将讨论这种操作类型。在这方面,不要忘记手动删除订单。

与之前的一些示例一样,基于定时器执行一次性下单操作(因此,首先应确保市场是开放的)。

c
void OnTimer()
{
   // 执行一次并等待用户更改设置
   EventKillTimer();
   
   const string symbol = StringLen(Symbol) == 0 ? _Symbol : Symbol;
   if(PlaceOrder((ENUM_ORDER_TYPE)Type, symbol, Volume,
      Distance2SLTP, Expiration, Until, Magic, Comment))
   {
      Alert("Pending order placed - remove it manually, please");
   }
}

PlaceOrder函数接受所有设置作为参数,发送请求,并返回成功指示符(非零单号)。所有支持类型的订单都预先填充了与当前价格的距离,这些距离是作为每日报价范围的一部分计算得出的。

c
ulong PlaceOrder(const ENUM_ORDER_TYPE type,
   const string symbol, const double lot,
   const int sltp, ENUM_ORDER_TYPE_TIME expiration, datetime until,
   const ulong magic = 0, const string comment = NULL)
{
   static double coefficients[] = // 按订单类型索引
   {
      0  ,   // ORDER_TYPE_BUY - 不使用
      0  ,   // ORDER_TYPE_SELL - 不使用
     -0.5,   // ORDER_TYPE_BUY_LIMIT - 略低于价格
     +0.5,   // ORDER_TYPE_SELL_LIMIT - 略高于价格
     +1.0,   // ORDER_TYPE_BUY_STOP - 远高于价格
     -1.0,   // ORDER_TYPE_SELL_STOP - 远低于价格
     +0.7,   // ORDER_TYPE_BUY_STOP_LIMIT - 平均高于价格 
     -0.7,   // ORDER_TYPE_SELL_STOP_LIMIT - 平均低于价格
      0  ,   // ORDER_TYPE_CLOSE_BY - 不使用
   };
   ...

例如,ORDER_TYPE_BUY_LIMIT的系数为-0.5意味着订单将在当前价格下方半个每日范围处下单(在范围内反弹),而ORDER_TYPE_BUY_STOP的系数为+1.0意味着订单将在范围的上边界处(突破)。

每日范围本身的计算如下。

c
const double range = iHigh(symbol, PERIOD_D1, 1) - iLow(symbol, PERIOD_D1, 1);
   Print("Autodetected daily range: ", (float)range);
   ...

我们找到下面将需要的交易量和点数的值。

c
const double volume = lot == 0 ? SymbolInfoDouble(symbol, SYMBOL_VOLUME_MIN) : lot;
   const double point = SymbolInfoDouble(symbol, SYMBOL_POINT);

下单的价格水平根据总范围和给定的系数在price变量中计算。

c
const double price = TU::GetCurrentPrice(type, symbol) + range * coefficients[type];

stoplimit字段仅对于*_STOP_LIMIT订单必须填充。其值存储在origin变量中。

c
const bool stopLimit =
      type == ORDER_TYPE_BUY_STOP_LIMIT ||
      type == ORDER_TYPE_SELL_STOP_LIMIT;
   const double origin = stopLimit ? TU::GetCurrentPrice(type, symbol) : 0;

当这两种类型的订单触发时,将在当前价格处下一个新的挂单。实际上,在这种情况下,价格从当前值移动到订单激活的价格水平,因此“之前的当前”价格成为限价单指示的正确反弹水平。我们将在下面说明这种情况。

保护水平使用TU::TradeDirection对象确定。对于限价止损订单,我们从origin开始计算。

c
TU::TradeDirection dir(type);
   const double stop = sltp == 0 ? 0 :
      dir.negative(stopLimit ? origin : price, sltp * point);
   const double take = sltp == 0 ? 0 :
      dir.positive(stopLimit ? origin : price, sltp * point);

接下来,描述结构并填充可选字段。

c
MqlTradeRequestSync request(symbol);
   
   request.magic = magic;
   request.comment = comment;
   // request.type_filling = SYMBOL_FILLING_FOK;

在这里可以选择填充模式。默认情况下,MqlTradeRequestSync自动选择允许的模式中的第一个,即ENUM_ORDER_TYPE_FILLING。

根据用户选择的订单类型,我们调用相应的交易方法。

c
ResetLastError();
   // 填充并检查必填字段,发送请求
   ulong order = 0;
   switch(type)
   {
   case ORDER_TYPE_BUY_STOP:
      order = request.buyStop(volume, price, stop, take, expiration, until);
      break;
   case ORDER_TYPE_SELL_STOP:
      order = request.sellStop(volume, price, stop, take, expiration, until);
      break;
   case ORDER_TYPE_BUY_LIMIT:
      order = request.buyLimit(volume, price, stop, take, expiration, until);
      break;
   case ORDER_TYPE_SELL_LIMIT:
      order = request.sellLimit(volume, price, stop, take, expiration, until);
      break;
   case ORDER_TYPE_BUY_STOP_LIMIT:
      order = request.buyStopLimit(volume, price, origin, stop, take, expiration, until);
      break;
   case ORDER_TYPE_SELL_STOP_LIMIT:
      order = request.sellStopLimit(volume, price, origin, stop, take, expiration, until);
      break;
   }
   ...

如果收到单号,我们等待它出现在终端的交易环境中。

c
if(order != 0)
   {
      Print("OK order sent: #=", order);
      if(request.completed()) // 期望结果(订单确认)
      {
         Print("OK order placed");
      }
   }
   Print(TU::StringOf(request));
   Print(TU::StringOf(request.result));
   return order;
}

让我们在EURUSD图表上使用默认设置运行智能交易系统,并另外选择到保护水平的距离为1000点。我们将在日志中看到以下条目(假设默认设置与你账户中EURUSD的权限匹配)。

Autodetected daily range: 0.01413
OK order sent: #=1282106395
OK order placed
TRADE_ACTION_PENDING, EURUSD, ORDER_TYPE_BUY_STOP, V=0.01, ORDER_FILLING_FOK, »
  » @ 1.11248, SL=1.10248, TP=1.12248, ORDER_TIME_GTC, M=1234567890
DONE, #=1282106395, V=0.01, Request executed, Req=91
Alert: Pending order placed - remove it manually, please

这是图表上的显示情况:

挂单 ORDER_TYPE_BUY_STOP

挂单 ORDER_TYPE_BUY_STOP

让我们手动删除订单并将订单类型更改为ORDER_TYPE_BUY_STOP_LIMIT。结果是一个更复杂的画面。

挂单 ORDER_TYPE_BUY_STOP_LIMIT

挂单 ORDER_TYPE_BUY_STOP_LIMIT

上方一对点划线所在的价格是订单触发价格,结果将在当前价格水平处下一个ORDER_TYPE_BUY_LIMIT订单,止损和止盈值用红线标记。未来的ORDER_TYPE_BUY_LIMIT订单的止盈水平实际上与新创建的初步订单ORDER_TYPE_BUY_STOP_LIMIT的激活水平一致。

作为一个供自学的额外示例,本书附带了智能交易系统AllPendingsOrderSend.mq5;该智能交易系统一次设置6个挂单:每种类型各一个。

所有类型的挂单

所有类型的挂单

使用默认设置运行它的结果是,你可能会得到如下的日志条目:

Autodetected daily range: 0.01413
OK order placed: #=1282032135
TRADE_ACTION_PENDING, EURUSD, ORDER_TYPE_BUY_LIMIT, V=0.01, ORDER_FILLING_FOK, »
  » @ 1.08824, ORDER_TIME_GTC, M=1234567890
DONE, #=1282032135, V=0.01, Request executed, Req=73
OK order placed: #=1282032136
TRADE_ACTION_PENDING, EURUSD, ORDER_TYPE_SELL_LIMIT, V=0.01, ORDER_FILLING_FOK, »
  » @ 1.10238, ORDER_TIME_GTC, M=1234567890
DONE, #=1282032136, V=0.01, Request executed, Req=74
OK order placed: #=1282032138
TRADE_ACTION_PENDING, EURUSD, ORDER_TYPE_BUY_STOP, V=0.01, ORDER_FILLING_FOK, »
  » @ 1.10944, ORDER_TIME_GTC, M=1234567890
DONE, #=1282032138, V=0.01, Request executed, Req=75
OK order placed: #=1282032141
TRADE_ACTION_PENDING, EURUSD, ORDER_TYPE_SELL_STOP, V=0.01, ORDER_FILLING_FOK, »
  » @ 1.08118, ORDER_TIME_GTC, M=1234567890
DONE, #=1282032141, V=0.01, Request executed, Req=76
OK order placed: #=1282032142
TRADE_ACTION_PENDING, EURUSD, ORDER_TYPE_BUY_STOP_LIMIT, V=0.01, ORDER_FILLING_FOK, »
  » @ 1.10520, X=1.09531, ORDER_TIME_GTC, M=1234567890
DONE, #=1282032142, V=0.01, Request executed, Req=77
OK order placed: #=1282032144
TRADE_ACTION_PENDING, EURUSD, ORDER_TYPE_SELL_STOP_LIMIT, V=0.01, ORDER_FILLING_FOK, »
  » @ 1.08542, X=1.09531, ORDER_TIME_GTC, M=1234567890
DONE, #=1282032144, V=0.01, Request executed, Req=78
Alert: 6 pending orders placed - remove them manually, please

总的来说,挂单功能为交易者提供了更多的策略选择和交易灵活性。通过合理设置挂单的参数,如订单类型、价格、止损和止盈水平等,交易者可以更好地控制风险和实现盈利目标。在实际应用中,需要根据市场情况和个人交易策略来谨慎使用这些功能,并注意及时管理和调整订单,以避免不必要的损失。

此外,对于MQL编程开发者来说,理解和掌握挂单操作的实现原理以及相关函数和结构的使用方法,有助于开发出更强大和智能的交易系统,满足不同交易者的需求。在后续的学习和实践中,可以进一步探索更多关于挂单的高级应用和技巧,以及如何与其他交易功能相结合,实现更复杂和高效的交易策略。

希望以上关于挂单的介绍和示例能够帮助读者更好地理解和应用这一重要的交易功能。如果你在使用过程中遇到任何问题或有进一步的需求,请随时参考相关文档或寻求专业的帮助。

注意:以上内容中对代码部分尽量保留了原文格式,以保持代码的可读性和可理解性。对于一些特定的编程术语和变量名,按照常见的编程习惯和上下文进行了翻译,以确保译文的准确性和专业性。同时,对于一些日志输出信息和提示信息,也进行了准确的翻译,以便读者能够清晰地了解相关操作的结果和状态。

如果你还有其他相关的翻译或技术问题,欢迎继续提问,我将尽力为你提供帮助和解答。

修改挂单

MetaTrader 5 允许您修改挂单的某些属性,包括触发价格、保护水平(止损和止盈)以及到期日期。而订单类型或交易量等主要属性则无法更改。在这种情况下,您应该删除该订单并替换为另一个订单。唯一一种服务器自身可以更改订单类型的情况是,止损限价单(stop limit order)被触发时,它会转变为相应的限价单(limit order)。

通过 TRADE_ACTION_MODIFY 操作以编程方式修改订单:在使用 OrderSendOrderSendAsync 函数将订单发送到服务器之前,需要将这个常量写入结构体 MqlTradeRequestaction 字段中。被修改订单的单号在 order 字段中指明。考虑到 actionorder,此操作所需的完整字段列表包括:

  • action
  • order
  • price
  • type_time(默认值 0 对应 ORDER_TIME_GTC,即取消前有效)
  • expiration(默认值 0,对于 ORDER_TIME_GTC 不重要)
  • type_filling(默认值 0 对应 ORDER_FILLING_FOK,即全部成交或撤销)
  • stoplimit(仅适用于 ORDER_TYPE_BUY_STOP_LIMITORDER_TYPE_SELL_STOP_LIMIT 类型的订单)

可选字段:

  • sl(止损价)
  • tp(止盈价)

如果已经为订单设置了保护水平,则应指定这些水平以便保存。值为零表示删除止损和/或止盈。

MqlTradeRequestSync 结构体(MqlTradeSync.mqh)中,订单修改的实现位于 modify 方法中。

c++
struct MqlTradeRequestSync: public MqlTradeRequest
{
   ...
   bool modify(const ulong ticket,
      const double p, const double stop = 0, const double take = 0,
      ENUM_ORDER_TYPE_TIME duration = ORDER_TIME_GTC, datetime until = 0,
      const double origin = 0)
   {
      if(!OrderSelect(ticket)) return false;
      
      action = TRADE_ACTION_MODIFY;
      order = ticket;
      
      // 以下字段是子函数内部检查所必需的
      type = (ENUM_ORDER_TYPE)OrderGetInteger(ORDER_TYPE);
      symbol = OrderGetString(ORDER_SYMBOL);
      volume = OrderGetDouble(ORDER_VOLUME_CURRENT);
      
      if(!setVolumePrices(volume, p, stop, take, origin)) return false;
      if(!setExpiration(duration, until)) return false;
      ZeroMemory(result);
      return OrderSend(this, result);
   }

请求的实际执行同样在 completed 方法中,在 if 操作符的特定分支里完成。

c++
   bool completed()
   {
      ...
      else if(action == TRADE_ACTION_MODIFY)
      {
         result.order = order;
         result.bid = sl;
         result.ask = tp;
         result.price = price;
         result.volume = stoplimit;
         return result.modified(timeout);
      }
      ...
   }

为了让 MqlTradeResultSync 结构体了解已编辑订单属性的新值,并能够将它们与结果进行比较,我们将这些值写入空闲字段中(在这种类型的请求中,服务器不会填充这些字段)。在 modified 方法中,result 结构体等待修改被应用。

c++
struct MqlTradeResultSync: public MqlTradeResult
{
   ...
   bool modified(const ulong msc = 1000)
   {
      if(retcode != TRADE_RETCODE_DONE && retcode != TRADE_RETCODE_PLACED)
      {
         return false;
      }
   
      if(!wait(orderModified, msc))
      {
         Print("Order not found in environment: #" + (string)order);
         return false;
      }
      return true;
   }
   
   static bool orderModified(MqlTradeResultSync &ref)
   {
      if(!(OrderSelect(ref.order) || HistoryOrderSelect(ref.order)))
      {
         Print("OrderSelect failed: #=" + (string)ref.order);
         return false;
      }
      return TU::Equal(ref.bid, OrderGetDouble(ORDER_SL))
         && TU::Equal(ref.ask, OrderGetDouble(ORDER_TP))
         && TU::Equal(ref.price, OrderGetDouble(ORDER_PRICE_OPEN))
         && TU::Equal(ref.volume, OrderGetDouble(ORDER_PRICE_STOPLIMIT));
   }

在这里,我们可以看到如何使用 OrderGetDouble 函数读取订单属性,并将其与指定的值进行比较。所有这些都按照我们已经熟悉的过程进行,在 wait 函数内部的一个循环中,在一定的 msc 超时时间内(默认值为 1000 毫秒)完成。

作为一个示例,我们使用智能交易系统 PendingOrderModify.mq5,同时从 PendingOrderSend.mq5 继承一些代码片段。特别是一组输入参数和用于创建新订单的 PlaceOrder 函数。如果对于给定的交易品种和 Magic 数字组合没有订单,在首次启动时将使用该函数,从而确保智能交易系统有可修改的订单。

需要一个新的函数来找到合适的订单:GetMyOrder。它与 GetMyPosition 函数非常相似,GetMyPosition 函数在头寸跟踪示例(TrailingStop.mq5)中用于找到合适的头寸。GetMyOrder 内部使用的内置 MQL5 API 函数的用途,从它们的名称中大致可以清楚,其技术说明将在单独的部分中介绍。

c++
ulong GetMyOrder(const string name, const ulong magic)
{
   for(int i = 0; i < OrdersTotal(); ++i)
   {
      ulong t = OrderGetTicket(i);
      if(OrderGetInteger(ORDER_MAGIC) == magic
         && OrderGetString(ORDER_SYMBOL) == name)
      {
         return t;
      }
   }
   
   return 0;
}

现在缺少输入参数 Distance2SLTP。取而代之的是,新的智能交易系统将自动计算价格的每日波动范围,并将保护水平设置在该范围一半的距离处。每天开始时,将重新计算 sltp 字段中的波动范围和新水平。订单修改请求将基于这些新值生成。

那些被触发并转变为头寸的挂单,在达到止损或止盈时将被平仓。如果在 MQL 程序中描述了交易事件处理程序,终端可以通知该程序挂单的触发和头寸的平仓情况。例如,这可以避免在有未平仓头寸时创建新订单。不过,当前的策略也可以使用。所以,我们稍后再处理事件相关内容。

智能交易系统的主要逻辑在 OnTick 处理程序中实现。

c++
void OnTick()
{
   static datetime lastDay = 0;
   static const uint DAYLONG = 60 * 60 * 24; // 一天中的秒数
   // 舍弃“小数”部分,即时间
   if(TimeTradeServer() / DAYLONG * DAYLONG == lastDay) return;
   ...

函数开头的两行代码确保算法在每天开始时运行一次。为此,我们计算不包含时间的当前日期,并将其与 lastDay 变量的值进行比较,lastDay 变量包含上一个成功的日期。当然,成功或错误状态在函数末尾才会明确,所以我们稍后再讨论。

接下来,计算前一天的价格波动范围。

c++
   const string symbol = StringLen(Symbol) == 0 ? _Symbol : Symbol;
   const double range = iHigh(symbol, PERIOD_D1, 1) - iLow(symbol, PERIOD_D1, 1);
   Print("Autodetected daily range: ", (float)range);
   ...

根据 GetMyOrder 函数中是否存在订单,我们要么通过 PlaceOrder 创建一个新订单,要么使用 ModifyOrder 编辑现有的订单。

c++
   uint retcode = 0;
   ulong ticket = GetMyOrder(symbol, Magic);
   if(!ticket)
   {
      retcode = PlaceOrder((ENUM_ORDER_TYPE)Type, symbol, Volume,
         range, Expiration, Until, Magic);
   }
   else
   {
      retcode = ModifyOrder(ticket, range, Expiration, Until);
   }
   ...

PlaceOrderModifyOrder 这两个函数都基于智能交易系统的输入参数和找到的价格波动范围来工作。它们返回请求的状态,需要以某种方式对其进行分析,以决定采取何种行动:

  • 如果请求成功(订单已更新,智能交易系统将休眠至第二天开始),则更新 lastDay 变量。
  • 如果存在暂时问题(例如,交易时段尚未开始),则在一段时间内保留 lastDay 中的旧日期,以便在接下来的报价时刻(ticks)再次尝试。
  • 如果检测到严重问题(例如,所选订单类型或交易方向在该交易品种上不被允许),则停止智能交易系统。
c++
   ...
   if(/* 某种对 retcode 的分析 */)
   {
      lastDay = TimeTradeServer() / DAYLONG * DAYLONG;
   }
}

在“平仓:全部平仓和部分平仓”部分中,我们使用了简化的分析方法,通过 IS_TANGIBLE 宏来判断是否存在错误,它以“是”或“否”的类别给出答案。显然,这种方法需要改进,我们很快会回到这个问题上。目前,我们将重点关注智能交易系统的主要功能。

PlaceOrder 函数的源代码与之前的示例几乎没有变化。ModifyOrder 函数如下所示。

回想一下,我们根据每日波动范围来确定订单的位置,并应用了系数表。这个原则没有改变,然而,由于我们现在有两个处理订单的函数,PlaceOrderModifyOrderCoefficients 表被放置在全局上下文中。我们在这里不再重复它,直接来看 ModifyOrder 函数。

c++
uint ModifyOrder(const ulong ticket, const double range,
   ENUM_ORDER_TYPE_TIME expiration, datetime until)
{
   // 默认值
   const string symbol = OrderGetString(ORDER_SYMBOL);
   const double point = SymbolInfoDouble(symbol, SYMBOL_POINT);
   ...

   // 根据订单类型和传入的波动范围计算价格水平
   const ENUM_ORDER_TYPE type = (ENUM_ORDER_TYPE)OrderGetInteger(ORDER_TYPE);
   const double price = TU::GetCurrentPrice(type, symbol) + range * Coefficients[type];
   
   // 仅为 *_STOP_LIMIT 订单填充 origin
   const bool stopLimit =
      type == ORDER_TYPE_BUY_STOP_LIMIT ||
      type == ORDER_TYPE_SELL_STOP_LIMIT;
   const double origin = stopLimit ? TU::GetCurrentPrice(type, symbol) : 0; 
   
   TU::TradeDirection dir(type);
   const int sltp = (int)(range / 2 / point);
   const double stop = sltp == 0 ? 0 :
      dir.negative(stopLimit ? origin : price, sltp * point);
   const double take = sltp == 0 ? 0 :
      dir.positive(stopLimit ? origin : price, sltp * point);
   ...

   // 计算完所有值后,创建 MqlTradeRequestSync 结构体的对象并执行请求
   MqlTradeRequestSync request(symbol);
   
   ResetLastError();
   // 传递字段数据,发送订单并等待结果
   if(request.modify(ticket, price, stop, take, expiration, until, origin)
      && request.completed())
   {
      Print("OK order modified: #=", ticket);
   }
   
   Print(TU::StringOf(request));
   Print(TU::StringOf(request.result));
   return request.result.retcode;
}

为了分析我们必须在 OnTick 内部的调用块中执行的 retcode,开发了一种新的机制来补充 TradeRetcode.mqh 文件。所有服务器返回代码被分为几个“严重程度”组,由 TRADE_RETCODE_SEVERITY 枚举的元素来描述。

c++
enum TRADE_RETCODE_SEVERITY
{
   SEVERITY_UNDEFINED,   // 一些非标准情况 - 只需输出到日志
   SEVERITY_NORMAL,      // 正常操作
   SEVERITY_RETRY,       // 尝试再次更新环境/价格(可能需要多次) 
   SEVERITY_TRY_LATER,   // 我们应该等待并再次尝试
   SEVERITY_REJECT,      // 请求被拒绝,可能(!)可以再次尝试
                         // 
   SEVERITY_INVALID,     // 需要修正请求
   SEVERITY_LIMITS,      // 需要检查限制并修正请求
   SEVERITY_PERMISSIONS, // 需要通知用户并更改程序/终端设置
   SEVERITY_ERROR,       // 停止,将信息输出到日志和用户
};

简单来说,前半部分对应可恢复的错误:通常只需等待一段时间并重新尝试请求即可。后半部分则需要您更改请求的内容,检查账户或交易品种设置、程序权限,在最坏的情况下,需要停止交易。如果愿意,您可以在 SEVERITY_REJECT 之前画一条条件分隔线,而不是像现在这样在它之后,因为这样在视觉上更突出。

所有代码的分组是由 TradeCodeSeverity 函数完成的(以下为简略形式)。

c++
TRADE_RETCODE_SEVERITY TradeCodeSeverity(const uint retcode)
{
   static const TRADE_RETCODE_SEVERITY severities[] =
   {
      ...
      SEVERITY_RETRY,       // REQUOTE (10004)
      SEVERITY_UNDEFINED,     
      SEVERITY_REJECT,      // REJECT (10006)
      SEVERITY_NORMAL,      // CANCEL (10007)
      SEVERITY_NORMAL,      // PLACED (10008)
      SEVERITY_NORMAL,      // DONE (10009)
      SEVERITY_NORMAL,      // DONE_PARTIAL (10010)
      SEVERITY_ERROR,       // ERROR (10011)
      SEVERITY_RETRY,       // TIMEOUT (10012)
      SEVERITY_INVALID,     // INVALID (10013)
      SEVERITY_INVALID,     // INVALID_VOLUME (10014)
      SEVERITY_INVALID,     // INVALID_PRICE (10015)
      SEVERITY_INVALID,     // INVALID_STOPS (10016)
      SEVERITY_PERMISSIONS, // TRADE_DISABLED (10017)
      SEVERITY_TRY_LATER,   // MARKET_CLOSED (10018)
      SEVERITY_LIMITS,      // NO_MONEY (10019)
      ...
   };
   
   if(retcode == 0) return SEVERITY_NORMAL;
   if(retcode < 10000 || retcode > HEDGE_PROHIBITED) return SEVERITY_UNDEFINED;
   return severities[retcode - 10000];
}

借助这个功能,OnTick 处理程序可以补充“智能”错误处理。静态变量 RetryFrequency 存储程序在出现非关键错误时尝试重复请求的频率。上一次进行这种尝试的时间存储在 RetryRecordTime 变量中。

c++
void OnTick()
{
   ...
   const static int DEFAULT_RETRY_TIMEOUT = 1; // 秒
   static int RetryFrequency = DEFAULT_RETRY_TIMEOUT;
   static datetime RetryRecordTime = 0;
   if(TimeTradeServer() - RetryRecordTime < RetryFrequency) return;
   ...

一旦 PlaceOrderModifyOrder 函数返回 retcode 的值,我们就了解其严重程度,并根据严重程度从以下三种选择中选其一:停止智能交易系统、等待超时时间结束、常规操作(将当前日期标记为订单成功修改的日期,存入 lastDay 中)。

c++
   const TRADE_RETCODE_SEVERITY severity = TradeCodeSeverity(retcode);
   if(severity >= SEVERITY_INVALID)
   {
      Alert("Can't place/modify pending order, EA is stopped");
      RetryFrequency = INT_MAX;
   }
   else if(severity >= SEVERITY_RETRY)
   {
      RetryFrequency += (int)sqrt(RetryFrequency + 1);
      RetryRecordTime = TimeTradeServer();
      PrintFormat("Problems detected, waiting for better conditions "
         "(timeout enlarged to %d seconds)",
         RetryFrequency);
   }
   else
   {
      if(RetryFrequency > DEFAULT_RETRY_TIMEOUT)
      {
         RetryFrequency = DEFAULT_RETRY_TIMEOUT;
         PrintFormat("Timeout restored to %d second", RetryFrequency);
      }
      lastDay = TimeTradeServer() / DAYLONG * DAYLONG;
   }

如果出现被归类为可解决的重复问题,RetryFrequency 超时时间会随着后续每次错误逐渐增加,但当请求成功处理时,会重置为 1 秒。

需要注意的是,MqlTradeRequestSync 结构体的方法会检查大量参数组合的正确性,如果发现问题,会在调用 SendRequest 之前中断处理过程。这种行为是默认启用的,但可以通过在 #include <MQL5Book/MqlTradeSync.mqh> 指令之前定义一个空的 RETURN(X) 宏来禁用它。

c++
#define RETURN(X)
#include <MQL5Book/MqlTradeSync.mqh>

通过这个宏定义,检查只会将警告打印到日志中,但会继续执行方法,直到调用 SendRequest

无论如何,在调用 MqlTradeResultSync 结构体的某个方法之后,错误代码会被添加到 retcode 中。这将由服务器或 MqlTradeRequestSync 结构体的检查算法来完成(这里我们利用了 MqlTradeResultSync 实例包含在 MqlTradeRequestSync 内部这一事实)。为了简洁起见,我在这里不提供关于错误代码返回以及 MqlTradeRequestSync 方法中 RETURN 宏使用的描述。有兴趣的人可以在 MqlTradeSync.mqh 文件中查看完整的源代码。

让我们在测试器中运行智能交易系统 PendingOrderModify.mq5,启用可视化模式,使用 XAUUSD(黄金兑美元)、H1(1 小时时间框架)的数据(全报价或真实报价模式)。在默认设置下,智能交易系统将以最小手数下达 ORDER_TYPE_BUY_STOP(买入止损)类型的订单。让我们从日志和交易历史记录中确认该程序会下达挂单,并在每天开始时修改它们。

2022.01.03 01:05:00   Autodetected daily range: 14.37

2022.01.03 01:05:00   buy stop 0.01 XAUUSD at 1845.73 sl: 1838.55 tp: 1852.91 (1830.63 / 1831.36)

2022.01.03 01:05:00   OK order placed: #=2

2022.01.03 01:05:00   TRADE_ACTION_PENDING, XAUUSD, ORDER_TYPE_BUY_STOP, V=0.01, ORDER_FILLING_FOK, »

  » @ 1845.73, SL=1838.55, TP=1852.91, ORDER_TIME_GTC, M=1234567890

2022.01.03 01:05:00   DONE, #=2, V=0.01, Bid=1830.63, Ask=1831.36, Request executed

2022.01.04 01:05:00   Autodetected daily range: 33.5

2022.01.04 01:05:00   order modified [#2 buy stop 0.01 XAUUSD at 1836.56]

2022.01.04 01:05:00   OK order modified: #=2

2022.01.04 01:05:00   TRADE_ACTION_MODIFY, XAUUSD, ORDER_TYPE_BUY_STOP, V=0.01, ORDER_FILLING_FOK, » 

  » @ 1836.56, SL=1819.81, TP=1853.31, ORDER_TIME_GTC, #=2

2022.01.04 01:05:00   DONE, #=2, @ 1836.56, Bid=1819.81, Ask=1853.31, Request executed, Req=1

2022.01.05 01:05:00   Autodetected daily range: 18.23

2022.01.05 01:05:00   order modified [#2 buy stop 0.01 XAUUSD at 1832.56]

2022.01.05 01:05:00   OK order modified: #=2

2022.01.05 01:05:00   TRADE_ACTION_MODIFY, XAUUSD, ORDER_TYPE_BUY_STOP, V=0.01, ORDER_FILLING_FOK, »

  » @ 1832.56, SL=1823.45, TP=1841.67, ORDER_TIME_GTC, #=2

2022.01.05 01:05:00   DONE, #=2, @ 1832.56, Bid=1823.45, Ask=1841.67, Request executed, Req=2

...

2022.01.11 01:05:00   Autodetected daily range: 11.96

2022.01.11 01:05:00   order modified [#2 buy stop 0.01 XAUUSD at 1812.91]

2022.01.11 01:05:00   OK order modified: #=2

2022.01.11 01:05:00   TRADE_ACTION_MODIFY, XAUUSD, ORDER_TYPE_BUY_STOP, V=0.01, ORDER_FILLING_FOK, »

  » @ 1812.91, SL=1806.93, TP=1818.89, ORDER_TIME_GTC, #=2

2022.01.11 01:05:00   DONE, #=2, @ 1812.91, Bid=1806.93, Ask=1818.89, Request executed, Req=6

2022.01.11 18:10:58   order [#2 buy stop 0.01 XAUUSD at 1812.91] triggered

2022.01.11 18:10:58   deal #2 buy 0.01 XAUUSD at 1812.91 done (based on order #2)

2022.01.11 18:10:58   deal performed [#2 buy 0.01 XAUUSD at 1812.91]

2022.01.11 18:10:58   order performed buy 0.01 at 1812.91 [#2 buy stop 0.01 XAUUSD at 1812.91]

2022.01.11 20:28:59   take profit triggered #2 buy 0.01 XAUUSD 1812.91 sl: 1806.93 tp: 1818.89 »

  » [#3 sell 0.01 XAUUSD at 1818.89]

2022.01.11 20:28:59   deal #3 sell 0.01 XAUUSD at 1818.91 done (based on order #3)

2022.01.11 20:28:59   deal performed [#3 sell 0.01 XAUUSD at 1818.91]

2022.01.11 20:28:59   order performed sell 0.01 at 1818.91 [#3 sell 0.01 XAUUSD at 1818.89]

2022.01.12 01:05:00   Autodetected daily range: 23.28

2022.01.12 01:05:00   buy stop 0.01 XAUUSD at 1843.77 sl: 1832.14 tp: 1855.40 (1820.14 / 1820.49)

2022.01.12 01:05:00   OK order placed: #=4

2022.01.12 01:05:00   TRADE_ACTION_PENDING, XAUUSD, ORDER_TYPE_BUY_STOP, V=0.01, ORDER_FILLING_FOK, »

  » @ 1843.77, SL=1832.14, TP=1855.40, ORDER_TIME_GTC, M=1234567890

2022.01.12 01:05:00   DONE, #=4, V=0.01, Bid=1820.14, Ask=1820.49, Request executed, Req=7

订单可能在任何时刻被触发,之后头寸会在一段时间后因止损或止盈而平仓(如上述代码所示)。

在某些情况下,可能会出现这样的情况:在下一天开始时,头寸仍然存在,然后除了该头寸之外,还会创建一个新订单,如下图所示。

测试器中基于挂单交易策略的智能交易系统

请注意,由于我们请求 PERIOD_D1(日线图)时间框架的报价来计算每日波动范围,可视化测试器除了打开当前正在使用的图表外,还会打开相应的图表。这种功能不仅适用于非当前工作时间框架,也适用于其他交易品种。特别是在开发多货币智能交易系统时,这将非常有用。

为了检查错误处理的工作方式,尝试禁用智能交易系统的自动交易功能。日志将包含以下内容:

Autodetected daily range: 34.48
TRADE_ACTION_PENDING, XAUUSD, ORDER_TYPE_BUY_STOP, V=0.01, ORDER_FILLING_FOK, »
  » @ 1975.73, SL=1958.49, TP=1992.97, ORDER_TIME_GTC, M=1234567890
CLIENT_DISABLES_AT, AutoTrading disabled by client
Alert: Can't place/modify pending order, EA is stopped

这个错误是严重的,智能交易系统将停止工作。

为了演示一种较容易出现的错误,我们可以使用 OnTimer 处理程序代替 OnTick。然后在那些交易时段只占一天一部分的交易品种上启动同一个智能交易系统,会周期性地生成一系列关于市场关闭的非严重错误(“Market closed”,市场关闭)。在这种情况下,智能交易系统会不断尝试开始交易,不断增加等待时间。

特别是,这在测试器中很容易检查,测试器允许您为任何交易品种设置任意的交易时段。在“设置”选项卡中,在“延迟”下拉列表的右侧,有一个按钮,可打开“交易设置”对话框。在那里,您应该选中“使用您的设置”选项,并在“交易”选项卡中,至少向“非交易时段”表中添加一条记录。

在测试器中设置非交易时段

请注意,这里设置的是非交易时段,而不是交易时段,也就是说,与交易品种规格相比,这个设置的作用正好相反。

许多与交易限制相关的潜在错误可以通过使用类似于“账户交易的限制和权限”部分中介绍的 Permissions 类,对交易环境进行初步分析来消除。

删除挂单

在程序层面,使用 TRADE_ACTION_REMOVE 操作来删除挂单。在调用 OrderSend 函数的某个版本之前,应将这个常量赋值给 MqlTradeRequest 结构体的 action 字段。除了 action 字段,唯一必需的字段是 order,用于指定要删除的订单的编号。

MqlTradeSync.mqh 文件中 MqlTradeRequestSync 应用结构体里的 remove 方法很基础:

cpp
struct MqlTradeRequestSync: public MqlTradeRequest
{
   // ...
   bool remove(const ulong ticket)
   {
      if(!OrderSelect(ticket)) return false;
      action = TRADE_ACTION_REMOVE;
      order = ticket;
      ZeroMemory(result);
      return OrderSend(this, result);
   }

通常在 completed 方法中检查订单是否已被删除:

cpp
   bool completed()
   {
      // ...
      else if(action == TRADE_ACTION_REMOVE)
      {
         result.order = order;
         return result.removed(timeout);
      }
      // ...
   }

MqlTradeResultSync 结构体的 removed 方法中等待订单实际被删除:

cpp
struct MqlTradeResultSync: public MqlTradeResult
{
   // ...
   bool removed(const ulong msc = 1000)
   {
      if(retcode != TRADE_RETCODE_DONE)
      {
         return false;
      }
   
      if(!wait(orderRemoved, msc))
      {
         Print("Order removal timeout: #=" + (string)order);
         return false;
      }
      
      return true;
   }
   
   static bool orderRemoved(MqlTradeResultSync &ref)
   {
      return !OrderSelect(ref.order) && HistoryOrderSelect(ref.order);
   }

示例智能交易系统 PendingOrderDelete.mq5 用于演示删除订单,它几乎完全基于 PendingOrderSend.mq5 构建。这是因为在删除之前更容易保证订单的存在。因此,智能交易系统启动后会立即用指定参数创建一个新订单,然后在 OnDeinit 处理程序中删除该订单。如果更改智能交易系统的输入参数、交易品种或图表时间框架,旧订单也会被删除并创建一个新订单。

添加了全局变量 OwnOrder 来存储订单编号,它在调用 PlaceOrder 函数后被填充(PlaceOrder 函数本身未改变):

cpp
ulong OwnOrder = 0;
   
void OnTimer()
{
   // 针对当前参数执行一次代码
   EventKillTimer();
   
   const string symbol = StringLen(Symbol) == 0 ? _Symbol : Symbol;
   OwnOrder = PlaceOrder((ENUM_ORDER_TYPE)Type, symbol, Volume,
      Distance2SLTP, Expiration, Until, Magic, Comment);
}

下面是一个简单的删除函数 RemoveOrder,它创建请求对象并依次调用其 removecompleted 方法:

cpp
void OnDeinit(const int)
{
   if(OwnOrder != 0)
   {
      RemoveOrder(OwnOrder);
   }
}
   
void RemoveOrder(const ulong ticket)
{
   MqlTradeRequestSync request;
   if(request.remove(ticket) && request.completed())
   {
      Print("OK order removed");
   }
   Print(TU::StringOf(request));
   Print(TU::StringOf(request.result));
}

以下日志展示了将智能交易系统应用于 EURUSD 图表,之后将交易品种切换到 XAUUSD,最后删除智能交易系统所产生的记录:

plaintext
(EURUSD,H1)        Autodetected daily range: 0.0094
(EURUSD,H1)        OK order placed: #=1284920879
(EURUSD,H1)        TRADE_ACTION_PENDING, EURUSD, ORDER_TYPE_BUY_STOP, V=0.01, ORDER_FILLING_FOK, »
                » @ 1.11011, ORDER_TIME_GTC, M=1234567890
(EURUSD,H1)        DONE, #=1284920879, V=0.01, Request executed, Req=1
(EURUSD,H1)        OK order removed
(EURUSD,H1)        TRADE_ACTION_REMOVE, EURUSD, ORDER_TYPE_BUY, ORDER_FILLING_FOK, #=1284920879
(EURUSD,H1)        DONE, #=1284920879, Request executed, Req=2
(XAUUSD,H1)        Autodetected daily range: 47.45
(XAUUSD,H1)        OK order placed: #=1284921672
(XAUUSD,H1)        TRADE_ACTION_PENDING, XAUUSD, ORDER_TYPE_BUY_STOP, V=0.01, ORDER_FILLING_FOK, »
                » @ 1956.68, ORDER_TIME_GTC, M=1234567890
(XAUUSD,H1)        DONE, #=1284921672, V=0.01, Request executed, Req=3
(XAUUSD,H1)        OK order removed
(XAUUSD,H1)        TRADE_ACTION_REMOVE, XAUUSD, ORDER_TYPE_BUY, ORDER_FILLING_FOK, #=1284921672
(XAUUSD,H1)        DONE, #=1284921672, Request executed, Req=4

OnTrade 事件部分,我们会看到另一个删除订单的示例,用于实现 “一取消另一”(OCO)策略。

获取有效订单列表

智能交易系统程序常常需要枚举现有的有效订单,并分析它们的属性。特别是在挂单修改的相关章节中,在 PendingOrderModify.mq5 示例里,我们创建了一个特殊的函数 GetMyOrder,用于查找属于该智能交易系统的订单,以便对其进行修改。在那里,是通过交易品种名称和智能交易系统 ID(Magic)来进行分析的。从理论上讲,在上一节删除挂单的 PendingOrderDelete.mq5 示例中,也应该采用同样的方法。

在后一个例子中,为了简化操作,我们创建了一个订单,并将其订单号存储在一个全局变量中。但在一般情况下不能这样做,因为智能交易系统和整个终端随时都可能停止或重启。因此,智能交易系统必须包含一个恢复内部状态的算法,包括对整个交易环境的分析,以及订单、交易、头寸、账户余额等等。

在本节中,我们将学习 MQL5 中用于获取有效订单列表,并在交易环境中选择其中任意一个订单的函数,这样就可以读取该订单的所有属性。

cpp
int OrdersTotal()

OrdersTotal 函数返回当前有效订单的数量。这些订单包括挂单,以及尚未执行的市价单。通常情况下,市价单会迅速执行,因此不太容易在其处于有效阶段时捕捉到它,但如果市场流动性不足,就可能出现这种情况。一旦订单被执行(达成了一笔交易),它就会从有效订单类别转移到历史订单中。我们将在单独的章节中讨论如何处理订单历史记录。

请注意,只有订单可以是有效的或属于历史记录的。这一点显著地区分了订单与交易(交易总是记录在历史中)以及头寸(头寸只在在线状态下存在)。要恢复头寸的历史记录,应该分析交易的历史记录。

cpp
ulong OrderGetTicket(uint index)

OrderGetTicket 函数根据订单在终端交易环境中的订单列表中的编号来返回订单号。index 参数必须在 0 到 OrdersTotal() - 1 的范围内(包括 0 和 OrdersTotal() - 1)。订单的组织方式并未做规定。

OrderGetTicket 函数会选择一个订单,也就是说,会将关于该订单的数据复制到某个内部缓存中,以便 MQL 程序可以通过后续调用 OrderGetDoubleOrderGetIntegerOrderGetString 函数来读取它的所有属性,我们将在单独的章节中讨论这些函数。

存在这样一个缓存意味着从缓存中获取的数据可能会过时:订单可能已经不存在,或者可能已经被修改(例如,它可能有不同的状态、开仓价格、止损或止盈水平以及到期时间)。因此,为了保证获取到关于订单的相关数据,建议在请求数据之前立即调用 OrderGetTicket 函数。在 PendingOrderModify.mq5 示例中是这样做的:

cpp
ulong GetMyOrder(const string name, const ulong magic)
{
   for(int i = 0; i < OrdersTotal(); ++i)
   {
      ulong t = OrderGetTicket(i);
      if(OrderGetInteger(ORDER_MAGIC) == magic
      && OrderGetString(ORDER_SYMBOL) == name)
      {
         return t;
      }
   }
   return 0;
}

每个 MQL 程序都维护着自己的缓存(交易环境上下文),其中包括已选择的订单。在接下来的章节中,我们将了解到,除了订单之外,MQL 程序还可以将头寸以及包含交易和订单的历史片段选择到活动上下文中。

OrderSelect 函数执行类似的订单选择操作,并将订单数据复制到内部缓存中。

cpp
bool OrderSelect(ulong ticket)

该函数检查订单是否存在,并为进一步读取其属性做好准备。在这种情况下,指定订单不是通过序列号,而是通过订单号,MQL 程序必须以某种方式提前获取该订单号,特别是作为执行 OrderSend/OrderSendAsync 的结果。

如果成功,该函数返回 true。如果返回 false,通常意味着不存在具有指定订单号的订单。最常见的原因是订单状态已从有效变为历史记录,例如,由于订单执行或取消(我们稍后将学习如何确定确切的状态)。可以使用相关函数在历史记录中选择订单。

之前我们在 MqlTradeResultSync 结构体中使用 OrderSelect 函数来跟踪挂单的创建和删除情况。

订单属性(活跃订单和历史订单)

在与交易操作相关的章节中,特别是在进行买入/卖出、平仓以及下挂单操作时,我们已经了解到,请求是基于MqlTradeRequest结构中特定字段的填充被发送到服务器的,其中大多数字段直接定义了生成订单的属性。MQL5 API允许你了解这些属性以及交易系统自身设置的一些其他属性,例如单号、注册时间和状态。

需要注意的是,虽然许多属性的值对于活跃订单和历史订单会有所不同,但订单属性的列表对于这两者来说是通用的。

在MQL5中,订单属性根据我们已经熟悉的基于值类型的原则进行分组:整数类型(与long/ulong兼容)、实数类型(double)和字符串类型。每个属性组都有其对应的枚举。

整数属性汇总在ENUM_ORDER_PROPERTY_INTEGER中,如下表所示:

标识符描述类型
ORDER_TYPE订单类型ENUM_ORDER_TYPE
ORDER_TYPE_FILLING按交易量的执行类型ENUM_ORDER_TYPE_FILLING
ORDER_TYPE_TIME订单有效期(挂单)ENUM_ORDER_TYPE_TIME
ORDER_TIME_EXPIRATION订单到期时间(挂单)datetime
ORDER_MAGIC下订单的智能交易系统设置的任意标识符ulong
ORDER_TICKET订单单号;服务器为每个订单分配的唯一编号ulong
ORDER_STATE订单状态ENUM_ORDER_STATE(见下文)
ORDER_REASON订单的原因或来源ENUM_ORDER_REASON(见下文)
ORDER_TIME_SETUP订单下单时间datetime
ORDER_TIME_DONE订单执行或撤单时间datetime
ORDER_TIME_SETUP_MSC订单下单执行时间(以毫秒为单位)ulong
ORDER_TIME_DONE_MSC订单执行/撤单时间(以毫秒为单位)ulong
ORDER_POSITION_ID订单执行时生成或修改的持仓的IDulong
ORDER_POSITION_BY_ID对于ORDER_TYPE_CLOSE_BY类型的订单,对应的反向持仓标识符ulong

每个已执行的订单都会生成一笔交易,该交易要么开仓一个新持仓,要么改变一个现有持仓。这个持仓的ID会被赋值给已执行订单的ORDER_POSITION_ID属性。

ENUM_ORDER_STATE枚举包含描述订单状态的元素。请参阅下面简化的订单状态图(状态示意图):

标识符描述
ORDER_STATE_STARTED订单已检查正确性,但尚未被服务器接受
ORDER_STATE_PLACED订单已被服务器接受
ORDER_STATE_CANCELED订单已被客户(用户或MQL程序)取消
ORDER_STATE_PARTIAL订单已被部分执行
ORDER_STATE_FILLED订单已全部成交
ORDER_STATE_REJECTED订单已被服务器拒绝
ORDER_STATE_EXPIRED订单因到期而被取消
ORDER_STATE_REQUEST_ADD订单正在注册(正在被放置到交易系统中)
ORDER_STATE_REQUEST_MODIFY订单正在被修改(其参数正在被更改)
ORDER_STATE_REQUEST_CANCEL订单正在被删除(从交易系统中移除)

订单状态图

只有活跃订单的状态可以改变。对于历史订单(已成交或已取消),其状态是固定的。

你可以取消一个已经部分成交的订单,然后其在历史记录中的状态将变为ORDER_STATE_CANCELED。

ORDER_STATE_PARTIAL仅出现在活跃订单中。已执行(历史)订单的状态始终为ORDER_STATE_FILLED。

ENUM_ORDER_REASON枚举指定了订单来源的可能选项:

标识符描述
ORDER_REASON_CLIENT从桌面终端手动下单的订单
ORDER_REASON_EXPERT由智能交易系统或脚本从桌面终端下单的订单
ORDER_REASON_MOBILE从移动应用程序下单的订单
ORDER_REASON_WEB从网页终端(浏览器)下单的订单
ORDER_REASON_SL由于止损触发,由服务器下单的订单
ORDER_REASON_TP由于止盈触发,由服务器下单的订单
ORDER_REASON_SO由于强行平仓事件,由服务器下单的订单

实数属性收集在ENUM_ORDER_PROPERTY_DOUBLE枚举中:

标识符描述
ORDER_VOLUME_INITIAL下单时的初始交易量
ORDER_VOLUME_CURRENT当前交易量(初始交易量或部分执行后的剩余交易量)
ORDER_PRICE_OPEN订单中指定的价格
ORDER_PRICE_CURRENT尚未执行的订单的当前品种价格,或执行价格
ORDER_SL止损水平
ORDER_TP止盈水平
ORDER_PRICE_STOPLIMIT当限价止损订单触发时,下限价单的价格

ORDER_PRICE_CURRENT属性对于活跃的买入挂单包含当前的卖出价,对于活跃的卖出挂单包含当前的买入价。“当前”是指在使用OrderSelect或OrderGetTicket选择订单时,交易环境中已知的价格。对于历史记录中的已执行订单,此属性包含执行价格,由于滑点的原因,该价格可能与订单中指定的价格不同。

只有当订单状态为ORDER_STATE_PARTIAL时,ORDER_VOLUME_INITIAL和ORDER_VOLUME_CURRENT属性才不相等。

如果订单是部分成交的,那么其在历史记录中的ORDER_VOLUME_INITIAL属性将等于最后成交部分的交易量大小,与原始全部交易量相关的所有其他“成交”将作为单独的订单(和交易)执行。

字符串属性在ENUM_ORDER_PROPERTY_STRING枚举中描述:

标识符描述
ORDER_SYMBOL下单的品种
ORDER_COMMENT备注
ORDER_EXTERNAL_ID外部交易系统(交易所)中的订单ID

要读取上述所有属性,有两组不同的函数:一组用于活跃订单,一组用于历史订单。首先,我们将考虑用于活跃订单的函数,在熟悉了在历史记录中选择所需时间段的原则之后,我们再回到用于历史订单的函数。

读取活动订单属性的函数

用于获取活动订单和历史订单所有属性值的函数集有所不同。本节将介绍读取活动订单属性的函数。关于访问历史订单属性的函数,请参阅相关章节。

可以使用 OrderGetInteger 函数读取整数类型的属性,该函数有两种形式:第一种直接返回属性的值,第二种返回一个逻辑标志,表示成功(true)或错误(false),并将属性值填充到通过引用传递的第二个参数中。

c++
long OrderGetInteger(ENUM_ORDER_PROPERTY_INTEGER property)

bool OrderGetInteger(ENUM_ORDER_PROPERTY_INTEGER property, long &value)

这两个函数都可以获取与整数兼容类型(日期时间、长整型/无符号长整型或枚举类型)的请求订单属性。虽然原型中提到了 long,但从技术角度来看,该值存储在一个 8 字节的单元中,可以将其转换为兼容类型,而无需对内部表示进行任何转换,特别是对于所有单号使用的 ulong 类型。

对于实型 double 属性,也有类似的一对函数。

c++
double OrderGetDouble(ENUM_ORDER_PROPERTY_DOUBLE property)

bool OrderGetDouble(ENUM_ORDER_PROPERTY_DOUBLE property, double &value)

最后,字符串属性可以通过一对 OrderGetString 函数获取。

c++
string OrderGetString(ENUM_ORDER_PROPERTY_STRING property)

bool OrderGetString(ENUM_ORDER_PROPERTY_STRING property, string &value)

所有这些函数的第一个参数都是我们感兴趣的属性标识符。这必须是上一节中讨论的枚举类型之一 —— ENUM_ORDER_PROPERTY_INTEGERENUM_ORDER_PROPERTY_DOUBLEENUM_ORDER_PROPERTY_STRING 的元素。

请注意,在调用上述任何函数之前,您应该首先使用 OrderSelectOrderGetTicket 选择一个订单。

为了读取特定订单的所有属性,我们将开发 OrderMonitor 类(OrderMonitor.mqh),它的工作原理与之前讨论的交易品种(SymbolMonitor.mqh)和交易账户(AccountMonitor.mqh)监视器相同。

本书中讨论的这些和其他监视器类通过重载的虚拟 get 方法提供了一种统一的属性分析方式。

稍微提前说一下,成交记录和持仓的属性也根据三种主要值类型进行了相同的分组,我们也需要为它们实现监视器。因此,将通用算法分离到一个基抽象类 MonitorInterfaceTradeBaseMonitor.mqh)中是有意义的。这是一个带有三个参数的模板类,用于指定整数(I)、实数(D)和字符串(S)属性组的特定枚举类型。

c++
#include <MQL5Book/EnumToArray.mqh>

template<typename I,typename D,typename S>
class MonitorInterface
{
protected:
   bool ready;
public:
   MonitorInterface(): ready(false) { }
   
   bool isReady() const
   {
      return ready;
   }
   ...

由于在交易环境中查找订单(成交记录或持仓)可能会因各种原因失败,因此该类有一个预留变量 ready,派生类必须在其中写入成功初始化的标志,即选择一个对象以读取其属性。

几个纯虚方法声明了对相应类型属性的访问。

c++
   virtual long get(const I property) const = 0;
   virtual double get(const D property) const = 0;
   virtual string get(const S property) const = 0;
   virtual long get(const int property, const long) const = 0;
   virtual double get(const int property, const double) const = 0;
   virtual string get(const int property, const string) const = 0;
   ...

在前三个方法中,属性类型由模板参数之一指定。在接下来的三个方法中,类型由方法本身的第二个参数指定:这是必要的,因为最后几个方法的第一个参数不是特定枚举的常量,而只是一个整数。一方面,这便于对标识符进行连续编号(三种类型的枚举常量不会相交)。另一方面,我们需要另一个来源来确定值的类型,因为函数/方法返回的类型不参与选择合适重载的过程。

这种方法允许根据调用代码中可用的各种输入获取属性。接下来,我们将基于 OrderMonitor(以及未来的 DealMonitorPositionMonitor)创建类,以根据一组任意条件选择对象,并且所有这些方法都将在那里得到应用。

程序通常需要获取任何属性的字符串表示形式,例如用于日志记录。在新的监视器中,这通过 stringify 方法实现。显然,它们通过调用上述 get 方法来获取请求属性的值。

c++
   virtual string stringify(const long v, const I property) const = 0;
   
   virtual string stringify(const I property) const
   {
      return stringify(get(property), property);
   }
   
   virtual string stringify(const D property, const string format = NULL) const
   {
      if(format == NULL) return (string)get(property);
      return StringFormat(format, get(property));
   }
   
   virtual string stringify(const S property) const
   {
      return get(property);
   }
   ...

唯一没有实现的方法是 long 类型的第一个版本的 stringify。这是因为正如我们在上一节中看到的,整数属性组实际上包含不同的应用类型,包括日期和时间、枚举和整数。因此,只有派生类才能将它们转换为易于理解的字符串。这种情况适用于所有交易实体,不仅包括订单,还包括我们稍后将讨论的成交记录和持仓。

当整数属性包含枚举元素(例如 ENUM_ORDER_TYPEORDER_TYPE_FILLING 等)时,应使用 EnumToString 函数将其转换为字符串。这个任务由辅助方法 enumstr 完成。很快我们将看到它在特定监视器类中的广泛应用,从下面几段后的 OrderMonitor 开始。

c++
   template<typename E>
   static string enumstr(const long v)
   {
      return EnumToString((E)v);
   }

为了记录特定类型的所有属性,我们创建了 list2log 方法,它在循环中使用 stringify

c++
   template<typename E>
   void list2log() const
   {
      E e = (E)0; // 抑制警告 '可能使用未初始化的变量'
      int array[];
      const int n = EnumToArray(e, array, 0, USHORT_MAX);
      Print(typename(E), " Count=", n);
      for(int i = 0; i < n; ++i)
      {
         e = (E)array[i];
         PrintFormat("% 3d %s=%s", i, EnumToString(e), stringify(e));
      }
   }

最后,为了更方便地记录所有三组属性,有一个 print 方法,它为每组属性调用三次 list2log

c++
   virtual void print() const
   {
      if(!ready) return;
      
      Print(typename(this));
      list2log<I>();
      list2log<D>();
      list2log<S>();
   }

有了基模板类 MonitorInterface,我们来描述 OrderMonitorInterface,在其中指定上一节中订单的某些枚举类型,并为订单的整数属性提供 stringify 的实现。

c++
class OrderMonitorInterface:
   public MonitorInterface<ENUM_ORDER_PROPERTY_INTEGER,
   ENUM_ORDER_PROPERTY_DOUBLE,ENUM_ORDER_PROPERTY_STRING>
{
public:
   // 根据子类型描述属性
   virtual string stringify(const long v,
      const ENUM_ORDER_PROPERTY_INTEGER property) const override
   {
      switch(property)
      {
         case ORDER_TYPE:
            return enumstr<ENUM_ORDER_TYPE>(v);
         case ORDER_STATE:
            return enumstr<ENUM_ORDER_STATE>(v);
         case ORDER_TYPE_FILLING:
            return enumstr<ENUM_ORDER_TYPE_FILLING>(v);
         case ORDER_TYPE_TIME:
            return enumstr<ENUM_ORDER_TYPE_TIME>(v);
         case ORDER_REASON:
            return enumstr<ENUM_ORDER_REASON>(v);
         
         case ORDER_TIME_SETUP:
         case ORDER_TIME_EXPIRATION:
         case ORDER_TIME_DONE:
            return TimeToString(v, TIME_DATE | TIME_SECONDS);
         
         case ORDER_TIME_SETUP_MSC:
         case ORDER_TIME_DONE_MSC:
            return STR_TIME_MSC(v);
      }
      
      return (string)v;
   }
};

用于以毫秒显示时间的 STR_TIME_MSC 宏定义如下:

c++
#define STR_TIME_MSC(T) (TimeToString((T) / 1000, TIME_DATE | TIME_SECONDS) \
    + StringFormat("'%03d", (T) % 1000))

现在我们准备描述用于读取任何订单属性的最终类:从 OrderMonitorInterface 派生的 OrderMonitor。订单单号传递给构造函数,并使用 OrderSelect 在交易环境中选择该订单。

c++
class OrderMonitor: public OrderMonitorInterface
{
public:
   const ulong ticket;
   OrderMonitor(const long t): ticket(t)
   {
      if(!OrderSelect(ticket))
      {
         PrintFormat("Error: OrderSelect(%lld) failed: %s",
            ticket, E2S(_LastError));
      }
      else
      {
         ready = true;
      }
   }
   ...

监视器的主要工作部分包括重新定义用于读取属性的虚拟函数。在这里,我们可以看到 OrderGetIntegerOrderGetDoubleOrderGetString 函数的调用。

c++
   virtual long get(const ENUM_ORDER_PROPERTY_INTEGER property) const override
   {
      return OrderGetInteger(property);
   }
   
   virtual double get(const ENUM_ORDER_PROPERTY_DOUBLE property) const override
   {
      return OrderGetDouble(property);
   }
   
   virtual string get(const ENUM_ORDER_PROPERTY_STRING property) const override
   {
      return OrderGetString(property);
   }
   
   virtual long get(const int property, const long) const override
   {
      return OrderGetInteger((ENUM_ORDER_PROPERTY_INTEGER)property);
   }
   
   virtual double get(const int property, const double) const override
   {
      return OrderGetDouble((ENUM_ORDER_PROPERTY_DOUBLE)property);
   }
   
   virtual string get(const int property, const string)  const override
   {
      return OrderGetString((ENUM_ORDER_PROPERTY_STRING)property);
   }
};

这个代码片段是简写形式:其中已移除了用于处理历史订单的操作符。我们将在后续章节探讨这方面内容时看到 OrderMonitor 的完整代码。

重要的是要注意,监视器对象不会存储其属性的副本。因此,必须在对象创建后立即访问 get 方法,相应地,要在调用 OrderSelect 之后。要在稍后的时间读取属性,您需要在 MQL 程序的内部缓存中重新分配该订单,例如,通过调用 refresh 方法。

c++
   void refresh()
   {
      ready = OrderSelect(ticket);
   }

让我们通过将 OrderMonitor 添加到智能交易系统 MarketOrderSend.mq5 中来测试其工作情况。新版本名为 MarketOrderSendMonitor.mq5,通过 #include 指令连接 OrderMonitor.mqh 文件,并在 OnTimer 函数体中(在成功确认基于订单开仓的代码块中)创建一个监视器对象并调用其 print 方法。

c++
#include <MQL5Book/OrderMonitor.mqh>
...
void OnTimer()
{
   ...
   const ulong order = (wantToBuy ?
      request.buy(volume, Price) :
      request.sell(volume, Price));
   if(order != 0)
   {
      Print("OK Order: #=", order);
      if(request.completed())
      {
         Print("OK Position: P=", request.result.position);
         
         OrderMonitor m(order);
         m.print();
         ...
      }
   }
}

在日志中,我们应该会看到包含订单所有属性的新行。

OK Order: #=1287846602
Waiting for position for deal D=1270417032
OK Position: P=1287846602
MonitorInterface<ENUM_ORDER_PROPERTY_INTEGER, »
   » ENUM_ORDER_PROPERTY_DOUBLE,ENUM_ORDER_PROPERTY_STRING>
ENUM_ORDER_PROPERTY_INTEGER Count=14
  0 ORDER_TIME_SETUP=2022.03.21 13:28:59
  1 ORDER_TIME_EXPIRATION=1970.01.01 00:00:00
  2 ORDER_TIME_DONE=2022.03.21 13:28:59
  3 ORDER_TYPE=ORDER_TYPE_BUY
  4 ORDER_TYPE_FILLING=ORDER_FILLING_FOK
  5 ORDER_TYPE_TIME=ORDER_TIME_GTC
  6 ORDER_STATE=ORDER_STATE_FILLED
  7 ORDER_MAGIC=1234567890
  8 ORDER_POSITION_ID=1287846602
  9 ORDER_TIME_SETUP_MSC=2022.03.21 13:28:59'572
 10 ORDER_TIME_DONE_MSC=2022.03.21 13:28:59'572
 11 ORDER_POSITION_BY_ID=0
 12 ORDER_TICKET=1287846602
 13 ORDER_REASON=ORDER_REASON_EXPERT
ENUM_ORDER_PROPERTY_DOUBLE Count=7
  0 ORDER_VOLUME_INITIAL=0.01
  1 ORDER_VOLUME_CURRENT=0.0
  2 ORDER_PRICE_OPEN=1.10275
  3 ORDER_PRICE_CURRENT=1.10275
  4 ORDER_PRICE_STOPLIMIT=0.0
  5 ORDER_SL=0.0
  6 ORDER_TP=0.0
ENUM_ORDER_PROPERTY_STRING Count=3
  0 ORDER_SYMBOL=EURUSD
  1 ORDER_COMMENT=
  2 ORDER_EXTERNAL_ID=
TRADE_ACTION_DEAL, EURUSD, ORDER_TYPE_BUY, V=0.01, ORDER_FILLING_FOK, »
   » @ 1.10275, P=1287846602, M=1234567890
DONE, D=1270417032, #=1287846602, V=0.01, @ 1.10275, Bid=1.10275, Ask=1.10275, »
   » Request executed, Req=3

第四行开始是 print 方法的输出,其中包括监视器对象 MonitorInterface 的全名以及参数类型(在这种情况下是三元组 ENUM_ORDER_PROPERTY),然后是特定订单的所有属性。

然而,打印属性并不是监视器能提供的最有趣的操作。对于智能交易系统来说,根据条件(任意属性的值)选择订单的任务要更有需求。将监视器作为辅助工具,我们将创建一个类似于为交易品种所做的订单过滤机制:SymbolFilter.mqh

按属性筛选订单

在关于符号属性的一个章节中,我们介绍了 SymbolFilter 类,用于筛选具有指定特征的金融工具。现在,我们将对订单应用相同的方法。

由于我们不仅需要以类似的方式分析订单,还需要分析交易和仓位,因此我们将把筛选算法的通用部分分离到基类 TradeFilterTradeFilter.mqh)中。它几乎与 SymbolFilter 的源代码完全相同。因此,我们在这里不再对其进行解释。

有兴趣的人可以对 SymbolFilter.mqhTradeFilter.mqh 进行上下文文件比较,看看它们有多相似,并找出细微的编辑之处。

主要的区别在于 TradeFilter 类是一个模板类,因为它必须处理不同对象(订单、交易和仓位)的属性。

c
enum IS // 筛选器中支持的比较条件
{
   EQUAL,
   GREATER,
   NOT_EQUAL,
   LESS
};

enum ENUM_ANY // 用于将所有枚举类型转换为它的虚拟枚举
{
};

template<typename T,typename I,typename D,typename S>
class TradeFilter
{
protected:
   MapArray<ENUM_ANY,long> longs;
   MapArray<ENUM_ANY,double> doubles;
   MapArray<ENUM_ANY,string> strings;
   MapArray<ENUM_ANY,IS> conditions;
   ...

   template<typename V>
   static bool equal(const V v1, const V v2);

   template<typename V>
   static bool greater(const V v1, const V v2);

   template<typename V>
   bool match(const T &m, const MapArray<ENUM_ANY,V> &data) const;

public:
   // 用于向筛选器添加条件的方法
   TradeFilter *let(const I property, const long value, const IS cmp = EQUAL);
   TradeFilter *let(const D property, const double value, const IS cmp = EQUAL);
   TradeFilter *let(const S property, const string value, const IS cmp = EQUAL);
   // 用于获取与筛选器匹配的记录数组的方法
   template<typename E,typename V>
   bool select(const E property, ulong &tickets[], V &data[],
      const bool sort = false) const;
   template<typename E,typename V>
   bool select(const E &property[], ulong &tickets[], V &data[][],
      const bool sort = false) const
   bool select(ulong &tickets[]) const;
   ...
}

模板参数 IDS 是三种主要类型(整数、实数和字符串)的属性组枚举:对于订单,它们在前面的章节中已经描述过,所以为了清晰起见,你可以想象 I = ENUM_ORDER_PROPERTY_INTEGERD = ENUM_ORDER_PROPERTY_DOUBLES = ENUM_ORDER_PROPERTY_STRING

T 类型用于指定一个监控类。目前我们只有一个准备好的监控类 OrderMonitor。稍后我们将实现 DealMonitorPositionMonitor

早些时候,在 SymbolFilter 类中,我们没有使用模板参数,因为对于符号来说,所有类型的属性枚举都是固定已知的,并且只有一个 SymbolMonitor 类。

回顾一下筛选器类的结构。一组 let 方法允许在筛选器中注册 “属性=值” 对的组合,这些组合随后将用于在 select 方法中筛选对象。属性 ID 在 property 参数中指定,值在 value 参数中。

还有几个 select 方法。它们允许调用代码用选定的订单号填充一个数组,并且如果需要,还可以用请求的对象属性值填充额外的数组。请求属性的特定标识符在 select 方法的第一个参数中设置;它可以是一个属性或多个属性。根据这一点,接收数组必须是一维或二维的。

属性和值的组合不仅可以检查是否相等(EQUAL),还可以检查大于/小于操作(GREATER/LESS)。对于字符串属性,可以指定一个带有字符 * 的搜索模式,* 表示任何字符序列(例如,对于 ORDER_COMMENT 属性,*[tp]* 将匹配任何包含 [tp] 的注释,尽管这只是一种可能性的演示 —— 而要搜索由触发的止盈产生的订单,则应该分析 ORDER_REASON)。

由于该算法需要遍历所有对象,并且对象可以是不同的类型(到目前为止是订单,但随后将支持交易和仓位),我们需要在 TradeFilter 类中描述两个抽象方法:totalget

c
   virtual int total() const = 0;
   virtual ulong get(const int i) const = 0;

第一个方法返回对象的数量,第二个方法根据对象的编号返回订单号。这应该会让你想起 OrdersTotalOrderGetTicket 这对函数。实际上,它们在筛选订单的方法的具体实现中会被用到。

下面是完整的 OrderFilter 类(OrderFilter.mqh)。

c
#include <MQL5Book/OrderMonitor.mqh>
#include <MQL5Book/TradeFilter.mqh>

class OrderFilter: public TradeFilter<OrderMonitor,
   ENUM_ORDER_PROPERTY_INTEGER,
   ENUM_ORDER_PROPERTY_DOUBLE,
   ENUM_ORDER_PROPERTY_STRING>
{
protected:
   virtual int total() const override
   {
      return OrdersTotal();
   }
   virtual ulong get(const int i) const override
   {
      return OrderGetTicket(i);
   }
};

考虑到可以轻松地为交易和仓位创建类似的筛选器,这种简洁性尤为重要。

借助这个新类,我们可以更轻松地检查是否存在属于我们的专家顾问的订单,也就是说,可以替换示例 PendingOrderModify.mq5 中使用的任何自定义版本的 GetMyOrder 函数。

c
   OrderFilter filter;
   ulong tickets[];

   // 为当前符号和我们的“魔术”数字设置订单条件
   filter.let(ORDER_SYMBOL, _Symbol).let(ORDER_MAGIC, Magic);
   // 在数组中选择合适的订单号
   if(filter.select(tickets))
   {
      ArrayPrint(tickets);
   }

这里的 “任何版本” 意味着,多亏了这个筛选器类,我们可以创建任意的订单筛选条件,并 “即时” 更改它们(例如,根据用户的指示,而不是程序员的指示)。

作为如何使用该筛选器的一个示例,让我们使用一个专家顾问,它创建一个挂单网格,用于在一定价格范围内从价格水平反弹时进行交易,也就是说,它是为波动的市场设计的。从这一部分开始,在接下来的几个部分中,我们将根据所学的内容对该专家顾问进行修改。

专家顾问 PendingOrderGrid1.mq5 的第一个版本从限价单和止损限价单构建一个给定大小的网格。参数将是价格水平的数量和它们之间的点数步长。操作方案如下图所示。

4 个水平、步长为 200 点的挂单网格

在某个初始时间,这个时间可以由日内时间表确定,例如可以对应于 “夜间横盘”,当前价格将向上舍入到网格步长的大小,然后从这个水平向上和向下设置指定数量的水平。

在每个较高的水平上,我们放置一个限价卖出订单和一个止损买入订单,未来限价订单的价格比该水平低一级。在每个较低的水平上,我们放置一个限价买入订单和一个止损卖出订单,未来限价订单的价格比该水平高一级。

当价格触及其中一个水平时,该水平上的限价订单将变成买入或卖出(仓位)。同时,同一水平的止损限价订单会被系统自动转换为下一个水平的相反方向的限价订单。

例如,如果价格在向上移动时突破了某个水平,我们将得到一个空头仓位,并且会在该水平下方一个步长距离处创建一个买入限价订单。

该专家顾问将监控每个水平上是否有与限价订单配对的止损限价订单。因此,在检测到一个新的买入限价订单后,程序将在同一水平上为其添加一个止损卖出订单,并且未来限价订单的目标价格是上方相邻的水平,即开仓的水平。

假设价格向下反转并激活了下方水平的一个限价订单 —— 我们将得到一个多头仓位。同时,止损限价订单将转换为上方相邻水平的卖出限价订单。现在,专家顾问将再次检测到一个 “孤立” 的限价订单,并为其创建一个买入止损限价订单作为配对,其价格为下方相邻水平的未来限价订单的价格。

如果存在相反的仓位,我们将关闭它们。我们还将设置一个日内时间段,在该时间段内交易系统处于启用状态,而在其余时间,所有订单和仓位都将被删除。这对于 “夜间横盘” 尤其有用,因为此时市场的回调波动特别明显。

当然,这只是网格策略的众多潜在实现之一,它缺少许多网格的自定义设置,但我们不会使这个示例过于复杂。

该专家顾问将在每个柱线(大概是 H1 时间框架或更低)上分析情况。从理论上讲,这个专家顾问的操作逻辑需要通过及时响应交易事件来改进,但我们尚未研究这些内容。因此,我们没有持续跟踪并即时 “手动” 恢复空网格水平上的限价订单,而是通过使用止损限价订单将这项工作委托给了服务器。然而,这里有一个细微差别。

事实是,每个水平上的限价订单和止损限价订单是相反类型的(买入/卖出),因此它们由不同类型的价格激活。

结果是,如果市场向上移动到网格上半部分的下一个水平,买入价(Ask 价格)可能会触及该水平并激活一个止损买入订单,但卖出价(Bid 价格)不会达到该水平,卖出限价订单将保持不变(它不会变成仓位)。在网格的下半部分,当市场向下移动时,情况是相反的。任何水平首先会被卖出价(Bid 价格)触及,并激活一个止损卖出订单,只有在价格进一步下跌时,买入价(Ask 价格)才会达到该水平。如果价格没有移动,买入限价订单将保持不变。

随着点差的增加,这个问题变得至关重要。因此,该专家顾问将需要对 “多余” 的限价订单进行额外的控制。换句话说,如果在其预期目标价格(相邻水平)已经存在一个限价订单,专家顾问将不会生成该水平缺少的止损限价订单。

源代码包含在文件 PendingOrderGrid1.mq5 中。在输入参数中,你可以设置每笔交易的交易量(默认情况下,如果留为 0,则采用图表符号的最小手数)、网格水平的数量 GridSize(必须是偶数)以及水平之间的步长 GridStep(以点数为单位)。在参数 StartTimeStopTime 中指定策略允许工作的日内时间段的开始和结束时间:在这两个参数中,只有时间是重要的。

c
#include <MQL5Book/MqlTradeSync.mqh>
#include <MQL5Book/OrderFilter.mqh>
#include <MQL5Book/MapArray.mqh>

input double Volume;                                       // 交易量(0 = 最小手数)
input uint GridSize = 6;                                   // 网格大小(价格水平的偶数数量)
input uint GridStep = 200;                                 // 网格步长(点数)
input ENUM_ORDER_TYPE_TIME Expiration = ORDER_TIME_GTC;
input ENUM_ORDER_TYPE_FILLING Filling = ORDER_FILLING_FOK;
input datetime StartTime = D'1970.01.01 00:00:00';         // 开始时间(hh:mm:ss)
input datetime StopTime = D'1970.01.01 09:00:00';          // 结束时间(hh:mm:ss)
input ulong Magic = 1234567890;

工作时间段可以在一天之内(StartTime < StopTime),也可以跨越一天的边界(StartTime > StopTime),例如从 22:00 到 09:00。如果两个时间相等,则假定为全天候交易。

在继续实现交易思路之前,让我们简化设置查询和向日志输出诊断信息的任务。为此,我们描述自己的结构 MqlTradeRequestSyncLog,它是 MqlTradeRequestSync 的派生类。

c
const ulong DAYLONG = 60 * 60 * 24; // 一天的长度(秒)

struct MqlTradeRequestSyncLog: public MqlTradeRequestSync
{
   MqlTradeRequestSyncLog()
   {
      magic = Magic;
      type_filling = Filling;
      type_time = Expiration;
      if(Expiration == ORDER_TIME_SPECIFIED)
      {
         expiration = (datetime)(TimeCurrent() / DAYLONG * DAYLONG
            + StopTime % DAYLONG);
         if(StartTime > StopTime)
         {
            expiration = (datetime)(expiration + DAYLONG);
         }
      }
   }
   ~MqlTradeRequestSyncLog()
   {
      Print(TU::StringOf(this));
      Print(TU::StringOf(this.result));
   }
};

在构造函数中,我们用不变的值填充所有字段。在析构函数中,我们记录有意义的查询和结果字段。显然,自动对象的析构函数总是会在形成和发送订单的代码块退出时被调用,也就是说,发送和接收的数据将被打印出来。

OnInit 中,让我们对输入变量的正确性进行一些检查,特别是检查网格大小是否为偶数。

c
int OnInit()
{
   if(GridSize < 2 || !!(GridSize % 2))
   {
      Alert("GridSize should be 2, 4, 6+ (even number)");
      return INIT_FAILED;
   }
   return INIT_SUCCEEDED;
}

算法的主要入口点是 OnTick 处理程序。为了简洁起见,我们将省略与示例 PendingOrderModify.mq5 中基于 TRADE_RETCODE_SEVERITY 的相同错误处理机制。

对于逐柱线的工作,该函数有一个静态变量 lastBar,我们在其中存储最后一个成功处理的柱线的时间。同一柱线上的所有后续报价都将被跳过。

c
void OnTick()
{
   static datetime lastBar = 0;
   if(iTime(_Symbol, _Period, 0) == lastBar) return;
   uint retcode = 0;

   ... // 主要算法(见下文)

   const TRADE_RETCODE_SEVERITY severity = TradeCodeSeverity(retcode);
   if(severity < SEVERITY_RETRY)
   {
      lastBar = iTime(_Symbol, _Period, 0);
   }
}

省略号部分将是主要算法,为了系统化,它被分成几个辅助函数。首先,让我们确定是否设置了日内工作时间段,如果设置了,还要确定该策略当前是否启用。这个属性存储在 tradeScheduled 变量中。

c
   ...
   bool tradeScheduled = true;

   if(StartTime != StopTime)
   {
      const ulong now = TimeCurrent() % DAYLONG;

      if(StartTime < StopTime)
      {
         tradeScheduled = now >= StartTime && now < StopTime;
      }
      else
      {
         tradeScheduled = now >= StartTime || now < StopTime;
      }
   }
   ...

当交易启用时,首先使用 CheckGrid 函数检查是否已经存在订单网络。如果不存在订单网络,该函数将返回 GRID_EMPTY 常量,我们应该通过调用 SetupGrid 来创建订单网络。如果订单网络已经构建好,那么检查是否存在需要关闭的相反仓位是有意义的:这是由 CompactPositions 函数完成的。

c
   if(tradeScheduled)
   {
      retcode = CheckGrid();

      if(retcode == GRID_EMPTY)
      {
         retcode = SetupGrid();
      }
      else
      {
         retcode = CompactPositions();
      }
   }
   ...

一旦交易时间段结束,就需要删除订单并关闭所有仓位(如果有的话)。这分别由 RemoveOrdersCompactPositions 函数完成,但要使用一个布尔标志(true):这个单个的可选参数指示在相反仓位关闭后,对剩余仓位应用简单的平仓操作。

c
   else
   {
      retcode = CompactPositions(true);
      if(!retcode) retcode = RemoveOrders();
   }

所有函数都返回一个服务器代码,通过 TradeCodeSeverity 对其进行分析以确定是否成功。特殊的应用代码 GRID_OKGRID_EMPTY 也根据 TRADE_RETCODE_SEVERITY 被视为标准代码。

c
#define GRID_OK    +1
#define GRID_EMPTY  0

现在让我们逐个看看这些函数。

CheckGrid 函数使用了本节开头介绍的 OrderFilter 类。筛选器请求当前符号且带有 “我们的” 标识号的所有挂单,找到的订单的订单号存储在数组中。

c
uint CheckGrid()
{
   OrderFilter filter;
   ulong tickets[];

   filter.let(ORDER_SYMBOL, _Symbol).let(ORDER_MAGIC, Magic)
      .let(ORDER_TYPE, ORDER_TYPE_SELL, IS::GREATER)
      .select(tickets);
   const int n = ArraySize(tickets);
   if(!n) return
```c
GRID_EMPTY;
   ...

   // 价格水平 => 存在于该水平的订单类型的掩码
   MapArray<ulong,uint> levels;

   const double point = SymbolInfoDouble(_Symbol, SYMBOL_POINT);
   int limits = 0;
   int stops = 0;

   for(int i = 0; i < n; ++i)
   {
      if(OrderSelect(tickets[i]))
      {
         const ulong level = (ulong)MathRound(OrderGetDouble(ORDER_PRICE_OPEN) / point);
         const ulong type = OrderGetInteger(ORDER_TYPE);
         if(type == ORDER_TYPE_BUY_LIMIT || type == ORDER_TYPE_SELL_LIMIT)
         {
            ++limits;
            levels.put(level, levels[level] | (1 << type));
         }
         else if(type == ORDER_TYPE_BUY_STOP_LIMIT 
            || type == ORDER_TYPE_SELL_STOP_LIMIT)
         {
            ++stops;
            levels.put(level, levels[level] | (1 << type));
         }
      }
   }
   ...

   if(limits == stops)
   {
      if(limits == GridSize) return GRID_OK; // 完整的网格
      
      Alert("Error: Order number does not match requested");
      return TRADE_RETCODE_ERROR;
   }
   ...

   if(limits > stops)
   {
      const uint stopmask = 
         (1 << ORDER_TYPE_BUY_STOP_LIMIT) | (1 << ORDER_TYPE_SELL_STOP_LIMIT);
      for(int i = 0; i < levels.getSize(); ++i)
      {
         if((levels[i] & stopmask) == 0) // 该水平没有止损限价单
         {
            // 需要限价单的方向来设置相反的止损限价单
            const bool buyLimit = (levels[i] & (1 << ORDER_TYPE_BUY_LIMIT));
            // 此处省略了由于点差导致的“额外”订单的检查(见源代码)
            ...
            // 在所需方向创建一个止损限价单
            const uint retcode = RepairGridLevel(levels.getKey(i), point, buyLimit);
            if(TradeCodeSeverity(retcode) > SEVERITY_NORMAL)
            {
               return retcode;
            }
         }
      }
      return GRID_OK;
   }
   ...

   Alert("Error: Orphaned Stop-Limit orders found");
   return TRADE_RETCODE_ERROR;
}

RepairGridLevel 函数执行以下操作。

c
uint RepairGridLevel(const ulong level, const double point, const bool buyLimit)
{
   const double price = level * point;
   const double volume = Volume == 0?
      SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN) : Volume;
   
   MqlTradeRequestSyncLog request;
   
   request.comment = "repair";
   
   // 如果有未配对的买入限价单,为其设置卖出止损限价单
   // 如果有未配对的卖出限价单,为其设置买入止损限价单
   const ulong order = (buyLimit?
      request.sellStopLimit(volume, price, price + GridStep * point) :
      request.buyStopLimit(volume, price, price - GridStep * point));
   const bool result = (order != 0) && request.completed();
   if(!result) Alert("RepairGridLevel failed");
   return request.result.retcode;
}

请注意,我们实际上不需要填充该结构体(除了一个注释,如果需要,可以使其更具信息性),因为一些字段会由构造函数自动填充,并且我们直接将交易量和价格传递给 sellStopLimitbuyStopLimit 方法。

SetupGrid 函数也采用了类似的方法,它创建一个全新的完整订单网络。在函数开始时,我们准备用于计算的变量,并描述 MqlTradeRequestSyncLog 结构体数组。

c
uint SetupGrid()
{
   const double current = SymbolInfoDouble(_Symbol, SYMBOL_BID);
   const double point = SymbolInfoDouble(_Symbol, SYMBOL_POINT);
   const double volume = Volume == 0?
      SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN) : Volume;
   // 范围的中心价格四舍五入到最接近的步长,
   // 从这个价格向上和向下确定水平
   const double base = ((ulong)MathRound(current / point / GridStep) * GridStep)
      * point;
   const string comment = "G[" + DoubleToString(base,
      (int)SymbolInfoInteger(_Symbol, SYMBOL_DIGITS)) + "]";
   const static string message = "SetupGrid failed: ";
   MqlTradeRequestSyncLog request[][2]; // 限价单和止损限价单 - 一对
   ArrayResize(request, GridSize);      // 每个水平有 2 个挂单

   // 生成订单
   for(int i = 0; i < (int)GridSize / 2; ++i)
   {
      const int k = i + 1;
      
      // 网格的下半部分
      request[i][0].comment = comment;
      request[i][1].comment = comment;
      
      if(!(request[i][0].buyLimit(volume, base - k * GridStep * point)))
      {
         Alert(message + (string)i + "/BL");
         return request[i][0].result.retcode;
      }
      if(!(request[i][1].sellStopLimit(volume, base - k * GridStep * point,
         base - (k - 1) * GridStep * point)))
      {
         Alert(message + (string)i + "/SSL");
         return request[i][1].result.retcode;
      }
      
      // 网格的上半部分
      const int m = i + (int)GridSize / 2;
      
      request[m][0].comment = comment;
      request[m][1].comment = comment;
      
      if(!(request[m][0].sellLimit(volume, base + k * GridStep * point)))
      {
         Alert(message + (string)m + "/SL");
         return request[m][0].result.retcode;
      }
      if(!(request[m][1].buyStopLimit(volume, base + k * GridStep * point,
         base + (k - 1) * GridStep * point)))
      {
         Alert(message + (string)m + "/BSL");
         return request[m][1].result.retcode;
      }
   }

   // 检查订单是否准备好
   for(int i = 0; i < (int)GridSize; ++i)
   {
      for(int j = 0; j < 2; ++j)
      {
         if(!request[i][j].completed())
         {
            Alert(message + (string)i + "/" + (string)j + " post-check");
            return request[i][j].result.retcode;
         }
      }
   }
   return GRID_OK;
}

虽然检查(调用 completed)与发送订单是分开进行的,但我们的结构体在内部仍然使用同步形式的 OrderSend。实际上,为了加快发送一批订单(就像我们的网格专家顾问那样),最好使用异步版本的 OrderSendAsync。但那样的话,订单执行状态应该从事件处理程序 OnTradeTransaction 中启动。我们稍后会学习这个内容。

发送任何订单时出现错误都会导致提前退出循环并返回服务器的代码。这个测试用的专家顾问在出现错误时会简单地停止进一步工作。对于一个真正的机器人,最好对错误的含义进行智能分析,并且如果有必要,删除所有订单并关闭仓位。

由挂单生成的仓位将由 CompactPositions 函数关闭。

c
uint CompactPositions(const bool cleanup = false)

cleanup 参数默认值为 false,表示在交易时间段内进行常规的仓位 “清理”,即关闭相反的仓位(如果有的话)。当 cleanup 的值为 true 时,用于在交易时间段结束时强制关闭所有仓位。

该函数使用辅助函数 GetMyPositions 将多头仓位和空头仓位的订单号填充到 ticketsLongticketsShort 数组中。我们在 “关闭相反仓位:全部和部分” 这一章节的示例 TradeCloseBy.mq5 中已经使用过 GetMyPositions 函数。在这个新的专家顾问中,该示例中的 CloseByPosition 函数只进行了最小的修改:它返回服务器的代码,而不是成功或错误的逻辑指示符。

c
uint CompactPositions(const bool cleanup = false)
{
   uint retcode = 0;
   ulong ticketsLong[], ticketsShort[];
   const int n = GetMyPositions(_Symbol, Magic, ticketsLong, ticketsShort);
   if(n > 0)
   {
      Print("CompactPositions, pairs: ", n);
      for(int i = 0; i < n; ++i)
      {
         retcode = CloseByPosition(ticketsShort[i], ticketsLong[i]);
         if(retcode) return retcode;
      }
   }
   ...

   if(cleanup)
   {
      if(ArraySize(ticketsLong) > ArraySize(ticketsShort))
      {
         retcode = CloseAllPositions(ticketsLong, ArraySize(ticketsShort));
      }
      else if(ArraySize(ticketsLong) < ArraySize(ticketsShort))
      {
         retcode = CloseAllPositions(ticketsShort, ArraySize(ticketsLong));
      }
   }
   
   return retcode;
}

对于所有找到的剩余仓位,通过调用 CloseAllPositions 进行常规平仓。

c
uint CloseAllPositions(const ulong &tickets[], const int start = 0)
{
   const int n = ArraySize(tickets);
   Print("CloseAllPositions ", n);
   for(int i = start; i < n; ++i)
   {
      MqlTradeRequestSyncLog request;
      request.comment = "close down " + (string)(i + 1 - start)
         + " of " + (string)(n - start);
      if(!(request.close(tickets[i]) && request.completed()))
      {
         Print("Error: position is not closed ", tickets[i]);
         return request.result.retcode;
      }
   }
   return 0; // 成功
}

现在我们只需要考虑 RemoveOrders 函数。它也使用订单筛选器来获取订单列表,然后在循环中调用 remove 方法。

c
uint RemoveOrders()
{
   OrderFilter filter;
   ulong tickets[];
   filter.let(ORDER_SYMBOL, _Symbol).let(ORDER_MAGIC, Magic)
      .select(tickets);
   const int n = ArraySize(tickets);
   for(int i = 0; i < n; ++i)
   {
      MqlTradeRequestSyncLog request;
      request.comment = "removal " + (string)(i + 1) + " of " + (string)n;
      if(!(request.remove(tickets[i]) && request.completed()))
      {
         Print("Error: order is not removed ", tickets[i]);
         return request.result.retcode;
      }
   }
   return 0;
}

让我们在测试器中使用默认设置(交易时间段从 00:00 到 09:00)检查一下这个专家顾问的工作情况。下面是在 EURUSD、H1 时间框架上运行的截图。

在测试器中的网格策略 PendingOrderGrid1.mq5

在日志中,除了定期记录关于在一天开始时批量创建几个订单以及在早上删除订单的信息外,我们还会定期看到订单网络的恢复(添加已触发订单的替代订单)以及关闭仓位的记录。

buy stop limit 0.01 EURUSD at 1.14200 (1.14000) (1.13923 / 1.13923)
TRADE_ACTION_PENDING, EURUSD, ORDER_TYPE_BUY_STOP_LIMIT, V=0.01, ORDER_FILLING_FOK, »
   » @ 1.14200, X=1.14000, ORDER_TIME_GTC, M=1234567890, repair
DONE, #=159, V=0.01, Bid=1.13923, Ask=1.13923, Request executed, Req=287
CompactPositions, pairs: 1
close position #152 sell 0.01 EURUSD by position #153 buy 0.01 EURUSD (1.13923 / 1.13923)
deal #18 buy 0.01 EURUSD at 1.13996 done (based on order #160)
deal #19 sell 0.01 EURUSD at 1.14202 done (based on order #160)
Positions collapse initiated
OK CloseBy Order/Deal/Position
TRADE_ACTION_CLOSE_BY, EURUSD, ORDER_TYPE_BUY, ORDER_FILLING_FOK, P=152, b=153, »
   » M=1234567890, compacting
DONE, D=18, #=160, Request executed, Req=288

现在是时候学习用于处理仓位的 MQL5 函数,并在我们的专家顾问中改进对仓位的选择和分析了。接下来的章节将涉及这方面的内容。

获取头寸列表

在许多智能交易系统的示例中,我们已经使用了 MQL5 API 中用于分析已开仓交易头寸的函数。本节将对这些函数进行正式的介绍。

需要重点注意的是,这组函数无法创建、修改或删除头寸。正如我们之前所了解的,所有这些操作都是通过发送订单间接完成的。如果订单成功执行,就会产生交易,最终形成头寸。

另一个特点是,这些函数仅适用于在线头寸。要恢复头寸的历史记录,必须分析交易的历史记录。

PositionsTotal 函数可以让你了解账户上已开仓头寸的总数(针对所有金融工具)。

cpp
int PositionsTotal()

在对头寸进行净额计算(ACCOUNT_MARGIN_MODE_RETAIL_NETTINGACCOUNT_MARGIN_MODE_EXCHANGE)的情况下,任何时候每个交易品种只能有一个头寸。这个头寸可能由一笔或多笔交易形成。

在头寸独立表示(ACCOUNT_MARGIN_MODE_RETAIL_HEDGING)的情况下,每个交易品种可以同时开设多个头寸,包括方向相反的头寸。每一次入市交易都会创建一个单独的头寸,所以一个订单的逐步部分执行可能会产生多个头寸。

PositionGetSymbol 函数根据头寸的编号返回该头寸的交易品种。

cpp
string PositionGetSymbol(int index)

index 必须在 0 到 N - 1 之间,其中 N 是预先调用 PositionsTotal 函数所得到的值。头寸的顺序是不固定的。

如果没有找到相应的头寸,那么将返回一个空字符串,并且错误代码会存储在 _LastError 中。

在几个测试智能交易系统(TrailingStop.mq5TradeCloseBy.mq5 等)中,在名为 GetMyPosition/GetMyPositions 的函数里提供了使用这两个函数的示例。

已开仓的头寸由一个唯一的订单号来标识,这个订单号将它与其他头寸区分开来,但在某些情况下,该订单号在头寸存续期间可能会发生变化,比如在净额模式下通过一笔交易进行头寸反向操作,或者由于服务器上的服务操作(为计算掉期而重新开仓、清算等)。

要根据头寸的编号获取其订单号,我们使用 PositionGetTicket 函数。

cpp
ulong PositionGetTicket(int index)

此外,该函数会在终端的交易环境中突出显示该头寸,然后就可以使用一组特殊的 PositionGet 函数来读取其属性。换句话说,与订单类似,终端会为每个 MQL 程序维护一个内部缓存,用于存储一个头寸的属性。除了 PositionGetTicket 函数外,还有两个用于突出显示头寸的函数:PositionSelectPositionSelectByTicket,我们将在下面进行讨论。

如果出现错误,PositionGetTicket 函数将返回 0。

不应将订单号与分配给每个头寸且永远不会改变的标识符混淆。正是这些标识符用于将头寸与订单和交易关联起来。我们稍后会详细讨论这一点。

在涉及头寸的请求中需要用到订单号:订单号会在 MqlTradeRequest 结构体的 positionposition_by 字段中指定。此外,通过将订单号保存在一个变量中,程序随后可以使用 PositionSelectByTicket 函数(见下文)选择特定的头寸,并对其进行操作,而无需在循环中重复枚举头寸。

当在净额账户上进行头寸反向操作时,POSITION_TICKET 会更改为发起该操作的订单的订单号。不过,这样的头寸仍然可以使用标识符进行跟踪。在对冲模式下不支持头寸反向操作。

cpp
bool PositionSelect(const string symbol)

该函数通过金融工具的名称选择一个已开仓的头寸。

在头寸独立表示(ACCOUNT_MARGIN_MODE_RETAIL_HEDGING)的情况下,每个交易品种可能同时存在多个已开仓的头寸。在这种情况下,PositionSelect 函数将选择订单号最小的那个头寸。

返回的结果表示函数执行成功(true)或失败(false)。

被选中头寸的属性被缓存这一事实意味着,该头寸本身可能已经不存在了,或者如果程序在一段时间后读取其属性,头寸可能已经发生了变化。建议在访问数据之前立即调用 PositionSelect 函数。

cpp
bool PositionSelectByTicket(ulong ticket)

该函数根据指定的订单号选择一个已开仓的头寸,以便进一步对其进行操作。

我们将在稍后学习头寸属性以及相关的 PositionGet 函数时,查看使用这些函数的示例。

在使用 PositionsTotalOrdersTotal 以及类似函数构建算法时,应该考虑到终端操作的异步原则。我们在编写 MqlTradeSync.mqh 类以及实现等待交易请求的执行结果时,已经涉及到了这个话题。然而,在客户端并不总是能够进行这种等待。特别是,如果我们下达了一个挂单,那么它转换为市价单以及随后的执行将在服务器上进行。在这个时候,该订单可能不再列在有效订单之中(OrdersTotal 将返回 0),但头寸尚未显示出来(PositionsTotal 也等于 0)。因此,一个在没有头寸的情况下设置了下单条件的 MQL 程序可能会错误地发起一个新订单,最终导致头寸翻倍。

为了解决这个问题,MQL 程序必须更深入地分析交易环境,而不仅仅是一次性检查订单和头寸的数量。例如,可以保存交易环境最后正确状态的快照,并且在没有某种确认的情况下,不允许任何实体消失。只有这样才能形成新的状态记录。因此,一个订单只有在伴随着头寸的变化(创建、平仓)时才能被删除,或者以取消状态移至历史记录中。TradeGuard.mqh 文件中的 TradeGuard 类提供了一种可能的解决方案。本书还包含演示脚本 TradeGuardExample.mq5,你可以进一步学习。

持仓属性

所有持仓属性根据值的类型分为三组:整数类型以及与其兼容的类型、实数类型和字符串类型。它们用于配合类似于OrderGet系列的PositionGet函数进行读取操作。我们将在下一部分描述这些函数本身,而在这里,我们会给出所有可在这些函数的第一个参数中指定的属性标识符。

整数属性在ENUM_POSITION_PROPERTY_INTEGER枚举中提供。

标识符描述类型
POSITION_TICKET持仓单号ulong
POSITION_TIME持仓开仓时间datetime
POSITION_TIME_MSC持仓开仓时间(以毫秒为单位)ulong
POSITION_TIME_UPDATE持仓变化(交易量)时间datetime
POSITION_TIME_UPDATE_MSC持仓变化(交易量)时间(以毫秒为单位)ulong
POSITION_TYPE持仓类型ENUM_POSITION_TYPE
POSITION_MAGIC持仓魔术数(基于ORDER_MAGIC)ulong
POSITION_IDENTIFIER持仓标识符;分配给每个新开立持仓的唯一编号,在其整个存续期内不会改变ulong
POSITION_REASON开仓原因ENUM_POSITION_REASON

通常情况下,POSITION_IDENTIFIER对应于开仓订单的单号。在每一个开仓、改变或平仓的订单(ORDER_POSITION_ID)和交易(DEAL_POSITION_ID)中都会标明持仓标识符。因此,使用它来搜索与某个持仓相关的订单和交易是很方便的。

如果订单是部分成交的,那么持仓以及与剩余交易量对应的具有匹配单号的活跃挂单可以同时存在。此外,这样的持仓可以及时平仓,并且在后续挂单的剩余部分成交时,具有相同单号的持仓会再次出现。

在净额结算模式下,通过一笔交易反向持仓被视为持仓变化,而不是新持仓,所以POSITION_IDENTIFIER会被保留。只有在前一个持仓以零交易量平仓之后,才可能出现该品种的新持仓。

POSITION_TIME_UPDATE属性仅对交易量的变化做出响应(例如,由于部分平仓或增加持仓量导致的变化),而不对止损/止盈水平或掉期费用等其他参数的变化做出响应。

持仓只有两种类型(ENUM_POSITION_TYPE)。

标识符描述
POSITION_TYPE_BUY买入
POSITION_TYPE_SELL卖出

持仓的来源选项,即持仓是如何开立的,在ENUM_POSITION_REASON枚举中给出。

标识符描述
POSITION_REASON_CLIENT触发从桌面终端下达的订单
POSITION_REASON_MOBILE触发从移动应用程序下达的订单
POSITION_REASON_WEB触发从网络平台(浏览器)下达的订单
POSITION_REASON_EXPERT触发由智能交易系统或脚本下达的订单

实数属性收集在ENUM_POSITION_PROPERTY_DOUBLE中。

标识符描述
POSITION_VOLUME持仓交易量
POSITION_PRICE_OPEN持仓价格
POSITION_SL止损价格
POSITION_TP止盈价格
POSITION_PRICE_CURRENT当前品种价格
POSITION_SWAP累计掉期
POSITION_PROFIT当前盈利

当前价格类型对应于持仓平仓操作。例如,多头持仓必须通过卖出平仓,因此在POSITION_PRICE_CURRENT中跟踪的是其买入价。

最后,持仓支持以下字符串属性(ENUM_POSITION_PROPERTY_STRING)。

标识符描述
POSITION_SYMBOL开立持仓的品种
POSITION_COMMENT持仓备注
POSITION_EXTERNAL_ID外部系统(交易所)中的持仓ID

在查看了持仓属性列表之后,我们准备了解用于读取这些属性的函数了。

读取持仓属性的函数

MQL 程序可依据属性类型,借助多个 PositionGet 函数来获取持仓属性。在所有函数里,首个参数会定义所请求的具体属性,该参数采用上一节提及的 ENUM_POSITION_PROPERTY 枚举中的一个 ID。

针对每种属性类型,函数都有简略和详细两种形式:简略形式直接返回属性值,详细形式则将属性值写入通过引用传递的第二个参数。

PositionGetInteger 函数可获取整数属性以及兼容类型(日期时间、枚举)的属性。

c++
long PositionGetInteger(ENUM_POSITION_PROPERTY_INTEGER property)

bool PositionGetInteger(ENUM_POSITION_PROPERTY_INTEGER property, long &value)

若操作失败,函数会返回 0 或者 false

PositionGetDouble 函数用于获取实型属性。

c++
double PositionGetDouble(ENUM_POSITION_PROPERTY_DOUBLE property)

bool PositionGetDouble(ENUM_POSITION_PROPERTY_DOUBLE property, double &value)

最后,PositionGetString 函数会返回字符串属性。

c++
string PositionGetString(ENUM_POSITION_PROPERTY_STRING property)

bool PositionGetString(ENUM_POSITION_PROPERTY_STRING property, string &value)

若操作失败,函数的简略形式会返回空字符串。

为读取持仓属性,我们已经有了现成的抽象接口 MonitorInterfaceTradeBaseMonitor.mqh),此前编写订单监视器时用到过它。现在实现一个类似的持仓监视器就轻而易举了。实现结果放在 PositionMonitor.mqh 文件中。

PositionMonitorInterface 类继承自 MonitorInterface,并为模板类型 IDS 分配了当前考虑的 ENUM_POSITION_PROPERTY 枚举,同时考虑到持仓属性的特点,重写了几个 stringify 方法。

c++
class PositionMonitorInterface:
   public MonitorInterface<ENUM_POSITION_PROPERTY_INTEGER,
   ENUM_POSITION_PROPERTY_DOUBLE,ENUM_POSITION_PROPERTY_STRING>
{
public:
   virtual string stringify(const long v,
      const ENUM_POSITION_PROPERTY_INTEGER property) const override
   {
      switch(property)
      {
         case POSITION_TYPE:
            return enumstr<ENUM_POSITION_TYPE>(v);
         case POSITION_REASON:
            return enumstr<ENUM_POSITION_REASON>(v);
         
         case POSITION_TIME:
         case POSITION_TIME_UPDATE:
            return TimeToString(v, TIME_DATE | TIME_SECONDS);
         
         case POSITION_TIME_MSC:
         case POSITION_TIME_UPDATE_MSC:
            return STR_TIME_MSC(v);
      }
      
      return (string)v;
   }
   
   virtual string stringify(const ENUM_POSITION_PROPERTY_DOUBLE property,
      const string format = NULL) const override
   {
      if(format == NULL &&
         (property == POSITION_PRICE_OPEN || property == POSITION_PRICE_CURRENT
         || property == POSITION_SL || property == POSITION_TP))
      {
         const int digits = (int)SymbolInfoInteger(PositionGetString(POSITION_SYMBOL),
            SYMBOL_DIGITS);
         return DoubleToString(PositionGetDouble(property), digits);
      }
      return MonitorInterface<ENUM_POSITION_PROPERTY_INTEGER,
         ENUM_POSITION_PROPERTY_DOUBLE,ENUM_POSITION_PROPERTY_STRING>
         ::stringify(property, format);
   }

继承链中的下一个具体监视器类是 PositionMonitor,它基于 PositionGet 函数,可用于查看持仓情况。在构造函数中会依据单号选择持仓。

c++
class PositionMonitor: public PositionMonitorInterface
{
public:
   const ulong ticket;
   PositionMonitor(const ulong t): ticket(t)
   {
      if(!PositionSelectByTicket(ticket))
      {
         PrintFormat("Error: PositionSelectByTicket(%lld) failed: %s",
            ticket, E2S(_LastError));
      }
      else
      {
         ready = true;
      }
   }
   
   virtual long get(const ENUM_POSITION_PROPERTY_INTEGER property) const override
   {
      return PositionGetInteger(property);
   }
   
   virtual double get(const ENUM_POSITION_PROPERTY_DOUBLE property) const override
   {
      return PositionGetDouble(property);
   }
   
   virtual string get(const ENUM_POSITION_PROPERTY_STRING property) const override
   {
      return PositionGetString(property);
   }
   ...
};

一个简单的脚本能够记录首个持仓(若至少存在一个持仓)的所有特征。

c++
void OnStart()
{
   PositionMonitor pm(PositionGetTicket(0));
   pm.print();
}

在日志中,我们应该会看到类似如下的内容。

MonitorInterface<ENUM_POSITION_PROPERTY_INTEGER, »
   » ENUM_POSITION_PROPERTY_DOUBLE,ENUM_POSITION_PROPERTY_STRING>
ENUM_POSITION_PROPERTY_INTEGER Count=9
  0 POSITION_TIME=2022.03.24 23:09:45
  1 POSITION_TYPE=POSITION_TYPE_BUY
  2 POSITION_MAGIC=0
  3 POSITION_IDENTIFIER=1291755067
  4 POSITION_TIME_MSC=2022.03.24 23:09:45'261
  5 POSITION_TIME_UPDATE=2022.03.24 23:09:45
  6 POSITION_TIME_UPDATE_MSC=2022.03.24 23:09:45'261
  7 POSITION_TICKET=1291755067
  8 POSITION_REASON=POSITION_REASON_EXPERT
ENUM_POSITION_PROPERTY_DOUBLE Count=8
  0 POSITION_VOLUME=0.01
  1 POSITION_PRICE_OPEN=1.09977
  2 POSITION_PRICE_CURRENT=1.09965
  3 POSITION_SL=0.00000
  4 POSITION_TP=1.10500
  5 POSITION_COMMISSION=0.0
  6 POSITION_SWAP=0.0
  7 POSITION_PROFIT=-0.12
ENUM_POSITION_PROPERTY_STRING Count=3
  0 POSITION_SYMBOL=EURUSD
  1 POSITION_COMMENT=
  2 POSITION_EXTERNAL_ID=

若当前没有未平仓的持仓,我们会看到一条错误消息。

Error: PositionSelectByTicket(0) failed: TRADE_POSITION_NOT_FOUND

不过,监视器的用处并非仅仅在于将属性输出到日志中。基于 PositionMonitor,我们创建了一个依据条件筛选持仓的类,这和我们为订单所做的(OrderFilter)类似。最终目标是改进我们的网格智能交易系统。

得益于面向对象编程(OOP),创建一个新的筛选类几乎不费吹灰之力。以下是完整的源代码(PositionFilter.mqh 文件)。

c++
class PositionFilter: public TradeFilter<PositionMonitor,
   ENUM_POSITION_PROPERTY_INTEGER,
   ENUM_POSITION_PROPERTY_DOUBLE,
   ENUM_POSITION_PROPERTY_STRING>
{
protected:
   virtual int total() const override
   {
      return PositionsTotal();
   }
   virtual ulong get(const int i) const override
   {
      return PositionGetTicket(i);
   }
};

现在,我们可以编写一个脚本来获取具有给定魔术数字的持仓的特定利润。

c++
input ulong Magic;
   
void OnStart()
{
   PositionFilter filter;
   
   ENUM_POSITION_PROPERTY_DOUBLE properties[] =
      {POSITION_PROFIT, POSITION_VOLUME};
   
   double profits[][2];
   ulong tickets[];
   string symbols[];
   
   filter.let(POSITION_MAGIC, Magic).select(properties, tickets, profits);
   filter.select(POSITION_SYMBOL, tickets, symbols);
   
   for(int i = 0; i < ArraySize(symbols); ++i)
   {
      PrintFormat("%s[%lld]=%f",
         symbols[i], tickets[i], profits[i][0] / profits[i][1]);
   }
}

在这种情况下,我们不得不调用两次 select 方法,因为我们感兴趣的属性类型不同:实型的利润和手数,以及字符串类型的交易品种名称。在本章开头的一个小节中,我们开发交易品种筛选类时,介绍了元组的概念。在 MQL5 中,我们可以将其实现为带有任意类型字段的结构模板。这样的元组对于完善筛选类的层次结构非常有用,因为届时就可以描述一个 select 方法,用任意类型字段的元组数组来填充。

元组在 Tuples.mqh 文件中进行了描述。其中所有的结构名称都为 TupleN<T1,...>,这里的 N 是 2 到 8 之间的数字,它对应于模板参数(Ti 类型)的数量。例如,Tuple2

c++
template<typename T1,typename T2>
struct Tuple2
{
   T1 _1;
   T2 _2;
   
   static int size() { return 2; };
   
   // M — 订单、持仓、成交记录监视器类,任何 MonitorInterface<>
   template<typename M>
   void assign(const int &properties[], M &m)
   {
      if(ArraySize(properties) != size()) return;
      _1 = m.get(properties[0], _1);
      _2 = m.get(properties[1], _2);
   }
};

TradeFilter 类(TradeFilter.mqh)中,我们添加一个使用元组的 select 函数版本。

c++
template<typename T,typename I,typename D,typename S>
class TradeFilter
{
   ...
 template<typename U> // 类型 U 必须是 Tuple<>,例如 Tuple3<T1,T2,T3>
   bool select(const int &property[], U &data[], const bool sort = false) const
   {
      const int q = ArraySize(property);
      static const U u;                 // PRB: U::size() 无法编译
      if(q != u.size()) return false;   // 必要条件
      
      const int n = total();
      // 遍历订单/持仓/成交记录
      for(int i = 0; i < n; ++i)
      {
         const ulong t = get(i);
         // 通过监视器 T 访问属性
         T m(t);
         // 检查不同类型属性的所有筛选条件
         if(match(m, longs)
         && match(m, doubles)
         && match(m, strings))
         {
            // 对于合适的对象,将属性存储在元组数组中
            const int k = EXPAND(data);
            data[k].assign(property, m);
         }
      }
      
      if(sort)
      {
         sortTuple(data, u._1);
      }
      
      return true;
   }

元组数组可以选择按照第一个字段 _1 进行排序,所以你可以进一步研究 sortTuple 辅助方法。

有了元组,你可以在一次 select 调用中向筛选对象查询三种不同类型的属性。

以下代码展示了具有某个魔术数字的持仓,并按利润排序;同时还额外获取了每个持仓的交易品种和单号。

c++
 input ulong Magic;
   
   void OnStart()
   {
      int props[] = {POSITION_PROFIT, POSITION_SYMBOL, POSITION_TICKET};
      Tuple3<double,string,ulong> tuples[];
      PositionFilter filter;
      filter.let(POSITION_MAGIC, Magic).select(props, tuples, true);
      ArrayPrint(tuples);
   }

当然,元组数组描述中的参数类型(这里是 Tuple3<double,string,ulong>)必须与所请求的属性枚举类型(POSITION_PROFITPOSITION_SYMBOLPOSITION_TICKET)相匹配。

现在,我们可以对网格智能交易系统进行一定程度的简化(这里指的不仅是代码更简短,而且更易于理解)。新版本名为 PendingOrderGrid2.mq5。这些更改将影响所有与持仓管理相关的函数。

GetMyPositions 函数会填充通过引用传递的 types4tickets 元组数组。在每个 Tuple2 元组中,预计会存储持仓的类型和单号。在这种特定情况下,由于两个属性的基础类型相同,我们本可以使用二维 ulong 数组来替代元组。不过,我们使用元组是为了展示如何在调用代码中使用它们。

c++
#include <MQL5Book/Tuples.mqh>
#include <MQL5Book/PositionFilter.mqh>
   
int GetMyPositions(const string s, const ulong m,
   Tuple2<ulong,ulong> &types4tickets[])
{
   int props[] = {POSITION_TYPE, POSITION_TICKET};
   PositionFilter filter;
   filter.let(POSITION_SYMBOL, s).let(POSITION_MAGIC, m)
      .select(props, types4tickets, true);
   return ArraySize(types4tickets);
}

请注意,select 方法的第三个参数为 true,这表明要按照第一个字段(即持仓类型)对数组进行排序。这样一来,买入持仓会排在前面,卖出持仓会排在后面。这对于反向平仓操作是必要的。

CompactPositions 方法的改进版本如下。

c++
uint CompactPositions(const bool cleanup = false)
{
   uint retcode = 0;
   Tuple2<ulong,ulong> types4tickets[];
   int i = 0, j = 0;
   int n = GetMyPositions(_Symbol, Magic, types4tickets);
   if(n > 0)
   {
      Print("CompactPositions: ", n);
      for(i = 0, j = n - 1; i < j; ++i, --j)
      {
         if(types4tickets[i]._1 != types4tickets[j]._1) // 只要类型不同
         {
            retcode = CloseByPosition(types4tickets[i]._2, types4tickets[j]._2);
            if(retcode) return retcode; // 错误
         }
         else
         {
            break;
         }
      }
   }
   
   if(cleanup && j < n)
   {
      retcode = CloseAllPositions(types4tickets, i, j + 1);
   }
   
   return retcode;
}

CloseAllPositions 函数几乎没有变化:

c++
uint CloseAllPositions(const Tuple2<ulong,ulong> &types4tickets[],
   const int start = 0, const int end = 0)
{
   const int n = end == 0 ? ArraySize(types4tickets) : end;
   Print("CloseAllPositions ", n - start);
   for(int i = start; i < n; ++i)
   {
      MqlTradeRequestSyncLog request;
      request.comment = "close down " + (string)(i + 1 - start)
         + " of " + (string)(n - start);
      const ulong ticket = types4tickets[i]._2;
      if(!(request.close(ticket) && request.completed()))
      {
         Print("Error: position is not closed ", ticket);
         return request.result.retcode; // 错误
      }
   }
   return 0; // 成功 
}

你可以在测试器中对比 PendingOrderGrid1.mq5PendingOrderGrid2.mq5 这两个智能交易系统的运行情况。

报告内容会稍有不同,因为如果存在多个持仓,它们会以相反的组合方式平仓,这样其他未配对的持仓平仓时会依据各自的点差进行。

交易属性

交易是基于订单执行交易操作这一事实的体现。由于部分执行或反向平仓操作,一个订单可能会产生多笔交易。

交易具有三种基本类型的属性:整数型(及其兼容类型)、实数型和字符串型。每个属性在以下枚举类型之一中都有对应的常量进行描述:ENUM_DEAL_PROPERTY_INTEGERENUM_DEAL_PROPERTY_DOUBLEENUM_DEAL_PROPERTY_STRING

要读取交易属性,可使用 HistoryDealGet 系列函数。使用这些函数的前提是,事先已使用专门的函数从历史记录中筛选出所需的订单和交易。

整数型属性

整数型属性在 ENUM_DEAL_PROPERTY_INTEGER 枚举中进行描述。

标识符描述类型
DEAL_TICKET交易单号;为每笔交易分配的唯一编号ulong
DEAL_ORDER执行该交易所依据的订单单号ulong
DEAL_TIME交易时间datetime
DEAL_TIME_MSC交易时间(毫秒)ulong
DEAL_TYPE交易类型ENUM_DEAL_TYPE(见下文)
DEAL_ENTRY交易方向;开仓、平仓或反转ENUM_DEAL_ENTRY(见下文)
DEAL_MAGIC交易的魔术数字(基于 ORDER_MAGICulong
DEAL_REASON交易原因或来源ENUM_DEAL_REASON(见下文)
DEAL_POSITION_ID该交易开仓、修改或平仓的仓位标识符ulong

交易类型

可能的交易类型由 ENUM_DEAL_TYPE 枚举表示。

标识符描述
DEAL_TYPE_BUY买入
DEAL_TYPE_SELL卖出
DEAL_TYPE_BALANCE账户余额增加
DEAL_TYPE_CREDIT信用额度增加
DEAL_TYPE_CHARGE额外费用
DEAL_TYPE_CORRECTION账户调整
DEAL_TYPE_BONUS奖金
DEAL_TYPE_COMMISSION额外佣金
DEAL_TYPE_COMMISSION_DAILY交易日结束时收取的佣金
DEAL_TYPE_COMMISSION_MONTHLY月末收取的佣金
DEAL_TYPE_COMMISSION_AGENT_DAILY交易日结束时代理商收取的佣金
DEAL_TYPE_COMMISSION_AGENT_MONTHLY月末代理商收取的佣金
DEAL_TYPE_INTEREST闲置资金的利息收益
DEAL_TYPE_BUY_CANCELED已取消的买入交易
DEAL_TYPE_SELL_CANCELED已取消的卖出交易
DEAL_DIVIDEND股息收益
DEAL_DIVIDEND_FRANKED已扣税股息收益(免税)
DEAL_TAX税费

DEAL_TYPE_BUY_CANCELEDDEAL_TYPE_SELL_CANCELED 这两种情况反映了之前的交易被取消的情形。此时,之前执行的交易类型(DEAL_TYPE_BUYDEAL_TYPE_SELL)会变为 DEAL_TYPE_BUY_CANCELEDDEAL_TYPE_SELL_CANCELED,并且其盈亏会重置为零。之前获得的盈亏会作为一笔单独的账户余额操作进行贷记或借记。

交易方向

交易在仓位变更方式上存在差异。可能是简单的开仓(进入市场)、增加之前已开仓位的交易量、通过反向交易平仓,或者当反向交易覆盖了之前已开仓位的交易量时进行仓位反转。最后一种操作仅在净额结算账户中支持。

所有这些情况由 ENUM_DEAL_ENTRY 枚举的元素进行描述。

标识符描述
DEAL_ENTRY_IN开仓
DEAL_ENTRY_OUT平仓
DEAL_ENTRY_INOUT反转
DEAL_ENTRY_OUT_BY通过反向仓位平仓

交易原因

交易原因总结在 ENUM_DEAL_REASON 枚举中。

标识符描述
DEAL_REASON_CLIENT从桌面终端下达的订单触发
DEAL_REASON_MOBILE从移动应用下达的订单触发
DEAL_REASON_WEB从网页平台下达的订单触发
DEAL_REASON_EXPERT由专家顾问或脚本下达的订单触发
DEAL_REASON_SL止损订单触发
DEAL_REASON_TP止盈订单触发
DEAL_REASON_SO爆仓事件
DEAL_REASON_ROLLOVER仓位转至新的一天
DEAL_REASON_VMARGIN增加/扣除浮动保证金
DEAL_REASON_SPLIT持有仓位的交易品种进行拆股(价格降低)

实数型属性

实数型属性由 ENUM_DEAL_PROPERTY_DOUBLE 枚举表示。

标识符描述
DEAL_VOLUME交易交易量
DEAL_PRICE交易价格
DEAL_COMMISSION交易佣金
DEAL_SWAP平仓时累计的掉期费用
DEAL_PROFIT交易的财务结果
DEAL_FEE交易完成后立即收取的费用
DEAL_SL止损水平
DEAL_TP止盈水平

最后两个属性的填充规则如下:对于开仓或反转交易,止损/止盈值取自开仓或加仓所依据的订单。对于平仓交易,止损/止盈值取自平仓时的仓位。

字符串型属性

字符串型交易属性可通过 ENUM_DEAL_PROPERTY_STRING 枚举常量获取。

标识符描述
DEAL_SYMBOL进行交易的交易品种名称
DEAL_COMMENT交易备注
DEAL_EXTERNAL_ID外部交易系统(交易所)中的交易标识符

我们将在 HistoryDealGet 函数相关章节中,通过 DealMonitorDealFilter 类来测试如何读取这些属性。

从历史记录中选择订单和交易

MetaTrader 5 允许为智能交易系统或脚本创建特定时间段的历史记录快照。该快照是一个订单和交易的列表,可以通过相应的函数进一步访问。此外,还可以针对特定的订单、交易或头寸来请求历史记录。

通过 HistorySelect 函数显式地(按日期)选择所需的时间段。之后,可以分别使用 HistoryDealsTotalHistoryOrdersTotal 函数来获取交易列表的大小和订单列表的大小。可以使用 HistoryOrderGetTicket 函数检查订单列表的元素;对于交易列表的元素,则使用 HistoryDealGetTicket 函数。

有必要区分有效的(正在处理的)订单和历史记录中的订单,即已执行、已取消或已拒绝的订单。要分析有效订单,请使用与获取有效订单列表以及读取其属性相关章节中讨论的函数。

cpp
bool HistorySelect(datetime from, datetime to)

该函数请求服务器时间指定时间段(包括 fromto,且 to >= from)内的交易和订单历史记录,如果成功则返回 true

即使在请求的时间段内没有订单和交易,在没有错误的情况下该函数也会返回 true。例如,构建订单或交易列表时内存不足就可能导致错误。

请注意,订单有两个时间:设置时间(ORDER_TIME_SETUP)和执行时间(ORDER_TIME_DONE)。HistorySelect 函数按执行时间选择订单。

要提取整个账户的历史记录,可以使用语法 HistorySelect(0, LONG_MAX)

访问部分历史记录的另一种方法是通过头寸 ID。

cpp
bool HistorySelectByPosition(ulong positionID)

该函数请求在 ORDER_POSITION_IDDEAL_POSITION_ID 属性中具有指定头寸 ID 的交易和订单历史记录。

注意!对于反向平仓操作,该函数不会按相反头寸的 ID 选择订单。换句话说,ORDER_POSITION_BY_ID 属性会被忽略,尽管订单数据参与了头寸的形成。

例如,一个智能交易系统可以在启用对冲的账户上完成一次买入(订单 #1)和一次卖出(订单 #2)操作。这将随后导致头寸 #1 和头寸 #2 的形成。头寸的反向平仓需要 ORDER_TYPE_CLOSE_BY(#3)订单。结果是,调用 HistorySelectByPosition(#1) 将选择订单 #1 和 #3,这是预期的。然而,调用 HistorySelectByPosition(#2) 将只选择订单 #2(尽管订单 #3 的 ORDER_POSITION_BY_ID 属性中有 #2,并且严格来说,订单 #3 参与了头寸 #2 的平仓)。

HistorySelectHistorySelectByPosition 这两个函数中的任何一个成功执行后,终端会为 MQL 程序生成一个内部的订单和交易列表。你还可以使用 HistoryOrderSelectHistoryDealSelect 函数更改历史上下文,为此你需要提前知道相应对象的订单号(例如,从请求结果中保存它)。

重要的是要注意,HistoryOrderSelect 仅影响订单列表,而 HistoryDealSelect 仅用于交易列表。

所有上下文选择函数都返回一个布尔值,表示成功(true)或错误(false)。错误代码可以在内置的 _LastError 变量中读取。

cpp
bool HistoryOrderSelect(ulong ticket)

HistoryOrderSelect 函数按订单号在历史记录中选择一个订单。然后,该订单可用于对交易进行进一步操作(读取属性)。

在应用 HistoryOrderSelect 函数期间,如果按订单号搜索订单成功,在历史记录中选择的新订单列表将仅包含刚找到的这一个订单。换句话说,之前选择的订单列表(如果有的话)将被重置。但是,该函数不会重置之前选择的交易历史记录,即它不会选择与该订单相关的交易。

cpp
bool HistoryDealSelect(ulong ticket)

HistoryDealSelect 函数在历史记录中选择一笔交易,以便通过相应的函数进一步访问它。该函数不会重置订单历史记录,即它不会选择与所选交易相关的订单。

在通过调用上述函数之一在历史记录中选择了特定上下文后,MQL 程序可以调用相关函数来遍历属于此上下文的订单和交易,并读取它们的属性。

cpp
int HistoryOrdersTotal()

HistoryOrdersTotal 函数返回历史记录中(所选范围内)的订单数量。

cpp
ulong HistoryOrderGetTicket(int index)

HistoryOrderGetTicket 函数允许你根据所选历史上下文中订单的序列号获取订单号。index 必须在 0 到 N - 1 之间,其中 N 是从 HistoryOrdersTotal 函数获得的值。

知道了订单号,使用 HistoryOrderGet 函数就很容易获取订单的所有必要属性。历史订单的属性与现有订单的属性完全相同。

对于处理交易也有类似的一对函数。

cpp
int HistoryDealsTotal()

HistoryDealsTotal 函数返回历史记录中(所选范围内)的交易数量。

cpp
ulong HistoryDealGetTicket(int index)

HistoryDealGetTicket 函数允许你根据所选历史上下文中交易的序列号获取交易单号。这对于使用 HistoryDealGet 函数进一步处理交易是必要的。通过这些函数可访问的交易属性列表已在上一节中进行了描述。

在学习了 HistoryOrderGetHistoryDealGet 函数之后,我们将考虑一个使用这些函数的示例。

从历史记录中读取订单属性的函数

用于读取历史订单属性的函数,根据属性值的基本类型分为三组,这与我们在探讨活跃订单时,在单独章节中讨论的将可用属性标识符划分为三个枚举(ENUM_ORDER_PROPERTY_INTEGER、ENUM_ORDER_PROPERTY_DOUBLE和ENUM_ORDER_PROPERTY_STRING)是一致的。

在调用这些函数之前,需要以某种方式在历史记录中选择合适的单号集合。

如果尝试读取单号在所选历史记录上下文之外的订单或交易的属性,运行环境可能会生成WRONG_INTERNAL_PARAMETER(4002)错误,可以通过_LastError来分析该错误。

对于每种基本属性类型,都有两种函数形式:一种直接返回请求属性的值,另一种将属性值写入通过引用传递的参数中,并返回成功指示(true)或错误(false)。

对于整数及兼容类型(datetime、枚举类型)的属性,有专门的函数HistoryOrderGetInteger。

c
long HistoryOrderGetInteger(ulong ticket, ENUM_ORDER_PROPERTY_INTEGER property)
bool HistoryOrderGetInteger(ulong ticket, ENUM_ORDER_PROPERTY_INTEGER property,
  long &value)

该函数允许通过单号从所选历史记录中查询订单属性。

对于实数属性,使用HistoryOrderGetDouble函数。

c
double HistoryOrderGetDouble(ulong ticket, ENUM_ORDER_PROPERTY_DOUBLE property)
bool HistoryOrderGetDouble(ulong ticket, ENUM_ORDER_PROPERTY_DOUBLE property,
  double &value)

最后,字符串属性可以使用HistoryOrderGetString函数读取。

c
string HistoryOrderGetString(ulong ticket, ENUM_ORDER_PROPERTY_STRING property)
bool HistoryOrderGetString(ulong ticket, ENUM_ORDER_PROPERTY_STRING property,
  string &value)

现在我们可以扩展用于处理历史订单的OrderMonitor类(OrderMonitor.mqh)。首先,向该类添加一个布尔型变量,我们将在构造函数中根据选择带有传入单号的订单所在的范围来填充它:在活跃订单中(OrderSelect)还是在历史记录中(HistoryOrderSelect)。

c
class OrderMonitor: public OrderMonitorInterface
{
   bool history;
   
public:
   const ulong ticket;
   OrderMonitor(const long t): ticket(t), history(!OrderSelect(t))
   {
      if(history &&!HistoryOrderSelect(ticket))
      {
         PrintFormat("Error: OrderSelect(%lld) failed: %s", ticket, E2S(_LastError));
      }
      else
      {
         ResetLastError();
         ready = true;
      }
   }
   ...

在成功的if分支中,我们需要调用ResetLastError函数,以便重置OrderSelect函数可能设置的错误(如果订单在历史记录中)。

实际上,这个版本的构造函数存在一个严重的逻辑错误,我们会在接下来的几段内容中再回到这个问题。

为了在get方法中读取属性,现在我们根据history变量的值调用不同的内置函数。

c
   virtual long get(const ENUM_ORDER_PROPERTY_INTEGER property) const override
   {
      return history? HistoryOrderGetInteger(ticket, property) : OrderGetInteger(property);
   }
   virtual double get(const ENUM_ORDER_PROPERTY_DOUBLE property) const override
   {
      return history? HistoryOrderGetDouble(ticket, property) : OrderGetDouble(property);
   }
   virtual string get(const ENUM_ORDER_PROPERTY_STRING property) const override
   {
      return history? HistoryOrderGetString(ticket, property) : OrderGetString(property);
   }
   ...

OrderMonitor类的主要目的是为其他分析类提供数据。OrderMonitor对象用于在OrderFilter类中过滤活跃订单,并且我们需要一个类似的类,用于根据历史记录中的任意条件选择订单:HistoryOrderFilter。

让我们在同一个文件OrderFilter.mqh中编写这个类。它使用了两个用于处理历史记录的新函数:HistoryOrdersTotal和HistoryOrderGetTicket。

c
class HistoryOrderFilter: public TradeFilter<OrderMonitor,
   ENUM_ORDER_PROPERTY_INTEGER,
   ENUM_ORDER_PROPERTY_DOUBLE,
   ENUM_ORDER_PROPERTY_STRING>
{
protected:
   virtual int total() const override
   {
      return HistoryOrdersTotal();
   }
   virtual ulong get(const int i) const override
   {
      return HistoryOrderGetTicket(i);
   }
};

这段简单的代码继承自模板类TradeFilter,在模板中,将OrderMonitor类作为第一个参数传递,以便读取相应对象的属性(我们已经见过处理持仓的类似情况,并且很快会为交易创建一个类似的情况)。

这里就存在OrderMonitor构造函数的问题。正如我们在“从历史记录中选择订单和交易”这部分所了解到的,要分析账户,我们首先必须使用诸如HistorySelect之类的函数来设置上下文。所以在HistoryOrderFilter的源代码中,假定MQL程序已经选择了所需的历史记录片段。然而,OrderMonitor构造函数的新的中间版本使用HistoryOrderSelect调用来检查历史记录中单号的存在情况。同时,这个函数会重置之前的历史订单上下文,并选择单个订单。

所以我们需要一个辅助方法historyOrderSelectWeak,以一种“温和”的方式验证单号,而不破坏现有的上下文。为此,我们可以简单地检查ORDER_TICKET属性是否等于传入的单号t:(HistoryOrderGetInteger(t, ORDER_TICKET) == t)。如果这样的单号已经被选择(可用),检查将成功,并且监视器不需要对历史记录进行操作。

c
class OrderMonitor: public OrderMonitorInterface
{
   bool historyOrderSelectWeak(const ulong t) const
   {
      return (((HistoryOrderGetInteger(t, ORDER_TICKET) == t) ||
         (HistorySelect(0, LONG_MAX) && (HistoryOrderGetInteger(t, ORDER_TICKET) == t))));
   }
   bool history;
   
public:
   const ulong ticket;
   OrderMonitor(const long t): ticket(t), history(!OrderSelect(t))
   {
      if(history &&!historyOrderSelectWeak(ticket))
      {
         PrintFormat("Error: OrderSelect(%lld) failed: %s", ticket, E2S(_LastError));
      }
      else
      {
         ResetLastError();
         ready = true;
      }
   }

在我们为交易准备好类似的功能之后,下一部分将考虑在历史记录上应用订单过滤的示例。

从历史记录中读取成交记录属性的函数

为了读取成交记录属性,有按属性类型组织的函数组:整数型、实数型和字符串型。在调用函数之前,需要选择所需的历史时间段,从而确保所有函数的第一个参数(单号)中传入的单号所对应的成交记录是可用的。

每种属性类型都有两种形式的函数:一种是直接返回属性值,另一种是通过引用将属性值写入变量。第二种形式在成功时返回 true。第一种形式在出错时将简单地返回 0,错误代码存储在 _LastError 变量中。

整数型及兼容的属性类型(日期时间、枚举)可以使用 HistoryDealGetInteger 函数获取。

c++
long HistoryDealGetInteger(ulong ticket, ENUM_DEAL_PROPERTY_INTEGER property)

bool HistoryDealGetInteger(ulong ticket, ENUM_DEAL_PROPERTY_INTEGER property,
  long &value)

实数型属性可通过 HistoryDealGetDouble 函数读取。

c++
double HistoryDealGetDouble(ulong ticket, ENUM_DEAL_PROPERTY_DOUBLE property)

bool HistoryDealGetDouble(ulong ticket, ENUM_DEAL_PROPERTY_DOUBLE property,
  double &value)

对于字符串型属性,则有 HistoryDealGetString 函数。

c++
string HistoryDealGetString(ulong ticket, ENUM_DEAL_PROPERTY_STRING property)

bool HistoryDealGetString(ulong ticket, ENUM_DEAL_PROPERTY_STRING property,
  string &value)

DealMonitor 类(DealMonitor.mqh)将提供统一的成交记录属性读取方式,其组织方式与 OrderMonitorPositionMonitor 完全相同。基类是 DealMonitorInterface,它继承自模板类 MonitorInterface(我们在“读取活动订单属性的函数”部分描述过它)。正是在这个层级,将 ENUM_DEAL_PROPERTY 枚举的特定类型指定为模板参数,并实现 stringify 方法的具体逻辑。

c++
#include <MQL5Book/TradeBaseMonitor.mqh>

class DealMonitorInterface:
   public MonitorInterface<ENUM_DEAL_PROPERTY_INTEGER,
   ENUM_DEAL_PROPERTY_DOUBLE,ENUM_DEAL_PROPERTY_STRING>
{
public:
   // 考虑整数子类型的属性描述
   virtual string stringify(const long v,
      const ENUM_DEAL_PROPERTY_INTEGER property) const override
   {
      switch(property)
      {
         case DEAL_TYPE:
            return enumstr<ENUM_DEAL_TYPE>(v);
         case DEAL_ENTRY:
            return enumstr<ENUM_DEAL_ENTRY>(v);
         case DEAL_REASON:
            return enumstr<ENUM_DEAL_REASON>(v);
         
         case DEAL_TIME:
            return TimeToString(v, TIME_DATE | TIME_SECONDS);
         
         case DEAL_TIME_MSC:
            return STR_TIME_MSC(v);
      }
      
      return (string)v;
   }
};

下面的 DealMonitor 类与最近修改后用于处理历史记录的 OrderMonitor 类有些相似。除了使用 HistoryDeal 函数而非 HistoryOrder 函数之外,还应注意,对于成交记录,无需在在线环境中检查单号,因为成交记录仅存在于历史记录中。

c++
class DealMonitor: public DealMonitorInterface
{
   bool historyDealSelectWeak(const ulong t) const
   {
      return ((HistoryDealGetInteger(t, DEAL_TICKET) == t) ||
         (HistorySelect(0, LONG_MAX) && (HistoryDealGetInteger(t, DEAL_TICKET) == t)));
   }
public:
   const ulong ticket;
   DealMonitor(const long t): ticket(t)
   {
      if(!historyDealSelectWeak(ticket))
      {
         PrintFormat("Error: HistoryDealSelect(%lld) failed", ticket);
      }
      else
      {
         ready = true;
      }
   }
   
   virtual long get(const ENUM_DEAL_PROPERTY_INTEGER property) const override
   {
      return HistoryDealGetInteger(ticket, property);
   }
   
   virtual double get(const ENUM_DEAL_PROPERTY_DOUBLE property) const override
   {
      return HistoryDealGetDouble(ticket, property);
   }
   
   virtual string get(const ENUM_DEAL_PROPERTY_STRING property) const override
   {
      return HistoryDealGetString(ticket, property);
   }
   ...
};

基于 DealMonitorTradeFilter,很容易创建一个成交记录筛选器(DealFilter.mqh)。回想一下,TradeFilter 作为许多实体的基类,在“按属性选择订单”部分有过描述。

c++
#include <MQL5Book/DealMonitor.mqh>
#include <MQL5Book/TradeFilter.mqh>

class DealFilter: public TradeFilter<DealMonitor,
   ENUM_DEAL_PROPERTY_INTEGER,
   ENUM_DEAL_PROPERTY_DOUBLE,
   ENUM_DEAL_PROPERTY_STRING>
{
protected:
   virtual int total() const override
   {
      return HistoryDealsTotal();
   }
   virtual ulong get(const int i) const override
   {
      return HistoryDealGetTicket(i);
   }
};

作为处理历史记录的一个通用示例,考虑持仓历史恢复脚本 TradeHistoryPrint.mq5

TradeHistoryPrint

该脚本将为当前图表的交易品种构建历史记录。

我们首先需要成交记录和订单的筛选器。

c++
#include <MQL5Book/OrderFilter.mqh>
#include <MQL5Book/DealFilter.mqh>

从成交记录中,我们将提取持仓 ID,并基于这些 ID 请求有关订单的详细信息。

可以查看完整的历史记录,也可以查看特定持仓的历史记录,为此我们将在输入变量中提供模式选择和一个用于输入标识符的输入字段。

c++
enum SELECTOR_TYPE
{
   TOTAL,    // 完整历史记录
   POSITION, // 持仓 ID
};

input SELECTOR_TYPE Type = TOTAL;
input ulong PositionID = 0; // 持仓 ID

应该记住,对较长的账户历史记录进行采样可能会带来额外开销,因此在运行中的智能交易系统中,最好提供对获取的历史记录处理结果的缓存,同时记录上次处理的时间戳。在后续每次分析历史记录时,您可以从记住的时刻开始处理,而不是从头开始。

为了以一种视觉上吸引人的方式对齐列来显示历史记录信息,将其表示为一个结构数组是很有意义的。然而,我们的筛选器已经支持查询存储在特殊结构——元组中的数据。因此,我们将采用一个技巧:我们将描述我们的应用结构,遵循元组的规则:

  1. 第一个字段必须命名为 _1;它可选择性地用于排序算法。
  2. 必须在结构中描述返回字段数量的 size 函数。
  3. 该结构应该有一个模板方法 assign,用于从派生自 MonitorInterface 的传递的监视器对象的属性中填充字段。

在标准元组中,assign 方法描述如下:

c++
   template<typename M> 
   void assign(const int &properties[], M &m);

它的第一个参数接收一个包含与我们感兴趣的字段相对应的属性 ID 的数组。实际上,这个数组是由调用代码传递给筛选器(TradeFilter::select)的 select 方法的,然后通过引用传递给 assign 方法。但是,由于我们现在创建的不是一些标准元组,而是我们自己的结构,这些结构“知道”其字段的应用性质,所以我们可以将属性 ID 数组留在结构内部,而不必将其“驱动”到筛选器中,再返回到同一结构的 assign 方法中。

特别是,为了请求成交记录,我们描述了具有 8 个字段的 DealTuple 结构。它们的标识符将在 fields 静态数组中指定。

c++
struct DealTuple
{
   datetime _1;   // 成交时间
   ulong deal;    // 成交单号
   ulong order;   // 订单单号
   string type;   // 以字符串形式表示的 ENUM_DEAL_TYPE 
   string in_out; // 以字符串形式表示的 ENUM_DEAL_ENTRY 
   double volume;
   double price;
   double profit;
   
   static int size() { return 8; }; // 属性数量 
   static const int fields[]; // 请求的成交记录属性的标识符
   ...
};

static const int DealTuple::fields[] =
{
   DEAL_TIME, DEAL_TICKET, DEAL_ORDER, DEAL_TYPE,
   DEAL_ENTRY, DEAL_VOLUME, DEAL_PRICE, DEAL_PROFIT
};

这种方法将标识符和用于存储相应值的字段集中在一个地方,这使得源代码更易于理解和维护。

用属性值填充字段将需要一个稍微修改(简化)版本的 assign 方法,该方法从 fields 数组中获取 ID,而不是从输入参数中获取。

c++
struct DealTuple
{
   ...
   template<typename M> // M 派生自 MonitorInterface<>
   void assign(M &m)
   {
      static const int DEAL_TYPE_ = StringLen("DEAL_TYPE_");
      static const int DEAL_ENTRY_ = StringLen("DEAL_ENTRY_");
      static const ulong L = 0; // 默认类型声明(虚拟)
      
      _1 = (datetime)m.get(fields[0], L);
      deal = m.get(fields[1], deal);
      order = m.get(fields[2], order);
      const ENUM_DEAL_TYPE t = (ENUM_DEAL_TYPE)m.get(fields[3], L);
      type = StringSubstr(EnumToString(t), DEAL_TYPE_);
      const ENUM_DEAL_ENTRY e = (ENUM_DEAL_ENTRY)m.get(fields[4], L);
      in_out = StringSubstr(EnumToString(e), DEAL_ENTRY_);
      volume = m.get(fields[5], volume);
      price = m.get(fields[6], price);
      profit = m.get(fields[7], profit);
   }
};

同时,我们将 ENUM_DEAL_TYPEENUM_DEAL_ENTRY 枚举的数字元素转换为用户友好的字符串。当然,这仅在记录日志时需要。对于编程分析,应保留类型的原始形式。

由于我们在元组中发明了 assign 方法的新版本,所以需要在 TradeFilter 类中为它添加一个新的 select 方法版本。这个创新肯定对其他程序有用,因此我们将直接将其引入 TradeFilter 类中,而不是引入某个新的派生类。

c++
template<typename T,typename I,typename D,typename S>
class TradeFilter
{
   ...
   template<typename U> // U 必须有第一个字段 _1 和方法 assign(T)
   bool select(U &data[], const bool sort = false) const
   {
      const int n = total();
      // 遍历元素
      for(int i = 0; i < n; ++i)
      {
         const ulong t = get(i);
         // 通过监视器对象读取属性
         T m(t);
         // 检查所有筛选条件
         if(match(m, longs)
         && match(m, doubles)
         && match(m, strings))
         {
            // 对于合适的对象,将其属性添加到数组中
            const int k = EXPAND(data);
            data[k].assign(m);
         }
      }
      
      if(sort)
      {
         static const U u;
         sortTuple(data, u._1);
      }
      
      return true;
   }

回想一下,所有模板方法在代码中使用特定类型调用之前,编译器都不会实现它们。因此,如果您不使用元组,TradeFilter 中存在这样的模式并不强制您包含任何元组头文件或描述类似的结构。

所以,如果之前使用标准元组选择交易,我们必须这样编写代码:

c++
#include <MQL5Book/Tuples.mqh>
...
DealFilter filter;
int properties[] =
{
   DEAL_TIME, DEAL_TICKET, DEAL_ORDER, DEAL_TYPE,
   DEAL_ENTRY, DEAL_VOLUME, DEAL_PRICE, DEAL_PROFIT
};
Tuple8<ulong,ulong,ulong,ulong,ulong,double,double,double> tuples[];
filter.let(DEAL_SYMBOL, _Symbol).select(properties, tuples);

那么使用自定义结构,一切就简单得多了:

c++
DealFilter filter;
DealTuple tuples[];
filter.let(DEAL_SYMBOL, _Symbol).select(tuples);

类似于 DealTuple 结构,让我们描述用于订单的 10 个字段的结构 OrderTuple

c++
struct OrderTuple
{
   ulong _1;       // 单号(也用作 'ulong' 原型)
   datetime setup;
   datetime done;
   string type;
   double volume;
   double open;
   double current;
   double sl;
   double tp;
   string comment;
   
   static int size() { return 10; }; // 属性数量
   static const int fields[]; // 请求的订单属性的标识符
   
   template<typename M> // M 派生自 MonitorInterface<>
   void assign(M &m)
   {
      static const int ORDER_TYPE_ = StringLen("ORDER_TYPE_");
      
      _1 = m.get(fields[0], _1);
      setup = (datetime)m.get(fields[1], _1);
      done = (datetime)m.get(fields[2], _1);
      const ENUM_ORDER_TYPE t = (ENUM_ORDER_TYPE)m.get(fields[3], _1);
      type = StringSubstr(EnumToString(t), ORDER_TYPE_);
      volume = m.get(fields[4], volume);
      open = m.get(fields[5], open);
      current = m.get(fields[6], current);
      sl = m.get(fields[7], sl);
      tp = m.get(fields[8], tp);
      comment = m.get(fields[9], comment);
   }
};

static const int OrderTuple::fields[] =
{
   ORDER_TICKET, ORDER_TIME_SETUP, ORDER_TIME_DONE, ORDER_TYPE, ORDER_VOLUME_INITIAL,
   ORDER_PRICE_OPEN, ORDER_PRICE_CURRENT, ORDER_SL, ORDER_TP, ORDER_COMMENT
};

现在一切准备就绪,可以实现脚本的主要函数 OnStart 了。在最开始,我们将描述成交记录和订单筛选器的对象。

c++
void OnStart()
{
   DealFilter filter;
   HistoryOrderFilter subfilter;
   ...

根据输入变量,我们选择查看整个历史记录还是特定持仓。

c++
   if(PositionID == 0 || Type == TOTAL)
   {
      HistorySelect(0, LONG_MAX);
   }
   else if(Type == POSITION)
   {
      HistorySelectByPosition(PositionID);
   }
   ...

接下来,我们将把所有持仓标识符收集到一个数组中,或者保留用户指定的一个。

c++
   ulong positions[];
   if(PositionID == 0)
   {
      ulong tickets[];
      filter.let(DEAL_SYMBOL, _Symbol)
         .select(DEAL_POSITION_ID, tickets, positions, true); // true - 排序
      ArrayUnique(positions);
   }
   else
   {
      PUSH(positions, PositionID);
   }
   
   const int n = ArraySize(positions);
   Print("Positions total: ", n);
   if(n == 0) return;
   ...

辅助函数 ArrayUnique 会在数组中保留不重复的元素。它要求源数组已排序才能正常工作。

进一步,在遍历持仓的循环中,我们请求与每个持仓相关的成交记录和订单。成交记录按 DealTuple 结构的第一个字段(即按时间)排序。也许最有趣的是计算持仓的盈亏。为此,我们将所有成交记录的 profit 字段的值相加。

c++
   for(int i = 0; i < n; ++i)
   {
      DealTuple deals[];
      filter.let(DEAL_POSITION_ID, positions[i]).select(deals, true);
      const int m = ArraySize(deals);
      if(m == 0)
      {
         Print("Wrong position ID: ", positions[i]);
         break; // 用户设置的无效 ID
      }
      double profit = 0; // TODO: 需要考虑佣金、掉期和费用
      for(int j = 0; j < m; ++j) profit += deals[j].profit;
      PrintFormat("Position: % 8d %16lld Profit:%f", i + 1, positions[i], (profit));
      ArrayPrint(deals);
      
      Print("Order details:");
      OrderTuple orders[];
      subfilter.let(ORDER_POSITION_ID, positions[i], IS::OR_EQUAL)
         .let(ORDER_POSITION_BY_ID, positions[i], IS::OR_EQUAL)
         .select(orders);
      ArrayPrint(orders);
   }
}

此代码未分析成交记录属性中的佣金(DEAL_COMMISSION)、掉期(DEAL_SWAP)和费用(DEAL_FEE)。在实际的智能交易系统中,可能应该进行这样的分析(取决于策略的要求)

交易类型

除了执行交易操作外,MQL 程序还能对交易事件做出响应。需要注意的是,这类事件的发生不仅源于程序的操作,还有其他因素,比如用户手动操作,或者服务器自动执行某些操作(如激活挂单、触发止损、止盈、爆仓、仓位转至新的一天、账户存入或提取资金等)。

不管操作的发起者是谁,最终都会在账户上执行交易操作。交易操作是不可分割的步骤,涵盖以下内容:

  1. 处理交易请求
  2. 更改有效订单列表(包括添加新订单、执行并删除已触发的订单)
  3. 更改订单历史记录
  4. 更改交易历史记录
  5. 更改仓位情况

根据操作的性质,部分步骤可能并非必需。例如,修改仓位的保护级别时,中间的三个步骤就不会涉及。而当发送买入订单时,市场会经历完整的流程:处理请求,为账户创建相应订单,执行订单,将其从有效列表中移除,添加到订单历史记录,接着将相应交易添加到历史记录,并创建新的仓位。所有这些操作都属于交易操作。

要接收此类事件的通知,需在专家顾问或指标中定义特殊的 OnTradeTransaction 处理函数。我们将在下一节详细探讨该函数。实际上,它的第一个也是最重要的参数是预定义结构 MqlTradeTransaction 类型。因此,我们先介绍一下交易相关的内容。

c
struct MqlTradeTransaction
{ 
   ulong                         deal;             // 交易单号
   ulong                         order;            // 订单单号
   string                        symbol;           // 交易品种名称
   ENUM_TRADE_TRANSACTION_TYPE   type;             // 交易类型
   ENUM_ORDER_TYPE               order_type;       // 订单类型
   ENUM_ORDER_STATE              order_state;      // 订单状态
   ENUM_DEAL_TYPE                deal_type;        // 交易类型
   ENUM_ORDER_TYPE_TIME          time_type;        // 订单有效期类型
   datetime                      time_expiration;  // 订单过期日期
   double                        price;            // 价格
   double                        price_trigger;    // 止损限价单触发价格
   double                        price_sl;         // 止损水平
   double                        price_tp;         // 止盈水平
   double                        volume;           // 手数
   ulong                         position;         // 仓位单号
   ulong                         position_by;      // 反向仓位单号
};

以下表格对结构体的每个字段进行了说明:

字段描述
deal交易单号
order订单单号
symbol进行交易的交易品种名称
type交易类型,属于 ENUM_TRADE_TRANSACTION_TYPE 枚举(见下文)
order_type订单类型,属于 ENUM_ORDER_TYPE 枚举
order_state订单状态,属于 ENUM_ORDER_STATE 枚举
deal_type交易类型,属于 ENUM_DEAL_TYPE 枚举
time_type订单有效期类型,属于 ENUM_ORDER_TYPE_TIME 枚举
time_expiration挂单过期日期
price根据交易情况,可能是订单、交易或仓位的价格
price_trigger止损限价单的止损价格(触发价格)
price_sl止损价格;根据交易情况,可能指订单、交易或仓位的止损价格
price_tp止盈价格;根据交易情况,可能指订单、交易或仓位的止盈价格
volume手数;根据交易情况,可能表示订单、交易或仓位的当前手数
position受交易影响的仓位单号
position_by反向仓位单号

部分字段仅在特定情况下有意义。具体而言,当 time_typeORDER_TIME_SPECIFIEDORDER_TIME_SPECIFIED_DAY 时,time_expiration 字段才会被填充。price_trigger 字段仅用于止损限价单(ORDER_TYPE_BUY_STOP_LIMITORDER_TYPE_SELL_STOP_LIMIT)。

显然,仓位修改操作是基于仓位单号(position 字段)进行的,而不涉及订单或交易单号。此外,position_by 字段专门用于关闭反向仓位,即针对同一交易品种但方向相反的仓位。

分析交易的关键特征是其类型(type 字段)。为描述该类型,MQL5 API 引入了特殊的枚举 ENUM_TRADE_TRANSACTION_TYPE,其中包含所有可能的交易类型。

标识符描述
TRADE_TRANSACTION_ORDER_ADD添加新订单
TRADE_TRANSACTION_ORDER_UPDATE更改有效订单
TRADE_TRANSACTION_ORDER_DELETE删除有效订单
TRADE_TRANSACTION_DEAL_ADD向历史记录中添加交易
TRADE_TRANSACTION_DEAL_UPDATE更改历史记录中的交易
TRADE_TRANSACTION_DEAL_DELETE从历史记录中删除交易
TRADE_TRANSACTION_HISTORY_ADD因执行或取消操作,将订单添加到历史记录
TRADE_TRANSACTION_HISTORY_UPDATE更改历史记录中的订单
TRADE_TRANSACTION_HISTORY_DELETE从历史记录中删除订单
TRADE_TRANSACTION_POSITION更改仓位
TRADE_TRANSACTION_REQUEST通知交易请求已由服务器处理,并返回处理结果

以下是一些解释:

  • TRADE_TRANSACTION_ORDER_UPDATE 类型的交易中,订单的更改不仅包括客户端终端或交易服务器的显式更改,还包括其状态的改变(例如,从 ORDER_STATE_STARTED 状态转变为 ORDER_STATE_PLACED 状态,或从 ORDER_STATE_PLACED 状态转变为 ORDER_STATE_PARTIAL 状态等)。
  • TRADE_TRANSACTION_ORDER_DELETE 交易中,订单可能因显式请求或在服务器上执行(成交)而被删除。在这两种情况下,订单都会被转移到历史记录中,同时必然会发生 TRADE_TRANSACTION_HISTORY_ADD 交易。
  • TRADE_TRANSACTION_DEAL_ADD 交易不仅会在订单执行时发生,也会在账户余额发生交易时出现。
  • TRADE_TRANSACTION_DEAL_UPDATETRADE_TRANSACTION_DEAL_DELETETRADE_TRANSACTION_HISTORY_DELETE 这类交易比较少见,因为它们描述的是服务器追溯更改或删除历史记录中的交易或订单的情况。这通常是与外部交易系统(交易所)同步的结果。
  • 需要注意的是,添加或平仓操作不会引发 TRADE_TRANSACTION_POSITION 交易。此类交易表示交易服务器端的仓位被更改,可能是通过程序操作,也可能是用户手动操作。具体来说,仓位可能会在交易量(部分反向平仓、反转)、开仓价格、止损和止盈水平等方面发生变化。某些操作,如追加保证金,不会触发此事件。
  • MQL 程序发出的所有交易请求都会反映在 TRADE_TRANSACTION_REQUEST 交易中,这有助于延迟分析请求的执行情况。在使用 OrderSendAsync 函数时,这一点尤为重要,因为该函数会立即将控制权返回给调用代码,所以结果未知。同时,使用同步的 OrderSend 函数时也会以相同方式生成交易。
  • 此外,通过 TRADE_TRANSACTION_REQUEST 交易,还可以分析用户在终端界面上的交易操作。

交易事务事件(OnTradeTransaction event)

如果智能交易系统(Expert Advisors)和指标的代码中包含一个特殊的处理函数 OnTradeTransaction,它们就可以接收有关交易事件的通知。

cpp
void OnTradeTransaction(const MqlTradeTransaction &trans,
  const MqlTradeRequest &request, const MqlTradeResult &result)

第一个参数是上一节中描述的 MqlTradeTransaction 结构体。第二个和第三个参数分别是 MqlTradeRequestMqlTradeResult 结构体,我们在之前的相关章节中已经介绍过它们。

描述交易事务的 MqlTradeTransaction 结构体根据 type 字段中指定的事务类型进行不同的填充。例如,对于 TRADE_TRANSACTION_REQUEST 类型的事务,所有其他字段都不重要,要获取额外信息,就需要分析该函数的第二个和第三个参数(requestresult)。相反,对于所有其他类型的事务,该函数的最后两个参数应该被忽略。

TRADE_TRANSACTION_REQUEST 的情况下,result 变量中的 request_id 字段包含一个标识符(通过序列号),交易请求在终端中就是根据这个标识符进行注册的。这个编号与订单号、交易单号以及头寸标识符都没有关系。在与终端的每个会话期间,编号都是从起始值(1)开始的。请求标识符的存在使得可以将执行的操作(调用 OrderSendOrderSendAsync 函数)与传递给 OnTradeTransaction 的该操作的结果关联起来。我们稍后会看一些示例。

对于与有效订单相关的交易事务(TRADE_TRANSACTION_ORDER_ADDTRADE_TRANSACTION_ORDER_UPDATETRADE_TRANSACTION_ORDER_DELETE)以及订单历史记录(TRADE_TRANSACTION_HISTORY_ADDTRADE_TRANSACTION_HISTORY_UPDATETRADE_TRANSACTION_HISTORY_DELETE),MqlTradeTransaction 结构体中会填充以下字段:

  • order - 订单号
  • symbol - 订单中的金融工具名称
  • type - 交易事务类型
  • order_type - 订单类型
  • orders_state - 当前订单状态
  • time_type - 订单到期类型
  • time_expiration - 订单到期时间(对于 ORDER_TIME_SPECIFIEDORDER_TIME_SPECIFIED_DAY 到期类型的订单)
  • price - 客户/程序指定的订单价格
  • price_trigger - 触发止损限价单的止损价格(仅适用于 ORDER_TYPE_BUY_STOP_LIMITORDER_TYPE_SELL_STOP_LIMIT
  • price_sl - 止损订单价格(如果在订单中指定则填充)
  • price_tp - 止盈订单价格(如果在订单中指定则填充)
  • volume - 当前订单交易量(未执行),初始订单交易量可以从订单历史记录中找到
  • position - 已开仓、已修改或已平仓头寸的订单号
  • position_by - 相反头寸的订单号(仅适用于反向平仓订单)

对于与交易相关的交易事务(TRADE_TRANSACTION_DEAL_ADDTRADE_TRANSACTION_DEAL_UPDATETRADE_TRANSACTION_DEAL_DELETE),MqlTradeTransaction 结构体中会填充以下字段:

  • deal - 交易单号
  • order - 基于其进行交易的订单号
  • symbol - 交易中的金融工具名称
  • type - 交易事务类型
  • deal_type - 交易类型
  • price - 交易价格
  • price_sl - 止损价格(如果在基于其进行交易的订单中指定则填充)
  • price_tp - 止盈价格(如果在基于其进行交易的订单中指定则填充)
  • volume - 交易交易量
  • position - 已开仓、已修改或已平仓头寸的订单号
  • position_by - 相反头寸的订单号(对于反向平仓交易)

对于与头寸变化相关的交易事务(TRADE_TRANSACTION_POSITION),MqlTradeTransaction 结构体中会填充以下字段:

  • symbol - 头寸的金融工具名称
  • type - 交易事务类型
  • deal_type - 头寸类型(DEAL_TYPE_BUYDEAL_TYPE_SELL
  • price - 加权平均头寸开仓价格
  • price_sl - 止损价格
  • price_tp - 止盈价格
  • volume - 头寸交易量(手数)
  • position - 头寸订单号

并非所有关于订单、交易和头寸的可用信息(例如,注释)都会在交易事务的描述中传输。要获取更多信息,请使用相关函数:OrderGetHistoryOrderGetHistoryDealGetPositionGet

从终端手动或通过交易函数 OrderSend/OrderSendAsync 发送的一个交易请求可能会在交易服务器上生成几个连续的交易事务。同时,这些事务的通知到达终端的顺序是无法保证的,所以不能基于等待某些交易事务在其他事务之后到达来构建你的交易算法。

交易事件是异步处理的,也就是说,相对于生成时刻会有(时间上的)延迟。每个交易事件都会被发送到 MQL 程序的队列中,程序会按照队列的顺序依次处理它们。

当智能交易系统在 OnTradeTransaction 处理器内部处理交易事务时,终端会继续接受传入的交易事务。因此,在 OnTradeTransaction 运行期间,交易账户的状态可能会发生变化。将来,程序会按照事件出现的顺序收到所有这些事件的通知。

事务队列的长度为 1024 个元素。如果 OnTradeTransaction 处理下一个事务的时间过长,队列中的旧事务可能会被新事务挤出。

由于终端与交易对象的并行多线程操作,在调用 OnTradeTransaction 处理程序时,其中提到的所有实体,包括订单、交易和头寸,可能已经处于与事务属性中指定的状态不同的状态。要获取它们的当前状态,应该在当前环境或历史记录中选择它们,并使用相应的 MQL5 函数请求它们的属性。

我们从一个简单的智能交易系统示例 TradeTransactions.mq5 开始,它会记录所有 OnTradeTransaction 交易事件。它唯一的参数 DetailedLog 允许你选择是否使用 OrderMonitorDealMonitorPositionMonitor 类来显示所有属性。默认情况下,智能交易系统只显示以参数形式传入处理程序的 MqlTradeTransactionMqlTradeRequestMqlTradeResult 结构体中已填充字段的内容;同时,仅对 TRADE_TRANSACTION_REQUEST 事务处理 requestresult

cpp
input bool DetailedLog = false; // DetailedLog ('true' shows order/deal/position details)
   
void OnTradeTransaction(const MqlTradeTransaction &transaction,
   const MqlTradeRequest &request,
   const MqlTradeResult &result)
{
   static ulong count = 0;
   PrintFormat(">>>% 6d", ++count);
   Print(TU::StringOf(transaction));
   
   if(transaction.type == TRADE_TRANSACTION_REQUEST)
   {
      Print(TU::StringOf(request));
      Print(TU::StringOf(result));
   }
   
   if(DetailedLog)
   {
      if(transaction.order != 0)
      {
         OrderMonitor m(transaction.order);
         m.print();
      }
      if(transaction.deal != 0)
      {
         DealMonitor m(transaction.deal);
         m.print();
      }
      if(transaction.position != 0)
      {
         PositionMonitor m(transaction.position);
         m.print();
      }
   }
}

我们在 EURUSD 图表上运行它并手动执行几个操作,相应的记录将出现在日志中(为了实验的纯粹性,假设没有其他人或其他任何东西在交易账户上执行操作,特别是没有其他智能交易系统在运行)。

让我们以最小手数开一个多头头寸。

>>>      1

TRADE_TRANSACTION_ORDER_ADD, #=1296991463(ORDER_TYPE_BUY/ORDER_STATE_STARTED), EURUSD, »

   » @ 1.10947, V=0.01

>>>      2

TRADE_TRANSACTION_DEAL_ADD, D=1279627746(DEAL_TYPE_BUY), »

   » #=1296991463(ORDER_TYPE_BUY/ORDER_STATE_STARTED), EURUSD, @ 1.10947, V=0.01, P=1296991463

>>>      3

TRADE_TRANSACTION_ORDER_DELETE, #=1296991463(ORDER_TYPE_BUY/ORDER_STATE_FILLED), EURUSD, »

   » @ 1.10947, P=1296991463

>>>      4

TRADE_TRANSACTION_HISTORY_ADD, #=1296991463(ORDER_TYPE_BUY/ORDER_STATE_FILLED), EURUSD, »

   » @ 1.10947, P=1296991463

>>>      5

TRADE_TRANSACTION_REQUEST

TRADE_ACTION_DEAL, EURUSD, ORDER_TYPE_BUY, V=0.01, ORDER_FILLING_FOK, @ 1.10947, #=1296991463

DONE, D=1279627746, #=1296991463, V=0.01, @ 1.10947, Bid=1.10947, Ask=1.10947, Req=7

我们将以双倍最小手数卖出。

>>>      6

TRADE_TRANSACTION_ORDER_ADD, #=1296992157(ORDER_TYPE_SELL/ORDER_STATE_STARTED), EURUSD, »

   » @ 1.10964, V=0.02

>>>      7

TRADE_TRANSACTION_DEAL_ADD, D=1279628463(DEAL_TYPE_SELL), »

   » #=1296992157(ORDER_TYPE_BUY/ORDER_STATE_STARTED), EURUSD, @ 1.10964, V=0.02, P=1296992157

>>>      8

TRADE_TRANSACTION_ORDER_DELETE, #=1296992157(ORDER_TYPE_SELL/ORDER_STATE_FILLED), EURUSD, »

   » @ 1.10964, P=1296992157

>>>      9

TRADE_TRANSACTION_HISTORY_ADD, #=1296992157(ORDER_TYPE_SELL/ORDER_STATE_FILLED), EURUSD, »

   » @ 1.10964, P=1296992157

>>>     10

TRADE_TRANSACTION_REQUEST

TRADE_ACTION_DEAL, EURUSD, ORDER_TYPE_SELL, V=0.02, ORDER_FILLING_FOK, @ 1.10964, #=1296992157

DONE, D=1279628463, #=1296992157, V=0.02, @ 1.10964, Bid=1.10964, Ask=1.10964, Req=8

让我们执行反向平仓操作。

>>>     11

TRADE_TRANSACTION_ORDER_ADD, #=1296992548(ORDER_TYPE_CLOSE_BY/ORDER_STATE_STARTED), EURUSD, »

   » @ 1.10964, V=0.01, P=1296991463, b=1296992157

>>>     12

TRADE_TRANSACTION_DEAL_ADD, D=1279628878(DEAL_TYPE_SELL), »

   » #=1296992548(ORDER_TYPE_BUY/ORDER_STATE_STARTED), EURUSD, @ 1.10964, V=0.01, P=1296991463

>>>     13

TRADE_TRANSACTION_POSITION, EURUSD, @ 1.10947, P=1296991463

>>>     14

TRADE_TRANSACTION_DEAL_ADD, D=1279628879(DEAL_TYPE_BUY), »

   » #=1296992548(ORDER_TYPE_BUY/ORDER_STATE_STARTED), EURUSD, @ 1.10947, V=0.01, P=1296992157

>>>     15

TRADE_TRANSACTION_ORDER_DELETE, #=1296992548(ORDER_TYPE_CLOSE_BY/ORDER_STATE_FILLED), EURUSD, »

   » @ 1.10964, P=1296991463, b=1296992157

>>>     16

TRADE_TRANSACTION_HISTORY_ADD, #=1296992548(ORDER_TYPE_CLOSE_BY/ORDER_STATE_FILLED), EURUSD, »

   » @ 1.10964, P=1296991463, b=1296992157

>>>     17

TRADE_TRANSACTION_REQUEST

TRADE_ACTION_CLOSE_BY, EURUSD, ORDER_TYPE_BUY, V=0.01, ORDER_FILLING_FOK, #=1296992548, »

   » P=1296991463, b=1296992157

DONE, D=1279628878, #=1296992548, V=0.01, @ 1.10964, Bid=1.10961, Ask=1.10965, Req=9

我们仍然有一个最小手数的空头头寸。让我们平仓。

>>>     18

TRADE_TRANSACTION_ORDER_ADD, #=1297002683(ORDER_TYPE_BUY/ORDER_STATE_STARTED), EURUSD, »

   » @ 1.10964, V=0.01, P=1296992157

>>>     19

TRADE_TRANSACTION_ORDER_DELETE, #=1297002683(ORDER_TYPE_BUY/ORDER_STATE_FILLED), EURUSD, »

   » @ 1.10964, P=1296992157

>>>     20

TRADE_TRANSACTION_HISTORY_ADD, #=1297002683(ORDER_TYPE_BUY/ORDER_STATE_FILLED), EURUSD, »

   » @ 1.10964, P=1296992157

>>>     21

TRADE_TRANSACTION_DEAL_ADD, D=1279639132(DEAL_TYPE_BUY), »

   » #=1297002683(ORDER_TYPE_BUY/ORDER_STATE_STARTED), EURUSD, @ 1.10964, V=0.01, P=1296992157

>>>     22

TRADE_TRANSACTION_REQUEST

TRADE_ACTION_DEAL, EURUSD, ORDER_TYPE_BUY, V=0.01, ORDER_FILLING_FOK, @ 1.10964, #=1297002683, »

   » P=1296992157

DONE, D=1279639132, #=1297002683, V=0.01, @ 1.10964, Bid=1.10964, Ask=1.10964, Req=10

如果你愿意,可以启用 DetailedLog 选项,以便在事件处理时记录交易对象的所有属性。在详细日志中,你可以注意到存储在事务结构体中的对象状态(在其启动时)与当前状态之间的差异。例如,当添加一个平仓订单(反向或正常)时,事务中会指定一个订单号,根据这个订单号,监控对象将无法再读取任何内容,因为头寸已经被删除。结果,我们会在日志中看到类似这样的行:

TRADE_TRANSACTION_ORDER_ADD, #=1297777749(ORDER_TYPE_CLOSE_BY/ORDER_STATE_STARTED), EURUSD, »

   » @ 1.10953, V=0.01, P=1297774881, b=1297776850

...

Error: PositionSelectByTicket 1297774881) failed: TRADE_POSITION_NOT_FOUND

让我们重新启动智能交易系统 `TradeTransaction.mq5`,以重置已记录的事件,以便进行下一次测试。这次我们将使用默认设置(不显示详细信息)。

现在,让我们尝试在新的智能交易系统 `OrderSendTransaction1.mq5` 中以编程方式执行交易操作,同时在其中描述我们的 `OnTradeTransaction` 处理程序(与前面的示例相同)。

这个智能交易系统允许你选择交易方向和交易量:如果你将其保留为零,默认情况下将使用当前交易品种的最小手数。此外,参数中还有止损和止盈保护水平的点数距离。使用指定的参数进入市场,在设置止损和止盈之间有 5 秒的暂停,然后平仓,这样用户就可以进行干预(例如,手动编辑止损),尽管这不是必需的,因为我们已经确保手动操作会被程序拦截。

```cpp
enum ENUM_ORDER_TYPE_MARKET
{
   MARKET_BUY = ORDER_TYPE_BUY,    // ORDER_TYPE_BUY
   MARKET_SELL = ORDER_TYPE_SELL   // ORDER_TYPE_SELL
};
   
input ENUM_ORDER_TYPE_MARKET Type;
input double Volume;               // Volume (0 - minimal lot)
input uint Distance2SLTP = 1000;

该策略仅启动一次,为此使用了一个 1 秒的定时器,该定时器在其自身的处理程序中关闭。

cpp
int OnInit()
{
   EventSetTimer(1);
   return INIT_SUCCEEDED;
}
   
void OnTimer()
{
   EventKillTimer();
   ...

所有操作都是通过一个已经熟悉的具有高级功能的 MqlTradeRequestSync 结构体(MqlTradeSync.mqh)来执行的:使用正确的值隐式初始化字段、市价单的买入/卖出方法、调整保护水平以及平仓操作。

步骤 1:

cpp
   MqlTradeRequestSync request;
   
   const double volume = Volume == 0 ?
      SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN) : Volume;
   
   Print("Start trade");
   const ulong order = (Type == MARKET_BUY ? request.buy(volume) : request.sell(volume));
   if(order == 0 || !request.completed())
   {
      Print("Failed Open");
      return;
   }
   
   Print("OK Open");

步骤 2:

cpp
   Sleep(5000); // wait 5 seconds (user can edit position)
   Print("SL/TP modification");
   const double price = PositionGetDouble(POSITION_PRICE_OPEN);
   const double point = SymbolInfoDouble(_Symbol, SYMBOL_POINT);
   TU::TradeDirection dir((ENUM_ORDER_TYPE)Type);
   const double SL = dir.negative(price, Distance2SLTP * point);
   const double TP = dir.positive(price, Distance2SLTP * point);
   if(request.adjust(SL, TP) && request.completed())
   {
      Print("OK Adjust");
   }
   else
   {
      Print("Failed Adjust");
   }

步骤 3:

cpp
   Sleep(5000); // wait another 5 seconds
   Print("Close down");
   if(request.close(request.result.position) && request.completed())
   {
      Print("Finish");
   }
   else
   {
      Print("Failed Close");
   }
}

中间的等待不仅使我们有时间观察这个过程,还展示了 MQL5 编程的一个重要方面,即单线程性。当我们的交易智能交易系统在 OnTimer 内部时,终端生成的交易事件会累积在其队列中,并且只有在从 OnTimer 退出后,才会以延迟的方式转发到内部的 OnTradeTransaction 处理程序。

同时,并行运行的 TradeTransactions 智能交易系统不忙于任何计算,并且会尽快接收交易事件。

两个智能交易系统的执行结果显示在以下带有时间记录的日志中(为简洁起见,将 OrderSendTransaction1 标记为 OS1,将 Trade Transactions 标记为 TTs)。

19:09:08.078  OS1  Start trade

19:09:08.109  TTs  >>>     1

19:09:08.125  TTs  TRADE_TRANSACTION_ORDER_ADD, #=1298021794(ORDER_TYPE_BUY/ORDER_STATE_STARTED), »

                   EURUSD, @ 1.10913, V=0.01

19:09:08.125  TTs  >>>     2

19:09:08.125  TTs  TRADE_TRANSACTION_DEAL_ADD, D=1280661362(DEAL_TYPE_BUY), »

                   #=1298021794(ORDER_TYPE_BUY/ORDER_STATE_STARTED), EURUSD, @ 1.10913, V=0.01, »

                   P=1298021794

19:09:08.125  TTs  >>>     3

19:09:08.125  TTs  TRADE_TRANSACTION_ORDER_DELETE, #=1298021794(ORDER_TYPE_BUY/ORDER_STATE_FILLED), »

                   EURUSD, @ 1.10913, P=1298021794

19:09:08.125  TTs  >>>     4

19:09:08.125  TTs  TRADE_TRANSACTION_HISTORY_ADD, #=1298021794(ORDER_TYPE_BUY/ORDER_STATE_FILLED), »

                   EURUSD, @ 1.10913, P=1298021794

19:09:08.125  TTs  >>>     5

19:09:08.125  TTs  TRADE_TRANSACTION_REQUEST

19:09:08.125  TTs  TRADE_ACTION_DEAL, EURUSD, ORDER_TYPE_BUY, V=0.01, ORDER_FILLING_FOK, @ 1.10913, »

                   D=10, #=1298021794, M=1234567890

19:09:08.125  TTs  DONE, D=1280661362, #=1298021794, V=0.01, @ 1.10913, Bid=1.10913, Ask=1.10913, »

                   Req=9

19:09:08.125  OS1  Waiting for position for deal D=1280661362

19:09:08.125  OS1  OK Open

19:09:13.133  OS1  SL/TP modification

19:09:13.164  TTs  >>>     6

19:09:13.164  TTs  TRADE_TRANSACTION_POSITION, EURUSD, @ 1.10913, SL=1.09913, TP=1.11913, V=0.01, »

                   P=1298021794

19:09:13.164  OS1  OK Adjust

19:09:13.164  TTs  >>>     7

19:09:13.164  TTs  TRADE_TRANSACTION_REQUEST

19:09:13.164  TTs  TRADE_ACTION_SLTP, EURUSD, ORDER_TYPE_BUY, V=0.01, ORDER_FILLING_FOK, SL=1.09913, »

                   TP=1.11913, D=10, P=1298021794, M=1234567890

19:09:13.164  TTs  DONE, Req=10

19:09:18.171  OS1  Close down

19:09:18.187  OS1  Finish

19:09:18.218  TTs  >>>     8

19:09:18.218  TTs  TRADE_TRANSACTION_ORDER_ADD, #=1298022443(ORDER_TYPE_SELL/ORDER_STATE_STARTED), »

                   EURUSD, @ 1.10901, V=0.01, P=1298021794

19:09:18.218  TTs  >>>     9

19:09:18.218  TTs  TRADE_TRANSACTION_DEAL_ADD, D=1280661967(DEAL_TYPE_SELL), »

                   #=1298022443(ORDER_TYPE_BUY/ORDER_STATE_STARTED), EURUSD, @ 1.10901, »

                   SL=1.09913, TP=1.11913, V=0.01, P=1298021794

19:09:18.218  TTs  >>>    10

19:09:18.218  TTs  TRADE_TRANSACTION_ORDER_DELETE, #=1298022443(ORDER_TYPE_SELL/ORDER_STATE_FILLED), »

                   EURUSD, @ 1.10901, P=1298021794

19:09:18.218  TTs  >>>    11

19:09:18.218  TTs  TRADE_TRANSACTION_HISTORY_ADD, #=1298022443(ORDER_TYPE_SELL/ORDER_STATE_FILLED), »

                   EURUSD, @ 1.10901, P=1298021794

19:09:18.218  TTs  >>>    12

19:09:18.218  TTs  TRADE_TRANSACTION_REQUEST

19:09:18.218  TTs  TRADE_ACTION_DEAL, EURUSD, ORDER_TYPE_SELL, V=0.01, ORDER_FILLING_FOK, @ 1.10901, »

                   D=10, #=1298022443, P=1298021794, M=1234567890

19:09:18.218  TTs  DONE, D=1280661967, #=1298022443, V=0.01, @ 1.10901, Bid=1.10901, Ask=1.10901, »

                   Req=11

19:09:18.218  OS1  >>>     1

19:09:18.218  OS1  TRADE_TRANSACTION_ORDER_ADD, #=1298021794(ORDER_TYPE_BUY/ORDER_STATE_STARTED), »

                   EURUSD, @ 1.10913, V=0.01

19:09:18.218  OS1  >>>     2

19:09:18.218  OS1  TRADE_TRANSACTION_DEAL_ADD, D=1280661362(DEAL_TYPE_BUY), »

                   #=1298021794(ORDER_TYPE_BUY/ORDER_STATE_STARTED), EURUSD, »

                   @ 1.10913, V=0.01, P=1298021794

19:09:18.218  OS1  >>>     3

19:09:18.218  OS1  TRADE_TRANSACTION_ORDER_DELETE, #=1298021794(ORDER_TYPE_BUY/ORDER_STATE_FILLED), »

                   EURUSD, @ 1.10913, P=1298021794

19:09:18.218  OS1  >>>     4

19:09:18.218  OS1  TRADE_TRANSACTION_HISTORY_ADD, #=1298021794(ORDER_TYPE_BUY/ORDER_STATE_FILLED), »

                   EURUSD, @ 1.10913, P=1298021794

19:09:18.218  OS1  >>>     5

19:09:18.218  OS1  TRADE_TRANSACTION_REQUEST

19:09:18.218  OS1  TRADE_ACTION_DEAL, EURUSD, ORDER_TYPE_BUY, V=0.01, ORDER_FILLING_FOK, @ 1.10913, »

                   D=10, #=1298021794, M=1234567890

19:09:18.218  OS1  DONE, D=1280661362, #=1298021794, V=0.01, @ 1.10913, Bid=1.10913, Ask=1.10913, »

                   Req=9

19:09:18.218  OS1  >>>     6

19:09:18.218  OS1  TRADE_TRANSACTION_POSITION, EURUSD, @ 1.10913, SL=1.09913, TP=1.11913, V=0.01, »

                   P=1298021794

19:09:18.218  OS1  >>>     7

19:09:18.218  OS1  TRADE_TRANSACTION_REQUEST

19:09:18.218  OS1  TRADE_ACTION_SLTP, EURUSD, ORDER_TYPE_BUY, V=0.01, ORDER_FILLING_FOK, »

                   SL=1.09913, TP=1.11913, D=10, P=1298021794, M=1234567890

19:09:18.218  OS1  DONE, Req=10

19:09:18.218  OS1  >>>     8

19:09:18.218  OS1  TRADE_TRANSACTION_ORDER_ADD, #=1298022443(ORDER_TYPE_SELL/ORDER_STATE_STARTED), »

                   EURUSD, @ 1.10901, V=0.01, P=1298021794

19:09:18.218  OS1  >>>     9

19:09:18.218  OS1  TRADE_TRANSACTION_DEAL_ADD, D=1280661967(DEAL_TYPE_SELL), »

                   #=1298022443(ORDER_TYPE_BUY/ORDER_STATE_STARTED), EURUSD, @ 1.10901, »

                   SL=1.09913, TP=1.11913, V=0.01, P=1298021794

19:09:18.218  OS1  >>>    10

19:09:18.218  OS1  TRADE_TRANSACTION_ORDER_DELETE, #=1298022443(ORDER_TYPE_SELL/ORDER_STATE_FILLED), »

                   EURUSD, @ 1.10901, P=1298021794

19:09:18.218  OS1  >>>    11

19:09:18.218  OS1  TRADE_TRANSACTION_HISTORY_ADD, #=1298022443(ORDER_TYPE_SELL/ORDER_STATE_FILLED), »

                   EURUSD, @ 1.10901, P=1298021794

19:09:18.218  OS1  >>>    12
19:09:18.218  OS1  TRADE_TRANSACTION_REQUEST
19:09:18.218  OS1  TRADE_ACTION_DEAL, EURUSD, ORDER_TYPE_SELL, V=0.01, ORDER_FILLING_FOK, @ 1.10901, »
                   D=10, #=1298022443, P=1298021794, M=1234567890
19:09:18.218  OS1  DONE, D=1280661967, #=1298022443, V=0.01, @ 1.10901, Bid=1.10901, Ask=1.10901, »
                   Req=11

程序中的事件编号是相同的(前提是按照建议全新启动它们)。请注意,同一个事件首先会在请求执行后立即从 TTs 打印出来,第二次则是在测试结束时才打印,此时实际上是将队列中的所有事件输出到 OS1 中。

如果我们去掉人为的延迟,脚本当然会运行得更快,但 OnTradeTransaction 处理程序仍然会在所有三个步骤完成后才(多次)收到通知,而不是在每个相应的请求之后。这有多关键呢?

现在的示例使用了我们对 MqlTradeRequestSync 结构体的修改版本,特意采用了同步选项 OrderSend,它还实现了一个通用的 completed 方法,用于检查请求是否成功完成。有了这个控制,我们就可以为一个头寸设置保护水平,因为我们知道如何等待其订单号出现。在这种同步概念(为了方便而采用)的框架内,我们不需要在 OnTradeTransaction 中分析查询结果。然而,情况并非总是如此。

当一个智能交易系统需要一次性发送多个请求时,就像在关于头寸属性的章节中讨论的设置订单网格的示例 PendingOrderGrid2.mq5 那样,等待每个头寸或订单 “就绪” 可能会降低智能交易系统的整体性能。在这种情况下,建议使用 OrderSendAsync 函数。但如果成功,它只会填充 MqlTradeResult 中的 request_id 字段,之后你需要使用这个字段在 OnTradeTransaction 中跟踪订单、交易和头寸的出现情况。

实现这个方案最明显但不是特别优雅的技巧之一,是在全局上下文中的数组中存储发送的请求的标识符或整个请求结构体。然后可以在 OnTradeTransaction 接收到的交易中查找这些标识符,在 MqlTradeResult 参数中找到订单号,并采取进一步的行动。结果,交易逻辑被分散到不同的函数中。例如,在最后一个智能交易系统 OrderSendTransaction1.mq5 的上下文中,这种 “分散化” 体现在发送第一个订单后,代码片段必须转移到 OnTradeTransaction 中,并检查以下内容:

  • MqlTradeTransaction 中的交易类型(transaction type);
  • MqlTradeRequest 中的请求类型(request action);
  • MqlTradeResult 中的请求 ID(result.request_id);

所有这些都应该补充特定的应用逻辑(例如,检查头寸是否存在),以便根据交易策略状态进行分支处理。稍后我们将对 OrderSendTransaction 智能交易系统进行类似的修改,使用不同的编号,以直观地展示额外的源代码量。然后我们将提供一种更线性地组织程序的方法,但不放弃事务事件。

目前,我们只需注意,开发者应该选择是围绕 OnTradeTransaction 构建算法,还是不使用它。在许多情况下,当不需要批量发送订单时,可以采用同步编程范式。然而,OnTradeTransaction 是控制挂单和保护水平触发以及服务器生成的其他事件的最实用方法。经过一些准备后,我们将展示两个相关的示例:网格智能交易系统的最终修改版本和流行的两个 OCO(One Cancels Other)订单设置的实现(见 “On Trade” 部分)。

OnTradeTransaction 的一种替代方法是定期分析交易环境,实际上就是记住订单和头寸的数量,并在其中寻找变化。这种方法适用于基于图表的策略或允许一定时间延迟的策略。

我们再次强调,使用 OnTradeTransaction 并不意味着程序必须从 OrderSend 切换到 OrderSendAsync:你可以使用其中一种,也可以两种都用。请记住,OrderSend 函数也不完全是同步的,因为它最多只能返回订单和交易的订单号,而不是头寸的订单号。很快我们将能够使用 OrderSendOrderSendAsync 这两个函数的变体,在同一个网格策略中测量一批订单的执行时间。

为了统一同步和异步程序的开发,最好在我们的 MqlTradeRequestSync 结构体(尽管它有这个名字)中支持 OrderSendAsync。这只需做几个修改就可以实现。首先,需要将所有现有的 OrderSend 调用替换为你自己的 orderSend 方法,并在其中根据一个标志切换调用 OrderSendOrderSendAsync

cpp
struct MqlTradeRequestSync: public MqlTradeRequest
{
   ...
   static bool AsyncEnabled;
   ...
private:
   bool orderSend(const MqlTradeRequest &req, MqlTradeResult &res)
   {
      return AsyncEnabled ? ::OrderSendAsync(req, res) : ::OrderSend(req, res);
   }
};

通过将公共变量 AsyncEnabled 设置为 truefalse,你可以在两种模式之间切换,例如,在批量发送订单的代码片段中。

其次,该结构体中那些返回订单号的方法(例如,用于进入市场的方法),应该返回 request_id 字段而不是 order。例如,在 _pending_market 方法内部,我们之前有以下语句:

cpp
if(OrderSend(this, result)) return result.order;

现在它被替换为:

cpp
if(orderSend(this, result)) return result.order ? result.order :
   (result.retcode == TRADE_RETCODE_PLACED ? result.request_id : 0);

当然,当启用异步模式时,我们不能再使用 completed 方法在发送请求后立即等待查询结果就绪。但这个方法基本上是可选的:即使在通过 OrderSend 工作时,你也可以直接去掉它。

因此,考虑到 MqlTradeSync.mqh 文件的新修改,让我们创建 OrderSendTransaction2.mq5

这个智能交易系统将像以前一样从 OnTimer 发送初始请求,同时在 OnTradeTransaction 中逐步设置保护水平和平仓。尽管这次我们在各阶段之间不会有人为的延迟,但状态序列本身对于许多智能交易系统来说是标准的:开仓、修改、平仓(如果满足某些市场条件,这里暂不考虑这些条件)。

两个全局变量将允许跟踪状态:RequestID 存储最后发送的请求的 ID(我们期望其结果),PositionTicket 存储开仓头寸的订单号。当头寸尚未出现或不再存在时,订单号为 0。

cpp
uint RequestID = 0;
ulong PositionTicket = 0;

OnInit 处理程序中启用异步模式。

cpp
int OnInit()
{
   ...
   MqlTradeRequestSync::AsyncEnabled = true;
   ...
}

现在 OnTimer 函数要短得多。

cpp
void OnTimer()
{
   ...
   // send a request TRADE_ACTION_DEAL (asynchronously!)
   const ulong order = (Type == MARKET_BUY ? request.buy(volume) : request.sell(volume));
   if(order) // in asynchronous mode this is now request_id
   {
      Print("OK Open?");
      RequestID = request.result.request_id; // same as order
   }
   else
   {
      Print("Failed Open");
   }
}

请求成功完成后,我们只得到 request_id 并将其存储在 RequestID 变量中。状态打印现在包含一个问号,如 “OK Open?”,因为实际结果还未知。

由于需要验证结果并根据条件执行后续交易订单,OnTradeTransaction 变得明显复杂了。让我们逐步分析它。

在这种情况下,整个交易逻辑都转移到了 TRADE_TRANSACTION_REQUEST 类型的交易分支中。当然,如果开发者愿意,也可以使用其他类型,但我们使用这种类型是因为它以熟悉的 MqlTradeResult 结构体的形式包含信息,也就是说,这有点像异步调用 OrderSendAsync 的延迟结束。

cpp
void OnTradeTransaction(const MqlTradeTransaction &transaction,
   const MqlTradeRequest &request,
   const MqlTradeResult &result)
{
   static ulong count = 0;
   PrintFormat(">>>% 6d", ++count);
   Print(TU::StringOf(transaction));
   
   if(transaction.type == TRADE_TRANSACTION_REQUEST)
   {
      Print(TU::StringOf(request));
      Print(TU::StringOf(result));
      
      ...
      // here is the whole algorithm
   }
}

我们只应该关注我们期望的 ID 的请求。所以下一个语句将是嵌套的 if。在其代码块中,我们提前描述 MqlTradeRequestSync 对象,因为按照计划,需要用它来发送常规交易请求。

cpp
      if(result.request_id == RequestID)
      {
         MqlTradeRequestSync next;
         next.magic = Magic;
         next.deviation = Deviation;
         ...
      }

我们只有两种有效的请求类型,所以为它们再添加一个嵌套的 if

cpp
         if(request.action == TRADE_ACTION_DEAL)
         {
            ... // here is the reaction to opening and closing a position
         }
         else if(request.action == TRADE_ACTION_SLTP)
         {
            ... // here is the reaction to setting SLTP for an open position
         }

请注意,TRADE_ACTION_DEAL 既用于开仓也用于平仓,因此需要再添加一个 if,在其中根据 PositionTicket 变量的值区分这两种状态。

cpp
            if(PositionTicket == 0)
            {
               ... // there is no position, so this is an opening notification 
            }
            else
            {
               ... // there is a position, so this is a closure
            }

在当前考虑的交易策略中,没有头寸增加(对于净额结算)或多个头寸(对于套期保值),所以这部分逻辑很简单。实际的智能交易系统将需要对中间状态进行更多不同的评估。

在收到开仓通知的情况下,代码块如下:

cpp
            if(PositionTicket == 0)
            {
               // trying to get results from the transaction: select an order by ticket
               if(!HistoryOrderSelect(result.order))
               {
                  Print("Can't select order in history");
                  RequestID = 0;
                  return;
               }
               // get position ID and ticket
               const ulong posid = HistoryOrderGetInteger(result.order, ORDER_POSITION_ID);
               PositionTicket = TU::PositionSelectById(posid);
               ...

为了简单起见,这里省略了错误和重新报价检查。你可以在附带的源代码中看到它们的处理示例。请记住,所有这些检查已经在 MqlTradeRequestSync 结构体的方法中实现,但它们只在同步模式下工作,因此我们必须明确地重复这些检查。

设置保护水平的下一个代码片段变化不大。

cpp
            if(PositionTicket == 0)
            {
               ...
               const double price = PositionGetDouble(POSITION_PRICE_OPEN);
               const double point = SymbolInfoDouble(_Symbol, SYMBOL_POINT);
               TU::TradeDirection dir((ENUM_ORDER_TYPE)Type);
               const double SL = dir.negative(price, Distance2SLTP * point);
               const double TP = dir.positive(price, Distance2SLTP * point);
               // sending TRADE_ACTION_SLTP request (asynchronously!)
               if(next.adjust(PositionTicket, SL, TP))
               {
                  Print("OK Adjust?");
                  RequestID = next.result.request_id;
               }
               else
               {
                  Print("Failed Adjust");
                  RequestID = 0;
               }
            }

这里唯一的区别是:我们用新的 TRADE_ACTION_SLTP 请求的 ID 填充 RequestID 变量。

收到非零 PositionTicket 的交易通知意味着头寸已经平仓。

cpp
            if(PositionTicket == 0)
            {
               ... // see above
            }
            else
            {
               if(!PositionSelectByTicket(PositionTicket))
               {
                  Print("Finish");
                  RequestID = 0;
                  PositionTicket = 0;
               }
            }

如果成功删除,就无法使用 PositionSelectByTicket 选择头寸,因此我们重置 RequestIDPositionTicket。然后智能交易系统返回其初始状态,准备进行下一次买入/卖出 - 修改 - 平仓循环。

我们还需要考虑发送平仓请求。在我们简化到极致的策略中,这在成功修改保护水平后立即进行。

cpp
         if(request.action == TRADE_ACTION_DEAL)
         {
            ... // see above
         }
         else if(request.action == TRADE_ACTION_SLTP)
         {
            // send a TRADE_ACTION_DEAL request to close (asynchronously!)
            if(next.close(PositionTicket))
            {
               Print("OK Close?");
               RequestID = next.result.request_id;
            }
            else
            {
               PrintFormat("Failed Close %lld", PositionTicket);
            }
         }

这就是整个 OnTradeTransaction 函数。智能交易系统就完成了。

让我们在 EURUSD 上使用默认设置运行 OrderSendTransaction2.mq5。以下是一个示例日志。

Start trade
OK Open?
>>>     1
TRADE_TRANSACTION_ORDER_ADD, #=1299508203(ORDER_TYPE_BUY/ORDER_STATE_STARTED), EURUSD, »
   » @ 1.10640, V=0.01
>>>     2
TRADE_TRANSACTION_DEAL_ADD, D=1282135720(DEAL_TYPE_BUY), »
   » #=1299508203(ORDER_TYPE_BUY/ORDER_STATE_STARTED), EURUSD, @ 1.10640, V=0.01, P=1299508203
>>>     3
TRADE_TRANSACTION_ORDER_DELETE, #=1299508203(ORDER_TYPE_BUY/ORDER_STATE_FILLED), EURUSD, »
   » @ 1.10640, P=1299508203
>>>     4
TRADE_TRANSACTION_HISTORY_ADD, #=1299508203(ORDER_TYPE_BUY/ORDER_STATE_FILLED), EURUSD, »
   » @ 1.10640, P=1299508203
>>>     5
TRADE_TRANSACTION_REQUEST
TRADE_ACTION_DEAL, EURUSD, ORDER_TYPE_BUY, V=0.01, ORDER_FILLING_FOK, @ 1.10640, D=10, »
   » #=1299508203, M=1234567890
DONE, D=1282135720, #=1299508203, V=0.01, @ 1.1064, Bid=1.1064, Ask=1.1064, Req=7
OK Adjust?
>>>     6
TRADE_TRANSACTION_POSITION, EURUSD, @ 1.10640, SL=1.09640, TP=1.11640, V=0.01, P=1299508203
>>>     7
TRADE_TRANSACTION_REQUEST
TRADE_ACTION_SLTP, EURUSD, ORDER_TYPE_BUY, V=0.01, ORDER_FILLING_FOK, SL=1.09640, TP=1.11640, »
   » D=10, P=1299508203, M=1234567890
DONE, Req=8
OK Close?
>>>     8
TRADE_TRANSACTION_ORDER_ADD, #=1299508215(ORDER_TYPE_SELL/ORDER_STATE_STARTED), EURUSD, »
   » @ 1.10638, V=0.01, P=1299508203
>>>     9
TRADE_TRANSACTION_ORDER_DELETE, #=1299508215(ORDER_TYPE_SELL/ORDER_STATE_FILLED), EURUSD, »
   » @ 1.10638, P=1299508203
>>>    10
TRADE_TRANSACTION_HISTORY_ADD, #=1299508215(ORDER_TYPE_SELL/ORDER_STATE_FILLED), EURUSD, »
   » @ 1.10638, P=1299508203
>>>    11
TRADE_TRANSACTION_DEAL_ADD, D=1282135730(DEAL_TYPE_SELL), »
   » #=1299508215(ORDER_TYPE_BUY/ORDER_STATE_STARTED), EURUSD, @ 1.10638, »
   » SL=1.09640, TP=1.11640, V=0.01, P=1299508203
>>>    12
TRADE_TRANSACTION_REQUEST
TRADE_ACTION_DEAL, EURUSD, ORDER_TYPE_SELL, V=0.01, ORDER_FILLING_FOK, @ 1.10638, D=10, »
   » #=1299508215, P=1299508203, M=1234567890
DONE, D=1282135730, #=1299508215, V=0.01, @ 1.10638, Bid=1.10638, Ask=1.10638, Req=9
Finish

交易逻辑按预期工作,交易事件严格在每个后续订单发送后到达。如果我们现在并行运行新的智能交易系统和交易拦截器 TradeTransactions.mq5,两个智能交易系统的日志消息将同步出现。

然而,从第一个直接版本 OrderSendTransaction1.mq5 重制为异步的第二个版本 OrderSendTransaction2.mq5 需要更复杂的代码。问题来了:是否可以以某种方式结合交易逻辑的顺序描述原则(代码的透明度)和并行处理(速度)呢?

从理论上讲,这是可能的,但这需要花费一些时间来创建某种辅助机制。

同步和异步请求

在深入探讨细节之前,我们先提醒一下,每个MQL程序都在其自身的线程中执行,因此,只有当另一个MQL程序来处理时,交易(以及其他事件)的并行异步处理才有可能实现。与此同时,必须确保程序之间的信息交换。我们已经知道了几种实现这一目的的方法:终端的全局变量和文件。在本书的第七部分,我们将探索其他功能,如图形资源和数据库。

实际上,想象一下,一个类似于TradeTransactions.mq5的智能交易系统与交易智能交易系统并行运行,并将接收到的交易信息(不一定是所有字段,而只是那些影响决策的关键字段)存储在全局变量中。然后,该智能交易系统可以在发送下一个请求后立即检查全局变量,并从中读取结果,而无需离开当前函数。此外,它不需要自己的OnTradeTransaction处理程序。

然而,组织第三方智能交易系统的运行并不容易。从技术角度来看,可以通过创建一个图表对象,并应用一个预定义的带有交易监控智能交易系统的模板来实现。但还有一种更简单的方法。关键在于,OnTradeTransaction事件不仅会传递给智能交易系统,也会传递给指标。反过来,指标是最容易启动的MQL程序类型:只需调用iCustom即可。

此外,使用指标还有一个好处:它可以描述一个指标缓冲区,外部程序可通过CopyBuffer访问该缓冲区,并在其中设置一个环形缓冲区,用于存储从终端传来的交易信息(请求结果)。这样一来,就无需处理全局变量了。

注意:在测试器中,指标不会生成OnTradeTransaction事件,因此,你只能在线检查智能交易系统与指标组合的运行情况。

我们将这个指标命名为TradeTransactionRelay.mq5,并在其中描述一个缓冲区。它可以设置为不可见,因为它写入的数据无法呈现,但为了验证这一概念,我们将其设置为可见。

c
#property indicator_chart_window
#property indicator_buffers 1
#property indicator_plots   1
   
double Buffer[];
   
void OnInit()
{
   SetIndexBuffer(0, Buffer, INDICATOR_DATA);
}

OnCalculate处理程序为空。

c
int OnCalculate(const int rates_total,
                const int prev_calculated,
                const int begin,
                const double &price[])
{
   return rates_total;
}

在代码中,我们需要一个现成的将double类型转换为ulong类型以及反向转换的转换器,因为如果使用简单的类型转换将大的ulong值写入缓冲区单元格,可能会导致数据损坏(请参阅“实数”部分)。

c
#include <MQL5Book/ConverterT.mqh>
Converter<ulong,double> cnv;

以下是OnTradeTransaction函数。

c
#defineFIELD_NUM6// the most important fields in MqlTradeResult
   
void OnTradeTransaction(const MqlTradeTransaction &transaction,
   const MqlTradeRequest &request,
   const MqlTradeResult &result)
{
   if(transaction.type == TRADE_TRANSACTION_REQUEST)
   {
      ArraySetAsSeries(Buffer, true);
      
      // store FIELD_NUM result fields into consecutive buffer cells
      const int offset = (int)((result.request_id * FIELD_NUM)
         % (Bars(_Symbol, _Period) / FIELD_NUM * FIELD_NUM));
      Buffer[offset + 1] = result.retcode;
      Buffer[offset + 2] = cnv[result.deal];
      Buffer[offset + 3] = cnv[result.order];
      Buffer[offset + 4] = result.volume;
      Buffer[offset + 5] = result.price;
      // this assignment must come last,
      // because it is the result ready flag
      Buffer[offset + 0] = result.request_id;
   }
}

我们决定只保留MqlTradeResult结构中最重要的六个字段。如果有需要,你可以将这个机制扩展到整个结构,但要传输字符串字段comment,你将需要一个字符数组,为此你必须预留相当多的元素。

这样,每个结果现在占用六个连续的缓冲区单元格。这六个单元格中第一个单元格的索引是根据请求ID确定的:这个数字简单地乘以6。由于可能会有很多请求,写入操作基于环形缓冲区的原理进行,即通过对指标缓冲区大小(将柱线数量向上取整到6的倍数)取余(' % ')来对得到的索引进行归一化。当请求编号超过缓冲区大小时,记录将从初始元素开始循环写入。

由于新柱线的形成会影响柱线的编号,建议将指标应用于较大的时间框架,如D1。这样,只有在一天开始时,才有可能(但可能性相当小)出现这样的情况:在处理下一笔交易时,指标中的柱线编号会直接发生偏移,然后指标记录的结果可能无法被智能交易系统读取(可能会错过一笔交易)。

指标已经准备好。现在,我们开始实现测试智能交易系统OrderSendTransaction3.mq5的一个新修改版本(太棒了,这是它的最终版本)。我们为指标句柄描述一个handle变量,并在OnInit中创建该指标。

c
int handle = 0;
   
int OnInit()
{
   ...
   const static string indicator = "MQL5Book/p6/TradeTransactionRelay";
   handle = iCustom(_Symbol, PERIOD_D1, indicator);
   if(handle == INVALID_HANDLE)
   {
      Alert("Can't start indicator ", indicator);
      return INIT_FAILED;
   }
   return INIT_SUCCEEDED;
}

为了从指标缓冲区读取查询结果,我们准备一个辅助函数AwaitAsync。它的第一个参数是对MqlTradeRequestSync结构的引用。如果成功,从带有句柄handle的指标缓冲区中获取的结果将写入这个结构中。我们感兴趣的请求的标识符应该已经在嵌套结构的result.request_id字段中。当然,在这里我们必须按照相同的原则读取数据,即读取六个柱线的数据。

c
#define FIELD_NUM   6  // the most important fields in MqlTradeResult
#define TIMEOUT  1000  // 1 second
   
bool AwaitAsync(MqlTradeRequestSync &r, const int _handle)
{
   Converter<ulong,double> cnv;
   const int offset = (int)((r.result.request_id * FIELD_NUM)
      % (Bars(_Symbol, _Period) / FIELD_NUM * FIELD_NUM));
   const uint start = GetTickCount();
   // wait for results or timeout
   while(!IsStopped() && GetTickCount() - start < TIMEOUT)
   {
      double array[];
      if((CopyBuffer(_handle, 0, offset, FIELD_NUM, array)) == FIELD_NUM)
      {
         ArraySetAsSeries(array, true);
         // when request_id is found, fill other fields with results
         if((uint)MathRound(array[0]) == r.result.request_id)
         {
            r.result.retcode = (uint)MathRound(array[1]);
            r.result.deal = cnv[array[2]];
            r.result.order = cnv[array[3]];
            r.result.volume = array[4];
            r.result.price = array[5];
            PrintFormat("Got Req=%d at %d ms",
               r.result.request_id, GetTickCount() - start);
            Print(TU::StringOf(r.result));
            return true;
         }
      }
   }
   Print("Timeout for: ");
   Print(TU::StringOf(r));
   return false;
}

现在我们有了这个函数,让我们以异步 - 同步的风格编写一个交易算法:作为一个直接的步骤序列,由于并行指标程序的通知,每一步都等待前一步准备就绪,同时仍在一个函数内部。

c
void OnTimer()
{
   EventKillTimer();
   
   MqlTradeRequestSync::AsyncEnabled = true;
   
   MqlTradeRequestSync request;
   request.magic = Magic;
   request.deviation = Deviation;
   
   const double volume = Volume == 0 ?
      SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN) : Volume;
   ...

步骤1.
   Print("Start trade");
   ResetLastError();
   if((bool)(Type == MARKET_BUY ? request.buy(volume) : request.sell(volume)))
   {
      Print("OK Open?");
   }
   
   if(!(AwaitAsync(request, handle) && request.completed()))
   {
      Print("Failed Open");
      return;
   }
   ...

步骤2.
   Print("SL/TP modification");
   ...
   if(request.adjust(SL, TP))
   {
      Print("OK Adjust?");
   }
   
   if(!(AwaitAsync(request, handle) && request.completed()))
   {
      Print("Failed Adjust");
   }

步骤3.
   Print("Close down");
   if(request.close(request.result.position))
   {
      Print("OK Close?");
   }
   
   if(!(AwaitAsync(request, handle) && request.completed()))
   {
      Print("Failed Close");
   }
   
   Print("Finish");
}

请注意,现在completed方法的调用不是在发送请求之后,而是在AwaitAsync函数接收到结果之后。

否则,这与该算法的第一个版本非常相似,但现在它是基于异步函数调用构建的,并对异步事件做出反应。

在这个对单个持仓进行一系列操作的特定示例中,这可能看起来并不重要。然而,我们可以使用相同的技术来发送和控制一批订单。到那时,其优势就会变得明显。稍后,我们将借助一个网格智能交易系统来演示这一点,同时比较两个函数的性能:OrderSend和OrderSendAsync。

但现在,当我们完成了OrderSendTransaction智能交易系统的系列开发后,让我们运行最新版本,并在日志中查看所有步骤的常规线性执行情况。

Start trade
OK Open?
Got Req=1 at 62 ms
DONE, D=1282677007, #=1300045365, V=0.01, @ 1.10564, Bid=1.10564, Ask=1.10564, Order placed, Req=1
Waiting for position for deal D=1282677007
SL/TP modification
OK Adjust?
Got Req=2 at 63 ms
DONE, Order placed, Req=2
Close down
OK Close?
Got Req=3 at 78 ms
DONE, D=1282677008, #=1300045366, V=0.01, @ 1.10564, Bid=1.10564, Ask=1.10564, Order placed, Req=3
Finish

响应延迟的时间很大程度上取决于服务器、一天中的时间以及交易品种。当然,这里的部分时间不是花在带有确认的交易请求上,而是花在CopyBuffer函数的执行上。根据我们的观察,它所花费的时间不超过16毫秒(在标准系统定时器的一个周期内,有兴趣的人可以使用高精度定时器GetMicrosecondCount对程序进行性能分析)。

忽略状态(DONE)和字符串描述(“Order placed”)之间的差异。事实上,注释(以及买价/卖价字段)从OrderSendAsync函数发送结构的那一刻起就保留在结构中,而retcode字段中的最终状态是由我们的AwaitAsync函数写入的。对我们来说重要的是,在结果结构中,单号(交易单号和订单单号)、执行价格(price)和交易量(volume)是最新的。

基于之前考虑的OrderSendTransaction3.mq5示例,让我们创建一个新的网格智能交易系统版本PendingOrderGrid3.mq5(之前的版本在“读取持仓属性的函数”部分提供)。它将能够根据用户的选择,以同步或异步模式设置完整的订单网格。我们还将检测设置完整网格的时间,以便进行比较。

模式由输入变量EnableAsyncSetup控制。为指标句柄分配handle变量。

c
input bool EnableAsyncSetup = false;
   
int handle;

在初始化过程中,如果是异步模式,我们创建一个TradeTransactionRelay指标的实例。

c
int OnInit()
{
   ...
   if(EnableAsyncSetup)
   {
      const uint start = GetTickCount();
      const static string indicator = "MQL5Book/p6/TradeTransactionRelay";
      handle = iCustom(_Symbol, PERIOD_D1, indicator);
      if(handle == INVALID_HANDLE)
      {
         Alert("Can't start indicator ", indicator);
         return INIT_FAILED;
      }
      PrintFormat("Started in %d ms", GetTickCount() - start);
   }
   ...
}

为了简化编码,我们在SetupGrid函数中用一维数组替换了二维请求数组。

c
uint SetupGrid()
{
   ...                                  // prev:
   MqlTradeRequestSyncLog request[];    // MqlTradeRequestSyncLog request[][2];
   ArrayResize(request, GridSize * 2);  // ArrayResize(request, GridSize);
   ...
}

在随后遍历数组的循环中,我们使用寻址request[i * 2 + 1],而不是request[i][1]类型的调用。

进行这个小的转换有以下原因。由于我们在创建网格时使用这个结构数组进行查询,并且我们需要等待所有结果,AwaitAsync函数现在应该将对数组的引用作为其第一个参数。一维数组更易于处理。

对于每个请求,根据其request_id计算它在指标缓冲区中的偏移量:所有偏移量都存储在offset数组中。当收到请求确认时,通过在数组的相应元素中写入-1来标记为已处理。已执行请求的数量在done变量中计数。当它等于数组大小时,整个网格就准备好了。

c
bool AwaitAsync(MqlTradeRequestSyncLog &r[], const int _handle)
{
   Converter<ulong,double> cnv;
   int offset[];
   const int n = ArraySize(r);
   int done = 0;
   ArrayResize(offset, n);
   
   for(int i = 0; i < n; ++i)
   {
      offset[i] = (int)((r[i].result.request_id * FIELD_NUM)
         % (Bars(_Symbol, _Period) / FIELD_NUM * FIELD_NUM));
   }
   
   const uint start = GetTickCount();
   while(!IsStopped() && done < n && GetTickCount() - start < TIMEOUT)
   for(int i = 0; i < n; ++i)
   {
      if(offset[i] == -1) continue; // skip empty elements
      double array[];
      if((CopyBuffer(_handle, 0, offset[i], FIELD_NUM, array)) == FIELD_NUM)
      {
         ArraySetAsSeries(array, true);
         if((uint)MathRound(array[0]) == r[i].result.request_id)
         {
            r[i].result.retcode = (uint)MathRound(array[1]);
            r[i].result.deal = cnv[array[2]];
            r[i].result.order = cnv[array[3]];
            r[i].result.volume = array[4];
            r[i].result.price = array[5];
            PrintFormat("Got Req=%d at %d ms", r[i].result.request_id,
               GetTickCount() - start);
            Print(TU::StringOf(r[i].result));
            offset[i] = -1; // mark processed
            done++;
         }
      }
   }
   return done == n;
}

回到SetupGrid函数,让我们看看在请求发送循环之后如何调用AwaitAsync。

c
uint SetupGrid()
{
   ...
   const uint start = GetTickCount();
   for(int i = 0; i < (int)GridSize / 2; ++i)
   {
      // calls of buyLimit/sellStopLimit/sellLimit/buyStopLimit
   }
   
   if(EnableAsyncSetup)
   {
      if(!AwaitAsync(request, handle))
      {
         Print("Timeout");
         return TRADE_RETCODE_ERROR;
      }
   }
   
   PrintFormat("Done %d requests in %d ms (%d ms/request)",
      GridSize * 2, GetTickCount() - start,
      (GetTickCount() - start) / (GridSize * 2));
   ...
}

如果在设置网格时发生超时(并非所有请求都能在规定时间内收到确认),我们将返回TRADE_RETCODE_ERROR代码,并且智能交易系统将尝试“回滚”它已经创建的内容。

需要注意的是,异步模式仅用于在我们需要发送一批请求时设置完整的网格。否则,仍将使用同步模式。因此,我们必须在发送循环之前将MqlTradeRequestSync::AsyncEnabled标志设置为true,并在之后将其设置回false。然而,请注意以下一点。循环内部可能会发生错误,导致循环提前终止,并返回服务器的最后一个代码。因此,如果我们在循环之后进行异步重置,不能保证它一定会被重置。

为了解决这个问题,在MqlTradeSync.mqh文件中添加了一个小的AsyncSwitcher类。该类从其构造函数和析构函数控制异步模式的启用和禁用。这与在“文件描述符管理”部分讨论的RAII(资源获取即初始化)资源管理概念一致。

c
class AsyncSwitcher
{
public:
   AsyncSwitcher(constbool enabled = true)
   {
      MqlTradeRequestSync::AsyncEnabled = enabled;
   }
   ~AsyncSwitcher()
   {
      MqlTradeRequestSync::AsyncEnabled = false;
   }
};

现在,为了安全地临时激活异步模式,我们只需在SetupGrid函数中描述局部的AsyncSwitcher对象。无论以何种方式退出该函数,代码都会自动恢复到同步模式。

```c
uint SetupGrid()
{
   ...
   AsyncSwitcher sync(EnableAsyncSetup);
   ...
   for(int i = 0; i < (int)GridSize / 2; ++i)
   {
      ...
   }
   ...
}

这个智能交易系统已经准备就绪。让我们尝试运行它两次:分别在同步和异步模式下,使用足够大的网格(10层,网格步长为200)。

对于10层的网格,我们将有20个请求,所以下面是一些日志内容。首先是使用同步模式的情况。需要说明的是,关于请求准备就绪的提示信息会显示在请求相关消息之前,因为后者是在函数退出时由结构的析构函数生成的。处理速度是每个请求51毫秒。

Start setup at 1.10379
Done 20 requests in 1030 ms (51 ms/request)
TRADE_ACTION_PENDING, EURUSD, ORDER_TYPE_BUY_LIMIT, V=0.01, ORDER_FILLING_FOK, @ 1.10200, »
   » ORDER_TIME_GTC, M=1234567890, G[1.10400]
DONE, #=1300978336, V=0.01, Request executed, Req=1
TRADE_ACTION_PENDING, EURUSD, ORDER_TYPE_SELL_STOP_LIMIT, V=0.01, ORDER_FILLING_FOK, @ 1.10200, »
   » X=1.10400, ORDER_TIME_GTC, M=1234567890, G[1.10400]
DONE, #=1300978337, V=0.01, Request executed, Req=2
TRADE_ACTION_PENDING, EURUSD, ORDER_TYPE_BUY_LIMIT, V=0.01, ORDER_FILLING_FOK, @ 1.10000, »
   » ORDER_TIME_GTC, M=1234567890, G[1.10400]
DONE, #=1300978343, V=0.01, Request executed, Req=5
TRADE_ACTION_PENDING, EURUSD, ORDER_TYPE_SELL_STOP_LIMIT, V=0.01, ORDER_FILLING_FOK, @ 1.10000, » 
   » X=1.10200, ORDER_TIME_GTC, M=1234567890, G[1.10400]
DONE, #=1300978344, V=0.01, Request executed, Req=6
TRADE_ACTION_PENDING, EURUSD, ORDER_TYPE_BUY_LIMIT, V=0.01, ORDER_FILLING_FOK, @ 1.09800, »
   » ORDER_TIME_GTC, M=1234567890, G[1.10400]
DONE, #=1300978348, V=0.01, Request executed, Req=9
TRADE_ACTION_PENDING, EURUSD, ORDER_TYPE_SELL_STOP_LIMIT, V=0.01, ORDER_FILLING_FOK, @ 1.09800, »
   » X=1.10000, ORDER_TIME_GTC, M=1234567890, G[1.10400]
DONE, #=1300978350, V=0.01, Request executed, Req=10
...
TRADE_ACTION_PENDING, EURUSD, ORDER_TYPE_SELL_LIMIT, V=0.01, ORDER_FILLING_FOK, @ 1.10600, »
   » ORDER_TIME_GTC, M=1234567890, G[1.10400]
DONE, #=1300978339, V=0.01, Request executed, Req=3
TRADE_ACTION_PENDING, EURUSD, ORDER_TYPE_BUY_STOP_LIMIT, V=0.01, ORDER_FILLING_FOK, @ 1.10600, »
   » X=1.10400, ORDER_TIME_GTC, M=1234567890, G[1.10400]
DONE, #=1300978340, V=0.01, Request executed, Req=4
TRADE_ACTION_PENDING, EURUSD, ORDER_TYPE_SELL_LIMIT, V=0.01, ORDER_FILLING_FOK, @ 1.10800, »
   » ORDER_TIME_GTC, M=1234567890, G[1.10400]
DONE, #=1300978345, V=0.01, Request executed, Req=7
TRADE_ACTION_PENDING, EURUSD, ORDER_TYPE_BUY_STOP_LIMIT, V=0.01, ORDER_FILLING_FOK, @ 1.10800, »
   » X=1.10600, ORDER_TIME_GTC, M=1234567890, G[1.10400]
DONE, #=1300978347, V=0.01, Request executed, Req=8
...
TRADE_ACTION_PENDING, EURUSD, ORDER_TYPE_SELL_LIMIT, V=0.01, ORDER_FILLING_FOK, @ 1.11400, »
   » ORDER_TIME_GTC, M=1234567890, G[1.10400]
DONE, #=1300978365, V=0.01, Request executed, Req=19
TRADE_ACTION_PENDING, EURUSD, ORDER_TYPE_BUY_STOP_LIMIT, V=0.01, ORDER_FILLING_FOK, @ 1.11400, »
   » X=1.11200, ORDER_TIME_GTC, M=1234567890, G[1.10400]
DONE, #=1300978366, V=0.01, Request executed, Req=20

网格的中间层与价格1.10400匹配。系统按照接收请求的顺序为请求分配编号,并且它们在数组中的编号与我们下单的顺序相对应:从中心基础层开始,我们逐渐向两侧扩展。因此,不要惊讶在一对1和2(对应1.10200层)之后是5和6(1.10000层),因为3和4(1.10600层)是更早发送的。

在异步模式下,析构函数之前会实时显示在AwaitAsync中接收到的特定请求准备就绪的消息,而且不一定是按照请求发送的顺序(例如,第49和50个请求“超过”了第47和48个请求)。

Started in 16 ms
Start setup at 1.10356
Got Req=41 at 109 ms
DONE, #=1300979180, V=0.01, Order placed, Req=41
Got Req=42 at 109 ms
DONE, #=1300979181, V=0.01, Order placed, Req=42
Got Req=43 at 125 ms
DONE, #=1300979182, V=0.01, Order placed, Req=43
Got Req=44 at 140 ms
DONE, #=1300979183, V=0.01, Order placed, Req=44
Got Req=45 at 156 ms
DONE, #=1300979184, V=0.01, Order placed, Req=45
Got Req=46 at 172 ms
DONE, #=1300979185, V=0.01, Order placed, Req=46
Got Req=49 at 172 ms
DONE, #=1300979188, V=0.01, Order placed, Req=49
Got Req=50 at 172 ms
DONE, #=1300979189, V=0.01, Order placed, Req=50
Got Req=47 at 172 ms
DONE, #=1300979186, V=0.01, Order placed, Req=47
Got Req=48 at 172 ms
DONE, #=1300979187, V=0.01, Order placed, Req=48
Got Req=51 at 172 ms
DONE, #=1300979190, V=0.01, Order placed, Req=51
Got Req=52 at 203 ms
DONE, #=1300979191, V=0.01, Order placed, Req=52
Got Req=55 at 203 ms
DONE, #=1300979194, V=0.01, Order placed, Req=55
Got Req=56 at 203 ms
DONE, #=1300979195, V=0.01, Order placed, Req=56
Got Req=53 at 203 ms
DONE, #=1300979192, V=0.01, Order placed, Req=53
Got Req=54 at 203 ms
DONE, #=1300979193, V=0.01, Order placed, Req=54
Got Req=57 at 218 ms
DONE, #=1300979196, V=0.01, Order placed, Req=57
Got Req=58 at 218 ms
DONE, #=1300979198, V=0.01, Order placed, Req=58
Got Req=59 at 218 ms
DONE, #=1300979199, V=0.01, Order placed, Req=59
Got Req=60 at 218 ms
DONE, #=1300979200, V=0.01, Order placed, Req=60
Done 20 requests in 234 ms (11 ms/request)
...

由于所有请求都是并行执行的,总发送时间(234毫秒)仅略多于单个请求的时间(这里大约是100毫秒,但具体时间会因情况而异)。结果是,我们得到了每个请求11毫秒的速度,这比同步方法快5倍。由于请求几乎是同时发送的,我们无法知道每个请求的执行时间,而这里的毫秒数表示从组发送开始的那一刻起,特定请求结果的到达时间。

进一步的日志,和之前的情况一样,包含了从结构析构函数中打印的所有查询和结果字段。在OrderSendAsync之后,“Order placed”这一行保持不变,因为我们的辅助指标TradeTransactionRelay.mq5不会完整发布TRADE_TRANSACTION_REQUEST消息中的MqlTradeResult结构。

...
TRADE_ACTION_PENDING, EURUSD, ORDER_TYPE_BUY_LIMIT, V=0.01, ORDER_FILLING_FOK, @ 1.10200, »
   » ORDER_TIME_GTC, M=1234567890, G[1.10400]
DONE, #=1300979180, V=0.01, Order placed, Req=41
TRADE_ACTION_PENDING, EURUSD, ORDER_TYPE_SELL_STOP_LIMIT, V=0.01, ORDER_FILLING_FOK, @ 1.10200, »
   » X=1.10400, ORDER_TIME_GTC, M=1234567890, G[1.10400]
DONE, #=1300979181, V=0.01, Order placed, Req=42
TRADE_ACTION_PENDING, EURUSD, ORDER_TYPE_BUY_LIMIT, V=0.01, ORDER_FILLING_FOK, @ 1.10000, »
   » ORDER_TIME_GTC, M=1234567890, G[1.10400]
DONE, #=1300979184, V=0.01, Order placed, Req=45
TRADE_ACTION_PENDING, EURUSD, ORDER_TYPE_SELL_STOP_LIMIT, V=0.01, ORDER_FILLING_FOK, @ 1.10000, »
   » X=1.10200, ORDER_TIME_GTC, M=1234567890, G[1.10400]
DONE, #=1300979185, V=0.01, Order placed, Req=46
TRADE_ACTION_PENDING, EURUSD, ORDER_TYPE_BUY_LIMIT, V=0.01, ORDER_FILLING_FOK, @ 1.09800, »
   » ORDER_TIME_GTC, M=1234567890, G[1.10400]
DONE, #=1300979188, V=0.01, Order placed, Req=49
TRADE_ACTION_PENDING, EURUSD, ORDER_TYPE_SELL_STOP_LIMIT, V=0.01, ORDER_FILLING_FOK, @ 1.09800, »
   » X=1.10000, ORDER_TIME_GTC, M=1234567890, G[1.10400]
DONE, #=1300979189, V=0.01, Order placed, Req=50
...
TRADE_ACTION_PENDING, EURUSD, ORDER_TYPE_SELL_LIMIT, V=0.01, ORDER_FILLING_FOK, @ 1.10600, »
   » ORDER_TIME_GTC, M=1234567890, G[1.10400]
DONE, #=1300979182, V=0.01, Order placed, Req=43
TRADE_ACTION_PENDING, EURUSD, ORDER_TYPE_BUY_STOP_LIMIT, V=0.01, ORDER_FILLING_FOK, @ 1.10600, »
   » X=1.10400, ORDER_TIME_GTC, M=1234567890, G[1.10400]
DONE, #=1300979183, V=0.01, Order placed, Req=44
TRADE_ACTION_PENDING, EURUSD, ORDER_TYPE_SELL_LIMIT, V=0.01, ORDER_FILLING_FOK, @ 1.10800, »
   » ORDER_TIME_GTC, M=1234567890, G[1.10400]
DONE, #=1300979186, V=0.01, Order placed, Req=47
TRADE_ACTION_PENDING, EURUSD, ORDER_TYPE_BUY_STOP_LIMIT, V=0.01, ORDER_FILLING_FOK, @ 1.10800, »
   » X=1.10600, ORDER_TIME_GTC, M=1234567890, G[1.10400]
DONE, #=1300979187, V=0.01, Order placed, Req=48
...
TRADE_ACTION_PENDING, EURUSD, ORDER_TYPE_SELL_LIMIT, V=0.01, ORDER_FILLING_FOK, @ 1.11400, »
   » ORDER_TIME_GTC
   » M=1234567890, G[1.10400]
DONE, #=1300979199, V=0.01, Order placed, Req=59
TRADE_ACTION_PENDING, EURUSD, ORDER_TYPE_BUY_STOP_LIMIT, V=0.01, ORDER_FILLING_FOK, @ 1.11400, »
   » X=1.11200, ORDER_TIME_GTC, M=1234567890, G[1.10400]
DONE, #=1300979200, V=0.01, Order placed, Req=60

到目前为止,我们的网格智能交易系统在每一层都有一对挂单:限价单和止损限价单。为了避免这种重复,我们只保留限价单。这将是 PendingOrderGrid4.mq5 的最终版本,它同样可以在同步和异步模式下运行。我们不会详细探讨其源代码,仅指出与之前版本的主要区别。

SetupGrid 函数中,我们需要一个大小等于 GridSize 的结构数组,而不是原来的两倍。请求的数量也将减少一半:仅使用 buyLimitsellLimit 方法。

CheckGrid 函数以不同的方式检查网格的完整性。之前,在有限价单的层级上没有配对的止损限价单会被视为错误。这种情况可能发生在服务器上相邻层级的止损限价单被触发时。然而,如果在一根K线上出现强烈的双向价格波动(尖峰),该方案无法恢复网格:它不仅会打掉原有的限价单,还会打掉由止损限价单生成的新订单。现在,该算法会诚实地检查当前价格两侧的空层级,并使用 RepairGridLevel 在这些位置创建限价单。这个辅助函数之前用于放置止损限价单。

最后,PendingOrderGrid4.mq5 中出现了 OnTradeTransaction 处理程序。挂单的触发会导致交易执行(并且需要修正网格配置),因此我们通过给定的交易品种和魔术数字来监控交易。当检测到交易时,会立即调用 CheckGrid 函数,此外,它仍然会在每根K线开始时执行。

c
void OnTradeTransaction(const MqlTradeTransaction &transaction,
   const MqlTradeRequest &,
   const MqlTradeResult &)
{
   if(transaction.type == TRADE_TRANSACTION_DEAL_ADD)
   {
      if(transaction.symbol == _Symbol)
      {
         DealMonitor dm(transaction.deal); // 选择交易
         if(dm.get(DEAL_MAGIC) == Magic)
         {
            CheckGrid();
         }
      }
   }
}

需要注意的是,仅依靠 OnTradeTransaction 事件并不足以编写能够抵御不可预见外部影响的智能交易系统。当然,事件可以让我们快速对情况做出反应,但我们无法保证智能交易系统不会因为某些原因在一段时间内被关闭(或离线),从而错过某些交易。因此,OnTradeTransaction 处理程序应该仅用于加速程序在没有它的情况下也能执行的流程。特别是,在启动后正确恢复其状态。

然而,除了 OnTradeTransaction 事件之外,MQL5 还提供了另一个更简单的事件:OnTrade

总结

上述内容围绕 MQL5 中交易请求的同步与异步处理展开,详细介绍了相关实现方法和代码示例。主要包括:

  1. 异步指标实现:通过 TradeTransactionRelay.mq5 指标将交易结果存储在指标缓冲区,实现交易结果的异步记录。
  2. 异步请求处理:在 OrderSendTransaction3.mq5 中,利用 AwaitAsync 函数从指标缓冲区读取请求结果,实现异步 - 同步风格的交易算法。
  3. 网格智能交易系统PendingOrderGrid3.mq5 可在同步或异步模式下设置订单网格,通过 AsyncSwitcher 类安全管理异步模式。
  4. 最终版本改进PendingOrderGrid4.mq5 简化订单类型,只保留限价单,并通过 OnTradeTransaction 处理程序监控交易并及时调整网格。

交易事件(OnTrade event)

当已挂单列表、未平仓头寸、订单历史记录以及交易历史记录发生变化时,会触发 OnTrade 事件。任何交易操作(挂单、激活或删除挂单、开仓或平仓、设置保护止损止盈水平等)都会相应地改变订单和交易的历史记录,以及头寸列表和当前订单列表。操作的发起者可以是用户、程序或服务器。

要在程序中接收该事件,你应该描述相应的处理函数。

cpp
void OnTrade(void)

在使用 OrderSendOrderSendAsync 发送交易请求的情况下,一个请求会触发多个 OnTrade 事件,因为处理过程通常分多个阶段进行,并且每个操作都可能改变订单、头寸和交易历史的状态。

一般来说,OnTrade 和 OnTradeTransaction 的调用次数没有确切的比例关系。OnTrade 是在相应的 OnTradeTransaction 调用之后被调用的。

由于 OnTrade 事件具有通用性,并且没有指定操作的本质,所以 MQL 程序的开发者对它的使用较少。通常需要在代码中检查交易账户状态的所有方面,并将其与某些已保存的状态进行比较,也就是与交易策略中使用的交易实体的应用缓存进行比较。在最简单的情况下,例如,你可以在 OnTrade 处理函数中记住已创建订单的单号,以便查询其所有属性。然而,这可能意味着要对大量与特定订单无关的偶然事件进行 “不必要” 的分析。

我们将在关于多货币智能交易系统(Expert Advisor)的部分中讨论交易环境和历史的应用缓存的可能性。

为了进一步探索 OnTrade,让我们研究一个实现基于两个 “二选一订单(OCO,One Cancels Other)” 挂单策略的智能交易系统。它将下达一对突破止损挂单,并等待其中一个挂单触发,然后移除第二个挂单。为了清晰起见,我们将同时支持两种类型的交易事件,即 OnTrade 和 OnTradeTransaction,这样工作逻辑将根据用户的选择从其中一个处理函数中运行。

源代码在 OCO2.mq5 文件中。它的输入参数包括手数大小 Volume(默认值为 0,表示最小手数)、用于设置每个订单的点数距离 Distance2SLTP,它还用于确定保护止损止盈水平、从设置时间开始的到期时间 Expiration(以秒为单位)以及事件切换器 ActivationBy(默认值为 OnTradeTransaction)。由于 Distance2SLTP 既设置了与当前价格的偏移量,又设置了到止损位的距离,所以两个订单的止损位是相同的,并且等于设置时的价格。

cpp
enum EVENT_TYPE
{
   ON_TRANSACTION, // OnTradeTransaction
   ON_TRADE        // OnTrade
};

input double Volume;            // Volume (0 - minimal lot)
input uint Distance2SLTP = 500; // Distance Indent/SL/TP (points)
input ulong Magic = 1234567890;
input ulong Deviation = 10;
input ulong Expiration = 0;     // Expiration (seconds in future, 3600 - 1 hour, etc)
input EVENT_TYPE ActivationBy = ON_TRANSACTION;

为了简化请求结构的初始化,我们将描述自己的 MqlTradeRequestSyncOCO 结构体,它派生自 MqlTradeRequestSync

cpp
struct MqlTradeRequestSyncOCO: public MqlTradeRequestSync
{
   MqlTradeRequestSyncOCO()
   {
      symbol = _Symbol;
      magic = Magic;
      deviation = Deviation;
      if(Expiration > 0)
      {
         type_time = ORDER_TIME_SPECIFIED;
         expiration = (datetime)(TimeCurrent() + Expiration);
      }
   }
};

在全局层面,我们引入几个对象和变量。

cpp
OrderFilter orders;        // object for selecting orders
PositionFilter trades;     // object for selecting positions
bool FirstTick = false;    // or single processing of OnTick at start
ulong ExecutionCount = 0;  // counter of trading strategy calls RunStrategy()

除了开始时刻之外,所有的交易逻辑都将由交易事件触发。在 OnInit 处理函数中,我们设置过滤对象并等待第一个报价(将 FirstTick 设置为 true)。

cpp
int OnInit()
{
   FirstTick = true;
   
   orders.let(ORDER_MAGIC, Magic).let(ORDER_SYMBOL, _Symbol)
      .let(ORDER_TYPE, (1 << ORDER_TYPE_BUY_STOP) | (1 << ORDER_TYPE_SELL_STOP),
      IS::OR_BITWISE);
   trades.let(POSITION_MAGIC, Magic).let(POSITION_SYMBOL, _Symbol);
      
   return INIT_SUCCEEDED;
}

我们只对止损订单(买入/卖出)以及具有特定魔术数字和当前交易品种的头寸感兴趣。

OnTick 函数中,我们只调用一次设计为 RunStrategy 的算法的主要部分(我们将在下面描述它)。此后,这个函数将只从 OnTrade 或 OnTradeTransaction 中被调用。

cpp
void OnTick()
{
   if(FirstTick)
   {
      RunStrategy();
      FirstTick = false;
   }
}

例如,当启用 OnTrade 模式时,这个片段会起作用。

cpp
void OnTrade()
{
   static ulong count = 0;
   PrintFormat("OnTrade(%d)", ++count);
   if(ActivationBy == ON_TRADE)
   {
      RunStrategy();
   }
}

请注意,无论这里是否激活了策略,都会对 OnTrade 处理函数的调用次数进行计数。同样,在 OnTradeTransaction 处理函数中也会对相关事件进行计数(即使它们是无意义地发生)。这样做是为了能够在日志中同时看到这两个事件及其计数器。

当 OnTradeTransaction 模式开启时,显然,RunStrategy 从那里开始运行。

cpp
void OnTradeTransaction(const MqlTradeTransaction &transaction,
   const MqlTradeRequest &request,
   const MqlTradeResult &result)
{
   static ulong count = 0;
   PrintFormat("OnTradeTransaction(%d)", ++count);
   Print(TU::StringOf(transaction));
   
   if(ActivationBy != ON_TRANSACTION) return;
   
   if(transaction.type == TRADE_TRANSACTION_ORDER_DELETE)
   {
      // why not here? for answer, see the text
      /* // this won't work online: m.isReady() == false because order temporarily lost
      OrderMonitor m(transaction.order);
      if(m.isReady() && m.get(ORDER_MAGIC) == Magic && m.get(ORDER_SYMBOL) == _Symbol)
      {
         RunStrategy();
      }
      */
   }
   else if(transaction.type == TRADE_TRANSACTION_HISTORY_ADD)
   {
      OrderMonitor m(transaction.order);
      if(m.isReady() && m.get(ORDER_MAGIC) == Magic && m.get(ORDER_SYMBOL) == _Symbol)
      {
         // the ORDER_STATE property does not matter - in any case, you need to remove the remaining
         // if(transaction.order_state == ORDER_STATE_FILLED
         // || transaction.order_state == ORDER_STATE_CANCELED ...)
         RunStrategy();
      }
   }
}

应该注意的是,在实盘交易中,一个触发的挂单可能会在一段时间内从交易环境中消失,因为它从现有订单转移到了历史记录中。当我们收到 TRADE_TRANSACTION_ORDER_DELETE 事件时,该订单已经从活动列表中移除,但尚未出现在历史记录中。只有当我们收到 TRADE_TRANSACTION_HISTORY_ADD 事件时,它才会出现在历史记录中。在测试器中不会出现这种情况,也就是说,一个被删除的订单会立即被添加到历史记录中,并且在 TRADE_TRANSACTION_ORDER_DELETE 阶段就可以在那里进行选择和读取属性。

在两个交易事件处理函数中,我们都对调用次数进行计数并记录到日志中。对于 OnTrade 的情况,它必须与我们很快会在 RunStrategy 内部看到的 ExecutionCount 相匹配。但是对于 OnTradeTransaction,计数器和 ExecutionCount 会有很大差异,因为这里的策略是针对一种类型的事件有选择地调用的。基于此,我们可以得出结论,OnTradeTransaction 通过仅在适当的时候调用算法,能够更有效地利用资源。

当智能交易系统卸载时,ExecutionCount 计数器会输出到日志中。

cpp
void OnDeinit(const int r)
{
   Print("ExecutionCount = ", ExecutionCount);
}

现在,最后,让我们引入 RunStrategy 函数。承诺的计数器在一开始就会递增。

cpp
void RunStrategy()
{
   ExecutionCount++;
   ...

   ulong tickets[];
   ulong states[];

   orders.select(ORDER_STATE, tickets, states);
   const int n = ArraySize(tickets);
   if(n == 2) return; // OK - standard state
   ...

   if(n > 0)          // 1 or 2+ orders is an error, you need to delete everything
   {
      // delete all matching orders, except for partially filled ones
      MqlTradeRequestSyncOCO r;
      for(int i = 0; i < n; ++i)
      {
         if(states[i] != ORDER_STATE_PARTIAL)
         {
            r.remove(tickets[i]) && r.completed();
         }
      }
   }
   ...

   else // n == 0
   {
      // if there are no open positions, place 2 orders
      if(!trades.select(tickets))
      {
         MqlTradeRequestSyncOCO r;
         SymbolMonitor sm(_Symbol);
         
         const double point = sm.get(SYMBOL_POINT);
         const double lot = Volume == 0 ? sm.get(SYMBOL_VOLUME_MIN) : Volume;
         const double buy = sm.get(SYMBOL_BID) + point * Distance2SLTP;
         const double sell = sm.get(SYMBOL_BID) - point * Distance2SLTP;
         
         r.buyStop(lot, buy, buy - Distance2SLTP * point,
            buy + Distance2SLTP * point) && r.completed();
         r.sellStop(lot, sell, sell + Distance2SLTP * point,
            sell - Distance2SLTP * point) && r.completed();
      }
   }
}

让我们在测试器中使用默认设置,对欧元兑美元(EURUSD)交易品种运行这个智能交易系统。下面的图片展示了测试过程。

基于 OCO 策略的带有一对挂单止损订单的智能交易系统在测试器中

在下达一对订单的阶段,我们会在日志中看到以下记录。

buy stop 0.01 EURUSD at 1.11151 sl: 1.10651 tp: 1.11651 (1.10646 / 1.10683)

sell stop 0.01 EURUSD at 1.10151 sl: 1.10651 tp: 1.09651 (1.10646 / 1.10683)

OnTradeTransaction(1)

TRADE_TRANSACTION_ORDER_ADD, #=2(ORDER_TYPE_BUY_STOP/ORDER_STATE_PLACED), ORDER_TIME_GTC, EURUSD, »

   » @ 1.11151, SL=1.10651, TP=1.11651, V=0.01

OnTrade(1)

OnTradeTransaction(2)

TRADE_TRANSACTION_REQUEST

OnTradeTransaction(3)

TRADE_TRANSACTION_ORDER_ADD, #=3(ORDER_TYPE_SELL_STOP/ORDER_STATE_PLACED), ORDER_TIME_GTC, EURUSD, »

   » @ 1.10151, SL=1.10651, TP=1.09651, V=0.01

OnTrade(2)

OnTradeTransaction(4)

TRADE_TRANSACTION_REQUEST

一旦其中一个订单被触发,就会发生以下情况:

order [#3 sell stop 0.01 EURUSD at 1.10151] triggered

deal #2 sell 0.01 EURUSD at 1.10150 done (based on order #3)

deal performed [#2 sell 0.01 EURUSD at 1.10150]

order performed sell 0.01 at 1.10150 [#3 sell stop 0.01 EURUSD at 1.10151]

OnTradeTransaction(5)

TRADE_TRANSACTION_DEAL_ADD, D=2(DEAL_TYPE_SELL), #=3(ORDER_TYPE_BUY/ORDER_STATE_STARTED), »

   » EURUSD, @ 1.10150, SL=1.10651, TP=1.09651, V=0.01, P=3

OnTrade(3)

OnTradeTransaction(6)

TRADE_TRANSACTION_ORDER_DELETE, #=3(ORDER_TYPE_SELL_STOP/ORDER_STATE_FILLED), ORDER_TIME_GTC, »

   » EURUSD, @ 1.10151, SL=1.10651, TP=1.09651, V=0.01, P=3

OnTrade(4)

OnTradeTransaction(7)

TRADE_TRANSACTION_HISTORY_ADD, #=3(ORDER_TYPE_SELL_STOP/ORDER_STATE_FILLED), ORDER_TIME_GTC, »

   » EURUSD, @ 1.10151, SL=1.10651, TP=1.09651, P=3

order canceled [#2 buy stop 0.01 EURUSD at 1.11151]

OnTrade(5)

OnTradeTransaction(8)

TRADE_TRANSACTION_ORDER_DELETE, #=2(ORDER_TYPE_BUY_STOP/ORDER_STATE_CANCELED), ORDER_TIME_GTC, »

   » EURUSD, @ 1.11151, SL=1.10651, TP=1.11651, V=0.01

OnTrade(6)

OnTradeTransaction(9)

TRADE_TRANSACTION_HISTORY_ADD, #=2(ORDER_TYPE_BUY_STOP/ORDER_STATE_CANCELED), ORDER_TIME_GTC, »

   » EURUSD, @ 1.11151, SL=1.10651, TP=1.11651, V=0.01

OnTrade(7)

OnTradeTransaction(10)

TRADE_TRANSACTION_REQUEST

订单 #3 自行被删除,订单 #2 被我们的智能交易系统删除(取消)。

如果我们运行智能交易系统时,仅在设置中更改通过 OnTrade 事件运行的模式,在其他条件相同的情况下(例如,如果不包括报价生成中的随机延迟),我们应该得到完全相似的财务结果。唯一不同的是 RunStrategy 函数的调用次数。例如,对于 2022 年 4 个月的欧元兑美元(EURUSD)、1 小时时间框架(H1)以及 88 笔交易,我们将得到以下关于 ExecutionCount 的大致指标(重要的是比例关系,而不是与你的经纪商报价相关的绝对值):

OnTradeTransaction — 132 OnTrade — 438

这是一个实际证明,与 OnTrade 相比,基于 OnTradeTransaction 可以构建更具选择性的算法。

这个 OCO2.mq5 版本的智能交易系统对订单和头寸的操作反应相当直接。特别是,一旦之前的头寸因止损或止盈而平仓,它将下达两个新订单。如果你手动删除其中一个订单,智能交易系统将立即删除第二个订单,然后根据当前价格偏移重新创建一对新订单。你可以通过嵌入类似于网格智能交易系统中的时间表来改进其行为,并且不对历史记录中已取消的订单做出反应(不过请注意,MQL5 没有提供方法来查明一个订单是手动取消还是通过程序取消的)。当我们探索经济日历 API 时,我们将介绍改进这个智能交易系统的另一个方向。

此外,当前版本中已经有一个有趣的模式,与在输入变量 Expiration 中设置挂单的到期时间有关。如果一对订单没有触发,那么在它们到期后,会立即根据变化后的新当前价格下达一对新订单。作为一个独立的练习,你可以尝试在测试器中通过更改 ExpirationDistance2SLTP 来优化这个智能交易系统。关于测试器的编程工作,包括优化模式下的工作,将在下一章中介绍。

下面是从 2021 年初开始的 16 个月期间,针对欧元兑美元(EURUSD)交易品种找到的一种设置选项(Distance2SLTP=250Expiration=5000)。

监控交易环境变化

在上一节关于 OnTrade 事件的内容中,我们提到一些交易策略编程方法可能要求对交易环境进行快照,并随着时间推移对这些快照进行比较。这在使用 OnTrade 时是一种常见做法,不过也可以按计划、在每个K线周期甚至每个报价点变动时触发该操作。我们之前的监控类能够读取订单、交易和仓位的属性,但缺乏保存状态的能力。在本节中,我们将介绍一种交易环境缓存的方案。

所有交易对象的属性按类型可分为三组:整数型、实数型和字符串型。每个对象类都有各自的属性组(例如,对于订单,整数型属性在 ENUM_ORDER_PROPERTY_INTEGER 枚举中描述,对于仓位则在 ENUM_POSITION_PROPERTY_INTEGER 枚举中描述),但分类的本质是相同的。因此,我们将引入 PROP_TYPE 枚举,借助它可以描述对象属性所属的类型。这种归纳是很自然的,因为无论属性属于订单、仓位还是交易,存储和处理相同类型属性的机制应该是相同的。

c
enum PROP_TYPE
{
   PROP_TYPE_INTEGER,
   PROP_TYPE_DOUBLE,
   PROP_TYPE_STRING,
};

数组是存储属性值的最简单方式。显然,由于存在三种基本类型,我们需要三个不同的数组。我们在 MonitorInterface 中嵌套的新类 TradeState 里(TradeBaseMonitor.mqh)对它们进行描述。

基本模板 MonitorInterface<I,D,S> 构成了所有应用监控类(OrderMonitorDealMonitorPositionMonitor)的基础。这里的类型 IDS 分别对应整数型、实数型和字符串型属性的具体枚举。

将存储机制包含在基础监控类中是很合理的,特别是因为创建的属性缓存将通过从监控对象读取属性来填充数据。

c
template<typename I,typename D,typename S>
class MonitorInterface
{
   ...
   class TradeState
   {
   public:
      ...
      long ulongs[];
      double doubles[];
      string strings[];
      const MonitorInterface *owner;
      
      TradeState(const MonitorInterface *ptr) : owner(ptr)
      {
         ...
      }
   };

整个 TradeState 类被设为公有,因为需要从父监控对象(作为指针传递给构造函数)访问其字段,而且 TradeState 只会在监控类的保护部分使用(外部无法访问)。

为了用三种不同类型的属性值填充三个数组,首先必须确定属性在每种特定数组中的类型分布和索引。

对于每种交易对象类型(订单、交易和仓位),具有不同类型属性的三个相应枚举的标识符互不相交,并形成连续的编号。下面来展示一下。

在“枚举”章节中,我们研究了 ConversionEnum.mq5 脚本,它实现了 process 函数来记录特定枚举的所有元素。该脚本检查了 ENUM_APPLIED_PRICE 枚举。现在我们可以创建该脚本的副本,并分析另外三个枚举。例如:

c
void OnStart()
{
   process((ENUM_POSITION_PROPERTY_INTEGER)0);
   process((ENUM_POSITION_PROPERTY_DOUBLE)0);
   process((ENUM_POSITION_PROPERTY_STRING)0);
}

执行该脚本后,我们得到以下日志。左列是枚举内部的编号,右边(等号后面)的值是元素的内置常量(标识符)。

ENUM_POSITION_PROPERTY_INTEGER Count=9
0 POSITION_TIME=1
1 POSITION_TYPE=2
2 POSITION_MAGIC=12
3 POSITION_IDENTIFIER=13
4 POSITION_TIME_MSC=14
5 POSITION_TIME_UPDATE=15
6 POSITION_TIME_UPDATE_MSC=16
7 POSITION_TICKET=17
8 POSITION_REASON=18
ENUM_POSITION_PROPERTY_DOUBLE Count=8
0 POSITION_VOLUME=3
1 POSITION_PRICE_OPEN=4
2 POSITION_PRICE_CURRENT=5
3 POSITION_SL=6
4 POSITION_TP=7
5 POSITION_COMMISSION=8
6 POSITION_SWAP=9
7 POSITION_PROFIT=10
ENUM_POSITION_PROPERTY_STRING Count=3
0 POSITION_SYMBOL=0
1 POSITION_COMMENT=11
2 POSITION_EXTERNAL_ID=19

例如,常量为 0 的属性是字符串类型的 POSITION_SYMBOL,常量为 12 的属性是整数类型的 POSITION_TIMEPOSITION_TYPE,常量为 3 的属性是实数类型的 POSITION_VOLUME,以此类推。

因此,常量是所有类型属性的端到端索引系统,我们可以使用相同的算法(基于 EnumToArray.mqh)来获取它们。

对于每个属性,需要记住它的类型(决定了三个数组中的哪一个将存储该值)以及在相同类型属性中的序号(这将是相应数组中元素的索引)。例如,我们看到仓位只有 3 个字符串属性,所以一个仓位快照中的 strings 数组必须具有相同的大小,并且 POSITION_SYMBOL0)、POSITION_COMMENT11)和 POSITION_EXTERNAL_ID19)将被写入其索引 012 处。

属性的端到端索引转换为其类型(PROP_TYPE 之一)以及在相应类型数组中的序号,可以在程序启动时进行一次,因为带有属性的枚举是常量(内置于系统中)。我们将得到的间接寻址表写入一个静态二维索引数组中。其第一维的大小将动态确定为属性的总数(所有三种类型)。我们将大小写入 limit 静态变量中。为第二维分配了两个单元格:indices[i][0] 表示 PROP_TYPE 类型,indices[i][1] 表示 ulongsdoublesstrings 数组中的索引(取决于 indices[i][0])。

c
   class TradeState
   {
      ...
      static int indices[][2];
      static int j, d, s;
   public:
      const static int limit;
      
      static PROP_TYPE type(const int i)
      {
         return (PROP_TYPE)indices[i][0];
      }
      
      static int offset(const int i)
      {
         return indices[i][1];
      }
      ...

变量 jds 将用于按顺序索引三种不同类型属性中的每一种。在静态方法 calcIndices 中是这样实现的:

c
      static int calcIndices()
      {
         const int size = fmax(boundary<I>(),
            fmax(boundary<D>(), boundary<S>())) + 1;
         ArrayResize(indices, size);
         j = d = s = 0;
         for(int i = 0; i < size; ++i)
         {
            if(detect<I>(i))
            {
               indices[i][0] = PROP_TYPE_INTEGER;
               indices[i][1] = j++;
            }
            else if(detect<D>(i))
            {
               indices[i][0] = PROP_TYPE_DOUBLE;
               indices[i][1] = d++;
            }
            else if(detect<S>(i))
            {
               indices[i][0] = PROP_TYPE_STRING;
               indices[i][1] = s++;
            }
            else
            {
               Print("Unresolved int value as enum: ", i, " ", typename(TradeState));
            }
         }
         return size;
      }

boundary 方法返回给定枚举 E 中所有元素的最大常量。

c
   template<typename E>
   static int boundary(const E dummy = (E)NULL)
   {
      int values[];
      const int n = EnumToArray(dummy, values, 0, 1000);
      ArraySort(values);
      return values[n - 1];
   }

三种类型枚举的最大值决定了应根据其所属属性类型进行排序的整数范围。

这里我们使用 detect 方法,如果整数是枚举的一个元素,则该方法返回 true

c
   template<typename E>
   static bool detect(const int v)
   {
      ResetLastError();
      const string s = EnumToString((E)v); // 结果未使用
      if(_LastError == 0) // 仅错误不存在才重要
      {
         return true;
      }
      return false;
   }

最后一个问题是如何在程序启动时运行此计算。这是通过利用变量和方法的静态特性来实现的。

c
template<typename I,typename D,typename S>
static int MonitorInterface::TradeState::indices[][2];
template<typename I,typename D,typename S>
static int MonitorInterface::TradeState::j,
   MonitorInterface::TradeState::d,
   MonitorInterface::TradeState::s;
template<typename I,typename D,typename S>
const static int MonitorInterface::TradeState::limit =
   MonitorInterface::TradeState::calcIndices();

请注意,limit 是通过调用我们的 calcIndices 函数的结果进行初始化的。

有了索引表后,我们在 cache 方法中实现用属性值填充数组的操作。

c
   class TradeState
   {
      ...
      TradeState(const MonitorInterface *ptr) : owner(ptr)
      {
         cache(); // 创建对象时,立即缓存属性
      }
      
      template<typename T>
      void _get(const int e, T &value) const // 按引用记录的重载
      {
         value = owner.get(e, value);
      }
      
      void cache()
      {
         ArrayResize(ulongs, j);
         ArrayResize(doubles, d);
         ArrayResize(strings, s);
         for(int i = 0; i < limit; ++i)
         {
            switch(indices[i][0])
            {
            case PROP_TYPE_INTEGER: _get(i, ulongs[indices[i][1]]); break;
            case PROP_TYPE_DOUBLE: _get(i, doubles[indices[i][1]]); break;
            case PROP_TYPE_STRING: _get(i, strings[indices[i][1]]); break;
            }
         }
      }
   };

我们遍历从 0limit 的整个属性范围,并根据 indices[i][0] 中的属性类型,将其值写入 ulongsdoublesstrings 数组中序号为 indices[i][1] 的元素(相应数组元素通过引用传递给 _get 方法)。

owner.get(e, value) 的调用引用了监控类的标准方法之一(在这里它作为抽象指针 MonitorInterface 可见)。特别是对于 PositionMonitor 类中的仓位,这将导致调用 PositionGetIntegerPositionGetDoublePositionGetString。编译器将选择正确的类型。订单和交易监控类也有类似的实现,这些实现会被此基础代码自动包含。

从监控类继承一个交易对象快照的描述是合理的。由于我们必须缓存订单、交易和仓位,因此将新类设为模板并在其中收集适用于所有对象的通用算法是有意义的。我们将其称为 TradeBaseState(文件 TradeState.mqh)。

c
template<typename M,typename I,typename D,typename S>
class TradeBaseState: public M
{
   M::TradeState state;
   bool cached;
   
public:
   TradeBaseState(const ulong t) : M(t), state(&this), cached(ready)
   {
   }
   
   void passthrough(const bool b)   // 根据需要启用/禁用缓存
   {
      cached = b;
   }
   ...

前面描述的特定监控类之一隐藏在字母 M 后面(OrderMonitor.mqhPositionMonitor.mqhDealMonitor.mqh)。基础是新引入的 M::TradeState 类的状态缓存对象。根据 M 的不同,内部将形成特定的索引表(每个 M 类一个),并且属性数组将被分配(每个 M 实例都有自己的数组,即每个订单、交易、仓位)。

cached 变量包含一个标志,用于指示 state 中的数组是否填充了属性值,以及是否要查询对象的属性以从缓存中返回值。这在以后比较保存的状态和当前状态时是必需的。

换句话说,当 cached 设置为 false 时,该对象的行为将像常规监控类一样,从交易环境中读取属性。当 cached 等于 true 时,该对象将从内部数组返回先前存储的值。

c
   virtual long get(const I property) const override
   {
      return cached ? state.ulongs[M::TradeState::offset(property)] : M::get(property);
   }
   
   virtual double get(const D property) const override
   {
      return cached ? state.doubles[M::TradeState::offset(property)] : M::get(property);
   }
   
   virtual string get(const S property) const override
   {
      return cached ? state.strings[M::TradeState::offset(property)] : M::get(property);
   }
   ...

默认情况下,当然是启用缓存的。

我们还必须提供一个直接执行缓存(填充数组)的方法。为此,只需为 state 对象调用 cache 方法。

c
   bool update()
   {
      if(refresh())
      {
         cached = false; // 禁用从缓存中读取
         state.cache();  // 读取实际属性并写入缓存
         cached = true;  // 重新启用对外部缓存的访问
         return true;
      }
      return false;
   }

那么 refresh 方法是什么呢?

到目前为止,我们一直以简单模式使用监控对象:创建、读取属性并删除它们。同时,属性读取假定在交易上下文中(在构造函数内部)选择了相应的订单、交易或仓位。由于我们现在正在改进监控类以支持内部状态,因此有必要确保即使在不确定的时间之后,也能重新分配所需的元素以读取属性(当然,要检查该元素是否仍然存在)。为了实现这一点,我们在模板 MonitorInterface 类中添加了 refresh 虚拟方法。

c
// TradeBaseMonitor.mqh
template<typename I,typename D,typename S>
class MonitorInterface
{
   ...
   virtual bool refresh() = 0;

当成功分配订单、交易或仓位时,它必须返回 true。如果结果为 false,则内置的 _LastError 变量中应包含以下错误之一:

  • 4753 ERR_TRADE_POSITION_NOT_FOUND
  • 4754 ERR_TRADE_ORDER_NOT_FOUND
  • 4755 ERR_TRADE_DEAL_NOT_FOUND

在这种情况下,在派生类中实现此方法时,用于指示对象可用性的 ready 成员变量必须重置为 false

例如,在 PositionMonitor 构造函数中,我们有且仍然有这样的初始化。订单和交易监控类的情况类似。

c
// PositionMonitor.mqh
   const ulong ticket;
   PositionMonitor(const ulong t): ticket(t)
   {
      if(!PositionSelectByTicket(ticket))
      {
         PrintFormat("Error: PositionSelectByTicket(%lld) failed: %s", ticket,
            E2S(_LastError));
      }
      else
      {
         ready = true;
      }
   }
   ...

现在我们将 refresh 方法添加到所有此类特定类中(见示例 PositionMonitor):

c
// PositionMonitor.mqh
   virtual bool refresh() override
   {
      ready = PositionSelectByTicket(ticket);
      return ready;
   }

但是,用属性值填充缓存数组只是完成了一半的工作。另一半是将这些值与订单、交易或仓位的实际状态进行比较。

为了识别差异并将已更改属性的索引写入 changes 数组,生成的 TradeBaseState 类提供了 getChanges 方法。当检测到更改时,该方法返回 true

c
template<typename M,typename I,typename D,typename S>
class TradeBaseState: public M
{
   ...
   bool getChanges(int &changes[])
   {
      const bool previous = ready;
      if(refresh())
      {
         // 在交易环境中选择了元素 = 可以读取和比较属性
         cached = false;    // 直接读取
         const bool result = M::diff(state, changes);
         cached = true;     // 默认重新启用缓存
         return result;
      }
      // 不再“就绪” = 很可能已删除
      return previous != ready; // 如果刚刚删除,这也是一种更改 
   }

如您所见,主要工作委托给了 M 类中的某个 diff 方法。这是一个新方法:我们需要编写它。幸运的是,由于面向对象编程(OOP),您可以在基础模板 MonitorInterface 中编写一次,该方法将立即应用于订单、交易和仓位。

c
// TradeBaseMonitor.mqh
template<typename I,typename D,typename S>
class MonitorInterface
{
   ...
   bool diff(const TradeState &that, int &changes[])
   {
      ArrayResize(changes, 0);
      for(int i = 0; i < TradeState::limit; ++i)
      {
         switch(TradeState::indices[i][0])
         {
         case PROP_TYPE_INTEGER:
            if(this.get((I)i) != that.ulongs[TradeState::offset(i)])
            {
               PUSH(changes, i);
            }
            break;
         case PROP_TYPE_DOUBLE:
            if(!TU::Equal(this.get((D)i), that.doubles[TradeState::offset(i)]))
            {
               PUSH(changes, i);
            }
            break;
         case PROP_TYPE_STRING:
            if(this.get((S)i) != that.strings[TradeState::offset(i)])
            {
               PUSH(changes, i);
            }
            break;
         }
      }
      return ArraySize(changes) > 0;
   }

这样,为订单、交易和仓位形成特定的缓存类的所有准备工作都已就绪。例如,仓位将存储在基于 PositionMonitor 的扩展监控类 PositionState 中。

c
class PositionState: public TradeBaseState<PositionMonitor,
   ENUM_POSITION_PROPERTY_INTEGER,
   ENUM_POSITION_PROPERTY_DOUBLE,
   ENUM_POSITION_PROPERTY_STRING>
{
public:
   PositionState(const long t): TradeBaseState(t) { }
};

类似地,在 TradeState.mqh 文件中定义了交易的缓存类。

c
class DealState: public TradeBaseState<DealMonitor,
   ENUM_DEAL_PROPERTY_INTEGER,
   ENUM_DEAL_PROPERTY_DOUBLE,
   ENUM_DEAL_PROPERTY_STRING>
{
public:
   DealState(const long t): TradeBaseState(t) { }
};

对于订单,情况稍微复杂一些,因为订单可以是活跃的或已成为历史的。到目前为止,我们有一个通用的订单监控类 OrderMonitor。它试图在活跃订单列表和历史记录中查找提交的订单单号。这种方法不适合缓存,因为专家顾问需要跟踪订单从一种状态到另一种状态的转变。

出于这个原因,我们在 OrderMonitor.mqh 文件中添加了两个更具体的类:ActiveOrderMonitorHistoryOrderMonitor

c
// OrderMonitor.mqh
class ActiveOrderMonitor: public OrderMonitor
{
public:
   ActiveOrderMonitor(const ulong t): OrderMonitor(t)
   {
      if(history) // 如果订单在历史记录中,那么它已经是非活跃的了
      {
         ready = false;   // 重置就绪标志
         history = false; // 根据定义,这个对象只用于活跃订单
      }
   }
   
   virtual bool refresh() override
   {
      ready = OrderSelect(ticket);
      return ready;
   }
};
   
class HistoryOrderMonitor: public OrderMonitor
{
public:
   HistoryOrderMonitor(const ulong t): OrderMonitor(t) { }
   
   virtual bool refresh() override
   {
      history = true; // 只处理历史记录
      ready = historyOrderSelectWeak(ticket);
      return ready; // 就绪状态由历史记录中是否存在该单号决定
   }
};

它们中的每一个都只在各自的范围内搜索单号。基于这些监控类,现在已经可以创建缓存类了。

c
// TradeState.mqh
class OrderState: public TradeBaseState<ActiveOrderMonitor,
   ENUM_ORDER_PROPERTY_INTEGER,
   ENUM_ORDER_PROPERTY_DOUBLE,
   ENUM_ORDER_PROPERTY_STRING>
{
public:
   OrderState(const long t): TradeBaseState(t) { }
};
   
class HistoryOrderState: public TradeBaseState<HistoryOrderMonitor,
   ENUM_ORDER_PROPERTY_INTEGER,
   ENUM_ORDER_PROPERTY_DOUBLE,
   ENUM_ORDER_PROPERTY_STRING>
{
public:
   HistoryOrderState(const long t): TradeBaseState(t) { }
};

为了方便起见,我们将在 TradeBaseState 类中添加的最后一个功能是一个将属性值转换为字符串的特殊方法。尽管监控类中有几个版本的 stringify 方法,但它们都会“打印”缓存中的值(如果成员变量 cached 等于 true)或者交易环境中原始对象的值(如果 cached 等于 false)。为了可视化缓存和已更改对象之间的差异(当发现这些差异时),我们需要同时从缓存中读取值并绕过缓存。因此,我们添加了 stringifyRaw 方法,该方法始终直接处理属性(因为 cached 变量会被临时重置并重新设置)。

c
   // 绕过缓存获取属性'i'的字符串表示
   string stringifyRaw(const int i)
   {
      const bool previous = cached;
      cached = false;
      const string s = stringify(i);
      cached = previous;
   }

让我们通过一个简单的专家顾问示例来检查缓存监控类的性能,该专家顾问监控活跃订单的状态(OrderSnapshot.mq5)。稍后,我们将扩展这个想法,以缓存任何一组订单、交易或仓位,也就是说,我们将创建一个完整的缓存。

该专家顾问将尝试在活跃订单列表中找到最后一个订单,并为其创建 OrderState 对象。如果没有订单,将提示用户创建一个订单或开仓(后者与在市场上放置并执行订单相关)。一旦找到订单,我们就检查订单状态是否发生了变化。此检查在 OnTrade 处理函数中执行。该专家顾问将继续监控此订单,直到它被卸载。

c
int OnInit()
{
   if(OrdersTotal() == 0)
   {
      Alert("Please, create a pending order or open/close a position");
   }
   else
   {
      OnTrade(); // 自调用
   }
   return INIT_SUCCEEDED;
}
   
void OnTrade()
{
   static int count = 0;
   // 对象指针存储在静态AutoPtr中
   static AutoPtr<OrderState> auto;
   // 获取一个“干净”的指针(这样就不必到处解引用auto[])
   OrderState *state = auto[];
   
   PrintFormat(">>> OnTrade(%d)", count++);
   
   if(OrdersTotal() > 0 && state == NULL)
   {
      const ulong ticket = OrderGetTicket(OrdersTotal() - 1);
      auto = new OrderState(ticket);
      PrintFormat("Order picked up: %lld %s", ticket,
         auto[].isReady() ? "true" : "false");
      auto[].print(); // “捕获”订单时的初始状态
   }
   else if(state)
   {
      int changes[];
      if(state.getChanges(changes))
      {
         Print("Order properties changed:");
         ArrayPrint(changes);
         ...
      }
      if(_LastError != 0) Print(E2S(_LastError));
   }
}

除了显示已更改属性的数组外,最好还能显示更改的具体内容。因此,我们将添加这样一个片段来代替省略号(这在我们未来的完整缓存类中会很有用)。

c
         for(int k = 0; k < ArraySize(changes); ++k)
         {
            switch(OrderState::TradeState::type(changes[k]))
            {
            case PROP_TYPE_INTEGER:
               Print(EnumToString((ENUM_ORDER_PROPERTY_INTEGER)changes[k]), ": ",
                  state.stringify(changes[k]), " -> ",
                  state.stringifyRaw(changes[k]));
                  break;
            case PROP_TYPE_DOUBLE:
               Print(EnumToString((ENUM_ORDER_PROPERTY_DOUBLE)changes[k]), ": ",
                  state.stringify(changes[k]), " -> ",
                  state.stringifyRaw(changes[k]));
                  break;
            case PROP_TYPE_STRING:
               Print(EnumToString((ENUM_ORDER_PROPERTY_STRING)changes[k]), ": ",
                  state.stringify(changes[k]), " -> ",
                  state.stringifyRaw(changes[k]));
                  break;
            }
         }

这里我们使用了新的 stringifyRaw 方法。在显示更改后,不要忘记更新缓存状态。

c
         state.update();

如果在没有活跃订单的账户上运行该专家顾问,然后下达一个新订单,您将在日志中看到以下条目(这里在当前市场价格下方创建了一个欧元/美元的买入限价单)。

Alert: Please, create a pending order or open/close a position

>>> OnTrade(0)

Order picked up: 1311736135 true

MonitorInterface<ENUM_ORDER_PROPERTY_INTEGER,ENUM_ORDER_PROPERTY_DOUBLE,ENUM_ORDER_PROPERTY_STRING>

ENUM_ORDER_PROPERTY_INTEGER Count=14

  0 ORDER_TIME_SETUP=2022.04.11 11:42:39

  1 ORDER_TIME_EXPIRATION=1970.01.01 00:00:00

  2 ORDER_TIME_DONE=1970.01.01 00:00:00

  3 ORDER_TYPE=ORDER_TYPE_BUY_LIMIT

  4 ORDER_TYPE_FILLING=ORDER_FILLING_RETURN

  5 ORDER_TYPE_TIME=ORDER_TIME_GTC

  6 ORDER_STATE=ORDER_STATE_STARTED

  7 ORDER_MAGIC=0

  8 ORDER_POSITION_ID=0

  9 ORDER_TIME_SETUP_MSC=2022.04.11 11:42:39'729

 10 ORDER_TIME_DONE_MSC=1970.01.01 00:00:00'000

 11 ORDER_POSITION_BY_ID=0

 12 ORDER_TICKET=1311736135

 13 ORDER_REASON=ORDER_REASON_CLIENT

ENUM_ORDER_PROPERTY_DOUBLE Count=7

  0 ORDER_VOLUME_INITIAL=0.01

  1 ORDER_VOLUME_CURRENT=0.01

  2 ORDER_PRICE_OPEN=1.087

  3 ORDER_PRICE_CURRENT=1.087

  4 ORDER_PRICE_STOPLIMIT=0.0

  5 ORDER_SL=0.0

  6 ORDER_TP=0.0

ENUM_ORDER_PROPERTY_STRING Count=3

  0 ORDER_SYMBOL=EURUSD

  1 ORDER_COMMENT=

  2 ORDER_EXTERNAL_ID=

>>> OnTrade(1)

Order properties changed:

10 14

ORDER_PRICE_CURRENT: 1.087 -> 1.09073

ORDER_STATE: ORDER_STATE_STARTED -> ORDER_STATE_PLACED

>>> OnTrade(2)

>>> OnTrade(3)

>>> OnTrade(4)

在这里,您可以看到订单状态如何从 STARTED 变为 PLACED。如果我们不是下达挂单,而是以小交易量在市场上开仓,我们可能没有时间收到这些变化,因为这样的订单通常设置得非常快,并且它们观察到的状态会从 STARTED 立即变为 FILLED。而后者已经意味着该订单已被移至历史记录中。因此,需要并行监控历史记录来跟踪它们。我们将在下一个示例中展示这一点。

请注意,可能会有许多 OnTrade 事件,但并非所有事件都与我们的订单相关。

让我们尝试设置止盈水平并检查日志。

>>> OnTrade(5)
Order properties changed:
10 13
ORDER_PRICE_CURRENT: 1.09073 -> 1.09079
ORDER_TP: 0.0 -> 1.097
>>> OnTrade(6)
>>> OnTrade(7)

接下来,更改到期日期:从 GTC(取消前有效)改为一天。

>>> OnTrade(8)
Order properties changed:
10
ORDER_PRICE_CURRENT: 1.09079 -> 1.09082
>>> OnTrade(9)
>>> OnTrade(10)
Order properties changed:
2 6
ORDER_TIME_EXPIRATION: 1970.01.01 00:00:00 -> 2022.04.11 00:00:00
ORDER_TYPE_TIME: ORDER_TIME_GTC -> ORDER_TIME_DAY
>>> OnTrade(11)

在这里,在更改我们订单的过程中,价格有足够的时间发生变化,因此我们“捕获”到了关于 ORDER_PRICE_CURRENT 新值的中间通知。只有在那之后,ORDER_TYPE_TIMEORDER_TIME_EXPIRATION 的预期变化才进入日志。

接下来,我们删除了订单。

>>> OnTrade(12)
TRADE_ORDER_NOT_FOUND

现在,对于任何导致 OnTrade 事件的账户操作,我们的专家顾问都会输出 TRADE_ORDER_NOT_FOUND,因为它旨在跟踪单个订单。如果重新启动专家顾问,如果有其他订单,它将“捕获”另一个订单。但我们将停止该专家顾问,并开始为更紧急的任务做准备。

通常,需要进行缓存和控制变化的不是单个订单或仓位,而是根据某些条件选择的所有或一组订单、交易或仓位。为此,我们将开发一个基础模板类 TradeCacheTradeCache.mqh),并基于它为订单、交易和仓位列表创建应用类。

c
template<typename T,typename F,typename E>
class TradeCache
{
   AutoPtr<T> data[];
   const E property;
   const int NOT_FOUND_ERROR;
   
public:
   TradeCache(const E id, const int error): property(id), NOT_FOUND_ERROR(error) { }
   
   virtual string rtti() const
   {
      return typename(this); // 将在派生类中重新定义,以便输出到日志中进行可视化
   }
   ...

在这个模板中,字母 T 表示 TradeState 类族中的一个类。如您所见,以自动指针形式的此类对象数组被命名为 data 并预留了空间。

字母 F 描述了用于选择缓存项的过滤器类(OrderFilter.mqh,包括 HistoryOrderFilterDealFilter.mqhPositionFilter.mqh)之一的类型。在最简单的情况下,当过滤器不包含 let 条件时,所有元素都将被缓存(对于历史记录中的对象,是相对于采样历史记录而言)。

字母 E 对应于标识对象的属性所在的枚举。由于此属性通常是 SOME_TICKET,因此假定该枚举是整数型的 ENUM_SOMETHING_PROPERTY_INTEGER

NOT_FOUND_ERROR 变量用于存储在尝试分配不存在的对象进行读取时发生的错误代码,例如,对于仓位是 ERR_TRADE_POSITION_NOT_FOUND

在参数中,主类方法 scan 接收对已配置过滤器的引用(它应该由调用代码进行配置)。

c
   void scan(F &f)
   {
      const int existedBefore = ArraySize(data);
      
      ulong tickets[];
      ArrayResize(tickets, existedBefore);
      for(int i = 0; i < existedBefore; ++i)
      {
         tickets[i] = data[i][].get(property);
      }
      ...

在方法开始时,我们将已经缓存的对象的标识符收集到 tickets 数组中。显然,在第一次运行时,它将为空。

接下来,我们使用过滤器将相关对象的单号填充到对象数组中。对于每个新单号,我们创建一个缓存监控对象 T 并将其添加到 data 数组中。对于旧对象,我们通过调用 data[j][].getChanges(changes) 分析是否存在变化,然后通过调用 data[j][].update() 更新缓存。

c
      ulong objects[];
      f.select(objects);
      for(int i = 0, j; i < ArraySize(objects); ++i)
      {
         const ulong ticket = objects[i];
         for(j = 0; j < existedBefore; ++j)
         {
            if(tickets[j] == ticket)
            {
               tickets[j] = 0; // 标记为已找到
               break;
            }
         }
         
         if(j == existedBefore) // 这不在缓存中,需要添加
         {
            const T *ptr = new T(ticket);
            PUSH(data, ptr);
            onAdded(*ptr);
         }
         else
         {
            ResetLastError();
            int changes[];
            if(data[j][].getChanges(changes))
            {
               onUpdated(data[j][], changes);
               data[j][].update();
            }
            if(_LastError) PrintFormat("%s: %lld (%s)", rtti(), ticket, E2S(_LastError));
         }
      }
      ...

如您所见,在变化的每个阶段,即在添加对象时或对象更改后,都会调用 onAddedonUpdated 方法。这些是虚拟的存根方法,scan 方法可以使用它们来通知程序相应的事件。应用程序代码应实现一个派生类,并覆盖这些方法的版本。我们稍后会涉及这个问题,但现在我们将继续考虑 scan 方法。

在上述循环中,tickets 数组里所有找到的订单号都被设置为零,因此剩下的元素就对应着交易环境中缺失的对象。接下来,通过调用 getChanges 并将错误代码与 NOT_FOUND_ERROR 进行比较来检查这些元素。如果条件为真,就会调用 onRemoved 虚方法。该方法返回一个布尔标志(由你的应用程序代码提供),表明是否应将该项目从缓存中移除。

cpp
for(int j = 0; j < existedBefore; ++j)
{
    if(tickets[j] == 0) continue; // 跳过已处理的元素
    
    // 未找到此订单号,很可能已被删除
    int changes[];
    ResetLastError();
    if(data[j][].getChanges(changes))
    {
        if(_LastError == NOT_FOUND_ERROR) // 例如,ERR_TRADE_POSITION_NOT_FOUND
        {
            if(onRemoved(data[j][]))
            {
                data[j] = NULL;             // 释放对象和数组元素
            }
            continue;
        }
        
        // 注意!通常我们不应走到这里
        PrintFormat("Unexpected ticket: %lld (%s) %s", tickets[j],
                    E2S(_LastError), rtti());
        onUpdated(data[j][], changes, true);
        data[j][].update();
    }
    else
    {
        PrintFormat("Orphaned element: %lld (%s) %s", tickets[j],
                    E2S(_LastError), rtti());
    }
}

scan 方法的最后,会从 data 数组中清除空元素,但为简洁起见,此处省略了这部分代码片段。

基类为 onAddedonRemovedonUpdated 方法提供了标准实现,这些实现会在日志中显示事件的本质。在包含头文件 TradeCache.mqh 之前,在你的代码中定义 PRINT_DETAILS 宏,你就可以要求打印每个新对象的所有属性。

cpp
virtual void onAdded(const T &state)
{
    Print(rtti(), " added: ", state.get(property));
    #ifdef PRINT_DETAILS
    state.print();
    #endif
}

virtual bool onRemoved(const T &state)
{
    Print(rtti(), " removed: ", state.get(property));
    return true; // 允许从缓存中移除该对象(返回 false 则保留)
}

virtual void onUpdated(T &state, const int &changes[],
                       const bool unexpected = false)
{
   ...
}

我们不会展示 onUpdated 方法,因为它实际上与上面展示的来自智能交易系统 OrderSnapshot.mq5 的输出变化的代码重复。

当然,基类提供了获取缓存大小以及通过编号访问特定对象的功能。

cpp
int size() const
{
    return ArraySize(data);
}

T *operator[](int i) const
{
    return data[i][]; // 从 AutoPtr 对象返回指针(T*)
}

基于基础的 TradeCache 类,我们可以轻松创建用于缓存持仓列表、挂单以及历史订单的特定类。交易缓存则作为一个独立的任务保留。

cpp
class PositionCache: public TradeCache<PositionState,PositionFilter,
                                       ENUM_POSITION_PROPERTY_INTEGER>
{
public:
    PositionCache(const ENUM_POSITION_PROPERTY_INTEGER selector = POSITION_TICKET,
                  const int error = ERR_TRADE_POSITION_NOT_FOUND): TradeCache(selector, error) { }
};

class OrderCache: public TradeCache<OrderState,OrderFilter,
                                    ENUM_ORDER_PROPERTY_INTEGER>
{
public:
    OrderCache(const ENUM_ORDER_PROPERTY_INTEGER selector = ORDER_TICKET,
               const int error = ERR_TRADE_ORDER_NOT_FOUND): TradeCache(selector, error) { }
};

class HistoryOrderCache: public TradeCache<HistoryOrderState,HistoryOrderFilter,
                                           ENUM_ORDER_PROPERTY_INTEGER>
{
public:
    HistoryOrderCache(const ENUM_ORDER_PROPERTY_INTEGER selector = ORDER_TICKET,
                      const int error = ERR_TRADE_ORDER_NOT_FOUND): TradeCache(selector, error) { }
};

为了总结所呈现功能的开发过程,我们给出主要类的类图。这是一个简化版的 UML 类图,在使用 MQL5 设计复杂程序时可能会很有用。

交易对象的监控器、过滤器和缓存的类图

交易对象的监控器、过滤器和缓存的类图

模板用黄色标记,抽象类用白色表示,特定的实现用彩色显示。带有实心箭头的实线表示继承关系,带有空心箭头的虚线表示模板类型。带有开口箭头的虚线表示类之间相互使用指定的方法。带有菱形的连接表示组合关系(将一些对象包含到其他对象中)。

作为使用缓存的一个示例,让我们创建一个智能交易系统 TradeSnapshot.mq5,它将通过 OnTrade 处理程序响应交易环境中的任何变化。为了进行过滤和缓存,代码描述了 6 个对象,每种类型的元素(持仓、挂单和历史订单)各有 2 个(过滤器和缓存)。

cpp
PositionFilter filter0;
PositionCache positions;

OrderFilter filter1;
OrderCache orders;

HistoryOrderFilter filter2;
HistoryOrderCache history;

没有通过 let 方法调用为过滤器设置条件,这样所有在线发现的对象都会进入缓存。对于历史订单还有一个额外的设置。

在启动时,你可以选择将过去的订单按给定的历史深度加载到缓存中。这可以通过 HistoryLookup 输入变量来完成。在这个变量中,你可以选择最后一天、最后一周(按持续时间,而不是日历周)、一个月(30 天)或一年(360 天)。默认情况下,不加载过去的历史记录(更准确地说,只加载过去 1 秒的记录)。由于在智能交易系统中定义了 PRINT_DETAILS 宏,对于历史记录较多的账户要小心:如果不限制时间段,它们可能会生成大量日志。

cpp
enum ENUM_HISTORY_LOOKUP
{
    LOOKUP_NONE = 1,
    LOOKUP_DAY = 86400,
    LOOKUP_WEEK = 604800,
    LOOKUP_MONTH = 2419200,
    LOOKUP_YEAR = 29030400,
    LOOKUP_ALL = 0,
};

input ENUM_HISTORY_LOOKUP HistoryLookup = LOOKUP_NONE;

datetime origin;

OnInit 处理程序中,我们重置缓存(以防智能交易系统使用新参数重新启动),在 origin 变量中计算历史记录的开始日期,并首次调用 OnTrade

cpp
int OnInit()
{
    positions.reset();
    orders.reset();
    history.reset();
    origin = HistoryLookup? TimeCurrent() - HistoryLookup : 0;
    
    OnTrade(); // 自行启动
    return INIT_SUCCEEDED;
}

OnTrade 处理程序非常简洁,因为所有的复杂性现在都隐藏在类内部了。

cpp
void OnTrade()
{
    static int count = 0;
    
    PrintFormat(">>> OnTrade(%d)", count++);
    positions.scan(filter0);
    orders.scan(filter1);
    // 在调用'scan'方法内的过滤器之前进行历史记录选择
    HistorySelect(origin, LONG_MAX);
    history.scan(filter2);
    PrintFormat(">>> positions: %d, orders: %d, history: %d",
                positions.size(), orders.size(), history.size());
}

在一个干净的账户上启动智能交易系统后,我们会立即看到以下消息:

>>> OnTrade(0)
>>> positions: 0, orders: 0, history: 0

让我们尝试执行最简单的测试用例:在一个没有未平仓头寸和挂单的 “空” 账户上进行买入或卖出操作。日志将记录以下事件(几乎瞬间发生)。

首先,会检测到一个挂单。

>>> OnTrade(1)

OrderCache added: 1311792104

MonitorInterface<ENUM_ORDER_PROPERTY_INTEGER,ENUM_ORDER_PROPERTY_DOUBLE,ENUM_ORDER_PROPERTY_STRING>

ENUM_ORDER_PROPERTY_INTEGER Count=14

  0 ORDER_TIME_SETUP=2022.04.11 12:34:51

  1 ORDER_TIME_EXPIRATION=1970.01.01 00:00:00

  2 ORDER_TIME_DONE=1970.01.01 00:00:00

  3 ORDER_TYPE=ORDER_TYPE_BUY

  4 ORDER_TYPE_FILLING=ORDER_FILLING_FOK

  5 ORDER_TYPE_TIME=ORDER_TIME_GTC

  6 ORDER_STATE=ORDER_STATE_STARTED

  7 ORDER_MAGIC=0

  8 ORDER_POSITION_ID=0

  9 ORDER_TIME_SETUP_MSC=2022.04.11 12:34:51'096

 10 ORDER_TIME_DONE_MSC=1970.01.01 00:00:00'000

 11 ORDER_POSITION_BY_ID=0

 12 ORDER_TICKET=1311792104

 13 ORDER_REASON=ORDER_REASON_CLIENT

ENUM_ORDER_PROPERTY_DOUBLE Count=7

  0 ORDER_VOLUME_INITIAL=0.01

  1 ORDER_VOLUME_CURRENT=0.01

  2 ORDER_PRICE_OPEN=1.09218

  3 ORDER_PRICE_CURRENT=1.09218

  4 ORDER_PRICE_STOPLIMIT=0.0

  5 ORDER_SL=0.0

  6 ORDER_TP=0.0

ENUM_ORDER_PROPERTY_STRING Count=3

  0 ORDER_SYMBOL=EURUSD

  1 ORDER_COMMENT=

  2 ORDER_EXTERNAL_ID=

然后这个订单会被移到历史记录中(同时,至少状态、执行时间和持仓 ID 会发生变化)。

HistoryOrderCache added: 1311792104

MonitorInterface<ENUM_ORDER_PROPERTY_INTEGER,ENUM_ORDER_PROPERTY_DOUBLE,ENUM_ORDER_PROPERTY_STRING>

ENUM_ORDER_PROPERTY_INTEGER Count=14

  0 ORDER_TIME_SETUP=2022.04.11 12:34:51

  1 ORDER_TIME_EXPIRATION=1970.01.01 00:00:00

  2 ORDER_TIME_DONE=2022.04.11 12:34:51

  3 ORDER_TYPE=ORDER_TYPE_BUY

  4 ORDER_TYPE_FILLING=ORDER_FILLING_FOK

  5 ORDER_TYPE_TIME=ORDER_TIME_GTC

  6 ORDER_STATE=ORDER_STATE_FILLED

  7 ORDER_MAGIC=0

  8 ORDER_POSITION_ID=1311792104

  9 ORDER_TIME_SETUP_MSC=2022.04.11 12:34:51'096

 10 ORDER_TIME_DONE_MSC=2022.04.11 12:34:51'097

 11 ORDER_POSITION_BY_ID=0

  12 ORDER_TICKET=1311792104

  13 ORDER_REASON=ORDER_REASON_CLIENT

ENUM_ORDER_PROPERTY_DOUBLE Count=7

  0 ORDER_VOLUME_INITIAL=0.01

  1 ORDER_VOLUME_CURRENT=0.0

  2 ORDER_PRICE_OPEN=1.09218

  3 ORDER_PRICE_CURRENT=1.09218

  4 ORDER_PRICE_STOPLIMIT=0.0

  5 ORDER_SL=0.0

  6 ORDER_TP=0.0

ENUM_ORDER_PROPERTY_STRING Count=3

  0 ORDER_SYMBOL=EURUSD

  1 ORDER_COMMENT=

  2 ORDER_EXTERNAL_ID=

>>> positions: 0, orders: 1, history: 1

请注意,这些修改发生在对 OnTrade 的同一调用中。换句话说,当我们的程序在分析新订单的属性(通过调用 orders.scan)时,终端在并行处理该订单,而当检查历史记录时(通过调用 history.scan),该订单已经进入历史记录了。这就是为什么根据此日志片段的最后一行,它既出现在挂单缓存中又出现在历史订单缓存中。这种行为对于多线程程序来说是正常的,在设计程序时应该考虑到这一点。但并非总是如此。我们只是在此引起注意。当快速执行 MQL 程序时,这种情况通常不会发生。

如果我们先检查历史记录,然后再检查在线订单,那么在第一阶段我们可能会发现订单还不在历史记录中,而在第二阶段会发现订单已经不在在线状态了。也就是说,理论上它可能会在一瞬间丢失。更现实的情况是,由于历史记录同步而跳过订单的挂单阶段,即第一次就直接在历史记录中发现它。

回想一下,MQL5 不允许你对整个交易环境进行同步,而只能部分同步:

  • 在挂单中,对于刚刚调用了 OrderSelectOrderGetTicket 函数的订单,信息是相关的。
  • 在持仓中,对于刚刚调用了 PositionSelectPositionSelectByTicketPositionGetTicket 函数的持仓,信息是相关的。
  • 对于历史记录中的订单和交易,信息在最后一次调用 HistorySelectHistorySelectByPositionHistoryOrderSelectHistoryDealSelect 的上下文中是可用的。

此外,提醒你交易事件(和任何 MQL5 事件一样)是关于已发生变化的消息,被放入队列中,并延迟从队列中取出,而不是在变化发生时立即处理。而且,OnTrade 事件在相关的 OnTradeTransaction 事件之后发生。

尝试不同的程序配置,进行调试,并生成详细的日志,以便为你的交易系统选择最可靠的算法。

让我们回到我们的日志。在下一次触发 OnTrade 时,情况已经得到修正:挂单缓存检测到订单已被删除。同时,持仓缓存发现了一个已开仓的头寸。

>>> OnTrade(2)

PositionCache added: 1311792104

MonitorInterface<ENUM_POSITION_PROPERTY_INTEGER,ENUM_POSITION_PROPERTY_DOUBLE,ENUM_POSITION_PROPERTY_STRING>

ENUM_POSITION_PROPERTY_INTEGER Count=9

  0 POSITION_TIME=2022.04.11 12:34:51

  1 POSITION_TYPE=POSITION_TYPE_BUY

  2 POSITION_MAGIC=0

  3 POSITION_IDENTIFIER=1311792104

  4 POSITION_TIME_MSC=2022.04.11 12:34:51'097

  5 POSITION_TIME_UPDATE=2022.04.11 12:34:51

  6 POSITION_TIME_UPDATE_MSC=2022.04.11 12:34:51'097

  7 POSITION_TICKET=1311792104

  8 POSITION_REASON=POSITION_REASON_CLIENT

ENUM_POSITION_PROPERTY_DOUBLE Count=8

  0 POSITION_VOLUME=0.01

  1 POSITION_PRICE_OPEN=1.09218

  2 POSITION_PRICE_CURRENT=1.09214

  3 POSITION_SL=0.00000

  4 POSITION_TP=0.00000

  5 POSITION_COMMISSION=0.0

  6 POSITION_SWAP=0.00

  7 POSITION_PROFIT=-0.04

ENUM_POSITION_PROPERTY_STRING Count=3

  0 POSITION_SYMBOL=EURUSD

  1 POSITION_COMMENT=

  2 POSITION_EXTERNAL_ID=

OrderCache removed: 1311792104

>>> positions: 1, orders: 0, history: 1

过了一段时间,我们平仓。由于在我们的代码中首先检查持仓缓存(positions.scan),已平仓头寸的变化被记录在日志中。

>>> OnTrade(8)
PositionCache changed: 1311792104
POSITION_PRICE_CURRENT: 1.09214 -> 1.09222
POSITION_PROFIT: -0.04 -> 0.04

在对 OnTrade 的同一调用中,我们检测到一个平仓订单的出现以及它瞬间被移到历史记录中(同样,这是由于终端对其进行了快速并行处理)。

OrderCache added: 1311796883

MonitorInterface<ENUM_ORDER_PROPERTY_INTEGER,ENUM_ORDER_PROPERTY_DOUBLE,ENUM_ORDER_PROPERTY_STRING>

ENUM_ORDER_PROPERTY_INTEGER Count=14

  0 ORDER_TIME_SETUP=2022.04.11 12:39:55

  1 ORDER_TIME_EXPIRATION=1970.01.01 00:00:00

  2 ORDER_TIME_DONE=1970.01.01 00:00:00

  3 ORDER_TYPE=ORDER_TYPE_SELL

  4 ORDER_TYPE_FILLING=ORDER_FILLING_FOK

  5 ORDER_TYPE_TIME=ORDER_TIME_GTC

  6 ORDER_STATE=ORDER_STATE_STARTED

  7 ORDER_MAGIC=0

  8 ORDER_POSITION_ID=1311792104

  9 ORDER_TIME_SETUP_MSC=2022.04.11 12:39:55'710

 10 ORDER_TIME_DONE_MSC=1970.01.01 00:00:00'000

 11 ORDER_POSITION_BY_ID=0

 12 ORDER_TICKET=1311796883

 13 ORDER_REASON=ORDER_REASON_CLIENT

ENUM_ORDER_PROPERTY_DOUBLE Count=7

  0 ORDER_VOLUME_INITIAL=0.01

  1 ORDER_VOLUME_CURRENT=0.01

  2 ORDER_PRICE_OPEN=1.09222

  3 ORDER_PRICE_CURRENT=1.09222

  4 ORDER_PRICE_STOPLIMIT=0.0

  5 ORDER_SL=0.0

  6 ORDER_TP=0.0

ENUM_ORDER_PROPERTY_STRING Count=3

  0 ORDER_SYMBOL=EURUSD

  1 ORDER_COMMENT=

  2 ORDER_EXTERNAL_ID=

HistoryOrderCache added: 1311796883

MonitorInterface<ENUM_ORDER_PROPERTY_INTEGER,ENUM_ORDER_PROPERTY_DOUBLE,ENUM_ORDER_PROPERTY_STRING>

ENUM_ORDER_PROPERTY_INTEGER Count=14

  0 ORDER_TIME_SETUP=2022.04.11 12:39:55

  1 ORDER_TIME_EXPIRATION=1970.01.01 00:00:00

  2 ORDER_TIME_DONE=2022.04.11 12:39:55

  3 ORDER_TYPE=ORDER_TYPE_SELL

  4 ORDER_TYPE_FILLING=ORDER_FILLING_FOK

  5 ORDER_TYPE_TIME=ORDER_TIME_GTC

  6 ORDER_STATE=ORDER_STATE_FILLED

  7 ORDER_MAGIC=0

  8 ORDER_POSITION_ID=1311792104

  9 ORDER_TIME_SETUP_MSC=2022.04.11 12:39:55'710

 10 ORDER_TIME_DONE_MSC=2022.04.11 12:39:55'711

 11 ORDER_POSITION_BY_ID=0

 12 ORDER_TICKET=1311796883

 13 ORDER_REASON=ORDER_REASON_CLIENT

ENUM_ORDER_PROPERTY_DOUBLE Count=7

  0 ORDER_VOLUME_INITIAL=0.01

  1 ORDER_VOLUME_CURRENT=0.0

  2 ORDER_PRICE_OPEN=1.09222

  3 ORDER_PRICE_CURRENT=1.09222

  4 ORDER_PRICE_STOPLIMIT=0.0

  5 ORDER_SL=0.0

  6 ORDER_TP=0.0

ENUM_ORDER_PROPERTY_STRING Count=3

  0 ORDER_SYMBOL=EURUSD

  1 ORDER_COMMENT=

  2 ORDER_EXTERNAL_ID=

>>> positions: 1, orders: 1, history: 2

历史订单缓存中已经有 2 个订单了,但在检查历史订单缓存之前分析的持仓和挂单缓存尚未应用这些变化。

但在下一个 OnTrade 事件中,我们看到头寸已平仓,并且市价订单已消失。

>>> OnTrade(9)
PositionCache removed: 1311792104
OrderCache removed: 1311796883
>>> positions: 0, orders: 0, history: 2

如果我们在每个报价时刻(或每秒一次,但不只是针对 OnTrade 事件)监控缓存,我们会看到 ORDER_PRICE_CURRENTPOSITION_PRICE_CURRENT 属性的实时变化。POSITION_PROFIT 也会发生变化。

我们的类不具备持久性,也就是说,它们只存在于随机存取存储器(RAM)中,并且不知道如何在任何长期存储(如文件)中保存和恢复它们的状态。这意味着程序可能会错过在终端会话之间发生的变化。如果你需要这样的功能,你应该自己实现它。在未来,本书的第 7 部分中,我们将研究 MQL5 中内置的 SQLite 数据库支持,它提供了一种最有效和方便的方式来存储交易环境缓存和类似的表格数据。

创建多品种智能交易系统

到目前为止,在本书的框架内,我们主要分析了在图表当前工作品种上进行交易的智能交易系统示例。然而,MQL5 允许你为行情报价表中的任何品种生成交易订单,而与图表的工作品种无关。

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

  • “发送交易请求” 部分的 CustomOrderSend.mq5
  • “买卖操作” 部分的 MarketOrderSend.mq5
  • “读取活跃订单属性的函数” 部分的 MarketOrderSendMonitor.mq5
  • “设置挂单” 部分的 PendingOrderSend.mq5
  • “修改挂单” 部分的 PendingOrderModify.mq5
  • “删除挂单” 部分的 PendingOrderDelete.mq5

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

此外,正如我们在 OnBookEvenOnTradeTransaction 事件的描述中所看到的,它们是通用的,会通知与任意品种相关的交易环境变化。但 OnTick 事件并非如此,它仅在当前品种的新价格发生变化时才会生成。通常,这不是问题,但高频多货币交易需要采取一些额外的技术步骤,例如订阅其他品种的 OnBookEvent 事件或设置高频定时器。在 “生成自定义事件” 部分中介绍了另一种绕过此限制的方式,即间谍指标 EventTickSpy.mq5

在讨论多品种交易的支持时,应该注意,类似的多时间框架智能交易系统的概念并不完全正确。在新柱线开盘时间进行交易只是按任意周期对报价点进行分组的一种特殊情况,这些周期不一定是标准的。当然,由于像 iTime(_Symbol, PERIOD_XX, 0) 这样的函数,系统核心简化了对特定时间框架上新柱线出现的分析,但这种分析无论如何都是基于报价点的。

你可以在智能交易系统内部根据报价点数量(等量图)、价格区间(砖形图、区间图)等构建虚拟柱线。在某些情况下,包括为了清晰起见,在智能交易系统外部以自定义品种的形式显式生成这样的 “时间框架” 是有意义的。但这种方法有其局限性,我们将在本书的下一部分讨论这些局限性。

然而,如果交易系统仍然需要基于柱线开盘来分析报价,或者使用多货币指标,那么就需要以某种方式等待所有相关交易品种的柱线同步。我们在 “跟踪柱线形成” 部分中提供了一个执行此任务的类的示例。

在开发多品种智能交易系统时,一项必要的任务是将通用交易算法分离为不同的模块。这些模块随后可以应用于具有不同设置的各种品种。实现这一目标最合理的方法是在面向对象编程(OOP)概念的框架内定义一个或多个类。

让我们以一个采用著名的马丁格尔策略的智能交易系统为例来说明这种方法。如大家所知,马丁格尔策略本质上是有风险的,因为它在每次亏损交易后将手数翻倍,期望弥补之前的损失。必须减轻这种风险,一种有效的方法是同时交易多个品种,最好是那些相关性较弱的品种。这样,一种交易品种的暂时亏损可能会被其他品种的盈利所抵消。

在智能交易系统中纳入各种交易品种(或单个交易系统中的不同设置,甚至是不同的交易系统)有助于减少单个组件失败的总体影响。本质上,交易品种或系统的多样性越大,最终结果对其组成部分的个别挫折的依赖就越小。

我们将一个新的智能交易系统命名为 MultiMartingale.mq5。交易算法设置包括:

  • UseTime —— 用于启用/禁用定时交易的逻辑标志
  • HourStartHour End —— 如果 UseTimetrue,则为允许交易的小时范围
  • Lots —— 系列中第一笔交易的手数
  • Factor —— 亏损后后续交易的手数增加系数
  • Limit —— 手数倍增的亏损系列中的最大交易次数(达到该次数后,恢复到初始手数)
  • Stop LossTake Profit —— 与保护水平的点数距离
  • StartType —— 第一笔交易的类型(买入或卖出)
  • Trailing —— 止损追踪指示

在源代码中,它们是这样描述的:

c
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)的百分比来设置保护水平,而不是以点数为单位。然而,目前这不是主要任务。

此外,智能交易系统还包含一种机制,在出现错误的情况下,可根据用户指定的持续时间(由参数 SkipTimeOnError 控制)暂时停止交易操作。这里我们将省略对此方面的详细讨论,因为可以在源代码中查阅相关内容。

为了将所有配置整合为一个统一的实体,定义了一个名为 Settings 的结构。该结构的字段与输入变量相对应。此外,该结构还包括 symbol 字段,以解决策略的多货币性质。换句话说,symbol 可以是任意的,并且与图表上的工作品种不同。

c
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)。

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

调用 CopyClose 不仅可以检查该品种在行情报价表中是否在线,还会在测试器中启动加载其(所需时间框架的)报价和报价点数据。如果不这样做,默认情况下,测试器中仅提供当前选择的交易品种和时间框架的报价和报价点数据(在真实报价点模式下)。由于我们正在编写一个多货币智能交易系统,我们将需要第三方的报价和报价点数据。

c
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]
|     | |   |   |  |    |   |  |
|     | |   |   |  |    |   |  `直到这个小时允许交易
|     | |   |   |  |    |   `从这个小时允许交易
|     | |   |   |  |    `止盈点数
|     | |   |   |  `止损点数
|     | |   |   `亏损交易系列的最大规模(在 '^' 之后)
|     | |   `手数倍增系数(在 '*' 之后)
|     | `系列中的初始手数
|     `+ 以买入开始
|     `- 以卖出开始
`交易品种

当我们转向多货币交易时,Settings 结构中还将需要其他方法。目前,让我们想象一下在单个品种上进行交易的智能交易系统的 OnInit 处理程序的简化版本可能是什么样子。

c
int OnInit()
{
   Settings settings =
   {
      UseTime, HourStart, HourEnd,
      Lots, Factor, Limit,
      StopLoss, TakeProfit,
      StartType, Magic, SkipTimeOnError, Trailing, _Symbol
   };
   
   if(settings.validate())
   {
      settings.print();
      ...
      // 这里你将需要使用这些设置初始化交易算法
   }
   ...
}

遵循面向对象编程(OOP),广义形式的交易系统应该被描述为一个软件接口。同样,为了简化示例,我们在这个接口中只使用一个方法:trade

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

嗯,算法的主要任务是进行交易,而且我们决定从哪里调用这个方法甚至都无关紧要:可以在 OnTick 中的每个报价点调用、在柱线开盘时调用,或者可能在定时器触发时调用。

你实际使用的智能交易系统很可能需要额外的接口方法来设置和支持各种模式。但在这个示例中不需要它们。

让我们开始基于该接口创建一个特定交易系统的类。在我们的例子中,所有实例都将是 SimpleMartingale 类的实例。然而,也可以在一个智能交易系统中实现许多不同的类来继承该接口,然后以统一的方式任意组合使用它们。策略组合(最好在性质上非常不同)通常具有更高的财务绩效稳定性。

c
class SimpleMartingale: public TradingStrategy
{
protected:
   Settings settings;
   SymbolMonitor symbol;
   AutoPtr<PositionState> position;
   AutoPtr<TrailingStop> trailing;
   ...
};

在类内部,我们看到了熟悉的 Settings 结构和工作品种监视器 SymbolMonitor。此外,我们将需要控制持仓的存在,并跟踪它们的止损水平,为此我们引入了指向 PositionStateTrailingStop 对象的自动指针变量。自动指针使我们在代码中无需担心对象的显式删除,因为当控制流离开作用域或为自动指针分配新指针时,对象将自动被删除。

TrailingStop 类是一个基类,具有最简单的价格跟踪实现,你可以从它继承许多更复杂的算法,我们之前考虑过的 TrailingStopByMA 就是一个例子。因此,为了使程序在未来具有灵活性,最好确保调用代码可以传递自己特定的、定制的从 TrailingStop 派生的追踪对象。例如,可以通过将指针传递给构造函数,或者将 SimpleMartingale 变成一个模板类(然后追踪类将由模板参数设置)来实现。

这种面向对象编程的原则称为依赖注入,并且与我们在 “面向对象编程的理论基础:组合” 部分中简要提到的许多其他原则一起被广泛使用。

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

c
class SimpleMartingale: public TradingStrategy
{
   ...
   double lotsStep;
   double lotsLimit;
   double takeProfit, stopLoss;
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);
      
      // 计算系列中的最大手数(在给定的倍增次数之后)
      for(int pos = 0; pos < (int)settings.limit; pos++)
      {
         lotsLimit = MathFloor((lotsLimit * settings.factor) / lotsStep) * lotsStep;
      }
      
      double maxLot = symbol.get(SYMBOL_VOLUME_MAX);
      if(lotsLimit > maxLot)
      {
         lotsLimit = maxLot;
      }
      ...

接下来,我们使用 PositionFilter 对象来搜索现有的 “自己的” 持仓(通过魔术数字和品种)。如果找到这样的持仓,我们创建 PositionState 对象,并在必要时为其创建 TrailingStop 对象。

c
      PositionFilter positions;
      ulong tickets[];
      positions.let(POSITION_MAGIC, settings.magic).let(POSITION_SYMBOL, settings.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 开仓,即建立多头或空头头寸。

c
   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 更新持仓信息,并且如果设置中要求,还会跟踪止损。

c
      else // position[] != NULL
      {
         if(position[].refresh()) // 持仓仍然存在吗?
         {
            position[].update();
            if(trailing[]) trailing[].trail();
         }
         ...

如果持仓已平仓,refresh 将返回 false,我们将进入另一个 if 分支来开仓新的头寸:如果实现了盈利,则与之前方向相同;如果发生了亏损,则与之前方向相反。请注意,我们在缓存中仍然保留了之前持仓的快照。

c
         else // 持仓已平仓 - 需要开仓新的头寸
         {
            if(position[].get(POSITION_PROFIT) >= 0.0) 
            {
              // 保持相同方向:
              // 如果之前是盈利的买入,则再次买入
              // 如果之前是盈利的卖出,则再次卖出
               if(position[].get(POSITION_TYPE) == POSITION_TYPE_BUY)
                  ticket = openBuy(settings.lots);
               else
                  ticket = openSell(settings.lots);
            }
            else
            {
               // 在指定范围内增加手数
               double lots = MathFloor((position[].get(POSITION_VOLUME) * settings.factor) / lotsStep) * lotsStep;
   
               if(lotsLimit < lots)
               {
                  lots = settings.lots;
               }
             
               // 改变交易方向:
               // 如果之前是亏损的买入,则卖出
               // 如果之前是亏损的卖出,则买入
               if(position[].get(POSITION_TYPE) == POSITION_TYPE_BUY)
                  ticket = openSell(lots);
               else
                  ticket = openBuy(lots);
            }
         }
      }
      ...

在这个最后阶段,如果 ticket 不为零,则意味着我们必须使用新的 PositionStateTrailingStop 对象开始控制它。

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

我们现在给出 `openBuy` 方法(`openSell` 方法与之类似,在此省略部分细节)。它有三个步骤:
1. 使用 `prepare` 方法准备 `MqlTradeRequestSync` 结构(此处未展示,该方法用于填充 `deviation` 和 `magic` 等信息)。
2. 通过调用 `request.buy` 方法发送订单。
3. 使用 `postprocess` 方法检查结果(此处未展示,该方法会调用 `request.completed`,并且在出现错误的情况下,会开始暂停交易的时间段,以等待更好的交易条件)。

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

通常,头寸会因止损或止盈而平仓。然而,我们也支持可能导致平仓的定时操作。让我们回到 trade 方法的开头来了解定时操作的工作原理。

c
   virtual bool trade() override
   {
      if(settings.useTime &&!scheduled(TimeCurrent())) // 时间超出计划范围了吗?
      {
         // 如果存在开仓头寸,则平仓
         if(position[] && position[].isReady())
         {
            if(close(position[].get(POSITION_TICKET)))
            {
                                // 根据设计者的要求:
               position = NULL; // 清除缓存,或者我们可以...
               // 不进行这种清零操作,即保留缓存中的头寸,
               // 以便将下一笔交易的方向和手数传递到新的系列中
            }
            else
            {
               position[].refresh(); // 确保重置'ready'标志
            }
         }
         return false;
      }
      ...// 开仓操作(如上文所述)
   }

在上述代码中,scheduled 方法(此处未展示其实现)用于检查当前时间 TimeCurrent() 是否在设定的允许交易的时间范围内(由 settings.useTimeHourStartHourEnd 控制)。如果时间超出范围且存在已准备好的开仓头寸,就尝试平仓。如果平仓成功,可以选择清除头寸缓存(将 position 设为 NULL),或者保留缓存以便后续交易使用;如果平仓失败,则刷新头寸信息以重置 ready 标志。只有当时间在计划范围内或者成功处理了超出时间范围的情况后,才会继续执行后续的开仓操作逻辑。

总结来说,这段内容围绕多品种智能交易系统 MultiMartingale.mq5 的开发展开,涵盖了从交易系统的设置、类和接口的定义,到具体交易算法的实现以及定时操作等方面。具体要点如下:

  1. 多品种交易概念:MQL5 支持对任意品种交易,与图表工作品种无关,介绍了相关事件的特点及多时间框架概念的不完全正确性。
  2. 智能交易系统设置MultiMartingale.mq5 的交易算法设置包括交易时间控制、手数、系数、止损止盈等参数。
  3. 设置结构 Settings:定义了包含输入变量及品种信息的结构,具备验证和打印设置等方法,通过 CopyClose 检查品种并加载数据。
  4. 交易策略接口与类:定义了 TradingStrategy 接口,创建了继承该接口的 SimpleMartingale 类,类中包含设置、品种监视器、持仓和止损跟踪等对象,通过构造函数初始化内部变量。
  5. 交易算法实现trade 方法根据持仓情况进行开仓、跟踪止损等操作,openBuyopenSell 方法用于具体的开仓操作,包含准备、发送订单和检查结果等步骤。
  6. 定时操作:根据设置的时间范围控制交易,超出范围时若有开仓头寸则尝试平仓,并处理相关缓存和标志。

工作方法 close 在很大程度上与 openBuy 类似,因此这里不再赘述。另一个方法 scheduled 会根据当前时间是否处于指定的工作时间范围(hourStarthourEnd)内,返回 truefalse

至此,交易类已准备就绪。但对于多货币交易,需要创建该类的多个实例。TradingStrategyPool 类将负责管理这些实例,我们在其中定义了一个指向 TradingStrategy 的指针数组以及用于填充该数组的方法:带参数的构造函数和 push 方法。

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

不必让 pool 类继承自 TradingStrategy 接口,但如果这样做,就可以在未来将策略池打包到更大的策略池中,以此类推。trade 方法只是简单地调用数组中所有对象的同名方法。

在全局环境中,我们添加一个指向交易池的自动指针,并在 OnInit 处理函数中对其进行填充。我们可以从一个单一策略开始(稍后再处理多货币交易)。

cpp
AutoPtr<TradingStrategyPool> pool;
   
int OnInit()
{
   ... // 之前已经给出设置初始化代码
   if(settings.validate())
   {
      settings.print();
      pool = new TradingStrategyPool(new SimpleMartingale(settings));
      return INIT_SUCCEEDED;
   }
   else
   {
      return INIT_FAILED;
   }
   ...
}

要开始交易,只需编写以下简单的 OnTick 处理函数:

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

多货币支持的实现

不过,多货币支持该如何实现呢?

当前的输入参数集仅针对单一交易品种设计。我们可以用它来对单个交易品种的智能交易系统进行测试和优化,但在为所有交易品种找到最优设置后,需要以某种方式将这些设置组合起来并传递给算法。

在这种情况下,我们采用最简单的解决方案。上述代码中有一行是通过 Settings 结构体的 print 方法生成的设置信息。我们在 parse 结构体中实现一个执行反向操作的方法:根据字符串描述恢复字段的状态。此外,由于需要将不同交易品种的多个设置信息连接起来,我们约定可以通过一个特殊的分隔符(例如 ;)将它们连接成一个长字符串。然后,编写 parseAll 静态方法来读取合并后的设置信息集就很容易了,该方法会调用 parse 方法来填充通过引用传递的 Settings 结构体数组。这些方法的完整源代码可在附件文件中找到。

cpp
struct Settings
{
   ...
   bool parse(const string &line);
   void static parseAll(const string &line, Settings &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 方法可以解析这种类型的字符串。为了将这样的字符串输入到智能交易系统中,我们定义了输入变量 WorkSymbols

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

如果该变量为空,智能交易系统将使用前面介绍的单个输入变量中的设置信息进行工作。如果指定了字符串,OnInit 处理函数将根据解析该字符串的结果填充交易系统池。

cpp
int OnInit()
{
   if(WorkSymbols == "")
   {
      ...// 像之前一样处理当前单个交易品种
   }
   else
   {
      Print("Parsed settings:");
      Settings settings[];
      Settings::parseAll(WorkSymbols, settings);
      const int n = ArraySize(settings);
      pool = new TradingStrategyPool(n);
      for(int i = 0; i < n; i++)
      {
         settings[i].trailing = Trailing;
         // 为对冲账户支持在一个交易品种上使用多个系统
         settings[i].magic = Magic + i;  // 为每个子系统设置不同的魔术数字
         pool[].push(new SimpleMartingale(settings[i]));
      }
   }
   return INIT_SUCCEEDED;
}

需要注意的是,在 MQL5 中,输入字符串的长度限制为 250 个字符。此外,在测试器进行优化时,字符串会进一步截断为最多 63 个字符。因此,为了对多个交易品种的并发交易进行优化,必须设计一种替代的设置加载方法,例如从文本文件中读取设置。通过使用相同的输入变量,可以轻松实现这一点,只需将其指定为文件名而非包含设置的字符串即可。

这种方法在上述的 Settings::parseAll 方法中实现。将无长度限制的输入字符串传递给智能交易系统的文本文件名,按照适用于所有类似情况的通用原则设置:文件名以智能交易系统的名称开头,然后在连字符之后必须是包含文件数据的变量名称。例如,在我们的例子中,在 WorkSymbols 输入变量中,可以选择指定文件名 MultiMartingale - WorkSymbols.txt。然后,parseAll 方法将尝试从文件中读取文本(该文件应位于标准的 MQL5/Files 沙箱中)。

在输入参数中传递文件名时,需要采取额外的步骤来对这样的智能交易系统进行进一步测试和优化:应在源代码中添加 #property tester_file "MultiMartingale - WorkSymbols.txt" 指令。这将在“测试器预处理器指令”部分详细讨论。添加该指令后,智能交易系统在测试器中运行时将需要该文件的存在,否则将无法启动!

智能交易系统的测试与优化

智能交易系统已准备就绪。我们可以分别在不同的交易品种上对其进行测试,为每个交易品种选择最佳设置,并构建一个交易组合。在下一章中,我们将学习测试器 API,包括优化功能,这个智能交易系统将派上用场。在此期间,让我们来检查一下它的多货币交易功能。

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 报告不提供按交易品种细分的统计信息,因此只能通过交易/订单/头寸表来区分单货币报告和多货币报告)。

多货币马丁格尔策略智能交易系统的测试器报告

需要注意的是,由于该策略是从 OnTick 处理函数启动的,在不同的主要交易品种(即在测试器设置下拉列表中选择的品种)上运行会得到略有不同的结果。在我们的测试中,我们只是简单地使用 EURUSD 作为流动性最强、报价最频繁的交易品种,这对于大多数应用来说已经足够。然而,如果你想对所有交易品种的报价做出反应,可以使用像 EventTickSpy.mq5 这样的指标。另外,你也可以在定时器上运行交易逻辑,而不依赖于特定交易品种的报价。

下面是针对单个交易品种(在本例中为 AUDJPY)的交易策略图表:

多货币马丁格尔策略智能交易系统的测试图表

顺便说一下,对于所有多货币智能交易系统,还有另一个重要问题在这里没有涉及。我们讨论的是手数大小的选择方法,例如基于存款负载或风险。之前,我们在非交易智能交易系统 LotMarginExposureTable.mq5 中展示了此类计算的示例。在 MultiMartingale.mq5 中,我们通过为每个交易品种选择固定手数并在设置中显示来简化了这个问题。然而,在实际的多货币智能交易系统中,根据交易品种的价值(按保证金或波动性)成比例地选择手数是有意义的。

总结与展望

最后,我想指出,多货币策略可能需要不同的优化原则。本文所讨论的策略可以分别为各个交易品种找到参数,然后将它们组合起来。然而,一些套利和集群策略(例如配对交易)是基于对所有交易工具的同时分析来做出交易决策的。在这种情况下,与所有交易品种相关的设置应单独包含在输入参数中。

专家顾问的局限性和优势

由于其特定的运行方式,专家顾问(EA)存在一些局限性,同时相较于其他类型的 MQL 程序也具有一定的优势。具体而言,所有为指标设计的函数在专家顾问中都是被禁止使用的,这些函数包括:

  • SetIndexBuffer
  • IndicatorSetDouble
  • IndicatorSetInteger
  • IndicatorSetString
  • PlotIndexSetDouble
  • PlotIndexSetInteger
  • PlotIndexSetString
  • PlotIndexGetInteger

此外,专家顾问不应定义其他类型程序中常见的事件处理函数,例如 OnStart(脚本和服务程序)和 OnCalculate(指标)。 与指标不同的是,每个图表上只能放置一个专家顾问。 与此同时,专家顾问是 MQL 程序中唯一一种除了可以进行测试(我们已经对指标和专家顾问都进行过测试)之外,还能够进行优化的程序类型。优化器可以根据各种标准(包括交易标准和抽象的数学标准)找到最佳的输入参数。为了实现这些目的,API 中包含了额外的函数以及几个特定的事件处理函数。我们将在下一章学习这部分内容。 另外,在专家顾问中(同样也在脚本和服务程序中,即除指标外的所有程序类型中),可以使用一组内置的 MQL5 函数,这些函数用于在套接字级别与网络进行交互,以及处理各种互联网协议(如 HTTP、FTP、SMTP)。我们将在本书的第七部分对这些函数进行探讨。

在 MQL 向导中创建专家顾问

至此,我们即将完成对用于开发专家顾问的交易 API 的学习。在本章中,我们探讨了各种示例,你可以将它们作为自己项目的起点。不过,如果你想从头开始创建一个专家顾问,也不必真的“从零开始”。MetaEditor 提供了内置的 MQL 向导,它能让你创建专家顾问模板。此外,对于专家顾问,该向导提供了两种不同的源代码生成方式。

我们在“MQL 向导与程序草稿”部分已经了解过向导的第一步。显然,在第一步中,我们要选择要创建的项目类型。在前面提到的章节中,我们创建了一个脚本模板。之后,在指标相关章节中,我们了解了如何创建指标模板。现在,我们来看看以下两种选项:

  • 专家顾问(模板)
  • 专家顾问(生成)

第一种选项较为简单。你可以选择名称、输入参数和所需的事件处理函数,如下方截图所示,但生成的源文件中不会包含交易逻辑和现成的算法。

第二种选项则更复杂。它会基于标准库生成一个现成的专家顾问,该标准库在 MetaTrader 5 标准包中的头文件里提供了一组类。这些文件位于 MQL5/Include/Expert/MQL5/Include/TradeMQL5/Include/Indicators 等文件夹中。库中的类实现了最常用的指标信号、基于信号组合执行交易操作的机制,以及资金管理和追踪止损算法。对标准库的详细研究超出了本书的范围。

无论你选择哪种选项,在向导的第二步,都需要输入专家顾问的名称和输入参数。这一步的界面与“MQL 向导与程序草稿”部分展示的类似。唯一需要注意的是,基于标准库的专家顾问必须包含两个强制(不可移除)参数:Symbol(交易品种)和 TimeFrame(时间周期)。

对于简单模板,在第三步,你可以选择要添加到源代码中的额外事件处理函数,除了 OnTickOnTick 总是会被插入)。

创建专家顾问模板 - 第三步:额外事件处理函数

最后一步(第四步)允许你为测试器指定一个或多个可选的事件处理函数。我们将在下一章讨论这些内容。

创建专家顾问模板 - 第四步:测试器事件处理函数

如果用户在向导的第一步选择基于标准库生成程序,那么第三步就是设置交易信号。

生成现成的专家顾问 - 第三步:设置交易信号

你可以在文档中了解更多相关信息。

第四步和第五步用于在专家顾问中加入追踪止损功能,并根据预定义的方法之一自动选择手数。

生成现成的专家顾问 - 第四步:选择追踪止损方法

生成现成的专家顾问 - 第五步:选择手数

当然,向导并非万能工具,生成的程序原型通常需要进一步完善。不过,通过本章所学的知识,你在面对生成的源代码时会更有信心,并能根据需要对其进行扩展。