Skip to content

资源

MQL 程序的运行可能需要许多辅助资源,这些资源可以是应用数据数组,也可以是各种类型的文件,包括图像、声音和字体。MQL 开发环境允许在编译阶段将所有这些资源包含到可执行文件中。这样就无需在传输和安装主程序的同时并行传输和安装这些资源,并且使程序成为一个完整的、自给自足的产品,方便终端用户使用。

在本章中,我们将学习如何描述不同类型的资源,以及用于对已连接资源进行后续操作的内置函数。

以广为人知的 BMP 格式表示为点(像素)数组的光栅图像,在资源中占据着独特的位置。MQL5 API 允许创建、操作这些图形资源,并在图表上动态显示它们。

之前,我们已经讨论过图形对象,特别是对于设计用户界面很有用的 OBJ_BITMAPOBJ_BITMAP_LABEL 类型的对象。对于这些对象,有 OBJPROP_BMPFILE 属性,它将图像指定为一个文件或资源。之前,我们只考虑过使用文件的示例。现在我们将学习如何使用资源图像。

使用 #resource 指令描述资源

为了将资源文件包含到编译后的程序版本中,可在源代码里使用 #resource 指令。该指令依据文件类型不同呈现出不同形式。无论何种情况,指令都包含 #resource 关键字,其后跟随一个常量字符串。

plaintext
#resource "path_file_name"

#resource 命令会告知编译器把指定名称以及可选位置(编译时)的文件以二进制格式(ex5)包含到正在生成的可执行程序里。路径是可选的:若字符串仅包含文件名,编译器会在编译源代码所在目录的相邻目录中查找该文件;若字符串中有路径,则遵循以下规则。

编译器按以下顺序在指定路径查找资源:

  • 若路径以反斜杠 \ 开头(由于单个反斜杠是控制字符,所以必须使用双反斜杠,例如换行符 \r\n 和制表符 \t 就使用了单个反斜杠),那么会从终端数据目录内的 MQL5 文件夹开始查找资源。
  • 若没有反斜杠,就会相对于注册该资源的源文件的位置来查找资源。

要注意,在包含资源路径的常量字符串里,必须使用双反斜杠作为分隔符,这里不支持使用正斜杠,这与文件系统中的路径有所不同。

以下是一些示例:

plaintext
#resource "\\Images\\euro.bmp" // euro.bmp 位于 /MQL5/Images/
#resource "picture.bmp"        // picture.bmp 位于源文件(mq5 或 mqh)所在的同一目录
#resource "Resource\\map.bmp"  // map.bmp 位于源文件(mq5 或 mqh)所在目录的 Resource 子文件夹中

如果在 mqh 头文件里用相对路径声明资源,那么该路径是相对于这个 mqh 文件的,而非相对于正在编译的程序的 mq5 文件。

资源路径中不允许出现子字符串 "..\\"":\\"

借助多个指令,例如,你能够把所有必要的图片和声音文件直接嵌入到 ex5 文件中。这样,在其他终端运行该程序时,就无需单独传输这些文件。后续章节会探讨从 MQL5 以编程方式访问资源的方法。

常量字符串 "path_file_name" 的长度不能超过 63 个字符,资源文件大小不能超过 128 Mb。资源文件在被包含进可执行文件之前会自动进行压缩。

使用 #resource 指令声明资源后,就能在程序的任意部分使用该资源。资源的名称就是指令中指定的常量字符串(若开头有斜杠则去掉),并且要在字符串内容前添加一个特殊的资源标识(双冒号 "::")。

下面是资源的示例,注释中给出了它们的名称:

plaintext
#resource "\\Images\\euro.bmp"          // 资源名称 - ::Images\\euro.bmp
#resource "picture.bmp"                 // 资源名称 - ::picture.bmp
#resource "Resource\\map.bmp"           // 资源名称 - ::Resource\\map.bmp
#resource "\\Files\\Pictures\\good.bmp" // 资源名称 - ::Files\\Pictures\\good.bmp
#resource "\\Files\\demo.wav";          // 资源名称 - ::Files\\demo.wav
#resource "\\Sounds\\thrill.wav";       // 资源名称 - ::Sounds\\thrill.wav

在 MQL 代码中,可按如下方式引用这些资源(这里我们仅了解 ObjectSetStringPlaySound 函数,不过还有其他选择,如 ResourceReadImage,后续章节会进行介绍):

plaintext
ObjectSetString(0, bitmap_name, OBJPROP_BMPFILE, 0, "::Images\\euro.bmp");
...
ObjectSetString(0, my_bitmap, OBJPROP_BMPFILE, 0, "::picture.bmp");
...
ObjectSetString(0, bitmap_label, OBJPROP_BMPFILE, 0, "::Resource\\map.bmp");
ObjectSetString(0, bitmap_label, OBJPROP_BMPFILE, 1, "::Files\\Pictures\\good.bmp");
...
PlaySound("::Files\\demo.wav");
...
PlaySound("::Sounds\\thrill.wav");

需要注意的是,当把资源中的图像设置给 OBJ_BITMAPOBJ_BITMAP_LABEL 对象时,不能手动更改 OBJPROP_BMPFILE 属性的值(在对象的属性对话框中)。

要知道,PlaySound 函数默认设置的 wav 文件是相对于终端数据目录中的 Sounds 文件夹(或其子文件夹)的。同时,若资源(包括声音资源)在路径中以斜杠开头进行描述,那么会在 MQL5 目录内查找这些资源。所以,在上述示例中,字符串 "\\Sounds\\thrill.wav" 指的是文件 MQL5/Sounds/thrill.wav,而非相对于数据目录的 Sounds/thrill.wav(实际上确实有一个包含标准终端声音的 Sounds 目录)。

前面讨论的 #resource 指令的简单语法仅能描述图像资源(BMP 格式)和声音资源(WAV 格式)。若尝试将其他类型的文件描述为资源,会引发 “未知资源类型” 错误。

处理 #resource 指令后,文件实际上会被嵌入到可执行二进制程序中,并且能通过资源名称进行访问。此外,要留意这类资源的一个特殊属性,即它们可被其他程序公开访问(下一节会详细介绍)。

MQL5 还支持另一种将文件嵌入程序的方式:以资源变量的形式。此方法使用 #resource 指令的扩展语法,不仅能连接 BMP 或 WAV 文件,还能连接其他类型的文件,例如文本文件或结构体数组。

接下来的几个章节会分析连接资源的实际示例。

MQL不同程序资源的共享使用

资源名称在整个终端中是唯一的。后续我们会了解到,除了在编译阶段通过 #resource 指令创建资源外,还能利用 ResourceCreate 函数动态创建资源。无论哪种方式,资源都是在创建它的程序上下文中声明的,因此通过与文件系统(特定 .ex5 文件的路径和名称)绑定,能自动保证完整名称的唯一性。

除了包含和使用自身资源,MQL 程序还能访问其他已编译程序(.ex5 文件)的资源。前提是使用资源的程序要知晓包含所需资源的另一个程序的存放路径、名称以及该资源的名称。

由此可见,终端赋予了资源一个重要特性——共享使用:一个 .ex5 文件中的资源可在多个其他程序里使用。

若要使用第三方 .ex5 文件中的资源,需按 "路径_文件名.ex5::资源名" 的格式指定。例如,假设脚本 DrawingScript.mq5 引用了文件 triangle.bmp 里的特定图像资源:

cpp
#resource "\\Files\\triangle.bmp"

那么在实际脚本中使用时,其名称应写成 "::Files\\triangle.bmp"

若要从另一个程序(如智能交易系统)使用相同资源,资源名称前需加上 .ex5 脚本文件相对于终端数据目录中 MQL5 文件夹的路径以及脚本本身的名称(编译后的形式,即 DrawingScript.ex5)。假设该脚本存于标准的 MQL5/Scripts/ 文件夹中,此时应使用 "\\Scripts\\DrawingScript.ex5::Files\\triangle.bmp" 字符串来访问该图像。.ex5 扩展名是可选的。

当访问其他 .ex5 文件的资源却未指定该文件的路径时,会在请求资源的程序所在的同一文件夹中搜索该文件。例如,假设同一个智能交易系统位于标准的 MQL5/Experts/ 文件夹中,它在查询资源时未指定路径(如 "DrawingScript.ex5::Files\\triangle.bmp"),那么就会在 MQL5/Experts/ 文件夹中搜索 DrawingScript.ex5

由于资源可以共享使用,所以能通过动态创建和更新资源来实现 MQL 程序间的数据交换。这种方式直接在内存中进行,因此是文件或全局变量的不错替代方案。

需要注意的是,要从 MQL 程序加载资源,无需运行该程序:只要有包含资源的 .ex5 文件,就能读取资源。

当资源以资源变量的形式描述时,是一个重要的例外情况,此时无法进行报告共享。

资源变量

#resource 指令有一种特殊形式,通过它可以将外部文件声明为资源变量,并在程序中像访问相应类型的普通变量一样来访问这些资源。声明格式如下:

#resource "path_file_name" as resource_variable_type resource_variable_name

以下是一些声明示例:

#resource "data.bin" as int Data[]           // 包含来自 data.bin 文件数据的 int 类型数组
#resource "rates.dat" as MqlRates Rates[]    // 来自 rates.dat 文件的 MqlRates 结构体数组
#resource "data.txt" as string Message       // 包含 data.txt 文件内容的字符串
#resource "image.bmp" as bitmap Bitmap1[]    // 包含来自 image.bmp 文件图像像素的一维数组
#resource "image.bmp" as bitmap Bitmap2[][]  // 包含相同图像的二维数组

下面进行一些解释。资源变量是常量(在 MQL5 代码中无法对其进行修改)。例如,要在屏幕上显示图像之前对其进行编辑,应该创建资源数组变量的副本。

对于文本文件(字符串类型的资源),编码会根据是否存在字节序标记(BOM)头自动确定。如果没有 BOM,则根据文件内容确定编码。支持 ANSI、UTF-8 和 UTF-16 编码。从文件读取数据时,所有字符串都会转换为 Unicode 编码。

资源字符串变量的使用不仅可以极大地简化纯 MQL5 程序的编写,也能简化基于其他附加技术的程序编写。例如,可以在一个单独的文件中编写 OpenCL 代码(MQL5 作为扩展支持 OpenCL 代码),然后将其作为字符串包含在 MQL 程序的资源中。在大型智能交易系统示例中,我们已经使用资源字符串来包含 HTML 模板。

对于图像,引入了一种特殊的 bitmap 类型;该类型有几个特点。

bitmap 类型描述图像中的单个点或像素,由一个 4 字节的无符号整数(uint)表示。像素包含 4 个字节,对应 ARGB 或 XRGB 格式的颜色分量(一个字母代表一个字节),其中 R 代表红色,G 代表绿色,B 代表蓝色,A 代表透明度(alpha 通道),X 是一个被忽略的字节(无透明度)。在将图像叠加到图表上以及相互叠加时,透明度可用于实现各种效果。

我们将在动态创建图形资源的章节(请参阅 ResourceCreate)中研究 ARGB 和 XRGB 格式的定义。例如,对于 ARGB,十六进制数 0xFFFF0000 指定了一个完全不透明的红色像素(最高字节为 0xFF,下一个字节也为 0xFF),而绿色和蓝色分量的字节为零。

需要注意的是,像素颜色编码与 color 类型的字节表示形式不同。回顾一下,color 类型的值可以用十六进制形式表示为:0x00BBGGRR,其中 BBGGRR 分别是蓝色、绿色和红色分量(每个字节中,值 255 表示该分量的最大强度)。对于像素的类似表示,字节顺序是相反的:0xAARRGGBB。当高字节(这里表示为 AA)为 0 时,像素完全透明,值 255 表示纯色。可以使用 ColorToARGB 函数将 color 转换为 ARGB。

BMP 文件可以有各种编码方法(如果在任何编辑器中创建或编辑它们,请在该程序的文档中查看这个问题)。MQL5 资源并不支持所有现有的编码方法。可以使用 ResourceCreate 函数检查某个特定文件是否受支持。在指令中指定不支持的 BMP 格式文件将导致编译错误。

加载 24 位颜色编码的文件时,alpha 通道分量的所有像素都将设置为 255(不透明)。加载没有 alpha 通道的 32 位颜色编码文件时,也意味着没有透明度,即对于所有图像像素,alpha 通道分量都设置为 255。加载带有 alpha 通道的 32 位颜色编码文件时,不会对像素进行任何操作。

图像可以用一维数组和二维数组来描述。这只会影响寻址方法,而占用的内存量是相同的。在这两种情况下,数组大小都会根据 BMP 文件中的数据自动设置。一维数组的大小将等于图像高度和宽度的乘积(height * width),二维数组将有单独的维度 [height][width]:第一个索引是行号,第二个是该行中的点。

注意!声明与资源变量关联的资源时,访问该资源的唯一方式是通过该变量,并且通过名称 ::resource_name(或更一般的 path_file_name.ex5::resource_name)进行读取的标准方式不再有效。这也意味着此类资源不能用作其他程序的共享资源。

我们以两个指标为例进行说明;这两个指标都是无缓冲区的。选择这种 MQL 程序类型只是为了方便,因为它除了可以与其他指标一起应用于图表而不会产生冲突,而智能交易系统则需要一个没有其他智能交易系统的图表。此外,与脚本不同,它们会保留在图表上,并可用于后续的设置更改。

BmpOwner.mq5 指标包含对三个资源的描述:

  • 图像 search1.bmp,使用简单的 #resource 指令声明,可从其他程序访问。
  • 图像 search2.bmp,作为 bitmap 类型的资源数组变量,无法从外部访问。
  • 文本文件 message.txt,作为资源字符串,用于向用户显示警告信息。

在这个指标中,这两个图像都未以任何方式使用。OnInit 函数中需要使用警告字符串来调用 Alert 函数,因为该指标并非用于独立使用,而只是作为图像资源的提供者。

如果资源变量在源代码中未被使用,编译器可能根本不会将该资源包含在程序的二进制代码中,但这不适用于图像。

cpp
#resource "search1.bmp"
#resource "search2.bmp" as bitmap image[]
#resource "message.txt" as string Message

这三个文件都位于与指标源文件相同的目录中:MQL5/Indicators/MQL5Book/p7/

如果用户尝试运行该指标,它会显示一条警告并立即停止工作。警告信息包含在 Message 资源字符串变量中。

cpp
int OnInit()
{
   Alert(Message); // 等同于以下代码行
   // Alert("This indicator is not intended to run, it holds a bitmap resource");
   
   // 显式删除指标,因为否则它会在未初始化的情况下“挂”在图表上
   ChartIndicatorDelete(0, 0, MQLInfoString(MQL_PROGRAM_NAME));
   return INIT_FAILED;
}

在第二个指标 BmpUser.mq5 中,我们将尝试使用输入变量 ResourceOffResourceOn 中指定的外部资源,在 OBJ_BITMAP_LABEL 对象中显示。

cpp
input string ResourceOff = "BmpOwner.ex5::search1.bmp";
input string ResourceOn = "BmpOwner.ex5::search2.bmp";

默认情况下,对象处于禁用/释放状态(“Off”),并且其图像取自前一个指标 BmpOwner.ex5::search1.bmp。此路径和资源名称类似于完整表示形式 \\Indicators\\MQL5Book\\p7\\BmpOwner.ex5::search1.bmp。鉴于这些指标彼此相邻,这里的简短形式是可接受的。如果随后打开对象属性对话框,您将在“Bitmap file (On/Off)”字段中看到完整的表示形式。

对于按下状态,在 ResourceOn 中我们应该读取资源 BmpOwner.ex5::search2.bmp(让我们看看会发生什么)。

在其他输入变量中,可以选择图表的角落,相对于该角落设置图像的位置,以及水平和垂直缩进。

cpp
input int X = 25;
input int Y = 25;
input ENUM_BASE_CORNER Corner = CORNER_RIGHT_LOWER;

OnInit 中创建 OBJ_BITMAP_LABEL 对象并设置其属性,包括将资源名称作为 OBJPROP_BMPFILE 的图片。

cpp
const string Prefix = "BMP_";
const ENUM_ANCHOR_POINT Anchors[] =
{
   ANCHOR_LEFT_UPPER,
   ANCHOR_LEFT_LOWER,
   ANCHOR_RIGHT_LOWER,
   ANCHOR_RIGHT_UPPER
};
   
void OnInit()
{
   const string name = Prefix + "search";
   ObjectCreate(0, name, OBJ_BITMAP_LABEL, 0, 0, 0);
   
   ObjectSetString(0, name, OBJPROP_BMPFILE, 0, ResourceOn);
   ObjectSetString(0, name, OBJPROP_BMPFILE, 1, ResourceOff);
   ObjectSetInteger(0, name, OBJPROP_XDISTANCE, X);
   ObjectSetInteger(0, name, OBJPROP_YDISTANCE, Y);
   ObjectSetInteger(0, name, OBJPROP_CORNER, Corner);
   ObjectSetInteger(0, name, OBJPROP_ANCHOR, Anchors[(int)Corner]);
}

回顾一下,在 OBJPROP_BMPFILE 中指定图像时,按下状态由修饰符 0 表示,释放(未按下)状态(默认)由修饰符 1 表示,这有点出乎意料。

OnDeinit 处理程序在卸载指标时删除该对象。

cpp
void OnDeinit(const int)
{
   ObjectsDeleteAll(0, Prefix);
}

让我们编译这两个指标,并使用默认设置运行 BmpUser.ex5。图形文件 search1.bmp 的图像应该会出现在图表上(见左侧)。

图表上对象中图形资源的正常显示(左)和有问题的显示(右)

图表上对象中图形资源的正常显示(左)和错误显示(右)

如果点击图片,即切换到按下状态,程序将尝试访问 BmpOwner.ex5::search2.bmp 资源(由于附加到它的资源数组 bitmap 无法访问)。结果,我们将看到一个红色方块,表示没有图片的空对象(见上方,右侧)。如果输入参数指定了一个明知不存在或不可共享的资源的路径或名称,始终会出现类似情况。您可以创建自己的程序,在其中描述一个指向某个现有 bmp 文件的资源,然后在指标 BmpUser 的输入参数中指定它。在这种情况下,该指标将能够在图表上显示图片。

连接自定义指标作为资源

为了正常运行,MQL 程序可能需要一个或多个自定义指标。所有这些指标都可以作为资源包含在 ex5 可执行文件中,这使得程序的分发和安装变得更加简便。

带有嵌套指标描述的 #resource 指令具有以下格式:

c
#resource "path_indicator_name.ex5"

设置和查找指定文件的规则与一般所有资源的规则相同。

我们已经在大型智能交易系统示例中,即在 UnityMartingale.mq5 的最终版本中使用了此功能。

c
#resource "\\Indicators\\MQL5Book\\p6\\UnityPercentEvent.ex5"

在那个智能交易系统中,传递给 iCustom 函数的不是指标名称,而是这个资源:"::Indicators\\MQL5Book\\p6\\UnityPercentEvent.ex5"

OnInit 函数中的自定义指标创建一个或多个自身实例时,这种情况需要单独考虑(如果这种技术解决方案看起来很奇怪,我们将在介绍性示例之后给出一个实际示例)。

如我们所知,要在 MQL 程序中使用一个资源,必须以以下形式指定它:path_file_name.ex5::resource_name。例如,如果 EmbeddedIndicator.ex5 指标作为资源包含在另一个指标 MainIndicator.mq5(更准确地说,包含在其二进制镜像 MainIndicator.ex5)中,那么通过 iCustom 调用自身时指定的名称就不能再是简短的、没有路径的名称了,并且路径必须包含 “父” 指标在 MQL5 文件夹内的位置。否则,系统将无法找到嵌套指标。

确实,在正常情况下,一个指标可以使用例如 iCustom(_Symbol, _Period, myself,...) 操作符来调用自身,其中 myself 是一个字符串,等于 MQLInfoString(MQL_PROGRAM_NAME) 或者是之前在代码中分配给 INDICATOR_SHORTNAME 属性的名称。但是当该指标作为资源位于另一个 MQL 程序内部时,这个名称不再指向相应的文件,因为作为资源原型的文件保留在执行编译的计算机上,而在用户的计算机上只有 MainIndicator.ex5 文件。这将需要在启动程序时对程序环境进行一些分析。

让我们通过实践来看一下。

首先,让我们创建一个指标 NonEmbeddedIndicator.mq5。需要注意的是,它位于文件夹 MQL5/Indicators/MQL5Book/p7/SubFolder/ 中,也就是说,相对于为本书这部分的所有指标分配的 p7 文件夹,它位于一个子文件夹中。这样做是有意的,目的是模拟用户计算机上不存在已编译文件的情况。现在我们将看看它是如何工作的(或者更确切地说,展示一下这个问题)。

该指标有一个单一的输入参数 Reference。它的目的是计算自身的副本数量:首次创建时,该参数等于 0,并且该指标将使用参数值 1 创建自身的一个副本。第二个副本在 “看到” 值为 1 后,将不再创建另一个副本(否则,如果没有停止复制的边界条件,我们很快就会耗尽资源)。

c
input int Reference = 0;

handle 变量用于保留副本指标的句柄。

c
int handle = 0;

OnInit 处理程序中,为了清晰起见,我们首先显示 MQL 程序的名称和路径。

c
int OnInit()
{
   const string name = MQLInfoString(MQL_PROGRAM_NAME);
   const string path = MQLInfoString(MQL_PROGRAM_PATH);
   Print(Reference);
   Print("Name: " + name);
   Print("Full path: " + path);
   ...

接下来是适合单独自启动一个指标(以我们熟悉的 NonEmbeddedIndicator.ex5 文件形式存在)的代码。

c
   if(Reference == 0)
   {
      handle = iCustom(_Symbol, _Period, name, 1);
      if(handle == INVALID_HANDLE)
      {
         return INIT_FAILED;
      }
   }
   Print("Success");
   return INIT_SUCCEEDED;
}

我们可以成功地将这样一个指标放置在图表上,并在日志中收到以下类型的记录(你将有自己的文件系统路径):

0
Name: NonEmbeddedIndicator
Full path: C:\Program Files\MT5East\MQL5\Indicators\MQL5Book\p7\SubFolder\NonEmbeddedIndicator.ex5
Success
1
Name: NonEmbeddedIndicator
Full path: C:\Program Files\MT5East\MQL5\Indicators\MQL5Book\p7\SubFolder\NonEmbeddedIndicator.ex5
Success

仅通过使用名称 NonEmbeddedIndicator 就成功启动了副本。

现在让我们先放下这个指标,创建第二个指标 FaultyIndicator.mq5,我们将把第一个指标作为资源包含在其中(注意资源相对路径中对文件夹的指定;这是必要的,因为 FaultyIndicator.mq5 指标位于上一级文件夹 MQL5/Indicators/MQL5Book/p7/ 中)。

c
// FaultyIndicator.mq5
#resource "SubFolder\\NonEmbeddedIndicator.ex5"
   
int handle;
   
int OnInit()
{
   handle = iCustom(_Symbol, _Period, "::SubFolder\\NonEmbeddedIndicator.ex5");
   if(handle == INVALID_HANDLE)
   {
      return INIT_FAILED;
   }
   return INIT_SUCCEEDED;
}

如果你尝试运行已编译的 FaultyIndicator.ex5,将会发生一个错误:

0
Name: NonEmbeddedIndicator
Full path: C:\Program Files\MT5East\MQL5\Indicators\MQL5Book\p7\FaultyIndicator.ex5 »
»  ::SubFolder\NonEmbeddedIndicator.ex5
cannot load custom indicator 'NonEmbeddedIndicator' [4802]

当启动嵌套指标的副本时,它会在描述资源的主指标文件夹中搜索。但是没有 NonEmbeddedIndicator.ex5 文件,因为所需的资源在 FaultyIndicator.ex5 内部。

为了解决这个问题,我们修改 NonEmbeddedIndicator.mq5。首先,让我们给它另一个更合适的名称 EmbeddedIndicator.mq5。在源代码中,我们需要添加一个辅助函数 GetMQL5Path,它可以从已启动 MQL 程序的完整路径中分离出 MQL5 文件夹内的相对部分(如果指标是从资源启动的,这部分还将包含资源名称)。

c
// EmbeddedIndicator.mq5
string GetMQL5Path()
{
   static const string MQL5 = "\\MQL5\\";
   static const int length = StringLen(MQL5) - 1;
   static const string path = MQLInfoString(MQL_PROGRAM_PATH);
   const int start = StringFind(path, MQL5);
   if(start != -1)
   {
      return StringSubstr(path, start + length);
   }
   return path;
}

考虑到新函数,我们将更改 OnInit 处理程序中的 iCustom 调用。

c
int OnInit()
{
   ...
   const string location = GetMQL5Path();
   Print("Location in MQL5:" + location);
   if(Reference == 0)
   {
      handle = iCustom(_Symbol, _Period, location, 1);
      if(handle == INVALID_HANDLE)
      {
         return INIT_FAILED;
      }
   }
   return INIT_SUCCEEDED;
}

让我们确保这次编辑没有破坏指标的启动。将其覆盖在图表上会在日志中出现预期的行:

0
Name: EmbeddedIndicator
Full path: C:\Program Files\MT5East\MQL5\Indicators\MQL5Book\p7\SubFolder\EmbeddedIndicator.ex5
Location in MQL5:\Indicators\MQL5Book\p7\SubFolder\EmbeddedIndicator.ex5
Success
1
Name: EmbeddedIndicator
Full path: C:\Program Files\MT5East\MQL5\Indicators\MQL5Book\p7\SubFolder\EmbeddedIndicator.ex5
Location in MQL5:\Indicators\MQL5Book\p7\SubFolder\EmbeddedIndicator.ex5
Success

在这里,我们添加了 GetMQL5Path 函数获取的相对路径的调试输出。现在这个路径在 iCustom 中使用,并且在这种模式下它是有效的:已经创建了一个副本。

现在让我们将这个指标作为资源嵌入到 MQL5Book/p7 文件夹中的另一个指标 MainIndicator.mq5 中。MainIndicator.mq5FaultyIndicator.mq5 完全相同,除了连接的资源。

c
// MainIndicator.mq5
#resource "SubFolder\\EmbeddedIndicator.ex5"
...
int OnInit()
{
   handle = iCustom(_Symbol, _Period, "::SubFolder\\EmbeddedIndicator.ex5");
   ...
}

让我们编译并运行它。日志中会出现带有新相对路径的记录,该路径包含嵌套资源。

0
Name: EmbeddedIndicator
Full path: C:\Program Files\MT5East\MQL5\Indicators\MQL5Book\p7\MainIndicator.ex5 »
»  ::SubFolder\EmbeddedIndicator.ex5
Location in MQL5:\Indicators\MQL5Book\p7\MainIndicator.ex5::SubFolder\EmbeddedIndicator.ex5
Success
1
Name: EmbeddedIndicator
Full path: C:\Program Files\MT5East\MQL5\Indicators\MQL5Book\p7\MainIndicator.ex5 »
»  ::SubFolder\EmbeddedIndicator.ex5
Location in MQL5:\Indicators\MQL5Book\p7\MainIndicator.ex5::SubFolder\EmbeddedIndicator.ex5
Success

如我们所见,这次嵌套指标成功地创建了自身的一个副本,因为它使用了带有相对路径和资源名称 \\Indicators\\MQL5Book\\p7\\MainIndicator.ex5::SubFolder\\EmbeddedIndicator.ex5 的限定名称。

在多次尝试启动这个指标的实验过程中,请注意,在主指标被移除后,嵌套副本不会立即从图表中卸载。因此,只有在我们等待卸载发生后才能进行重新启动:否则,仍在运行的副本将被重复使用,并且上述初始化行将不会出现在日志中。为了控制卸载,在 OnDeinit 处理程序中添加了 Reference 值的打印输出。

我们承诺过要展示创建指标副本并不是什么不寻常的事情。作为这种技术的一个应用演示,我们使用指标 DeltaPrice.mq5,它计算给定阶数的价格增量之差。阶数 0 表示不进行差分(仅用于检查原始时间序列),1 表示一阶差分,2 表示二阶差分,依此类推。

阶数在输入参数 Differentiating 中指定。

c
input int Differencing = 1;

差分序列将显示在子窗口的单个缓冲区中。

c
#property indicator_separate_window
#property indicator_buffers 1
#property indicator_plots   1
   
#property indicator_type1 DRAW_LINE
#property indicator_color1 clrDodgerBlue
#property indicator_width1 2
#property indicator_style1 STYLE_SOLID
   
double Buffer[];

OnInit 处理程序中,我们设置缓冲区并创建相同的指标,将输入参数的值减 1 后传递进去。

c
#include <MQL5Book/AppliedTo.mqh> // APPLIED_TO_STR macro
 
int handle = 0;
   
int OnInit()
{
   const string label = "DeltaPrice (" + (string)Differencing + "/"
      + APPLIED_TO_STR() + ")";
   IndicatorSetString(INDICATOR_SHORTNAME, label);
   PlotIndexSetString(0, PLOT_LABEL, label);
   
   SetIndexBuffer(0, Buffer);
   if(Differencing > 1)
   {
      handle = iCustom(_Symbol, _Period, GetMQL5Path(), Differencing - 1);
      if(handle == INVALID_HANDLE)
      {
         return INIT_FAILED;
      }
   }
   return INIT_SUCCEEDED;
}

为了避免将指标作为资源嵌入时可能出现的问题,我们使用已经验证过的函数 GetMQL5Path

OnCalculate 函数中,我们执行时间序列相邻值相减的操作。当 Differentiating 等于 1 时,操作数是价格数组的元素。当 Differentiating 的值更大时,我们读取为前一阶数创建的指标副本的缓冲区。

c
int OnCalculate(const int rates_total,
                const int prev_calculated,
                const int begin,
                const double &price[])
{
   for(int i = fmax(prev_calculated - 1, 1); i < rates_total; ++i)
   {
      if(Differencing > 1)
      {
         static double value[2];
         CopyBuffer(handle, 0, rates_total - i - 1, 2, value);
         Buffer[i] = value[1] - value[0];
      }
      else if(Differencing == 1)
      {
         Buffer[i] = price[i] - price[i - 1];
      }
      else
      {
         Buffer[i] = price[i];
      }
   }
   return rates_total;
}

在指标设置对话框的 “应用于” 下拉列表中设置差分价格的初始类型。默认情况下,这是收盘价。

这就是在图表上具有不同差分阶数的该指标的几个副本的样子。

动态资源创建:ResourceCreate

#resource 指令可在编译阶段将资源嵌入程序,因此可称其为静态资源。然而,在程序执行阶段,常常需要生成资源(创建全新资源或修改现有资源)。为此,MQL5 提供了 ResourceCreate 函数,借助该函数创建的资源被称为动态资源。

此函数有两种形式:第一种可从文件加载图片和声音;第二种则用于基于内存中准备好的像素数组创建位图图像。

plaintext
bool ResourceCreate(const string resource, const string filepath)

该函数从 filepath 所指定的文件加载名为 resource 的资源。若路径以反斜杠 \ 开头(在常量字符串中需双写为 \\,例如 \\path\\name.ext),则会在终端数据目录下的 MQL5 文件夹的相对路径中查找文件(例如,\\Files\\CustomSounds\\Hello.wav 对应的是 MQL5/Files/CustomSounds/Hello.wav)。若路径中没有反斜杠,则会从调用该函数的可执行文件所在的文件夹开始查找资源。

路径可以指向硬编码在第三方或当前 MQL 程序中的静态资源。例如,某个脚本能够基于“资源变量”部分所讨论的 BmpOwner.mq5 指标中的图片来创建资源。

plaintext
ResourceCreate("::MyImage", "\\Indicators\\MQL5Book\\p7\\BmpOwner.ex5::search1.bmp");

resource 参数中的资源名称可以以双冒号开头(不过这并非必需,因为若没有该前缀,调用时会自动为名称添加 ::)。这样能确保在 ResourceCreate 调用中声明资源的语句与后续访问该资源的语句(例如设置 OBJPROP_BMPFILE 属性时)保持统一。

当然,若仅想将第三方图像资源加载到图表上的对象中,上述创建动态资源的语句就显得多余了,因为只需直接将字符串 \\Indicators\\MQL5Book\\p7\\BmpOwner.ex5::search1.bmp 赋值给 OBJPROP_BMPFILE 属性即可。但如果需要编辑图像,动态资源就必不可少了。接下来,我们会在“读取和修改资源数据”部分给出示例。

动态资源可通过其完整名称供其他 MQL 程序公开访问,完整名称包含创建该资源的程序的路径和名称。例如,若之前的 ResourceCreate 调用是由脚本 MQL5/Scripts/MyExample.ex5 发起的,那么另一个 MQL 程序可使用完整链接 \\Scripts\\MyExample.ex5::MyImage 访问同一资源,而同一文件夹中的其他脚本则可使用简写形式 MyExample.ex5::MyImage(此处相对路径已简化)。前文已介绍过完整路径(从 MQL5 根文件夹开始)和相对路径的书写规则。

ResourceCreate 函数执行后会返回一个布尔值,用以指示操作成功(true)或出错(false)。和往常一样,可在 _LastError 变量中查看错误代码。具体而言,你可能会遇到以下错误:

  • ERR_RESOURCE_NAME_DUPLICATED (4015) —— 动态资源和静态资源名称重复。
  • ERR_RESOURCE_NOT_FOUND (4016) —— 在 filepath 参数中指定的资源或文件未找到。
  • ERR_RESOURCE_UNSUPPOTED_TYPE (4017) —— 不支持的资源类型或资源大小超过 2 GB。
  • ERR_RESOURCE_NAME_IS_TOO_LONG (4018) —— 资源名称超过 63 个字符。

上述情况不仅适用于该函数的第一种形式,也适用于第二种形式。

plaintext
bool ResourceCreate(const string resource, const uint &data[], uint img_width, uint img_height, uint data_xoffset, uint data_yoffset, uint data_width, ENUM_COLOR_FORMAT color_format)

resource 参数仍然表示新资源的名称,而图像的内容由其余参数指定。

data 数组可以是一维数组(data[])或二维数组(data[][]),用于传递光栅的像素点。img_widthimg_height 参数用于设置显示图像的尺寸(以像素为单位)。这些尺寸可能小于 data 数组中图像的实际尺寸,从而实现裁剪效果,即仅输出原始图像的一部分。data_xoffsetdata_yoffset 参数确定“裁剪框”左上角的坐标。

data_width 参数表示原始图像在 data 数组中的完整宽度。若其值为 0,则表示该宽度与 img_width 相同。data_width 参数仅在 data 参数指定为一维数组时才有意义,因为对于二维数组,其两个维度的尺寸是已知的(此时,data_width 参数会被忽略,并假定其等于 data[][] 数组的第二维大小)。

在最常见的情况下,若要完整显示图像(即“原样”显示),可使用以下语法:

plaintext
ResourceCreate(name, data, width, height, 0, 0, 0, ...);

例如,若程序中有一个静态资源,用二维位图数组表示:

plaintext
#resource "static.bmp" as bitmap data[][]

那么,基于该静态资源创建动态资源的操作可按如下方式进行:

plaintext
ResourceCreate("dynamic", data, ArrayRange(data, 1), ArrayRange(data, 0), 0, 0, 0, ...);

不仅在需要直接编辑资源时,而且在控制资源显示时的颜色处理方式时,基于静态资源创建动态资源都很有用。该功能通过函数的最后一个参数 color_format 来选择。此参数使用 ENUM_COLOR_FORMAT 枚举类型。

标识符描述
COLOR_FORMAT_XRGB_NOALPHA忽略 alpha 通道分量(透明度)
COLOR_FORMAT_ARGB_RAW终端不处理颜色分量
COLOR_FORMAT_ARGB_NORMALIZE终端处理颜色分量(见下文)

COLOR_FORMAT_XRGB_NOALPHA 模式下,图像将无特效显示:每个像素点以纯色显示(这是最快的绘制方式)。另外两种模式在显示像素时会考虑每个像素高字节中的透明度,但效果不同。在 COLOR_FORMAT_ARGB_NORMALIZE 模式下,终端在调用 ResourceCreate 准备光栅时,会对每个像素点的颜色分量进行以下转换:

plaintext
R = R * A / 255
G = G * A / 255
B = B * A / 255
A = A

#resource 指令中的静态图像资源是通过 COLOR_FORMAT_ARGB_NORMALIZE 方式连接的。

动态资源中的数组大小限制为 INT_MAX 字节(2147483647,即 2 GB),这大大超过了编译器在处理静态 #resource 指令时所施加的限制:文件大小不能超过 128 MB。

若调用该函数的第二种形式来创建同名资源,但更改了其他参数(像素数组内容、宽度、高度或偏移量),则不会重新创建新资源,而是直接更新现有资源。只有拥有该资源的程序(即最初创建该资源的程序)才能以这种方式修改资源。

若在不同图表上运行的程序副本中创建动态资源时,每个副本都需要有自己的资源,则应在资源名称中添加 ChartID

为了演示以各种颜色方案动态创建图像,我们来剖析 ARGBbitmap.mq5 脚本。

该脚本静态附加了图像 argb.bmp

plaintext
#resource "argb.bmp" as bitmap Data[][]

用户通过 ColorFormat 参数选择颜色格式化方法。

plaintext
input ENUM_COLOR_FORMAT ColorFormat = COLOR_FORMAT_XRGB_NOALPHA;

用于显示图像的对象名称和动态资源名称分别由变量 BitmapObjectResName 描述。

plaintext
const string BitmapObject = "BitmapObject";
const string ResName = "::image";

以下是该脚本的主函数:

plaintext
void OnStart()
{
   ResourceCreate(ResName, Data, ArrayRange(Data, 1), ArrayRange(Data, 0),
      0, 0, 0, ColorFormat);
   
   ObjectCreate(0, BitmapObject, OBJ_BITMAP_LABEL, 0, 0, 0);
   ObjectSetInteger(0, BitmapObject, OBJPROP_XDISTANCE, 50);
   ObjectSetInteger(0, BitmapObject, OBJPROP_YDISTANCE, 50);
   ObjectSetString(0, BitmapObject, OBJPROP_BMPFILE, ResName);
   
   Comment("Press ESC to stop the demo");
   const ulong start = TerminalInfoInteger(TERMINAL_KEYSTATE_ESCAPE);
   while(!IsStopped()  // waiting for the user's command to end the demo
   && TerminalInfoInteger(TERMINAL_KEYSTATE_ESCAPE) == start)
   {
      Sleep(1000);
   }
   
   Comment("");
   ObjectDelete(0, BitmapObject);
   ResourceFree(ResName);
}

该脚本以指定的颜色模式创建新资源,并将其赋值给 OBJ_BITMAP_LABEL 类型对象的 OBJPROP_BMPFILE 属性。接着,脚本会等待用户明确停止脚本或按下 Esc 键,然后删除对象(通过调用 ObjectDelete),并使用 ResourceFree 函数删除资源。需注意,删除对象并不会自动删除资源,这就是为何需要使用 ResourceFree 函数,我们将在下一节讨论该函数。

若不调用 ResourceFree,则动态资源在 MQL 程序终止后仍会保留在终端内存中,直至终端关闭。这使得动态资源可作为存储库或 MQL 程序间的信息交换工具。

使用 ResourceCreate 函数的第二种形式创建的动态资源不一定是图像。若不将 data 数组用于渲染,它可以包含任意数据。在这种情况下,重要的是设置 COLOR_FORMAT_XRGB_NOALPHA 方案。我们稍后会给出这样的示例。

在此期间,让我们来验证 ARGBbitmap.mq5 脚本的运行情况。

上述图像 argb.bmp 包含透明度信息:左上角具有完全透明的背景,且透明度沿对角线向右下角逐渐减弱。

以下图像展示了该脚本在三种不同模式下的运行结果。

删除动态资源:ResourceFree

ResourceFree 函数用于移除之前创建的动态资源,并释放其占用的内存。如果你不调用 ResourceFree 函数,动态资源将一直保留在内存中,直到当前终端会话结束。这可以作为一种方便的数据存储方式,但对于常规的图像使用,建议在不再需要这些图像时释放它们。

即使在删除资源后,附着于该被删除资源的图形对象仍会正确显示。然而,新创建的图形对象(OBJ_BITMAPOBJ_BITMAP_LABEL)将无法再使用已删除的资源。

c
bool ResourceFree(const string resource)

资源名称在 resource 参数中设置,并且必须以 "::" 开头。

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

该函数仅删除由给定 MQL 程序创建的动态资源,而不会删除 “第三方” 创建的资源。

在上一节中,我们看到了脚本 ARGBbitmap.mq5 的一个示例,它在操作完成时调用了 ResourceFree 函数。

保存图像到文件:ResourceSave

MQL5 API 允许你使用 ResourceSave 函数将资源写入 BMP 文件。目前该框架仅支持图像资源。

cpp
bool ResourceSave(const string resource, const string filename)

resourcefilename 参数分别指定资源名称和文件名称。资源名称必须以 "::" 开头。文件名可以包含相对于 MQL5/Files 文件夹的路径。如有必要,该函数将创建所有中间子目录。如果指定的文件已存在,它将被覆盖。

如果成功,该函数返回 true

为了测试此函数的运行情况,最好创建一个原始图像。我们正好有合适的图像来进行此操作。

在面向对象编程(OOP)的学习中,在“类和接口”这一章节里,我们开始了一系列关于图形形状的示例:从“类定义”部分的第一个版本 Shapes1.mq5 到“嵌套类型”部分的最后一个版本 Shapes6.mq5。那时我们还无法进行绘图操作,直到“图形对象”这一章节,我们才能够在脚本 ObjectShapesDraw.mq5 中实现可视化。现在,在学习了图形资源之后,是时候进行另一次“升级”了。

在新版本的脚本 ResourceShapesDraw.mq5 中,我们将绘制这些形状。为了更轻松地分析与前一版本的差异,我们将保留相同的一组形状:矩形、正方形、椭圆形、圆形和三角形。这样做是为了举例说明,并非因为有什么限制我们绘制更多形状:相反,我们有扩展形状集合、视觉效果和标签的潜力。我们将从当前示例开始,在几个示例中研究这些特性。不过请注意,在本书的范围内无法展示其全部应用场景。

在生成并绘制形状之后,我们将生成的资源保存到文件中。

形状类层次结构的基础是 Shape 类,它有一个 draw 方法。

cpp
class Shape
{
public:
   ...
   virtual void draw() = 0;
   ...
}

在派生类中,它是基于图形对象实现的,调用 ObjectCreate 函数,随后使用 ObjectSet 函数设置对象。这种绘图的共享画布就是图表本身。

现在我们需要根据特定的形状在某个共享资源中绘制像素。最好将共享资源以及在其中修改像素的方法,分配到一个单独的类中,或者更好的是,分配到一个接口中。

一个抽象实体将使我们无需与创建和配置资源的方法建立联系。特别是,我们的下一个实现将把资源放置在 OBJ_BITMAP_LABEL 对象中(就像我们在本章中已经做过的那样),而对于某些情况,可能只需在内存中生成图像并保存到磁盘而无需绘图(就像许多交易员喜欢定期捕获图表状态一样)。

我们将这个接口命名为 Drawing

cpp
interface Drawing
{
   void point(const float x1, const float y1, const uint pixel);
   void line(const int x1, const int y1, const int x2, const int y2, const color clr);
   void rect(const int x1, const int y1, const int x2, const int y2, const color clr);
};

这里只有三个最基本的绘图方法,对于这种情况已经足够了。

point 方法是公共的(这使得可以绘制单个点),但在某种意义上,它是低级别的,因为其他所有方法都将通过它来实现。这就是为什么其中的坐标是实数,并且像素的内容是 uint 类型的现成值。这将在必要时允许应用各种抗锯齿算法,以便形状不会因为像素化而看起来呈阶梯状。在这里我们不会涉及这个问题。

考虑到接口,Shape::draw 方法变为以下形式:

cpp
virtual void draw(Drawing *drawing) = 0;

然后,在 Rectangle 类中,将矩形的绘制委托给新接口非常容易。

cpp
class Rectangle : public Shape
{
protected:
   int dx, dy; // 大小(宽度,高度)
   ...
public:
   void draw(Drawing *drawing) override
   {
 // x, y - 形状中的锚点(中心)
      drawing.rect(x — dx / 2, y — dy / 2, x + dx / 2, y + dy / 2, backgroundColor);
   }
};

绘制椭圆需要更多的工作。

cpp
class Ellipse : public Shape
{
protected:
   int dx, dy; // 长半径和短半径
   ...
public:
   void draw(Drawing *drawing) override
   {
      // (x, y) - 中心
      const int hh = dy * dy;
      const int ww = dx * dx;
      const int hhww = hh * ww;
      int x0 = dx;
      int step = 0;
      
      // 主水平直径
      drawing.line(x - dx, y, x + dx, y, backgroundColor);
      
      // 上半部分和下半部分的水平线,长度对称递减
      for(int j = 1; j <= dy; j++)
      {
         for(int x1 = x0 - (step - 1); x1 > 0; --x1)
         {
            if(x1 * x1 * hh + j * j * ww <= hhww)
            {
               step = x0 - x1;
               break;
            }
         }
         x0 -= step;
         drawing.line(x - x0, y - j, x + x0, y - j, backgroundColor);
         drawing.line(x - x0, y + j, x + x0, y + j, backgroundColor);
      }
   }
};

最后,对于三角形,渲染实现如下。

cpp
class Triangle: public Shape
{
protected:
   int dx;  // 一个尺寸,因为三角形是等边的
   ...
public:
   virtual void draw(Drawing *drawing) override
   {
      // (x, y) - 中心
      // R = a * sqrt(3) / 3
      // p0: x, y + R
      // p1: x - R * cos(30), y - R * sin(30)
      // p2: x + R * cos(30), y - R * sin(30)
      // 勾股定理求高:dx * dx = dx * dx / 4 + h * h
      // sqrt(dx * dx * 3/4) = h
      const double R = dx * sqrt(3) / 3;
      const double H = sqrt(dx * dx * 3 / 4);
      const double angle = H / (dx / 2);
      
      // 主垂直线(三角形的高)
      const int base = y + (int)(R - H);
      drawing.line(x, y + (int)R, x, base, backgroundColor);
      
      // 左右两侧较小的垂直线,对称
      for(int j = 1; j <= dx / 2; ++j)
      {
         drawing.line(x - j, y + (int)(R - angle * j), x - j, base, backgroundColor);
         drawing.line(x + j, y + (int)(R - angle * j), x + j, base, backgroundColor);
      }
   }
};

现在让我们来看一下从 Drawing 接口派生的 MyDrawing 类。MyDrawing 类必须根据形状中对接口方法的调用,确保在位图中显示某个资源。因此,该类描述了图形对象名称(object)和资源名称(sheet)的变量,以及用于存储图像的 uint 类型的数据数组。此外,我们将之前在 OnStart 处理程序中声明的形状数组 shapes 也移到了这里。由于 MyDrawing 负责绘制所有形状,在这里管理它们的集合会更好。

cpp
class MyDrawing: public Drawing
{
   const string object;     // 带有位图的对象
   const string sheet;      // 资源
   uint data[];             // 像素
   int width, height;       // 尺寸
   AutoPtr<Shape> shapes[]; // 图形/形状
   const uint bg;           // 背景颜色
   ...

在构造函数中,我们为整个图表的大小创建一个图形对象,并为数据数组分配内存。画布用零(表示“黑色透明”)或 background 参数中传入的任何值填充,然后基于此创建一个资源。默认情况下,资源名称以字母 'D' 开头,并包含当前图表的 ID,但你也可以指定其他名称。

cpp
public:
   MyDrawing(const uint background = 0, const string s = NULL) :
      object((s == NULL ? "Drawing" : s)),
      sheet("::" + (s == NULL ? "D" + (string)ChartID() : s)), bg(background)
   {
      width = (int)ChartGetInteger(0, CHART_WIDTH_IN_PIXELS);
      height = (int)ChartGetInteger(0, CHART_HEIGHT_IN_PIXELS);
      ArrayResize(data, width * height);
      ArrayInitialize(data, background);
   
      ResourceCreate(sheet, data, width, height, 0, 0, width, COLOR_FORMAT_ARGB_NORMALIZE);
      
      ObjectCreate(0, object, OBJ_BITMAP_LABEL, 0, 0, 0);
      ObjectSetInteger(0, object, OBJPROP_XDISTANCE, 0);
      ObjectSetInteger(0, object, OBJPROP_YDISTANCE, 0);
      ObjectSetInteger(0, object, OBJPROP_XSIZE, width);
      ObjectSetInteger(0, object, OBJPROP_YSIZE, height);
      ObjectSetString(0, object, OBJPROP_BMPFILE, sheet);
   }

调用代码可以使用 resource 方法获取资源名称。

cpp
   string resource() const
   {
      return sheet;
   }

在析构函数中删除资源和对象。

cpp
   ~MyDrawing()
   {
      ResourceFree(sheet);
      ObjectDelete(0, object);
   }

push 方法填充形状数组。

cpp
   Shape *push(Shape *shape)
   {
      shapes[EXPAND(shapes)] = shape;
      return shape;
   }

draw 方法绘制形状。它只需在循环中调用每个形状的 draw 方法,然后更新资源和图表。

cpp
   void draw()
   {
      for(int i = 0; i < ArraySize(shapes); ++i)
      {
         shapes[i][].draw(&this);
      }
      ResourceCreate(sheet, data, width, height, 0, 0, width, COLOR_FORMAT_ARGB_NORMALIZE);
      ChartRedraw();
   }

下面是最重要的方法,即 Drawing 接口的方法,它们实际实现了绘图功能。

让我们从 point 方法开始,目前我们以简化形式呈现它(我们稍后会处理改进部分)。

cpp
   virtual void point(const float x1, const float y1, const uint pixel) override
   {
      const int x_main = (int)MathRound(x1);
      const int y_main = (int)MathRound(y1);
      const int index = y_main * width + x_main;
      if(index >= 0 && index < ArraySize(data))
      {
         data[index] = pixel;
      }
   }

基于 point 方法,很容易实现线条绘制。当起点和终点的坐标在其中一个维度上相同时,我们使用 rect 方法进行绘制,因为直线是单位厚度矩形的退化情况。

cpp
   virtual void line(const int x1, const int y1, const int x2, const int y2, const color clr) override
   {
      if(x1 == x2) rect(x1, y1, x1, y2, clr);
      else if(y1 == y2) rect(x1, y1, x2, y1, clr);
      else
      {
         const uint pixel = ColorToARGB(clr);
         double angle = 1.0 * (y2 - y1) / (x2 - x1);
         if(fabs(angle) < 1) // 沿距离最大的轴(x 轴)步进
         {
            const int sign = x2 > x1 ? +1 : -1;
            for(int i = 0; i <= fabs(x2 - x1); ++i)
            {
               const float p = (float)(y1 + sign * i * angle);
               point(x1 + sign * i, p, pixel);
            }
         }
         else // 或沿 y 轴步进
         {
            const int sign = y2 > y1 ? +1 : -1;
            for(int i = 0; i <= fabs(y2 - y1); ++i)
            {
               const float p = (float)(x1 + sign * i / angle);
               point(p, y1 + sign * i, pixel);
            }
         }
      }
   }

这是 rect 方法。

cpp
   virtual void rect(const int x1, const int y1, const int x2, const int y2, const color clr) override
   {
      const uint pixel = ColorToARGB(clr);
      for(int i = fmin(x1, x2); i <= fmax(x1, x2); ++i)
      {
         for(int j = fmin(y1, y2); j <= fmax(y1, y2); ++j)
         {
            point(i, j, pixel);
         }
      }
   }

现在我们需要修改 OnStart 处理程序,这样脚本就准备好了。

首先,我们设置图表(隐藏所有元素)。理论上这不是必需的:保留它是为了与原型脚本匹配。

cpp
void OnStart()
{
   ChartSetInteger(0, CHART_SHOW, false);
   ...

接下来,我们描述 MyDrawing 类的对象,生成预定义数量的随机形状(这里一切保持不变,包括 addRandomShape 生成器和等于 21 的 FIGURES 宏),在资源中绘制它们,并在图表上的对象中显示它们。

cpp
   MyDrawing raster;
   
   for(int i = 0; i < FIGURES; ++i)
   {
      raster.push(addRandomShape());
   }
   
   raster.draw(); // 显示初始状态
   ...

在示例 ObjectShapesDraw.mq5 中,我们启动了一个无限循环,在其中随机移动图形。让我们在这里重复这个技巧。这里我们需要添加 MyDrawing 类,因为形状数组存储在其中。让我们编写一个简单的 shake 方法。

cpp
class MyDrawing: public Drawing
{
public:
   ...
   void shake()
   {
      ArrayInitialize(data, bg);
      for(int i = 0; i < ArraySize(shapes); ++i)
      {
         shapes[i][].move(random(20) - 10, random(20) - 10);
      }
   }
   ...
};

然后,在 OnStart 中,我们可以在循环中使用新方法,直到用户停止动画。

cpp
void OnStart()
{
   ...
   while(!IsStopped())
   {
      Sleep(250);
      raster.shake();
      raster.draw();
   }
   ...
}

此时,实际上重复了前一个示例的功能。但我们需要添加将图像保存到文件的功能。所以让我们添加一个输入参数 SaveImage

cpp
input bool SaveImage = false;

当它设置为 true 时,检查 ResourceSave 函数的执行情况。

cpp
void OnStart()
{
   ...
   if(SaveImage)
   {
      const string filename = "temp.bmp";
      if(ResourceSave(raster.resource(), filename))
      {
         Print("Bitmap image saved: ", filename);
      }
      else
      {
         Print("Can't save image ", filename, ", ", E2S(_LastError));
      }
   }
}

此外,既然我们在讨论输入变量,那就让用户选择背景,并将结果值传递给 MyDrawing 构造函数。

cpp
input color BackgroundColor = clrNONE;
void OnStart()
{
   ...
   MyDrawing raster(BackgroundColor != clrNONE ? ColorToARGB(BackgroundColor) : 0);
   ...
}

所以,第一次测试的一切都准备好了。

如果你运行脚本 ResourceShapesDraw.mq5,图表将形成如下图像。

带有一组随机形状的资源位图

带有一组随机形状的资源位图

当将此图像与我们在示例 ObjectShapesDraw.mq5 中看到的图像进行比较时,会发现我们新的渲染方式与终端显示对象的方式有些不同。尽管形状和颜色是正确的,但形状重叠的地方表示方式不同。

我们的脚本用指定的颜色绘制形状,按照它们在数组中出现的顺序相互叠加。后面的形状会覆盖前面的形状。而终端则在重叠的地方应用了某种颜色混合(反相)。

这两种方法都有其存在的价值,这里没有错误。然而,在绘图时是否有可能实现类似的效果呢?

我们对绘图过程拥有完全的控制权,所以不仅可以应用终端所使用的效果,还可以对其应用任何效果。

除了最初简单的绘图方式,我们再实现几种模式。所有这些模式都汇总在 COLOR_EFFECT 枚举中。

cpp
enum COLOR_EFFECT
{
   PLAIN,         // 简单的重叠绘图(默认)
   COMPLEMENT,    // 用互补色绘图(类似于终端中的方式) 
   BLENDING_XOR,  // 用异或 '^' 混合颜色
   DIMMING_SUM,   // 用 '+' “使颜色变暗”
   LIGHTEN_OR,    // 用 '|' “使颜色变亮”
};

我们添加一个输入变量来选择模式。

cpp
input COLOR_EFFECT ColorEffect = PLAIN;

我们在 MyDrawing 类中支持这些模式。首先,描述相应的字段和方法。

cpp
class MyDrawing: public Drawing
{
   ...
   COLOR_EFFECT xormode;
   ...
public:
   void setColorEffect(const COLOR_EFFECT x)
   {
      xormode = x;
   }
   ...

然后我们改进 point 方法。

cpp
   virtual void point(const float x1, const float y1, const uint pixel) override
   {
      ...
      if(index >= 0 && index < ArraySize(data))
      {
         switch(xormode)
         {
         case COMPLEMENT:
            data[index] = (pixel ^ (1 - data[index])); // 与互补色混合
            break;
         case BLENDING_XOR:
            data[index] = (pixel & 0xFF000000) | (pixel ^ data[index]); // 直接混合(异或)
            break;
         case DIMMING_SUM:
            data[index] =  (pixel + data[index]); // “变暗”(相加)
            break;
         case LIGHTEN_OR:
            data[index] =  (pixel & 0xFF000000) | (pixel | data[index]); // “变亮”(或运算)
            break;
         case PLAIN:
         default:
            data[index] = pixel;
         }
      }
   }

你可以尝试以不同模式运行脚本并比较结果。不要忘记自定义背景的功能。以下是颜色变亮模式的示例图像。

带有颜色变亮混合效果的图形图像

带有颜色变亮混合效果的图形图像

为了直观地看出效果的差异,你可以关闭颜色随机化和形状移动功能。对象重叠的标准方式对应于 COMPLEMENT 常量。

作为最后的实验,启用 SaveImage 选项。在 OnStart 处理程序中,现在我们在生成图像文件的名称时使用当前模式的名称。我们需要将图表上的图像副本保存到文件中。

cpp
   ...
   if(SaveImage)
   {
      const string filename = EnumToString(ColorEffect) + ".bmp";
      if(ResourceSave(raster.resource(), filename))
      ...

对于我们的 Drawing 接口来说,要进行更复杂的图形构建可能还不够。因此,你可以使用 MetaTrader 5 提供的现成绘图类,或者使用 mql5.com 代码库中可用的绘图类。特别是,可以查看 MQL5/Include/Canvas/Canvas.mqh 文件。

字体与文本输出到图形资源

除了在图形资源数组中渲染单个像素外,我们还可以使用内置函数来显示文本。这些函数可以改变当前字体及其属性(TextSetFont),获取能够容纳给定字符串的矩形尺寸(TextGetSize),以及直接将标题插入到生成的图像中(TextOut)。

cpp
bool TextSetFont(const string name, int size, uint flags, int orientation = 0)

该函数为后续使用 TextOut 函数在图像缓冲区中绘制文本设置字体及其属性(详见后文)。name 参数可以是内置的 Windows 字体名称,或者是通过资源指令连接的 TrueType 字体文件(.ttf 字体)(如果名称以 :: 开头)。

size(大小)可以用点(排版单位)或像素(屏幕点)来指定。正值表示单位是像素,负值则以十分之一磅为单位进行度量。根据用户显示器的技术能力和设置,以像素为单位的高度在用户眼中可能会有所不同。而以点为单位的高度,对每个人来说大致(“凭肉眼判断”)是相同的。

排版中的 “点” 是一个物理长度单位,传统上等于 1/72 英寸。因此,1 点等于 0.352778 毫米。屏幕上的像素是一个虚拟的长度度量单位。其物理大小取决于屏幕的硬件分辨率。例如,在屏幕密度为 96 DPI(每英寸点数)的情况下,1 像素将占用 0.264583 毫米或 0.75 点。然而,大多数现代显示器的 DPI 值要高得多,因此像素也更小。正因为如此,包括 Windows 在内的操作系统早就有了增加界面元素可见缩放比例的设置。所以,如果你以点为单位指定大小(负值),那么以像素为单位的文本大小将取决于操作系统中的显示器和缩放设置(例如,“标准” 100%、“中等” 125% 或 “大” 150%)。

放大操作会使系统人为地放大显示的像素。这相当于以像素为单位减小屏幕尺寸,并且系统会应用有效 DPI 以实现相同的物理尺寸。如果启用了缩放,那么会将有效 DPI 报告给包括终端以及 MQL 程序在内的程序。如果需要,你可以从 TERMINAL_SCREEN_DPI 属性中获取屏幕的 DPI(请参阅屏幕规格)。然而在实际中,通过以点为单位设置字体大小,我们无需根据 DPI 重新计算其大小,因为系统会为我们完成这项工作。

默认字体是 Arial,默认大小是 -120(12 磅)。控件,特别是图表上的内置对象,也使用以点为单位的字体大小进行操作。例如,如果在 MQL 程序中,你想绘制与 OBJ_LABEL 对象中大小为 10 点的文本相同大小的文本,则应使用大小参数等于 -100。

flags 参数设置描述字体样式的标志组合。该组合是通过按位运算符 OR|)构成的位掩码。标志分为两组:样式标志和粗细标志。

以下表格列出了样式标志。它们可以混合使用。

标志描述
FONT_ITALIC斜体
FONT_UNDERLINE下划线
FONT_STRIKEOUT中划线

粗细标志具有与之对应的相对权重(用于比较预期效果)。

标志描述
FW_DONTCARE0(将应用系统默认值)
FW_THIN100
FW_EXTRALIGHT, FW_ULTRALIGHT200
FW_LIGHT300
FW_NORMAL, FW_REGULAR400
FW_MEDIUM500
FW_SEMIBOLD, FW_DEMIBOLD600
FW_BOLD700
FW_EXTRABOLD, FW_ULTRABOLD800
FW_HEAVY, FW_BLACK900

在标志组合中只能使用这些值中的一个。

orientation 参数指定文本相对于水平方向的角度,单位为十分之一度。例如,orientation = 0 表示正常的文本输出,而 orientation = 450 将导致文本倾斜 45 度(逆时针方向)。

请注意,在一次 TextSetFont 调用中所做的设置将影响所有后续的 TextOut 调用,直到这些设置被更改。

如果函数成功执行,则返回 true;如果出现问题(例如,找不到字体),则返回 false

在描述完所有函数后,我们将考虑使用此函数以及另外两个函数的示例。

cpp
bool TextGetSize(const string text, uint &width, uint &height)

该函数返回在当前字体设置下(可以是默认字体或在上一次 TextSetFont 调用中指定的字体)一行文本的宽度和高度。

text 参数传递一个需要获取其长度和宽度(以像素为单位)的字符串。函数会根据 widthheight 参数中的引用写入尺寸值。

需要注意的是,在调用 TextSetFont 时由 orientation 参数指定的显示文本的旋转(倾斜)对尺寸计算没有任何影响。换句话说,如果文本要旋转 45 度,那么 MQL 程序本身必须计算出能够容纳该文本的最小正方形。TextGetSize 函数计算的是标准(水平)位置下的文本大小。

cpp
bool TextOut(const string text, int x, int y, uint anchor, uint &data[], uint width, uint height, uint color, ENUM_COLOR_FORMAT color_format)

该函数在图形缓冲区的指定坐标处绘制文本,同时考虑颜色、格式以及之前的设置(字体、样式和方向)。

text 参数传递的文本必须是一行的形式。

以像素为单位指定的 xy 坐标定义了图形缓冲区中显示文本的点。生成的文本在点 (x, y) 处的位置取决于 anchor 参数中的绑定方法(详见后文)。

缓冲区由 data 数组表示,尽管该数组是一维的,但它存储了一个尺寸为 width x height 点的二维 “画布”。这个数组可以从 ResourceReadImage 函数获取,也可以由 MQL 程序分配。在完成所有编辑操作(包括文本输出)后,你应该基于此缓冲区创建一个新资源,或者将其应用于已有的资源。在这两种情况下,都应该调用 ResourceCreate

文本的颜色和处理颜色的方式由 colorcolor_format 参数设置(请参阅 ENUM_COLOR_FORMAT)。请注意,用于表示颜色的类型是 uint,即要传递颜色,应该使用 ColorToARGB 进行转换。

anchor 参数指定的锚定方法是两个文本定位标志的组合:垂直标志和水平标志。

水平文本位置标志包括:

  • TA_LEFT — 锚定到边界框的左侧
  • TA_CENTER — 锚定到矩形左右两侧的中间
  • TA_RIGHT — 锚定到边界框的右侧

垂直文本位置标志包括:

  • TA_TOP — 锚定到边界框的顶部
  • TA_VCENTER — 锚定到矩形上下两侧的中间
  • TA_BOTTOM — 锚定到边界框的底部

总共有 9 种有效的标志组合来描述锚定方法。

输出文本相对于锚点的位置

输出文本相对于锚点的位置

这里,在生成的图像中,图片的中心有一个故意放大的大圆点,其坐标为 (x, y)。根据标志的不同,文本会相对于该点出现在指定的位置(文本内容与应用的锚定方法相对应)。

为了便于参考,所有文本都以标准的水平位置显示。然而,请注意,也可以对其中任何一个文本应用一个角度(orientation),然后相应的文本将围绕该点旋转。在这张图片中,只有在两个维度上都居中的标签进行了旋转。

不要将这些标志与文本对齐混淆。边界框总是调整大小以适应文本,并且它相对于锚点的位置在某种意义上与标志名称的含义相反。

让我们来看一些使用这三个函数的示例。

首先,我们来检查设置字体粗细和样式的最简单选项。ResourceText.mq5 脚本允许在输入变量中选择字体名称、字体大小以及背景和文本的颜色。标签将在图表上显示指定的秒数。

cpp
input string Font = "Arial";             // 字体名称
input int    Size = -240;                // 大小
input color  Color = clrBlue;            // 字体颜色
input color  Background = clrNONE;       // 背景颜色
input uint   Seconds = 10;               // 演示时间(秒)

每种粗细等级的名称将显示在标签文本中,因此为了简化过程(通过使用 EnumToString),声明了 ENUM_FONT_WEIGHTS 枚举。

cpp
enum ENUM_FONT_WEIGHTS
{
   _DONTCARE = FW_DONTCARE,
   _THIN = FW_THIN,
   _EXTRALIGHT = FW_EXTRALIGHT,
   _LIGHT = FW_LIGHT,
   _NORMAL = FW_NORMAL,
   _MEDIUM = FW_MEDIUM,
   _SEMIBOLD = FW_SEMIBOLD,
   _BOLD = FW_BOLD,
   _EXTRABOLD = FW_EXTRABOLD,
   _HEAVY = FW_HEAVY,
};
cpp
const int nw = 10; // 不同粗细的数量

文本的样式标志收集在 rendering 数组中,并从中选择随机组合。

cpp
const uint rendering[] =
{
   FONT_ITALIC,
   FONT_UNDERLINE,
   FONT_STRIKEOUT
};
const int nr = sizeof(rendering) / sizeof(uint);

为了在一个范围内获取随机数,有一个辅助函数 Random

cpp
int Random(const int limit)
{
   return rand() % limit;
}

在脚本的主函数中,我们获取图表的大小,并创建一个覆盖整个空间的 OBJ_BITMAP_LABEL 对象。

cpp
void OnStart()
{
   ...
   const string name = "FONT";
   const int w = (int)ChartGetInteger(0, CHART_WIDTH_IN_PIXELS);
   const int h = (int)ChartGetInteger(0, CHART_HEIGHT_IN_PIXELS);
   
   // 用于填充整个窗口的图片资源的对象
   ObjectCreate(0, name, OBJ_BITMAP_LABEL, 0, 0, 0);
   ObjectSetInteger(0, name, OBJPROP_XSIZE, w);
   ObjectSetInteger(0, name, OBJPROP_YSIZE, h);
   ...

接下来,我们为图像缓冲区分配内存,用指定的背景颜色填充它(或者默认情况下使其透明),基于缓冲区创建一个资源,并将其绑定到对象上。

cpp
uint data[];
ArrayResize(data, w * h);
ArrayInitialize(data, Background == clrNONE? 0 : ColorToARGB(Background));
ResourceCreate(name, data, w, h, 0, 0, w, COLOR_FORMAT_ARGB_RAW);
ObjectSetString(0, name, OBJPROP_BMPFILE, "::" + name);
   ...

顺便提一下,除非对象要在两种状态之间切换,否则在 ObjectSetString 调用中我们可以在不使用修饰符(0 或 1)的情况下设置 OBJPROP_BMPFILE 属性。

所有字体粗细都列在 weights 数组中。

cpp
const uint weights[] =
{
   FW_DONTCARE,
   FW_THIN,
   FW_EXTRALIGHT, // FW_ULTRALIGHT,
   FW_LIGHT,
   FW_NORMAL,     // FW_REGULAR,
   FW_MEDIUM,
   FW_SEMIBOLD,   // FW_DEMIBOLD,
   FW_BOLD,
   FW_EXTRABOLD,  // FW_ULTRABOLD,
   FW_HEAVY,      // FW_BLACK
};
const int nw = sizeof(weights) / sizeof(uint);

在循环中,我们依次使用 TextSetFont 为每一行设置下一个粗细等级,并预先选择一个随机样式。使用 TextOut 在缓冲区中绘制包括字体名称和粗细在内的字体描述。

cpp
const int step = h / (nw + 2);
int cursor = 0;    // 当前“文本行”的 Y 坐标

for(int weight = 0; weight < nw; ++weight)
{
   // 应用随机样式
   const int r = Random(8);
   uint render = 0;
   for(int j = 0; j < 3; ++j)
   {
      if((bool)(r & (1 << j))) render |= rendering[j];
   }
   TextSetFont(Font, Size, weights[weight] | render);
   
   // 生成字体描述
   const string text = Font + EnumToString((ENUM_FONT_WEIGHTS)weights[weight]);
   
   // 在单独的“行”上绘制文本
   cursor += step;
   TextOut(text, w / 2, cursor, TA_CENTER | TA_TOP, data, w, h,
      ColorToARGB(Color), COLOR_FORMAT_ARGB_RAW);
}
   ...

现在更新资源和图表。

cpp
ResourceCreate(name, data, w, h, 0, 0, w, COLOR_FORMAT_ARGB_RAW);
ChartRedraw();
   ...

用户可以提前停止演示。

cpp
const uint timeout = GetTickCount() + Seconds * 1000;
while(!IsStopped() && GetTickCount() < timeout)
{
   Sleep(1000);
}

最后,脚本删除资源和对象。

cpp
ObjectDelete(0, name);
ResourceFree("::" + name);
}

脚本的运行结果如下图所示。

绘制不同粗细和样式的文本

绘制不同粗细和样式的文本

ResourceFont.mq5 的第二个示例中,我们将增加任务难度,把自定义字体作为资源包含进来,并以 90 度为增量使用文本旋转。

字体文件位于脚本旁边。

cpp
#resource "a_LCDNova3DCmObl.ttf"

可以在输入参数中更改消息内容。

cpp
input string Message = "Hello world!";   // 消息

这次,OBJ_BITMAP_LABEL 将不会占据整个窗口,因此它在水平和垂直方向上都居中显示。

cpp
void OnStart()
{
   const string name = "FONT";
   const int w = (int)ChartGetInteger(0, CHART_WIDTH_IN_PIXELS);
   const int h = (int)ChartGetInteger(0, CHART_HEIGHT_IN_PIXELS);
   
   // 用于图片资源的对象
   ObjectCreate(0, name, OBJ_BITMAP_LABEL, 0, 0, 0);
   ObjectSetInteger(0, name, OBJPROP_XDISTANCE, w / 2);
   ObjectSetInteger(0, name, OBJPROP_YDISTANCE, h / 2);
   ObjectSetInteger(0, name, OBJPROP_ANCHOR, ANCHOR_CENTER);
   ...

首先,分配最小尺寸的缓冲区,只是为了完成资源创建。稍后我们将扩展它以适应文本的尺寸,为此预留了变量 widthheight

cpp
uint data[], width, height;
ArrayResize(data, 1);
ResourceCreate(name, data, 1, 1, 0, 0, 1, COLOR_FORMAT_ARGB_RAW);
ObjectSetString(0, name, OBJPROP_BMPFILE, "::" + name);
   ...

在带有测试时间倒计时的循环中,我们需要更改文本的方向,为此有 angle 变量(角度将在其中滚动)。方向将每秒更改一次,倒计时在 remain 变量中。

cpp
const uint timeout = GetTickCount() + Seconds * 1000;
int angle = 0;
int remain = 10;
   ...

在循环中,我们不断更改文本的旋转角度,并在文本中显示剩余秒数的倒计时。对于每个新文本,使用 TextGetSize 计算其大小,并据此重新分配缓冲区。

cpp
while(!IsStopped() && GetTickCount() < timeout)
{
   // 应用新的角度
   TextSetFont("::a_LCDNova3DCmObl.ttf", -240, 0, angle * 10);
   
   // 生成文本
   const string text = Message + " (" + (string)remain-- + ")";
   
   // 获取文本大小,分配数组
   TextGetSize(text, width, height);
   ArrayResize(data, width * height);
   ArrayInitialize(data, 0);            // 透明
   
   // 对于垂直方向,交换尺寸
   if((bool)(angle / 90 & 1))
   {
      const uint t = width;
      width = height;
      height = t;
   }
   
   // 调整对象的大小

   ObjectSetInteger(0, name, OBJPROP_XSIZE, width);
   ObjectSetInteger(0, name, OBJPROP_YSIZE, height);
   
   // 绘制文本
   TextOut(text, width / 2, height / 2, TA_CENTER | TA_VCENTER, data, width, height,
      ColorToARGB(clrBlue), COLOR_FORMAT_ARGB_RAW);
   
   // 更新资源和图表
   ResourceCreate(name, data, width, height, 0, 0, width, COLOR_FORMAT_ARGB_RAW);
   ChartRedraw();
   
   // 改变角度
   angle += 90;
   
   Sleep(100);
}
   ...

请注意,如果文本是垂直的,就需要交换尺寸。更一般地说,当文本旋转到任意角度时,需要更多的数学计算来得到能容纳整个文本的缓冲区大小。

最后,我们同样删除对象和资源。

cpp
   ObjectDelete(0, name);
   ResourceFree("::" + name);
}

脚本执行过程中的某一时刻截图如下。

带有自定义字体的文本

带有自定义字体的文本

作为最后一个例子,我们来看一下 ResourceTextAnchOrientation.mq5 脚本,它展示了文本的各种旋转角度和锚点。

该脚本使用指定的字体生成指定数量的标签(ExampleCount)。

cpp
input string Font = "Arial";             // 字体名称
input int    Size = -150;                // 大小
input int    ExampleCount = 11;          // 示例数量

锚点和旋转角度是随机选择的。

为了在标签中指定锚点的名称,声明了 ENUM_TEXT_ANCHOR 枚举,其中包含了所有有效的选项。这样,我们可以简单地对任何随机选择的元素调用 EnumToString

cpp
enum ENUM_TEXT_ANCHOR
{
   LEFT_TOP = TA_LEFT | TA_TOP,
   LEFT_VCENTER = TA_LEFT | TA_VCENTER,
   LEFT_BOTTOM = TA_LEFT | TA_BOTTOM,
   CENTER_TOP = TA_CENTER | TA_TOP,
   CENTER_VCENTER = TA_CENTER | TA_VCENTER,
   CENTER_BOTTOM = TA_CENTER | TA_BOTTOM,
   RIGHT_TOP = TA_RIGHT | TA_TOP,
   RIGHT_VCENTER = TA_RIGHT | TA_VCENTER,
   RIGHT_BOTTOM = TA_RIGHT | TA_BOTTOM,
};

OnStart 处理函数中声明了一个包含这些新常量的数组。

cpp
void OnStart()
{
   const ENUM_TEXT_ANCHOR anchors[] =
   {
      LEFT_TOP,
      LEFT_VCENTER,
      LEFT_BOTTOM,
      CENTER_TOP,
      CENTER_VCENTER,
      CENTER_BOTTOM,
      RIGHT_TOP,
      RIGHT_VCENTER,
      RIGHT_BOTTOM,
   };
   const int na = sizeof(anchors) / sizeof(uint);
   ...

初始的对象和资源创建过程与 ResourceText.mq5 示例类似,所以这里我们省略这部分内容。最有趣的部分发生在循环中。

cpp
   for(int i = 0; i < ExampleCount; ++i)
   {
      // 应用一个随机角度
      const int angle = Random(360);
      TextSetFont(Font, Size, 0, angle * 10);
      
      // 选取随机坐标和一个锚点
      const ENUM_TEXT_ANCHOR anchor = anchors[Random(na)];
      const int x = Random(w / 2) + w / 4;
      const int y = Random(h / 2) + h / 4;
      const color clr = ColorMix::HSVtoRGB(angle);
      
     // 直接在图像中锚点所在的位置绘制一个圆
      TextOut(ShortToString(0x2022), x, y, TA_CENTER | TA_VCENTER, data, w, h,
         ColorToARGB(clr), COLOR_FORMAT_ARGB_NORMALIZE);
      
      // 生成描述锚点类型和角度的文本
      const string text =  EnumToString(anchor) +
         "(" + (string)angle + CharToString(0xB0) + ")";
   
      // 绘制文本
      TextOut(text, x, y, anchor, data, w, h,
         ColorToARGB(clr), COLOR_FORMAT_ARGB_NORMALIZE);
   }
   ...

现在只需要更新图片和图表,然后等待用户的指令并释放资源。

cpp
   ResourceCreate(name, data, w, h, 0, 0, w, COLOR_FORMAT_ARGB_NORMALIZE);
   ChartRedraw();
   
   const uint timeout = GetTickCount() + Seconds * 1000;
   while(!IsStopped() && GetTickCount() < timeout)
   {
      Sleep(1000);
   }
   
   ObjectDelete(0, name);
   ResourceFree("::" + name);
}

以下是我们得到的结果。

具有随机坐标、锚点和角度的文本输出

具有随机坐标、锚点和角度的文本输出

此外,为了供读者自主学习,本书提供了一个简单的图形编辑器 SimpleDrawing.mq5。它被设计为一个无缓冲区的指标,并且在其工作过程中使用了前面讨论过的形状类(请参阅 ResourceShapesDraw.mq5 示例)。这些类几乎原封不动地放在头文件 ShapesDrawing.mqh 中。之前,形状是由脚本随机生成的。现在,用户可以选择形状并将其绘制在图表上。为此,根据已注册的形状类的数量,实现了一个带有调色板和按钮栏的界面。该界面由 SimpleDrawing 类(SimpleDrawing.mqh)实现。

简单图形编辑器

简单图形编辑器

面板和调色板可以沿着图表的任何边界放置,展示了旋转标签的能力。

通过按下面板中的按钮来选择下一个要绘制的形状:按钮会保持按下状态,其背景颜色表示所选的绘图颜色。要更改颜色,点击调色板上的任意位置即可。

当在面板中选择了一种形状类型(其中一个按钮处于 “活动” 状态)时,在绘图区域(图表的其余部分,用阴影表示)中点击,会在该位置绘制一个预定义大小的形状。此时,按钮会 “关闭”。在这种所有按钮都不活动的状态下,你可以使用鼠标在工作区中移动形状。如果按住 Ctrl 键,形状不会移动,而是会调整大小。“热点” 位于每个形状的中心(敏感区域的大小由源代码中的宏设置,对于非常高 DPI 的显示器可能需要增大该值)。

请注意,编辑器在生成的资源名称中包含了绘图 ID(ChartID)。这使得可以在多个图表上并行运行该编辑器。

图形资源在交易中的应用

当然,美化并非资源的首要目的。让我们来看看如何基于这些资源创建一个实用的工具。我们还将弥补另一个遗漏之处:到目前为止,我们仅在以屏幕坐标定位的 OBJ_BITMAP_LABEL 对象内部使用资源。然而,图形资源也可以参照报价坐标(价格和时间)嵌入到 OBJ_BITMAP 对象中。

在本书前面,我们已经了解了 IndDeltaVolume.mq5 指标,它可以计算每个柱形图的成交量差值(跳动量或实际成交量)。除了这种成交量差值的表示方式外,还有另一种同样受用户欢迎的方式:市场轮廓图。这是在价格水平背景下的成交量分布情况。可以为整个窗口、给定深度(例如在一天内)或单个柱形图构建这样的直方图。

我们以新指标 DeltaVolumeProfile.mq5 的形式实现的正是最后一种选项。在上述指标的框架内,我们已经考虑了获取跳动历史数据的主要技术细节,所以现在我们将主要关注图形部分。

输入变量中的 ShowSplittedDelta 标志将控制成交量的显示方式:按买入/卖出方向拆分显示还是合并显示。

plaintext
input bool ShowSplittedDelta = true;

该指标将没有缓冲区。它会根据用户的请求,具体来说是通过点击特定柱形图,为该柱形图计算并显示直方图。因此,我们将使用 OnChartEvent 处理程序。在这个处理程序中,我们获取屏幕坐标,将其重新计算为价格和时间,并调用一些辅助函数 RequestData 来启动计算。

plaintext
void OnChartEvent(const int id, const long &lparam, const double &dparam, const string &sparam)
{
   if(id == CHARTEVENT_CLICK)
   {
      datetime time;
      double price;
      int window;
      ChartXYToTimePrice(0, (int)lparam, (int)dparam, window, time, price);
      time += PeriodSeconds() / 2;
      const int b = iBarShift(_Symbol, _Period, time, true);
      if(b != -1 && window == 0)
      {
         RequestData(b, iTime(_Symbol, _Period, b));
      }
   }
  ...
}

为了实现计算功能,我们需要 DeltaVolumeProfile 类,它的构建方式与 IndDeltaVolume.mq5 中的 CalcDeltaVolume 类类似。

这个新类描述了一些变量,这些变量考虑了成交量的计算方法(tickType)、构建图表所基于的价格类型(barType)、来自 ShowSplittedDelta 输入变量的模式(将存储在成员变量 delta 中),以及图表上生成对象的前缀。

plaintext
class DeltaVolumeProfile
{
   const COPY_TICKS tickType;
   const ENUM_SYMBOL_CHART_MODE barType;
   const bool delta;
   
   static const string prefix;
  ...
public:
   DeltaVolumeProfile(const COPY_TICKS type, const bool d) :
      tickType(type), delta(d),
      barType((ENUM_SYMBOL_CHART_MODE)SymbolInfoInteger(_Symbol, SYMBOL_CHART_MODE))
   {
   }
   
   ~DeltaVolumeProfile()
   {
      ObjectsDeleteAll(0, prefix, 0); // 待办事项:删除资源
   }
  ...
};
   
static const string DeltaVolumeProfile::prefix = "DVP";
   
DeltaVolumeProfile deltas(TickType, ShowSplittedDelta);

只有对于那些可获取实际成交量的交易品种,才可以将跳动类型更改为 TRADE_TICKS 值。默认情况下,启用 INFO_TICKS 模式,该模式适用于所有交易品种。

createProfileBar 方法用于请求特定柱形图的跳动数据。

plaintext
   int createProfileBar(const int i)
   {
      MqlTick ticks[];
      const datetime time = iTime(_Symbol, _Period, i);
      // prev 和 next - 柱形图的时间限制
      const datetime prev = time;
      const datetime next = prev + PeriodSeconds();
      ResetLastError();
      const int n = CopyTicksRange(_Symbol, ticks, COPY_TICKS_ALL,
         prev * 1000, next * 1000 - 1);
      if(n > -1 && _LastError == 0)
      {
         calcProfile(i, time, ticks);
      }
      else
      {
         return -_LastError;
      }
      return n;
   }

对跳动数据的直接分析和成交量的计算在受保护的 calcProfile 方法中进行。在这个方法中,我们首先要确定柱形图的价格范围及其以像素为单位的高度。

plaintext
   void calcProfile(const int b, const datetime time, const MqlTick &ticks[])
   {
      const string name = prefix + (string)(ulong)time;
      const double high = iHigh(_Symbol, _Period, b);
      const double low = iLow(_Symbol, _Period, b);
      const double range = high - low;
      
      ObjectCreate(0, name, OBJ_BITMAP, 0, time, high);
      
      int x1, y1, x2, y2;
      ChartTimePriceToXY(0, 0, time, high, x1, y1);
      ChartTimePriceToXY(0, 0, time, low, x2, y2);
      
      const int h = y2 - y1 + 1;
      const int w = (int)(ChartGetInteger(0, CHART_WIDTH_IN_PIXELS)
         / ChartGetInteger(0, CHART_WIDTH_IN_BARS));
  ...

基于这些信息,我们创建一个 OBJ_BITMAP 对象,为图像分配一个数组,并创建一个资源。整个图片的背景为空(透明)。每个对象的上中点锚定在其柱形图的最高价处,宽度为一个柱形图的宽度。

plaintext
      uint data[];
      ArrayResize(data, w * h);
      ArrayInitialize(data, 0);
      ResourceCreate(name + (string)ChartID(), data, w, h, 0, 0, w, COLOR_FORMAT_ARGB_NORMALIZE);
         
      ObjectSetString(0, name, OBJPROP_BMPFILE, "::" + name + (string)ChartID());
      ObjectSetInteger(0, name, OBJPROP_XSIZE, w);
      ObjectSetInteger(0, name, OBJPROP_YSIZE, h);
      ObjectSetInteger(0, name, OBJPROP_ANCHOR, ANCHOR_UPPER);
  ...

接下来是对传入数组中的跳动数据进行成交量计算。价格水平的数量等于柱形图以像素为单位的高度(h)。通常,这个高度小于以点数表示的价格范围,因此像素在某种程度上充当了计算统计数据的“容器”。如果在较小的时间框架下,点数范围小于像素尺寸,那么直方图在视觉上会显得稀疏。买入和卖出的成交量分别累积在 plusminus 数组中。

plaintext
      long plus[], minus[], max = 0;
      ArrayResize(plus, h);
      ArrayResize(minus, h);
      ArrayInitialize(plus, 0);
      ArrayInitialize(minus, 0);
      
      const int n = ArraySize(ticks);
      for(int j = 0; j < n; ++j)
      {
         const double p1 = price(ticks[j]); // 返回买价或最新价
         const int index = (int)((high - p1) / range * (h - 1));
         if(tickType == TRADE_TICKS)
         {
            // 如果可获取实际成交量,我们可以将其考虑在内
            if((ticks[j].flags & TICK_FLAG_BUY) != 0)
            {
               plus[index] += (long)ticks[j].volume;
            }
            if((ticks[j].flags & TICK_FLAG_SELL) != 0)
            {
               minus[index] += (long)ticks[j].volume;
            }
         }
         else // tickType == INFO_TICKS 或 tickType == ALL_TICKS
         if(j > 0)
         {
           // 如果没有实际成交量,
           // 价格的上涨/下跌可作为成交量类型的估计
            if((ticks[j].flags & (TICK_FLAG_ASK | TICK_FLAG_BID)) != 0)
            {
               const double d = (((ticks[j].ask + ticks[j].bid)
                              - (ticks[j - 1].ask + ticks[j - 1].bid)) / _Point);
               if(d > 0) plus[index] += (long)d;
               else minus[index] -= (long)d;
            }
         }
  ...

为了使直方图归一化,我们要找到最大值。

         if(delta)
         {
            if(plus[index] > max) max = plus[index];
            if(minus[index] > max) max = minus[index];
         }
         else
         {
            if(fabs(plus[index] - minus[index]) > max)
               max = fabs(plus[index] - minus[index]);
         }
      }
  ...

最后,将计算得到的统计数据输出到图形缓冲区 data 中,并发送到资源中。买入成交量以蓝色显示,卖出成交量以红色显示。如果启用了净模式,则以绿色显示数量。

plaintext
      for(int i = 0; i < h; i++)
      {
         if(delta)
         {
            const int dp = (int)(plus[i] * w / 2 / max);
            const int dm = (int)(minus[i] * w / 2 / max);
            for(int j = 0; j < dp; j++)
            {
               data[i * w + w / 2 + j] = ColorToARGB(clrBlue);
            }
            for(int j = 0; j < dm; j++)
            {
               data[i * w + w / 2 - j] = ColorToARGB(clrRed);
            }
         }
         else
         {
            const int d = (int)((plus[i] - minus[i]) * w / 2 / max);
            const int sign = d > 0 ? +1 : -1;
            for(int j = 0; j < fabs(d); j++)
            {
               data[i * w + w / 2 + j * sign] = ColorToARGB(clrGreen);
            }
         }
      }
      ResourceCreate(name + (string)ChartID(), data, w, h, 0, 0, w, COLOR_FORMAT_ARGB_NORMALIZE);
   }

现在我们可以回到 RequestData 函数:它的任务是调用 createProfileBar 方法并处理错误(如果有的话)。

plaintext
void RequestData(const int b, const datetime time, const int count = 0)
{
   Comment("Requesting ticks for ", time);
   if(deltas.createProfileBar(b) <= 0)
   {
      Print("No data on bar ", b, ", at ", TimeToString(time),
         ". Sending event for refresh...");
      ChartSetSymbolPeriod(0, _Symbol, _Period); // 请求更新图表
      EventChartCustom(0, TRY_AGAIN, b, count + 1, NULL);
   }
   Comment("");
}

唯一的错误处理策略是再次尝试请求跳动数据,因为这些数据可能还没来得及加载。为此,该函数向图表发送一个自定义的 TRY_AGAIN 消息,并自行处理该消息。

plaintext
void OnChartEvent(const int id, const long &lparam, const double &dparam, const string &sparam)
{
  ...
   else if(id == CHARTEVENT_CUSTOM + TRY_AGAIN)
   {
      Print("Refreshing... ", (int)dparam);
      const int b = (int)lparam;
      if((int)dparam < 5)
      {
         RequestData(b, iTime(_Symbol, _Period, b), (int)dparam);
      }
      else
      {
         Print("Give up. Check tick history manually, please, then click the bar again");
      }
   }
}

我们重复这个过程最多 5 次,因为跳动历史数据的深度可能有限,而且没有理由无端地让计算机负载过重。

DeltaVolumeProfile 类还具有处理 CHARTEVENT_CHART_CHANGE 消息的机制,以便在图表大小或比例发生变化时重绘现有对象。具体细节可在源代码中找到。

该指标的结果如下图所示。

在图形资源中显示单个柱形图的成交量直方图

在图形资源中显示单个柱形图的成交量直方图

请注意,在绘制指标后,直方图不会立即显示:你必须点击柱形图才能计算其直方图。