Skip to content

自定义交易品种

MetaTrader 5 一个有趣的技术特性是支持自定义金融交易品种。这些交易品种并非由经纪商在服务器端定义,而是由交易者直接在终端中定义。

自定义交易品种可以与标准交易品种一起添加到“市场报价”列表中。可以像平常一样使用这些交易品种的图表。

创建自定义交易品种最简单的方法是在相应属性中指定其计算公式。为此,从终端界面在“市场报价”窗口中调用上下文菜单,执行“交易品种”命令,进入交易品种层级结构及其“自定义”分支,然后按下“创建交易品种”按钮。结果会打开一个用于设置新交易品种属性的对话框。在同一位置,你可以从文件中将外部的跳动数据历史记录(“跳动数据”选项卡)或报价数据(“柱形图”选项卡)导入到类似的工具中。这在 MetaTrader 5 文档中有详细讨论。

然而,MQL5 应用程序编程接口(API)能对自定义交易品种进行最全面的控制。

对于自定义交易品种,API 提供了一组用于处理金融交易品种和“市场报价”的函数。特别是,可以使用诸如 SymbolsTotalSymbolNameSymbolInfo 等标准函数从程序中列出这些交易品种。我们在“自定义交易品种属性”部分已经简要提及了这种可能性,并提供了一个示例。自定义交易品种的一个显著特征是启用了 SYMBOL_CUSTOM 标志(属性)。

使用内置函数,你可以拼接期货合约、生成具有指定特征的随机时间序列、模拟砖形图、等量图、等交易量图以及其他非标准类型的图表(例如,秒级时间框架图)。此外,与导入静态文件不同,通过软件控制的自定义交易品种可以根据来自加密货币交易所等网络服务的数据实时生成。关于将 MQL 程序与网络集成的讨论尚待展开,但这种可能性不容忽视。

自定义交易品种可以轻松用于在策略测试器中测试交易策略,或作为技术分析的一种额外方法。然而,这项技术也有其局限性。

由于自定义交易品种是在终端中定义的,而非在服务器端,所以无法对其进行在线交易。特别是,如果你创建了一个砖形图,基于该图的交易策略将需要以某种方式进行调整,以便交易信号和实际交易能够通过不同的交易品种区分开来:人工设定的用户交易品种和真实的经纪商交易品种。我们将探讨几个解决该问题的方案。

此外,由于平台中同一时间框架的所有柱形图的持续时间是相同的,任何对不同周期柱形图(砖形图、等交易量图等)的模拟通常都基于可用的最小的 M1 时间框架,并且无法与现实实现完全的时间同步。换句话说,属于这种柱形图的跳动数据在 60 秒内被赋予了一个人为设定的时间,即使一个砖形图的“砖块”或给定交易量的柱形图实际上需要更多时间来形成。否则,如果我们按照实时跳动数据来处理,它们将形成下一个 M1 柱形图,从而违反砖形图或等交易量图的规则。而且,存在这样的情况,即一个砖形图的“砖块”或其他人工生成的柱形图应该与前一个柱形图的时间间隔小于 1 分钟(例如,在市场快速波动加剧时)。在这种情况下,将有必要更改自定义交易品种报价中历史柱形图的时间(“追溯性”地将它们向左移动),或者为新柱形图设置未来的时间(这是非常不可取的)。在用户自定义交易品种技术的框架内,这个问题无法以通用的方式得到解决。

创建和删除自定义交易品种

要使用自定义交易品种,你首先需要用到两个函数:CustomSymbolCreateCustomSymbolDelete

创建自定义交易品种:CustomSymbolCreate

c
bool CustomSymbolCreate(const string name, const string path = "", const string origin = NULL)

该函数用于在指定的分组(路径)中创建一个具有指定名称的自定义交易品种。若有需要,还能以某个示例交易品种为模板,其名称可在 origin 参数中指定。

  • name 参数:应为一个简单的标识符,不包含层级结构。若需要指定一级或多级分组(子文件夹),应在 path 参数中进行设置,分隔符为反斜杠 \(与文件系统不同,这里不支持正斜杠)。在字符串字面量中,反斜杠必须使用双反斜杠 \\
  • path 参数:若为空字符串(""NULL),交易品种将直接创建在 Custom 文件夹中,该文件夹是为用户自定义交易品种在交易品种总层级结构中分配的。若 path 不为空,交易品种将在 Custom 文件夹内按照指定路径的完整深度创建(若相应文件夹不存在则会自动创建)。
  • 名称规则:交易品种名称以及任意层级的分组名称可包含拉丁字母和数字,不能包含标点符号、空格和特殊字符,仅允许使用 ._&#。名称在整个交易品种层级结构中必须唯一,无论交易品种计划创建在哪个分组中。若同名交易品种已存在,函数将返回 false,并在 _LastError 中设置错误代码 5300(ERR_NOT_CUSTOM_SYMBOL)或 5304(ERR_CUSTOM_SYMBOL_EXIST)。
  • 路径处理:若 path 字符串层级结构中的最后一个(甚至是唯一的)元素与 name 完全匹配(区分大小写),则该元素将被视为路径中的交易品种名称,而非文件夹。例如,若 namepath 分别为 "Example""MQL5Book\\Example",则交易品种 "Example" 将创建在 "Custom\\MQL5Book\\" 文件夹中;若将 name 改为 "example",则 "example" 交易品种将创建在 "Custom\\MQL5Book\\Example" 文件夹中。
  • SYMBOL_PATH 属性:该属性返回的路径末尾包含交易品种名称。若直接将某个示例交易品种的 SYMBOL_PATH 属性值复制给新创建的交易品种,会在原交易品种名称的文件夹内创建新的交易品种。若要在与原交易品种相同的分组中创建自定义交易品种,必须从 SYMBOL_PATH 属性获取的字符串中去除原交易品种的名称。不过,这种复制 SYMBOL_PATH 属性的效果也可加以利用,例如基于一个原交易品种创建多个新交易品种时,复制 SYMBOL_PATH 可确保所有新交易品种都放置在以原交易品种命名的文件夹中,实现按原型交易品种对交易品种进行分组。自定义交易品种的 SYMBOL_PATH 属性始终以 "Custom\\" 文件夹开头(此前缀会自动添加)。
  • 长度限制name 长度限制为 31 个字符,超出限制时,CustomSymbolCreate 将返回 false,并设置错误代码 5302(ERR_CUSTOM_SYMBOL_NAME_LONG)。path 参数的最大长度为 127 个字符,包括 "Custom\\"、分组分隔符 \\ 以及若在末尾指定的交易品种名称。
  • origin 参数:可选择指定一个交易品种的名称,新创建的自定义交易品种将复制该交易品种的属性。创建自定义交易品种后,可使用相应函数(见 CustomSymbolSet 函数)将其任意属性修改为所需值。若 origin 参数指定的交易品种不存在,自定义交易品种将被创建为“空”的,就像未指定 origin 参数一样,此时会引发错误 4301(ERR_MARKET_UNKNOWN_SYMBOL)。新创建的“空白”交易品种的所有属性都将设置为默认值,例如合约大小为 100000,价格小数位数为 4,保证金计算遵循外汇规则,图表绘制基于买入价(Bid)。指定 origin 时,仅从该交易品种复制设置,不会复制报价或报价数据点,因为它们需要单独生成,后续章节会对此进行讨论。创建交易品种不会自动将其添加到市场报价窗口,因此必须显式(手动或通过编程)添加,否则图表窗口将为空。

删除自定义交易品种:CustomSymbolDelete

c
bool CustomSymbolDelete(const string name)

该函数用于删除具有指定名称的自定义交易品种,不仅会删除设置,还会删除该交易品种的所有数据(报价和报价数据点)。需要注意的是,历史数据不会立即删除,而是会有一定延迟,若打算使用相同名称重新创建交易品种,这可能会引发问题(在“添加、替换和删除报价”部分的示例中会涉及此点)。只能删除自定义交易品种,不能删除市场报价窗口中选中的交易品种或有打开图表的交易品种。需注意,交易品种也可能被隐式选中,即使未显示在可见列表中(此时 SYMBOL_VISIBLE 属性为 falseSYMBOL_SELECT 属性为 true),在尝试删除此类交易品种之前,必须先调用 SymbolSelect("name", false) 将其“隐藏”,否则会出现 CUSTOM_SYMBOL_SELECTED(5306)错误。若删除交易品种后使文件夹(或文件夹层级结构)为空,该文件夹也会被删除。

示例脚本:CustomSymbolCreateDelete.mq5

c
input string CustomSymbol = "Dummy";         // 自定义交易品种名称
input string CustomPath = "MQL5Book\\Part7"; // 自定义交易品种文件夹
input string Origin;

void OnStart()
{
   bool custom = false;
   if(!PRTF(SymbolExist(CustomSymbol, custom)))
   {
      if(IDYES == MessageBox("Create new custom symbol?", "Please, confirm", MB_YESNO))
      {
         PRTF(CustomSymbolCreate(CustomSymbol, CustomPath, Origin));
      }
   }
   else
   {
      if(custom)
      {
         if(IDYES == MessageBox("Delete existing custom symbol?", "Please, confirm", MB_YESNO))
         {
            PRTF(CustomSymbolDelete(CustomSymbol));
         }
      }
      else
      {
         Print("Can't delete non-custom symbol");
      }
   }
}

使用默认选项连续运行两次该脚本,日志应显示如下内容:

plaintext
SymbolExist(CustomSymbol,custom)=false / ok
Create new custom symbol?
CustomSymbolCreate(CustomSymbol,CustomPath,Origin)=true / ok

SymbolExist(CustomSymbol,custom)=true / ok
Delete existing custom symbol?
CustomSymbolDelete(CustomSymbol)=true / ok

在两次运行之间,你可以打开终端中的交易品种对话框,检查交易品种层级结构中是否已出现相应的自定义交易品种。

自定义交易品种属性

自定义交易品种与经纪商提供的交易品种具有相同的属性。这些属性可通过金融工具章节中讨论的标准函数进行读取。

自定义交易品种的属性可以通过一组特殊的 CustomSymbolSet 函数来设置,每种基本数据类型(整数、实数、字符串)对应一个函数。

cpp
bool CustomSymbolSetInteger(const string name, ENUM_SYMBOL_INFO_INTEGER property, long value)
bool CustomSymbolSetDouble(const string name, ENUM_SYMBOL_INFO_DOUBLE property, double value)
bool CustomSymbolSetString(const string name, ENUM_SYMBOL_INFO_STRING property, string value)

这些函数为名为 name 的自定义交易品种将 property 属性的值设置为 value。所有现有的属性被分组到枚举类型 ENUM_SYMBOL_INFO_INTEGERENUM_SYMBOL_INFO_DOUBLEENUM_SYMBOL_INFO_STRING 中,这些枚举在上述章节的各部分中已逐个进行了介绍。

这些函数返回操作成功(true)或失败(false)的指示。操作失败的一个可能原因是并非所有属性都允许更改。当尝试设置只读属性时,会出现错误 CUSTOM_SYMBOL_PROPERTY_WRONG(错误代码 5307)。如果尝试为属性写入无效值,则会出现 CUSTOM_SYMBOL_PARAMETER_ERROR(错误代码 5308)错误。

请注意,如果在交易品种规范中更改了以下任何属性,自定义交易品种的分钟和tick历史记录将被完全删除:

  • SYMBOL_CHART_MODE — 用于构建K线的价格类型(买价或最新价)
  • SYMBOL_DIGITS — 价格值的小数位数
  • SYMBOL_POINT — 一个点的值
  • SYMBOL_TRADE_TICK_SIZE — 一个tick的值,即允许的最小价格变动
  • SYMBOL_TRADE_TICK_VALUE — 每个tick的价格变动成本(另见 SYMBOL_TRADE_TICK_VALUE_PROFITSYMBOL_TRADE_TICK_VALUE_LOSS
  • SYMBOL_FORMULA — 价格计算的公式

如果自定义交易品种是通过公式计算得出的,那么在删除其历史记录后,终端将自动尝试使用更新后的属性创建新的历史记录。然而,对于通过编程生成的交易品种,MQL程序本身必须负责重新计算。

在修改先前创建的自定义交易品种(在 CustomSymbolCreate 函数中指定第三个参数 origin 之后)时,编辑单个属性的需求最为常见。

在其他情况下,批量更改属性可能会产生一些微妙的影响。问题在于属性在内部是相互关联的,更改其中一个属性可能需要其他属性处于特定状态才能使操作成功完成。此外,设置某些属性会导致其他属性自动更改。

以最简单的例子来说,设置 SYMBOL_DIGITS 属性后,你会发现 SYMBOL_POINT 属性也发生了变化。还有一个不太明显的情况:为外汇交易品种设置 SYMBOL_CURRENCY_MARGINSYMBOL_CURRENCY_PROFIT 属性没有效果,因为系统假定货币名称分别占据交易品种名称的前3个和接下来的3个字母(“XXXYYY[后缀]”)。请注意,在创建一个“空”的交易品种后,它默认被视为外汇交易品种,因此在不先更改市场类型的情况下,无法为其设置这些属性。

在复制或设置交易品种属性时,要注意平台存在一些特殊规定。特别是,SYMBOL_TRADE_CALC_MODE 属性在交易品种创建后但未设置任何属性之前的默认值为0,而在 ENUM_SYMBOL_CALC_MODE 枚举中,0对应于 SYMBOL_CALC_MODE_FOREX 成员。同时,外汇交易品种有特殊的命名规则,形式为 XXXYYY(其中 XXX 和 YYY 是货币代码),外加一个可选的后缀。因此,如果你不事先将 SYMBOL_TRADE_CALC_MODE 更改为其他所需模式,指定的交易品种名称的子字符串(前三个和后三个字符)将自动分别成为基础货币(SYMBOL_CURRENCY_BASE)和利润货币(SYMBOL_CURRENCY_PROFIT)的属性。例如,如果你指定名称为 “Dummy”,它将被拆分为两个伪货币 “Dum” 和 “my”。

另一个细微差别是,在以 N 位小数的精度设置 SYMBOL_POINT 属性的值之前,需要确保 SYMBOL_DIGITS 至少为 N。

本书附带了脚本 CustomSymbolProperties.mq5,它允许你尝试创建当前图表交易品种的副本,并在实践中研究产生的效果。特别是,你可以选择交易品种的名称、路径,以及按照语言中属性编号的顺序正向或反向遍历(设置)所有支持的属性。该脚本使用了一个特殊的类 CustomSymbolMonitor,它是上述内置函数的包装器,我们将在后面进行介绍。

设置保证金率

之前,我们学习了 SymbolInfoMarginRate 函数,它会返回经纪商为每个交易品种设置的保证金率。对于自定义交易品种,我们可以使用 CustomSymbolSetMarginRate 函数自由设置这些保证金率。

cpp
bool CustomSymbolSetMarginRate(const string name, ENUM_ORDER_TYPE orderType, double initial, double maintenance)

该函数根据订单的类型和方向来设置保证金率(依据 ENUM_ORDER_TYPE 枚举中的 orderType 值)。用于计算初始保证金和维持保证金(开仓或现有仓位每手的抵押品)的比率,分别通过 initialmaintenance 参数来传递。

最终的保证金金额是根据多个交易品种属性(SYMBOL_TRADE_CALC_MODESYMBOL_MARGIN_INITIALSYMBOL_MARGIN_MAINTENANCE 等)来确定的,这些属性在 “保证金要求” 部分有介绍,所以如果有需要,也应该在自定义交易品种上设置这些属性。

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

借助这个函数以及与保证金计算相关的属性,你可以模拟由于某种原因无法使用的服务器的交易条件,并在测试器中调试你的 MQL 程序。

配置报价和交易时段

有两个 API 函数可用于设置自定义交易品种的报价时段和交易时段。这两个概念在“交易和报价时段安排”部分已有讨论。

plaintext
bool CustomSymbolSetSessionQuote(const string name, ENUM_DAY_OF_WEEK dayOfWeek,
  uint sessionIndex, datetime from, datetime to)

bool CustomSymbolSetSessionTrade(const string name, ENUM_DAY_OF_WEEK dayOfWeek,
  uint sessionIndex, datetime from, datetime to)

CustomSymbolSetSessionQuote 函数用于设置特定星期几(dayOfWeek)、由编号(sessionIndex)指定的报价时段的开始时间(from)和结束时间(to)。CustomSymbolSetSessionTrade 函数则对交易时段执行相同的操作。

时段编号从 0 开始。

时段只能按顺序添加,也就是说,只有在已经存在编号为 0 的时段时,才能添加编号为 1 的时段。如果违反此规则,将不会创建新的时段,并且函数将返回 false

fromto 参数中的日期值以秒为单位进行度量,且 from 的值应小于 to 的值。时间范围限制在两天内,从 0(0 时 0 分 0 秒)到 172800(次日 23 时 59 分 59 秒)。设置这样的日期范围变化是为了能够指定在午夜前开始并在午夜后结束的时段。当交易所相对于经纪商(交易商)服务器位于世界的另一端时,这种情况经常会出现。

如果为 sessionIndex 对应的时段传递了起始和结束参数都为零(from = 0to = 0),那么该时段将被删除,并且后续时段(如果有)的编号将向下移动。

交易时段不能超出报价时段的范围。

例如,我们可以通过调整日内报价时间和时段安排,为不同的时区创建一个交易品种的副本,以便在不同条件下(比如针对任何特殊的经纪商情况)调试交易机器人。

添加、替换和删除报价

自定义交易品种的报价通过两个内置函数来填充:CustomRatesUpdateCustomRatesReplace。这两个函数的输入除了交易品种名称外,都期望接收一个 M1 时间框架的 MqlRates 结构体数组(更高时间框架的数据会自动从 M1 时间框架生成)。CustomRatesReplace 还有一对额外的参数(fromto),用于定义历史数据编辑的时间范围。

c
int CustomRatesUpdate(const string symbol, const MqlRates &rates[], uint count = WHOLE_ARRAY)
int CustomRatesReplace(const string symbol, datetime from, datetime to, const MqlRates &rates[], uint count = WHOLE_ARRAY)

CustomRatesUpdate 函数会将缺失的 K 线添加到历史数据中,并使用数组中的数据替换现有的匹配 K 线。

CustomRatesReplace 函数会用数组中的数据完全替换指定时间间隔内的历史数据。

这两个函数的区别源于不同的预期应用场景。具体区别如下表所示:

CustomRatesUpdateCustomRatesReplace
应用传入的 MqlRates 数组中的元素到历史数据,不考虑它们的时间戳仅应用传入的 MqlRates 数组中落在指定范围内的元素
对于在函数调用前已存在于历史数据中的 M1 K 线,且与数组中的 K 线时间不重合的部分,保持不变对于不在指定范围内的所有历史数据,保持不变
当时间戳匹配时,用数组中的 K 线替换现有的历史 K 线完全删除指定范围内的现有历史 K 线
如果与旧 K 线没有匹配项,则将数组中的元素作为 “新” K 线插入将落在相关范围内的数组中的 K 线插入到指定的历史范围内

rates 数组中的数据必须由有效的开盘价(Open)、最高价(High)、最低价(Low)、收盘价(Close)价格表示,并且 K 线开盘时间不能包含秒数。

fromto 之间的时间间隔是包含两端的:from 等于要处理的第一根 K 线的时间,to 等于最后一根 K 线的时间。

下面的图表更清楚地说明了这些规则。每根 K 线的唯一时间戳用其对应的拉丁字母表示。历史数据中已有的 K 线用大写字母表示,数组中的 K 线用小写字母表示。字符 '-' 表示历史数据或数组中对应时间的空缺。

History                        ABC-EFGHIJKLMN-PQRST------    B
Array                          -------hijk--nopqrstuvwxyz    A
Result of CustomRatesUpdate    ABC-EFGhijkLMnopqrstuvwxyz    R
Result of CustomRatesReplace   ABC-E--hijk--nopqrstuvw---    S
                                    ^                ^
                                    |from          to|    TIME

可选参数 count 设置应使用的 rates 数组中的元素数量(其他元素将被忽略),这允许部分处理传入的数组。默认值 WHOLE_ARRAY 表示整个数组。

自定义交易品种的报价历史数据可以使用 CustomRatesDelete 函数全部或部分删除。

c
int CustomRatesDelete(const string symbol, datetime from, datetime to)

这里,fromto 参数也设置了要删除的 K 线的时间范围。要覆盖整个历史数据,可指定 0LONG_MAX

这三个函数都返回处理的 K 线数量:更新或删除的数量。如果出现错误,结果为 -1

需要注意的是,自定义交易品种的报价不仅可以通过添加现成的 K 线来形成,还可以通过报价数据点数组甚至单个报价数据点的序列来形成。相关函数将在下一节介绍。添加报价数据点时,终端会自动根据它们计算 K 线。这些方法的区别在于,自定义报价数据点历史记录允许在 “真实” 报价数据点模式下测试 MQL 程序,而仅 K 线历史记录将迫使你要么局限于 M1 时间框架的 OHLC 或开盘价模式,要么依赖测试器实现的报价数据点模拟。

此外,一次添加一个报价数据点可以模拟自定义交易品种图表上的标准事件 OnTickOnCalculate,这会使图表像在线工具一样 “动起来”,并且如果 MQL 程序绘制在图表上,还会启动相应的处理函数。但我们将在下一节讨论这个问题。

作为使用新函数的示例,让我们考虑脚本 CustomSymbolRandomRates.mq5。它旨在根据 “随机游走” 原则或对现有报价添加噪声来生成随机报价。后者可用于评估智能交易系统的稳定性。

为了检查报价形成的正确性,我们还支持一种模式,即在脚本启动的图表上创建原始交易品种的完整副本。

所有模式都收集在 RANDOMIZATION 枚举中:

c
enum RANDOMIZATION
{
   ORIGINAL,
   RANDOM_WALK,
   FUZZY_WEAK,
   FUZZY_STRONG,
};

我们实现了两种强度级别的报价噪声:弱和强。

在输入参数中,除了模式外,你还可以选择交易品种层级结构中的文件夹、日期范围以及用于初始化随机数生成器的数字(以便能够重现结果)。

c
input string CustomPath = "MQL5Book\\Part7";    // 自定义交易品种文件夹
input RANDOMIZATION RandomFactor = RANDOM_WALK;
input datetime _From;                           // 起始时间(默认:120 天前)
input datetime _To;                             // 结束时间(默认:当前时间)
input uint RandomSeed = 0;

默认情况下,当未指定日期时,脚本会为过去 120 天生成报价。RandomSeed 参数中的值 0 表示随机初始化。

交易品种的名称是根据当前图表的交易品种和所选设置生成的:

c
const string CustomSymbol = _Symbol + "." + EnumToString(RandomFactor)
   + (RandomSeed? "_" + (string)RandomSeed : "");

OnStart 的开头,我们将准备并检查数据:

c
datetime From;
datetime To;
   
void OnStart()
{
   From = _From == 0? TimeCurrent() - 60 * 60 * 24 * 120 : _From;
   To = _To == 0? TimeCurrent() / 60 * 60 : _To;
   if(From > To)
   {
      Alert("Date range must include From <= To");
      return;
   }
   
   if(RandomSeed != 0) MathSrand(RandomSeed);
   ...

由于脚本很可能需要多次运行,我们将提供删除之前创建的自定义交易品种的功能,并在删除前请求用户确认:

c
   bool custom = false;
   if(PRTF(SymbolExist(CustomSymbol, custom)) && custom)
   {
      if(IDYES == MessageBox(StringFormat("Delete custom symbol '%s'?", CustomSymbol),
         "Please, confirm", MB_YESNO))
      {
         if(CloseChartsForSymbol(CustomSymbol))
         {
            Sleep(500); // 等待更改生效(尝试性等待)
            PRTF(CustomRatesDelete(CustomSymbol, 0, LONG_MAX));
            PRTF(SymbolSelect(CustomSymbol, false));
            PRTF(CustomSymbolDelete(CustomSymbol));
         }
      }
   }
   ...

这里没有展示辅助函数 CloseChartsForSymbol(有兴趣的人可以查看附带的源代码),它的目的是查看打开的图表列表,并关闭那些工作交易品种为要删除的自定义交易品种的图表(不这样做的话,删除操作将无法进行)。

更重要的是要注意使用完整日期范围调用 CustomRatesDelete 函数。如果不这样做,之前用户交易品种的数据将在历史数据库(文件夹 bases/Custom/history/<symbol-name>)中在磁盘上保留一段时间。换句话说,上面最后一行显示的 CustomSymbolDelete 调用不足以实际从终端中清除自定义交易品种。

如果用户决定立即再次创建同名的交易品种(我们在下面的代码中提供了这样的机会),那么旧的报价可能会混入新的报价中。

此外,在用户确认后,将启动生成报价的过程。这是由 GenerateQuotes 函数完成的(见下文):

c
   if(IDYES == MessageBox(StringFormat("Create new custom symbol '%s'?", CustomSymbol),
      "Please, confirm", MB_YESNO))
   {
      if(PRTF(CustomSymbolCreate(CustomSymbol, CustomPath, _Symbol)))
      {
         if(RandomFactor == RANDOM_WALK)
         {
            CustomSymbolSetInteger(CustomSymbol, SYMBOL_DIGITS, 8);
         }
         
         CustomSymbolSetString(CustomSymbol, SYMBOL_DESCRIPTION, "Randomized quotes");
      
         const int n = GenerateQuotes();
         Print("Bars M1 generated: ", n);
         if(n > 0)
         {
            SymbolSelect(CustomSymbol, true);
            ChartOpen(CustomSymbol, PERIOD_M1);
         }
      }
   }

如果成功,新创建的交易品种将在市场报价窗口中被选中,并为其打开一个图表。在此过程中,这里展示了设置一对属性:SYMBOL_DIGITSSYMBOL_DESCRIPTION

GenerateQuotes 函数中,除了 RANDOM_WALK 模式外,对于所有模式都需要请求原始交易品种的报价:

c
int GenerateQuotes()
{
   MqlRates rates[];
   MqlRates zero = {};
   datetime start;     // 当前 K 线的时间
   double price;       // 上一个收盘价
   
   if(RandomFactor != RANDOM_WALK)
   {
      if(PRTF(CopyRates(_Symbol, PERIOD_M1, From, To, rates)) <= 0)
      {
         return 0; // 错误
      }
      if(RandomFactor == ORIGINAL)
      {
         return PRTF(CustomRatesReplace(CustomSymbol, From, To, rates));
      }
      ...

重要的是要记住,CopyRates 函数会受到图表上 K 线数量限制的影响,该限制是在终端设置中设置的。

ORIGINAL 模式下,我们只需将生成的数组 rates 传递给 CustomRatesReplace 函数。对于噪声模式,我们将特别选择的 pricestart 变量设置为第一根 K 线的价格和时间的初始值:

c
      price = rates[0].open;
      start = rates[0].time;
   }
   ...

RANDOM_WALK 模式下,不需要报价,所以我们只是为未来的随机 M1 K 线分配 rates 数组:

c
   else
   {
      ArrayResize(rates, (int)((To - From) / 60) + 1);
      price = 1.0;
      start = From;
   }
   ...

在遍历 rates 数组的循环中,将随机值添加到原始交易品种的有噪声的价格中,或者 “按原样” 添加。在 RANDOM_WALK 模式下,我们自己负责增加 start 变量中的时间。在其他模式下,时间已经包含在初始报价中:

c
   const int size = ArraySize(rates);
   
   double hlc[3]; // 未来的最高价、最低价、收盘价(顺序未知)
   for(int i = 0; i < size; ++i)
   {
      if(RandomFactor == RANDOM_WALK)
      {
         rates[i] = zero;             // 将结构体清零
         rates[i].time = start += 60; // 在上一根 K 线的时间上加一分钟
         rates[i].open = price;       // 从上一个价格开始
         hlc[0] = RandomWalk(price);
         hlc[1] = RandomWalk(price);
         hlc[2] = RandomWalk(price);
      }
      else
      {
         double delta = 0;
         if(i > 0)
         {
            delta = rates[i].open - price; // 累积校正
         }
         rates[i].open = price;
         hlc[0] = RandomWalk(rates[i].high - delta);
         hlc[1] = RandomWalk(rates[i].low - delta);
         hlc[2] = RandomWalk(rates[i].close - delta);
      }
      ArraySort(hlc);
      
      rates[i].high = fmax(hlc[2], rates[i].open);
      rates[i].low = fmin(hlc[0], rates[i].open);
      rates[i].close = price = hlc[1];
      rates[i].tick_volume = 4;
   }
   ...

基于最后一根 K 线的收盘价,生成 3 个随机值(使用 RandomWalk 函数)。其中的最大值和最小值分别成为新 K 线的最高价和最低价,中间值为收盘价。

在循环结束时,我们将数组传递给 CustomRatesReplace 函数:

c
   return PRTF(CustomRatesReplace(CustomSymbol, From, To, rates));
}

RandomWalk 函数中,尝试模拟具有宽尾分布的情况,这在真实报价中是很典型的:

c
double RandomWalk(const double p)
{
   const static double factor[] = {0.0, 0.1, 0.01, 0.05};
   const static double f = factor[RandomFactor] / 100;
   const double r = (rand() - 16383.0) / 16384.0; // [-1,+1]
   const int sign = r >= 0? +1 : -1;
   if(r != 0)
   {
      return p + p * sign * f * sqrt(-log(sqrt(fabs(r))));
   }
   return p;
}

随机变量的分散系数取决于模式。例如,弱噪声最多添加(或减去)价格的万分之一,强噪声添加价格的万分之五。

在运行时,脚本会输出详细的日志,如下所示:

Create new custom symbol 'GBPUSD.RANDOM_WALK'?
CustomSymbolCreate(CustomSymbol,CustomPath,_Symbol)=true / ok
CustomRatesReplace(CustomSymbol,From,To,rates)=171416 / ok
Bars M1 generated: 171416

让我们看看结果如何:

下面的图片展示了几种随机游走的实现(视觉叠加是在图形编辑器中完成的,实际上,每个自定义交易品种像往常一样在单独的窗口中打开):

自定义交易品种的随机游走报价选项

以下是有噪声的英镑兑美元(GBPUSD)报价的样子(原始报价为黑色,带噪声的为彩色)。首先是弱噪声版本:

低噪声的 GBPUSD 报价

然后是强噪声版本:

强噪声的 GBPUSD 报价

很明显可以看到较大的差异,不过局部特征仍然保留着。

添加、替换和删除报价点数据

MQL5 API不仅允许在K线级别生成自定义交易品种的历史数据,还可以在报价点级别进行生成。因此,在测试和优化智能交易系统(EA)时能够实现更高的真实性,同时也可以模拟自定义交易品种图表的实时更新,将报价点数据广播到图表中。在形成K线时,系统会自动考虑传输到系统中的报价点数据集合。换句话说,如果以报价点数据(即MqlTick结构体数组)的形式提供了同一时期内关于价格变化的更详细信息,就无需调用前一节中那些基于MqlRates结构体进行操作的函数。基于每根K线的MqlRates报价数据的唯一优势在于性能和内存效率方面。

有两个用于添加报价点数据的函数:CustomTicksAddCustomTicksReplace。第一个函数用于添加到达“市场报价”窗口的交互式报价点数据(这些数据会由终端自动从窗口传输到报价点数据库),并且会在MQL程序中生成相应的事件。第二个函数则直接将报价点数据写入报价点数据库。

int CustomTicksAdd(const string symbol, const MqlTick &ticks[], uint count = WHOLE_ARRAY)

CustomTicksAdd函数将Ticks数组中的数据添加到symbol指定的自定义交易品种的价格历史数据中。默认情况下,如果count设置为WHOLE_ARRAY,则会添加整个数组的数据。如有必要,可以指定一个较小的数量,仅下载部分报价点数据。

请注意,在调用该函数时,必须在“市场报价”窗口中选择相应的自定义交易品种。对于未在“市场报价”窗口中选择的交易品种,需要使用CustomTicksReplace函数(详见后文)。

报价点数据数组必须按时间升序排序,即对于所有的i < j,必须满足ticks[i].time_msc <= ticks[j].time_msc

该函数返回添加的报价点数量,如果发生错误则返回-1

CustomTicksAdd函数会以与从经纪商服务器接收数据相同的方式将报价点数据广播到图表中。通常,该函数用于添加一个或多个报价点数据。在这种情况下,这些数据会在“市场报价”窗口中“播放”,然后从窗口保存到报价点数据库中。

然而,当在一次调用中传输大量数据时,为了节省资源,该函数会改变其行为方式。如果传输的报价点数据超过256个,它们会被分为两部分。第一部分(数量较多的部分)会立即直接写入报价点数据库(就像CustomTicksReplace函数那样)。第二部分由最后(最新)的128个报价点数据组成,会被传递到“市场报价”窗口,然后由终端保存到数据库中。

MqlTick结构体有两个包含时间值的字段:time(报价点时间,单位为秒)和time_msc(报价点时间,单位为毫秒)。这两个值都是从1970年1月1日开始计时的。已填充(非空)的time_msc字段优先于time字段。请注意,time字段是根据公式time_msc / 1000重新计算后以秒为单位填充的。如果time_msc字段为零,则使用time字段的值,并且time_msc字段会反过来根据公式time * 1000获取以毫秒为单位的值。如果两个字段都等于零,则会将当前服务器时间(精确到毫秒)放入报价点数据中。

在描述交易量的两个字段中,volume_real字段的优先级高于volume字段。

根据特定数组元素(MqlTick结构体)中填充的其他字段,系统会在flags字段中为保存的报价点数据设置标志:

  • ticks[i].bidTICK_FLAG_BID(该报价点改变了买价)
  • ticks[i].askTICK_FLAG_ASK(该报价点改变了卖价)
  • ticks[i].lastTICK_FLAG_LAST(该报价点改变了最后一笔交易的价格)
  • ticks[i].volumeticks[i].volume_realTICK_FLAG_VOLUME(该报价点改变了交易量)

如果某个字段的值小于或等于零,则相应的标志不会写入flags字段。

TICK_FLAG_BUYTICK_FLAG_SELL标志不会添加到自定义交易品种的历史数据中。

CustomTicksReplace函数会用传入数组中的数据完全替换指定时间间隔内自定义交易品种的价格历史数据。

int CustomTicksReplace(const string symbol, long from_msc, long to_msc, const MqlTick &ticks[], uint count = WHOLE_ARRAY)

时间间隔由参数from_mscto_msc设置,单位为自1970年1月1日以来的毫秒数。这两个值都包含在时间间隔内。

Ticks数组必须按照报价点数据到达的时间顺序进行排序,这对应于时间的递增,更确切地说是非递减顺序,因为在具有毫秒精度的数据流中,经常会连续出现时间相同的报价点数据。

count参数可用于处理数组的一部分。

报价点数据会在to_msc指定的时间之前按天顺序进行替换,或者直到在报价点数据的顺序中出现错误为止。首先处理指定范围内的第一天的数据,然后是下一天,依此类推。一旦检测到报价点时间与递增(非递减)顺序之间存在差异,报价点数据的替换过程将在当前日期停止。在这种情况下,之前日期的报价点数据将成功替换,而当前日期(在出现错误报价点数据的时间点)以及指定时间间隔内的所有剩余日期的数据将保持不变。该函数将返回-1,并且_LastError中的错误代码为0(“无错误”)。

如果Ticks数组在from_mscto_msc(包含)之间的总体时间间隔内的某些时间段没有数据,那么在执行该函数后,自定义交易品种的历史数据中将出现与缺失数据对应的空白区间。

如果在指定的时间间隔内报价点数据库中没有数据,CustomTicksReplace函数将从Ticks数组中添加报价点数据到数据库中。

CustomTicksDelete函数可用于删除指定时间间隔内的所有报价点数据。

int CustomTicksDelete(const string symbol, long from_msc, long to_msc)

正在编辑的自定义交易品种的名称在symbol参数中设置,要清除的时间间隔由from_mscto_msc参数设置(包含),单位为毫秒。

该函数返回删除的报价点数量,如果发生错误则返回-1

注意!使用CustomTicksDelete删除报价点数据会导致相应的K线自动删除!但是,调用CustomRatesDelete(即删除K线)不会删除报价点数据!

为了在实践中掌握这些知识,我们将使用新介绍的函数来解决几个实际应用问题。

首先,让我们探讨一个有趣的任务,即基于真实交易品种创建一个自定义交易品种,但降低其报价点密度。与基于真实报价点数据的模式相比,这将加快测试和优化的速度,并减少资源消耗(主要是内存),同时保持可接受的、接近理想的处理质量。

加快测试和优化速度

交易者经常寻求加快智能交易系统优化和测试过程的方法。在可能的解决方案中,有些是显而易见的,只需简单地更改设置(在允许的情况下),而有些则需要更多时间和精力,需要对智能交易系统或测试环境进行调整。

第一类解决方案包括:

  • 通过消除一些参数或减小其步长来缩小优化空间;
  • 缩短优化周期;
  • 切换到质量较低的报价点模拟模式(例如,从真实报价点数据切换到M1时间周期的OHLC数据);
  • 启用以点数而不是货币计算利润;
  • 升级计算机;
  • 使用MQL云或额外的本地网络计算机。

与开发相关的第二类解决方案包括:

  • 进行代码分析,在此基础上可以消除代码中的“瓶颈”;
  • 如果可能,使用资源高效的指标计算方法,即不使用#property tester_everytick_calculate指令;
  • 将指标算法(如果使用了)直接转移到智能交易系统代码中:调用指标会带来一定的开销;
  • 消除图形和对象;
  • 如果可能,进行计算结果缓存;
  • 减少同时开仓的头寸数量和挂单数量(在数量较多时,每次报价点数据更新时对它们的计算可能会很明显);
  • 对结算、订单、交易和头寸进行完全虚拟化:内置的记账机制由于其通用性、多货币支持和其他特性,存在一定的开销,可以通过在MQL5代码中执行类似操作来消除这些开销(尽管这种方法是最耗时的)。

降低报价点密度属于中间类型的解决方案:它需要通过编程创建自定义交易品种,但不会影响智能交易系统的源代码。

一个降低报价点密度的自定义交易品种将由脚本CustomSymbolFilterTicks.mq5生成。初始交易品种将是运行该脚本的图表的当前交易品种。在输入参数中,可以指定自定义交易品种的文件夹以及历史数据处理的开始日期。默认情况下,如果未指定日期,则计算过去120天的数据。

cpp
input string CustomPath = "MQL5Book\\Part7"; // 自定义交易品种文件夹
input datetime _Start;                       // 开始时间(默认:往回120天)

交易品种的名称由源交易品种的名称和.TckFltr后缀组成。稍后我们会在名称中添加报价点减少方法的标识。

cpp
string CustomSymbol = _Symbol + ".TckFltr";
const uint DailySeconds = 60 * 60 * 24;
datetime Start = _Start == 0? TimeCurrent() - DailySeconds * 120 : _Start;

为了方便起见,在OnStart处理函数中,如果之前已经存在同名的交易品种,可以删除它。

cpp
void OnStart()
{
   bool custom = false;
   if(PRTF(SymbolExist(CustomSymbol, custom)) && custom)
   {
      if(IDYES == MessageBox(StringFormat("Delete existing custom symbol '%s'?", CustomSymbol),
         "Please, confirm", MB_YESNO))
      {
         SymbolSelect(CustomSymbol, false);
         CustomRatesDelete(CustomSymbol, 0, LONG_MAX);
         CustomTicksDelete(CustomSymbol, 0, LONG_MAX);
         CustomSymbolDelete(CustomSymbol);
      }
      else
      {
         return;
      }
   }

接下来,在用户同意的情况下,创建一个交易品种。在辅助函数GenerateTickData中用报价点数据填充历史数据。如果成功,脚本会将新交易品种添加到“市场报价”窗口并打开其图表。

cpp
   if(IDYES == MessageBox(StringFormat("Create new custom symbol '%s'?", CustomSymbol),
      "Please, confirm", MB_YESNO))
   {
      if(PRTF(CustomSymbolCreate(CustomSymbol, CustomPath, _Symbol)))
      {
         CustomSymbolSetString(CustomSymbol, SYMBOL_DESCRIPTION, "Prunned ticks by " + EnumToString(Mode));
         if(GenerateTickData())
         {
            SymbolSelect(CustomSymbol, true);
            ChartOpen(CustomSymbol, PERIOD_H1);
         }
      }
   }
}

GenerateTickData函数会在循环中按天分批处理报价点数据。通过调用CopyTicksRange函数请求每天的报价点数据。然后,需要以某种方式减少这些数据,这由TickFilter类实现,我们将在下面展示该类。最后,使用CustomTicksReplace函数将报价点数组添加到自定义交易品种的历史数据中。

cpp
bool GenerateTickData()
{
   bool result = true;
   datetime from = Start / DailySeconds * DailySeconds; // 向上取整到当天开始时间
   ulong read = 0, written = 0;
   uint day = 0;
   const uint total = (uint)((TimeCurrent() - from) / DailySeconds + 1);
   MqlTick array[];
   
   while(!IsStopped() && from < TimeCurrent())
   {
      Comment(TimeToString(from, TIME_DATE), " ", day++, "/", total);
      
      const int r = CopyTicksRange(_Symbol, array, COPY_TICKS_ALL,
         from * 1000L, (from + DailySeconds) * 1000L - 1);
      if(r < 0)
      {
         Alert("Error reading ticks at ", TimeToString(from, TIME_DATE));
         result = false;
         break;
      }
      read += r;
      
      if(r > 0)
      {
         const int t = TickFilter::filter(Mode, array);
         const int w = CustomTicksReplace(CustomSymbol,
            from * 1000L, (from + DailySeconds) * 1000L - 1, array);
         if(w <= 0)
         {
            Alert("Error writing custom ticks at ", TimeToString(from, TIME_DATE));
            result = false;
            break;
         }
         written += w;
      }
      from += DailySeconds;
   }
   
   if(read > 0)
   {
      PrintFormat("Done ticks - read: %lld, written: %lld, ratio: %.1f%%",
         read, written, written * 100.0 / read);
   }
   Comment("");
   return result;
}

在所有阶段都实现了错误控制和已处理报价点数据的计数。最后,我们将初始报价点数量和剩余报价点数量以及“压缩”系数输出到日志中。

现在让我们直接来看减少报价点数据的技术。显然,有很多方法,每种方法对特定的交易策略或多或少都有不同的适用性。我们将提供三种基本版本,它们组合在TickFilter类(TickFilter.mqh)中。此外,为了完整起见,还支持不减少报价点数据直接复制的模式。

因此,该类中实现了以下模式:

  • 不减少
  • 跳过价格单调变化且无反转的报价点序列(类似“之字形”)
  • 跳过价差范围内的价格波动
  • 仅记录当买价或卖价在相邻两个报价点之间呈现极值的分形配置的报价点数据

这些模式被描述为FILTER_MODE枚举的元素。

cpp
class TickFilter
{
public:
   enum FILTER_MODE
   {
      NONE,
      SEQUENCE,
      FLUTTER,
      FRACTALS,
   };
   ...

每种模式都由一个单独的静态方法实现,该方法接受一个需要精简的报价点数据数组作为输入。数组的编辑是在原地进行的(不分配新的输出数组)。

cpp
   static int filterBySequences(MqlTick &data[]);
   static int filterBySpreadFlutter(MqlTick &data[]);
   static int filterByFractals(MqlTick &data[]);

所有方法都返回剩余的报价点数量(精简后的数组大小)。

为了统一在不同模式下执行该过程,提供了filter方法。对于NONE模式,数据数组保持不变。

cpp
   static int filter(FILTER_MODE mode, MqlTick &data[])
   {
      switch(mode)
      {
      case SEQUENCE: return filterBySequences(data);
      case FLUTTER: return filterBySpreadFlutter(data);
      case FRACTALS: return filterByFractals(data);
      }
      return ArraySize(data);
   }

例如,下面是filterBySequences方法中如何实现按单调报价点序列进行过滤的。

cpp
   static int filterBySequences(MqlTick &data[])
   {
      const int size = ArraySize(data);
      if(size < 3) return size;
      
      int index = 2;
      bool dirUp = data[1].bid - data[0].bid + data[1].ask - data[0].ask > 0;
      
      for(int i = 2; i < size; i++)
      {
         if(dirUp)
         {
            if(data[i].bid - data[i - 1].bid + data[i].ask - data[i - 1].ask < 0)
            {
               dirUp = false;
               data[index++] = data[i];
            }
         }
         else
         {
            if(data[i].bid - data[i - 1].bid + data[i].ask - data[i - 1].ask > 0)
            {
               dirUp = true;
               data[index++] = data[i];
            }
         }
      }
      return ArrayResize(data, index);
   }

下面是分形精简的实现方式。

cpp
   static int filterByFractals(MqlTick &data[])
   {
      int index = 1;
      const int size = ArraySize(data);
      if(size < 3) return size;
      
      for(int i = 1; i < size - 2; i++)
      {
         if((data[i].bid < data[i - 1].bid && data[i].bid < data[i + 1].bid)
         || (data[i].ask > data[i - 1].ask && data[i].ask > data[i + 1].ask))
         {
            data[index++] = data[i];
         }
      }
      
      return ArrayResize(data, index);
   }

让我们依次在几种降低报价点密度的模式下为EURUSD创建一个自定义交易品种,并比较它们的性能,即“压缩”程度、测试速度以及智能交易系统的交易表现会如何变化。

例如,对报价点序列进行精简会得到以下结果(基于MQ模拟账户中一年半的历史数据):

Create new custom symbol 'EURUSD.TckFltr-SE'?
Fixing SYMBOL_TRADE_TICK_VALUE: 0.0 <<< 1.0
true  SYMBOL_TRADE_TICK_VALUE 1.0 -> SUCCESS (0)
Fixing SYMBOL_TRADE_TICK_SIZE: 0.0 <<< 1e-05
true  SYMBOL_TRADE_TICK_SIZE 1e-05 -> SUCCESS (0)
Number of found discrepancies: 2
Fixed
Done ticks - read: 31553509, written: 16927376, ratio: 53.6%

对于平滑波动模式和分形模式,指标有所不同:

EURUSD.TckFltr-FL will be updated
Done ticks - read: 31568782, written: 22205879, ratio: 70.3%
...   
Create new custom symbol 'EURUSD.TckFltr-FR'?
...
Done ticks - read: 31569519, written: 12732777, ratio: 40.3%

对于基于压缩报价点数据的实际交易实验,我们需要一个智能交易系统。让我们采用经过改编的BandOsMATicks.mq5版本,与原始版本相比,它启用了在每个报价点上进行交易(在SimpleStrategy::trade方法中,if(lastBar == iTime(_Symbol, _Period, 0)) return false;这一行被禁用),并且信号指标的值取自第0根和第1根K线(之前仅取自已完成的第1根和第2根K线)。

让我们使用2021年初到2022年6月1日的日期范围来运行这个智能交易系统。设置已附加在文件MQL5/Presets/MQL5Book/BandOsMAticks.set中。在所有模式下,账户余额曲线的总体走势相当相似。

不同报价点模式下的测试账户余额组合图表

不同报价点模式下的测试账户余额组合图表

不同曲线的等效极值在水平方向上的偏移是因为标准报告图表在水平坐标上使用的不是时间,而是交易次数。当然,由于不同报价点基础上的交易信号触发精度不同,交易次数也会有所不同。

性能指标的差异如下表所示(N - 交易次数,$ - 利润,PF - 利润系数,RF - 恢复系数,DD - 回撤):

模式报价点数量时间(mm:ss.msec)内存N$PFRFDD
真实报价点3100291902:45.251835 Mb962166.241.322.8854.99
模拟报价点2580813901:58.131687 Mb928171.941.343.4447.64
OHLC M1208482000:11.094224 Mb856193.521.393.9746.55
序列精简1631023601:24.784559 Mb860168.951.342.9255.16
波动平滑2136261601:52.172623 Mb920179.751.373.6047.28
分形精简1227085401:04.756430 Mb866142.191.272.4754.80

我们认为基于真实报价点数据的测试是最可靠的,并根据与该测试的接近程度来评估其他测试。显然,OHLC M1模式由于精度大幅下降,显示出最高的速度和较低的资源消耗(未考虑开盘价模式)。它呈现出过于乐观的财务结果。

在三种人为压缩报价点数据的模式中,“序列精简”在一系列指标方面最接近真实情况。它在时间上比真实模式快2倍,在内存消耗方面比真实模式高效1.5倍。“波动平滑”模式似乎能更好地保留原始交易次数。最快且最不消耗内存的分形模式,当然比OHLC M1模式需要更多的时间和资源,但它不会高估交易得分。

请记住,报价点减少算法在不同的交易策略、金融工具,甚至特定经纪商的报价点历史数据中可能会有不同的表现,或者相反,可能会产生较差的结果。请在您自己的智能交易系统和工作环境中进行研究。

作为使用自定义交易品种的第二个示例,让我们考虑一个有趣的功能,即通过CustomTicksAdd函数进行报价点数据转换。

许多交易者使用交易面板——带有交互式控件的程序,用于手动执行任意交易操作。他们主要需要在线练习使用这些面板,因为测试器存在一些限制。首先,测试器不支持图表上的事件和对象。这会导致控件停止工作。此外,在测试器中,您不能应用任意对象进行图形标记。

让我们尝试解决这些问题。

我们可以基于历史报价点数据以慢动作生成一个自定义交易品种。然后,这样一个交易品种的图表将成为一个可视化测试器的替代品。

这种方法有几个优点:

  • 所有图表事件的标准行为
  • 交互式应用和设置指标
  • 交互式应用和调整对象
  • 随时切换时间框架
  • 测试直到当前时间的历史数据,包括今天(标准测试器不允许测试今天的数据)

关于最后一点,我们注意到MetaTrader 5的开发者故意禁止检查最后(当前)一天的交易,尽管有时需要快速查找(代码或交易策略中的)错误。

在进行中修改价格(例如增加价差)也可能很有趣。

基于这样一个自定义交易品种的图表,我们以后可以在历史数据上实现一个手动交易模拟器。

符号生成器将是非交易性的智能交易系统CustomTester.mq5。在其输入参数中,我们将提供新自定义交易品种在符号层次结构中的位置指示、过去用于报价点转换(以及构建自定义交易品种报价)的开始日期,以及图表的时间框架,该图表将自动打开用于可视化测试。

cpp
input string CustomPath = "MQL5Book\\Part7"; // 自定义交易品种文件夹
input datetime _Start;                       // 开始时间(默认缩进120天)
input ENUM_TIMEFRAMES Timeframe = PERIOD_H1;

新交易品种的名称由当前图表的交易品种名称和.Tester后缀组成。

cpp
string CustomSymbol = _Symbol + ".Tester";

如果在参数中未指定开始日期,智能交易系统将从当前日期往回缩进120天。

cpp
const uint DailySeconds = 60 * 60 * 24;
datetime Start = _Start == 0? TimeCurrent() - DailySeconds * 120 : _Start;

报价点数据将从工作交易品种的真实报价点历史数据中一次性按天批量读取。正在读取的日期指针存储在Cursor变量中。

cpp
bool FirstCopy = true;
// 另外再往前1天,因为否则图表不会立即更新
datetime Cursor = (Start / DailySeconds - 1) * DailySeconds; // 在日期边界处取整

要重现的一天的报价点数据将在Ticks数组中请求,然后从该数组中以大小为step的小批次将它们转换到自定义交易品种的图表上。

cpp
MqlTick Ticks[];       // 过去“当前”一天的报价点数据
int Index = 0;         // 一天内报价点数据中的位置
int Step = 32;         // 每次快进32个报价点(默认)
int StepRestore = 0;   // 记住暂停期间的速度
long Chart = 0;        // 创建的自定义交易品种图表
bool InitDone = false; // 初始化完成的标志

为了以恒定的速率播放报价点数据,让我们在OnInit中启动计时器。

cpp
void OnInit()
{
   EventSetMillisecondTimer(100);
}
   
void OnTimer()
{
   if(!GenerateData())
   {
      EventKillTimer();
   }
}

报价点数据将由GenerateData函数生成。在启动后,当InitDone标志被重置时,我们将尝试创建一个新交易品种,或者如果自定义交易品种已经存在,则清除旧的报价和报价点数据。

cpp
bool GenerateData()
{
   if(!InitDone)
   {
      bool custom = false;
      if(PRTF(SymbolExist(CustomSymbol, custom)) && custom)
      {
         if(IDYES == MessageBox(StringFormat("Clean up existing custom symbol '%s'?",
            CustomSymbol), "Please, confirm", MB_YESNO))
         {
            PRTF(CustomRatesDelete(CustomSymbol, 0, LONG_MAX));
            PRTF(CustomTicksDelete(CustomSymbol, 0, LONG_MAX));
            Sleep(1000);
            MqlRates rates[1];
            MqlTick tcks[];
            if(PRTF(CopyRates(CustomSymbol, PERIOD_M1, 0, 1, rates)) == 1
            || PRTF(CopyTicks(CustomSymbol, tcks) > 0))
            {
               Alert("Can't delete rates and Ticks, internal error");
               ExpertRemove();
            }
         }
         else
         {
            return false;
         }
      }
      else
      if(!PRTF(CustomSymbolCreate(CustomSymbol, CustomPath, _Symbol)))
      {
         return false;
      }
      ... // (A)

此时,我们将在(A)处省略一些内容,稍后再回到这一点。

在创建交易品种后,我们在“市场报价”窗口中选择它,并为其打开一个图表。

cpp
 SymbolSelect(CustomSymbol, true);
      Chart = ChartOpen(CustomSymbol, Timeframe);
      ... // (B)
      ChartSetString(Chart, CHART_COMMENT, "Custom Tester");
      ChartSetInteger(Chart, CHART_SHOW_OBJECT_DESCR, true);
      ChartRedraw(Chart);
      InitDone = true;
   }
   ...

这里也缺少几行(B),它们与未来的改进有关,但目前还不需要。

如果交易品种已经创建,我们开始以Step个报价点为一批次广播报价点数据,但不超过256个。此限制与CustomTicksAdd函数的特性有关。

cpp
   else
   {
      for(int i = 0; i <= (Step - 1) / 256; ++i)
      if(Step > 0 && !GenerateTicks())
      {
         return false;
      }
   }
   return true;
}

辅助函数GenerateTicksStep个报价点为一批次(但不超过256个)广播报价点数据,通过偏移量Index从每日数组Ticks中读取数据。当数组为空或我们已读取到数组末尾时,我们通过调用FillTickBuffer请求下一天的报价点数据。

cpp
bool GenerateTicks()
{
   if(Index >= ArraySize(Ticks)) // 每日数组为空或已读取到末尾
   {
      if(!FillTickBuffer()) return false; // 用一天的报价点数据填充数组
   }
   
   const int m = ArraySize(Ticks);
   MqlTick array[];
   const int n = ArrayCopy(array, Ticks, 0, Index, fmin(fmin(Step, 256), m));
   if(n <= 0) return false;
   
   ResetLastError();
   if(CustomTicksAdd(CustomSymbol, array) != ArraySize(array) || _LastError != 0)
   {
      Print(_LastError); // 如果出现ERR_CUSTOM_TICKS_WRONG_ORDER (5310)错误
      ExpertRemove();
   }
   Comment("Speed: ", (string)Step, " / ", STR_TIME_MSC(array[n - 1].time_msc));
   Index += Step; // 向前移动“Step”个报价点
   return true;
}

FillTickBuffer函数使用CopyTicksRange进行操作。

cpp
bool FillTickBuffer()
{
   int r;
   ArrayResize(Ticks, 0);
   do
   {
      r = PRTF(CopyTicksRange(_Symbol, Ticks, COPY_TICKS_ALL, Cursor * 1000L,
         (Cursor + DailySeconds) * 1000L - 1));
      if(r > 0 && FirstCopy)
      {
         // 注意:此预调用仅用于将图表从“等待更新”状态显示出来
         PRTF(CustomTicksReplace(CustomSymbol, Cursor * 1000L,
            (Cursor + DailySeconds) * 1000L - 1, Ticks));
         FirstCopy = false;
         r = 0;
      }
      Cursor += DailySeconds;
   }
   while(r == 0 && Cursor < TimeCurrent()); // 跳过非交易日
   Index = 0;
   return r > 0;
}

当智能交易系统停止时,我们也将关闭相关的图表(以便在下一次启动时不会重复)。

cpp
void OnDeinit(const int)
{
   if(Chart != 0)
   {
      ChartClose(Chart);
   }
   Comment("");
}

此时,智能交易系统可以认为已完成,但存在一个问题。原因是,由于某种原因,自定义交易品种的属性不会“原样”从原始工作交易品种复制过来,至少在当前的MQL5 API实现中是这样。这甚至适用于非常重要的属性,如SYMBOL_TRADE_TICK_VALUESYMBOL_TRADE_TICK_SIZE。如果我们在调用CustomSymbolCreate(CustomSymbol, CustomPath, _Symbol)后立即打印这些属性的值,我们会看到那里是零。

为了组织属性检查、比较,并在必要时进行修正,我们编写了一个特殊的类CustomSymbolMonitorCustomSymbolMonitor.mqh),它派生自SymbolMonitor。您可以自行研究其内部结构,在这里我们仅展示其公共接口。

构造函数允许创建一个自定义交易品种监视器,指定一个示例工作交易品种(通过字符串名称,或从SymbolMonitor对象),该品种用作设置的来源。

cpp
class CustomSymbolMonitor: public SymbolMonitor
{
public:
   CustomSymbolMonitor(); // 示例 - _Symbol
   CustomSymbolMonitor(const string s, const SymbolMonitor *m = NULL);
   CustomSymbolMonitor(const string s, const string other);
   
   //设置/替换示例交易品种   
   void inherit(const SymbolMonitor &m);
   
   // 按正向或反向顺序从示例交易品种复制所有属性
   bool setAll(const bool reverseOrder = true, const int limit = UCHAR_MAX);
   
   // 根据示例检查所有属性,返回修正的数量
   int verifyAll(const int limit = UCHAR_MAX);
   
   // 根据示例检查指定的属性,返回修正的数量
   int verify(const int &properties[]);
   
   // 从示例复制给定的属性,如果所有属性都应用成功则返回true
   bool set(const int &properties[]);
   
   // 从示例复制特定属性,如果应用成功则返回true
   template<typename E>
   bool set(const E e);
   
   bool set(const ENUM_SYMBOL_INFO_INTEGER property, const long value) const
   {
      return CustomSymbolSetInteger(name, property, value);
   }
   
   bool set(const ENUM_SYMBOL_INFO_DOUBLE property, const double value) const
   {
      return CustomSymbolSetDouble(name, property, value);
   }
   
   bool set(const ENUM_SYMBOL_INFO_STRING property, const string value) const
   {
      return CustomSymbolSetString(name, property, value);
   }
};

由于自定义交易品种与标准交易品种不同,允许设置自己的属性,因此在该类中添加了三个设置方法。特别是,它们用于批量传输示例交易品种的属性,并在其他类方法中检查这些操作的成功情况。

我们现在可以回到自定义交易品种生成器及其源代码片段,如前面注释(A)所指示的那样。

cpp
      // (A) 检查重要属性并以“手动”模式设置它们
      SymbolMonitor sm; // _Symbol
      CustomSymbolMonitor csm(CustomSymbol, &sm);
      int props[] = {SYMBOL_TRADE_TICK_VALUE, SYMBOL_TRADE_TICK_SIZE};
      const int d1 = csm.verify(props); // 检查并尝试修复
      if(d1)
      {
         Print("Number of found discrepancies: ", d1); // 编辑的数量
         if(csm.verify(props)) // 再次检查
         {
            Alert("Custom symbol can not be created, internal error!");
            return false; // 如果没有成功编辑,该交易品种不能使用
         }
         Print("Fixed");
      }

现在你可以运行CustomTester.mq5智能交易系统,并观察在自动打开的图表中报价是如何动态形成的,以及报价点数据在“市场报价”窗口中是如何从历史数据中转发的。

然而,这是以每0.1秒32个报价点的恒定速率完成的。理想情况下,应该能够根据用户的请求随时改变播放速度,既可以加快也可以减慢。例如,可以通过键盘来组织这种控制。

因此,需要添加OnChartEvent处理函数。如我们所知,对于CHARTEVENT_KEYDOWN事件,程序会在lparam参数中接收按下键的代码,我们将其传递给CheckKeys函数(见下文)。与(B)密切相关的片段(C)暂时不得不推迟,我们稍后会再回到它。

cpp
void OnChartEvent(const int id, const long &lparam, const double &dparam, const string &sparam)
{
   ... // (C)
   if(id == CHARTEVENT_KEYDOWN) // 这些事件仅在图表处于活动状态时到达!
   {
      CheckKeys(lparam);
   }
}

CheckKeys函数中,我们处理“向下箭头”和“向上箭头”键以降低和提高播放速度。此外,“暂停”键允许完全暂停“测试”过程(报价点数据的传输)。再次按下“暂停”键将以相同的速度恢复工作。

cpp
void CheckKeys(const long key)
{
   if(key == VK_DOWN)
   {
      Step /= 2;
      if(Step > 0)
      {
         Print("Slow down: ", Step);
         ChartSetString(Chart, CHART_COMMENT, "Speed: " + (string)Step);
      }
      else
      {
         Print("Paused");
         ChartSetString(Chart, CHART_COMMENT, "Paused");
         ChartRedraw(Chart);
      }
   }
   else if(key == VK_UP)
   {
      if(Step == 0)
      {
         Step = 1;
         Print("Resumed");
         ChartSetString(Chart, CHART_COMMENT, "Resumed");
      }
      else
      {
         Step *= 2;
         Print("Speed up: ", Step);
         ChartSetString(Chart, CHART_COMMENT, "Speed: " + (string)Step);
      }
   }
   else if(key == VK_PAUSE)
   {
      if(Step > 0)
      {
         StepRestore = Step;
         Step = 0;
         Print("Paused");
         ChartSetString(Chart, CHART_COMMENT, "Paused");
         ChartRedraw(Chart);
      }
      else
      {
         Step = StepRestore;
         Print("Resumed");
         ChartSetString(Chart, CHART_COMMENT, "Speed: " + (string)Step);
      }
   }
}

在确保智能交易系统工作的图表处于活动状态后,可以测试新代码的实际效果。请记住,键盘事件仅会发送到活动窗口。这是我们的测试器的另一个问题。

由于用户必须在自定义交易品种图表上执行交易操作,生成器窗口几乎总是会在后台。切换到生成器窗口以暂时停止报价点数据的流动,然后再恢复它,这并不实用。因此,需要以某种方式直接在自定义交易品种窗口中从键盘组织交互式控制。

为此,一个特殊的指标非常合适,我们可以自动将其添加到打开的自定义交易品种窗口中。该指标将在其自己的窗口(带有自定义交易品种的窗口)中拦截键盘事件,并将它们发送到生成器窗口。

指标的源代码附在文件KeyboardSpy.mq5中。当然,该指标没有图表。有一对输入参数专门用于获取应该发送消息的图表ID HostID,以及用于打包交互式事件的自定义事件代码EventID

cpp
#property indicator_chart_window
#property indicator_plots 0

input long HostID;
input ushort EventID;

主要工作在OnChartEvent处理函数中完成。

cpp
void OnChartEvent(const int id, const long &lparam, const double &dparam, const string &sparam)
{
   if(id == CHARTEVENT_KEYDOWN)
   {
      EventChartCustom(HostID, EventID, lparam,
         // 在iCustom内部时,这个值总是0
         (double)(ushort)TerminalInfoInteger(TERMINAL_KEYSTATE_CONTROL),
         sparam);
   }
}

请注意,我们选择的所有“热键”都是简单的,即它们不使用带有键盘状态键(如Ctrl或Shift)的组合键。这是迫不得已的,因为在以编程方式创建的指标(特别是通过iCustom创建的指标)内部,无法读取键盘状态。换句话说,调用TerminalInfoInteger(TERMINAL_KEYSTATE_XYZ)总是返回0。在上面的处理函数中,我们添加它只是为了演示目的,这样如果你愿意,你可以通过在“接收端”显示传入的参数来验证这个限制。

然而,单个箭头键和暂停键的点击将正常传输到父图表,这对我们来说已经足够了。剩下要做的唯一一件事就是将指标与智能交易系统集成起来。

在之前跳过的片段(B)中,在生成器的初始化过程中,我们将创建一个指标并将其添加到自定义交易品种图表中。

cpp
#define EVENT_KEY 0xDED // 自定义事件
      ...
      // (B)
      const int handle = iCustom(CustomSymbol, Timeframe, "MQL5Book/p7/KeyboardSpy",
         ChartID(), EVENT_KEY);
      ChartIndicatorAdd(Chart, 0, handle);

在片段(C)中,我们将确保从指标接收用户消息,并将它们传递给已经知道的CheckKeys函数。

cpp
void OnChartEvent(const int id, const long &lparam, const double &dparam, const string &sparam)
{
   // (C)
   if(id == CHARTEVENT_CUSTOM + EVENT_KEY) // 当相关图表处于活动状态时的通知
   {
      CheckKeys(lparam); // “远程”处理按键操作
   }
   else if(id == CHARTEVENT_KEYDOWN) // 这些事件仅在图表处于活动状态时触发!
   {
      CheckKeys(lparam); // 标准处理
   }
}

因此,现在既可以在带有智能交易系统的图表上,也可以在由它生成的自定义交易品种图表上控制播放速度。

使用新的工具包,你可以尝试与一个“活在过去”的图表进行交互式操作。在图表上会显示一个带有当前播放速度或暂停标记的注释。

在带有智能交易系统的图表上,“当前”广播报价点数据的时间会显示在注释中。

重现真实交易品种报价点(和报价)历史的智能交易系统

重现真实交易品种报价点(和报价)历史的智能交易系统

在这个窗口中,用户基本上无需做任何事情(除非删除智能交易系统并停止自定义交易品种的生成)。在这里看不到报价点数据转换的过程。此外,由于智能交易系统会自动打开一个自定义交易品种图表(在其中历史报价会更新),这个图表会变为活动状态。为了获得上面的截图,我们特意需要短暂切换到原始图表。

因此,让我们回到自定义交易品种的图表。它在过去平滑且逐步更新的方式已经很棒了,但你不能在上面进行交易实验。例如,如果你在上面运行你常用的交易面板,它的控件虽然在形式上会工作,但不会执行交易,因为自定义交易品种在服务器上不存在,因此你会得到错误。在任何没有经过专门为自定义交易品种适配的程序中都会观察到这个特性。让我们展示一个如何虚拟化与自定义交易品种进行交易的示例。

为了简化示例(但不失一般性),我们将以最简单的智能交易系统CustomOrderSend.mq5为基础,而不是使用交易面板。这个智能交易系统可以在按键时执行几个交易操作:

  • B — 市价买入
  • S — 市价卖出
  • U — 挂限价买入订单
  • L — 挂限价卖出订单
  • C — 平仓所有头寸
  • D — 删除所有订单
  • R — 将交易报告输出到日志

在智能交易系统的输入参数中,我们将设置一次交易的交易量(默认情况下为最小手数)以及止损和止盈水平与当前价格的点数距离。

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

const double Lot = Volume == 0? SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN) : Volume;

如果Distance2SLTP保持为零,则在市价订单中不设置保护水平,并且不形成挂单。当Distance2SLTP有非零值时,它将用作挂单时与当前价格的距离(根据命令向上或向下)。

考虑到之前从MqlTradeSync.mqh中呈现的类,上述逻辑转换为以下源代码。

cpp
#include <MQL5Book/MqlTradeSync.mqh>

#define KEY_B 66
#define KEY_C 67
#define KEY_D 68
#define KEY_L 76
#define KEY_R 82
#define KEY_S 83
#define KEY_U 85

void OnChartEvent(const int id, const long &lparam, const double &dparam, const string &sparam)
{
   if(id == CHARTEVENT_KEYDOWN)
   {
      MqlTradeRequestSync request;
      const double ask = SymbolInfoDouble(_Symbol, SYMBOL_ASK);
      const double bid = SymbolInfoDouble(_Symbol, SYMBOL_BID);
      const double point = SymbolInfoDouble(_Symbol, SYMBOL_POINT);

      switch((int)lparam)
      {
      case KEY_B:
         request.buy(Lot, 0,
            Distance2SLTP? ask - point * Distance2SLTP : Distance2SLTP,
            Distance2SLTP? ask + point * Distance2SLTP : Distance2SLTP);
         break;
      case KEY_S:
         request.sell(Lot, 0,
            Distance2SLTP? bid + point * Distance2SLTP : Distance2SLTP,
            Distance2SLTP? bid - point * Distance2SLTP : Distance2SLTP);
         break;
      case KEY_U:
         if(Distance2SLTP)
         {
            request.buyLimit(Lot, ask - point * Distance2SLTP);
         }
         break;
      case KEY_L:
         if(Distance2SLTP)
         {
            request.sellLimit(Lot, bid + point * Distance2SLTP);
         }
         break;
      case KEY_C:
         for(int i = PositionsTotal() - 1; i >= 0; i--)
         {
            request.close(PositionGetTicket(i));
         }
         break;
      case KEY_D:
         for(int i = OrdersTotal() - 1; i >= 0; i--)
         {
            request.remove(OrderGetTicket(i));
         }
         break;
      case KEY_R:
 // 这里应该有一些内容...
         break;
      }
   }
}

如我们所见,这里既使用了标准的交易API函数,也使用了MqlTradeRequestSync方法。后者间接上也最终会调用很多内置函数。我们需要使这个智能交易系统与自定义交易品种进行交易。

最简单但也最耗时的想法是用我们自己的类似函数替换所有标准函数,这些函数将在某些结构体中计算订单、交易、头寸和财务统计信息。当然,这只有在我们拥有智能交易系统的源代码并且应该对其进行适配的情况下才可能实现。

这种方法的实验性实现展示在附加文件CustomTrade.mqh中。你可以自行熟悉完整代码,因为在本书的框架内,我们只会列出要点。

首先,我们注意到许多计算是以简化形式进行的,不支持许多模式,并且没有对数据进行全面的正确性检查。将源代码用作你自己开发的起点。

整个代码都包含在CustomTrade命名空间中,以避免冲突。

订单、交易和头寸实体被形式化为相应的类CustomOrderCustomDealCustomPosition。它们都是类MonitorInterface<I,D,S>::TradeState的继承者。请记住,这个类已经自动支持为每种对象类型及其特定的三元枚举形成整数、实数和字符串属性数组。例如,CustomOrder看起来像这样:

cpp
class CustomOrder: public MonitorInterface<ENUM_ORDER_PROPERTY_INTEGER,
   ENUM_ORDER_PROPERTY_DOUBLE,ENUM_ORDER_PROPERTY_STRING>::TradeState
{
   static long ticket; // 订单计数器和订单号提供者
   static int done;    // 已执行(历史)订单计数器
public:
   CustomOrder(const ENUM_ORDER_TYPE type, const double volume, const string symbol)
   {
      _set(ORDER_TYPE, type);
      _set(ORDER_TICKET, ++ticket);
      _set(ORDER_TIME_SETUP, SymbolInfoInteger(symbol, SYMBOL_TIME));
      _set(ORDER_TIME_SETUP_MSC, SymbolInfoInteger(symbol, SYMBOL_TIME_MSC));
      if(type <= ORDER_TYPE_SELL)
      {
         // 待办事项:目前还没有延迟执行
         setDone(ORDER_STATE_FILLED);
      }
      else
      {
         _set(ORDER_STATE, ORDER_STATE_PLACED);
      }

      _set(ORDER_VOLUME_INITIAL, volume);
      _set(ORDER_VOLUME_CURRENT, volume);

      _set(ORDER_SYMBOL, symbol);
   }

   void setDone(const ENUM_ORDER_STATE state)
   {
      const string symbol = _get<string>(ORDER_SYMBOL);
      _set(ORDER_TIME_DONE, SymbolInfoInteger(symbol, SYMBOL_TIME));
      _set(ORDER_TIME_DONE_MSC, SymbolInfoInteger(symbol, SYMBOL_TIME_MSC));
      _set(ORDER_STATE, state);
      ++done;
   }

   bool isActive() const
   {
      return _get<long>(ORDER_TIME_DONE) == 0;
   }

   static int getDoneCount()
   {
      return done;
   }
};

请注意,在旧的“当前”时间的虚拟环境中,你不能使用TimeCurrent函数,而是使用自定义交易品种的最后已知时间SymbolInfoInteger(symbol, SYMBOL_TIME)

在虚拟交易期间,当前对象及其历史记录会累积在相应类的数组中。

cpp
AutoPtr<CustomOrder> orders[];
CustomOrder *selectedOrders[];
CustomOrder *selectedOrder = NULL;
AutoPtr<CustomDeal> deals[];
CustomDeal *selectedDeals[];
CustomDeal *selectedDeal = NULL;
AutoPtr<CustomPosition> positions[];
CustomPosition *selectedPosition = NULL;

选择订单、交易和头寸的方法是为了模拟内置函数中的类似方法。在CustomTrade命名空间中有它们的替代函数,使用宏替换指令来替换原始函数。

cpp
#define HistorySelect CustomTrade::MT5HistorySelect
#define HistorySelectByPosition CustomTrade::MT5HistorySelectByPosition
#define PositionGetInteger CustomTrade::MT5PositionGetInteger
#define PositionGetDouble CustomTrade::MT5PositionGetDouble
#define PositionGetString CustomTrade::MT5PositionGetString
#define PositionSelect CustomTrade::MT5PositionSelect
#define PositionSelectByTicket CustomTrade::MT5PositionSelectByTicket
#define PositionsTotal CustomTrade::MT5PositionsTotal
#define OrdersTotal CustomTrade::MT5OrdersTotal
#define PositionGetSymbol CustomTrade::MT5PositionGetSymbol
#define PositionGetTicket CustomTrade::MT5PositionGetTicket
#define HistoryDealsTotal CustomTrade::MT5HistoryDealsTotal
#define HistoryOrdersTotal CustomTrade::MT5HistoryOrdersTotal
#define HistoryDealGetTicket CustomTrade::MT5HistoryDealGetTicket
#define HistoryOrderGetTicket CustomTrade::MT5HistoryOrderGetTicket
#define HistoryDealGetInteger CustomTrade::MT5HistoryDealGetInteger
#define HistoryDealGetDouble CustomTrade::MT5HistoryDealGetDouble
#define HistoryDealGetString CustomTrade::MT5HistoryDealGetString
#define HistoryOrderGetDouble CustomTrade::MT5HistoryOrderGetDouble
#define HistoryOrderGetInteger CustomTrade::MT5HistoryOrderGetInteger
#define HistoryOrderGetString CustomTrade::MT5HistoryOrderGetString
#define OrderSend CustomTrade::MT5OrderSend
#define OrderSelect CustomTrade::MT5OrderSelect
#define HistoryOrderSelect CustomTrade::MT5HistoryOrderSelect
#define HistoryDealSelect CustomTrade::MT5HistoryDealSelect

例如,MT5HistorySelectByPosition函数是这样实现的。

cpp
bool MT5HistorySelectByPosition(long id)
{
   ArrayResize(selectedOrders, 0);
   ArrayResize(selectedDeals, 0);

   for(int i = 0; i < ArraySize(orders); i++)
   {
      CustomOrder *ptr = orders[i][];
      if(!ptr.isActive())
      {
         if(ptr._get<long>(ORDER_POSITION_ID) == id)
         {
            PUSH(selectedOrders, ptr);
          }
      }
   }
         for(int i = 0; i < ArraySize(deals); i++)
         {
            CustomDeal *ptr = deals[i][];
            if(ptr._get<long>(DEAL_POSITION_ID) == id)
            {
               PUSH(selectedDeals, ptr);
            }
         }
         return (ArraySize(selectedOrders) > 0 || ArraySize(selectedDeals) > 0);
      }

这种替换允许我们在不修改智能交易系统源代码的情况下,使用自定义交易品种进行交易。你可以在 CustomOrderSend.mq5 中包含 CustomTrade.mqh,然后运行这个智能交易系统,它将在虚拟环境中执行交易。

总结

在这部分内容中,我们探索了使用自定义交易品种在 MetaTrader 5 平台上进行交易模拟和测试的方法。主要包括以下几个方面:

  1. 报价点数据压缩与测试:对不同的报价点数据压缩模式(如序列精简、波动平滑、分形精简等)进行了性能测试,包括压缩程度、测试速度、内存消耗以及智能交易系统的交易表现。结果显示,不同模式在性能指标上存在差异,OHLC M1 模式速度最快但精度下降,“序列精简”模式在多方面最接近真实情况。

  2. 自定义交易品种生成:通过 CustomTester.mq5 智能交易系统,基于历史报价点数据生成自定义交易品种,实现了以慢动作播放报价点数据的功能。同时,通过添加 OnChartEvent 处理函数和 KeyboardSpy.mq5 指标,实现了在自定义交易品种图表上通过键盘控制播放速度的交互功能。

  3. 虚拟交易模拟:以 CustomOrderSend.mq5 智能交易系统为例,通过 CustomTrade.mqh 命名空间中的自定义函数替换标准交易 API 函数,实现了在虚拟环境中使用自定义交易品种进行交易的功能。

这些方法为交易者提供了在历史数据上进行交易模拟和测试的有效工具,有助于优化交易策略和发现潜在问题。同时,需要注意的是,不同的交易策略、金融工具和经纪商的报价点历史数据可能会对报价点减少算法的表现产生影响,因此在实际应用中需要进行充分的测试和验证。

订单簿变更的转换

如有必要,MQL 程序可以使用 CustomBookAdd 函数为自定义交易品种生成订单簿。这对于来自外部交易所的交易工具,例如加密货币,尤其有用。

cpp
int CustomBookAdd(const string symbol, const MqlBookInfo &books[], uint count = WHOLE_ARRAY)

该函数使用 books 数组中的数据,向已签名的 MQL 程序广播自定义交易品种的订单簿状态。该数组描述了订单簿的完整状态,即所有的买单和卖单。传输的状态会完全替换之前的状态,并且可以通过 MarketBookGet 函数获取。

通过 count 参数,你可以指定要传递给该函数的 books 数组元素的数量。默认情况下,使用整个数组。

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

为了获取由 CustomBookAdd 函数生成的订单簿,需要订单簿的 MQL 程序必须像往常一样,使用 MarketBookAdd 订阅相关事件。

订单簿的更新不会更新交易工具的买价(Bid)和卖价(Ask)。要更新所需的价格,请使用 CustomTicksAdd 添加报价。

会检查传输的数据是否正确:价格和交易量必须大于零,并且对于每个元素,必须指定其类型、价格和交易量(volume 和/或 volume_real 字段)。如果订单簿中至少有一个元素描述不正确,该函数将返回错误。

还会检查自定义交易品种的订单簿深度参数(SYMBOL_TICKS_BOOKDEPTH)。如果传输的订单簿中卖单或买单的层数超过该值,多余的层数将被丢弃。

精度更高的交易量 volume_real 优先于普通交易量 volume。如果为订单簿元素同时指定了这两个值,则将使用 volume_real

注意:在当前实现中,CustomBookAdd 会自动锁定自定义交易品种,就好像通过 MarketBookAdd 对其进行了订阅一样,但与此同时,OnBookEvent 事件不会到达(理论上,生成订单簿的程序可以通过显式调用 MarketBookAdd 并控制其他程序接收的内容来订阅这些事件)。你可以通过调用 MarketBookRelease 来解除此锁定。

这可能是必要的,因为对于那些已订阅订单簿的交易品种,无论如何都无法从 “市场报价” 中隐藏它们(直到从程序中取消所有显式或隐式订阅,并且关闭订单簿窗口)。因此,此类交易品种无法删除。

例如,我们创建一个非交易的智能交易系统 PseudoMarketBook.mq5,它将根据最近的报价历史生成订单簿的伪状态。这对于那些订单簿未被转换的交易品种(特别是外汇交易品种)可能很有用。如果你愿意,可以使用这样的自定义交易品种,通过订单簿对你自己的交易算法进行正式调试。

在输入参数中,我们指定订单簿的最大深度。

cpp
input uint CustomBookDepth = 20;

自定义交易品种的名称将通过在当前图表交易品种名称后添加后缀 .Pseudo 来形成。

cpp
string CustomSymbol = _Symbol + ".Pseudo";

OnInit 处理函数中,我们创建一个自定义交易品种,并将其公式设置为原始交易品种的名称。这样,我们将得到一个由终端自动更新的原始交易品种的副本,并且无需费心复制报价或报价数据。

cpp
int OnInit()
{
   bool custom = false;
   if(!PRTF(SymbolExist(CustomSymbol, custom)))
   {
      if(PRTF(CustomSymbolCreate(CustomSymbol, CustomPath, _Symbol)))
      {
         CustomSymbolSetString(CustomSymbol, SYMBOL_DESCRIPTION, "Pseudo book generator");
         CustomSymbolSetString(CustomSymbol, SYMBOL_FORMULA, "\"" + _Symbol + "\"");
      }
   }
   ...

如果自定义交易品种已经存在,智能交易系统可以询问用户是否删除它,并在那里完成操作(用户应首先关闭所有包含该交易品种的图表)。

cpp
   else
   {
      if(IDYES == MessageBox(StringFormat("Delete existing custom symbol '%s'?",
         CustomSymbol), "Please, confirm", MB_YESNO))
      {
         PRTF(MarketBookRelease(CustomSymbol));
         PRTF(SymbolSelect(CustomSymbol, false));
         PRTF(CustomRatesDelete(CustomSymbol, 0, LONG_MAX));
         PRTF(CustomTicksDelete(CustomSymbol, 0, LONG_MAX));
         if(!PRTF(CustomSymbolDelete(CustomSymbol)))
         {
            Alert("Can't delete ", CustomSymbol, ", please, check up and delete manually");
         }
         return INIT_PARAMETERS_INCORRECT;
      }
   }
   ...

这个交易品种的一个特殊之处是设置 SYMBOL_TICKS_BOOKDEPTH 属性,以及读取合约规模 SYMBOL_TRADE_CONTRACT_SIZE,在生成交易量时将需要用到该值。

cpp
   if(SymbolInfoInteger(_Symbol, SYMBOL_TICKS_BOOKDEPTH) != CustomBookDepth
   && SymbolInfoInteger(CustomSymbol, SYMBOL_TICKS_BOOKDEPTH) != CustomBookDepth)
   {
      Print("Adjusting custom market book depth");
      CustomSymbolSetInteger(CustomSymbol, SYMBOL_TICKS_BOOKDEPTH, CustomBookDepth);
   }
   
   depth = (int)PRTF(SymbolInfoInteger(CustomSymbol, SYMBOL_TICKS_BOOKDEPTH));
   contract = PRTF(SymbolInfoDouble(CustomSymbol, SYMBOL_TRADE_CONTRACT_SIZE));
   
   return INIT_SUCCEEDED;
}

该算法在 OnTick 处理函数中启动。在这里,我们调用尚未编写的 GenerateMarketBook 函数。它将填充通过引用传递的 MqlBookInfo 结构数组,然后我们将使用 CustomBookAdd 将其发送到自定义交易品种。

cpp
void OnTick()
{
   MqlBookInfo book[];
   if(GenerateMarketBook(2000, book))
   {
      ResetLastError();
      if(!CustomBookAdd(CustomSymbol, book))
      {
         Print("Can't add market books, ", E2S(_LastError));
         ExpertRemove();
      }
   }
}

GenerateMarketBook 函数分析最近的 count 个报价,并基于这些报价,根据以下假设模拟订单簿的可能状态:

  • 已买入的很可能会被卖出
  • 已卖出的很可能会被买入

一般情况下(在没有交易所标志的情况下),将报价分为对应于买入和卖出的报价,可以通过价格本身的变动来估计:

  • 卖价上升被视为买入
  • 买价下降被视为卖出

结果,我们得到了以下算法。

cpp
bool GenerateMarketBook(const int count, MqlBookInfo &book[])
{
   MqlTick tick; // 订单簿中心
   if(!SymbolInfoTick(_Symbol, tick)) return false;
   
   double buys[];  // 按价格水平的买入交易量
   double sells[]; // 按价格水平的卖出交易量
   
   MqlTick ticks[];
   CopyTicks(_Symbol, ticks, COPY_TICKS_ALL, 0, count); // 请求报价历史
   for(int i = 1; i < ArraySize(ticks); ++i)
   {
      // 我们认为卖价上升是由买入推动的
      int k = (int)MathRound((tick.ask - ticks[i].ask) / _Point);
      if(ticks[i].ask > ticks[i - 1].ask)
      {
         // 已经买入,很可能会通过卖出获利平仓
         if(k <= 0)
         {
            Place(sells, -k, contract / sqrt(sqrt(ArraySize(ticks) - i)));
         }
      }
      
      // 认为买价下降是由卖出推动的
      k = (int)MathRound((tick.bid - ticks[i].bid) / _Point);
      if(ticks[i].bid < ticks[i - 1].bid)
      {
         // 已经卖出,很可能会通过买入获利平仓
         if(k >= 0)
         {
            Place(buys, k, contract / sqrt(sqrt(ArraySize(ticks) - i)));
         }
      }
   }
   ...

辅助函数 Place 用于填充 buyssells 数组,按价格水平在其中累积交易量。我们将在下面展示。数组中的索引定义为与当前最佳价格(买价或卖价)的点数距离。交易量的大小与报价的时间间隔成反比,即过去时间间隔更远的报价影响较小。

在数组填充完成后,基于它们形成一个 MqlBookInfo 结构数组。

cpp
   for(int i = 0, k = 0; i < ArraySize(sells) && k < depth; ++i) // 订单簿上半部分
   {
      if(sells[i] > 0)
      {
         MqlBookInfo info = {};
         info.type = BOOK_TYPE_SELL;
         info.price = tick.ask + i * _Point;
         info.volume = (long)sells[i];
         info.volume_real = (double)(long)sells[i];
         PUSH(book, info);
         ++k;
      }
   }
   
   for(int i = 0, k = 0; i < ArraySize(buys) && k < depth; ++i) // 订单簿下半部分
   {
      if(buys[i] > 0)
      {
         MqlBookInfo info = {};
         info.type = BOOK_TYPE_BUY;
         info.price = tick.bid - i * _Point;
         info.volume = (long)buys[i];
         info.volume_real = (double)(long)buys[i];
         PUSH(book, info);
         ++k;
      }
   }
   
   return ArraySize(book) > 0;
}

Place 函数很简单。

cpp
void Place(double &array[], const int index, const double value = 1)
{
   const int size = ArraySize(array);
   if(index >= size)
   {
      ArrayResize(array, index + 1);
      for(int i = size; i <= index; ++i)
      {
         array[i] = 0;
      }
   }
   array[index] += value;
}

以下屏幕截图显示了运行 PseudoMarketBook.mq5 智能交易系统的 EURUSD 图表以及生成的订单簿版本。

自定义符号交易的具体细节

自定义符号仅客户端终端知晓,交易服务器上不可用。因此,如果一个自定义符号是基于某个真实符号构建的,那么放置在该自定义符号图表上的任何智能交易系统(EA)都应该为原始符号生成交易订单。

作为解决此问题的最简单方法,你可以将智能交易系统放置在原始符号的图表上,但从自定义符号接收信号(例如,来自指标的信号)。另一个显而易见的方法是在执行交易操作时替换符号名称。为了测试这两种方法,我们需要一个自定义符号和一个智能交易系统。

作为自定义符号的一个有趣的实际示例,我们来看几个不同的等量图。

等量图(Equal Volume Chart)是一种基于柱状图中包含的交易量相等原则构建的图表。在常规图表上,每个新柱形图是按照指定的频率形成的,该频率与时间框架大小一致。在等量图上,当 ticks 数量或实际交易量的总和达到预设值时,每个柱形图才被认为形成。此时,程序开始计算下一个柱形图的数值。当然,在计算交易量的过程中,会监控价格走势,我们在图表上得到常见的价格集合:开盘价(Open)、最高价(High)、最低价(Low)和收盘价(Close)。

等距柱形图的构建方式类似:当价格在任何方向上移动给定的点数时,就会形成一个新的柱形图。

因此,EqualVolumeBars.mq5 智能交易系统将支持三种模式,即三种图表类型:

  • EqualTickVolumes —— 基于 ticks 的等量柱形图
  • EqualRealVolumes —— 基于实际交易量的等量柱形图(如果有实际交易量数据广播的话)
  • RangeBars —— 等距柱形图

这些模式通过输入参数 WorkMode 进行选择。

用于计算的柱形图大小和历史深度在参数 TicksInBarStartDate 中指定。

input int TicksInBar = 1000;
input datetime StartDate = 0;

根据模式的不同,自定义符号将分别获得后缀 _Eqv_Qrv_Rng,并加上柱形图大小。

尽管等量图/等距图上的水平轴仍然代表时间顺序,但每个柱形图的时间戳是任意的,并且取决于每个时间框架内的波动性(交易数量或规模)。在这方面,自定义符号图表的时间框架应选择为最小的 M1。

该平台的限制在于,所有柱形图的名义持续时间相同,但对于我们的 “人工” 图表,应该记住每个柱形图的实际持续时间是不同的,可能会显著超过 1 分钟,或者相反,小于 1 分钟。因此,如果给定的单个柱形图的交易量足够小,可能会出现新柱形图的形成频率比每分钟一次高得多的情况,然后自定义符号柱形图的虚拟时间将领先于实际时间,进入未来。为了防止这种情况发生,你应该增加柱形图的交易量(TicksInBar 参数)或将旧柱形图向左移动。

管理自定义符号的初始化和其他辅助任务(特别是重置现有历史记录,以及打开带有新符号的图表)的执行方式与其他示例类似,我们将省略这些内容。让我们转向实际应用方面的细节。

我们将使用内置函数 CopyTicks/CopyTicksRange 读取实际 ticks 的历史记录:第一个函数用于以 10,000 个 ticks 为一批交换历史记录,第二个函数用于请求自上次处理以来的新 ticks。所有这些功能都封装在 TicksBuffer 类中(完整的源代码已附上)。

class TicksBuffer
{
private:
   MqlTick array[]; // 内部的 ticks 数组
   int tick;        // 用于读取下一个 tick 的递增索引
public:
   bool fill(ulong &cursor, const bool history = false);
   bool read(MqlTick &t);
};

公共方法 fill 旨在从光标时间(以毫秒为单位)开始,用下一部分 ticks 填充内部数组。同时,每次调用时 cursor 中的时间会根据最后一个读入缓冲区的 tick 的时间向前移动(请注意,该参数是通过引用传递的)。

参数 history 决定是使用 CopyTicks 还是 CopyTicksRange。通常,在线时我们将从 OnTick 处理程序中读取一个或多个新 ticks。

方法 read 从内部数组中返回一个 tick,并将内部指针(tick)移动到下一个 tick。如果在读取时到达数组末尾,该方法将返回 false,这意味着是时候调用 fill 方法了。

使用这些方法,tick 历史记录遍历算法实现如下(此代码通过定时器间接从 OnInit 中调用)。

   ulong cursor = StartDate * 1000;
   TicksBuffer tb;
    
   while(tb.fill(cursor, true) && !IsStopped())
   {
      MqlTick t;
      while(tb.read(t))
      {
         HandleTick(t, true);
      }
   }

HandleTick 函数中,需要在一些全局变量中考虑 tick t 的属性,这些全局变量控制 ticks 的数量、总交易量(如果有的话,为实际交易量)以及价格移动距离。根据操作模式的不同,应该以不同的方式分析这些变量以确定新柱形图的形成条件。因此,如果在等量模式下,ticks 的数量超过了 TicksInBar,我们应该通过将计数器重置为 1 来开始一个新的柱形图。在这种情况下,新柱形图的时间取为四舍五入到最接近分钟的 tick 时间。

这组全局变量用于存储自定义符号上最后一个(“当前”)柱形图的虚拟时间(now_time)、其 OHLC 价格以及交易量。

datetime now_time;
double now_close, now_open, now_low, now_high;
long now_volume, now_real;

这些变量在读取历史记录期间以及随后智能交易系统开始实时处理在线 ticks 时都会不断更新(我们稍后会再讨论这一点)。

HandleTick 内部的算法以某种简化形式如下所示:

void HandleTick(const MqlTick &t, const bool history = false)
{
   now_volume++;               // 计算 ticks 的数量
   now_real += (long)t.volume; // 累加所有实际交易量
   
   if(!IsNewBar()) // 继续当前柱形图
   {
      if(t.bid < now_low) now_low = t.bid;   // 监控价格向下波动
      if(t.bid > now_high) now_high = t.bid; // 监控价格向上波动
      now_close = t.bid;                     // 更新收盘价
    
      if(!history)
      {
         // 如果不是在处理历史记录,则更新当前柱形图
         WriteToChart(now_time, now_open, now_low, now_high, now_close,
            now_volume - !history, now_real);
      }
   }
   else // 新柱形图
   {
      do
      {
         // 保存具有所有属性的已关闭柱形图
         WriteToChart(now_time, now_open, now_low, now_high, now_close,
            WorkMode == EqualTickVolumes ? TicksInBar : now_volume,
            WorkMode == EqualRealVolumes ? TicksInBar : now_real);
   
         // 对新柱形图的时间四舍五入到分钟
         datetime time = t.time / 60 * 60;
   
         // 防止出现时间较旧或相同的柱形图
         // 如果进入了 “未来”,我们应该只取下一个 M1 计数
         if(time <= now_time) time = now_time + 60;
   
         // 从当前价格开始一个新柱形图
         now_time = time;
         now_open = t.bid;
         now_low = t.bid;
         now_high = t.bid;
         now_close = t.bid;
         now_volume = 1;             // 新柱形图中的第一个 tick
         if(WorkMode == EqualRealVolumes) now_real -= TicksInBar;
         now_real += (long)t.volume; // 新柱形图中的初始实际交易量
   
         // 保存新柱形图 0
         WriteToChart(now_time, now_open, now_low, now_high, now_close,
            now_volume - !history, now_real);
      }
      while(IsNewBar() && WorkMode == EqualRealVolumes);
   }
}

参数 history 决定计算是基于历史记录还是已经在实时进行(针对传入的在线 ticks)。如果基于历史记录,只需形成每个柱形图一次,而在线时,每个 tick 都会更新当前柱形图。这可以加快历史记录的处理速度。

辅助函数 IsNewBar 在满足根据模式关闭下一个柱形图的条件时返回 true

bool IsNewBar()
{
   if(WorkMode == EqualTickVolumes)
   {
      if(now_volume > TicksInBar) return true;
   }
   else if(WorkMode == EqualRealVolumes)
   {
      if(now_real > TicksInBar) return true;
   }
   else if(WorkMode == RangeBars)
   {
      if((now_high - now_low) / _Point > TicksInBar) return true;
   }
   
   return false;
}

函数 WriteToChart 通过调用 CustomRatesUpdate 创建具有给定特征的柱形图。

void WriteToChart(datetime t, double o, double l, double h, double c, long v, long m = 0)
{
   MqlRates r[1];
   
   r[0].time = t;
   r[0].open = o;
   r[0].low = l;
   r[0].high = h;
   r[0].close = c;
   r[0].tick_volume = v;
   r[0].spread = 0;
   r[0].real_volume = m;
   
   if(CustomRatesUpdate(SymbolName, r) < 1)
   {
      Print("CustomRatesUpdate failed: ", _LastError);
   }
}

上述读取和处理 ticks 的循环在首次访问历史记录时执行,即在创建或完全重新计算已存在的用户符号之后。当涉及到新 ticks 时,OnTick 函数使用类似的代码,但没有 “历史记录” 标志。

void OnTick()
{
   static ulong cursor = 0;
   MqlTick t;
   
   if(cursor == 0)
   {
      if(SymbolInfoTick(_Symbol, t))
      {
         HandleTick(t);
         cursor = t.time_msc + 1;
      }
   }
   else
   {
      TicksBuffer tb;
      while(tb.fill(cursor))
      {
         while(tb.read(t))
         {
            HandleTick(t);
         }
      }
   }
   
   RefreshWindow(now_time);
}

RefreshWindow 函数在市场报价窗口中添加一个自定义符号 tick。

请注意,tick 转发会将柱形图中的 tick 计数器增加 1,因此,在将 tick 计数器写入第 0 个柱形图时,我们之前减去了 1(请参阅调用 WriteToChart 时的表达式 now_volume - !history)。

tick 生成很重要,因为它会在自定义工具图表上触发 OnTick 事件,这潜在地允许放置在这样图表上的智能交易系统进行交易。然而,这种技术需要一些额外的技巧,我们稍后会考虑这些技巧。

void RefreshWindow(const datetime t)
{
   MqlTick ta[1];
   SymbolInfoTick(_Symbol, ta[0]);
   ta[0].time = t;
   ta[0].time_msc = t * 1000;
   if(CustomTicksAdd(SymbolName, ta) == -1)
   {
      Print("CustomTicksAdd failed:", _LastError, " ", (long) ta[0].time);
      ArrayPrint(ta);
   }
}

我们强调,生成的自定义 tick 的时间总是设置为等于当前柱形图的标签时间,因为我们不能保留真实的 tick 时间:如果它已经提前超过 1 分钟,并且我们将这样的 tick 发送到市场报价窗口,终端将创建下一个 M1 柱形图,这将违反我们的 “等量” 结构,因为我们的柱形图不是按时间形成的,而是按交易量填充形成的(并且我们自己控制这个过程)。

理论上,我们可以给每个 tick 添加 1 毫秒,但我们不能保证柱形图不需要存储超过 60,000 个 ticks(例如,如果用户要求绘制具有一定价格范围的图表,而这个价格范围需要多少个 ticks 是不可预测的)。

在按交易量的模式下,理论上可以使用线性公式内插 tick 时间的秒和毫秒部分:

  • EqualTickVolumes —— (now_volume - 1) * 60000 / TicksInBar
  • EqualRealVolumes —— (now_real - 1) * 60000 / TicksInBar

然而,这只不过是一种识别 ticks 的手段,并不是试图使 “人工” ticks 的时间更接近真实 ticks 的时间。这不仅涉及到真实 tick 流的不均匀性的损失,这本身就会导致原始符号和基于它生成的自定义符号之间的价格差异。

主要问题是需要在 M1 柱形图的边界处对 tick 时间进行舍入,并将它们 “打包” 在一分钟内(请参阅关于特殊类型图表的侧边栏)。例如,下一个真实时间为 12:37:05'123 的 tick 成为第 1001 个 tick,并且应该形成一个新的等量柱形图。然而,M1 柱形图只能将时间戳精确到分钟,即 12:37。结果,在 12:37 时工具的实际价格将与为 12:37 的等量柱形图提供开盘价的 tick 中的价格不匹配。此外,如果接下来的 1000 个 ticks 跨越了几分钟,我们仍然不得不 “压缩” 它们的时间,以免达到 12:38 这个时间点。

这个问题是系统性的,因为当通过标准的 M1 时间框架图表模拟特殊图表时存在时间量化问题。在这样的图表上,这个问题无法完全解决。但是,当使用连续时间生成带有 ticks 的自定义符号时(例如,使用合成报价或基于来自外部服务的流数据),这个问题就不会出现。

需要注意的是,在这个版本的生成器中,tick 转发仅在线进行,而在历史记录中不生成自定义 ticks!这样做是为了加快报价的创建速度。如果你需要生成 tick 历史记录,尽管这个过程会更慢,那么 EqualVolumeBars.mq5 智能交易系统应该进行调整:排除 WriteToChart 函数,并使用 CustomTicksReplace/CustomTicksAdd 进行整个生成过程。同时,应该记住,ticks 的原始时间应该被替换为一分钟内的另一个时间,以免干扰所形成的等量图的结构。

让我们看看 EqualVolumeBars.mq5 是如何工作的。这是 EURUSD M15 的工作图表,其中运行着智能交易系统。它是一个等量图,其中每个柱形图分配了 1000 个 ticks。

EqualVolumeBars 智能交易系统生成的每个柱形图有 1000 个 ticks 的 EURUSD 等量图

EqualVolumeBars 智能交易系统生成的每个柱形图有 1000 个 ticks 的 EURUSD 等量图

请注意,除了最后一个仍在形成的柱形图(tick 计数仍在继续)之外,所有柱形图的 tick 交易量都是相等的。

统计信息显示在日志中。

Creating "EURUSD.c_Eqv1000"
Processing tick history...
End of CopyTicks at 2022.06.15 12:47:51
Bar 0: 2022.06.15 12:40:00 866 0
2119 bars written in 10 sec
Open "EURUSD.c_Eqv1000" chart to view results

让我们检查另一种操作模式:等距模式。下面是一个图表,其中每个柱形图的范围是 250 点。

EqualVolumeBars 智能交易系统生成的每个柱形图为 250 点的 EURUSD 等距图

EqualVolumeBars 智能交易系统生成的每个柱形图为 250 点的 EURUSD 等距图

对于交易所工具,智能交易系统允许使用实际交易量模式,例如如下所示:

EqualVolumeBars 智能交易系统生成的每个柱形图实际交易量为 10000 的以太坊原始图和等量图

每个柱形图实际交易量为 10000 的以太坊原始图和等量图

放置智能交易系统生成器时,工作符号的时间框架并不重要,因为计算总是使用 tick 历史记录。

同时,自定义符号图表的时间框架必须等于 M1(终端中可用的最小时间框架)。因此,柱形图的时间,通常尽可能紧密地(在可能的范围内)对应于它们的形成时刻。然而,在市场剧烈波动期间,当 ticks 的数量或交易量的大小在一分钟内形成多个柱形图时,柱形图的时间将领先于实际时间。当市场平静下来时,等量柱形图的时间标记情况将恢复正常。这不会影响在线价格的流动,所以这可能并不是特别关键,因为使用等量或等距柱形图的全部意义在于与绝对时间脱钩。

不幸的是,平台本身无法以任何方式将原始符号的名称与基于它创建的自定义符号的名称关联起来。如果在自定义符号的属性中有一个 “origin”(来源)字符串字段,我们可以在其中写入真实工作工具的名称,那将会很方便。默认情况下,它将为空,但如果填充了该字段,平台可以在所有交易订单和历史记录请求中替换符号,并且对用户来说是自动且透明地进行的。从理论上讲,在用户定义符号的属性中,有一个 SYMBOL_BASIS 字段,就其含义而言是合适的,但由于我们不能保证任意的用户定义符号生成器(任何 MQL 程序)会正确填充它或确切地将其用于此目的,所以我们不能依赖它的使用。

由于平台中没有这个机制,我们将需要自己实现它。你将不得不使用参数来设置源符号和用户符号名称之间的对应关系。

为了解决这个问题,我们开发了 CustomOrder 类(请参阅附加文件 CustomOrder.mqh)。它包含了所有与发送交易订单和请求历史记录相关的 MQL API 函数的包装方法,这些函数都有一个带有符号名称的字符串参数。在这些方法中,自定义符号将被替换为当前的工作符号,反之亦然。其他 API 函数不需要 “挂钩”。下面是一个代码片段。

class CustomOrder
{
private:
   static string workSymbol;
   
   static void replaceRequest(MqlTradeRequest &request)
   {
      if(request.symbol == _Symbol && workSymbol != NULL)
      {
         request.symbol = workSymbol;
         if(MQLInfoInteger(MQL_TESTER)
            && (request.type == ORDER_TYPE_BUY
            || request.type == ORDER_TYPE_SELL))
         {
            if(TU::Equal(request.price, SymbolInfoDouble(_Symbol, SYMBOL_ASK)))
               request.price = SymbolInfoDouble(workSymbol, SYMBOL_ASK);
            if(TU::Equal(request.price, SymbolInfoDouble(_Symbol, SYMBOL_BID)))
               request.price = SymbolInfoDouble(workSymbol, SYMBOL_BID);
         }
      }
   }
   
public:
   static void setReplacementSymbol(const string replacementSymbol)
   {
      workSymbol = replacementSymbol;
   }
   
   static bool OrderSend(MqlTradeRequest &request, MqlTradeResult &result)
   {
      replaceRequest(request);
      return ::OrderSend(request, result);
   }
   ...

请注意,主要的工作方法 replaceRequest 不仅会替换符号,还会替换当前的买价(Ask)和卖价(Bid)。这是因为许多自定义工具,例如我们的等量图,具有与真实原型符号的时间不同的虚拟时间。因此,测试器模拟的自定义工具的价格与真实工具的相应价格不同步。

这种情况仅在测试器中出现。在线交易时,自定义符号图表将(在价格方面)与真实图表同步更新,尽管柱形图标签会有所不同(一个 “人工” 的 M1 柱形图的实际持续时间或多或少会超过一分钟,并且其倒计时时间不是一分钟的倍数)。因此,这种价格转换更多的是一种预防措施,以避免在测试器中出现重新报价的情况。然而,在测试器中,我们通常不需要进行符号替换,因为测试器可以使用自定义符号进行交易(这与经纪商的服务器不同)。此外,只是出于兴趣,我们将比较在进行和不进行字符替换的情况下运行测试的结果。

为了尽量减少对客户端源代码的编辑,提供了以下形式的全局函数和宏(适用于所有 CustomOrder 方法):

  bool CustomOrderSend(const MqlTradeRequest &request, MqlTradeResult &result)
  {
    return CustomOrder::OrderSend((MqlTradeRequest)request, result);
  }
  
  #define OrderSend CustomOrderSend

它们允许将所有标准 API 函数调用自动重定向到 CustomOrder 类的方法。为此,只需将 CustomOrder.mqh 包含到智能交易系统中,并设置工作符号,例如在 WorkSymbol 参数中:

  #include <CustomOrder.mqh>
  #include <Expert/Expert.mqh>
  ...
  input string WorkSymbol = "";
  
  int OnInit()
  {
    if(WorkSymbol != "")
    {
      CustomOrder::setReplacementSymbol(WorkSymbol);
      
      // 启动工作符号图表选项卡的打开(在测试器的可视化模式下)
      MqlRates rates[1];
      CopyRates(WorkSymbol, PERIOD_CURRENT, 0, 1, rates);
    }
    ...
  }

重要的是,指令 #include<CustomOrder.mqh> 必须是第一个,在其他指令之前。因此,它会影响所有的源代码,包括来自 MetaTrader 5 发行版的标准库。如果没有指定替换符号,连接的 CustomOrder.mqh 对智能交易系统没有影响,并且会 “透明地” 将控制权转移到标准 API 函数。

现在我们已经准备好测试在自定义符号上进行交易的想法,包括自定义符号本身。

应用上述技术,我们修改已经熟悉的智能交易系统 BandOsMaPro,将其重命名为 BandOsMaCustom.mq5。让我们在使用 EqualVolumeBars.mq5 获得的每个柱形图大小为 1000 个 ticks 的 EURUSD 等量图上对其进行测试。

优化或测试模式设置为 OHLC M1 价格(更精确的方法没有意义,因为我们没有生成 ticks,并且因为这个版本是在已形成的柱形图的价格上进行交易)。日期范围是整个 2021 年和 2022 年上半年。附带了带有设置的文件 BandOsMACustom.set

在测试器设置中,不要忘记选择自定义符号 EURUSD_Eqv1000 和 M1 时间框架,因为正是在这个时间框架上模拟了等量柱形图。

WorkSymbol 参数为空时,智能交易系统对自定义符号进行交易。以下是结果:

在 EURUSD_Eqv1000 等量图上进行交易时的测试器报告

在 EURUSD_Eqv1000 等量图上进行交易时的测试器报告

如果 WorkSymbol 参数等于 EURUSD,尽管智能交易系统在 EURUSD_Eqv1000 图表上工作,但它会对 EURUSD 货币对进行交易。结果有所不同,但差异不大。

从 EURUSD_Eqv1000 等量图上对 EURUSD 进行交易时的测试器报告

从 EURUSD_Eqv1000 等量图上对 EURUSD 进行交易时的测试器报告

然而,正如在本节开头已经提到的,对于基于指标信号进行交易的智能交易系统,有一种更简单的方法来支持自定义符号。为此,只需在自定义符号上创建指标,并将智能交易系统放置在工作符号的图表上。

我们可以轻松实现这个选项。我们将其称为 BandOsMACustomSignal.mq5

头文件 CustomOrder.mqh 不再需要。代替 WorkSymbol 输入参数,我们添加两个新的参数:

input string SignalSymbol = "";
input ENUM_TIMEFRAMES SignalTimeframe = PERIOD_M1;

它们应该传递给管理指标的 BandOsMaSignal 类的构造函数。以前,到处都使用 _Symbol_Period

interface TradingSignal
{
   virtual int signal(void);
   virtual string symbol();
   virtual ENUM_TIMEFRAMES timeframe();
};
   
class BandOsMaSignal: public TradingSignal
{
   int hOsMA, hBands, hMA;
   int direction;
   const string _symbol;
   const ENUM_TIMEFRAMES _timeframe;
public:
   BandOsMaSignal(const string s, const ENUM_TIMEFRAMES tf,
      const int fast, const int slow, const int signal, const ENUM_APPLIED_PRICE price,
      const int bands, const int shift, const double deviation,
      const int period, const int x, ENUM_MA_METHOD method): _symbol(s), _timeframe(tf)
   {
      hOsMA = iOsMA(s, tf, fast, slow, signal, price);
      hBands = iBands(s, tf, bands, shift, deviation, hOsMA);
      hMA = iMA(s, tf, period, x, method, hOsMA);
      direction = 0;
   }
   ...
   virtual string symbol() override
   {
      return _symbol;
   }
   
   virtual ENUM_TIMEFRAMES timeframe() override
   {
      return _timeframe;
   }
}

由于信号的符号和时间框架现在可能与图表的符号和周期不同,我们通过添加读取方法扩展了 TradingSignal 接口。实际值在 OnInit 中传递给构造函数。

int OnInit()
{
   ...
   strategy = new SimpleStrategy(
      new BandOsMaSignal(SignalSymbol != "" ? SignalSymbol : _Symbol,
         SignalSymbol != "" ? SignalTimeframe : _Period,
         p.fast, p.slow, SignalOsMA, PriceOsMA,
         BandsMA, BandsShift, BandsDeviation,
         PeriodMA, ShiftMA, MethodMA),
         Magic, StopLoss, Lots);
   return INIT_SUCCEEDED;
}

SimpleStrategy 类中,trade 方法现在不是根据当前图表,而是根据信号的属性来检查新柱形图的出现。

   virtual bool trade() override
   {
      // 在所需符号和时间框架的柱形图开盘时查找一次信号
      if(lastBar == iTime(command[].symbol(), command[].timeframe(), 0)) return false;
      
      int s = command[].signal(); // 获取信号
      ...
   }

为了进行具有相同设置的对比实验,BandOsMACustomSignal.mq5 智能交易系统应该在 EURUSD 上启动(可以使用 M1 或其他时间框架),并且应该在 SignalSymbol 参数中指定 EURUSD_Eqv1000SignalTimeframe 应该默认保持等于 PERIOD_M1。结果,我们将得到类似的报告。

基于 EURUSD_Eqv1000 等量符号的信号在 EURUSD 图表上进行交易时的测试器报告

基于 EURUSD_Eqv1000 等量符号的信号在 EURUSD 图表上进行交易时的测试器报告

这里的柱形图数量和 ticks 数量不同,因为选择了 EURUSD 作为测试工具,而不是自定义的 EURUSD_Eqv1000

所有三个测试结果都略有不同。这是由于将报价 “打包” 到分钟柱形图中,以及原始工具和自定义工具的价格走势略有不同步造成的。哪个结果更准确呢?这很可能取决于具体的交易系统及其实现的特点。在我们的 BandOsMa 智能交易系统控制柱形图开盘的情况下,直接在 EURUSD_Eqv1000 上进行交易的版本应该具有最现实的结果。从理论上讲,经验法则是在几个替代检查中,最可靠的结果往往是最不盈利的,这几乎总是成立的。

所以,我们已经分析了几种使智能交易系统适应在具有经纪商工作符号原型的自定义符号上进行交易的技术。然而,这种情况不是必需的。在许多情况下,自定义符号是基于来自外部系统(如加密货币交易所)的数据生成的。在这些符号上进行交易必须使用它们的公共 API 和 MQL5 网络函数。

用自定义符号模拟特殊类型的图表

许多交易者使用特殊类型的图表,其中不考虑连续的实时数据。这不仅包括等量和等距柱形图,还包括砖形图(Renko)、点数图(Point-And-Figure,PAF)、卡吉图(Kagi)等。自定义符号允许在 MetaTrader 5 中使用 M1 时间框架图表来模拟这些类型的图表,但在测试交易系统而不是进行技术分析时,应该谨慎对待这些图表。

对于特殊类型的图表,实际的柱形图开盘时间(精确到毫秒)几乎总是与 M1 柱形图标记的分钟不完全一致。因此,自定义柱形图的开盘价与标准符号的 M1 柱形图的开盘价不同。

此外,其他 OHLC 价格也会不同,因为特殊图表上 M1 柱形图的实际形成持续时间不等于一分钟。例如,等量图上的 1000 个 ticks 可能会积累超过 5 分钟的时间。

自定义柱形图的收盘价也与实际收盘时间不对应,因为从技术上讲,自定义柱形图是一个 M1 柱形图,即它的名义持续时间为 1 分钟。

在处理经典的砖形图或点数图等类型的图表时,应该特别小心。事实是,它们的反转柱形图的开盘价与前一个柱形图的收盘价存在缺口。因此,开盘价成为了未来价格走势的预测指标。

对这种图表的分析应该根据已形成的柱形图进行,也就是说,它们的特征价格是收盘价。然而,当按柱形图进行操作时,测试器仅为当前(最后一个)柱形图提供开盘价(没有按收盘价操作的模式)。即使我们从已关闭的柱形图(通常是第一个)获取指标信号,交易仍然是按照第 0 个柱形图的当前价格进行的。而且,即使我们采用 tick 模式,测试器总是根据通常的规则生成 ticks,以每个柱形图的配置为参考点。测试器不考虑我们试图用 M1 柱形图直观模拟的特殊图表的结构和行为。

在测试器中使用这样的符号以任何模式(按开盘价、M1 OHLC 或按 ticks)进行交易都会影响结果的准确性:结果过于乐观,可能会导致过高的期望。在这方面,重要的是不要在单独的砖形图或点数图上检查交易系统,而是要结合在真实符号上执行订单的情况进行检查。

自定义符号也可以用于次时间框架或 tick 图表。在这种情况下,也会为柱形图和 ticks 生成与实时脱钩的虚拟时间。因此,这样的图表非常适合进行操作分析,但在开发和测试交易策略,尤其是多符号策略时,需要额外的关注。

对于任何自定义符号,一种替代方法是在智能交易系统或指标内部独立计算柱形图和 ticks 的数组。然而,调试和可视化这样的结构需要额外的努力。