Skip to content

MQL 程序执行环境

我们知道,MQL 程序的源代码编译成 .ex5 格式的二进制可执行代码后,就可以在终端或测试代理上运行了。也就是说,终端或测试器提供了一个让 MQL 程序“运行”的通用环境。

需要提醒的是,内置测试器仅支持两种类型的 MQL 程序:专家顾问和指标。我们将在本书的第五部分详细讨论 MQL 程序的类型及其特点。在本章中,我们将重点介绍那些对所有类型的 MQL 程序都通用的 MQL5 API 函数,这些函数能让你分析执行环境,并在一定程度上对其进行控制。

大多数环境属性可通过 TerminalInfoIntegerTerminalInfoDoubleTerminalInfoStringMQLInfoIntegerMQLInfoString 函数进行只读访问。从函数名就能看出,每个函数返回特定类型的值。这样的架构使得同一函数中组合的属性的实际含义可能大不相同。你也可以在 MQL5 中实现自己的对象层来对这些属性进行另一种分组(稍后在“使用属性绑定到程序环境”部分会给出示例)。

上述这些函数有明确的逻辑划分,分为通用的终端属性(以“Terminal”为前缀)和单个 MQL 程序的属性(以“MQL”为前缀)。然而,在很多情况下,需要同时分析终端和程序的相似特性。例如,使用 DLL 的权限或执行交易操作的权限既会授予整个终端,也会授予特定的程序。因此,将这些函数作为一个整体来综合考虑是有意义的。

只有与错误代码相关的部分环境属性是可写的,具体来说,就是重置上一个错误(ResetLastError)和设置用户错误(SetUserError)。

此外,在本章中,我们还将介绍在程序中关闭终端的函数(TerminalCloseSetReturnError)以及在调试器中暂停程序的函数(DebugBreak)。

获取终端和程序属性的通用列表

用于获取环境属性的内置函数采用了一种通用的方法:每种特定类型的属性都被组合到一个单独的函数中,该函数带有一个指定请求属性的参数。为了识别属性,定义了一些枚举类型:每个枚举元素描述一个属性。

正如我们接下来会看到的,这种方法在 MQL5 API 以及其他领域(包括应用领域)中经常被使用。特别是,类似的函数集可用于获取交易账户和金融工具的属性。

使用 intdoublestring 这三种简单类型的属性就足以描述环境。然而,并非只有整型属性使用 int 类型的值来表示,逻辑标志(例如,权限/禁止、网络连接的存在与否等)以及其他内置枚举(例如,MQL 程序的类型和许可证的类型)也使用 int 类型表示。

考虑到对终端属性和特定 MQL 程序属性的大致划分,有以下用于描述环境的函数:

c
int MQLInfoInteger(ENUM_MQL_INFO_INTEGER p)
int TerminalInfoInteger(ENUM_TERMINAL_INFO_INTEGER p)
double TerminalInfoDouble(ENUM_TERMINAL_INFO_DOUBLE p)
string MQLInfoString(ENUM_MQL_INFO_STRING p)
string TerminalInfoString(ENUM_TERMINAL_INFO_STRING p)

这些函数原型将值类型映射到枚举类型。例如,int 类型的终端属性汇总在 ENUM_TERMINAL_INFO_INTEGER 中,double 类型的终端属性列在 ENUM_TERMINAL_INFO_DOUBLE 中,依此类推。可用枚举及其元素的列表可以在文档的“终端属性”和“MQL 程序”部分找到。

在接下来的部分中,我们将查看所有属性,并根据它们的用途进行分组。但在这里,我们要解决的问题是获取所有现有属性及其值的通用列表。这对于识别 MQL 程序在特定终端实例上运行的“瓶颈”或特性通常是必要的。一种常见的情况是,一个 MQL 程序在一台计算机上可以正常运行,但在另一台计算机上根本无法运行,或者运行时出现一些问题。

随着平台的发展,属性列表会不断更新,因此建议不要基于硬编码到源代码中的列表来请求属性,而是自动进行请求。

在“枚举”部分中,我们创建了一个模板函数 EnumToArray,用于获取枚举元素的完整列表(文件 EnumToArray.mqh)。同样在该部分中,我们引入了脚本 ConversionEnum.mq5,它使用了指定的头文件。在脚本中实现了一个辅助函数 process,它接收一个包含枚举元素代码的数组,并将它们输出到日志中。我们将以这些开发成果作为进一步改进的起点。

我们需要修改 process 函数,使其不仅能获取特定枚举的元素列表,还能使用内置的属性函数之一查询相应的属性。

我们给新版本的脚本命名为 Environment.mq5

由于环境属性分散在几个不同的函数中(在这种情况下是五个),你需要学习如何将指向所需内置函数的指针传递给新版本的 process 函数(请参阅“函数指针(typedef)”部分)。然而,MQL5 不允许将内置函数的地址赋给函数指针。这只能对在 MQL5 中实现的应用函数进行。因此,我们将创建包装函数。例如:

c
int _MQLInfoInteger(const ENUM_MQL_INFO_INTEGER p)
{
   return MQLInfoInteger(p);
}
// 指针类型描述示例  
typedef int (*IntFuncPtr)(const ENUM_MQL_INFO_INTEGER property);
// 指针变量的初始化
IntFuncPtr ptr1 = _MQLInfoInteger;  // 可行
IntFuncPtr ptr2 = MQLInfoInteger;   // 编译错误

上面展示了 MQLInfoInteger 的“包装”示例(显然,它应该有一个不同但最好相似的名称)。其他函数也以类似的方式进行“包装”。总共会有五个。

如果在旧版本的 process 中只有一个指定枚举的模板参数,那么在新版本中我们还需要传递返回值的类型(因为 MQL5 并不“理解”枚举名称中的单词:即使在 ENUM_MQL_INFO_INTEGER 名称中存在结尾“INTEGER”,编译器也无法将其与 int 类型关联起来)。

然而,除了将返回值类型与枚举类型关联起来之外,我们还需要以某种方式将指向相应包装函数的指针(我们之前定义的五个函数之一)传递给 process 函数。毕竟,编译器本身无法根据例如 ENUM_MQL_INFO_INTEGER 类型的参数来确定需要调用 MQLInfoInteger 函数。

为了解决这个问题,创建了一个特殊的模板结构体,它将所有这三个因素结合在一起。

c
template<typename E, typename R>
struct Binding
{
public:
   typedef R (*FuncPtr)(const E property);
   const FuncPtr f;
   Binding(FuncPtr p): f(p) { }
};

这两个模板参数允许使用结果和输入参数的所需组合来指定函数指针(FuncPtr)的类型。结构体实例有一个 f 字段,用于存储指向新定义类型的指针。

现在,新版本的 process 函数可以描述如下:

c
template<typename E, typename R>
void process(Binding<E, R> &b)
{
   E e = (E)0; // 关闭关于未初始化的警告
   int array[];
   // 将枚举元素列表获取到数组中
   int n = EnumToArray(e, array, 0, USHORT_MAX);
   Print(typename(E), " Count=", n);
   ResetLastError();
   // 显示每个元素的名称和值,
   // 通过调用 Binding 结构体中的指针获取
   for(int i = 0; i < n; ++i)
   {
      e = (E)array[i];
      R r = b.f(e); // 调用函数,然后解析 _LastError
      const int snapshot = _LastError;
      PrintFormat("% 3d %s=%s", i, EnumToString(e), (string)r +
         (snapshot != 0 ? E2S(snapshot) + " (" + (string)snapshot + ")" : ""));
      ResetLastError();
   }
}

输入参数是 Binding 结构体。它包含一个指向用于获取属性的特定函数的指针(这个字段将由调用代码填充)。

这个版本的算法会记录属性的序号、属性标识符及其值。再次注意,每个条目中的第一个数字将是枚举中元素的序号,而不是值(元素的值可以有间隔地分配)。你可以选择在 print format 指令中“原样”添加变量 e 的输出。

此外,你可以修改 process 函数,使其将得到的属性值收集到一个数组(或其他容器,如映射)中,并将它们“返回”到外部。

print format 指令中直接引用函数指针并同时分析 _LastError 错误代码可能会导致潜在的错误。问题在于,在这种情况下,函数参数(请参阅“参数和实参”部分)和表达式中操作数(请参阅“基本概念”部分)的求值顺序是未定义的。因此,当在读取 _LastError 的同一行上调用指针时,编译器可能会决定先执行第二个操作(读取 _LastError)再执行第一个操作(调用指针)。结果,我们将看到一个不相关的错误代码(例如,来自之前的函数调用)。

但这还不是全部。如果任何操作失败,内置变量 _LastError 几乎可以在表达式求值的任何地方改变其值。特别是,如果传递给 EnumToString 函数的参数值不在枚举中,该函数可能会引发一个错误代码。在这个代码片段中,我们不会受到这个问题的影响,因为我们的 EnumToArray 函数返回的数组中只包含经过检查(有效的)枚举元素。然而,在一般情况下,在任何“复合”指令中,可能有很多地方会改变 _LastError 的值。在这方面,最好在我们感兴趣的操作(这里是通过指针调用函数)之后立即固定错误代码,将其保存到中间变量 snapshot 中。

但让我们回到主要问题。我们终于可以组织对新函数 process 的调用,以获取软件环境的各种属性。

c
void OnStart()
{
   process(Binding<ENUM_MQL_INFO_INTEGER, int>(_MQLInfoInteger));
   process(Binding<ENUM_TERMINAL_INFO_INTEGER, int>(_TerminalInfoInteger));
   process(Binding<ENUM_TERMINAL_INFO_DOUBLE, double>(_TerminalInfoDouble));
   process(Binding<ENUM_MQL_INFO_STRING, string>(_MQLInfoString));
   process(Binding<ENUM_TERMINAL_INFO_STRING, string>(_TerminalInfoString));
}

下面是生成的日志条目的片段:

ENUM_MQL_INFO_INTEGER Count=15
  0 MQL_PROGRAM_TYPE=1
  1 MQL_DLLS_ALLOWED=0
  2 MQL_TRADE_ALLOWED=0
  3 MQL_DEBUG=1
...
  7 MQL_LICENSE_TYPE=0
...
ENUM_TERMINAL_INFO_INTEGER Count=50
  0 TERMINAL_BUILD=2988
  1 TERMINAL_CONNECTED=1
  2 TERMINAL_DLLS_ALLOWED=0
  3 TERMINAL_TRADE_ALLOWED=0
...
  6 TERMINAL_MAXBARS=100000
  7 TERMINAL_CODEPAGE=1251
  8 TERMINAL_MEMORY_PHYSICAL=4095
  9 TERMINAL_MEMORY_TOTAL=8190
 10 TERMINAL_MEMORY_AVAILABLE=7813
 11 TERMINAL_MEMORY_USED=377
 12 TERMINAL_X64=1
...
ENUM_TERMINAL_INFO_DOUBLE Count=2
  0 TERMINAL_COMMUNITY_BALANCE=0.0 (MQL5_WRONG_PROPERTY,4512)
  1 TERMINAL_RETRANSMISSION=0.0
ENUM_MQL_INFO_STRING Count=2
  0 MQL_PROGRAM_NAME=Environment
  1 MQL_PROGRAM_PATH=C:\Program Files\MT5East\MQL5\Scripts\MQL5Book\p4\Environment.ex5
ENUM_TERMINAL_INFO_STRING Count=6
  0 TERMINAL_COMPANY=MetaQuotes Software Corp.
  1 TERMINAL_NAME=MetaTrader 5
  2 TERMINAL_PATH=C:\Program Files\MT5East
  3 TERMINAL_DATA_PATH=C:\Program Files\MT5East
  4 TERMINAL_COMMONDATA_PATH=C:\Users\User\AppData\Roaming\MetaQuotes\Terminal\Common
  5 TERMINAL_LANGUAGE=Russian

这些以及其他属性将在接下来的部分中进行描述。

值得注意的是,一些属性是从平台开发的先前阶段继承而来的,仅为了兼容性而保留。特别是,TerminalInfoInteger 中的 TERMINAL_X64 属性返回终端是否为 64 位的指示。如今,32 位版本的开发已经停止,因此该属性始终等于 1(true)。

终端版本号

由于终端在不断改进,其新版本会出现新的功能,因此 MQL 程序可能需要分析当前的终端版本,以便应用不同的算法选项。此外,没有任何程序(包括终端本身)能完全避免出现错误。所以,如果出现问题,应该提供包含当前终端版本的诊断输出,这有助于重现和修复错误。

你可以使用 ENUM_TERMINAL_INFO_INTEGER 中的 TERMINAL_BUILD 属性来获取终端的版本号。

c
if(TerminalInfoInteger(TERMINAL_BUILD) >= 3000)
{
   ...
}

需要提醒的是,用于构建程序的编译器的版本号在源代码中可以通过宏定义 __MQLBUILD____MQL5BUILD__ 来获取(请参阅“预定义常量”部分)。

程序类型和许可证

相同的源代码可以以某种方式包含在不同类型的 MQL 程序中。除了在编译阶段将源代码(预处理指令 #include)包含到一个通用产品中的选项外,还可以组装库——在执行阶段连接到主程序的二进制程序模块。

然而,有些函数只允许在特定类型的程序中使用。例如,OrderCalcMargin 函数不能在指标中使用。尽管这种限制似乎没有根本的合理性,但开发一个通用的保证金计算算法(该算法不仅可以嵌入到专家顾问中,还可以嵌入到指标中)的开发者应该考虑到这一细微差别,并为指标提供一种替代的计算方法。

每个章节的适当部分将给出程序类型的完整限制列表。在所有这些情况下,了解“父”程序的类型很重要。

为了确定程序类型,ENUM_MQL_INFO_INTEGER 中有 MQL_PROGRAM_TYPE 属性。可能的属性值在 ENUM_PROGRAM_TYPE 枚举中描述。

标识符描述
PROGRAM_SCRIPT1脚本
PROGRAM_EXPERT2专家顾问
PROGRAM_INDICATOR4指标
PROGRAM_SERVICE5服务

在上一节的日志片段中,我们看到 PROGRAM_SCRIPT 属性被设置为 1,因为我们的测试是一个脚本。要获得字符串描述,可以使用 EnumToString 函数。

c
ENUM_PROGRAM_TYPE type = (ENUM_PROGRAM_TYPE)MQLInfoInteger(MQL_PROGRAM_TYPE);
Print(EnumToString(type));

MQL 程序的另一个便于分析以启用/禁用某些功能的属性是许可证类型。如你所知,MQL 程序可以免费分发,也可以在 MQL5 市场中分发。此外,市场中的程序可以购买,也可以作为演示版本下载。这些因素很容易检查,如果需要,可以为它们调整算法。为此,ENUM_MQL_INFO_INTEGER 中有 MQL_LICENSE_TYPE 属性,它使用 ENUM_LICENSE_TYPE 枚举作为类型。

标识符描述
LICENSE_FREE0免费无限制版本
LICENSE_DEMO1市场中付费产品的演示版本,仅在策略测试器中工作
LICENSE_FULL2购买的许可版本,允许至少 5 次激活(卖家可以增加激活次数)
LICENSE_TIME3限时版本(尚未实现)

这里需要注意的是,许可证指的是使用 MQLInfoInteger(MQL_LICENSE_TYPE) 进行请求的二进制 .ex5 模块。在库中,此函数将返回库自身的许可证,而不是链接该库的主程序的许可证。

作为测试本节两个函数的示例,本书附带了一个简单的服务 EnvType.mq5。它不包含工作循环,因此在执行 OnStart 中的两条指令后将立即终止。

c
#property service
   
void OnStart()
{
   Print(EnumToString((ENUM_PROGRAM_TYPE)MQLInfoInteger(MQL_PROGRAM_TYPE)));
   Print(EnumToString((ENUM_LICENSE_TYPE)MQLInfoInteger(MQL_LICENSE_TYPE)));
}

为了简化其启动,即消除在终端中通过导航器的上下文菜单创建服务实例并运行它的需要,建议使用调试器:只需在 MetaEditor 中打开源代码,然后执行命令“调试” -> “在真实数据上启动”(F5 或工具栏中的按钮)。

我们应该会得到以下日志条目:

EnvType (debug)        PROGRAM_SERVICE
EnvType (debug)        LICENSE_FREE

在这里,你可以清楚地看到程序类型是服务,并且实际上没有许可证(免费使用)。

终端和程序操作模式

MetaTrader 5 环境为交易与编程交叉领域的各种任务提供了解决方案,这就需要终端本身以及特定程序具备多种操作模式。

使用 MQL5 API,你可以区分常规的在线活动和回测,区分源代码调试(以识别潜在错误)和性能分析(查找代码中的瓶颈),以及区分终端的本地副本和云端副本(MetaTrader VPS)。

这些模式由标志来描述,每个标志都包含一个布尔类型的值:truefalse

标识符描述
MQL_DEBUG程序在调试模式下运行
MQL_PROFILER程序在代码分析模式下工作
MQL_TESTER程序在测试器中工作
MQL_FORWARD程序在正向测试过程中执行
MQL_OPTIMIZATION程序在优化过程中运行
MQL_VISUAL_MODE程序在可视化测试模式下运行
MQL_FRAME_MODE专家顾问在图表上以收集优化结果帧的模式执行
TERMINAL_VPS终端在 MetaTrader 虚拟主机(MetaTrader VPS)虚拟服务器上工作

MQL_FORWARDMQL_OPTIMIZATIONMQL_VISUAL_MODE 标志意味着 MQL_TESTER 标志已设置。

一些标志的两两组合是相互排斥的,即这些标志不能同时启用。

具体来说,MQL_FRAME_MODE 的存在排除了 MQL_TESTER,反之亦然。MQL_OPTIMIZATION 排除了 MQL_VISUAL_MODEMQL_PROFILER 排除了 MQL_DEBUG

我们将在专门介绍专家顾问以及部分介绍指标的章节中研究所有与测试相关的标志(MQL_TESTERMQL_VISUAL_MODE)。所有与专家顾问优化相关的内容(MQL_OPTIMIZATIONMQL_FORWARDMQL_FRAME_MODE)将在单独的章节中介绍。

现在让我们以调试(MQL_DEBUG)和分析(MQL_PROFILER)模式为例,了解读取标志的原理。同时,让我们回顾一下如何从 MetaEditor 激活这些模式(详细信息,请参阅文档中的“调试”和“分析”部分)。

我们将使用 EnvMode.mq5 脚本。

c
void OnStart()
{
   PRTF(MQLInfoInteger(MQL_TESTER));
   PRTF(MQLInfoInteger(MQL_DEBUG));
   PRTF(MQLInfoInteger(MQL_PROFILER));
   PRTF(MQLInfoInteger(MQL_VISUAL_MODE));
   PRTF(MQLInfoInteger(MQL_OPTIMIZATION));
   PRTF(MQLInfoInteger(MQL_FORWARD));
   PRTF(MQLInfoInteger(MQL_FRAME_MODE));
}

在运行程序之前,你应该检查调试/分析设置。为此,在 MetaEditor 中,运行“工具” -> “选项”命令,并检查“调试/分析”选项卡中的字段值。如果启用了“使用指定设置”选项,那么底层字段的值将影响程序将在其上启动的金融工具图表和时间框架。如果禁用该选项,将使用“市场报价”中的第一个金融工具和 H1 时间框架。

在这个阶段,选项的选择并不关键。

准备好后,使用“调试” -> “在真实数据上启动”(F5)命令运行脚本。由于该脚本仅将请求的属性打印到日志中(并且我们不需要在其中设置断点),其执行将是瞬间的。如果需要逐步调试,我们可以在源代码的任何语句上设置断点(F9),脚本执行将在我们需要的任何时间段内冻结,从而可以在 MetaEditor 中研究所有变量的内容,并且还可以逐行(F10)沿着算法移动。

在 MetaTrader 5 日志(“专家”选项卡)中,我们将看到以下内容:

MQLInfoInteger(MQL_TESTER)=0 / ok
MQLInfoInteger(MQL_DEBUG)=1 / ok
MQLInfoInteger(MQL_PROFILER)=0 / ok
MQLInfoInteger(MQL_VISUAL_MODE)=0 / ok
MQLInfoInteger(MQL_OPTIMIZATION)=0 / ok
MQLInfoInteger(MQL_FORWARD)=0 / ok
MQLInfoInteger(MQL_FRAME_MODE)=0 / ok

除了 MQL_DEBUG 之外,所有模式的标志都被重置。

现在让我们从 MetaTrader 5 的导航器中运行相同的脚本(只需用鼠标将其拖动到任何图表上)。我们将得到几乎相同的一组标志,但这次 MQL_DEBUG 将等于 0(因为程序是以常规方式执行的,而不是在调试器下执行的)。

请注意,在调试模式下启动程序之前,会以特殊模式对其进行重新编译,此时会在可执行文件中添加允许调试的服务信息。这样的二进制文件比平常更大且运行更慢。因此,在调试完成后,在将其用于实际交易、转移给客户或上传到市场之前,应该使用“文件” -> “编译”(F7)命令重新编译程序。

编译方法不会直接影响 MQL_DEBUG 属性。正如我们所看到的,程序的调试版本可以在没有调试器的终端中启动,在这种情况下 MQL_DEBUG 将被重置。有两个内置宏可用于确定编译方法:_DEBUG_RELEASE(请参阅“预定义常量”部分)。它们是常量,而不是函数,因为这个属性在编译时就“硬编码”到程序中了,之后无法更改(与运行时环境不同)。

现在让我们在 MetaEditor 中执行“调试” -> “在真实数据上开始分析”命令。当然,对这样一个简单的脚本进行分析没有特别的意义,但我们现在的任务是确保环境属性中的相应标志已打开。实际上,现在 MQL_PROFILER 对应的标志值为 1。

MQLInfoInteger(MQL_TESTER)=0 / ok
MQLInfoInteger(MQL_DEBUG)=0 / ok
MQLInfoInteger(MQL_PROFILER)=1 / ok
...

在分析模式下启动程序之前,也会以另一种特殊模式对其进行重新编译,这种模式会在二进制文件中添加其他服务信息,这些信息对于测量指令执行速度是必要的。在分析分析器报告并修复瓶颈之后,应该以常规方式重新编译程序。

原则上,调试和分析既可以在线进行,也可以在测试器(MQL_TESTER)中基于历史数据进行,但测试器仅支持专家顾问和指标。因此,在脚本示例中不可能看到设置的 MQL_TESTERMQL_VISUAL_MODE 标志。

如你所知,MetaTrader 5 允许以快速模式(无图表)和可视化模式(在单独的图表上)测试交易程序。正是在第二种情况下,MQL_VISUAL_MODE 属性将被启用。特别是,检查该属性是有意义的,以便在没有可视化的情况下禁用对图形对象的操作。

要在可视化模式下使用历史数据进行调试,必须首先在 MetaEditor 设置对话框中启用“在历史数据上使用可视化模式进行调试”选项。分析程序(指标)总是在可视化模式下进行测试。

请记住,在线调试对于交易专家顾问来说是不安全的。

权限

出于安全原因,MetaTrader 5 提供了限制 MQL 程序执行某些操作的功能。其中一些限制是两级的,也就是说,它们分别针对整个终端和特定程序进行设置。终端设置具有优先级,或者作为任何 MQL 程序设置的默认值。例如,交易者可以通过在 MetaTrader 5 设置对话框中勾选相应的复选框来禁用所有自动交易。在这种情况下,之前在特定交易机器人的对话框中设置的私人交易权限将失效。

在 MQL5 API 中,可以通过 TerminalInfoIntegerMQLInfoInteger 函数读取这些限制(或者相反,权限)。由于它们对 MQL 程序具有相同的影响,所以程序必须同样仔细地检查通用的和特定的禁止事项(以避免在尝试执行非法操作时产生错误)。因此,本节提供了不同级别所有选项的列表。

所有权限都是布尔标志,即它们存储的值为 truefalse

标识符描述
TERMINAL_DLLS_ALLOWED使用 DLL 的权限
TERMINAL_TRADE_ALLOWED在线自动交易的权限
TERMINAL_EMAIL_ENABLED发送电子邮件的权限(必须在终端设置中指定 SMTP 服务器和登录信息)
TERMINAL_FTP_ENABLED通过 FTP 将文件发送到指定服务器的权限(包括终端设置中指定的交易账户的报告)
TERMINAL_NOTIFICATIONS_ENABLED向智能手机发送推送通知的权限
MQL_DLLS_ALLOWED此程序使用 DLL 的权限
MQL_TRADE_ALLOWED程序自动交易的权限
MQL_SIGNALS_ALLOWED程序处理信号的权限

终端级别使用 DLL 的权限意味着,当运行包含指向某个动态库链接的 MQL 程序时,其属性对话框中“依赖项”选项卡上的“启用 DLL 导入”标志将默认启用。如果在终端设置中清除了该标志,那么 MQL 程序属性中的该选项将默认禁用。在任何情况下,用户都必须为单个程序允许导入(脚本有一个例外情况,下面将讨论)。否则,程序将无法运行。

换句话说,TERMINAL_DLLS_ALLOWEDMQL_DLLS_ALLOWED 标志可以由不绑定 DLL 的程序检查,也可以由绑定 DLL 的程序检查,但对于这个程序,MQL_DLLS_ALLOWED 必须明确等于 true(因为它已经启动)。因此,对于需要 DLL 的软件系统,可能有必要提供一个独立的实用程序,该程序可以监视标志的状态,并在标志突然关闭时为用户显示诊断信息。例如,一个专家顾问可能需要一个使用 DLL 的指标。然后,在尝试加载该指标并获取其句柄之前,专家顾问可以检查 TERMINAL_DLLS_ALLOWED 标志,如果该标志被重置,则生成一个警告。

对于脚本,其行为略有不同,因为只有在源代码中存在 #property script_show_inputs 指令时,才会打开脚本设置对话框。如果不存在该指令,那么当终端设置中 TERMINAL_DLLS_ALLOWED 标志被重置时,对话框会出现(并且用户必须启用该标志,脚本才能工作)。当通用标志 TERMINAL_DLLS_ALLOWED 被启用时,脚本无需用户确认即可运行,即 MQL_DLLS_ALLOWED 的值被假定为 true(根据 TERMINAL_DLLS_ALLOWED)。

在测试器中工作时,TERMINAL_TRADE_ALLOWEDMQL_TRADE_ALLOWED 标志始终等于 true。然而,在指标中,无论这些标志如何,对所有交易函数的访问都是被禁止的。测试器不允许测试依赖 DLL 的 MQL 程序。

TERMINAL_EMAIL_ENABLEDTERMINAL_FTP_ENABLEDTERMINAL_NOTIFICATIONS_ENABLED 标志对于“网络函数”部分中描述的 send mailSendFTPsend notification 函数至关重要。MQL_SIGNALS_ALLOWED 标志会影响一组管理 mql5.com 交易信号订阅的函数的可用性(本书不讨论)。它的状态对应于 MQL 程序属性“常规”选项卡中的“允许更改信号设置”选项。

由于检查某些属性需要额外的工作,所以将这些标志包装在一个类中是有意义的,该类在其方法中隐藏了对各种系统函数的多次调用。这一点更加必要,因为一些权限并不限于上述选项。例如,交易权限不仅可以在终端或 MQL 程序级别设置(或取消),还可以针对单个金融工具进行设置——根据你的经纪商和交易所交易时段的规定。因此,在这一步,我们将展示 Permissions 类的草案,它目前只包含我们熟悉的元素,然后我们将针对特定的应用 API 对其进行改进。

由于这个类充当程序层,程序员不必记住哪些权限是为 TerminalInfo 函数定义的,哪些是为 MqlInfo 函数定义的。

源代码位于 EnvPermissions.mq5 文件中。

c
class Permissions
{
public:
   static bool isTradeEnabled(const string symbol = NULL, const datetime session = 0)
   {
      // 待办事项:将补充对交易品种和交易时段的应用检查
      return PRTF(TerminalInfoInteger(TERMINAL_TRADE_ALLOWED))
          && PRTF(MQLInfoInteger(MQL_TRADE_ALLOWED));
   }
   static bool isDllsEnabledByDefault()
   {
      return (bool)PRTF(TerminalInfoInteger(TERMINAL_DLLS_ALLOWED));
   }
   static bool isDllsEnabled()
   {
      return (bool)PRTF(MQLInfoInteger(MQL_DLLS_ALLOWED));
   }
   
   static bool isEmailEnabled()
   {
      return (bool)PRTF(TerminalInfoInteger(TERMINAL_EMAIL_ENABLED));
   }
   
   static bool isFtpEnabled()
   {
      return (bool)PRTF(TerminalInfoInteger(TERMINAL_FTP_ENABLED));
   }
   
   static bool isPushEnabled()
   {
      return (bool)PRTF(TerminalInfoInteger(TERMINAL_NOTIFICATIONS_ENABLED));
   }
   
   static bool isSignalsEnabled()
   {
      return (bool)PRTF(MQLInfoInteger(MQL_SIGNALS_ALLOWED));
   }
};

所有类方法都是静态的,并在 OnStart 中调用。

c
void OnStart()
{
   Permissions::isTradeEnabled();
   Permissions::isDllsEnabledByDefault();
   Permissions::isDllsEnabled();
   Permissions::isEmailEnabled();
   Permissions::isPushEnabled();
   Permissions::isSignalsEnabled();
}

下面显示了生成的日志示例。

TerminalInfoInteger(TERMINAL_TRADE_ALLOWED)=1 / ok
MQLInfoInteger(MQL_TRADE_ALLOWED)=1 / ok
TerminalInfoInteger(TERMINAL_DLLS_ALLOWED)=0 / ok
MQLInfoInteger(MQL_DLLS_ALLOWED)=0 / ok
TerminalInfoInteger(TERMINAL_EMAIL_ENABLED)=0 / ok
TerminalInfoInteger(TERMINAL_NOTIFICATIONS_ENABLED)=0 / ok
MQLInfoInteger(MQL_SIGNALS_ALLOWED)=0 / ok

为了自学,该脚本具有内置(但已注释掉)的连接系统 DLL 以读取 Windows 剪贴板内容的功能。我们将在本书的第七部分“库”章节中考虑库的创建和使用,特别是 #import 指令。

假设在终端中禁用了全局 DLL 导入选项(出于安全原因,这是推荐的设置)。那么,如果脚本连接了 DLL,则只有在其单独的设置对话框中允许导入,才能运行该脚本,结果 MQLInfoInteger(MQL_DLLS_ALLOWED) 将返回 1(true)。如果给予了 DLL 的全局权限,那么 TerminalInfoInteger(TERMINAL_DLLS_ALLOWED)=1,并且 MQL_DLLS_ALLOWED 将继承该值。

检查网络连接

如你所知,MetaTrader 5 平台是一个分布式系统,包含多个环节。除了客户端终端和经纪商服务器外,它还包括 MQL5 社区、市场、云服务等等。实际上,客户端部分也是分布式的,由终端和测试代理组成,这些测试代理可以部署在本地网络的多台计算机上。在这种情况下,任何环节之间的连接都有可能因为这样或那样的原因而中断。尽管 MetaTrader 5 基础设施试图自动恢复其功能,但并不总是能够迅速做到这一点。

因此,在 MQL 程序中,应该考虑到连接中断的可能性。MQL5 API 允许你控制最重要的连接:与交易服务器和 MQL5 社区的连接。TerminalInfoInteger 中有以下可用属性:

标识符描述
TERMINAL_CONNECTED与交易服务器的连接状态
TERMINAL_PING_LAST上次已知到交易服务器的 ping 值(以微秒为单位)
TERMINAL_COMMUNITY_ACCOUNT终端中是否存在 MQL5.community 的授权数据
TERMINAL_COMMUNITY_CONNECTION与 MQL5.community 的连接状态
TERMINAL_MQID是否存在用于发送推送通知的 MetaQuotes ID

除了 TERMINAL_PING_LAST 之外,所有属性都是布尔标志。TERMINAL_PING_LAST 包含一个 int 类型的值。

除了连接状态之外,MQL 程序通常还需要确保其拥有的数据是最新的。特别是,已检查的 TERMINAL_CONNECTED 标志并不意味着你感兴趣的报价已经与服务器同步。为此,你需要额外检查 SymbolIsSynchronizedSeriesInfoInteger(..., SERIES_SYNCHRONIZED)。这些功能将在关于时间序列的章节中讨论。

TerminalInfoDouble 函数支持另一个有趣的属性:TERMINAL_RETRANSMISSION。它表示在这台计算机上所有正在运行的应用程序和服务中,TCP/IP 协议重传的网络数据包的百分比。即使在最快且配置最恰当的网络上,有时也会出现数据包丢失的情况,结果就是接收方和发送方之间不会有数据包送达的确认。在这种情况下,丢失的数据包会被重传。终端本身并不计算 TERMINAL_RETRANSMISSION 指标,而是每分钟在操作系统中请求一次该指标。

这个指标的数值较高可能表明存在外部问题(互联网连接、你的网络服务提供商、本地网络或计算机问题),这可能会降低终端连接的质量。

如果确认与社区有连接(TERMINAL_COMMUNITY_CONNECTION),MQL 程序可以通过调用 TerminalInfoDouble(TERMINAL_COMMUNITY_BALANCE) 查询用户的当前余额。这允许你使用对付费交易信号的自动订阅(API 文档可在 mql5.com 网站上获取)。

让我们使用脚本 EnvConnection.mq5 检查列出的属性:

c
void OnStart()
{
   PRTF(TerminalInfoInteger(TERMINAL_CONNECTED));
   PRTF(TerminalInfoInteger(TERMINAL_PING_LAST));
   PRTF(TerminalInfoInteger(TERMINAL_COMMUNITY_ACCOUNT));
   PRTF(TerminalInfoInteger(TERMINAL_COMMUNITY_CONNECTION));
   PRTF(TerminalInfoInteger(TERMINAL_MQID));
   PRTF(TerminalInfoDouble(TERMINAL_RETRANSMISSION));
   PRTF(TerminalInfoDouble(TERMINAL_COMMUNITY_BALANCE));
}

以下是一个日志示例(值将与你的设置匹配):

TerminalInfoInteger(TERMINAL_CONNECTED)=1 / ok
TerminalInfoInteger(TERMINAL_PING_LAST)=49082 / ok
TerminalInfoInteger(TERMINAL_COMMUNITY_ACCOUNT)=0 / ok
TerminalInfoInteger(TERMINAL_COMMUNITY_CONNECTION)=0 / ok
TerminalInfoInteger(TERMINAL_MQID)=0 / ok
TerminalInfoDouble(TERMINAL_RETRANSMISSION)=0.0 / ok
TerminalInfoDouble(TERMINAL_COMMUNITY_BALANCE)=0.0 / ok

计算资源:内存、磁盘和 CPU

与所有程序一样,MQL 应用程序会消耗计算机资源,包括内存、磁盘空间和 CPU。考虑到终端本身就是资源密集型的(特别是因为可能需要下载多个具有较长历史数据的金融工具的报价和报价数据点),有时有必要分析和控制可用资源接近极限的情况。

MQL5 API 提供了几个属性,使你能够估计可达到的最大资源量以及已消耗的资源量。这些属性汇总在 ENUM_MQL_INFO_INTEGERENUM_TERMINAL_INFO_INTEGER 枚举中。

标识符描述
MQL_MEMORY_LIMITMQL 程序可使用的最大动态内存量(单位:Kb)
MQL_MEMORY_USEDMQL 程序已使用的内存量(单位:Mb)
MQL_HANDLES_USED类对象的数量
TERMINAL_MEMORY_PHYSICAL系统中的物理内存量(单位:Mb)
TERMINAL_MEMORY_TOTAL终端(代理)进程可用的内存(物理内存 + 交换文件,即虚拟内存)量(单位:Mb)
TERMINAL_MEMORY_AVAILABLE终端(代理)进程的可用空闲内存量(单位:Mb),是 TERMINAL_MEMORY_TOTAL 的一部分
TERMINAL_MEMORY_USED终端(代理)已使用的内存量(单位:Mb),是 TERMINAL_MEMORY_TOTAL 的一部分
TERMINAL_DISK_SPACE考虑到终端(代理)的 MQL5/Files 文件夹可能存在的配额后的可用磁盘空间量(单位:Mb)
TERMINAL_CPU_CORES系统中的处理器核心数量
TERMINAL_OPENCL_SUPPORT支持的 OpenCL 版本,0x00010002 = 1.2;“0”表示不支持 OpenCL

MQL 程序可用的最大内存量由 MQL_MEMORY_LIMIT 属性描述。这是列出的唯一使用千字节(Kb)为单位的属性。所有其他属性都以兆字节(Mb)为单位返回。通常,MQL_MEMORY_LIMIT 等于 TERMINAL_MEMORY_TOTAL,即默认情况下,计算机上的所有可用内存都可以分配给一个 MQL 程序。然而,终端,特别是其针对 MetaTrader VPS 的云端实现,以及云端测试代理可能会限制单个 MQL 程序的内存。那么 MQL_MEMORY_LIMIT 将显著小于 TERMINAL_MEMORY_TOTAL

由于 Windows 通常会创建一个大小与物理内存(RAM)相等的交换文件,所以 TERMINAL_MEMORY_TOTAL 属性的值可能是 TERMINAL_MEMORY_PHYSICAL 的两倍。

所有可用的虚拟内存 TERMINAL_MEMORY_TOTAL 分为已使用的(TERMINAL_MEMORY_USED)和仍空闲的(TERMINAL_MEMORY_AVAILABLE)内存。

本书附带了脚本 EnvProvision.mq5,它会将所有指定的属性记录到日志中。

c
void OnStart()
{
   PRTF(MQLInfoInteger(MQL_MEMORY_LIMIT)); // Kb!
   PRTF(MQLInfoInteger(MQL_MEMORY_USED));
   PRTF(TerminalInfoInteger(TERMINAL_MEMORY_PHYSICAL));
   PRTF(TerminalInfoInteger(TERMINAL_MEMORY_TOTAL));
   PRTF(TerminalInfoInteger(TERMINAL_MEMORY_AVAILABLE));
   PRTF(TerminalInfoInteger(TERMINAL_MEMORY_USED));
   PRTF(TerminalInfoInteger(TERMINAL_DISK_SPACE));
   PRTF(TerminalInfoInteger(TERMINAL_CPU_CORES));
   PRTF(TerminalInfoInteger(TERMINAL_OPENCL_SUPPORT));
   
   uchar array[];
   PRTF(ArrayResize(array, 1024 * 1024 * 10)); // 分配 10 Mb
   PRTF(MQLInfoInteger(MQL_MEMORY_USED));
   PRTF(TerminalInfoInteger(TERMINAL_MEMORY_AVAILABLE));
   PRTF(TerminalInfoInteger(TERMINAL_MEMORY_USED));
}

在初始输出属性之后,我们为数组分配 10 Mb 的内存,然后再次检查内存情况。下面是一个结果示例(你会有自己的值):

MQLInfoInteger(MQL_MEMORY_LIMIT)=8388608 / ok
MQLInfoInteger(MQL_MEMORY_USED)=1 / ok
TerminalInfoInteger(TERMINAL_MEMORY_PHYSICAL)=4095 / ok
TerminalInfoInteger(TERMINAL_MEMORY_TOTAL)=8190 / ok
TerminalInfoInteger(TERMINAL_MEMORY_AVAILABLE)=7842 / ok
TerminalInfoInteger(TERMINAL_MEMORY_USED)=348 / ok
TerminalInfoInteger(TERMINAL_DISK_SPACE)=4528 / ok
TerminalInfoInteger(TERMINAL_CPU_CORES)=4 / ok
TerminalInfoInteger(TERMINAL_OPENCL_SUPPORT)=0 / ok
ArrayResize(array,1024*1024*10)=10485760 / ok
MQLInfoInteger(MQL_MEMORY_USED)=11 / ok
TerminalInfoInteger(TERMINAL_MEMORY_AVAILABLE)=7837 / ok
TerminalInfoInteger(TERMINAL_MEMORY_USED)=353 / ok

请注意,总虚拟内存(8190)是物理内存(4095)的两倍。脚本可用的内存量为 8388608 Kb,几乎等于 8190 Mb 的全部内存。空闲内存(7842)和已用系统内存(348)相加也等于 8190。

如果在为数组分配内存之前,MQL 程序占用了 1 Mb 的内存,那么在分配内存之后,它已经占用了 11 Mb。同时,终端占用的内存量仅增加了 5 Mb(从 348 到 353),因为预先保留了一些资源。

屏幕规格

TerminalInfoInteger 函数提供的几个属性与计算机的视频子系统相关。

标识符描述
TERMINAL_SCREEN_DPI输出到屏幕的信息分辨率,以每线性英寸的点数(DPI,Dots Per Inch)为单位进行测量
TERMINAL_SCREEN_LEFT虚拟屏幕的左坐标
TERMINAL_SCREEN_TOP虚拟屏幕的上坐标
TERMINAL_SCREEN_WIDTH虚拟屏幕的宽度
TERMINAL_SCREEN_HEIGHT虚拟屏幕的高度
TERMINAL_LEFT终端相对于虚拟屏幕的左坐标
TERMINAL_TOP终端相对于虚拟屏幕的上坐标
TERMINAL_RIGHT终端相对于虚拟屏幕的右坐标
TERMINAL_BOTTOM终端相对于虚拟屏幕的下坐标

了解 TERMINAL_SCREEN_DPI 参数后,你可以设置图形对象的尺寸,以便它们在不同分辨率的显示器上看起来相同。例如,如果你想创建一个可见尺寸为 X 厘米的按钮,那么你可以使用以下函数将其指定为屏幕点数(像素):

c
int cm2pixels(const double x)
{
   static const double inch2cm = 2.54; // 1 英寸等于 2.54 厘米
   return (int)(x / inch2cm * TerminalInfoInteger(TERMINAL_SCREEN_DPI));
}

虚拟屏幕是所有显示器的边界框。如果系统中有多个显示器,并且它们的排列顺序不是严格从左到右,那么虚拟屏幕的左坐标可能为负,并且中心(参考点)将在两个显示器的边界上(在主显示器的左上角)。

多个显示器组成的虚拟屏幕

多个显示器组成的虚拟屏幕

如果系统中只有一个显示器,那么虚拟屏幕的大小完全与其对应。

终端坐标不考虑其当前是否可能处于最大化状态(也就是说,如果主窗口已最大化,这些属性将返回未最大化时的大小,尽管终端已扩展到整个显示器)。

EnvScreen.mq5 脚本中,检查读取屏幕属性的功能。

c
void OnStart()
{
   PRTF(TerminalInfoInteger(TERMINAL_SCREEN_DPI));
   PRTF(TerminalInfoInteger(TERMINAL_SCREEN_LEFT));
   PRTF(TerminalInfoInteger(TERMINAL_SCREEN_TOP));
   PRTF(TerminalInfoInteger(TERMINAL_SCREEN_WIDTH));
   PRTF(TerminalInfoInteger(TERMINAL_SCREEN_HEIGHT));
   PRTF(TerminalInfoInteger(TERMINAL_LEFT));
   PRTF(TerminalInfoInteger(TERMINAL_TOP));
   PRTF(TerminalInfoInteger(TERMINAL_RIGHT));
   PRTF(TerminalInfoInteger(TERMINAL_BOTTOM));
}

以下是生成的日志条目的示例:

TerminalInfoInteger(TERMINAL_SCREEN_DPI)=96 / ok
TerminalInfoInteger(TERMINAL_SCREEN_LEFT)=0 / ok
TerminalInfoInteger(TERMINAL_SCREEN_TOP)=0 / ok
TerminalInfoInteger(TERMINAL_SCREEN_WIDTH)=1440 / ok
TerminalInfoInteger(TERMINAL_SCREEN_HEIGHT)=900 / ok
TerminalInfoInteger(TERMINAL_LEFT)=126 / ok
TerminalInfoInteger(TERMINAL_TOP)=41 / ok
TerminalInfoInteger(TERMINAL_RIGHT)=1334 / ok
TerminalInfoInteger(TERMINAL_BOTTOM)=836 / ok

除了屏幕和终端窗口的一般尺寸外,MQL 程序还经常需要分析图表(终端内的子窗口)的当前尺寸。为此,有一组特殊的函数(特别是 ChartGetInteger),我们将在“图表”部分讨论这些函数。

终端和程序的字符串属性

可以使用 MQLInfoStringTerminalInfoString 函数来了解终端和 MQL 程序的一些字符串属性。

标识符描述
MQL_PROGRAM_NAME正在运行的 MQL 程序的名称
MQL_PROGRAM_PATH正在运行的 MQL 程序的路径
TERMINAL_LANGUAGE终端语言
TERMINAL_COMPANY公司(经纪商)的名称
TERMINAL_NAME终端名称
TERMINAL_PATH启动终端的文件夹
TERMINAL_DATA_PATH存储终端数据的文件夹
TERMINAL_COMMONDATA_PATH安装在计算机上的所有客户端终端的共享文件夹

正在运行的程序名称(MQL_PROGRAM_NAME)通常与主模块(.mq5 文件)的名称一致,但也可能不同。特别是,如果你的源代码编译成一个库,并且该库被导入到另一个 MQL 程序(专家顾问、指标、脚本或服务)中,那么 MQL_PROGRAM_NAME 属性将返回主程序的名称,而不是库的名称(库不是一个可以独立运行的程序)。

我们在“处理文件”部分讨论了工作终端文件夹的布局。使用列出的这些属性,你可以了解终端安装在何处(TERMINAL_PATH),以及找到当前终端实例的工作数据(TERMINAL_DATA_PATH)和所有实例的工作数据(TERMINAL_COMMONDATA_PATH)。

一个简单的脚本 EnvDescription.mq5 将记录所有这些属性。

c
void OnStart()
{
   PRTF(MQLInfoString(MQL_PROGRAM_NAME));
   PRTF(MQLInfoString(MQL_PROGRAM_PATH));
   PRTF(TerminalInfoString(TERMINAL_LANGUAGE));
   PRTF(TerminalInfoString(TERMINAL_COMPANY));
   PRTF(TerminalInfoString(TERMINAL_NAME));
   PRTF(TerminalInfoString(TERMINAL_PATH));
   PRTF(TerminalInfoString(TERMINAL_DATA_PATH));
   PRTF(TerminalInfoString(TERMINAL_COMMONDATA_PATH));
}

以下是一个示例结果:

MQLInfoString(MQL_PROGRAM_NAME)=EnvDescription / ok
MQLInfoString(MQL_PROGRAM_PATH)= »
» C:\Program Files\MT5East\MQL5\Scripts\MQL5Book\p4\EnvDescription.ex5 / ok
TerminalInfoString(TERMINAL_LANGUAGE)=Russian / ok
TerminalInfoString(TERMINAL_COMPANY)=MetaQuotes Software Corp. / ok
TerminalInfoString(TERMINAL_NAME)=MetaTrader 5 / ok
TerminalInfoString(TERMINAL_PATH)=C:\Program Files\MT5East / ok
TerminalInfoString(TERMINAL_DATA_PATH)=C:\Program Files\MT5East / ok
TerminalInfoString(TERMINAL_COMMONDATA_PATH)= »
» C:\Users\User\AppData\Roaming\MetaQuotes\Terminal\Common / ok

终端的界面语言不仅可以作为 TERMINAL_LANGUAGE 属性中的字符串来查找,还可以作为代码页编号来查找(请参阅下一部分中的 TERMINAL_CODEPAGE 属性)。

自定义属性:K线数量限制和界面语言

在终端的属性中,有两个特殊属性用户可以通过交互方式进行更改。其中包括每个图表上默认显示的最大 K 线数量(它对应于“选项”对话框中“窗口中最大 K 线数”字段的值),以及界面语言(通过“查看” -> “语言”命令进行选择)。

标识符描述
TERMINAL_MAXBARS图表上的最大 K 线数量
TERMINAL_CODEPAGE客户端终端中所选语言的代码页编号

请注意,TERMINAL_MAXBARS 的值设置了显示 K 线的上限,但实际上,如果在任何时间框架下可用的报价历史深度不足,K 线的实际数量可能会更少。另一方面,历史数据的长度也可能超过指定的 TERMINAL_MAXBARS 限制。然后,你可以使用时间序列属性组中的函数 SeriesInfoInteger 并传入 SERIES_BARS_COUNT 属性来查找潜在可用的 K 线数量。请注意,TERMINAL_MAXBARS 的值会直接影响随机存取存储器(RAM)的消耗。

将程序绑定到运行时属性

作为处理前面几节所描述属性的一个示例,让我们考虑一个常见的任务,即将一个MQL程序绑定到硬件环境上,以防止其被复制。当程序通过MQL5市场分发时,这种绑定由该服务本身提供。然而,如果程序是定制开发的,它可以绑定到账号号码、客户名称或者终端(计算机)的可用属性上。第一种方式并不总是方便,因为许多交易者有多个真实账户(可能来自不同的经纪商),更不用说有效期有限的模拟账户了。第二种方式可能是虚构的或者过于普通。因此,我们将实现一个将程序绑定到选定的一组环境属性的原型算法。更严谨的安全方案可能会使用一个DLL,并直接从Windows读取设备硬件标签,但不是每个客户都会同意运行潜在不安全的库。

我们的保护方案在脚本 EnvSignature.mq5 中呈现。该脚本从给定的环境属性计算哈希值,并基于这些属性创建一个唯一的签名(印记)。

哈希是对任意信息的一种特殊处理,其结果是创建一个具有以下特征的新数据块(这些特征由所使用的算法保证):

  • 两个原始数据集的哈希值匹配,几乎100%意味着这些数据是相同的(随机匹配的概率可以忽略不计)。
  • 如果原始数据发生变化,它们的哈希值也会改变。
  • 除非对所有可能的初始值进行完全枚举(如果初始值的大小增加且没有关于其结构的信息,在可预见的未来这个问题是无法解决的),否则不可能从哈希值数学地恢复原始数据(原始数据将保持秘密)。
  • 哈希值的大小是固定的(不取决于初始数据的数量)。

假设其中一个环境属性由字符串描述:“TERMINAL_LANGUAGE=German”。可以使用如下简单语句(简化版)获取它:

c
string language = EnumToString(TERMINAL_LANGUAGE) +
            "=" + TerminalInfoString(TERMINAL_LANGUAGE);

实际的语言将与设置匹配。有了一个假设的哈希函数,我们就可以计算签名。

c
string signature = Hash(language);

当有更多属性时,我们只需对所有属性重复这个过程,或者从组合后的字符串中请求一个哈希值(目前这是伪代码,不是实际程序的一部分)。

c
string properties[];
// 按你希望的方式填充属性行
// ...
string signature;
for(int i = 0; i < ArraySize(properties); ++i)
{
   signature += properties[i];
}
return Hash(signature);

用户可以将接收到的签名报告给程序开发者,开发者会以一种特殊的方式对其进行 “签名”,即收到一个只适用于这个签名的验证字符串。这个签名也是基于哈希计算的,并且需要知道一些只有开发者知道并硬编码到程序中的秘密(密码短语)(用于验证阶段)。

开发者会将验证字符串传递给用户,然后用户可以通过在参数中指定这个字符串来运行程序。

当没有验证字符串而启动程序时,程序应该为当前环境生成一个新的签名,将其打印到日志中,然后退出(这些信息应该传递给开发者)。如果验证字符串无效,程序应该显示一条错误消息并退出。

可以为开发者本身提供几种启动模式:有签名但没有验证字符串(用于生成验证字符串),或者有签名和验证字符串(此时程序将对签名重新签名,并将其与指定的验证字符串进行比较,只是为了检查)。

让我们评估一下这种保护的选择性如何。毕竟,这里的绑定并不是绑定到任何唯一标识符上。

下表提供了关于两个特征的统计数据:屏幕大小和内存。显然,这些值会随着时间变化,但大致的分布情况将保持不变:一些典型的值会是最常见的,而一些 “新的” 先进的和 “旧的” 正在淘汰的值将构成逐渐减少的 “尾部”。

屏幕比例
1920x108021%
1536x8647%
1440x9005%
1366x76810%
800x6004%
内存4Gb8Gb16Gb32Gb64Gb
比例20%20%15%10%5%
4.204.203.152.101.05
1.401.401.050.700.35
1.001.000.750.500.25
2.02.01.51.00.5
0.80.80.60.40.2

请注意那些值最大的单元格,因为它们意味着相同的签名(除非我们在其中引入一个随机元素,这将在下面讨论)。在这种情况下,左上角的两种特征组合最有可能,每种占4.2%。但这些只是两个特征。如果在评估的环境中添加界面语言、时区、核心数量和工作数据路径(最好是共享的,因为它包含Windows用户名),那么潜在匹配的数量将明显减少。

对于哈希计算,我们使用内置的 CryptEncode 函数(它将在密码学部分进行描述),该函数支持SHA256哈希方法。正如其名称所示,它生成一个256位长的哈希值,即32字节。如果我们需要向用户展示它,那么我们会将其转换为十六进制表示的文本,得到一个64个字符长的字符串。

为了使签名更短,我们将使用Base64编码对其进行转换(CryptEncode 函数及其对应函数 CryptDecode 也支持Base64编码),这将得到一个44个字符长的字符串。与单向哈希操作不同,Base64编码是可逆的,即可以从它恢复原始数据。

主要操作由 EnvSignature 类实现。它定义了一个数据字符串,该字符串应该累积描述环境的某些片段。公共接口由几个重载版本的 append 函数组成,用于添加带有环境属性的字符串。本质上,它们使用由虚拟 pepper 方法返回的一些抽象元素作为链接,将请求的属性名称及其值连接起来。派生类将把它定义为一个特定的字符串(但它可以为空)。

c
class EnvSignature
{
private:
   string data;
protected:
   virtual string pepper() = 0;
public:
   bool append(const ENUM_TERMINAL_INFO_STRING e)
   {
      return append(EnumToString(e) + pepper() + TerminalInfoString(e));
   }
   bool append(const ENUM_MQL_INFO_STRING e)
   {
      return append(EnumToString(e) + pepper() + MQLInfoString(e));
   }
   bool append(const ENUM_TERMINAL_INFO_INTEGER e)
   {
      return append(EnumToString(e) + pepper()
        + StringFormat("%d", TerminalInfoInteger(e)));
   }
   bool append(const ENUM_MQL_INFO_INTEGER e)
   {
      return append(EnumToString(e) + pepper()
        + StringFormat("%d", MQLInfoInteger(e)));
   }

为了向对象添加任意字符串,有一个通用方法 append,上述方法中会调用它。

c
   bool append(const string s)
   {
      data += s;
      return true;
   }

开发者可以选择向哈希数据中添加所谓的 “盐”。这是一个包含随机生成数据的数组,它进一步增加了哈希反转的难度。即使环境保持不变,每次生成的签名也会与之前的不同。此功能的实现以及其他更具体的保护方面(如使用对称加密和动态计算秘密)留作独立研究。

由于环境由已知的属性组成(其列表受MQL5 API常量限制),并且并非所有属性都足够唯一,正如我们所计算的,如果不使用 “盐”,我们的保护机制可能会为不同用户生成相同的签名。如果发生许可证泄露,签名匹配将无法识别泄露源。

因此,你可以通过为每个客户更改哈希前呈现属性的方法来提高保护的有效性。当然,该方法本身不应被披露。在考虑的示例中,这意味着更改 pepper 方法的内容并重新编译产品。这可能成本较高,但它允许避免使用随机 “盐”。

填充好属性字符串后,我们可以生成一个签名。这是通过 emit 方法完成的。

c
   string emit() const
   {
      uchar pack[];
      if(StringToCharArray(data + secret(), pack, 0, 
         StringLen(data) + StringLen(secret()), CP_UTF8) <= 0) return NULL;
   
      uchar key[], result[];
      if(CryptEncode(CRYPT_HASH_SHA256, pack, key, result) <= 0) return NULL;
      Print("Hash bytes:");
      ArrayPrint(result);
   
      uchar text[];
      CryptEncode(CRYPT_BASE64, result, key, text);
      return CharArrayToString(text);
   }

该方法将某个秘密(仅开发者知道且位于程序内部的字节序列)添加到数据中,并计算合并字符串的哈希值。秘密是从虚拟 secret 方法中获取的,派生类也将定义该方法。

得到的带有哈希值的字节数组使用Base64编码成字符串。

现在是最重要的类函数:check。正是这个函数实现了开发者的签名并检查用户的签名。

c
   bool check(const string sig, string &validation)
   {
      uchar bytes[];
      const int n = StringToCharArray(sig + secret(), bytes, 0, 
         StringLen(sig) + StringLen(secret()), CP_UTF8);
      if(n <= 0) return false;
      
      uchar key[], result1[], result2[];
      if(CryptEncode(CRYPT_HASH_SHA256, bytes, key, result1) <= 0) return false;
      
      /*
        WARNING
        以下代码仅应存在于开发者工具中。
        提供给用户的程序在编译时必须不包含此if语句。
      */
      #ifdef I_AM_DEVELOPER
      if(StringLen(validation) == 0)
      {
         if(CryptEncode(CRYPT_BASE64, result1, key, result2) <= 0) return false;
         validation = CharArrayToString(result2);
         return true;
      }
      #endif
      uchar values[];
      // 需要精确的长度以避免附加终止符'0'
      if(StringToCharArray(validation, values, 0, 
         StringLen(validation)) <= 0) return false;
      if(CryptDecode(CRYPT_BASE64, values, key, result2) <= 0) return false;
      
      return ArrayCompare(result1, result2) == 0;
   }

在正常操作(对于用户)期间,该方法从接收到的签名(补充了秘密)计算哈希值,并将其与验证字符串中的值进行比较(必须首先将其从Base64解码为哈希的原始二进制表示)。如果两个哈希值匹配,验证成功:验证字符串与属性集匹配。显然,空的验证字符串(或随机输入的字符串)将无法通过测试。

在开发者的机器上,签名工具的源代码中必须定义 I_AM_DEVELOPER 宏,这会导致对空验证字符串的处理方式不同。在这种情况下,得到的哈希值会进行Base64编码,并且这个字符串会通过 validation 参数传出。这样,该工具将能够向开发者显示给定签名的现成验证字符串。

要创建一个对象,需要一个特定的派生类,该类定义带有秘密和 pepper 的字符串。

c
// WARNING: 将宏更改为你自己的一组随机字节
#define PROGRAM_SPECIFIC_SECRET "<PROGRAM-SPECIFIC-SECRET>"
// WARNING: 选择你要用于连接名称'='值对的字符
#define INSTANCE_SPECIFIC_PEPPER "=" // 为演示选择了明显的单个符号
// WARNING: 以下宏在实际产品中需要禁用,
//          它应仅存在于签名工具中
#define I_AM_DEVELOPER
#ifdef I_AM_DEVELOPER
#define INPUT input
#else
#define INPUT const
#endif
 
INPUT string Signature = "";
INPUT string Secret = PROGRAM_SPECIFIC_SECRET;
INPUT string Pepper = INSTANCE_SPECIFIC_PEPPER;
 
class MyEnvSignature : public EnvSignature
{
protected:
   virtual string secret() override
   {
      return Secret;
   }
   virtual string pepper() override
   {
      return Pepper;
   }
};

让我们快速选择一些属性来填充签名。

c
void FillEnvironment(EnvSignature &env)
{
   // 顺序不重要,你可以混合
   env.append(TERMINAL_LANGUAGE);
   env.append(TERMINAL_COMMONDATA_PATH);
   env.append(TERMINAL_CPU_CORES);
   env.append(TERMINAL_MEMORY_PHYSICAL);
   env.append(TERMINAL_SCREEN_DPI);
   env.append(TERMINAL_SCREEN_WIDTH);
   env.append(TERMINAL_SCREEN_HEIGHT);
   env.append(TERMINAL_VPS);
   env.append(MQL_PROGRAM_TYPE);
}

现在一切准备就绪,可以在 OnStart 函数中测试我们的保护方案了。但首先,让我们看一下输入变量。由于相同的程序将编译成两个版本,一个给最终用户,一个给开发者,所以有两组输入变量:一组用于用户输入注册数据,一组用于根据开发者的签名生成这些数据。上面已经使用 INPUT 宏描述了供开发者使用的输入变量。用户只能使用验证字符串。

c
input string Validation = "";

当字符串为空时,程序将收集环境数据,生成一个新的签名,并将其打印到日志中。由于对有用代码的访问尚未得到确认,脚本的工作到此结束。

c
void OnStart()
{
   MyEnvSignature env;
    string signature;
   if(StringLen(Signature) > 0)
   {
     // ... 这里将是作者要签名的代码
   }
   else
   {
      FillEnvironment(env);
      signature = env.emit();
   }
   
   if(StringLen(Validation) == 0)
   {
      Print("Validation string from developer is required to run this script");
      Print("Environment Signature is generated for current state...");
      Print("Signature:", signature);
      return;
   }
   else
   {
     // ... 在此处检查验证字符串
   }
   Print("The script is validated and running normally");
   // ... 实际工作代码在此处
}

如果变量 Validation 已填充,我们检查它是否与签名相符,如果不相符则终止工作。

c
   if(StringLen(Validation) == 0)
   {
      ...
   }
   else
   {
      validation = Validation; // 需要一个非const参数
      const bool accessGranted = env.check(Signature, validation);
      if(!accessGranted)
      {
         Print("Wrong validation string, terminating");
         return;
      }
      // 成功
   }
   Print("The script is validated and running normally");
   // ... 实际工作代码在此处
}

如果没有差异,算法将继续执行程序的工作代码。

在开发者这边(在使用 I_AM_DEVELOPER 宏构建的程序版本中),可以引入一个签名。我们使用签名恢复 MyEnvSignature 对象的状态,并计算验证字符串。

c
void OnStart()
{
   ...
   if(StringLen(Signature) > 0)
   {
      #ifdef I_AM_DEVELOPER
      if(StringLen(Validation) == 0)
      {
         string validation;
         if(env.check(Signature, validation))
           Print("Validation:", validation);
         return;
      }
      signature = Signature; 
      #endif
   }
   ...

开发者不仅可以指定签名,还可以验证它:在这种情况下,代码执行将以用户模式继续(用于调试目的)。

如果你愿意,可以模拟环境的变化,例如,如下所示:

c
      FillEnvironment(env);
      // 人为地使环境发生变化(添加一个时区)
      // env.append("Dummy" + (string)(TimeGMTOffset() - TimeDaylightSavings()));
      const string update = env.emit();
      if(update != signature)
      {
         Print("Signature and environment mismatch");
         return;
      }

让我们看一些测试日志。

当首次运行 EnvSignature.mq5 脚本时,“用户” 将看到类似以下的日志(由于环境差异,值会有所不同):

Hash bytes:
  4 249 194 161 242  28  43  60 180 195  54 254  97 223 144 247 216 103 238 245 244 22
  4  7  68 101 253 248 134  27 102 202 153
运行此脚本需要来自开发者的验证字符串。
已为当前状态生成环境签名...
签名:BPnCofIcKzy0wzb+Yd+Q99hn7vX04AdEZf34hhtmypk=

它将生成的签名发送给 “开发者”(在测试期间没有实际的用户,所以 “用户” 和 “开发者” 的所有角色都是带引号的),“开发者” 将其输入到签名工具(使用 I_AM_DEVELOPER 宏编译)的 Signature 参数中。结果,程序将生成一个验证字符串:

验证:YBpYpQ0tLIpUhBslIw+AsPhtPG48b0qut9igJ+Tk1fQ=

“开发者” 将其发回给 “用户”,“用户” 通过将其输入到 Validation 参数中,将激活该脚本:

哈希字节:
  4 249 194 161 242  28  43  60 180 195  54 254  97 223 144 247 216 103 238 245 244 224   7  68 101 253 248 134  27 102 202 153
脚本已通过验证并正常运行

为了展示保护的有效性,让我们将该脚本复制为一个服务:为此,将文件复制到 MQL5/Services/MQL5Book/p4/ 文件夹中,并将源代码中的以下行:

#property script_show_inputs

替换为以下行:

#property service

让我们编译该服务,创建并运行它的实例,并在输入参数中指定先前收到的验证字符串。结果,该服务将中止(在到达包含所需代码的语句之前),并显示以下消息:

哈希字节:
147 131  69  39  29 254  83 141  90 102 216 180 229 111   2 246 245  19  35 205 223 145 194 245  67 129  32 108 178 187 232 113
验证字符串错误,终止运行

关键在于,在我们使用的环境属性中,包含了字符串 MQL_PROGRAM_TYPE。因此,为一种类型的程序颁发的许可证,对于另一种类型的程序将不起作用,即使它们在同一用户的计算机上运行。

检查键盘状态

TerminalInfoInteger 函数可用于了解控制键(也称为虚拟键)的状态。这些键特别包括 Ctrl、Alt、Shift、Enter、Ins、Del、Esc、方向键等等。它们被称为虚拟键,是因为键盘通常提供多种方式来产生相同的控制操作。例如,Ctrl、Shift 和 Alt 在空格键的左右两侧都有重复按键,而光标既可以通过专用按键移动,也可以在按下 Fn 时通过主按键移动。因此,此函数无法在物理层面区分控制方式(例如,区分左右 Shift 键)。

API 为以下按键定义了常量:

标识符描述
TERMINAL_KEYSTATE_LEFT左箭头键
TERMINAL_KEYSTATE_UP上箭头键
TERMINAL_KEYSTATE_RIGHT右箭头键
TERMINAL_KEYSTATE_DOWN下箭头键
TERMINAL_KEYSTATE_SHIFTShift 键
TERMINAL_KEYSTATE_CONTROLCtrl 键
TERMINAL_KEYSTATE_MENUWindows 键
TERMINAL_KEYSTATE_CAPSLOCKCapsLock 键
TERMINAL_KEYSTATE_NUMLOCKNumLock 键
TERMINAL_KEYSTATE_SCRLOCKScrollLock 键
TERMINAL_KEYSTATE_ENTEREnter 键
TERMINAL_KEYSTATE_INSERTInsert 键
TERMINAL_KEYSTATE_DELETEDelete 键
TERMINAL_KEYSTATE_HOMEHome 键
TERMINAL_KEYSTATE_ENDEnd 键
TERMINAL_KEYSTATE_TABTab 键
TERMINAL_KEYSTATE_PAGEUPPageUp 键
TERMINAL_KEYSTATE_PAGEDOWNPageDown 键
TERMINAL_KEYSTATE_ESCAPEEscape 键

该函数返回一个两字节的整数值,通过一对位来报告所请求按键的当前状态。

最低有效位记录自上次函数调用以来的按键操作。例如,如果 TerminalInfoInteger(TERMINAL_KEYSTATE_ESCAPE) 在某一时刻返回 0,然后用户按下了 Escape 键,那么在下一次调用时,TerminalInfoInteger(TERMINAL_KEYSTATE_ESCAPE) 将返回 1。如果再次按下该键,值将回到 0。

对于负责切换输入模式的按键,如 CapsLock、NumLock 和 ScrollLock,位的位置表示相应模式是启用还是禁用。

如果当前按键被按下(未释放),则第二字节的最高有效位(0x8000)会被置位。

此功能不能用于跟踪字母数字键和功能键的按下情况。为此,需要在程序中实现 OnChartEvent 处理程序并拦截代码为 CHARTEVENT_KEYDOWN 的消息。请注意,事件是在图表上生成的,仅适用于专家顾问和指标。其他类型的程序(脚本和服务)不支持事件编程模型。

EnvKeys.mq5 脚本包含一个遍历所有 TERMINAL_KEYSTATE 常量的循环。

c
void OnStart()
{
   for(ENUM_TERMINAL_INFO_INTEGER i = TERMINAL_KEYSTATE_TAB;
      i <= TERMINAL_KEYSTATE_SCRLOCK; ++i)
   {
      const string e = EnumToString(i);
      // 跳过不是枚举元素的值
      if(StringFind(e, "ENUM_TERMINAL_INFO_INTEGER") == 0) continue;
      PrintFormat("%s=%4X", e, (ushort)TerminalInfoInteger(i));
   }
}

你可以通过按键操作和启用/禁用键盘模式进行实验,以查看日志中的值如何变化。

例如,如果默认情况下大写锁定被禁用,我们将看到以下日志:

TERMINAL_KEYSTATE_SCRLOCK= 0

如果我们按下 ScrollLock 键,并且在不释放的情况下再次运行脚本,我们将得到以下日志:

TERMINAL_KEYSTATE_CAPSLOCK=8001

也就是说,模式已开启且按键被按下。让我们释放该按键,下次脚本运行时将返回:

TERMINAL_KEYSTATE_SCRLOCK= 1

模式仍然开启,但按键已释放。

TerminalInfoInteger 不适用于检查通过 iCustomIndicatorCreate 调用创建的依赖指标中按键(TERMINAL_KEYSTATE_XYZ)的状态。在这些指标中,即使使用 ChartIndicatorAdd 将指标添加到图表中,该函数也总是返回 0。

此外,当 MQL 程序的图表不处于活动状态(用户已切换到另一个图表)时,该函数也不起作用。MQL5 没有提供永久控制键盘的方法。

检查 MQL 程序状态和终止原因

在本书的不同示例中,我们已经接触过 IsStopped 函数。当 MQL 程序进行长时间计算时,需要时不时调用该函数。这能让你检查用户是否发起了程序关闭操作(即是否尝试从图表中移除该程序)。

c
bool IsStopped() ≡ bool _StopFlag

如果程序被用户中断(例如,在上下文菜单中通过“专家列表”命令打开的对话框里按下“删除”按钮),该函数返回 true

程序会有 3 秒钟的时间来妥善暂停计算,必要时保存中间结果,然后完成其工作。若未在这个时间内完成,程序将被强制从图表中移除。

你也可以检查内置的 _StopFlag 变量的值,来替代使用 IsStopped 函数。

测试脚本 EnvStop.mq5 在循环中模拟长时间计算:搜索质数。while 循环的退出条件是使用 IsStopped 函数编写的。所以,当用户删除脚本时,循环会正常中断,日志会显示找到的质数统计信息(脚本也可以将这些数字保存到文件中)。

c
bool isPrime(int n)
{
   if(n < 1) return false;
   if(n <= 3) return true;
   if(n % 2 == 0) return false;
   const int p = (int)sqrt(n);
   int i = 3;
   for( ; i <= p; i += 2)
   {
      if(n % i == 0) return false;
   }
   
   return true;
}
   
void OnStart()
{
   int count = 0;
   int candidate = 1;
   
   while(!IsStopped()) // 尝试将其替换为 while(true)
   {
      // 模拟长时间计算
      if(isPrime(candidate))
      {
         Comment("Count:", ++count, ", Prime:", candidate);
      }
      ++candidate;
      Sleep(10);
   }
   Comment("");
   Print("Total found:", count);
}

如果把循环条件替换为 true(无限循环),脚本将不再响应用户的停止请求,会被强制从图表中卸载。结果,日志里会出现“异常终止”错误,窗口左上角的注释也不会被清除。这样,在这个示例中象征着保存数据和释放占用资源的所有指令(比如从窗口中删除自己创建的图形对象)都会被忽略。

在向程序发送停止请求后(并且 _StopFlag 的值为 true),可以使用 UninitializeReason 函数找出终止原因。

可惜的是,这个功能仅适用于专家顾问和指标。

c
int UninitializeReason() ≡ int _UninitReason

该函数返回一个预定义的代码,用于描述程序初始化失败的原因。

常量描述
REASON_PROGRAM0仅在专家顾问和脚本中可用的 ExpertRemove 函数被调用
REASON_REMOVE1程序从图表中移除
REASON_RECOMPILE2程序重新编译
REASON_CHARTCHANGE3图表的交易品种或周期发生改变
REASON_CHARTCLOSE4图表关闭
REASON_PARAMETERS5程序的输入参数发生改变
REASON_ACCOUNT6连接了另一个账户或重新连接到交易服务器
REASON_TEMPLATE7应用了另一个图表模板
REASON_INITFAILED8OnInit 事件处理函数返回了错误标志
REASON_CLOSE9终端关闭

你也可以访问内置的全局变量 _UninitReason,来替代使用这个函数。

初始化失败的原因代码也会作为参数传递给 OnDeinit 事件处理函数。

后续在学习“程序启动和停止特性”时,我们会看到一个指标(Indicators/MQL5Book/p5/LifeCycle.mq5)和一个专家顾问(Experts/MQL5Book/p5/LifeCycle.mq5),它们会记录初始化失败的原因,并且能让你探究程序根据用户操作所表现出的行为。

以编程方式关闭终端并设置返回码

MQL5 API 包含了一些函数,这些函数不仅可用于读取,还可用于修改程序环境。其中最极端的函数之一是 TerminalClose。使用这个函数,MQL 程序可以关闭终端(无需用户确认!)。

c
bool TerminalClose(int retcode)

该函数有一个参数 retcode,它是 terminal64.exe 进程返回给 Windows 操作系统的代码。这样的代码可以在批处理文件(*.bat 和 .cmd)中进行分析,也可以在 shell 脚本(支持 VBScript 和 JScript 的 Windows 脚本宿主 (WSH),或者扩展名为.ps 文件的 Windows PowerShell (WPS))以及其他自动化工具(例如,内置的 Windows 计划任务、Windows 下支持的 Linux 子系统中的 *.sh 文件等)中进行分析。

该函数不会立即停止终端,而是向终端发送一个终止命令。

如果调用结果为 true,这意味着该命令已成功“被接受处理”,并且终端将尽快正确关闭(生成通知并停止其他正在运行的 MQL 程序)。当然,在调用代码中,也必须为立即终止工作做好所有准备(特别是,所有先前打开的文件都应该关闭),并且在函数调用后,控制权应返回给终端。

另一个与进程返回码相关的函数是 SetReturnError。它允许在不立即发送关闭命令的情况下预先分配此代码。

c
void SetReturnError(int retcode)

该函数设置终端进程在关闭后将返回给 Windows 系统的代码。

请注意,不需要通过 TerminalClose 函数强制关闭终端。用户正常关闭终端时也会使用指定的代码。此外,如果终端由于意外的严重错误而关闭,此代码也会进入系统。

如果 SetReturnError 函数被多次调用,并且/或者从不同的 MQL 程序中调用,终端将返回最后设置的代码。

让我们使用 EnvClose.mq5 脚本测试这些函数。

c
#property script_show_inputs
   
input int ReturnCode = 0;
input bool CloseTerminalNow = false;
   
void OnStart()
{
   if(CloseTerminalNow)
   {
      TerminalClose(ReturnCode);
   }
   else
   {
      SetReturnError(ReturnCode);
   }
}

为了实际测试它,我们还需要 envrun.bat 文件(位于 MQL5/Files/MQL5Book/ 文件夹中)。

terminal64.exe
@echo Exit code: %ERRORLEVEL%

实际上,它只是启动终端,并在终端完成运行后将生成的代码显示到控制台。该文件应放置在终端文件夹中(或者应在 PATH 系统变量中注册系统中安装的多个 MetaTrader 5 实例中的当前实例)。

例如,如果我们使用批处理文件启动终端,并执行 EnvClose.mq5 脚本,例如,使用参数 ReturnCode=100CloseTerminalNow=true,我们将在控制台中看到类似以下内容:

Microsoft Windows [Version 10.0.19570.1000]
(c) 2020 Microsoft Corporation. All rights reserved.
C:\Program Files\MT5East>envrun
C:\Program Files\MT5East>terminal64.exe
Exit code: 100
C:\Program Files\MT5East>

提醒一下,MetaTrader 5 从命令行启动时支持各种选项(详细信息请参阅文档中的“运行交易平台”部分)。因此,例如,可以组织对各种专家顾问或设置的批量测试,以及在数千个受监控的账户之间进行顺序切换,而在一台计算机上持续并行运行这么多实例来实现这些操作是不太现实的。

处理运行时错误

任何编写得足够正确、能够无错误编译的程序,仍然无法完全避免运行时错误。这些错误可能由于开发者的疏忽而产生,也可能由于软件环境中出现的意外情况(例如网络连接中断、内存耗尽等)而引发。但同样有可能的是,错误是由于程序的不正确应用而导致的。在所有这些情况下,程序必须能够分析问题的本质并进行适当的处理。

每个 MQL5 语句都是运行时错误的潜在来源。如果发生这样的错误,终端会将一个描述性代码保存到特殊的 _LastError 变量中。务必在每条语句执行后立即分析该代码,因为后续语句中的潜在错误可能会覆盖这个值。

请注意,存在一些严重错误,一旦发生这些错误,程序执行将立即中止:

  • 除零错误
  • 索引越界
  • 对象指针错误

有关错误代码及其含义的完整列表,请参阅文档。

在“打开和关闭文件”部分,我们已经在编写一个有用的 PRTF 宏时解决了错误诊断的问题。在那里,特别是我们看到了一个辅助头文件 MQL5/Include/MQL5Book/MqlError.mqh,其中的 MQL_ERROR 枚举允许使用 EnumToString 轻松地将数字错误代码转换为名称。

c
enum MQL_ERROR
{
   SUCCESS = 0, 
   INTERNAL_ERROR = 4001, 
   WRONG_INTERNAL_PARAMETER = 4002, 
   INVALID_PARAMETER = 4003, 
   NOT_ENOUGH_MEMORY = 4004, 
   ...
   // 程序员定义的错误区域开始(请参阅下一部分)
   USER_ERROR_FIRST = 65536, 
};
#define E2S(X) EnumToString((MQL_ERROR)(X))

在这里,作为 E2S 宏的 X 参数,我们应该使用 _LastError 变量或其等效的 GetLastError 函数。

c
int GetLastError() ≡ int _LastError

该函数返回 MQL 程序语句中发生的最后一个错误的代码。最初,在没有错误时,该值为 0。读取 _LastError 和调用 GetLastError 函数之间的区别纯粹是语法上的(根据偏好的风格选择合适的选项)。

应该记住,语句的正常无错误执行不会重置错误代码。调用 GetLastError 函数也不会重置它。

因此,如果存在一系列操作,其中只有一个操作会设置错误标志,那么该函数将为后续(成功的)操作返回该错误标志。例如:

c
// _LastError 默认值为 0
action1; // 正常,_LastError 不变
action2; // 错误,_LastError = X
action3; // 正常,_LastError 不变,即仍等于 X
action4; // 另一个错误,_LastError = Y
action5; // 正常,_LastError 不变,即仍等于 Y
action6; // 正常,_LastError 不变,即仍等于 Y

这种行为会使定位问题区域变得困难。为了避免这种情况,有一个单独的 ResetLastError 函数,它将 _LastError 变量重置为 0。

c
void ResetLastError()

该函数将内置的 _LastError 变量的值设置为零。

建议在任何可能导致错误的操作之前调用该函数,并在之后使用 GetLastError 函数分析错误。

已经提到的 PRTF 宏(PRTF.mqh 文件)是使用这两个函数的一个很好的例子。其代码如下:

c
#include <MQL5Book/MqlError.mqh>
   
#define PRTF(A) ResultPrint(#A, (A))
   
template<typename T>
T ResultPrint(const string s, const T retval = NULL)
{
   const int snapshot = _LastError; // 记录输入时的 _LastError
   const string err = E2S(snapshot) + "(" + (string)snapshot + ")";
   Print(s, "=", retval, " / ", (snapshot == 0? "ok" : err));
   ResetLastError(); // 为下一次调用清除错误标志
   return retval;
}

该宏以及包含在其中的 ResultPrint 函数的目的是记录传递的值(即当前错误代码),并立即清除错误代码。因此,在一系列语句上连续应用 PRTF 始终确保打印到日志中的错误(或成功指示)与获得 retval 参数值的最后一条语句相对应。

我们需要将 _LastError 保存到中间局部变量 snapshot 中,因为如果任何操作失败,_LastError 几乎可以在表达式求值的任何位置更改其值。在这个特定示例中,E2S 宏使用了 EnumToString 函数,如果将不在枚举中的值作为参数传递,该函数可能会引发自己的错误代码。然后,在同一表达式的后续部分中,当形成字符串时,我们将看到的不是初始错误,而是引发的错误。

在任何语句中,可能有几个位置会使 _LastError 突然更改。在这方面,最好在期望的操作之后立即记录错误代码。

用户定义的错误

开发者可以将内置的 _LastError 变量用于他们自己的应用目的。SetUserError 函数为此提供了便利。

c
void SetUserError(ushort user_error)

该函数将内置的 _LastError 变量设置为 ERR_USER_ERROR_FIRST + user_error 的值,其中 ERR_USER_ERROR_FIRST 为 65536。所有低于此值的代码都预留给系统错误。

使用这种机制,你可以部分绕过 MQL5 中与该语言不支持异常这一事实相关的限制。

很多时候,函数会使用返回值作为错误的标志。然而,有些算法中函数必须返回应用类型的值。我们以 double 类型为例。如果函数的定义范围是负无穷到正无穷,我们选择用于指示错误的任何值(例如 0)都将无法与计算的实际结果区分开来。对于 double 类型,当然可以选择返回一个专门构造的 NaN 值(非数字,见“检查实数的正常性”部分)。但是如果函数返回一个结构体或类对象呢?一种可能的解决方案是通过引用或指针参数返回结果,但这种形式使得无法将函数用作表达式的操作数。

在类的上下文中,让我们考虑称为“构造函数”的特殊函数。它们返回对象的一个新实例。然而,有时由于某些情况,无法构造整个对象,然后调用代码似乎得到了该对象,但不应该使用它。如果类能够提供一个额外的方法来检查对象是否可用,那就太好了。但作为一种统一的替代方法(例如,适用于所有类),我们可以使用 SetUserError 函数。

在“运算符重载”部分,我们遇到了 Matrix 类。我们将为它补充计算行列式和逆矩阵的方法,然后用它来演示用户定义的错误(见 Matrix.mqh 文件)。为矩阵定义了重载运算符,允许在单个表达式中将它们组合成运算符链,因此在其中实现对潜在错误的检查会很不方便。

我们的 Matrix 类是对最近添加的 MQL5 内置对象类型 matrix 的自定义替代实现。

我们从在 Matrix 主类构造函数中验证输入参数开始。如果有人试图创建一个零大小的矩阵,我们设置一个自定义错误 ERR_USER_MATRIX_EMPTY(提供的几个错误之一)。

c
enum ENUM_ERR_USER_MATRIX
{
   ERR_USER_MATRIX_OK = 0, 
   ERR_USER_MATRIX_EMPTY =  1, 
   ERR_USER_MATRIX_SINGULAR = 2, 
   ERR_USER_MATRIX_NOT_SQUARE = 3
};
   
class Matrix
{
   ...
public:
   Matrix(const int r, const int c) : rows(r), columns(c)
   {
      if(rows <= 0 || columns <= 0)
      {
         SetUserError(ERR_USER_MATRIX_EMPTY);
      }
      else
      {
         ArrayResize(m, rows * columns);
         ArrayInitialize(m, 0);
      }
   }

这些新操作仅为方阵定义,所以我们创建一个具有适当大小约束的派生类。

c
class MatrixSquare : public Matrix
{
public:
   MatrixSquare(const int n, const int _ = -1) : Matrix(n, n)
   {
      if(_ != -1 && _ != n)
      {
         SetUserError(ERR_USER_MATRIX_NOT_SQUARE);
      }
   }
   ...

构造函数中的第二个参数应该不存在(假定它等于第一个参数),但我们需要它,因为 Matrix 类有一个模板转置方法,其中所有 T 类型都必须支持带有两个整数参数的构造函数。

c
class Matrix
{
   ...
   template<typename T>
   T transpose() const
   {
      T result(columns, rows);
      for(int i = 0; i < rows; ++i)
      {
         for(int j = 0; j < columns; ++j)
         {
            result[j][i] = this[i][(uint)j];
         }
      }
      return result;
   }

由于 MatrixSquare 构造函数中有两个参数,我们还必须检查它们是否相等。如果不相等,我们设置 ERR_USER_MATRIX_NOT_SQUARE 错误。

最后,在计算逆矩阵时,我们可能会发现矩阵是退化的(行列式为 0)。ERR_USER_MATRIX_SINGULAR 错误就是为这种情况预留的。

c
class MatrixSquare : public Matrix
{
public:
   ...
   MatrixSquare inverse() const
   {
      MatrixSquare result(rows);
      const double d = determinant();
      if(fabs(d) > DBL_EPSILON)
      {
         result = complement().transpose<MatrixSquare>() * (1 / d);
      }
      else
      {
         SetUserError(ERR_USER_MATRIX_SINGULAR);
      }
      return result;
   }
   
   MatrixSquare operator!() const
   {
      return inverse();
   }
   ...

为了直观地输出错误,在日志中添加了一个静态方法,返回 ENUM_ERR_USER_MATRIX 枚举,很容易将其传递给 EnumToString

c
   static ENUM_ERR_USER_MATRIX lastError()
   {
      if(_LastError >= ERR_USER_ERROR_FIRST)
      {
         return (ENUM_ERR_USER_MATRIX)(_LastError - ERR_USER_ERROR_FIRST);
      }
      return (ENUM_ERR_USER_MATRIX)_LastError;
   }

所有方法的完整代码可以在附件文件中找到。

我们将在测试脚本 EnvError.mq5 中检查应用错误代码。

首先,让我们确保类能够正常工作:对矩阵求逆,并检查原始矩阵和逆矩阵的乘积是否等于单位矩阵。

c
void OnStart()
{
   Print("Test matrix inversion (should pass)");
   double a[9] =
   {
      1,  2,  3, 
      4,  5,  6, 
      7,  8,  0, 
   };
      
   ResetLastError();
   Matrix SquaremA(a);   // 将数据分配给原始矩阵
   Print("Input");
   mA.print();
   MatrixSquare mAinv(3);
   mainv = !mA;          // 求逆并存储在另一个矩阵中
   Print("Result");
   mAinv.print();
   
   Print("Check inverted by multiplication");
   Matrix Squaretest(3); // 将第一个矩阵乘以第二个矩阵
   test = mA * mAinv;
   test.print();         // 得到单位矩阵
   Print(EnumToString(Matrix::lastError())); // 正常
   ...

这段代码片段生成以下日志条目:

Test matrix inversion (should pass)
Input
1.00000 2.00000 3.00000
4.00000 5.00000 6.00000
7.00000 8.00000 0.00000
Result
-1.77778  0.88889 -0.11111
 1.55556 -0.77778  0.22222
-0.11111  0.22222 -0.11111
Check inverted by multiplication
 1.00000 +0.00000  0.00000
 -0.00000   1.00000  +0.00000
0.00000 0.00000 1.00000
ERR_USER_MATRIX_OK

请注意,在单位矩阵中,由于浮点误差,一些零元素实际上是非常接近零的小值,因此它们有符号。

然后,让我们看看算法如何处理退化矩阵。

c
   Print("Test matrix inversion (should fail)");
   double b[9] =
   {
     -22, -7, 17, 
     -21, 15,  9, 
     -34,-31, 33
   };
   
   MatrixSquare mB(b);
   Print("Input");
   mB.print();
   ResetLastError();
   Print("Result");
   (!mB).print();
   Print(EnumToString(Matrix::lastError())); // 奇异矩阵
   ...

结果如下:

Test matrix inversion (should fail)
Input
-22.00000  -7.00000  17.00000
-21.00000  15.00000   9.00000
-34.00000 -31.00000  33.00000
Result
0.0 0.0 0.0
0.0 0.0 0.0
0.0 0.0 0.0
ERR_USER_MATRIX_SINGULAR

在这种情况下,我们只是显示错误描述。但在实际程序中,应该能够根据问题的性质选择继续执行的选项。

最后,我们将模拟剩下的两种应用错误的情况。

c
   Print("Empty matrix creation");
   MatrixSquare m0(0);
   Print(EnumToString(Matrix::lastError()));
   
   Print("'Rectangular' square matrix creation");
   MatrixSquare r12(1, 2);
   Print(EnumToString(Matrix::lastError()));
}

在这里,我们描述了一个空矩阵和一个看似方阵但大小不同的矩阵。

Empty matrix creation
ERR_USER_MATRIX_EMPTY
'Rectangular' square matrix creation
ERR_USER_MATRIX_NOT_SQUARE

在这些情况下,我们无法避免创建对象,因为编译器会自动执行此操作。

当然,这个测试明显违反了契约(类和方法“认为”有效的数据和操作规范)。然而,在实践中,参数通常是从代码的其他部分获取的,在处理大量“第三方”数据的过程中,检测与预期的偏差并非易事。

一个程序能够在不产生致命后果的情况下“处理”不正确的数据,以及对于正确的输入数据产生正确的结果,这是衡量其质量的最重要指标。

调试管理

MetaEditor 中的内置调试器允许在源代码中设置断点,断点就是程序执行应该暂停的行。有时这个系统会失效,也就是暂停功能不起作用,这时你可以使用 DebugBreak 函数来显式强制程序停止。

c
void DebugBreak()

调用该函数会暂停程序,并在调试模式下激活编辑器窗口,同时提供所有用于查看变量、调用栈以及逐步继续执行程序的工具。

只有当程序是从编辑器以调试模式启动时(通过“调试” -> “在真实数据上启动”或“在历史数据中启动”命令),程序执行才会被中断。在所有其他模式下,包括常规启动(在终端中)和性能分析模式,该函数不起作用。

预定义变量

每个 MQL 程序都有一组由终端提供的通用全局变量。在前面的章节中,我们已经介绍了其中的大部分变量,下面是一个总结表格。几乎所有变量都是只读的,例外的是 _LastError 变量,它可以通过 ResetLastError 函数重置。

变量
_LastError最后一个错误的值,与 GetLastError 函数类似
_StopFlag程序停止标志,与 IsStopped 函数类似
_UninitReason程序初始化失败的原因代码,与 UninitializeReason 函数类似
_RandomSeed伪随机整数生成器的当前内部状态
_IsX6464 位终端的标志,类似于使用 TerminalInfoInteger 函数获取 TERMINAL_X64 属性

此外,对于在图表上下文中运行的 MQL 程序,如专家顾问、脚本和指标,该语言提供了具有图表属性的预定义变量(同样,这些变量也不能在程序中修改)。

变量
_Symbol当前图表交易品种的名称,与 Symbol 函数类似
_Period当前图表的时间周期,与 Period 函数类似
_Digits当前图表交易品种价格的小数位数,与 Digits 函数类似
_Point当前交易品种价格的点值(以报价货币为单位),与 Point 函数类似
_AppliedTo指标计算所基于的数据类型(仅适用于指标)

MQL5 语言的预定义常量

本节描述了运行时环境为任何程序定义的所有常量。我们在前面的章节中已经见过其中的一些常量。有些常量与应用 MQL5 编程方面相关,这些内容将在后面的章节中介绍。

常量描述
CHARTS_MAX同时打开的图表的最大可能数量100
clrNONE无颜色-1 (0xFFFFFFFF)
EMPTY_VALUE指标缓冲区中的空值
DBL_MAX
INVALID_HANDLE无效句柄-1
NULL任何类型的空值0
WHOLE_ARRAY直到数组末尾的元素数量,即整个数组将被处理-1
WRONG_VALUE一个常量,可以隐式转换为任何枚举类型-1

如“文件”章节所示,INVALID_HANDLE 常量可用于验证文件描述符。

WHOLE_ARRAY 常量用于那些处理数组且需要指定所处理数组中元素数量的函数:如果需要从指定位置处理到数组的所有值,就指定 WHOLE_ARRAY 值。

EMPTY_VALUE 常量通常被赋给指标缓冲区中那些不应在图表上绘制的元素。换句话说,这个常量表示默认的空值。稍后,我们将描述如何为特定的指标缓冲区将其替换为另一个值,例如 0。

WRONG_VALUE 常量用于那些需要指定不正确枚举值的情况。

此外,有两个常量的值取决于编译方法。

常量描述
IS_DEBUG_MODE以调试模式运行 mq5 程序的属性:在调试模式下为非零值,否则为 0
IS_PROFILE_MODE以性能分析模式运行 mq5 程序的属性:在性能分析模式下为非零值,否则为 0

IS_PROFILE_MODE 常量允许在性能分析模式下为正确收集信息而改变程序的操作。性能分析可以测量单个程序片段(函数和单个行)的执行时间。

编译器在编译期间设置 IS_PROFILE_MODE 常量的值。通常,它被设置为 0。当程序以性能分析模式启动时,会执行特殊的编译,在这种情况下,会使用非零值来替代 IS_PROFILE_MODE

IS_DEBUG_MODE 常量的工作方式类似:在原生编译结果中它等于 0,在调试编译后它大于 0。在出于验证目的需要稍微改变 MQL 程序的操作时,它很有用,例如,向日志输出额外信息或在图表上创建辅助图形对象。

预处理器定义了含义类似的 _DEBUG_RELEASE 常量(见“预定义预处理器常量”)。

关于程序操作模式的更详细信息,可以在运行时使用 MQLInfoInteger 函数获取(见“终端和程序操作模式”)。特别是,程序的调试版本可以在没有调试器的情况下运行。