Skip to content

二进制格式库的开发与连接

除了 MQL 程序的专业类型——专家顾问、指标、脚本和服务之外,MetaTrader 5 平台还允许创建和连接具有任意功能的独立二进制模块,这些模块可以编译为 ex5 文件,或者是 Windows 标准的常用 DLL(动态链接库)。这些功能可以是分析算法、图形可视化、与 Web 服务的网络交互、外部程序的控制,甚至是操作系统本身的控制。无论如何,这些库在终端中并非作为独立的 MQL 程序运行,而是与上述 4 种类型中的任意一种程序协同工作。

将库与主(父)程序集成的思路是,库导出某些函数,即声明这些函数可供外部使用,而主程序则导入这些函数的原型。正是函数原型的描述——包括函数名、参数列表和返回值——使得在代码中调用这些函数时,即使没有函数的具体实现也能进行调用。

然后,在启动 MQL 程序时,会执行早期动态链接。这意味着在主程序加载之后加载库,并在导入的原型与库中可用的导出函数之间建立对应关系。通过函数名、参数列表和返回类型建立一一对应的关系是成功加载的先决条件。如果至少有一个函数的导入描述找不到对应的导出实现,MQL 程序的执行将被取消(程序会在启动阶段以错误结束)。

MQL 程序与库的通信组件图

MQL 程序与库的通信组件图

在启动 MQL 程序时,无法选择所包含的库。这种链接是由开发者在编译主程序时连同库的导入一起设置的。不过,用户可以在程序启动之间手动将一个 ex5/dll 文件替换为另一个(前提是库中实现的导出函数的原型匹配)。例如,如果库中包含带标签的字符串资源,这可以用于切换用户界面语言。然而,库最常被用作一种包含某些专有技术的商业产品,作者不准备以开放头文件的形式分发这些技术。

对于从其他环境转到 MQL5 并且已经熟悉 DLL 技术的程序员,我们想补充一点关于后期动态链接的说明,这是 DLL 的优点之一。在执行过程中,一个 MQL 程序(或 DLL 模块)完全动态连接到另一个 MQL 程序是不可能的。MQL5 允许 “动态进行” 的唯一类似操作是通过 iCustomIndicatorCreate 将专家顾问与指标链接起来,在这种情况下,指标充当动态链接库(不过,与指标的编程交互必须通过指标 API 进行,这意味着与通过导出/#import 直接调用函数相比,CopyBuffer 的开销会增加)。

请注意,在正常情况下,当从源代码编译 MQL 程序而不导入外部函数时,会使用静态链接,也就是说,生成的二进制代码会直接引用被调用的函数,因为在编译时这些函数是已知的。

严格来说,一个库也可以依赖其他库,即它可以导入一些函数。理论上,这种依赖链甚至可以更长:例如,一个 MQL 程序包含库 A,库 A 使用库 B,而库 B 又使用库 C。然而,这样的依赖链是不可取的,因为它们会使产品的分发和安装变得复杂,也会使识别潜在启动问题的原因更加困难。因此,库通常直接连接到父 MQL 程序。

在本章中,我们将描述在 MQL5 中创建库的过程、导出和导入函数(包括对其中使用的数据类型的限制),以及连接外部(现成的)DLL。DLL 的开发超出了本书的范围。

创建 ex5 库以及导出函数

为了描述一个库,需要在主(已编译)模块的源代码中(通常在文件开头)添加 #property library 指令。

#property library

在通过 #include 包含在编译过程中的其他任何文件里指定此指令是没有效果的。

library 属性告知编译器给定的 ex5 文件是一个库,关于这一点的标记会存储在 ex5 文件的头部。

在 MetaTrader 5 中,MQL5/Libraries 这个单独的文件夹是为库预留的。就像 MQL5 中其他类型的程序一样,你可以在其中组织嵌套文件夹的层次结构。

库不直接参与事件处理,因此编译器不要求代码中存在任何标准的事件处理函数。不过,你可以从连接了该库的 MQL 程序的事件处理函数中调用库的导出函数。

要从库中导出一个函数,只需用特殊关键字 export 对其进行标记,这个修饰符必须放在函数头的最后面。

result_type function_id ( [ parameter_type parameter_id
                          [ = default_value] ...] ) export
{
   ...
}

参数必须是简单类型或字符串、具有这些类型字段的结构体,或者它们的数组。对于 MQL5 对象类型,允许使用指针和引用(关于导入 DLL 的限制,请参阅相关部分)。

以下是一些示例:

  1. 参数是一个质数:
double Algebraic2(const double x) export
{
   return x / sqrt(1 + x * x); 
}
  1. 参数是一个对象指针和一个指针引用(允许在函数内部赋值指针):
class X
{
public:
   X() { Print(__FUNCSIG__); }
};
void setObject(const X *obj) export { ... }
void getObject(X *&obj) export { obj = new X(); }
  1. 参数是一个结构体:
struct Data
{
   int value;
   double data[];
   Data(): value(0) { }
   Data(const int i): value(i) { ArrayResize(data, i); }
};
   
void getRefStruct(const int i, Data &data) export { ... }

你只能导出函数,而不能导出整个类或结构体。借助指针和引用可以避免其中的一些限制,后面我们会更详细地讨论这一点。

函数模板不能用 export 关键字声明,也不能在 #import 指令中声明。

export 修饰符指示编译器将该函数包含在给定的 ex5 可执行文件的导出函数表中。这样一来,其他 MQL 程序就可以使用(“看到”)这些函数了,在通过特殊指令 #import 导入之后即可使用。

所有要导出的函数都必须用 export 修饰符标记。不过主程序不一定要导入所有这些函数,它可以只导入需要的函数。

如果你忘记导出一个函数,但在主 MQL 程序的导入指令中包含了它,那么当主程序启动时就会出现错误:

cannot find 'function' in 'library.ex5'
unresolved import function call

如果导出函数的描述和它的导入原型不一致,也会出现类似的问题。例如,如果你在对编程接口(通常在单独的头文件中描述)进行更改后,忘记重新编译库或主程序,就可能会出现这种情况。

库是无法调试的,所以如果有必要,你应该有一个辅助脚本或另一个 MQL 程序,它是在调试模式下从库的源代码构建而来的,并且可以在设置断点或逐步执行的情况下运行。当然,这需要使用一些真实或模拟的数据来模拟对导出函数的调用。

对于 DLL,根据创建它们所使用的编程语言的不同,导出函数的描述方式也不同。你可以在你选择的开发环境的文档中查找详细信息。

来看一个简单库 MQL5/Libraries/MQL5Book/LibRand.mq5 的示例,它导出了几个具有不同类型参数和结果的函数,这个库用于生成随机数据:

  1. 生成具有伪正态分布的数值数据;
  2. 生成包含来自给定字符集的随机字符的字符串(这可能对生成密码有用)。

具体来说,可以使用 PseudoNormalValue 函数获取一个随机数,该函数将期望值和方差作为参数进行设置:

double PseudoNormalValue(const double mean = 0.0, const double sigma = 1.0,
   const bool rooted = false) export
{
   // use ready-made sqrt for mass generation in a cycle in PseudoNormalArray
   const double s = !rooted ? sqrt(sigma) : sigma; 
   const double r = (rand() - 16383.5) / 16384.0; // [-1,+1] excluding borders
   const double x = -(log(1 / ((r + 1) / 2) - 1) * s) / M_PI * M_E + mean;
   return x;
}

PseudoNormalArray 函数用给定数量(n)且符合所需分布的随机值填充数组:

bool PseudoNormalArray(double &array[], const int n,
   const double mean = 0.0, const double sigma = 1.0) export
{
   bool success = true;
   const double s = sqrt(fabs(sigma)); // passing ready sqrt when calling PseudoNormalValue
   ArrayResize(array, n);
   for(int i = 0; i < n; ++i)
   {
      array[i] = PseudoNormalValue(mean, s, true);
      success = success && MathIsValidNumber(array[i]);
   }
   return success;
}

为了生成一个随机字符串,编写了 RandomString 函数,它从提供的字符集(pattern)中“选取”给定数量(length)的任意字符。当 pattern 参数为空(默认情况)时,假定使用完整的字母和数字集。使用辅助函数 StringPatternAlphaStringPatternDigit 来获取字符集,这些函数也是可导出的(书中未列出,查看源代码):

string RandomString(const int length, string pattern = NULL) export
{
   if(StringLen(pattern) == 0)
   {
      pattern = StringPatternAlpha() + StringPatternDigit();
   }
   const int size = StringLen(pattern);
   string result = "";
   for(int i = 0; i < length; ++i)
   {
      result += ShortToString(pattern[rand() % size]);
   }
   return result;
}

一般来说,要使用一个库,需要发布一个头文件,描述从外部应该可以在其中使用的所有内容(并且内部实现的细节可以而且应该隐藏起来)。在我们的例子中,这样的文件名为 MQL5Book/LibRand.mqh。特别是,它描述了用户定义的类型(在我们的例子中是 STRING_PATTERN 枚举)和函数原型。

虽然我们还不知道 #import 块的确切语法,但这并不影响其中声明的清晰度:这里重复了导出函数的函数头,但没有 export 关键字。

enum STRING_PATTERN
{
   STRING_PATTERN_LOWERCASE = 1, // 仅小写字母
   STRING_PATTERN_UPPERCASE = 2, // 仅大写字母
   STRING_PATTERN_MIXEDCASE = 3  // 大小写混合
};
   
#import "MQL5Book/LibRand.ex5"
string StringPatternAlpha(const STRING_PATTERN _case = STRING_PATTERN_MIXEDCASE);
string StringPatternDigit();
string RandomString(const int length, string pattern = NULL);
void RandomStrings(string &array[], const int n, const int minlength,
   const int maxlength, string pattern = NULL);
void PseudoNormalDefaultMean(const double mean = 0.0);
void PseudoNormalDefaultSigma(const double sigma = 1.0);
double PseudoNormalDefaultValue();
double PseudoNormalValue(const double mean = 0.0, const double sigma = 1.0,
   const bool rooted = false);
bool PseudoNormalArray(double &array[], const int n,
   const double mean = 0.0, const double sigma = 1.0);
#import

在下一节中,在学习了 #import 指令之后,我们将编写一个使用这个库的测试脚本。

包含库;函数的#import指令

函数可以从已编译的MQL5模块(.ex5文件)和Windows动态链接库模块(.dll文件)中导入。在#import指令中指定模块名称,随后是对导入函数原型的描述。这样的一个代码块必须以另一个#import指令结束,而且,这个指令可以没有名称,仅仅用于结束该代码块本身,或者可以在该指令中指定另一个库的名称,这样下一个导入代码块会同时开始。一系列的导入代码块总是应以一个没有库名称的指令结束。

该指令的最简形式如下:

c
#import "[路径] 模块名称 [.扩展名]"
  函数类型 函数名称([参数列表]);
  [函数类型 函数名称([参数列表]);]
   ... 
#import

库文件名可以不带扩展名指定:那么默认情况下会认为是DLL文件。ex5扩展名是必需的(对于MQL5模块)。

名称前面可以加上库的存储路径。默认情况下,如果没有指定路径,会在MQL5/Libraries文件夹或者连接库的MQL程序所在文件夹的相邻文件夹中搜索库。否则,根据库的类型是DLL还是EX5,会应用不同的规则来搜索库,这些规则将在单独的章节中介绍。

下面是一个从两个库中依次导入代码块的示例:

c
#import "user32.dll"
   int     MessageBoxW(int hWnd, string szText, string szCaption, int nType); 
   int     SendMessageW(int hWnd, int Msg, int wParam, int lParam); 
#import "lib.ex5" 
   double  round(double value); 
#import

通过这样的指令,导入的函数可以像直接在MQL程序中定义的函数一样从源代码中调用。加载库以及将调用重定向到第三方模块的所有技术问题都由MQL程序执行环境来处理。

为了让编译器正确地发出对导入函数的调用并组织参数的传递,需要完整的描述:包括结果类型、所有参数、修饰符以及默认值(如果在源文件中有默认值的话)。

由于导入的函数在已编译模块之外,编译器无法检查传递的参数和返回值的正确性。预期数据格式和接收到的数据格式之间的任何差异都会在程序执行期间导致错误,这可能表现为程序严重停止或者出现意外行为。

如果无法加载库或者找不到被调用的导入函数,MQL程序会终止并在日志中显示相应的消息。在问题解决之前,程序将无法运行,例如,通过修改并重新编译、将所需的库放置在搜索路径中的某个位置,或者允许使用DLL(仅针对DLL文件)。

当使用多个库时(无论是DLL还是EX5都没关系),请记住,它们必须具有不同的名称,无论它们所在的目录是什么。所有导入的函数都有一个与库文件名匹配的作用域,也就是说,这是一种隐式为每个包含的库分配的命名空间。

导入的函数可以有任何名称,包括与内置函数名称相同的名称(尽管不建议这样做)。此外,还可以同时从不同模块中导入名称相同的函数。在这种情况下,应该应用操作上下文权限来确定应该调用哪个函数。

例如:

c
#import "kernel32.dll"
   int GetLastError();
#import "lib.ex5" 
   int GetLastError();
#import
  
class Foo
{
public: 
   int GetLastError() { return(12345); }
   void func() 
   { 
      Print(GetLastError());           // 调用类方法
      Print(::GetLastError());         // 调用内置(全局)MQL5函数
      Print(kernel32::GetLastError()); // 调用来自kernel32.dll的函数
      Print(lib::GetLastError());      // 调用来自lib.ex5的函数
   }
};
   
void OnStart()
{
   Foo foo; 
   foo.func(); 
}

让我们来看一个简单的脚本示例LibRandTest.mq5,它使用了上一节中创建的EX5库中的函数。

c
#include <MQL5Book/LibRand.mqh>

在输入参数中,你可以选择数字数组中的元素数量、分布参数,以及直方图的步长,我们将计算直方图以确保分布大致符合正态分布规律。

c
input int N = 10000;
input double Mean = 0.0;
input double Sigma = 1.0;
input double HistogramStep = 0.5;
input int RandomSeed = 0;

MQL5内置的随机数生成器(均匀分布)通过RandomSeed的值进行初始化,如果这里留为0,则会选取GetTickCount的值(每次启动时都是新的)。

为了构建直方图,我们使用MapArray和QuickSortStructT(我们分别在多货币指标和数组排序的章节中已经使用过它们)。映射将以HistogramStep步长累积随机数落入直方图单元格的计数器。

c
#include <MQL5Book/MapArray.mqh>
#include <MQL5Book/QuickSortStructT.mqh>

为了基于映射显示直方图,需要能够按键值顺序对映射进行排序。为此,我们必须定义一个派生类。

c
#define COMMA ,
   
template<typename K,typename V>
class MyMapArray: public MapArray<K,V>
{
public:
   void sort()
   {
      SORT_STRUCT(Pair<K COMMA V>, array, key);
   }
};

请注意,COMMA宏成为逗号字符“,”的替代表示,并且在调用另一个SORT_STRUCT宏时使用。如果没有这个替换,Pair<K,V>内部的逗号会被预处理器解释为普通的宏参数分隔符,结果是SORT_STRUCT的输入会接收到4个参数,而不是预期的3个——这将导致编译错误。预处理器对MQL5语法一无所知。

在OnStart的开头,在生成器初始化之后,我们检查是否接收到单个随机字符串以及不同长度的字符串数组。

c
void OnStart()
{
   const uint seed = RandomSeed ? RandomSeed : GetTickCount();
   Print("Random seed: ", seed);
   MathSrand(seed);
   
   // 调用两个库函数: StringPatternDigit和RandomString
   Print("Random HEX-string: ", RandomString(30, StringPatternDigit() + "ABCDEF"));
   Print("Random strings:");
   string text[];
   RandomStrings(text, 5, 10, 20);         // 5行长度在10到20个字符之间的字符串
   ArrayPrint(text);
   ...

接下来,我们测试正态分布的随机数。

c
   // 调用另一个库函数: PseudoNormalArray
   double x[];
   PseudoNormalArray(x, N, Mean, Sigma);   // 填充数组x
   
   Print("Random pseudo-gaussian histogram: ");
   
   // 选择'long'作为键类型,因为'int'已经用于索引访问
   MyMapArray<long,int> map;
   
   for(int i = 0; i < N; ++i)
   {
 // 值x[i]确定直方图的单元格,我们在其中增加统计数据
      map.inc((long)MathRound(x[i] / HistogramStep));
   }
   map.sort();                             // 按键(即按值)排序
   
   int max = 0;                            // 搜索最大值以进行归一化
   for(int i = 0; i < map.getSize(); ++i)
   {
      max = fmax(max, map.getValue(i));
   }
   
   const double scale = fmax(max / 80, 1); // 直方图最多有80个符号
   
   for(int i = 0; i < map.getSize(); ++i)  // 打印直方图
   {
      const int p = (int)MathRound(map.getValue(i) / scale);
      string filler;
      StringInit(filler, p, '*');
      Print(StringFormat("%+.2f (%4d)",
         map.getKey(i) * HistogramStep, map.getValue(i)), " ", filler);
   }

这是使用默认设置运行时的结果(计时器随机化——每次运行都会选择一个新的种子)。

Random seed: 8859858

Random HEX-string: E58B125BCCDA67ABAB2F1C6D6EC677

Random strings:

"K4ZOpdIy5yxq4ble2" "NxTrVRl6q5j3Hr2FY" "6qxRdDzjp3WNA8xV"  "UlOPYinnGd36"      "6OCmde6rvErGB3wG" 

Random pseudo-gaussian histogram: 

-9.50 (   2) 

-8.50 (   1) 

-8.00 (   1) 

-7.00 (   1) 

-6.50 (   5) 

-6.00 (  10) *

-5.50 (  10) *

-5.00 (  24) *

-4.50 (  28) **

-4.00 (  50) ***

-3.50 ( 100) ******

-3.00 ( 195) ***********

-2.50 ( 272) ***************

-2.00 ( 510) ****************************

-1.50 ( 751) ******************************************

-1.00 (1029) *********************************************************

-0.50 (1288) ************************************************************************

+0.00 (1457) *********************************************************************************

+0.50 (1263) **********************************************************************

+1.00 (1060) ***********************************************************

+1.50 ( 772) *******************************************

+2.00 ( 480) ***************************

+2.50 ( 280) ****************

+3.00 ( 172) **********

+3.50 ( 112) ******

+4.00 (  52) ***

+4.50 (  43) **

+5.00 (  10) *

+5.50 (   8) 

+6.00 (   8) 

+6.50 (   2) 

+7.00 (   3) 

+7.50 (   1)

在这个库中,我们只导出和导入了具有内置类型的函数。然而,具有结构体、类和模板的对象接口从实际应用的角度来看更有趣且更有需求。我们将在单独的章节中讨论在库中使用它们的细微之处。

在测试器中测试专家顾问和指标时,应该记住与库相关的一个重要要点。从#import指令中会自动确定被测试的主要MQL程序所需的库。然而,如果从主程序中调用了一个自定义指标,并且该指标连接了某个库,那么必须在程序属性中明确指出它间接依赖于某个特定的库。这可以通过以下指令来完成:

c
#property tester_library "路径_库名称.扩展名"

库文件搜索顺序

如果指定库名时未附带路径,或者指定的是相对路径,那么将根据库的类型,按照不同规则进行搜索。

系统库(DLL)是根据操作系统的规则来加载的。如果该库已被加载(例如,被另一个智能交易系统加载,甚至是被并行启动的另一个客户端终端加载),那么调用将指向已加载的库。否则,搜索将按以下顺序进行:

  1. 启动导入该DLL的已编译EX5程序的文件夹。
  2. MQL5/Libraries文件夹。
  3. 正在运行的MetaTrader 5终端所在的文件夹。
  4. 系统文件夹(通常在Windows系统内部)。
  5. Windows目录。
  6. 终端进程的当前工作文件夹(可能与终端所在的文件夹不同)。
  7. PATH系统变量中列出的文件夹。

在#import指令中,不建议使用像Drive:/Directory/FileName.dll这种完整限定的可加载模块名称。

如果某个DLL在其运行过程中要使用另一个DLL,那么若缺少第二个DLL,第一个DLL将无法加载。

对于导入的EX5库,搜索按以下顺序进行:

  1. 启动导入EX5程序的文件夹。
  2. 特定终端实例的MQL5/Libraries文件夹。
  3. 所有MetaTrader 5终端的公共文件夹中的MQL5/Libraries文件夹(Common/MQL5/Libraries)。

在加载一个MQL程序之前,会先形成一个所有EX5库模块的总列表,程序本身以及来自该列表中的库所支持的模块都将从这个列表中选取。这个列表被称为依赖列表,它可能会形成一个非常复杂的 “树状结构”。

对于EX5库,终端还提供了一次性下载可复用模块的功能。

无论库的类型是什么,每个库实例都处理与调用它的智能交易系统、脚本、服务或指标的上下文相关的自身数据。库并非是用于对MQL5变量或数组进行共享访问的工具。

EX5库和DLL在调用它们的模块所在的线程上运行。

在库代码中,没有常规的方法来查找该库是从何处加载的。

DLL 连接详情

以下实体不能作为参数传递给从 DLL 导入的函数:

  1. 类(对象以及指向它们的指针)。
  2. 包含动态数组、字符串、类和其他复杂结构的结构体。
  3. 字符串数组或上述复杂对象的数组。

除非明确声明是按引用传递,否则所有简单类型的参数都是按值传递。传递字符串时,传递的是复制后的字符串的缓冲区地址;如果字符串是按引用传递,那么该特定字符串的缓冲区地址将直接传递给从 DLL 导入的函数,而不会进行复制。

将数组传递给 DLL 时,始终传递数据缓冲区起始地址(与 AS_SERIES 标志无关)。DLL 内部的函数并不知道 AS_SERIES 标志,传递的数组是长度未知的数组,需要一个额外的参数来指定其大小。

在描述导入函数的原型时,可以使用带有默认值的参数。

导入 DLL 时,应在特定 MQL 程序的属性中或终端的常规设置中授予使用它们的权限。在这方面,在“权限”部分,我们提供了脚本 EnvPermissions.mq5,其中特别包含一个使用系统 DLL 读取 Windows 系统剪贴板内容的函数。这个函数是可选提供的:它的调用被注释掉了,因为我们不知道如何处理库。现在,我们将把它转移到一个单独的脚本 LibClipboard.mq5 中。

运行脚本可能会提示用户进行确认(因为出于安全原因,DLL 默认是禁用的)。如有必要,在对话框的“依赖项”选项卡上启用该选项。

头文件位于目录 MQL5/Include/WinApi 中,其中还包含对许多必需的系统函数的 #import 指令,例如剪贴板管理(OpenClipboard、GetClipboardData 和 CloseClipboard)、内存管理(GlobalLock 和 GlobalUnlock)、Windows 窗口等。我们将只包含两个文件:winuser.mqh 和 winbase.mqh。它们包含所需的导入指令,并且通过与 windef.mqh 的连接,间接包含了 Windows 术语宏(HANDLE 和 PVOID):

#define HANDLE  long
#define PVOID   long
#import "user32.dll"
...
int             OpenClipboard(HANDLE wnd_new_owner);
HANDLE          GetClipboardData(uint format);
int             CloseClipboard(void);
...
#import
#import "kernel32.dll"
...
PVOID           GlobalLock(HANDLE mem);
int             GlobalUnlock(HANDLE mem);
...
#import

此外,我们从 kernel32.dll 库中导入 lstrcatW 函数,因为我们对默认提供的 winbase.mqh 中该函数的描述不满意:这为该函数提供了第二个原型,适用于在第一个参数中传递 PVOID 值。

#include <WinApi/winuser.mqh>
#include <WinApi/winbase.mqh>
#define CF_UNICODETEXT 13 // 标准交换格式之一 - Unicode 文本
#import "kernel32.dll"
string lstrcatW(PVOID string1, const string string2);
#import

使用剪贴板的本质是使用 OpenClipboard“获取”对它的访问权限,然后应该获取数据句柄(GetClipboardData),将其转换为内存地址(GlobalLock),最后将数据从系统内存复制到您的变量中(lstrcatW)。接下来,以相反的顺序释放占用的资源(GlobalUnlock 和 CloseClipboard)。

void ReadClipboard()
{
   if(OpenClipboard(NULL))
   {
      HANDLE h = GetClipboardData(CF_UNICODETEXT);
      PVOID p = GlobalLock(h);
      if(p != 0)
      {
         const string text = lstrcatW(p, "");
         Print("Clipboard: ", text);
         GlobalUnlock(h);
      }
      CloseClipboard();
   }
}

尝试将文本复制到剪贴板,然后运行脚本:剪贴板的内容应该会被记录下来。如果缓冲区中包含没有文本表示形式的图像或其他数据,结果将为空。

从 DLL 导入的函数遵循 Windows API 函数的二进制可执行链接约定。为了确保这种约定,在程序的源文本中使用特定于编译器的关键字,例如 C 或 C++ 中的 __stdcall。这些链接规则意味着以下几点:

  1. 调用函数(在我们的例子中是 MQL 程序)必须看到被调用函数(从 DLL 导入的函数)的原型,以便正确地将参数压入堆栈。
  2. 调用函数(在我们的例子中是 MQL 程序)以相反的顺序,从右到左堆叠参数 —— 这是导入函数读取传递给它的参数的顺序。
  3. 参数按值传递,除非明确按引用传递(在我们的例子中是字符串)。
  4. 导入的函数读取传递给它的参数并清除堆栈。

这里是另一个使用 DLL 的脚本示例 —— LibWindowTree.mq5。它的任务是遍历所有终端窗口的树形结构,并获取它们的类名(根据使用 WinApi 在系统中的注册信息)和标题。这里的窗口指的是 Windows 界面的标准元素,其中也包括控件。这个过程对于终端的自动化工作可能很有用:模拟在窗口中按下按钮、切换无法通过 MQL5 实现的模式等等。

为了导入所需的系统函数,我们包含使用 user32.dll 的头文件 WinUser.mqh。

#include <WinAPI/WinUser.mqh>

您可以使用函数 GetClassNameW 和 GetWindowTextW 获取窗口类名及其标题:它们在函数 GetWindowData 中被调用。

void GetWindowData(HANDLE w, string &clazz, string &title)
{
   static ushort receiver[MAX_PATH];
   if(GetWindowTextW(w, receiver, MAX_PATH))
   {
      title = ShortArrayToString(receiver);
   }
   if(GetClassNameW(w, receiver, MAX_PATH))
   {
      clazz = ShortArrayToString(receiver);
   }
}

函数名中的“W”后缀表示它们适用于 Unicode 格式的字符串(每个字符 2 个字节),这是当今最常用的格式(用于 ANSI 字符串的“A”后缀仅在与旧库向后兼容时才有意义)。

给定某个 Windows 窗口的初始句柄,向上遍历其父窗口层次结构由函数 TraverseUp 提供:它的操作基于系统函数 GetParent。对于找到的每个窗口,TraverseUp 调用 GetWindowData 并将得到的类名和标题输出到日志中。

HANDLE TraverseUp(HANDLE w)
{
   HANDLE p = 0;
   while(w != 0)
   {
      p = w;
      string clazz, title;
      GetWindowData(w, clazz, title);
      Print("'", clazz, "' '", title, "'");
      w = GetParent(w);
   }
   return p;
}

深入层次结构遍历由函数 TraverseDown 执行:使用系统函数 FindWindowExW 来枚举子窗口。

HANDLE TraverseDown(const HANDLE w, const int level = 0)
{
   // 请求第一个子窗口(如果有的话)
   HANDLE child = FindWindowExW(w, NULL, NULL, NULL);
   while(child)          // 当存在子窗口时循环
   {
      string clazz, title;
      GetWindowData(child, clazz, title);
      Print(StringFormat("%*s", level * 2, ""), "'", clazz, "' '", title, "'");
      TraverseDown(child, level + 1);
      // 请求下一个子窗口
      child = FindWindowExW(w, child, NULL, NULL);
   }
   return child;
}

在 OnStart 函数中,我们通过从运行脚本的当前图表的句柄向上遍历窗口来找到主终端窗口。然后构建整个终端窗口树。

void OnStart()
{
   HANDLE h = TraverseUp(ChartGetInteger(0, CHART_WINDOW_HANDLE));
   Print("Main window handle: ", h);
   TraverseDown(h, 1);
}

我们还可以通过类名和/或标题搜索所需的窗口,因此可以通过调用 FindWindowW 立即获取主窗口,因为它的属性是已知的。

h = FindWindowW("MetaQuotes::MetaTrader::5.00", NULL);

以下是一个示例日志(片段):

 'AfxFrameOrView140su' ''

 'Afx:000000013F110000:b:0000000000010003:0000000000000006:00000000000306BA' 'EURUSD,H1'

 'MDIClient' ''

 'MetaQuotes::MetaTrader::5.00' '12345678 - MetaQuotes-Demo: Demo Account - Hedge - ...'

Main window handle: 263576

  'msctls_statusbar32' 'For Help, press F1'

  'AfxControlBar140su' 'Standard'

    'ToolbarWindow32' 'Timeframes'

    'ToolbarWindow32' 'Line Studies'

    'ToolbarWindow32' 'Standard'

  'AfxControlBar140su' 'Toolbox'

    'Afx:000000013F110000:b:0000000000010003:0000000000000000:0000000000000000' 'Toolbox'

      'AfxWnd140su' ''

        'ToolbarWindow32' ''

...

  'MDIClient' ''

    'Afx:000000013F110000:b:0000000000010003:0000000000000006:00000000000306BA' 'EURUSD,H1'

      'AfxFrameOrView140su' ''

        'Edit' '0.00'

    'Afx:000000013F110000:b:0000000000010003:0000000000000006:00000000000306BA' 'XAUUSD,Daily'

      'AfxFrameOrView140su' ''

        'Edit' '0.00'

    'Afx:000000013F110000:b:0000000000010003:0000000000000006:00000000000306BA' 'EURUSD,M15'

      'AfxFrameOrView140su' ''

        'Edit' '0.00'

MQL5 库中的类和模板

尽管通常禁止导出和导入类和模板,但开发人员可以通过将抽象基接口的描述移到库头文件中并传递指针来绕过这些限制。让我们以一个对图像执行霍夫变换的库为例来说明这个概念。

霍夫变换是一种通过将图像与由一组参数描述的某种形式模型(公式)进行比较来提取图像特征的算法。

最简单的霍夫变换是通过将图像转换为极坐标来选择图像上的直线。通过这种处理,或多或少排成一行的 “填充” 像素序列会在极坐标空间中,在直线倾斜的特定角度(“theta”)与其相对于坐标中心的偏移(“ro”)的交点处形成峰值。

直线的霍夫变换

左边(原始)图像上的三个彩色点中的每一个点在极坐标空间(右边)中都会留下一条轨迹,因为可以通过一个点以不同的角度绘制无数条直线,并且这些直线都有到中心的垂线。每个轨迹片段仅被 “标记” 一次,但红色标记除外:在这个点上,三条轨迹相交并给出最大响应值(3)。确实,正如我们在原始图像中看到的,有一条直线穿过了所有三个点。因此,通过极坐标中的最大值可以揭示出直线的两个参数。

我们可以在价格图表上使用这种霍夫变换来突出显示替代的支撑线和阻力线。如果通常在个别极值点处绘制这些线,并且实际上是对异常值进行分析,那么霍夫变换线可以考虑所有的最高价或所有的最低价,甚至可以考虑柱线内的报价成交量分布。所有这些都可以让我们对价格水平有一个更合理的估计。

让我们从头文件 LibHoughTransform.mqh 开始。由于是由某个抽象图像提供用于分析的初始数据,因此我们定义 HoughImage 接口模板。

cpp
template<typename T>
interface HoughImage
{
   virtual int getWidth() const;
   virtual int getHeight() const;
   virtual T get(int x, int y) const;
};

在处理图像时,我们所需要知道的关于图像的所有信息就是它的尺寸以及每个像素的内容。出于通用性的考虑,像素内容用参数化类型 T 来表示。显然,在最简单的情况下,它可以是 intdouble 类型。

调用图像分析处理会稍微复杂一些。在库中,我们需要描述一个类,其对象将从一个特殊的工厂函数(以指针的形式)返回。正是这个函数应该从库中导出。假设它是这样的:

cpp
template<typename T>
class HoughTransformDraft
{
public:
   virtual int transform(const HoughImage<T> &image, double &result[],
      const int elements = 8) = 0;
};
   
HoughTransformDraft<?> *createHoughTransform() export { ... } // 问题 - 模板!

然而,模板类型和模板函数不能被导出。因此,我们将创建一个中间的非模板类 HoughTransform,在其中我们将为图像参数添加一个模板方法。不幸的是,模板方法不能是虚函数,因此我们将在方法内部手动调度调用(使用 dynamic_cast),将处理重定向到具有虚函数的派生类。

cpp
class HoughTransform
{
public:
   template<typename T>
   int transform(const HoughImage<T> &image, double &result[],
      const int elements = 8)
   {
      HoughTransformConcrete<T> *ptr = dynamic_cast<HoughTransformConcrete<T> *>(&this);
      if(ptr) return ptr.extract(image, result, elements);
      return 0;
   }
};
   
template<typename T>
class HoughTransformConcrete: public HoughTransform
{
public:
   virtual int extract(const HoughImage<T> &image, double &result[],
      const int elements = 8) = 0;
};

HoughTransformConcrete 的内部实现将被写入库文件 MQL5/Libraries/MQL5Book/LibHoughTransform.mq5 中。

cpp
#property library
   
#include <MQL5Book/LibHoughTransform.mqh>
   
template<typename T>
class LinearHoughTransform: public HoughTransformConcrete<T>
{
protected:
   int size;
   
public:
   LinearHoughTransform(const int quants): size(quants) { }
   ...

由于我们要将图像点重新计算到新的极坐标空间中,因此应该为该任务分配一定的空间大小。这里我们讨论的是离散霍夫变换,因为我们将原始图像视为离散的点集(像素),并且我们将在单元(量子)中累积角度与垂线的值。为了简单起见,我们将重点关注具有正方形空间的变体,其中角度和到中心的距离的读数数量是相等的。这个参数将传递给类的构造函数。

cpp
template<typename T>
class LinearHoughTransform: public HoughTransformConcrete<T>
{
protected:
   int size;
   Plain2DArray<T> data;
   Plain2DArray<double> trigonometric;
   
   void init()
   {
      data.allocate(size, size);
      trigonometric.allocate(2, size);
      double t, d = M_PI / size;
      int i;
      for(i = 0, t = 0; i < size; i++, t += d)
      {
         trigonometric.set(0, i, MathCos(t));
         trigonometric.set(1, i, MathSin(t));
      }
   }
   
public:
   LinearHoughTransform(const int quants): size(quants)
   {
      init();
   }
   ...

为了计算 “填充” 像素在尺寸为 size 乘以 size 的变换后的空间中留下的 “痕迹” 统计信息,我们描述了 data 数组。辅助模板类 Plain2DArray(带有类型参数 T)允许模拟任意大小的二维数组。同样的类,但参数类型为 double,应用于预先计算好的角度正弦和余弦值的三角函数表。我们将需要这个表来快速地将像素映射到新的空间中。

检测最突出直线参数的方法称为 extract。它将图像作为输入,并且必须用找到的直线参数对来填充输出结果数组。在下面的方程中:

y = a * x + b

参数 a(斜率,“theta”)将被写入结果数组的偶数索引位置,参数 b(截距,“ro”)将被写入数组的奇数索引位置。例如,在该方法完成后,第一条最明显的直线由以下表达式描述:

y = result[0] * x + result[1];

对于第二条直线,索引将分别增加到 2 和 3,依此类推,直到请求的最大直线数量(lines)。结果数组的大小等于直线数量的两倍。

cpp
template<typename T>
class LinearHoughTransform: public HoughTransformConcrete<T>
{
   ...
   virtual int extract(const HoughImage<T> &image, double &result[],
      const int lines = 8) override
   {
      ArrayResize(result, lines * 2);
      ArrayInitialize(result, 0);
      data.zero();
   
      const int w = image.getWidth();
      const int h = image.getHeight();
      const double d = M_PI / size;     // 例如 180 / 36 = 5 度
      const double rstep = MathSqrt(w * w + h * h) / size;
      ...

在直线搜索模块中组织了对图像像素的嵌套循环。对于每个 “填充”(非零)点,执行对倾斜角度的循环,并在变换后的空间中标记相应的极坐标对。在这种情况下,我们只是调用方法将单元格的内容增加像素返回的值:data.inc((int)r, i, v),但根据应用场景和类型 T 的不同,可能需要更复杂的处理。

cpp
      double r, t;
      int i;
      for(int x = 0; x < w; x++)
      {
         for(int y = 0; y < h; y++)
         {
            T v = image.get(x, y);
            if(v == (T)0) continue;
   
            for(i = 0, t = 0; i < size; i++, t += d) // t < Math.PI
            {
               r = (x * trigonometric.get(0, i) + y * trigonometric.get(1, i));
               r = MathRound(r / rstep); // 范围 [-range, +range]
               r += size; // [0, +2size]
               r /= 2;
   
               if((int)r < 0) r = 0;
               if((int)r >= size) r = size - 1;
               if(i < 0) i = 0;
               if(i >= size) i = size - 1;
   
               data.inc((int)r, i, v);
            }
         }
      }
      ...

在该方法的第二部分,在新空间中搜索最大值并填充输出数组 result

cpp
      for(i = 0; i < lines; i++)
      {
         int x, y;
         if(!findMax(x, y))
         {
            return i;
         }
   
         double a = 0, b = 0;
         if(MathSin(y * d) != 0)
         {
            a = -1.0 * MathCos(y * d) / MathSin(y * d);
            b = (x * 2 - size) * rstep / MathSin(y * d);
         }
         if(fabs(a) < DBL_EPSILON && fabs(b) < DBL_EPSILON)
         {
            i--;
            continue;
         }
         result[i * 2 + 0] = a;
         result[i * 2 + 1] = b;
      }
   
      return i;
   }

辅助方法 findMax(见源代码)将新空间中最大值的坐标写入 xy 变量中,另外还会覆盖该位置的邻域,以免反复找到它。

LinearHoughTransform 类已经准备好,我们可以编写一个可导出的工厂函数来生成对象。

cpp
HoughTransform *createHoughTransform(const int quants,
   const ENUM_DATATYPE type = TYPE_INT) export
{
   switch(type)
   {
   case TYPE_INT:
      return new LinearHoughTransform<int>(quants);
   case TYPE_DOUBLE:
      return new LinearHoughTransform<double>(quants);
   ...
   }
   return NULL;
}

由于模板不允许导出,我们在第二个参数中使用 ENUM_DATATYPE 枚举在转换期间以及在原始图像表示中改变数据类型。

为了测试结构的导出/导入,我们还在库的这个版本中描述了一个包含关于变换的元信息的结构,并导出了一个返回这样一个结构的函数。

cpp
struct HoughInfo
{
   const int dimension; // 模型公式中的参数数量
   const string about;  // 文字描述
   HoughInfo(const int n, const string s): dimension(n), about(s) { }
   HoughInfo(const HoughInfo &other): dimension(other.dimension), about(other.about) { }
};
   
HoughInfo getHoughInfo() export
{
   return HoughInfo(2, "Line: y = a * x + b; a = p[0]; b = p[1];");
}

霍夫变换的各种修改不仅可以揭示直线,还可以揭示其他与给定分析公式相对应的结构(例如圆)。这样的修改将揭示不同数量的参数并具有不同的含义。拥有一个自我记录的函数可以使库的集成更容易(特别是当库很多的时候;请注意,我们的头文件仅包含与实现此霍夫变换接口的任何库相关的通用信息,而不仅仅是针对直线的库)。

当然,这个导出只有一个公共方法的类的例子有点随意,因为可以直接导出变换函数。然而,在实践中,类往往包含更多的功能。特别是,很容易在我们的类中添加算法灵敏度的调整、存储用于检测历史数据中检查的信号的直线示例模式等等。

让我们在一个指标中使用这个库,该指标根据给定数量的柱线的最高价和最低价来计算支撑线和阻力线。多亏了霍夫变换和编程接口,该库允许显示几条最重要的这样的线。

指标的源代码在文件 MQL5/Indicators/MQL5Book/p7/LibHoughChannel.mq5 中。它还包含头文件 LibHoughTransform.mqh,我们在其中添加了导入指令。

cpp
#import "MQL5Book/LibHoughTransform.ex5"
HoughTransform *createHoughTransform(const int quants,
   const ENUM_DATATYPE type = TYPE_INT);
HoughInfo getHoughInfo();
#import

在分析的图像中,我们用像素表示报价中特定价格类型(OHLC)的位置。为了实现图像,我们需要描述从 HoughImage<int> 派生的 HoughQuotes 类。

我们将提供几种 “绘制” 像素的方法:在蜡烛体内、在蜡烛的全范围内,以及直接在最高价和最低价处。所有这些都在 PRICE_LINE 枚举中进行了形式化。目前,该指标将仅使用 HighHighLowLow,但这可以在设置中进行调整。

cpp
class HoughQuotes: public HoughImage<int>
{
public:
   enum PRICE_LINE
   {
      HighLow = 0,   // 柱线范围 |最高价..最低价|
      OpenClose = 1, // 柱线实体 |开盘价..收盘价|
      LowLow = 2,    // 柱线最低价
      HighHigh = 3,  // 柱线最高价
   };
   ...

在构造函数参数和内部变量中,我们指定要分析的柱线范围。柱线数量 size 决定了图像的水平大小。为了简单起见,我们在垂直方向上使用相同数量的读数。因此,价格离散化步长(step)等于 size 根柱线的实际价格范围(pp)除以 size。对于变量 base,我们计算在指定柱线中要考虑的价格下限。这个变量将用于根据找到的霍夫变换参数来确定直线的构建。

cpp
protected:
   int size;
   int offset;
   int step;
   double base;
   PRICE_LINE type;
   
public:
   HoughQuotes(int startbar, int barcount, PRICE_LINE price)
   {
      offset = startbar;
      size = barcount;
      type = price;
      int hh = iHighest(NULL, 0, MODE_HIGH, size, startbar);
      int ll = iLowest(NULL, 0, MODE_LOW, size, startbar);
      int pp = (int)((iHigh(NULL, 0, hh) - iLow(NULL, 0, ll)) / _Point);
      step = pp / size;
      base = iLow(NULL, 0, ll);
   }
   ...

回想一下,HoughImage 接口需要实现三个方法:getWidthgetHeightget。前两个很容易实现。

cpp
   virtual int getWidth() const override
   {
      return size;
   }
   
   virtual int getHeight() const override
   {
      return size;
   }

用于根据报价获取 “像素” 的 get 方法,如果指定的点根据从 PRICE_LINE 中选择的计算方法落在柱线或单元格范围内,则返回 1。否则,返回 0。这个方法可以通过评估分形、持续增加极值,或者对权重更高的 “整数” 价格(像素加粗)进行评估来显著改进。

cpp
   virtual int get(int x, int y) const override
   {
      if(offset + x >= iBars(NULL, 0)) return 0;
   
      const double price = convert(y);
      if(type == HighLow)
      {
         if(price >= iLow(NULL, 0, offset + x) && price <= iHigh(NULL, 0, offset + x))
         {
            return 1;
         }
      }
      else if(type == OpenClose)
      {
         if(price >= fmin(iOpen(NULL, 0, offset + x), iClose(NULL, 0, offset + x))
         && price <= fmax(iOpen(NULL, 0, offset + x), iClose(NULL, 0, offset + x)))
         {
            return 1;
         }
      }
      else if(type == LowLow)
      {
         if(iLow(NULL, 0, offset + x) >= price - step * _Point / 2
         && iLow(NULL, 0, offset + x) <= price + step * _Point / 2)
         {
            return 1;
         }
      }
      else if(type == HighHigh)
      {
         if(iHigh(NULL, 0, offset + x) >= price - step * _Point / 2
         && iHigh(NULL, 0, offset + x) <= price + step * _Point / 2)
         {
            return 1;
         }
      }
      return 0;
   }

辅助方法 convert 提供从像素 y 坐标到价格值的重新计算。

cpp
   double convert(const double y) const
   {
      return base + y * step * _Point;
   }
};

现在,编写指标的技术部分所需的一切都已准备就绪。首先,让我们声明三个输入变量,以选择要分析的片段以及直线的数量。所有直线都将使用一个通用前缀进行标识。

cpp
input int BarOffset = 0;
input int BarCount = 21;
input int MaxLines = 3;
   
const string Prefix = "HoughChannel-";

提供变换服务的对象将被声明为全局变量:在这里调用库中的工厂函数 createHoughTransform

cpp
HoughTransform *ht = createHoughTransform(BarCount);

OnInit 函数中,我们只需使用第二个导入的函数 getHoughInfo 记录库的描述信息。

cpp
int OnInit()
{
   HoughInfo info = getHoughInfo();
   Print(info.dimension, " per ", info.about);
   return INIT_SUCCEEDED;
}

我们将在 OnCalculate 函数中在新一根K线开盘时执行一次计算。

cpp
int OnCalculate(const int rates_total,
                const int prev_calculated,
                const int begin,
                const double &price[])
{
   static datetime now = 0;
   if(now != iTime(NULL, 0, 0))
   {
      ... // 见下一个代码块
      now = iTime(NULL, 0, 0);
   }
   return rates_total;
}

变换计算本身会在由不同价格类型形成的一对图像(最高价和最低价)上运行两次。在这种情况下,工作由同一个对象 ht 依次执行。如果成功检测到直线,我们将使用函数 DrawLine 在图表上显示它们。由于直线在结果数组中按重要性降序排列,因此直线被赋予递减的权重。

cpp
      HoughQuotes highs(BarOffset, BarCount, HoughQuotes::HighHigh);
      HoughQuotes lows(BarOffset, BarCount, HoughQuotes::LowLow);
      static double result[];
      int n;
      n = ht.transform(highs, result, fmin(MaxLines, 5));
      if(n)
      {
         for(int i = 0; i < n; ++i)
         {
            DrawLine(highs, Prefix + "Highs-" + (string)i,
               result[i * 2 + 0], result[i * 2 + 1], clrBlue, 5 - i);
         }
      }
      n = ht.transform(lows, result, fmin(MaxLines, 5));
      if(n)
      {
         for(int i = 0; i < n; ++i)
         {
            DrawLine(lows, Prefix + "Lows-" + (string)i,
               result[i * 2 + 0], result[i * 2 + 1], clrRed, 5 - i);
         }
      }

DrawLine 函数基于趋势图形对象(OBJ_TREND,见源代码)。

当指标去初始化时,我们删除直线和分析对象。

cpp
void OnDeinit(const int)
{
   AutoPtr<HoughTransform> destructor(ht);
   ObjectsDeleteAll(0, Prefix);
}

在测试新开发的内容之前,不要忘记编译库和指标。

使用默认设置运行指标会得到类似这样的结果。

基于霍夫变换库的最高价/最低价主要线条指标

基于霍夫变换库的最高价/最低价主要线条指标

在我们的例子中,测试是成功的。但是如果需要调试库该怎么办呢?没有内置的工具来进行调试,所以可以使用以下技巧。库的源测试有条件地编译到产品的调试版本中,并且使用已构建的库对产品进行测试。让我们以我们的指标为例来考虑。

让我们提供 LIB_HOUGH_IMPL_DEBUG 宏来启用将库源直接集成到指标中。该宏应该放在包含头文件之前。

cpp
#define LIB_HOUGH_IMPL_DEBUG
#include <MQL5Book/LibHoughTransform.mqh>

在头文件本身中,我们将使用预处理器条件编译指令覆盖从二进制独立库副本的导入块。当启用该宏时,将运行另一个分支,带有 #include 语句。

cpp
#ifdef LIB_HOUGH_IMPL_DEBUG
#include "../../Libraries/MQL5Book/LibHoughTransform.mq5"
#else
#import "MQL5Book/LibHoughTransform.ex5"
HoughTransform *createHoughTransform(const int quants,
   const ENUM_DATATYPE type = TYPE_INT);
HoughInfo getHoughInfo();
#import
#endif

在库源文件 LibHoughTransform.mq5 中,在 getHoughInfo 函数内部,我们根据宏是否启用,向日志中添加关于编译方法的信息输出。

cpp
HoughInfo getHoughInfo() export
{
#ifdef LIB_HOUGH_IMPL_DEBUG
   Print("inline library (debug)");
#else
   Print("standalone library (production)");
#endif
   return HoughInfo(2, "Line: y = a * x + b; a = p[0]; b = p[1];");
}

如果在指标代码文件 LibHoughChannel.mq5 中取消对指令 #define LIB_HOUGH_IMPL_DEBUG 的注释,就可以测试逐像素的图像分析。

从 .NET 库中导入函数

MQL5 提供了一项用于处理 .NET 库函数的特殊功能:你可以直接导入 DLL 文件本身,而无需指定特定的函数。MetaEditor 会自动导入所有可使用的函数,这些函数包括:

  1. 普通旧数据(POD)—— 即仅包含简单数据类型的结构体;
  2. 公共静态函数,其参数仅使用简单的 POD 类型、结构体或它们的数组。

遗憾的是,目前无法查看 MetaEditor 所识别的函数原型。

例如,在 TestLib.dll 库中,TestClass 类的 Inc 函数有如下 C# 代码:

public class TestClass
{ 
   public static void Inc(ref int x)
   {
      x++;
   }
}

那么,要导入并调用该函数,只需编写以下代码:

#import "TestLib.dll"
   
void OnStart()
{
   int x = 1;
   TestClass::Inc(x);
   Print(x);
}

执行后,脚本将返回值 2。