Skip to content

图表上的交互式事件

MetaTrader 5 图表不仅提供数据的可视化呈现,并且是 MQL 程序的执行环境,同时还支持交互式事件机制,这使得程序能够响应用户和其他程序的操作。这是通过一种特殊的事件类型——OnChartEvent 来实现的,我们在“事件处理函数概述”中已经讨论过这个事件。

任何指标或智能交易系统都可以接收此类事件,前提是在代码中定义了具有预定义签名的同名事件处理函数。在我们之前讨论的一些指标示例中,我们已经利用过这个功能。在本章中,我们将详细研究事件系统。

OnChartEvent 事件是由客户端终端在用户执行以下图表操作时生成的:

  • 更改图表大小或设置
  • 当图表窗口处于焦点时的按键操作
  • 鼠标光标移动
  • 在图表上的鼠标点击
  • 在图形对象上的鼠标点击
  • 创建图形对象
  • 删除图形对象
  • 用鼠标移动图形对象
  • 完成对 OBJ_EDIT 对象输入字段中测试内容的编辑

MQL 程序仅从其正在运行的图表接收上述事件。与其他事件类型一样,它们会被添加到一个队列中。然后所有事件将按照到达的顺序依次处理。如果 MQL 程序队列中已经存在特定类型的 OnChartEvent 事件,或者该事件正在被处理,那么相同类型的新事件将不会被加入队列(会被丢弃)。

有些事件类型始终处于活动状态,而其他事件默认是禁用的,必须通过使用 ChartSetInteger 调用设置相应的图表属性来显式启用。这些被禁用的事件尤其包括鼠标移动和鼠标滚轮滚动。它们的特点是都可能生成大量的事件流,为了节省资源,建议仅在必要时启用它们。

除了标准事件外,还有“自定义事件”的概念。此类事件的参数含义和内容由 MQL 程序自身指定和解释(如果涉及多个程序的交互,则由一个或多个程序来完成)。MQL 程序可以使用 EventChartCustom 函数向图表(包括其他图表)发送“用户事件”。此类事件也由 OnChartEvent 函数处理。

如果图表上有多个带有 OnChartEvent 处理函数的 MQL 程序,它们都将接收到相同的事件流。

所有 MQL 程序都在应用程序主线程之外的线程中运行。终端的主线程负责处理所有 Windows 系统消息,并且在处理过程中,它又会为其自身的应用程序生成 Windows 消息。例如,用鼠标拖动图表会生成几个 WM_MOUSE_MOVE 系统消息(从 Windows API 的角度来看),用于后续绘制应用程序窗口,同时也会向在此图表上运行的智能交易系统和指标发送内部消息。在这种情况下,可能会出现这样的情况:应用程序的主线程尚未处理关于重绘 WM_PAINT 窗口的系统消息(因此尚未改变图表的外观),而智能交易系统或指标已经接收到关于鼠标光标移动的事件。那么只有在图表绘制完成后,图表属性 CHART_FIRST_VISIBLE_BAR 才会改变。

由于在两种类型的交互式 MQL 程序中,到目前为止我们仅研究了指标,所以本章中的所有示例都将基于指标来构建。第二种类型,即智能交易系统,将在本书的下一部分进行描述。不过,在智能交易系统中处理事件的原则与这里介绍的完全一致。

图表事件处理函数 OnChartEvent

如果指标或智能交易系统的代码中包含具有以下原型的 OnChartEvent 函数,那么它们就可以从终端接收交互式事件。

c
void OnChartEvent(const int event, const long &lparam, const double &dparam, const string &sparam)

终端会调用此函数来响应用户操作,或者在使用 EventChartCustom 生成 “用户事件” 的情况下调用。

event 参数中,会传递事件标识符(事件类型),它是 ENUM_CHART_EVENT 枚举的取值之一(见下表)。

标识符描述
CHARTEVENT_KEYDOWN键盘操作
CHARTEVENT_MOUSE_MOVE鼠标移动以及鼠标按键点击(如果为图表设置了 CHART_EVENT_MOUSE_MOVE 属性)
CHARTEVENT_MOUSE_WHEEL鼠标滚轮点击或滚动(如果为图表设置了 CHART_EVENT_MOUSE_WHEEL 属性)
CHARTEVENT_CLICK在图表上的鼠标点击
CHARTEVENT_OBJECT_CREATE创建图形对象(如果为图表设置了 CHART_EVENT_OBJECT_CREATE 属性)
CHARTEVENT_OBJECT_CHANGE通过属性对话框修改图形对象
CHARTEVENT_OBJECT_DELETE删除图形对象(如果为图表设置了 CHART_EVENT_OBJECT_DELETE 属性)
CHARTEVENT_OBJECT_CLICK鼠标点击图形对象
CHARTEVENT_OBJECT_DRAG拖动图形对象
CHARTEVENT_OBJECT_ENDEDIT在 “输入字段” 图形对象中完成文本编辑
CHARTEVENT_CHART_CHANGE更改图表尺寸或属性(通过属性对话框、工具栏或上下文菜单)
CHARTEVENT_CUSTOM自定义事件范围的起始事件编号
CHARTEVENT_CUSTOM_LAST自定义事件范围的结束事件编号

lparamdparamsparam 参数的使用方式取决于事件类型。一般来说,可以说它们包含了处理特定事件所需的额外数据。以下各节将针对每种类型提供详细信息。

注意! OnChartEvent 函数仅对直接绘制在图表上的指标和智能交易系统调用。如果任何指标是使用 iCustomIndicatorCreate 以编程方式创建的,OnChartEvent 事件将不会传递给它。

此外,即使在可视化模式下,OnChartEvent 处理程序也不会在测试器中被调用。

为了首次演示 OnChartEvent 处理程序,让我们考虑一个无缓冲指标 EventAll.mq5,它会拦截并记录所有事件。

c
void OnChartEvent(const int id,
   const long &lparam, const double &dparam, const string &sparam)
{
   ENUM_CHART_EVENT evt = (ENUM_CHART_EVENT)id;
   PrintFormat("%s %lld %f '%s'", EnumToString(evt), lparam, dparam, sparam);
}

默认情况下,除了四种大量事件类型外,图表上可以生成所有类型的事件。如上表所示,这四种大量事件类型需要通过图表的特殊属性来启用。在下一节中,我们将为该指标添加设置,以便根据偏好包含特定的事件类型。

在已有对象的图表上运行该指标,或者在指标运行时创建对象。

更改图表的大小或设置,进行鼠标点击,并编辑对象的属性。日志中会出现以下记录。

CHARTEVENT_CHART_CHANGE 0 0.000000 ''
CHARTEVENT_CLICK 149 144.000000 ''
CHARTEVENT_OBJECT_CLICK 112 105.000000 'Daily Rectangle 53404'
CHARTEVENT_CLICK 112 105.000000 ''
CHARTEVENT_KEYDOWN 46 1.000000 '339'
CHARTEVENT_CLICK 13 252.000000 ''
CHARTEVENT_OBJECT_DRAG 0 0.000000 'Daily Button 61349'
CHARTEVENT_OBJECT_CLICK 145 104.000000 'Daily Button 61349'
CHARTEVENT_CLICK 145 104.000000 ''
CHARTEVENT_CHART_CHANGE 0 0.000000 ''
CHARTEVENT_OBJECT_DRAG 0 0.000000 'Daily Vertical Line 22641'
CHARTEVENT_OBJECT_DRAG 0 0.000000 'Daily Vertical Line 22641'
CHARTEVENT_OBJECT_CLICK 177 206.000000 'Daily Vertical Line 22641'
CHARTEVENT_CLICK 177 206.000000 ''
CHARTEVENT_OBJECT_CHANGE 0 0.000000 'Daily Rectangle 37930'
CHARTEVENT_CHART_CHANGE 0 0.000000 ''
CHARTEVENT_CLICK 152 118.000000 ''

在这里,我们看到了各种类型的事件,在阅读以下各节后,它们参数的含义就会变得清晰。

与事件相关的图表属性

有四种类型的事件能够产生大量消息,因此默认情况下是禁用的。若要在之后启用或禁用它们,可以使用 ChartSetInteger 函数来设置相应的图表属性。所有这些属性都是布尔类型:true 表示启用,false 表示禁用。

标识符描述
CHART_EVENT_MOUSE_WHEEL向图表发送关于鼠标滚轮事件的 CHARTEVENT_MOUSE_WHEEL 消息
CHART_EVENT_MOUSE_MOVE向图表发送关于鼠标移动的 CHARTEVENT_MOUSE_MOVE 消息
CHART_EVENT_OBJECT_CREATE向图表发送关于图形对象创建的 CHARTEVENT_OBJECT_CREATE 消息
CHART_EVENT_OBJECT_DELETE向图表发送关于图形对象删除的 CHARTEVENT_OBJECT_DELETE 消息

如果任何 MQL 程序更改了这些属性中的一个,它将影响在同一图表上运行的所有其他程序,并且即使原始程序终止后,该更改仍然有效。

默认情况下,所有这些属性的值都为 false

让我们在前一节的 EventAll.mq5 指标中补充四个输入变量,这些变量允许启用这些类型的事件中的任何一种(除了其他那些无法禁用的事件之外)。此外,我们将定义四个辅助变量,以便在删除该指标后能够恢复图表的设置。

c
input bool ShowMouseMove = false;
input bool ShowMouseWheel = false;
input bool ShowObjectCreate = false;
input bool ShowObjectDelete = false;
   
bool mouseMove, mouseWheel, objectCreate, objectDelete;

在启动时,记住属性的当前值,然后应用用户选择的设置。

c
void OnInit()
{
   mouseMove = PRTF(ChartGetInteger(0, CHART_EVENT_MOUSE_MOVE));
   mouseWheel = PRTF(ChartGetInteger(0, CHART_EVENT_MOUSE_WHEEL));
   objectCreate = PRTF(ChartGetInteger(0, CHART_EVENT_OBJECT_CREATE));
   objectDelete = PRTF(ChartGetInteger(0, CHART_EVENT_OBJECT_DELETE));
   
   ChartSetInteger(0, CHART_EVENT_MOUSE_MOVE, ShowMouseMove);
   ChartSetInteger(0, CHART_EVENT_MOUSE_WHEEL, ShowMouseWheel);
   ChartSetInteger(0, CHART_EVENT_OBJECT_CREATE, ShowObjectCreate);
   ChartSetInteger(0, CHART_EVENT_OBJECT_DELETE, ShowObjectDelete);
}

OnDeinit 处理程序中恢复属性。

c
void OnDeinit(const int)
{
   ChartSetInteger(0, CHART_EVENT_MOUSE_MOVE, mouseMove);
   ChartSetInteger(0, CHART_EVENT_MOUSE_WHEEL, mouseWheel);
   ChartSetInteger(0, CHART_EVENT_OBJECT_CREATE, objectCreate);
   ChartSetInteger(0, CHART_EVENT_OBJECT_DELETE, objectDelete);
}

在启用新事件类型的情况下运行该指标。要准备好接收大量的鼠标移动消息。以下是日志的一个片段:

CHARTEVENT_MOUSE_WHEEL 5308557 -120.000000 ''
CHARTEVENT_CHART_CHANGE 0 0.000000 ''
CHARTEVENT_MOUSE_WHEEL 5308557 -120.000000 ''
CHARTEVENT_CHART_CHANGE 0 0.000000 ''
CHARTEVENT_MOUSE_MOVE 141 81.000000 '2'
CHARTEVENT_MOUSE_MOVE 141 81.000000 '0'
...
CHARTEVENT_OBJECT_CREATE 0 0.000000 'Daily Rectangle 37664'
CHARTEVENT_MOUSE_MOVE 323 146.000000 '0'
CHARTEVENT_MOUSE_MOVE 322 146.000000 '0'
CHARTEVENT_MOUSE_MOVE 321 146.000000 '0'
CHARTEVENT_MOUSE_MOVE 320 146.000000 '0'
CHARTEVENT_MOUSE_MOVE 318 146.000000 '0'
CHARTEVENT_MOUSE_MOVE 316 146.000000 '0'
CHARTEVENT_MOUSE_MOVE 314 146.000000 '0'
CHARTEVENT_MOUSE_MOVE 314 145.000000 '0'
...
CHARTEVENT_OBJECT_DELETE 0 0.000000 'Daily Rectangle 37664'
CHARTEVENT_KEYDOWN 46 1.000000 '339

我们将在下面的相关部分中揭示每种类型事件的信息细节。

图表更改事件

当更改图表大小、价格显示模式、比例或其他参数时,终端会发送 CHARTEVENT_CHART_CHANGE 事件,该事件没有参数。MQL 程序必须通过调用 ChartGet 函数自行查明这些更改。

我们已经在“图表显示模式”部分的 ChartModeMonitor.mq5 示例中使用过这个事件。现在让我们看另一个示例。

如你所知,MetaTrader 5 允许将当前图表的屏幕截图保存为指定大小的文件(上下文菜单中的“另存为图片”命令)。然而,这种获取屏幕截图的方法并不适用于所有情况。特别是,如果你需要带有工具提示的图像,或者当输入字段类型的对象处于活动状态时(当字段内的文本被选中且文本光标可见时),标准命令将不起作用,因为它会重新生成图表图像,而不考虑窗口当前状态的这些以及其他一些细微差别。

获取窗口精确副本的唯一替代方法是使用终端外部的工具(例如,通过 Windows 剪贴板使用 PrtSc 键),但这种方法不能保证所需的窗口大小。为了避免通过反复试验或使用一些额外程序来选择大小,我们将创建一个指标 EventWindowSizer.mq5,它将实时跟踪用户的大小设置,并在注释中输出当前值。

所有工作都在 OnChartEvent 处理函数中完成,首先检查事件 ID 是否为 CHARTEVENT_CHART_CHANGE。窗口的像素尺寸可以使用 CHART_WIDTH_IN_PIXELSCHART_HEIGHT_IN_PIXELS 属性获取。然而,它们返回的尺寸没有考虑边框,而在截图时通常是需要边框的。因此,我们将在注释中不仅显示属性值(标记为“Screen”),还会显示修正后的值(标记为“Picture”):宽度应增加 2 个像素,垂直方向增加 1 个像素(这是终端中窗口渲染的特点)。

c
void OnChartEvent(const int id, const long &lparam, const double &dparam, const string &sparam)
{
   if(id == CHARTEVENT_CHART_CHANGE)
   {
      const int w = (int)ChartGetInteger(0, CHART_WIDTH_IN_PIXELS);
      const int h = (int)ChartGetInteger(0, CHART_HEIGHT_IN_PIXELS);
      // “原始”尺寸按原样显示,标记为“Screen”,
      // 需要对(-2,-1)进行修正以包含边框 - 显示时标记为“Picture”,
      // 需要对(-54,-22)进行修正以包含刻度 - 显示时带有“Including scales”标志。
      Comment(StringFormat("Screen: %d x %d\nPicture: %d x %d\nIncluding scales: %d x %d",
         w, h, w + 2, h + 1, w + 2 + 54, h + 1 + 22));
   }
}

此外,获取的值没有考虑时间和价格刻度。如果在屏幕截图的大小中也应考虑这些刻度,那么也应该对它们的大小进行调整。不幸的是,MQL5 API 没有提供一种方法来查明这些大小,所以我们只能凭经验确定:对于标准的 Windows 字体设置,价格刻度宽度为 54 像素,时间刻度高度为 22 像素。这些常量对于你的 Windows 版本可能会有所不同,所以你应该编辑它们,或者使用输入参数来设置它们。

在图表上运行该指标后,尝试调整窗口大小,看看注释中的数字将如何变化。

键盘事件

MQL 程序可以通过在 OnChartEvent 函数中处理 CHARTEVENT_KEYDOWN 事件,从终端接收按键消息。

需要注意的是,只有在活动图表且该图表具有输入焦点时,才会生成事件。

在 Windows 系统中,焦点是指用户当前正在与之交互的特定窗口的逻辑和视觉选择。通常,焦点可通过鼠标点击或特殊键盘快捷键(如 Tab、Ctrl + Tab)来移动,使被选中的窗口突出显示。例如,输入字段中会出现文本光标,列表中的当前行将以另一种颜色显示,等等。

在终端中也能注意到类似的视觉效果,特别是当市场报价、数据窗口或专家日志窗口获得焦点时。不过,图表窗口的情况有所不同。有时很难从外观上判断前台可见的图表是否具有输入焦点。可以通过以下方式保证切换焦点:如前文所述,点击所需图表(是点击图表本身,而非窗口标题或边框),或者使用热键:

  • Alt + W 会弹出一个包含图表列表的窗口,可从中选择一个图表。
  • Ctrl + F6 切换到下一个图表(在窗口列表中,顺序通常与标签顺序一致)。
  • Crtl + Shift + F6 切换到上一个图表。

MetaTrader 5 热键的完整列表可在文档中找到。请注意,有些组合键不符合 Microsoft 的一般建议(例如,F10 打开报价窗口,而非激活主菜单)。

CHARTEVENT_KEYDOWN 事件参数包含以下信息:

  • lparam:按下的键的代码。
  • dparam:按键在被按住期间产生的击键次数。
  • sparam:描述键盘按键状态的位掩码,已转换为字符串。
描述
0 - 7键扫描码(取决于硬件、OEM)
8扩展键盘键属性
9 - 12供 Windows 服务使用(请勿使用)
13按键 Alt 的状态(1 - 按下,0 - 释放),实际不可用(见下文)
14按键的上一个状态(1 - 按下,0 - 释放)
15按键状态的改变(释放为 1,按下为 0)

实际上,Alt 键的状态不可用,因为它会被终端拦截,该位始终为 0。由于此事件的触发上下文,位 15 也始终为 0:只有按键按下事件会传递给 MQL 程序,而不包括按键释放事件。

扩展键盘属性(位 8)会在某些情况下被设置,例如数字小键盘的键(在笔记本电脑上通常通过 Fn 激活)、NumLock、ScrollLock、右 Ctrl(与左主 Ctrl 不同)等。更多相关信息可在 Windows 文档中查看。

首次按下任何非系统键时,位 14 为 0。如果持续按住该键,后续自动生成的重复事件中该位将为 1。

以下结构体有助于确保位描述的正确性:

c
struct KeyState
{
    uchar scancode;
    bool extended;
    bool altPressed;
    bool previousState;
    bool transitionState;

    KeyState() { }
    KeyState(const ushort keymask)
    {
        this = keymask; // 使用重载的赋值运算符
    }
    void operator=(const ushort keymask)
    {
        scancode = (uchar)(0xFF & keymask);
        extended = 0x100 & keymask;
        altPressed = 0x2000 & keymask;
        previousState = 0x4000 & keymask;
        transitionState = 0x8000 & keymask;
    }
};

在 MQL 程序中可以这样使用:

c
void OnChartEvent(const int id,
                  const long &lparam,
                  const double &dparam,
                  const string &sparam)
{
    if(id == CHARTEVENT_KEYDOWN)
    {
        PrintFormat("%lld %lld %4llX", lparam, (ulong)dparam, (ushort)sparam);
        KeyState state[1];
        state[0] = (ushort)sparam;
        ArrayPrint(state);
    }
}

出于实际应用考虑,使用宏从键掩码中提取位属性会更方便:

c
#define KEY_SCANCODE(SPARAM) ((uchar)(((ushort)SPARAM) & 0xFF))
#define KEY_EXTENDED(SPARAM) ((bool)(((ushort)SPARAM) & 0x100))
#define KEY_PREVIOUS(SPARAM) ((bool)(((ushort)SPARAM) & 0x4000))

你可以在图表上运行 “与事件相关的图表属性” 部分中的 EventAll.mq5 指标,查看按下某些键时日志中显示的参数值。

需要注意的是,lparam 中的代码是虚拟键盘键代码之一。其列表可在 MetaTrader 5 附带的 MQL5/Include/VirtualKeys.mqh 文件中查看。例如:

c
#define VK_SPACE          0x20
#define VK_PRIOR          0x21
#define VK_NEXT           0x22
#define VK_END            0x23
#define VK_HOME           0x24
#define VK_LEFT           0x25
#define VK_UP             0x26
#define VK_RIGHT          0x27
#define VK_DOWN           0x28
...
#define VK_INSERT         0x2D
#define VK_DELETE         0x2E
...
// VK_0 - VK_9 是字符 '0' - '9' 的 ASCII 码(0x30 - 0x39)
// VK_A - VK_Z 是字符 'A' - 'Z' 的 ASCII 码(0x41 - 0x5A)

这些代码被称为虚拟代码,因为对应的键在不同键盘上的位置可能不同,甚至可能通过组合辅助键(如笔记本电脑上的 Fn)来实现。此外,虚拟性还有另一个方面:同一个键可能会生成不同的符号或控制操作。例如,同一个键在不同的语言布局中可能代表不同的字母。而且,每个字母键根据 CapsLock 模式和 Shift 键的状态,可能会生成大写或小写字母。

因此,为了从虚拟键代码获取字符,MQL5 API 提供了特殊函数 TranslateKey

c
short TranslateKey(int key)

该函数根据传入的虚拟键代码,结合当前输入语言和控制键的状态,返回一个 Unicode 字符。

如果出现错误,将返回值 -1。当代码与正确字符不匹配时可能会出错,例如尝试获取 Shift 键的字符。

需要注意的是,除了接收到的按下键的代码外,MQL 程序还可以额外检查控制键和模式方面的键盘状态。顺便说一下,传递给 TerminalInfoInteger 函数的 TERMINAL_KEYSTATE_XXX 形式的常量是基于 1000 + 虚拟键代码 的原则。例如,TERMINAL_KEYSTATE_UP 为 1038,因为 VK_UP 为 38(0x26)。

在设计对按键做出反应的算法时,请记住终端可能会拦截许多键组合,因为它们被保留用于执行某些操作(上文已给出文档链接)。特别是,按下空格键会打开一个用于沿时间轴快速导航的字段。MQL5 API 允许部分控制这种内置的键盘处理,并在必要时禁用它。请参阅 “鼠标和键盘控制” 部分。

简单的无缓冲指标 EventTranslateKey.mq5 用于演示此函数。在其 OnChartEvent 处理程序中,针对 CHARTEVENT_KEYDOWN 事件调用 TranslateKey 以获取有效的 Unicode 字符。如果成功,将该符号添加到显示在绘图注释中的消息字符串中。按下 Enter 键时,会在文本中插入换行符;按下 Backspace 键时,会从文本末尾删除最后一个字符。

c
#include <VirtualKeys.mqh>

string message = "";

void OnChartEvent(const int id,
                  const long &lparam,
                  const double &dparam,
                  const string &sparam)
{
    if(id == CHARTEVENT_KEYDOWN)
    {
        if(lparam == VK_RETURN)
        {
            message += "\n";
        }
        else if(lparam == VK_BACK)
        {
            StringSetLength(message, StringLen(message) - 1);
        }
        else
        {
            ResetLastError();
            const ushort c = TranslateKey((int)lparam);
            if(_LastError == 0)
            {
                message += ShortToString(c);
            }
        }
        Comment(message);
    }
}

你可以尝试以不同大小写和不同语言输入字符。

请注意,该函数返回有符号短整型值,主要是为了能够返回错误代码 -1。然而,“宽” 两字节字符的类型被认为是无符号整数 ushort。如果接收变量声明为 ushort,使用 -1 进行检查(例如 c != -1)会发出 “符号不匹配” 的编译器警告(需要显式类型转换),而另一种检查(c >= 0)通常是错误的,因为它始终为真。

为了能在消息中插入单词间的空格,在 OnInit 处理程序中预先禁用了由空格键激活的快速导航:

c
void OnInit()
{
    ChartSetInteger(0, CHART_QUICK_NAVIGATION, false);
}

作为使用键盘事件的完整示例,考虑以下应用任务。终端用户知道,无需打开设置对话框,使用鼠标就能交互式更改主图表窗口的缩放比例:只需在价格刻度上按下鼠标按钮,不松开并向上/下移动即可。但遗憾的是,这种方法在子窗口中不起作用。

子窗口始终会自动缩放以适应所有内容,若要更改缩放比例,必须打开对话框并手动输入值。有时,当子窗口中的指标显示 “异常值”(即过大的单个读数,会干扰对其他正常(中等)大小数据的分析)时,就会有此需求。此外,有时只是希望放大图片以查看更精细的细节。

为解决此问题,让用户能通过按键调整子窗口的缩放比例,我们实现了 SubScaler.mq5 指标。该指标没有缓冲区,也不显示任何内容。

SubScaler 必须是子窗口中的第一个指标,更严格地说,必须在将你感兴趣的工作指标添加到子窗口之前,先将其添加到该子窗口,这样才能控制其缩放比例。要使 SubScaler 成为第一个指标,应将其放置在图表(主窗口)上,从而创建一个新的子窗口,然后再在其中添加从属指标。

在工作指标的设置对话框中,务必启用 “继承比例” 选项(在 “比例” 选项卡上)。

当两个指标都在子窗口中运行时,可使用向上/向下箭头键进行放大/缩小操作。如果按下 Shift 键,则垂直轴上当前可见的值范围会向上或向下移动。

放大意味着放大细节(“相机变焦”),这样部分数据可能会移出窗口。缩小意味着整体画面变小(“相机拉远”)。

设置的输入参数如下:

  • 初始最大值:初始放置在图表上时数据的上限,默认值为 +1000。
  • 初始最小值:初始放置在图表上时数据的下限,默认值为 -1000。
  • 缩放因子:按键时缩放比例变化的步长,取值范围为 [0.01 ... 0.5],默认值为 0.1。

我们不得不向用户询问最小值和最大值,因为 SubScaler 无法提前知晓将添加到子窗口的任意第三方指标的工作值范围。

在启动新的终端会话后恢复图表,或加载 tpl 模板时,SubScaler 会恢复上一个(已保存)状态的缩放比例。

下面来看看 SubScaler 的实现:

上述设置在相应的输入变量中进行设置:

c
input double FixedMaximum = 1000;  // 初始最大值
input double FixedMinimum = -1000; // 初始最小值
input double _ScaleFactor = 0.1;   // 缩放因子 [0.01 ... 0.5]
input bool Disabled = false;

此外,Disabled 变量允许临时禁用特定指标实例的键盘响应,以便在不同子窗口中逐个设置不同的缩放比例。

由于 MQL5 中的输入变量是只读的,我们不得不声明另一个变量 ScaleFactor,以将输入值修正到允许的范围 [0.01 ... 0.5] 内:

c
double ScaleFactor;

当前子窗口的编号(w)和其中指标的数量(n)存储在全局变量中,它们都在 OnInit 处理程序中填充:

c
int w = -1, n = -1;

void OnInit()
{
    ScaleFactor = _ScaleFactor;
    if(ScaleFactor < 0.01 || ScaleFactor > 0.5)
    {
        PrintFormat("ScaleFactor %f 调整为默认值 0.1,有效范围是 [0.01, 0.5]", ScaleFactor);
        ScaleFactor = 0.1;
    }
    w = ChartWindowFind();
    n = ChartIndicatorsTotal(0, w);
}

OnChartEvent 函数中,我们处理两种类型的事件:图表更改和键盘事件。CHARTEVENT_CHART_CHANGE 事件用于跟踪向子窗口添加下一个指标(要缩放的工作指标)的情况。同时,我们请求子窗口值的当前范围(CHART_PRICE_MINCHART_PRICE_MAX),并判断其是否为退化情况,即最大值和最小值都等于零。在这种情况下,需要应用输入参数中指定的初始限制(FixedMinimumFixedMaximum)。

c
void OnChartEvent(const int id, const long &lparam, const double &dparam, const string &sparam)
{
    switch(id)
    {
    case CHARTEVENT_CHART_CHANGE:
        if(ChartIndicatorsTotal(0, w) > n)
        {
            n = ChartIndicatorsTotal(0, w);
            const double min = ChartGetDouble(0, CHART_PRICE_MIN, w);
            const double max = ChartGetDouble(0, CHART_PRICE_MAX, w);
            PrintFormat("Change: %f %f %d", min, max, n);
            if(min == 0 && max == 0)
            {
                IndicatorSetDouble(INDICATOR_MINIMUM, FixedMinimum);
                IndicatorSetDouble(INDICATOR_MAXIMUM, FixedMaximum);
            }
        }
        break;
    ...
    }
}

当接收到键盘按下事件时,会调用主 Scale 函数,该函数不仅接收 lparam,还接收通过 TerminalInfoInteger(TERMINAL_KEYSTATE_SHIFT) 获取的 Shift 键状态。

c
void OnChartEvent(const int id, const long &lparam, const double &dparam, const string &sparam)
{
    switch(id)
    {
        case CHARTEVENT_KEYDOWN:
            if(!Disabled)
                Scale(lparam, TerminalInfoInteger(TERMINAL_KEYSTATE_SHIFT));
            break;
        ...
    }
}

Scale 函数内部,首先将当前值范围获取到 minmax 变量中:

c
void Scale(const long cmd, const int shift)
{
    const double min = ChartGetDouble(0, CHART_PRICE_MIN, w);
    const double max = ChartGetDouble(0, CHART_PRICE_MAX, w);
    ...

然后,根据当前是否按下 Shift 键,进行缩放或平移操作,即向上或向下移动可见值范围。在这两种情况下,修改都是以给定的步长(乘数)ScaleFactor 相对于限制 minmax 进行的,并分别将其赋值给指标属性 INDICATOR_MINIMUMINDICATOR_MAXIMUM。由于从属指标设置了 “继承比例”,这也将成为其有效的设置。

c
if((shift & 0x10000000) == 0) // Shift 键未按下 - 进行缩放更改
{
    if(cmd == VK_UP) // 放大
    {
        IndicatorSetDouble(INDICATOR_MINIMUM, min / (1.0 + ScaleFactor));
        IndicatorSetDouble(INDICATOR_MAXIMUM, max / (1.0 + ScaleFactor));
        ChartRedraw();
    }
    else if(cmd == VK_DOWN) // 缩小
    {
        IndicatorSetDouble(INDICATOR_MINIMUM, min * (1.0 + ScaleFactor));
        IndicatorSetDouble(INDICATOR_MAXIMUM, max * (1.0 + ScaleFactor));
        ChartRedraw();
    }
}
else // Shift 键按下 - 平移/移动范围
{
    if(cmd == VK_UP) // 向上移动图表
    {
        const double d = (max - min) * ScaleFactor;
        IndicatorSetDouble(INDICATOR_MINIMUM, min - d);
        IndicatorSetDouble(INDICATOR_MAXIMUM, max - d);
        ChartRedraw();
    }
    else if(cmd == VK_DOWN) // 向下移动图表
    {
        const double d = (max - min) * ScaleFactor;
        IndicatorSetDouble(INDICATOR_MINIMUM, min + d);
        IndicatorSetDouble(INDICATOR_MAXIMUM, max + d);
        ChartRedraw();
    }
}

对于任何更改,都会调用 ChartRedraw 来更新图表。

让我们看看 SubScaler 如何与标准的成交量指标配合使用(任何其他指标,包括自定义指标,控制方式相同)。

在两个子窗口中,SubScaler 指标设置了不同的垂直缩放比例。

鼠标事件

我们已经有机会通过“与事件相关的图表属性”部分中的 EventAll.mq5 指标来确认我们接收到了鼠标事件。每当在窗口中点击鼠标按钮时,CHARTEVENT_CLICK 事件就会发送到 MQL 程序,而 CHARTEVENT_MOUSE_MOVE(光标移动)和 CHARTEVENT_MOUSE_WHEEL(滚轮滚动)事件需要事先在图表设置中激活,分别由 CHART_EVENT_MOUSE_MOVECHART_EVENT_MOUSE_WHEEL 属性来实现(这两个属性默认都是禁用的)。

如果鼠标下方有一个图形对象,当按下按钮时,不仅会生成 CHARTEVENT_CLICK 事件,还会生成 CHARTEVENT_OBJECT_CLICK 事件。

对于 CHARTEVENT_CLICKCHARTEVENT_MOUSE_MOVE 事件,OnChartEvent 处理程序的参数包含以下信息:

  • lparam - X 坐标
  • dparam - Y 坐标

此外,对于 CHARTEVENT_MOUSE_MOVE 事件,sparam 参数包含一个位掩码的字符串表示,该位掩码描述了鼠标按钮和控制键(CtrlShift)的状态。将特定的位设置为 1 表示按下了相应的按钮或键。

描述
0鼠标左键状态
1鼠标右键状态
2SHIFT 键状态
3CTRL 键状态
4鼠标中键状态
5第一个额外鼠标按钮的状态
6第二个额外鼠标按钮的状态

例如,如果第 0 位被设置,其值为 1($1 << 0$),如果第 4 位被设置,其值为 16($1 << 4$)。同时按下按钮或键通过位的叠加来表示。

对于 CHARTEVENT_MOUSE_WHEEL 事件,X 和 Y 坐标以及鼠标按钮和控制键的状态标志以一种特殊的方式编码在 lparam 参数内部,而 dparam 参数则报告滚轮滚动的方向(正/负)和数量(±120 的倍数)。

8 字节的整数 lparam 组合了上述提到的几个信息字段。

字节描述
0包含 X 坐标的短整型值
1
2包含 Y 坐标的短整型值
3
4按钮和键状态的位掩码
5未使用
6
7

无论事件类型如何,鼠标坐标都是相对于整个窗口(包括子窗口)传输的,所以如果有必要,应该针对特定子窗口重新计算这些坐标。

为了更好地理解 CHARTEVENT_MOUSE_WHEEL,可以使用 EventMouseWheel.mq5 指标。它接收并解码消息,然后将消息的描述输出到日志中。

c
#define KEY_FLAG_NUMBER 7
   
const string keyNameByBit[KEY_FLAG_NUMBER] =
{
   "[Left Mouse] ",
   "[Right Mouse] ",
   "(Shift) ",
   "(Ctrl) ",
   "[Middle Mouse] ",
   "[Ext1 Mouse] ",
   "[Ext2 Mouse] ",
};
   
void OnChartEvent(const int id,
   const long &lparam, const double &dparam, const string &sparam)
{
   if(id == CHARTEVENT_MOUSE_WHEEL)
   {
      const int keymask = (int)(lparam >> 32);
      const short x = (short)lparam;
      const short y = (short)(lparam >> 16);
      const short delta = (short)dparam;
      string message = "";
      
      for(int i = 0; i < KEY_FLAG_NUMBER; ++i)
      {
         if(((1 << i) & keymask) != 0)
         {
            message += keyNameByBit[i];
         }
      }
      
      PrintFormat("X=%d Y=%d D=%d %s", x, y, delta, message);
   }
}

在图表上运行该指标,并依次按下各种按钮和键来滚动鼠标滚轮。以下是一个示例结果:

X=186 Y=303 D=-120 
X=186 Y=312 D=120 
X=230 Y=135 D=-120 
X=230 Y=135 D=-120 (Ctrl) 
X=230 Y=135 D=-120 (Shift) (Ctrl) 
X=230 Y=135 D=-120 (Shift) 
X=230 Y=135 D=120 
X=230 Y=135 D=-120 [Middle Mouse] 
X=230 Y=135 D=120 [Middle Mouse] 
X=236 Y=210 D=-240 
X=236 Y=210 D=-360

这样,我们就详细了解了鼠标事件在 MQL5 编程环境中的工作方式。理解这些事件及其参数对于创建能够与用户进行交互的更复杂和有用的图表应用程序至关重要。

例如,我们可以利用这些知识来创建自定义的鼠标交互功能,如在图形对象上进行拖放操作、通过滚轮缩放图表视图、根据鼠标位置和按钮状态执行特定的操作等。

接下来,我们将探讨其他类型的事件,这些事件将进一步丰富我们对 MQL5 事件系统的理解,并为我们开发更强大的程序提供更多的可能性。

在处理鼠标事件时,还需要注意一些细节。例如,由于鼠标坐标是相对于整个窗口的,当图表包含多个子窗口时,我们需要根据具体的需求将坐标转换为子窗口内的坐标,以便准确地定位和操作子窗口中的对象。

此外,不同的鼠标按钮和控制键的组合可能会导致不同的行为,我们需要在程序中正确地处理这些组合情况,以提供一致和预期的用户体验。

通过不断地实践和实验,我们可以熟练掌握鼠标事件的处理技巧,并将其应用到实际的项目中。

下面我们来看一个简单的示例,展示如何在 MQL5 程序中使用鼠标事件来实现一个基本的交互功能。

假设我们想要创建一个指标,当用户在图表上点击鼠标左键时,在点击的位置创建一个圆形对象(OBJ_CIRCLE)。我们可以使用以下代码实现这个功能:

c
#property indicator_chart_window
#property indicator_buffers 0
#property indicator_plots   0

void OnChartEvent(const int id,
                  const long &lparam, const double &dparam, const string &sparam)
{
    if (id == CHARTEVENT_CLICK)
    {
        const int x = (int)lparam;
        const int y = (int)dparam;

        // 检查是否是鼠标左键点击
        const int keymask = StringToInteger(sparam);
        if ((keymask & 1) != 0)
        {
            const string objName = "ClickedCircle";
            ObjectCreate(0, objName, OBJ_CIRCLE, 0, 0, 0);
            ObjectSetInteger(0, objName, OBJPROP_XDISTANCE, x);
            ObjectSetInteger(0, objName, OBJPROP_YDISTANCE, y);
            ObjectSetInteger(0, objName, OBJPROP_XSIZE, 50);
            ObjectSetInteger(0, objName, OBJPROP_YSIZE, 50);
            ObjectSetInteger(0, objName, OBJPROP_COLOR, clrRed);
        }
    }
}

在这个示例中,我们首先检查事件类型是否为 CHARTEVENT_CLICK。然后,我们获取点击的坐标(xy),并通过 sparam 参数检查是否是鼠标左键点击(位掩码的第 0 位为 1 表示左键点击)。如果是左键点击,我们创建一个圆形对象,并设置其位置、大小和颜色。

通过这样的方式,我们可以利用鼠标事件来实现各种有趣和实用的功能,为图表添加更多的交互性和用户友好性。

希望这些关于鼠标事件的介绍和示例能够帮助你更好地理解和应用 MQL5 中的鼠标事件处理机制,从而在你的编程项目中取得更好的成果。

在后续的内容中,我们还将深入探讨其他重要的事件类型,如键盘事件、图表变化事件等,以及如何在实际应用中有效地处理这些事件。

总之,掌握 MQL5 中的事件系统是开发高质量图表应用程序的关键一步,通过不断学习和实践,我们可以充分发挥 MQL5 的强大功能,满足各种不同的需求。

图形对象事件

对于图表上的图形对象,终端会生成几个专门的事件。其中大多数事件适用于任何类型的对象。输入字段中的文本编辑结束事件 CHARTEVENT_OBJECT_ENDEDIT 仅针对 OBJ_EDIT 类型的对象生成。

对象点击(CHARTEVENT_OBJECT_CLICK)、鼠标拖动(CHARTEVENT_OBJECT_DRAG)和对象属性更改(CHARTEVENT_OBJECT_CHANGE)事件始终处于活动状态,而 CHARTEVENT_OBJECT_CREATE(对象创建)和 CHARTEVENT_OBJECT_DELETE(对象删除)事件需要通过设置图表的相关属性 CHART_EVENT_OBJECT_CREATECHART_EVENT_OBJECT_DELETE 来显式启用。

当手动重命名对象时(从属性对话框中进行),终端会生成一系列事件:CHARTEVENT_OBJECT_DELETECHARTEVENT_OBJECT_CREATECHARTEVENT_OBJECT_CHANGE。当通过编程方式重命名对象时,不会生成这些事件。

对象中的所有事件都会在 OnChartEvent 函数的 sparam 参数中携带相关对象的名称。

此外,对于 CHARTEVENT_OBJECT_CLICK 事件,会传递点击坐标:lparam 参数中传递的是 X 坐标,dparam 参数中传递的是 Y 坐标。这些坐标是整个图表(包括子窗口)通用的。

根据对象类型的不同,点击对象的工作方式也不同。对于某些对象,如椭圆,光标必须位于任何一个锚点上。对于其他对象(三角形、矩形、线条),光标可以位于对象的周边,而不仅仅是一个点上。在所有这些情况下,将鼠标光标悬停在对象的交互区域上会显示一个带有对象名称的工具提示。

与屏幕坐标关联的对象(这些对象允许形成程序的图形界面,特别是按钮、输入字段和矩形面板),当鼠标在对象内部的任何位置点击时都会生成事件。

如果光标下方有多个对象,则会为具有最高 Z 优先级的对象生成事件。如果对象的优先级相等,则事件将分配给后创建的对象(这与它们的视觉显示相对应,即后创建的对象会覆盖先创建的对象)。

新版本的指标 EventAllObjects.mq5 将帮助你检查对象中的事件。我们将使用已经熟悉的多个对象的 ObjectSelector 类来创建和配置它,然后在 OnChartEvent 处理函数中拦截它们的特征事件。

c
#include <MQL5Book/ObjectMonitor.mqh>
   
class ObjectBuilder: public ObjectSelector
{
protected:
   const ENUM_OBJECT type;
   const int window;
public:
   ObjectBuilder(const string _id, const ENUM_OBJECT _type,
      const long _chart = 0, const int _win = 0):
      ObjectSelector(_id, _chart), type(_type), window(_win)
   {
      ObjectCreate(host, id, type, window, 0, 0);
   }
};

最初,在 OnInit 函数中,我们创建一个按钮对象和一条垂直线。对于这条线,我们将跟踪其移动(拖动)事件,并且在按下按钮时,我们将创建一个输入字段,然后检查在该输入字段中输入的文本。

c
const string ObjNamePrefix = "EventShow-";
const string ButtonName = ObjNamePrefix + "Button";
const string EditBoxName = ObjNamePrefix + "EditBox";
const string VLineName = ObjNamePrefix + "VLine";
   
bool objectCreate, objectDelete;
   
void OnInit()
{
   // 记住原始设置以便在 OnDeinit 中恢复
   objectCreate = ChartGetInteger(0, CHART_EVENT_OBJECT_CREATE);
   objectDelete = ChartGetInteger(0, CHART_EVENT_OBJECT_DELETE);
   
   // 设置新属性
   ChartSetInteger(0, CHART_EVENT_OBJECT_CREATE, true);
   ChartSetInteger(0, CHART_EVENT_OBJECT_DELETE, true);
   
   ObjectBuilder button(ButtonName, OBJ_BUTTON);
   button.set(OBJPROP_XDISTANCE, 100).set(OBJPROP_YDISTANCE, 100)
   .set(OBJPROP_XSIZE, 200).set(OBJPROP_TEXT, "Click Me");
   
   ObjectBuilder line(VLineName, OBJ_VLINE);
   line.set(OBJPROP_TIME, iTime(NULL, 0, 0))
   .set(OBJPROP_SELECTABLE, true).set(OBJPROP_SELECTED, true)
   .set(OBJPROP_TEXT, "Drag Me").set(OBJPROP_TOOLTIP, "Drag Me");
   
   ChartRedraw();
}

在此过程中,不要忘记将图表属性 CHART_EVENT_OBJECT_CREATECHART_EVENT_OBJECT_DELETE 设置为 true,以便在对象集合发生变化时收到通知。

OnChartEvent 函数中,我们将对所需事件提供额外的响应:在拖动完成后,我们将在日志中显示线条的新位置,并且在编辑输入字段中的文本后,显示其内容。

c
void OnChartEvent(const int id,
   const long &lparam, const double &dparam, const string &sparam)
{
   ENUM_CHART_EVENT evt = (ENUM_CHART_EVENT)id;
   PrintFormat("%s %lld %f '%s'", EnumToString(evt), lparam, dparam, sparam);
   if(id == CHARTEVENT_OBJECT_CLICK && sparam == ButtonName)
   {
      if(ObjectGetInteger(0, ButtonName, OBJPROP_STATE))
      {
         ObjectBuilder edit(EditBoxName, OBJ_EDIT);
         edit.set(OBJPROP_XDISTANCE, 100).set(OBJPROP_YDISTANCE, 150)
         .set(OBJPROP_BGCOLOR, clrWhite)
         .set(OBJPROP_XSIZE, 200).set(OBJPROP_TEXT, "Edit Me");
      }
      else
      {
         ObjectDelete(0, EditBoxName);
      }
      
      ChartRedraw();
   }
   else if(id == CHARTEVENT_OBJECT_ENDEDIT && sparam == EditBoxName)
   {
      Print(ObjectGetString(0, EditBoxName, OBJPROP_TEXT));
   }
   else if(id == CHARTEVENT_OBJECT_DRAG && sparam == VLineName)
   {
      Print(TimeToString((datetime)ObjectGetInteger(0, VLineName, OBJPROP_TIME)));
   }
}

请注意,当首次按下按钮时,其状态从释放变为按下,作为响应,我们创建一个输入字段。如果再次点击按钮,它将恢复其状态,结果是输入字段将从图表中删除。

下面是指标运行时图表的图像。

由 OnChartEvent 事件处理函数控制的对象

由 OnChartEvent 事件处理函数控制的对象

在指标启动后,日志中会立即出现以下行:

CHARTEVENT_OBJECT_CREATE 0 0.000000 'EventShow-Button'
CHARTEVENT_OBJECT_CREATE 0 0.000000 'EventShow-VLine'
CHARTEVENT_CHART_CHANGE 0 0.000000 ''

如果然后用鼠标拖动线条,我们将看到类似这样的内容:

CHARTEVENT_OBJECT_DRAG 0 0.000000 'EventShow-VLine'
2022.01.05 10:00

接下来,你可以点击按钮并在新创建的输入字段中编辑文本(编辑完成后,按 Enter 键或在输入字段外部点击)。这将在日志中产生以下条目(坐标和消息文本可能不同;这里输入的文本是“new message”):

CHARTEVENT_OBJECT_CLICK 181 113.000000 'EventShow-Button'
CHARTEVENT_CLICK 181 113.000000 ''
CHARTEVENT_OBJECT_CREATE 0 0.000000 'EventShow-EditBox'
CHARTEVENT_OBJECT_CLICK 152 160.000000 'EventShow-EditBox'
CHARTEVENT_CLICK 152 160.000000 ''
CHARTEVENT_OBJECT_ENDEDIT 0 0.000000 'EventShow-EditBox'
new message

如果然后释放按钮,输入字段将被删除。

CHARTEVENT_OBJECT_CLICK 162 109.000000 'EventShow-Button'
CHARTEVENT_CLICK 162 109.000000 ''
CHARTEVENT_OBJECT_DELETE 0 0.000000 'EventShow-EditBox'

值得注意的是,按钮默认作为双位开关工作,即由于鼠标点击,它会交替保持在按下或释放状态。对于普通按钮,这种行为是多余的:为了简单地跟踪按钮按下情况,在处理事件时应通过调用 ObjectSetInteger(0, ButtonName, OBJPROP_STATE, false) 将其恢复到释放状态。

自定义事件的生成

除了标准事件之外,终端还支持通过编程方式生成自定义事件,其本质和内容由 MQL 程序决定。这些事件会被添加到图表事件的通用队列中,所有感兴趣的程序都可以在 OnChartEvent 函数里对其进行处理。

为自定义事件预留了一个包含 65536 个整数标识符的特殊范围:从 CHARTEVENT_CUSTOMCHARTEVENT_CUSTOM_LAST(包含两端)。换句话说,自定义事件的 ID 必须为 CHARTEVENT_CUSTOM + n,其中 n 的取值范围是 0 到 65535。CHARTEVENT_CUSTOM_LAST 恰好等于 CHARTEVENT_CUSTOM + 65535

可以使用 EventChartCustom 函数将自定义事件发送到图表。

c
bool EventChartCustom(long chartId, ushort customEventId,
  long lparam, double dparam, string sparam)
  • chartId 是事件接收图表的标识符,0 表示当前图表。
  • customEventId 是事件 ID(由 MQL 程序开发者选择)。这个标识符会自动添加到 CHARTEVENT_CUSTOM 值上,并转换为整数类型。该值会作为第一个参数传递给 OnChartEvent 处理程序。
  • EventChartCustom 的其他参数对应于 OnChartEvent 中标准事件的参数,类型分别为 longdoublestring,可以包含任意信息。

若用户事件成功入队,函数返回 true;若出现错误(错误代码可在 _LastError 中获取),则返回 false

随着我们进入本书中直接与交易自动化相关的最复杂且重要的部分,我们将着手解决一些在开发交易机器人时会很有用的实际问题。现在,为了展示自定义事件的功能,让我们来进行交易环境的多货币(或者更宽泛地说,多品种)分析。

稍早前,在关于指标的章节中,我们探讨过多货币指标,但没有关注一个重要的点:尽管这些指标会处理不同品种的报价,但计算实际上是在 OnCalculate 处理程序中启动的,而该处理程序仅在图表的一个工作品种收到新报价时才会触发。这就意味着其他品种的报价实际上被跳过了。例如,如果指标在品种 A 上运行,当品种 A 的报价到来时,我们只是获取其他品种(B、C、D)的最后已知报价,但很有可能在此期间其他品种的一些报价已经被错过了。

如果将多货币指标应用于流动性最强(即最常收到报价)的品种,这个问题可能不太严重。然而,不同的品种在一天中的不同时间可能具有不同的活跃度,如果分析或交易算法需要对投资组合中所有品种的新报价做出尽可能快速的响应,那么当前的解决方案就无法满足需求。

遗憾的是,在 MQL5 中,新报价到达的标准事件仅适用于当前图表的工作品种。在指标中,此时会调用 OnCalculate 处理程序;在智能交易系统中,则会调用 OnTick 处理程序。

因此,有必要设计一种机制,使 MQL 程序能够接收所有感兴趣品种的报价通知。这正是自定义事件能发挥作用的地方。当然,对于只分析一个品种的程序来说,这并非必要。

现在,我们来开发一个 EventTickSpy.mq5 指标示例。该指标在特定品种 X 上启动后,能够在其 OnCalculate 函数中使用 EventChartCustom 发送报价通知。最终,在专门用于接收此类通知的 OnChartEvent 处理程序中,就可以收集来自不同品种的指标实例发出的通知。

这个示例仅用于说明目的。后续在学习多货币自动交易时,我们会对这种技术进行调整,以便在智能交易系统中更方便地使用。

首先,我们要为该指标想一个自定义事件编号。由于我们打算从给定列表中的多个不同品种发送报价通知,这里可以采用不同的策略。例如,可以选择一个事件标识符,然后分别在 lparamsparam 参数中传递品种在列表中的编号和/或品种名称。或者,也可以选取一个常量(大于等于 CHARTEVENT_CUSTOM),并通过给这个常量加上品种编号来得到事件编号(这样一来,所有参数都是可用的,特别是 lparamdparam,可以用它们来传递 AskBid 价格或其他信息)。

我们选择使用一个事件代码的方案。在 TICKSPY 宏中声明该代码。这是一个默认值,如果需要避免与其他程序发生冲突(尽管这种可能性很小),用户可以修改它。

c
#define TICKSPY 0xFEED // 65261

这个值故意选得与允许的第一个 CHARTEVENT_CUSTOM 相距较远。

在指标的初始(交互式)启动过程中,用户必须指定指标要跟踪其报价的品种列表。为此,我们定义一个输入字符串变量 SymbolList,其中的品种名称用逗号分隔。

用户事件的标识符设置在 message 参数中。

最后,我们需要接收事件的图表标识符。为此,我们提供 Chart 参数。用户不应编辑该参数:在手动启动的第一个指标实例中,图表可以通过将其附加到图表上隐式确定。在我们的第一个实例以编程方式运行的其他指标副本中,该参数将通过调用 ChartID 函数来填充算法(见下文)。

c
input string SymbolList = "EURUSD,GBPUSD,XAUUSD,USDJPY"; // 用逗号分隔的品种列表(示例)
input ushort message = TICKSPY;                          // 自定义消息
input long chart = 0;                                     // 接收图表(请勿编辑)

SymbolList 参数中,例如列出了四个常见的品种。可根据你的市场报价列表按需编辑。

OnInit 处理程序中,我们将该列表转换为品种数组 Symbols,然后在循环中为数组中除当前品种之外的所有品种运行相同的指标(通常会有这种匹配,因为当前品种已经由这个初始的指标副本处理)。

c
string Symbols[];
   
void OnInit()
{
   PrintFormat("Starting for chart %lld, msg=0x%X [%s]", Chart, Message, SymbolList);
   if(Chart == 0)
   {
      if(StringLen(SymbolList) > 0)
      {
         const int n = StringSplit(SymbolList, ',', Symbols);
         for(int i = 0; i < n; ++i)
         {
            if(Symbols[i] != _Symbol)
            {
               ResetLastError();
               // 在另一个品种上运行相同的指标,并使用不同的设置,
               // 特别是,我们传递我们的 ChartID 以接收返回的通知
               iCustom(Symbols[i], PERIOD_CURRENT, MQLInfoString(MQL_PROGRAM_NAME),
                  "", Message, ChartID());
               if(_LastError != 0)
               {
                  PrintFormat("The symbol '%s' seems incorrect", Symbols[i]);
               }
            }
         }
      }
      else
      {
         Print("SymbolList is empty: tracking current symbol only!");
         Print("To monitor other symbols, fill in SymbolList, i.e."
            " 'EURUSD,GBPUSD,XAUUSD,USDJPY'");
      }
   }
}

OnInit 开始时,会在日志中显示已启动的指标实例的信息,以便清楚了解发生了什么。

如果我们选择为每个品种使用单独的事件代码的方案,就需要按以下方式调用 iCustom(将 i 加到 message 上):

c
iCustom(Symbols[i], PERIOD_CURRENT, MQLInfoString(MQL_PROGRAM_NAME), "",
  Message + i, ChartID());

请注意,Chart 参数的非零值意味着这个副本是通过编程方式启动的,并且它应该监控单个品种,即图表的工作品种。因此,在运行从属副本时,我们不需要传递品种列表。

在收到新报价时会调用的 OnCalculate 函数中,我们通过调用 EventChartCustomChart 图表发送 Message 自定义事件。在这种情况下,lparam 参数未使用(等于 0)。在 dparam 参数中,我们传递当前(最后)的价格 price[0](这是 BidLast 价格,具体取决于图表基于哪种价格类型:它也是图表处理的最后一个报价的价格),并在 sparam 参数中传递品种名称。

c
int OnCalculate(const int rates_total, const int prev_calculated,
  const int, const double &price[])
{
   if(prev_calculated)
   {
      ArraySetAsSeries(price, true);
      if(Chart > 0)
      {
         // 向主图表发送报价通知
         EventChartCustom(Chart, Message, 0, price[0], _Symbol);
      }
      else
      {
         OnSymbolTick(_Symbol, price[0]);
      }
   }
  
   return rates_total;
}

Chart 参数为 0 的指标原始实例中,我们直接调用一个特殊函数,即一种多资产报价处理程序 OnSymbolTick。在这种情况下,无需调用 EventChartCustom:尽管这样的消息仍会到达图表和这个指标副本,但传输会花费几毫秒时间,并且会无谓地占用队列。

在这个示例中,OnSymbolTick 的唯一目的是在日志中打印品种名称和新价格。

c
void OnSymbolTick(const string &symbol, const double price)
{
   Print(symbol, " ", DoubleToString(price,
      (int)SymbolInfoInteger(symbol, SYMBOL_DIGITS)));
}

当然,如果接收到我们的消息,在接收(源)指标副本的 OnChartEvent 处理程序中也会调用相同的函数。请记住,终端仅在交互式的指标副本(应用于图表的副本)中调用 OnChartEvent,而不会在我们使用 iCustom“隐形”创建的副本中调用。

c
void OnChartEvent(const int id,
  const long &lparam, const double &dparam, const string &sparam)
{
   if(id >= CHARTEVENT_CUSTOM + Message)
   {
      OnSymbolTick(sparam, dparam);
      // 或者(如果使用自定义事件范围):
      // OnSymbolTick(Symbols[id - CHARTEVENT_CUSTOM - Message], dparam);
   }
}

在我们的事件中,我们本可以不发送价格或品种名称,因为初始指标(启动该过程的指标)知道品种的通用列表,因此我们可以以某种方式告知它列表中品种的编号。这可以在 lparam 参数中完成,或者如上所述,通过在用户事件的基本常量上加上一个数字来完成。然后,初始指标在接收事件时,可以通过索引从数组中获取品种,并使用 SymbolInfoTick 获取关于最后一个报价的所有信息,包括不同类型的价格。

让我们在 EURUSD 图表上以默认设置运行该指标,包括测试列表 "EURUSD,GBPUSD,XAUUSD,USDJPY"。以下是日志内容:

plaintext
16:45:48.745 (EURUSD,H1) Starting for chart 0, msg=0xFEED [EURUSD,GBPUSD,XAUUSD,USDJPY]
16:45:48.761 (GBPUSD,H1) Starting for chart 132358585987782873, msg=0xFEED []
16:45:48.761 (USDJPY,H1) Starting for chart 132358585987782873, msg=0xFEED []
16:45:48.761 (XAUUSD,H1) Starting for chart 132358585987782873, msg=0xFEED []
16:45:48.777 (EURUSD,H1) XAUUSD 1791.00
16:45:49.120 (EURUSD,H1) EURUSD 1.13068 *
16:45:49.135 (EURUSD,H1) USDJPY 115.797
16:45:49.167 (EURUSD,H1) XAUUSD 1790.95
16:45:49.167 (EURUSD,H1) USDJPY 115.796
16:45:49.229 (EURUSD,H1) USDJPY 115.797
16:45:49.229 (EURUSD,H1) XAUUSD 1790.74
16:45:49.369 (EURUSD,H1) XAUUSD 1790.77
16:45:49.572 (EURUSD,H1) GBPUSD 1.35332
16:45:49.572 (EURUSD,H1) XAUUSD 1790.80
16:45:49.791 (EURUSD,H1) XAUUSD 1790.80
16:45:49.791 (EURUSD,H1) USDJPY 115.796
16:45:49.931 (EURUSD,H1) EURUSD 1.13069 *
16:45:49.931 (EURUSD,H1) XAUUSD 1790.86
16:45:49.931 (EURUSD,H1) USDJPY 115.795
16:45:50.056 (EURUSD,H1) USDJPY 115.793
16:45:50.181 (EURUSD,H1) XAUUSD 1790.88
16:45:50.321 (EURUSD,H1) XAUUSD 1790.90
16:45:50.399 (EURUSD,H1) EURUSD 1.13066 *
16:45:50.727 (EURUSD,H1) EURUSD 1.13067 *
16:45:50.773 (EURUSD,H1) GBPUSD 1.35334

请注意,在包含记录来源的 (品种,时间周期) 列中,我们首先看到在四个请求的品种上启动的指标实例。

启动后,第一个报价是 XAUUSD 的,而不是 EURUSD 的。后续各品种的报价以大致相同的频率到来,相互交错。EURUSD 的报价用星号标记,这样你就可以了解如果没有通知,会错过多少其他品种的报价。

左列保留了时间戳以供参考。

同一品种的两个连续事件的价格相同的地方,通常表明 Ask 价格发生了变化(这里我们只是没有显示它)。

稍后,在学习了 MQL5 交易 API 之后,我们将应用相同的原理来让智能交易系统响应多货币报价。