基于 .Net 框架和 C# 为 EA 交易和指标开发图形界面

Vasiliy Sokolov | 9 四月, 2019

简介

自2018年10月起,MQL5支持与 .NET 框架库的本机集成。本机支持意味着放置在.NET库中的类型、方法和类现在可以直接从MQL5程序访问,而无需事先声明调用函数及其参数,也无需对两种语言进行复杂的类型转换。这确实可以被视为一个明确的突破,因为巨大的.NET框架代码库和C语言的强大功能现在对所有MQL5用户都是现成的。

.NET框架功能不受库本身的限制。集成的免费Visual Studio开发环境大大简化了开发过程。例如,您可以使用它在拖放模式下开发一个完整的Windows应用程序,其所有元素的行为方式与任何其他图形窗口应用程序相同,这正是MQL所缺少的。

在语言存在的过程中,已经创建了多个库,极大地促进了MQL应用程序内部的图形应用程序开发。然而,不管这些库有多好,它们仍然由一组代码组成,这些代码需要理解,并能够将其与自定义EA交易和指标的代码集成。换句话说,非程序员很难使用它们。如果不与.NET框架库集成,那么在Visual Studio中创建表单的简单性与在MQL中配置图形库的复杂性之间的差距今天仍然存在。 

本文讨论了针对 MQL5 EA交易和指标的定制图形接口(GUI)的开发。GUI 是包含一组标准图形元素的标准 Windows 窗体,其中每个元素都与 EA 的交易逻辑紧密相连。

要开发图形应用程序,我们需要将MQL程序与.NET库集成,本文也详细讨论了这一任务。因此,它不仅对那些希望为其MQL程序创建任何图形形式的人有用,而且对那些希望与第三方.NET代码库集成的人也有用。

重点介绍了该方法的简单性,主要任务是使与 C# 代码的交互尽可能简单。交互本身的安排使 C# 代码在没有用户干预的情况下创建!由于高级 C# 语言工具和 Visual Studio 丰富的功能,这是可能的。

因此,读者不需要了解 C#。主要思想是将图形控件(如按钮或文本标签)置于可视模式,然后使用MQL语言为每个元素提供适当的逻辑。面板与MQL程序的集成是“幕后”自动完成的。  


与 .Net GUI 的交互. 一般原则

.Net 是微软在2002开发的通用语言平台的专有名称,作为流行的Java平台的替代品。该平台基于公共语言运行时(Common Language Runtime, CLR),与直接编译成机器代码并直接在计算机上启动的传统程序不同,.NET应用程序在 CLR 虚拟机上运行。因此,.NET是一种由使用高级语言开发的程序应用于用户PC上的环境。

C# 是 .Net 中的一种主要编程语言,当有人谈论C#,他们的意思可能就是 .NET,反之亦然 - .NET 显然与 C# 有关。简单地说,我们可以说 .NET是一个执行环境,主要是在C#语言中开发的程序。我们的文章也不例外,本文中展示的所有代码都是使用 C# 编写的。

在为.NET平台开发了一个程序之后,它被编译成由 CLR 虚拟机执行的中级低级CIL(公共中间语言)语言字节代码。代码本身被打包到标准的Windows程序实体中:exe可执行模块或dll动态库。.NET 虚拟机的编译代码具有高级结构,其属性易于探索,我们可以看到它包含哪些类型的数据,最新版本的MQL编译器使用了这个显著的特性。在编译期间,编译器下载动态网络库并读取其中定义的公共静态方法。除了公共静态方法之外,MQL编译器还了解C#基本数据类型。这些数据类型包括:

  • 所有整数数据类型:long/ulong、int/uint、byte、short/ushort;
  • 浮点数 float/double;
  • 'char' 字符数据类型 (与 MQL 不同, 那里的 char 和 uchar 是字节数据类型, 在 С# 中, 这种类型是用于定义一个符号的);
  • 'string' 类型;
  • 包含上面列出的基本类型作为字段的简单结构。

除了列出的类型之外,MQL编译器还可以看到C#数组。目前,在MQL程序中,通过“[]”索引器无法获得对数组元素的标准访问,我可以自信地说,未来将扩大对类型的支持。然而,今天的功能足以进行一个全面的交互。

在我们的项目中,我们将使用 Windows 窗体技术开发窗体。这是一组相当简单的API,即使是没有准备的用户也可以轻松快速地绘制GUI。它的特点是面向事件的方法,这意味着,当用户单击按钮或在输入窗口中输入文本时,将生成相应的事件,在处理此类事件后,C# 程序确定表单的特定图形元素已被用户修改。对于不熟悉C#的人来说,处理事件是一个相当复杂的过程,需要一个特殊的中间代码来处理表单中发生的事件,并将它们传递给在 MetaTrader 5终端中运行的MQL程序。

因此,我们的项目将包含三个相互作用的独立对象:

这三个对象都将通过消息系统相互作用。一个消息系统用于MQL应用程序和控制器之间的交互,另一个消息系统用于控制器和用户窗口之间的交互。



图 1. MQL程序和C#图形应用程序之间的交互。一般结构

该结构以最一般的形式呈现,到目前为止,还没有揭示我们未来图形应用程序中所描述部分之间交互的具体情况。然而,考虑到所提出的方案,我们的系统显然是高度分布式的:每个模块都是独立的,如果任何其他模块发生变化,则不需要干预其代码。在下面的部分中,我们将详细介绍这些部分和实现这种分离的方法之间的交互。


安装和配置 Visual Studio

既然我们已经准备好了一般的实施结构,现在是进行项目的时候了。为此,您应该在PC上安装Visual Studio的工作版本。如果已经安装了此程序,可以跳过此部分。如果你是一个初学者,而且以前从未接触过这个项目,那就继续阅读吧。 

isual Studio是一个专业的开发环境,可用于各种编程任务。该软件有几种版本,我们将会使用社区(Community)版来进行工作,这是一个免费的版本,使用30天后,应免费注册。要做到这一点,您应该使用其中一个Microsoft服务接受标准验证过程。在这里,我将展示下载、安装和注册平台的基本步骤,以便新用户可以在尽可能短的时间内开始使用它的功能,而不会遇到太多障碍。 

下面是在计算机上安装Visual Studio的分步指南,下面提供了安装程序的国际英语版本的屏幕截图,具体外观可能因您的电脑的区域设置而有所不同。 

首先,转到Visual Studio官方网站visualstudio.microsoft.com ,然后选择相应的分发工具包。选择社区版本:

 

图 2. 选择Visual Studio Distribution Kit


之后,开始下载Visual Studio安装程序。如果网站提示您注册,请跳过此步骤,我们稍后再做。

启动安装程序后,将出现一个窗口,通知您需要配置安装程序。点击继续:

图 3. 点击同意以继续安装

接下来开始下载必要的安装文件。可能需要一些时间,这取决于您的带宽。下载完成后,出现“Installation Configuration(安装配置)”窗口。从建议的组件中选择“.NET desktop development”选项: 


图 4. 选择组件

点击安装安装过程开始。可能还需要一些时间:

图 5. 安装

安装完成后,将自动启动Visual Studio。如果不是,就手动启动。在第一次启动期间,Visual Studio将要求您登录到您的帐户或创建一个新帐户。如果您没有帐户,请单击“创建一个”链接立即创建:


图 6. 创建一个新账户


将开始注册新邮箱,此邮箱将绑定到所有Microsoft服务。完成注册,持续执行建议的操作。注册过程相当标准,所以我们不会详细讨论。

如果您不想注册,请单击“现在不注册,可能稍后注册”跳过此步骤。但是,请记住,Visual Studio需要在30天内注册,否则,它将会停止工作。


创建第一个窗体。快速启动

注册并登录到帐户后,将启动Visual Studio。让我们开发第一个可视化表单,并将其连接到MetaTrader。本节将向您展示这项工作是多么容易。

首先,创建一个新项目,选择文件->新建->项目,弹出项目类型选择窗口:

图 7

选择“Windows窗体应用程序(.NET框架)”,在“名称”字段中输入项目名称,让我们更改默认名称并把我们的项目命名为GuiMT,点击 OK,Visual Studio使用自动创建的表单显示 Visual Designer:

 

图 8. 在Visual Studio窗口中创建图形窗体


解决方案资源管理器窗口包含项目结构,请注意 Form1.cs. 这是一个包含程序代码的文件,它创建了我们在Form1.cs[Design] 图形编辑器窗口中看到的窗体的图形表示。记住文件名,我们晚一点将会需要它。

可视化设计器允许我们使用鼠标更改窗体大小,您也可以在窗体上放置自定义元素。这些特性足以进行我们的第一次实验。打开“工具箱”选项卡,然后在主窗口左侧的侧选项卡和“所有窗口”窗体中选择按钮元素:

图 9. 选择按钮

使用鼠标将其拖动到窗体1的主表面上:

图 10. 第一个表单

按钮大小也可以更改,您可以测试主窗口的大小和按钮的位置。既然表单已经有了按钮,我们就假定第一个应用程序已经准备好了。让我们编译它,这可以用不同的方式完成,但现在我们只在调试模式下运行它。要执行此操作,请单击开始:

图 11. 在调试模式下运行应用程序的按钮  

单击按钮后,应用程序将自动编译并启动。启动应用程序后,只需关闭窗口或单击“停止”,即可在Visual Studio中停止调试:

图 11. 调试停止按钮

我们的第一个应用程序已经准备好了,我们要做的最后一件事是找出我们刚刚创建的程序的绝对路径。最简单的方法是简单地查看“属性”窗口的“项目文件夹”字段中的路径。应在解决方案资源管理器窗口中突出显示 GuiMT 项目:

图 12. 项目文件夹行中应用程序的绝对路径

此窗口中的路径与项目本身相关。我们程序的特定程序集将根据编译模式位于其中一个子目录中,在我们的例子中,就是 .\bin\debug\<Custom_project_name.exe>。因此,应用程序的完整路径如下:C:\Users\<User_name>\source\repos\GuiMT\GuiMT\bin\debug\GuiMT.exe. 在定义了路径之后,我们应该将其保存在某个地方,因为稍后需要将其插入到我们的MQL代码中。


取得最新版本的 GuiController.dll. 使用 GitHub

本文附带的文件中包含了 GuiController.dll 库,把它放到 \MQL5\Libraries 目录下。但是,经常会发生这样的情况:库继续被更新和开发,从而使附加到文章的存档文件过时。为了避免这种情况和所有类似的问题,我建议使用版本控制系统,允许新代码自动供用户使用。我们的项目也不例外。让我们使用 GitHub.com 的服务来保存开放源代码,并取得最新版本的 GuiController. 控制器的源代码已包含在此存储库中。我们需要做的就是下载它的项目并将控制器编译到动态库中。如果您不能或不想使用该系统,只需跳过本节,而是将 GuiController.dll 文件复制到 MQL5\Libraries 目录。 

如果当前解决方案仍处于打开状态,请将其关闭(文件->解决方案)。现在,转到团队资源管理器选项卡并单击克隆链接,在黄色字段中输入项目地址:

https://github.com/PublicMqlProjects/MtGuiController

下一个字段指定用于保存下载项目的本地路径,路径是根据下载的项目名称自动选择的,因此我们不会更改它。下面的屏幕截图显示了要输入到团队资源管理器的值:

图 13. 连接到远程源代码存储库

现在一切就绪,点击克隆,一段时间后,具有 MTGuiController 最新版本的项目将出现在指定地址。通过文件->打开->项目/解决方案菜单中的命令打开它。下载并打开项目后,应进行编译。要执行此操作,请按F6或选择菜单中的“生成”->“生成解决方案”。在 MtGuiController\bin\debug 文件夹下可以找到编译好的 MtGuiController.dll 文件,把它复制到 MetaTrader 5 的库目录中: MQL5\Libraries.

如果由于某种原因您无法通过GitHub获得最新版本,请从下面附加的存档中复制控制器。


将第一个应用程序与MetaTrader 5集成

既然我们有了第一个应用程序和控制器来向 MetaTrader 广播图形窗口信号,那么我们就必须执行最后一部分:编写一个MQL程序作为EA,通过控制器从窗口接收事件。让我们在 MetaEditor 中开发一个名为 GuiMtController 的新 EA,内容如下:

//+------------------------------------------------------------------+
//|                                              GuiMtController.mq5 |
//|                        Copyright 2019, MetaQuotes Software Corp. |
//|                                              http://www.mql5.com |
//+------------------------------------------------------------------+
#property copyright "Copyright 2018, MetaQuotes Software Corp."
#property link      "http://www.mql5.com"
#property version   "1.00"
#import  "MtGuiController.dll"
string assembly = "С:\\Users\\Bazil\\source\\repos\\GuiMT\\GuiMT\\bin\\Debug\\GuiMT.exe";
//+------------------------------------------------------------------+
//| EA 交易初始化函数                                                  |
//+------------------------------------------------------------------+
int OnInit()
  {
//--- 创建计时器
   EventSetMillisecondTimer(200);
   GuiController::ShowForm(assembly, "Form1");
//---
   return(INIT_SUCCEEDED);
  }
//+------------------------------------------------------------------+
//| EA 交易去初始化函数                                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
//--- 删除计时器
   GuiController::HideForm(assembly, "Form1");
   EventKillTimer();   
  }
//+------------------------------------------------------------------+
//| EA交易分时函数                                                     |
//+------------------------------------------------------------------+
void OnTick()
{
//---
}
//+------------------------------------------------------------------+
//| 计时器函数                                                         |
//+------------------------------------------------------------------+
void OnTimer()
  {
//---
   for(static int i = 0; i < GuiController::EventsTotal(); i++)
   {
      int id;
      string el_name;
      long lparam;
      double dparam;
      string sparam;
      GuiController::GetEvent(i, el_name, id, lparam, dparam, sparam);
      if(id == ClickOnElement)
         printf("Click on element " + el_name);
   }
  }
//+------------------------------------------------------------------+

正如我已经提到的,MtGuiController.dll 库应该放在 MQL5\Libraries 目录中以编译代码。此外,行中指定的绝对路径:

string assembly = "С:\\Users\\Bazil\\source\\repos\\GuiMT\\GuiMT\\bin\\Debug\\GuiMT.exe";

应该用窗口替换为程序的实际位置。
如果所有操作都正确,则编译EA。启动后,我们的窗口出现在 MetaTrader 主窗口的背景上:

图 14. 集成了使用 C# 语言图形应用的 EA

当点击了 button1, EA 会在专家页面显示 "点击了元素 button1" 的消息,以指出它已经接收到了按钮按下的事件。 


使用 GuiController 交互的 MQL 程序事件模型

让我们彻底分析上面显示的MQL代码列表,了解我们开发的程序是如何工作的。

我们首先可以看到的是import转义符和assembly字符串:

#import  "MtGuiController.dll"
string assembly = "C:\\Users\\Bazil\\source\\repos\\GuiMT\\GuiMT\\bin\\Debug\\GuiMT.exe";

第一行代码通知编译器将使用对MtGuiController.dll中打开的静态类方法的调用。在这个组件中,不需要指定我们要引用的确切方法,编译器会自动执行该操作。

第二行代码包含了我们将要管理的表单的路径,这个地址应当对应着您的表单的实际位置。

接下来是EA初始化过程的标准OnInit代码:

//+------------------------------------------------------------------+
//| EA 交易初始化函数                                                  |
//+------------------------------------------------------------------+
int OnInit()
  {
//--- 创建计时器
   EventSetMillisecondTimer(200);
   GuiController::ShowForm(assembly, "Form1");
//---
   return(INIT_SUCCEEDED);
  }

在这里,设置高频计时器,并首次调用一个自定义的类方法。稍后将介绍计时器功能,现在让我们来看一下调用ShowForm:

GuiController::ShowForm(assembly, "Form1");

在 C# 中, 函数不能独立于类而存在,所以,每个函数(方法)都有它所被定义其中的类。在 MtGuiController.dll 中就定义了单个的 GuiController 类,它包含允许您管理窗口的静态方法。MtGuiController.dll 中没有其他类,这意味着整个管理是通过类执行的,这非常方便,因为用户使用单个交互界面,并且不在一组不同的定义中搜索必要的函数。

在初始化块中执行的第一件事是调用ShowForm方法,顾名思义,它启动了显示表单的过程。方法的第一个参数设置表单定义文件的绝对路径,而第二个参数设置表单本身的名称。可以在一个文件中定义多个表单,因此,有必要指定要在文件中启动的确切形式。在这种情况下,默认情况下,表单以Visual Studio分配给自定义表单的表单类命名。如果我们在Visual Studio中打开以前创建的项目,并在代码视图模式下打开 Form1.Designer.cs文件,我们将看到类的必要名称:

partial class Form1
{
        /// <summary>
        /// Required designer variable.
        /// </summary>
        private System.ComponentModel.IContainer components = null;
        ...
}

有必要进一步给出更有意义的类名。在Visual Studio中,只需重命名类及其所有引用即可轻松完成此操作。在这种情况下,ShowForm方法的第二个参数的值也应该更改。

下一个函数是 OnTimer,根据定时器的设置,它每秒被调用五次。它包含了整个项目中最有趣的代码,函数体包含迭代事件序列号的for循环:

for(static int i = 0; i < GuiController::EventsTotal(); i++)
   {
      int id;
      string el_name;
      long lparam;
      double dparam;
      string sparam;
      GuiController::GetEvent(i, el_name, id, lparam, dparam, sparam);
      if(id == ClickOnElement)
         printf("Click on element " + el_name);
   }

从控制器的角度来看,事件是指向表单的任何用户操作。例如,当用户单击按钮或在文本框中输入文本时,控制器接收相应的事件并将其放入事件列表中。列表中的事件数由可以由我们的 MQL 程序调用的 GuiController::EventsTotal()静态方法广播。

Windows窗体具有许多事件,每个元素,如窗体、按钮或文本框,都包含几十个事件。并非所有事件都可以处理,但这不是必需的。GuiController 只处理最重要的事件,在当前版本中,只有三个事件被处理,它们如下:

  • 按钮点击事件;
  • 完成文字输入事件;
  • 水平滚动事件。

该列表将在将来进行扩展,尽管其当前状态已足以满足本文的目的。

在我们的 GuiController支持的事件发生后,它将被处理并添加到事件列表中。处理事件包括创建数据,在接收数据后,MQL程序可以相对容易地定义事件类型及其参数。这就是为什么每个事件的数据格式与 OnChartEvent 函数的事件模型具有非常相似的结构。由于这种相似性,使用 GuiController 的用户不需要学习新事件模型的格式。当然,所提出的方法也有其自身的困难,例如,复杂事件(如滚动)很难适应所提出的格式,但这些问题很容易通过使用C#语言工具及其先进的面向对象编程模型来解决。 同时,所提出的模型足以解决我们的任务。

每次新事件到达时,都可以使用 GuiController::GetEvent 静态方法通过引用类型接收其数据。此方法具有以下原型:

public static void GetEvent(int event_n, ref string el_name, ref int id, ref long lparam, ref double dparam, ref string sparam)

让我们描述一下它的参数: 

  • event-n — 要接收的事件的序列号。由于能够指定事件的序列号,因此无论新事件有多多多大,都更容易控制新事件;
  • el_name — 生成此事件的元素的名称;
  • id — 事件类型;
  • lparam — 事件的整数参数值;
  • dparam — 事件的实数参数值;
  • sparam — 事件的字符串参数值.

如您所见,GuiController事件模型与OnChartEvent模型非常相似。GuiController中的任何事件总是有一个序列号和一个生成它的源(元素名),其余参数是可选的。有些事件(如单击按钮)根本没有其他参数(lparam、dparam、sparam),而sparam参数中的文本完成事件包含用户输入到字段中的文本。

下面是一个包含当前支持的事件及其参数的表:

事件名称 ID  参数
Exception  0 sparam - 引起异常的消息
ClickOnElement  1 -
TextChange  2 sparam - 由用户输入的新文本 
ScrollChange  3

lparam - 之前滚动的水平

dparam - 当前滚动的水平

既然我们已经在 GuiController 中处理了事件模型,那么我们就可以最终理解for循环中提供的代码。String:

GuiController::GetEvent(i, el_name, id, lparam, dparam, sparam);

根据 i 索引取得事件,如果有人对按钮点击进行某种类型的更正,按钮的名字和关于其按下的信息就会在终端控制台上显示:

if(id == ClickOnElement)
   printf("Click on element " + el_name);

请注意,该ID与未在MQL程序代码中任何位置定义的 ClickOnElement 常量进行比较。此常量是在C#中的 GuiController 中定义的枚举的一部分。

/// <summary>
/// gui 事件的类型
/// </summary>
public enum GuiEventType
{
    Exception,
    ClickOnElement,
    TextChange,
    ScrollChange
}

如您所见,编译器理解并使用在.NET库中定义的外部枚举。 

让我们再次关注如何接收消息。虽然可以使用任何其他定期调用的函数(如 OnTick ),但该过程涉及计时器。然而,周期性是很难控制的。不确定两个连续的 OnTick 调用之间会经过多少时间。

此外,不可能保证调用的周期性(即使是在 OnTimer 中)。例如,在策略测试器中,OnTimer阈值调用频率与实际工作中可以为此函数设置的频率非常不同。这些效果使用户可以在两个函数调用之间的一行中生成多个事件。例如,在MQL程序有时间对第一次单击作出反应之前,用户可以单击按钮两到三次。

事件队列解决了这个问题,每个事件进入列表,然后等待其参数由MQL程序检索。程序通过在函数中定义静态变量来记住事件的最后一个编号。在下一次运行时,它会接收新到来的事件!这就是for循环具有非标准签名的原因:

//-- 循环会记住事件的最后一个索引,并在下次启动函数时从中开始工作。
for(static int i = 0; i < GuiController::EventsTotal(); i++)

事件也可以使用 GuiController::GetEvent 方法来接收,您也可以通过 GuiController::SendEvent 来发送它们。第二个方法用于将某些数据发送到窗口以更改其内容,它与GetEvent具有相同的原型。唯一的区别是它不包含事件的序列号,因为它在这里没有意义。我们不会详细讨论它,但是,我们将在本文最后一部分的一个示例中展示如何使用它。

最后一个我们还没有研究的方法是 GuiController::HideForm。它的样式与 ShowForm 类似, 而其行为则正好相反:这个方法会隐藏窗口。为此,应指定其位置和名称。

如您所见,用于显示表单和分析传入事件的MQL代码非常紧凑和简单。实际上,代码描述了三个简单的步骤:

  1. 启动程序时显示窗口;
  2. 从窗口接收新数据;
  3. 退出程序时隐藏窗口。

如您所见,结构是尽可能的简单。另外,请注意我们开发的表单的代码。尽管窗口窗体包含相同的代码,但我们还没有用C#编写一行代码,Visual Studio增强的代码自动生成方法以及GuiController为我们提供了所有功能。这就是 .NET 技术的力量是如何体现的,因为强大环境的最终目标是简单。


在GuiController的引擎盖下

如果你不精通C#,你可以跳过这一节。对于那些想了解 GuiController 如何工作以及如何访问单个独立的 .NET 应用程序的人来说,这将是很有趣的。

GuiController是一个共享类,由两部分组成:静态部分和实例部分。类的静态部分包含用于与 MetaTrader 交互的开放静态方法。类的这一部分实现了MetaTrader 5和控制器本身之间的接口。第二部分是实例,这意味着该部分的数据和方法只存在于实例级别。它们的任务是与独立位于图形窗口中的.NET程序集交互。Windows窗体中的图形窗口是从窗体基类继承的类。因此,对于每个用户窗口,您可以在表单类的更高抽象级别上工作。

.NET 程序集(如 DLL 或 EXE)包含固有打开的 .NET 类型,访问它们、它们的属性甚至方法都非常简单。这可以使用名为“反射”的机制来完成。由于这种机制,可以检查在 .NET 中创建的每个文件(如 DLL 或 EXE)是否存在必要的元素。这就是GuiController类所做的。当.NET程序集的绝对路径传递给它时,控制器使用特殊的机制加载该程序集。之后,它会找到需要显示的图形窗口。让我们提供执行工作的 GetGuiController方法代码:

/// <summary>
/// Create GuiController for windows form
/// </summary>
/// <param name="assembly_path">Path to assembly</param>
/// <param name="form_name">Windows Form's name</param>
/// <returns></returns>
private static GuiController GetGuiController(string assembly_path, string form_name)
{
    //-- 载入指定的程序集
    Assembly assembly = Assembly.LoadFile(assembly_path);
    //-- 从中找到指定的表单
    Form form = FindForm(assembly, form_name);
    //-- 将管理控制器分配给检测到的窗体
    GuiController controller = new GuiController(assembly, form, m_global_events);
    //-- 将管理控制器返回到调用方法
    return controller;
}

这个过程类似于所谓的资源抓取器:一个特殊的程序,允许从程序的二进制代码中提取媒体内容,如图标和图像。

通过使用反射来搜索表单。FindForm方法接收传递给它的程序集中定义的所有类型。在这些类型中,它搜索基类型与表单类型匹配的类型。如果检测到的类型的名称也与所需类型匹配,则会创建此类型的实例,该实例将作为表单返回:

/// <summary>
/// 寻找所需的表单
/// </summary>
/// <param name="assembly">Assembly</param>
/// <returns></returns>
private static Form FindForm(Assembly assembly, string form_name)
{
    Type[] types = assembly.GetTypes();
    foreach (Type type in types)
    {
        //assembly.CreateInstance()
        if (type.BaseType == typeof(Form) && type.Name == form_name)
        {
            object obj_form = type.Assembly.CreateInstance(type.FullName);
            return (Form)obj_form;
        }
    }
    throw new Exception("Form with name " + form_name + " in assembly " + assembly.FullName + "  not find");
}

最令人兴奋的时刻是应用程序本身的开发和启动。毕竟,真正的程序是从外部二进制数据集合中产生的,并开始作为独立的应用程序工作。

创建实例后,会为其分配一个控制器,控制器是 GuiController 类的一个实例,它监视提交给它的表单。控制器的目标包括跟踪事件并将其传递给表单,

表单在并行线程中启动和删除。这可以防止当前线程在等待当前操作完成时被阻塞。假设我们已经在当前线程中启动了窗口,当窗口工作时,调用它的外部进程挂起,等待窗口关闭。在单独的线程中启动窗口可以解决这个问题。

相应的控制器方法负责启动和删除窗口:

/// <summary>
/// 从MetaTrader调用的自定义表单应异步运行
/// 以确保界面保持响应。
/// </summary>
public static void ShowForm(string assembly_path, string form_name)
{
    try
    {
        GuiController controller = GetGuiController(assembly_path, form_name);
        string full_path = assembly_path + "/" + form_name;
        m_controllers.Add(full_path, controller);
        controller.RunForm();
    }
    catch(Exception e)
    {
        SendExceptionEvent(e);
    }
}
        
/// <summary>
/// EA处理完表格后,应完成其执行。
/// </summary>
public static void HideForm(string assembly_path, string form_name)
{
    try
    {
        string full_path = assembly_path + "/" + form_name;
        if (!m_controllers.ContainsKey(full_path))
            return;
        GuiController controller = m_controllers[full_path];
        controller.DisposeForm();
    }
    catch(Exception ex)
    {
        SendExceptionEvent(ex);
    }
}

我们应该考虑的最后一个与控制器相关的事情是处理事件。使用反射创建新表单时,它将传递给订阅其事件的方法,或者只传递给控制器可以处理的方法。为此会创建<元素 - 时间处理器列表> 映射. 在这个映射中,事件处理程序订阅了必要的事件: 

/// <summary>
/// 订阅支持的事件
/// </summary>
/// <param name="form">Windows form</param>
private void SubscribeOnElements(Form form)
{
    Dictionary<Type, List<HandlerControl>> types_and_events = new Dictionary<Type, List<HandlerControl>>();
    types_and_events.Add(typeof(VScrollBar), new List<HandlerControl>() { vscrol => ((VScrollBar)vscrol).Scroll += OnScroll });
    types_and_events.Add(typeof(Button), new List<HandlerControl>()  { button => ((Button)button).Click += OnClick });
    types_and_events.Add(typeof(Label), new List<HandlerControl>());
    types_and_events.Add(typeof(TextBox), new List<HandlerControl>() { text_box => text_box.LostFocus += OnLostFocus, text_box => text_box.KeyDown += OnKeyDown });
    foreach (Control control in form.Controls)
    {
        if (types_and_events.ContainsKey(control.GetType()))
        {
            types_and_events[control.GetType()].ForEach(el => el.Invoke(control));
            m_controls.Add(control.Name, control);
        }
    }
}

每个表单都有一个包含元素的开放列表,在搜索元素列表时,该方法会找到控制器能够支持并订阅所需事件的元素。如果控制器不支持窗体上的元素,则只会忽略它。与之关联的事件不会传递到MQL程序,并且MQL程序本身无法与此元素交互。


基于 GUI 的交易面板

既然我们已经涵盖了系统的所有部分,现在是时候创建一些真正有用的东西了。我们将从图表左上角模拟标准交易面板:

图 15. MetaTrader 5 内建交易面板

当然,我们的面板将由Windows OS窗口的标准图形元素组成,因此它将具有更简单的设计,而功能将保持不变。

我们可以从头开始开发这样的面板。但是,可视化设计器的描述超出了文章主题的边界。因此,让我们简单地将包含面板的项目上载到Visual Studio。这可以通过两种方式完成:从存档中复制项目并在Visual Studio中打开它,或者从远程Git存储库中按以下地址下载它: 

https://github.com/PublicMqlProjects/TradePanelForm

在这种情况下,使用Git与相应部分中描述的一样,所以我们不要再详细讨论这个问题。

下载并打开项目后,您将看到以下表单:

图 16. Visual Studio 编辑器中的 TradePanel 窗口

该项目包含交易面板的布局,在这样的实际项目中,我们需要不断地访问放置在表单上的元素,并向它们发送事件。为了达到这些目的,需要通过每个元素的名称来引用它。因此,元素的名称应该是有意义和令人难忘的。让我们看看如何调用要使用的元素。要查看每个元素的名称,请在“属性”窗口中查找“名称”属性,同时首先选择必要的元素。例如,标记为“购买”的按钮的名称为ButtonBuy:

图 17. 属性窗口中的元素名称

必须区分元素上描述的文本和元素本身的名称。这些是不同的值,尽管它们通常具有相似的含义。

这里是我们交易面板包含的元素列表:

虽然只有几个元素,但它们的组合提供了一个相当高级的界面。与前面的示例一样,我们的解决方案不包含一行C#代码。所有必需的元素属性都显示在属性窗口中,并且元素的位置和大小是使用拖放(即鼠标)设置的!


将图形窗口与EA代码集成

现在我们的窗口已经准备好了,它需要集成到一个交易EA中。我们将使用MQL编写交易逻辑,即与接口元素交互。完整的EA代码如下:

//+------------------------------------------------------------------+
//|                                                   TradePanel.mq5 |
//|                        Copyright 2019, MetaQuotes Software Corp. |
//|                                              http://www.mql5.com |
//+------------------------------------------------------------------+
#property copyright "Copyright 2019, MetaQuotes Software Corp."
#property link      "http://www.mql5.com"
#property version   "1.00"
#import  "MtGuiController.dll"
#include <Trade\Trade.mqh>
string assembly = "c:\\Users\\Bazil\\source\\repos\\TradePanel\\TradePanel\\bin\\Debug\\TradePanel.dll";
string FormName = "TradePanelForm";
double current_volume = 0.0;

//-- Trade module for executing orders
CTrade Trade;  
//+------------------------------------------------------------------+
//| EA 交易初始化函数                                                  |
//+------------------------------------------------------------------+
int OnInit()
{
//--- 创建计时器,显示窗口以及设置交易量
   EventSetMillisecondTimer(200);
   GuiController::ShowForm(assembly, FormName);
   current_volume = SymbolInfoDouble(Symbol(), SYMBOL_VOLUME_MIN);
   GuiController::SendEvent("CurrentVolume", TextChange, 0, 0.0, DoubleToString(current_volume, 2));
//---
   return(INIT_SUCCEEDED);
}
//+------------------------------------------------------------------+
//| EA 交易去初始化函数                                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
//--- 清理表单
   EventKillTimer();
   GuiController::HideForm(assembly, FormName);
//---
  }
//+------------------------------------------------------------------+
//| EA交易分时函数                                                     |
//+------------------------------------------------------------------+
void OnTick()
{
//--- 刷新买家卖家报价   
   double ask = SymbolInfoDouble(Symbol(), SYMBOL_ASK);
   double bid = SymbolInfoDouble(Symbol(), SYMBOL_BID);
   GuiController::SendEvent("AskLabel", TextChange, 0, 0.0, DoubleToString(ask, Digits()));
   GuiController::SendEvent("BidLabel", TextChange, 0, 0.0, DoubleToString(bid, Digits()));
//---
}

//+------------------------------------------------------------------+
//| 计时器函数                                                         |
//+------------------------------------------------------------------+
void OnTimer()
{
//--- 通过计时器取得新事件
   for(static int i = 0; i < GuiController::EventsTotal(); i++)
   {
      int id;
      string el_name;
      long lparam;
      double dparam;
      string sparam;
      GuiController::GetEvent(i, el_name, id, lparam, dparam, sparam);
      if(id == TextChange && el_name == "CurrentVolume")
         TrySetNewVolume(sparam);
      else if(id == ScrollChange && el_name == "IncrementVol")
         OnIncrementVolume(lparam, dparam, sparam);
      else if(id == ClickOnElement)
         TryTradeOnClick(el_name);
   }
//---
}
//+------------------------------------------------------------------+
//| 验证交易量                                                         |
//+------------------------------------------------------------------+
double ValidateVolume(double n_vol)
{
   double min_vol = SymbolInfoDouble(Symbol(), SYMBOL_VOLUME_MIN);
   double max_vol = SymbolInfoDouble(Symbol(), SYMBOL_VOLUME_MAX);
   //-- 检查最小限制 
   if(n_vol < min_vol)
      return min_vol;
   //-- 检查最大限制
   if(n_vol > max_vol)
      return max_vol;
   //-- 规范化交易量
   double vol_step = SymbolInfoDouble(Symbol(), SYMBOL_VOLUME_STEP);
   double steps = MathRound(n_vol / vol_step);
   double corr_vol = NormalizeDouble(vol_step * steps, 2);
   return corr_vol;
}
//+------------------------------------------------------------------+
//| 从给定的文字设置当前交易量                                           |
//+------------------------------------------------------------------+
bool TrySetNewVolume(string nstr_vol)
{
   double n_vol = StringToDouble(nstr_vol);
   current_volume = ValidateVolume(n_vol);
   string corr_vol = DoubleToString(current_volume, 2);
   GuiController::SendEvent("CurrentVolume", TextChange, 0, 0.0, corr_vol);
   return true;
}
//+------------------------------------------------------------------+
//| 执行交易订单                                                       |
//+------------------------------------------------------------------+
bool TryTradeOnClick(string el_name)
{
   if(el_name == "ButtonBuy")
      return Trade.Buy(current_volume);
   if(el_name == "ButtonSell")
      return Trade.Sell(current_volume);
   return false;
}
//+------------------------------------------------------------------+
//| 增加或者减小当前交易量                                               |
//+------------------------------------------------------------------+
void OnIncrementVolume(long lparam, double dparam, string sparam)
{
   double vol_step = 0.0;
   //-- 侦测按下增加
   if(dparam > lparam)
      vol_step = (-1.0) * SymbolInfoDouble(Symbol(), SYMBOL_VOLUME_STEP);
   //-- 侦测按下减少
   else if(dparam < lparam)
      vol_step = SymbolInfoDouble(Symbol(), SYMBOL_VOLUME_STEP);
   //-- 侦测再次按下增加
   else if(lparam == 0)
      vol_step = SymbolInfoDouble(Symbol(), SYMBOL_VOLUME_STEP);
   //-- 侦测再次按下减少
   else
      vol_step = (-1.0) * SymbolInfoDouble(Symbol(), SYMBOL_VOLUME_STEP);
   double n_vol = current_volume + vol_step;
   current_volume = ValidateVolume(n_vol);
   string nstr_vol = DoubleToString(current_volume, 2);
   GuiController::SendEvent("CurrentVolume", TextChange, 0, 0.0, nstr_vol);
}
//+------------------------------------------------------------------+

所呈现的代码是我们表单的工作核心。值得注意的是,整个功能都是在标准事件处理函数内用MQL5编写的。让我们详细分析提供的代码。

OnInit 函数的第一行所作的就是设定精度为200毫秒的计时器,随后使用ShowForm方法显示窗口:

GuiController::ShowForm(assembly, FormName);

其中“assembly”是窗口所在的程序集的路径,而 FormName 是窗体类的名称。

启动窗口后,我们立即在当前交易量文本框中设置最小交易量:

GuiController::SendEvent("CurrentVolume", TextChange, 0, 0.0, DoubleToString(current_volume, 2));

最小成交量本身是根据当前的交易环境使用 SymbolInforDouble函数计算的。

关闭EA时,窗体窗口也将关闭。这是在 OnDeinit函数中使用 GuiController::Hideform方法完成的。 

OnTick函数对更改当前的买卖价格做出反应。因此,如果我们在函数中收到当前价格并将其传递给表单的相应文本标签,面板将立即显示当前价格的所有更改。

//-- 取得卖家报价
double ask = SymbolInfoDouble(Symbol(), SYMBOL_ASK);
//-- 取得买家报价
double bid = SymbolInfoDouble(Symbol(), SYMBOL_BID);
//-- 把卖家报价转换为字符串再替换 AskLabel 文本标签:
GuiController::SendEvent("AskLabel", TextChange, 0, 0.0, DoubleToString(ask, Digits()));
//-- 把买家报价转换为字符串再替换 BidLabel 文本标签:
GuiController::SendEvent("BidLabel", TextChange, 0, 0.0, DoubleToString(bid, Digits()));

在 OnTimer函数中跟踪用户可以使用表单执行的三个操作。这三个操作包括:

  • 在 CurrentVolume 文本标签中输入新的交易量;
  • 点击分步增加或者减少交易量的按钮;
  • 点击买入或者卖出按钮而发送交易请求。

根据用户执行的操作,执行一组特定的指令。我们还没有分析单击滚动按钮将当前交易量增加/减少最小允许步骤的事件,因此让我们更详细地讨论一下。

在当前的事件模型中,滚动事件包含了两个参数: lparam 和 dparam,第一个参数包含一个常规值,该值表示在用户单击滚动按钮之前相对于零级别的移位。第二个参数在单击后包含相同的值。滚动本身具有一定的操作范围,例如从0到100。因此,如果lparam是30,而dparam是50,这意味着垂直滚动从30向下移动到50%(垂直滚动以相同的数量向右移动)。不需要在面板中定义滚动位置,我们只需要知道用户点击了哪个按钮。要做到这一点,我们应该分析以前和现在的值。OnIncrementVolume 函数就是为此提供的,在定义了滚动点击类型之后,它通过使用SystemInfoDouble系统函数定义的最小交易量步长来增加或减少当前交易量。

滚动箭头不是设置新交易量的唯一方法,您可以直接在文字标签中输入它。当用户输入新字符时,Windows窗体会生成相应的事件。但是,对我们来说分析最后一个字符串,而不是每个字符是很重要的。因此,GuiController 响应按“回车”键或更改文本标签焦点。这些事件被视为文本输入的结尾。当其中一个发生时,生成的文本被传递到事件队列,由EA按顺序读取。在到达标签事件中的文本更改后,MQL程序解析其新值,并根据指定的值设置新交易量。分析是使用 ValidateVolume 函数进行的,它控制输入交易量的以下参数:

  • 交易量应在最小和最大允许值之间;
  • 交易量的值应该是其步长的倍数。例如,如果步长为0.01手,并且用户输入值1.0234,则将其调整为1.02。

请注意,只有在当前交易环境的帮助下才能控制这些参数。因此,用户输入值的整个控制由MQL程序本身执行,而不是由用户创建的表单。 

让我们在图表上启动交易面板,并尝试执行几个交易:


图 18. 实时面板操作 

如您所见,交易面板成功地完成了分配给它的所有功能。


策略测试器中的GUI操作

MetaTrader 5 策略测试器具有许多特性,MQL GUI开发人员应该考虑这些特性。主要原因是根本不调用 OnChartEvent图形事件处理函数。此功能是合乎逻辑的,因为图形表单涉及实时与用户一起工作。然而,在测试器中实现某种类型的面板将是非常有趣的。这些是所谓的“交易播放器”,允许用户手动测试他们的交易策略。例如,策略测试器在快进中生成当前市场价格,而用户单击买入和卖出在历史中模拟交易操作。我们开发的 TradePanel 就是这种类型的面板。尽管它很简单,但它很可能是一个拥有最必要功能的普通交易播放器。 

但让我们来考虑一下我们的面板将如何在 MetaTrader 5 策略测试器中工作。TradePanel 的图形窗口作为独立的.NET程序集存在,因此,它不依赖于当前的 MetaTrader 5 环境,甚至终端本身。严格来说,它可以从任何其他程序运行,而即使用户自己也可以启动位于exe容器中的程序集。

因此,我们的程序不需要调用 OnChartEvent,此外,还可以在策略测试器中定期启动的任何事件处理功能中更新窗口中的数据并接收用户的新订单。OnTick 和 OnTimer 就是这样的函数,面板通过它们来工作。因此,尽管我们的面板是为实时操作而设计的,但是我们的面板在策略测试器中也能很好地工作,无需更改。让我们通过在测试器中启动面板并执行几个交易来检查此声明:


图 19. 策略测试器中模拟模式下的面板操作

事实证明,在策略测试器中工作时,使用 C# 开发图形界面为我们提供了意外的好处,对于Windows窗体应用程序,策略测试器不施加任何限制。事件模型操作功能既不影响面板,也不影响使用它们的方式,也不需要更改程序来在策略测试器中工作。 


结论

本文提出了一种允许用户快速、方便地开发自定义可视化表单的方法,这种方法将图形应用程序分为三个独立的部分:MQL程序、GUI控制器适配器和可视面板本身。应用程序的所有部分彼此独立。MQL 程序在 MetaTrader 交易环境中工作,并根据其通过 GuiController 从面板接收的参数执行交易或分析功能。GuiController本身是一个独立的程序,在更改窗体或其元素时不需要更改。最后,图形面板由用户自己使用Visual Studio的高级可视化工具创建。正因为如此,即使在开发一个相当复杂的表单时,C#编程语言的知识可能也不必要。

自定义窗体本身并不依赖于启动它们的程序,它可能是 MetaTrader 5 本身或是它的策略测试器,在这两种情况下,窗口都按照嵌入式逻辑工作。此外,窗口不依赖于调用它的函数。因此,无论是在MetaTrader 5本身还是在其策略测试器中,图形界面都能很好地工作,并且EA或指标是否与窗口一起工作也不重要。在所有情况下,窗口行为都是相同的。

考虑到上述特点,所提出的方法一定会找到它的粉丝。它可能在那些愿意开发半自动表单的人中最受欢迎:交易引擎或播放器、数据面板或任何其他标准图形用户界面形式的可视化表单。这种方法也将吸引那些不精通编程的人。您只需要掌握MQL5的一般知识就可以开发自定义表单。 

与任何技术一样,该方法也有其缺点。主要的一点是在应用市场上不可能工作,因为禁止调用第三方DLL。此外,启动不熟悉的 DLL 或 EXE 可能不安全,因为这些模块可能包含恶意函数。然而,项目的开放性解决了这个问题。用户知道他们开发的程序不包含他们指定的任何其他元素,而 GuiController 是一个公开的开源项目。另一个缺点是应用程序间交互是一个相当复杂的过程,它可能导致冻结或意外的程序终止,很大程度上取决于这里的界面开发人员。与在纯MQL5中开发的单片系统相比,更容易放弃这样的系统。

该项目目前尚处于起步阶段,也许,您在这里没有找到必要的控件,但是与图形窗口交互的当前功能仍然非常有限。这一切都是真的,然而,本文已经完成了它的主要任务,我们已经证明,开发Windows窗体和与之交互比看起来容易。如果本文对MQL社区有用,那么我们一定会继续在这方面的工作上进行构建。