Skip to content

图形对象 ​

MetaTrader 5 的用户对图形对象的概念都非常熟悉:趋势线、价格标签、通道、斐波那契水平、几何图形,以及许多其他用于分析图表标记的可视化元素。MQL5 语言允许你通过编程方式创建、编辑和删除图形对象。例如,当希望在指标的子窗口和主窗口中同时显示某些数据时,这就会很有用。由于该平台仅支持在一个窗口中输出指标缓冲区,我们可以在另一个窗口中生成对象。利用由图形对象创建的标记,使用智能交易系统来组织半自动交易就会很容易。此外,对象还常被用于为 MQL 程序构建自定义图形界面,比如按钮、输入字段和标记等。无需打开属性对话框即可控制这些程序,并且在 MQL 中创建的面板比标准输入变量具有更大的灵活性。

每个对象都存在于特定图表的环境中。这就是为什么我们在本章中要讨论的函数都有一个共同特点:第一个参数指定图表 ID。此外,每个图形对象都有一个在同一图表(包括所有子窗口)内唯一的名称。更改图形对象的名称意味着删除具有旧名称的对象,并使用新名称创建相同的对象。不能创建两个同名的对象。

定义图形对象属性的函数,以及在图表上创建(ObjectCreate)和移动(ObjectMove)对象的操作,本质上是用于向图表发送异步命令。如果这些函数成功执行,该命令将进入图表的共享事件队列。图形对象属性的可视化修改是在处理该特定图表的事件队列时发生的。因此,在调用函数后,图表的外观可能会有一定延迟才会反映出对象状态的变化。

一般来说,图表上图形对象的更新是由终端自动完成的,以响应与图表相关的事件,比如收到新报价、调整窗口大小等等。要强制更新图形对象,可以使用请求图表重绘的函数(ChartRedraw)。这在大量创建或修改对象之后尤其重要。

对象是编程事件的来源,例如对象的创建、删除、属性修改以及鼠标点击。事件的发生和处理的所有方面将在单独的章节中与一般窗口环境下的事件一起讨论。

我们将从理论基础开始,然后逐步深入到实际应用方面。

对象类型及指定其坐标的特点 ​

从关于图表的章节中我们了解到,窗口中存在两种坐标系:屏幕(像素)坐标和报价(时间和价格)坐标。鉴于此,所支持的对象类型的总体集合可分为两大类:与屏幕关联的对象,以及与价格图表关联的对象。第一类对象相对于窗口的某一个角总是保持在固定位置(以哪个角作为参考角由用户或程序员在对象属性中确定)。而后者则会随着窗口的工作区域一起滚动。

下面的图片展示了两个带有文本标签的对象以便进行比较:一个是附着于屏幕的(OBJ_LABEL),另一个是附着于价格图表的(OBJ_TEXT)。它们的类型(在括号中给出),以及用于设置坐标的属性,我们将在本章的相关部分进行学习。需要重点注意的是,当滚动价格图表时,OBJ_TEXT文本会与之同步移动,而OBJ_LABEL文本则会停留在相同的位置。

对象的两种不同坐标系

对象的两种不同坐标系

此外,对象在锚点数量上也有所不同。例如,单个价格标签(“箭头”)需要一个时间/价格点,而一条趋势线则需要两个这样的点。还有一些对象类型具有更多的锚点,比如等距通道、三角形或艾略特波浪。

当一个对象被选中时(例如,在“对象列表”对话框中,根据“图表”选项卡/“用鼠标单击选择对象”选项,通过双击或单击图表来选择),其锚点会用对比色的小方块标示出来。正是这些锚点被用于拖动对象以及改变其大小和方向。

所有支持的对象类型都在ENUM_OBJECT枚举中进行了描述。你可以在MQL5文档中完整地查阅它。我们将逐步、分部分地研究其中的元素。

时间和价格约束对象 ​

下表列出了具有时间和价格坐标的对象、它们在 ENUM_OBJECT 枚举中的标识符以及锚点的数量。

标识符名称锚点数量
OBJ_VLINE垂直线(仅时间坐标)1
OBJ_HLINE水平线(仅价格坐标)1
OBJ_TREND趋势线2
OBJ_ARROWED_LINE末端带箭头的线2
OBJ_CYCLES周期性重复的垂直线(循环线)2
OBJ_CHANNEL等距通道3
OBJ_STDDEVCHANNEL标准差通道2
OBJ_REGRESSION线性回归通道2
OBJ_PITCHFORK安德鲁斯叉形线3
OBJ_FIBO斐波那契水平位2
OBJ_FIBOTIMES斐波那契时间区间2
OBJ_FIBOFAN斐波那契扇形线2
OBJ_FIBOARC斐波那契弧线2
OBJ_FIBOCHANNEL斐波那契通道3
OBJ_EXPANSION扩展线3
OBJ_GANNLINE甘氏线2
OBJ_GANNFAN甘氏扇形线2
OBJ_GANNGRID甘氏网格线2
OBJ_ELLIOTWAVE5艾略特五浪(推动浪)5
OBJ_ELLIOTWAVE3艾略特三浪(调整浪)3
OBJ_RECTANGLE矩形2
OBJ_TRIANGLE三角形3
OBJ_ELLIPSE椭圆形3
OBJ_ARROW_THUMB_UP向上的大拇指标记1*
OBJ_ARROW_THUMB_DOWN向下的大拇指标记1*
OBJ_ARROW_UP向上箭头1*
OBJ_ARROW_DOWN向下箭头1*
OBJ_ARROW_STOP停止标记1*
OBJ_ARROW_CHECK对勾标记1*
OBJ_ARROW_LEFT_PRICE左侧价格标签1
OBJ_ARROW_RIGHT_PRICE右侧价格标签1
OBJ_ARROW_BUY买入标志(蓝色向上箭头)1
OBJ_ARROW_SELL卖出标志(红色向下箭头)1
OBJ_ARROW任意 Wingdings 字符1*
OBJ_TEXT文本1*
OBJ_BITMAP图片1*
OBJ_EVENT主窗口底部的时间戳(仅时间坐标)1

星号标记的那些对象允许在对象上选择一个锚点(例如,在对象的一个角上或在某一边的中间)。对于不同的对象类型,选择方法可能会有所不同,有关定义对象锚点的详细信息将在相关章节中介绍。锚点是必需的,因为对象具有一定的大小,如果没有锚点,就会存在位置上的歧义。

绑定到屏幕坐标的对象 ​

下表列出了基于屏幕坐标定位的对象的名称和 ENUM_OBJECT 标识符。除了图表对象之外,几乎所有这些对象都是为了给程序创建用户界面而设计的。特别是,有诸如按钮和输入字段这样的基本控件,以及用于对对象进行可视化分组的标签和面板。基于这些,你可以创建更复杂的控件(例如下拉列表或复选框)。与终端一起提供的还有一个包含现成控件的类库,以一组头文件的形式存在(请参阅 MQL5/Include/Controls 目录)。

标识符名称设置锚点
OBJ_LABEL文本标签是
OBJ_RECTANGLE_LABEL矩形面板否
OBJ_BITMAP_LABEL带图像的面板是
OBJ_BUTTON按钮否
OBJ_EDIT输入字段否
OBJ_CHART图表对象否

所有这些对象都需要在图表窗口中确定锚点角。默认情况下,它们的坐标是相对于窗口的左上角的。

此列表中的对象类型也会使用对象上的一个锚点,且仅使用一个。在某些对象中,锚点是可编辑的,而在另一些对象中则是硬编码的。例如,矩形面板、按钮、输入字段和图表对象总是以其左上角为锚点。而对于标签或带图片的面板,则有许多可用的选项。可以从“定义对象锚点”部分中描述的 ENUM_ANCHOR_POINT 枚举中进行选择。

文本标签(OBJ_LABEL)提供文本输出,但不具备编辑功能。如需编辑,请使用输入字段(OBJ_EDIT)。

创建对象 ​

要创建一个对象,需要一组所有类型对象都通用的最少属性。每种类型特有的额外属性可以在对象创建之后再进行设置或更改。所需的属性包括要创建对象的图表标识符、对象名称、窗口/子窗口编号,以及第一个锚点的两个坐标:时间和价格。

即便有一组对象是基于屏幕坐标定位的,创建它们时仍需传入两个值,通常为零,因为这些值在这种情况下不会被使用。

一般来说,ObjectCreate函数的原型如下:

c++
bool ObjectCreate(long chartId, const string name, ENUM_OBJECT type, int window,
  datetime time1, double price1, datetime time2 = 0, double price2 = 0, ...)

chartId值为 0 表示当前图表。name参数在整个图表(包括子窗口)内必须是唯一的,且长度不能超过 63 个字符。

在前面的章节中我们已经介绍了type参数可用的对象类型,这些都是ENUM_OBJECT枚举中的元素。

如我们所知,window参数的窗口/子窗口编号从 0 开始,这表示主图表窗口。如果为子窗口指定了更大的索引,那么该子窗口必须存在,否则函数会报错并返回false。

提醒一下,返回的成功标志(true)仅表明创建对象的命令已成功加入队列。其执行结果并不能马上知晓。这是异步调用的一个特点,采用异步调用是为了提升性能。

若要检查执行结果,可以使用ObjectFind函数或任何ObjectGet函数,这些函数可查询对象的属性。但要记住,这类函数会等待整个图表命令队列执行完毕,然后才会返回实际结果(对象的状态)。这个过程可能需要一些时间,在此期间 MQL 程序代码会暂停执行。换句话说,检查对象状态的函数是同步的,这与创建和修改对象的函数不同。

从第二个锚点开始,额外的锚点是可选的。允许的锚点数量最多为 30 个,这是为未来的使用预留的,目前的对象类型使用的锚点不超过 5 个。

需要注意的是,使用已存在对象的名称调用ObjectCreate函数,只会更改锚点(如果自上次调用以来坐标发生了变化)。这在编写统一代码时很方便,无需根据对象是否存在来设置条件分支。也就是说,如果我们不关心对象之前是否存在,无条件地调用ObjectCreate函数可以确保对象存在。不过,这里有个细微之处。如果在调用ObjectCreate时,对象类型或子窗口索引与已存在的对象不同,相关数据会保持不变,且不会产生错误。

调用ObjectCreate时,可以将所有锚点都设为默认值(空值),前提是在该指令之后调用带有相应OBJPROP_TIME和OBJPROP_PRICE属性的ObjectSet函数。

对于某些对象类型,指定锚点的顺序可能很重要。对于像OBJ_REGRESSION(线性回归通道)和OBJ_STDDEVCHANNEL(标准偏差通道)这样的通道,必须满足time1 < time2的条件。否则,虽然对象会创建成功,但通道将无法正常构建。

下面以ObjectSimpleShowcase.mq5脚本为例,该脚本在图表的最后几根 K 线上创建几种不同类型的对象,这些对象只需要一个锚点。

所有操作对象的示例都会使用ObjectPrefix.mqh头文件,其中包含一个对象名称通用前缀的字符串定义。这样,如果需要,我们就能更方便地从图表中清除“自己的”对象。

c++
const string ObjNamePrefix = "ObjShow-";

在OnStart函数中,定义了一个包含对象类型的数组。

c++
void OnStart()
{
   ENUM_OBJECT types[] =
   {
      // 直线
      OBJ_VLINE, OBJ_HLINE,
      // 标签(箭头和其他符号)
      OBJ_ARROW_THUMB_UP, OBJ_ARROW_THUMB_DOWN,
      OBJ_ARROW_UP, OBJ_ARROW_DOWN,
      OBJ_ARROW_STOP, OBJ_ARROW_CHECK,
      OBJ_ARROW_LEFT_PRICE, OBJ_ARROW_RIGHT_PRICE,
      OBJ_ARROW_BUY, OBJ_ARROW_SELL,
      // OBJ_ARROW, // 参见 ObjectWingdings.mq5 示例
      
      // 文本
      OBJ_TEXT,
      // 窗口底部的事件标记(类似于日历中的标记)
      OBJ_EVENT,
   };

接下来,在遍历数组元素的循环中,我们在主窗口创建对象,并传入第i根 K 线的时间和收盘价。

c++
   const int n = ArraySize(types);
   for(int i = 0; i < n; ++i)
   {
      ObjectCreate(0, ObjNamePrefix + (string)iTime(_Symbol, _Period, i), types[i],
         0, iTime(_Symbol, _Period, i), iClose(_Symbol, _Period, i));
   }
   
   PrintFormat("%d objects of various types created", n);
}

运行该脚本可能会得到如下结果。

最后几根 K 线收盘价处的简单类型对象

最后几根 K 线收盘价处的简单类型对象

在这个例子中,启用了按收盘价绘制线条和网格显示功能。我们之后会学习如何调整对象的大小、颜色和其他属性。特别是,大多数图标默认的锚点位于顶部边的中间,所以它们在视觉上会偏移到线条下方。不过,卖出图标在线条上方,因为其锚点总是在底部边的中间。

请注意,通过编程方式创建的对象默认不会显示在同名对话框的对象列表中。若要在列表中查看它们,需点击“全部”按钮。

删除对象 ​

MQL5 API 提供了两个用于删除对象的函数。如果要批量删除满足名称前缀、类型或子窗口编号条件的对象,可以使用 ObjectsDeleteAll 函数。如果需要根据其他一些标准(例如,根据过时的日期和时间坐标)来选择要删除的对象,或者要删除的是单个对象,则可以使用 ObjectDelete 函数。

ObjectsDeleteAll 函数有两种形式:一种带有名称前缀参数,另一种不带该参数。

int ObjectsDeleteAll(long chartId, int window = -1, int type = -1)
int ObjectsDeleteAll(long chartId, const string prefix, int window = -1, int type = -1)

该函数会删除具有指定 chartId 的图表上的所有对象,同时会考虑子窗口、类型和名称的初始子字符串。

与往常一样,chartId 参数中的值 0 表示当前图表。

window 和 type 参数的默认值(-1)分别表示所有子窗口和所有类型的对象。

如果 prefix 为空,则任何名称的对象都将被删除。

该函数是同步执行的,也就是说,它会阻塞调用它的 MQL 程序,直到函数执行完成,并返回已删除对象的数量。由于该函数会等待在调用它之前图表队列中所有命令的执行,因此该操作可能需要一些时间。

bool ObjectDelete(long chartId, const string name)

该函数会删除具有 chartId 的图表上指定名称的对象。

与 ObjectsDeleteAll 不同,ObjectDelete 是异步执行的,即它会向图形系统发送删除对象的命令,并立即将控制权返回给 MQL 程序。返回 true 表示命令已成功放入队列中。要检查执行结果,可以使用 ObjectFind 函数或任何 ObjectGet 函数,这些函数用于查询对象的属性。

举个例子,我们来看一下 ObjectCleanup1.mq5 脚本。它的任务是删除带有 “our” 前缀的对象,这些对象是由上一节中的 ObjectSimpleShowcase.mq5 脚本生成的。

在最简单的情况下,我们可以这样编写:

c++
#include "ObjectPrefix.mqh"

void OnStart()
{
    const int n = ObjectsDeleteAll(0, ObjNamePrefix);
    PrintFormat("%d objects deleted", n);
}

但为了增加一些变化,我们也可以提供一种使用 ObjectDelete 函数通过多次调用来删除对象的选项。当然,当 ObjectsDeleteAll 能满足所有要求时,这种方法就没有意义了。然而,情况并不总是如此:当需要根据特殊条件(即不仅仅是根据前缀和类型)来选择对象时,ObjectsDeleteAll 就不再适用了。

稍后,当我们熟悉了读取对象属性的函数后,将完善这个示例。目前,我们只引入一个输入变量(UseCustomDeleteAll),用于切换到 “高级” 删除模式。

c++
#property script_show_inputs
input bool UseCustomDeleteAll = false;

在 OnStart 函数中,根据所选的模式,我们将调用标准的 ObjectsDeleteAll 函数,或者调用我们自己实现的 CustomDeleteAllObjects 函数。

c++
void OnStart()
{
    const int n = UseCustomDeleteAll ?
        CustomDeleteAllObjects(0, ObjNamePrefix) :
        ObjectsDeleteAll(0, ObjNamePrefix);

    PrintFormat("%d objects deleted", n);
}

我们先来大致勾勒一下这个函数,然后再对其进行完善。

c++
int CustomDeleteAllObjects(const long chart, const string prefix,
    const int window = -1, const int type = -1)
{
    int count = 0;
    const int n = ObjectsTotal(chart, window, type);

    // 注意:按图表内部列表的反向顺序遍历对象
    // 以便在从尾部删除对象时保持编号正确
    for(int i = n - 1; i >= 0; --i)
    {
        const string name = ObjectName(chart, i, window, type);
        if(StringLen(prefix) == 0 || StringFind(name, prefix) == 0)
        // 一些额外的检查,是 ObjectsDeleteAll 不提供的,
        // 例如,根据坐标、颜色或锚点进行检查
       ...
        {
            // 发送删除特定对象的命令
            count += ObjectDelete(chart, name);
        }
    }
    return count;
}

在这里,我们看到了几个新函数(ObjectsTotal、ObjectName),下一节将对它们进行描述。大致来说,它们的作用是:第一个函数返回图表上对象的索引,第二个函数返回指定索引下的对象名称。

还值得注意的是,对象的遍历是按索引降序进行的。如果我们按常规方式进行遍历,那么删除列表开头的对象会导致编号混乱。严格来说,即使是当前这种按降序遍历的循环也不能保证完全删除对象,因为假设另一个 MQL 程序在我们删除对象的同时开始添加对象。实际上,一个新的 “外来” 对象可能会被添加到列表的开头(列表是按对象名称的字母顺序形成的),并使剩余的索引增加,从而将 “我们” 接下来要删除的对象推到当前索引 i 之外。添加到开头的新对象越多,就越有可能遗漏要删除的对象。

因此,为了提高可靠性,可以在循环之后检查剩余对象的数量是否等于初始对象数量与已删除对象数量之差。不过,这并不能提供 100% 的保证,因为其他程序可能会同时删除对象。我们将把这些细节留给大家自行研究。

在当前的实现中,无论是否切换 UseCustomDeleteAll 模式,我们的脚本都应该删除所有带有 “our” 前缀的对象。日志中应该会显示类似这样的内容:

ObjectSimpleShowcase (XAUUSD,H1) 14 objects of various types created 
ObjectCleanup1 (XAUUSD,H1) 14 objects deleted

让我们先熟悉一下刚刚使用的 ObjectsTotal 和 ObjectName 函数,然后再回到 ObjectCleanup2.mq5 版本的脚本。

查找对象 ​

在图表上查找对象有三个函数。前两个函数 ObjectsTotal 和 ObjectName 允许你按名称遍历对象,然后在需要时使用每个对象的名称来分析其其他属性(我们将在下一节描述具体做法)。第三个函数 ObjectFind 允许你通过已知名称检查对象是否存在。通过 ObjectGet 函数简单地请求某个属性也能实现同样的目的:如果传递的名称对应的对象不存在,_LastError 中会记录一个错误,但这不如调用 ObjectFind 方便。此外,该函数会立即返回对象所在窗口的编号。

plaintext
int ObjectsTotal(long chartId, int window = -1, int type = -1)

该函数返回具有 chartId 标识符的图表上的对象数量(0 表示当前图表)。计算时仅考虑指定窗口编号的子窗口中的对象(0 表示主窗口,-1 表示主窗口和所有子窗口)。请注意,仅考虑 type 参数指定的特定类型的对象(默认 -1 表示所有类型)。type 的值可以是 ENUM_OBJECT 枚举中的一个元素。

该函数是同步执行的,即它会阻塞调用的 MQL 程序的执行,直到收到结果。

plaintext
string ObjectName(long chartId, int index, int window = -1, int type = -1)

该函数返回具有 chartId 标识符的图表上索引编号为 index 的对象的名称。在编译用于搜索对象的内部列表时,会考虑指定的子窗口编号(window)和对象类型(type)。该列表按对象名称的字典顺序排序,即特别地,按字母顺序排序,区分大小写。

与 ObjectsTotal 一样,ObjectName 在执行时会等待整个图表命令队列被提取,然后从更新后的对象列表中返回对象的名称。

如果发生错误,将得到一个空字符串,并且 OBJECT_NOT_FOUND(4202)错误代码将存储在 _LastError 中。

为了测试这两个函数的功能,让我们创建一个名为 ObjectFinder.mq5 的脚本,它会记录所有图表上的所有对象。它使用图表迭代函数(ChartFirst 和 ChartNext)以及获取图表属性的函数(ChartSymbol、ChartPeriod 和 ChartGetInteger)。

c
#include <MQL5Book/Periods.mqh>
   
void OnStart()
{
   int count = 0;
   long id = ChartFirst();
   // 遍历图表
   while(id != -1)
   {
      PrintFormat("%s %s (%lld)", ChartSymbol(id), PeriodToString(ChartPeriod(id)), id);
      const int win = (int)ChartGetInteger(id, CHART_WINDOWS_TOTAL);
      // 遍历窗口
      for(int k = 0; k < win; ++k)
      {
         PrintFormat("  Window %d", k);
         const int n = ObjectsTotal(id, k);
         // 遍历对象
         for(int i = 0; i < n; ++i)
         {
            const string name = ObjectName(id, i, k);
            const ENUM_OBJECT type = (ENUM_OBJECT)ObjectGetInteger(id, name, OBJPROP_TYPE);
            PrintFormat("    %s %s", EnumToString(type), name);
            ++count;
         }
      }
      id = ChartNext(id);
   }
   
   PrintFormat("%d objects found", count);
}

对于每个图表,我们确定子窗口的数量(ChartGetInteger(id, CHART_WINDOWS_TOTAL)),为每个子窗口调用 ObjectsTotal,并在内部循环中调用 ObjectName。接下来,通过名称查找对象的类型,并将它们一起显示在日志中。

以下是该脚本可能的运行结果(有缩写):

plaintext
EURUSD H1 (132358585987782873)
  Window 0
    OBJ_FIBO H1 Fibo 58513
    OBJ_TEXT H1 Text 40688
    OBJ_TREND H1 Trendline 3291
    OBJ_VLINE H1 Vertical Line 28732
    OBJ_VLINE H1 Vertical Line 33752
    OBJ_VLINE H1 Vertical Line 35549
  Window 1
  Window 2
EURUSD D1 (132360375330772909)
  Window 0
EURUSD M15 (132544239145024745)
  Window 0
    OBJ_VLINE H1 Vertical Line 27032
...
XAUUSD D1 (132544239145024746)
  Window 0
    OBJ_EVENT ObjShow-2021.11.25 00:00:00
    OBJ_TEXT ObjShow-2021.11.26 00:00:00
    OBJ_ARROW_SELL ObjShow-2021.11.29 00:00:00
    OBJ_ARROW_BUY ObjShow-2021.11.30 00:00:00
    OBJ_ARROW_RIGHT_PRICE ObjShow-2021.12.01 00:00:00
    OBJ_ARROW_LEFT_PRICE ObjShow-2021.12.02 00:00:00
    OBJ_ARROW_CHECK ObjShow-2021.12.03 00:00:00
    OBJ_ARROW_STOP ObjShow-2021.12.06 00:00:00
    OBJ_ARROW_DOWN ObjShow-2021.12.07 00:00:00
    OBJ_ARROW_UP ObjShow-2021.12.08 00:00:00
    OBJ_ARROW_THUMB_DOWN ObjShow-2021.12.09 00:00:00
    OBJ_ARROW_THUMB_UP ObjShow-2021.12.10 00:00:00
    OBJ_HLINE ObjShow-2021.12.13 00:00:00
    OBJ_VLINE ObjShow-2021.12.14 00:00:00
...
35 objects found

在这里,你可以特别注意到,在 XAUUSD 的 D1 图表上有由 ObjectSimpleShowcase.mq5 脚本生成的对象。有些图表和有些子窗口中没有对象。

plaintext
int ObjectFind(long chartId, const string name)

该函数在由标识符指定的图表上按名称搜索对象,如果成功,返回找到该对象的窗口编号。

如果未找到对象,该函数返回一个负数。与本节中的前两个函数一样,ObjectFind 函数使用同步调用。

我们将在下一节的 ObjectCopy.mq5 脚本中看到使用该函数的示例。

对象属性访问函数概述 ​

对象具有多种类型的属性,可以使用 ObjectGet 和 ObjectSet 函数来读取和设置这些属性。如我们所知,这一原理已经应用于图表(请参阅“处理图表完整属性集的函数概述”部分)。

所有这些函数的前三个参数都是图表标识符、对象名称和属性标识符,其中属性标识符必须是 ENUM_OBJECT_PROPERTY_INTEGER、ENUM_OBJECT_PROPERTY_DOUBLE 或 ENUM_OBJECT_PROPERTY_STRING 枚举类型中的一个成员。我们将在后续章节中逐步研究具体的属性。它们的完整数据透视表可以在 MQL5 文档的“对象属性”页面中找到。

需要注意的是,这三个枚举中的属性标识符不会相互交叉,这使得将它们的联合处理合并到一个统一的代码中成为可能。我们将在示例中使用这一点。

一些属性是只读的,将标记为“r/o”(只读)。

与绘图 API 的情况一样,属性读取函数有短形式和长形式:短形式直接返回请求的值,长形式返回一个布尔值,表示成功(true)或错误(false),而值本身则通过引用传递到最后一个参数中。在调用短形式函数时,应使用内置的 _LastError 变量检查是否存在错误。

在访问某些属性时,必须指定一个额外的参数(修饰符),当属性是多值时,该参数用于指示值的编号或级别。例如,如果一个对象有多个锚点,那么修饰符可以用来选择特定的锚点。

以下是读取和写入整数属性的函数原型。请注意,其中值的类型是 long,这不仅允许存储 int 或 long 类型的属性,还可以存储 bool、color、datetime 和各种枚举类型(见下文)。

c
bool ObjectSetInteger(long chartId, const string name, ENUM_OBJECT_PROPERTY_INTEGER property, long value)
bool ObjectSetInteger(long chartId, const string name, ENUM_OBJECT_PROPERTY_INTEGER property, int modifier, long value)
long ObjectGetInteger(long chartId, const string name, ENUM_OBJECT_PROPERTY_INTEGER property, int modifier = 0)
bool ObjectGetInteger(long chartId, const string name, ENUM_OBJECT_PROPERTY_INTEGER property, int modifier, long &value)

实数属性的函数描述与此类似。

c
bool ObjectSetDouble(long chartId, const string name, ENUM_OBJECT_PROPERTY_DOUBLE property, double value)
bool ObjectSetDouble(long chartId, const string name, ENUM_OBJECT_PROPERTY_DOUBLE property, int modifier, double value)
double ObjectGetDouble(long chartId, const string name, ENUM_OBJECT_PROPERTY_DOUBLE property, int modifier = 0)
bool ObjectGetDouble(long chartId, const string name, ENUM_OBJECT_PROPERTY_DOUBLE property, int modifier, double &value)

最后,对于字符串也有四个类似的函数。

c
bool ObjectSetString(long chartId, const string name, ENUM_OBJECT_PROPERTY_STRING property, const string value)
bool ObjectSetString(long chartId, const string name, ENUM_OBJECT_PROPERTY_STRING property, int modifier, const string value)
string ObjectGetString(long chartId, const string name, ENUM_OBJECT_PROPERTY_STRING property, int modifier = 0)
bool ObjectGetString(long chartId, const string name, ENUM_OBJECT_PROPERTY_STRING property, int modifier, string &value)

为了提高性能,所有设置对象属性的函数(ObjectSetInteger、ObjectSetDouble 和 ObjectSetString)都是异步的,本质上是向图表发送修改对象的命令。当这些函数成功执行时,命令会被放入图表的共享事件队列中,返回结果为 true 表示成功。当发生错误时,函数将返回 false,并且必须在 _LastError 变量中检查错误代码。

对象属性的更改会有一定的延迟,在处理图表事件队列时才会生效。要强制更新图表上对象的外观和属性,特别是在一次更改多个对象之后,请使用 ChartRedraw 函数。

获取图表属性的函数(ObjectGetInteger、ObjectGetDouble 和 ObjectGetString)是同步的,也就是说,调用代码会等待它们的执行结果。在这种情况下,图表队列中的所有命令都会被执行,以获取属性的实际值。

让我们回到删除对象的脚本示例,更准确地说,回到它的新版本 ObjectCleanup2.mq5。回想一下,在 CustomDeleteAllObjects 函数中,我们希望实现根据对象属性选择对象的功能。假设这些属性应该是颜色和锚点。为了获取这些属性,使用 ObjectGetInteger 函数和一对 ENUM_OBJECT_PROPERTY_INTEGER 枚举元素:OBJPROP_COLOR 和 OBJPROP_ANCHOR。我们稍后会详细研究它们。

基于这些信息,代码将补充以下检查(这里,为了简单起见,颜色和锚点由 clrRed 和 ANCHOR_TOP 常量给出。实际上,我们将为它们提供输入变量)。

c
int CustomDeleteAllObjects(const long chart, const string prefix,
   const int window = -1, const int type = -1)
{
   int count = 0;
   
   for(int i = ObjectsTotal(chart, window, type) - 1; i >= 0; --i)
   {
      const string name = ObjectName(chart, i, window, type);
      // 关于名称和其他属性的条件,例如颜色和锚点
      if((StringLen(prefix) == 0 || StringFind(name, prefix) == 0)
         && ObjectGetInteger(0, name, OBJPROP_COLOR) == clrRed
         && ObjectGetInteger(0, name, OBJPROP_ANCHOR) == ANCHOR_TOP)
      {
         count += ObjectDelete(chart, name);
      }
   }
   return count;
}

请注意带有 ObjectGetInteger 的行。

它们的输入很长,并且包含一些赘述,因为特定的属性与已知类型的 ObjectGet 函数相关联。此外,随着条件数量的增加,重复图表 ID 和对象名称可能会显得多余。

为了简化记录,让我们使用在“图表显示模式”部分的 ChartModeMonitor.mqh 文件中测试过的技术。其含义是描述一个中介类,该类具有用于读取和写入所有类型属性的方法重载。我们将新的头文件命名为 ObjectMonitor.mqh。

ObjectProxy 类紧密复制了图表的 ChartModeMonitorInterface 类的结构。主要区别在于存在用于设置和获取图表 ID 和对象名称的虚方法。

c
class ObjectProxy
{
public:
   long get(const ENUM_OBJECT_PROPERTY_INTEGER property, const int modifier = 0)
   {
      return ObjectGetInteger(chart(), name(), property, modifier);
   }
   double get(const ENUM_OBJECT_PROPERTY_DOUBLE property, const int modifier = 0)
   {
      return ObjectGetDouble(chart(), name(), property, modifier);
   }
   string get(const ENUM_OBJECT_PROPERTY_STRING property, const int modifier = 0)
   {
      return ObjectGetString(chart(), name(), property, modifier);
   }
   bool set(const ENUM_OBJECT_PROPERTY_INTEGER property, const long value,
      const int modifier = 0)
   {
      return ObjectSetInteger(chart(), name(), property, modifier, value);
   }
   bool set(const ENUM_OBJECT_PROPERTY_DOUBLE property, const double value,
      const int modifier = 0)
   {
      return ObjectSetDouble(chart(), name(), property, modifier, value);
   }
   bool set(const ENUM_OBJECT_PROPERTY_STRING property, const string value,
      const int modifier = 0)
   {
      return ObjectSetString(chart(), name(), property, modifier, value);
   }
   
   virtual string name() = 0;
   virtual void name(const string) { }
   virtual long chart() { return 0; }
   virtual void chart(const long) { }
};

让我们在派生类中实现这些方法(稍后我们将用对象属性监视器来补充类层次结构,类似于图表属性监视器)。

c
class ObjectSelector: public ObjectProxy
{
protected:
   long host; // 图表 ID
   string id; // 图表 ID
public:
   ObjectSelector(const string _id, const long _chart = 0): id(_id), host(_chart) { }
   
   virtual string name()
   {
      return id;
   }
   virtual void name(const string _id)
   {
      id = _id;
   }
   virtual void chart(const long _chart) override
   {
      host = _chart;
   }
};

我们将抽象接口 ObjectProxy 及其在 ObjectSelector 中的最小实现分离出来,因为例如,以后我们可能需要为同一类型的多个对象实现代理数组。然后,在新的“多选择器”类中存储名称数组或它们的公共前缀就足够了,并通过调用重载的 [] 运算符确保从 name 方法返回其中一个:multiSelector[i].get(OBJPROP_XYZ)。

现在让我们回到 ObjectCleanup2.mq5 脚本,并描述两个输入变量,用于指定颜色和锚点,作为选择要删除对象的附加条件。

c
// ObjectCleanup2.mq5
...
input color CustomColor = clrRed;
input ENUM_ARROW_ANCHOR CustomAnchor = ANCHOR_TOP;

让我们将这些值传递给 CustomDeleteAllObjects 函数,并且由于中介类的存在,在对象循环中的新条件检查可以更紧凑地表述。

c
#include <MQL5Book/ObjectMonitor.mqh>
   
void OnStart()
{
   const int n = UseCustomDeleteAll ?
      CustomDeleteAllObjects(0, ObjNamePrefix, CustomColor, CustomAnchor) :
      ObjectsDeleteAll(0, ObjNamePrefix);
   PrintFormat("%d objects deleted", n);
}
   
int CustomDeleteAllObjects(const long chart, const string prefix,
   color clr, ENUM_ARROW_ANCHOR anchor,
   const int window = -1, const int type = -1)
{
   int count = 0;
   for(int i = ObjectsTotal(chart, window, type) - 1; i >= 0; --i)
   {
      const string name = ObjectName(chart, i, window, type);
      
      ObjectSelector s(name);
      ResetLastError();
      if((StringLen(prefix) == 0 || StringFind(s.get(OBJPROP_NAME), prefix) == 0)
      && s.get(OBJPROP_COLOR) == CustomColor
      && s.get(OBJPROP_ANCHOR) == CustomAnchor
      && _LastError != 4203) // OBJECT_WRONG_PROPERTY
      {
         count += ObjectDelete(chart, name);
      }
   }
   return count;
}

重要的是要注意,我们在创建 ObjectSelector 对象时只指定一次对象的名称(以及当前图表的隐式标识符 0)。此后,所有属性都通过 get 方法请求,该方法带有一个描述所需属性的单个参数,并且编译器会自动选择合适的 ObjectGet 函数。

对错误代码 4203(OBJECT_WRONG_PROPERTY)的额外检查允许过滤掉没有请求属性(如 OBJPROP_ANCHOR)的对象。通过这种方式,特别是可以进行选择,使得所有类型的箭头都会被选中(无需分别请求不同类型的 OBJ_ARROW_XYZ),但线条和“事件”将被排除在处理之外。

这很容易验证,首先在图表上运行 ObjectSimpleShowcase.mq5 脚本(它将创建 14 个不同类型的对象),然后运行 ObjectCleanup2.mq5。如果启用 UseCustomDeleteAll 模式,图表上将有 5 个未删除的对象:OBJ_VLINE、OBJ_HLINE、OBJ_ARROW_BUY、OBJ_ARROW_SELL 和 OBJ_EVENT。前两个和最后一个没有 OBJPROP_ANCHOR 属性,而买入和卖出箭头不通过颜色检查(假设默认情况下所有其他创建对象的颜色为红色)。

然而,提供 ObjectSelector 不仅仅是为了上述简单的应用。它是为单个对象创建属性监视器的基础,类似于为图表实现的监视器。因此,ObjectMonitor.mqh 头文件包含了一些更有趣的内容。

c
class ObjectMonitorInterface: public ObjectSelector
{
public:
   ObjectMonitorInterface(const string _id, const long _chart = 0):
      ObjectSelector(_id, _chart) { }
   virtual int snapshot() = 0;
   virtual void print() { };
   virtual int backup() { return 0; }
   virtual void restore() { }
   virtual void applyChanges(ObjectMonitorInterface *reference) { }
};

这组方法应该会让你想起 ChartModeMonitor.mqh 中的 ChartModeMonitorInterface。唯一的创新是 applyChanges 方法,它将一个对象的属性复制到另一个对象。

基于 ObjectMonitorInterface,以下是针对一对模板类型的属性监视器基本实现的描述:属性值类型(long、double 或 string 之一)和枚举类型(ENUM_OBJECT_PROPERTY_ 相关的枚举之一)。

c
template<typename T,typename E>
class ObjectMonitorBase: public ObjectMonitorInterface
{
protected:
   MapArray<E,T> data;  // 键值对数组 [属性, 值],当前状态
   MapArray<E,T> store; // 备份(按需填充)
   MapArray<E,T> change;// 两个状态之间已提交的更改
   ...

ObjectMonitorBase 构造函数有两个参数:对象的名称和一个标志数组,其中包含要在指定对象中观察的属性标识符。这段代码的很大一部分几乎与 ChartModeMonitor 相同。特别是,像以前一样,标志数组被传递给辅助方法 detect,其主要目的是识别那些作为 E 枚举元素的整数值常量,并排除所有其他值。唯一需要说明的新增内容是通过 ObjectGetInteger(0, id, OBJPROP_LEVELS) 获取对象中具有层级数量的属性。这对于支持由于存在层级(例如斐波那契)而具有多个值的属性的迭代是必要的。对于没有层级的对象,我们将得到数量 0,这样的属性将是普通的标量属性。

c
public:
   ObjectMonitorBase(const string _id, const int &flags[]): ObjectMonitorInterface(_id)
   {
      const int levels = (int)ObjectGetInteger(0, id, OBJPROP_LEVELS);
      for(int i = 0; i < ArraySize(flags); ++i)
      {
         detect(flags[i], levels);
      }
   }
   ...

当然,detect 方法与我们在 ChartModeMonitor 中看到的有所不同。回想一下,首先它包含一个片段,使用 EnumToString 函数检查 v 常量是否属于 E 枚举:如果枚举中不存在这样的元素,将引发一个错误代码。如果元素存在,我们将相应属性的值添加到 data 数组中。

c
// ChartModeMonitor.mqh
bool detect(const int v)
{
   ResetLastError();
   conststrings = EnumToString((E)v); // 结果字符串不重要
   if(_LastError == 0)                // 分析错误代码
   {
      data.put((E)v, get((E)v));
      return true;
   }
   return false;
}

在对象监视器中,我们不得不使这个方案复杂化,因为由于 ObjectGet 和 ObjectSet 函数中的修饰符参数,一些属性是多值的。

因此,我们引入一个静态数组 modifiables,其中包含修饰符支持的那些属性的列表(每个属性将在后面详细讨论)。关键是对于这样的多值属性,需要多次读取它们并将它们存储在 data 数组中,而不是一次。

c
// ObjectMonitor.mqh
   bool detect(const int v, const int levels)
   {
      // 以下属性支持多个值
      static const int modifiables[] =
      {
         OBJPROP_TIME,        // 按时间的锚点
         OBJPROP_PRICE,       // 按价格的锚点
         OBJPROP_LEVELVALUE,  // 层级值
         OBJPROP_LEVELTEXT,   // 层级线上的铭文
         // 注意:以下属性在超过实际层级或文件数量时不会生成错误
         OBJPROP_LEVELCOLOR,  // 层级线颜色
         OBJPROP_LEVELSTYLE,  // 层级线样式
         OBJPROP_LEVELWIDTH,  // 层级线宽度
         OBJPROP_BMPFILE,     // 图像文件
      };
      ...

在这里,我们还使用 EnumToString 的技巧来检查具有 v 标识符的属性是否存在。如果成功,我们检查它是否在 modifiables 列表中,并将相应的标志 modifiable 设置为 true 或 false。

c
      bool result = false;
      ResetLastError();
 conststrings =EnumToString((E)v); // 结果字符串不重要
 if(_LastError ==0)// 分析错误代码
      {
         bool modifiable = false;
         for(int i = 0; i < ArraySize(modifiables); ++i)
         {
            if(v == modifiables[i])
            {
               modifiable = true;
               break;
            }
         }
         ...

默认情况下,任何属性都被认为是单值的,因此通过 ObjectGet 函数进行读取或通过

ObjectSet 函数进行写入的所需次数为 1(下面的 k 变量)。

c
         int k = 1;
         // 对于带修饰符的属性,设置正确的次数
         if(modifiable)
         {
            if(levels > 0) k = levels;
            else if(v == OBJPROP_TIME || v == OBJPROP_PRICE) k = MOD_MAX;
            else if(v == OBJPROP_BMPFILE) k = 2;
         }

如果一个对象支持层级,我们使用 levels 参数来限制潜在的读写次数(我们记得,它是在调用代码中从 OBJPROP_LEVELS 属性获取的)。

对于 OBJPROP_BMPFILE 属性,正如我们很快会了解到的,只允许两种状态:开启(按钮按下,标志设置)或关闭(按钮释放,标志清除),所以 k = 2。

最后,对象坐标 —— OBJPROP_TIME 和 OBJPROP_PRICE —— 很方便,因为当尝试读取/写入不存在的锚点时它们会生成一个错误。因此,我们为 k 赋值一个明显较大的值 MOD_MAX,然后我们可以在 _LastError 不为零的值时中断读取点的循环。

c
         // 读取属性值 —— 一个或多个
         for(int i = 0; i < k; ++i)
         {
            ResetLastError();
            T temp = get((E)v, i);
            // 如果不存在第 i 个修饰符,我们将得到一个错误并中断循环
            if(_LastError != 0) break;
            data.put((E)MOD_COMBINE(v, i), temp);
            result = true;
         }
      }
      return result;
   }

由于一个属性可以有多个值,这些值在循环中读取到 k 次,我们不能再简单地写 data.put((E)v, get((E)v))。我们需要以某种方式将属性标识符 v 和它的修改编号 i 组合起来。幸运的是,属性的数量也在一个整数常量(int 类型)中受到限制,最多占用两个低字节。所以我们可以使用位运算符将 i 放到高字节中。为此开发了 MOD_COMBINE 宏。

c
#define MOD_COMBINE(V,I) (V | (I << 24))

当然,也提供了反向宏来检索属性 ID 和修订编号。

c
#define MOD_GET_NAME(V)  (V & 0xFFFFFF)
#define MOD_GET_INDEX(V) (V >> 24)

例如,我们可以在这里看到它们在 snapshot 方法中是如何使用的。

c
   virtual int snapshot() override
   {
      MapArray<E,T> temp;
      change.reset();
      
      // 将所有所需属性收集到 temp 中
      for(int i = 0; i < data.getSize(); ++i)
      {
         const E e = (E)MOD_GET_NAME(data.getKey(i));
         const int m = MOD_GET_INDEX(data.getKey(i));
         temp.put((E)data.getKey(i), get(e, m));
      }
      
      int changes = 0;
      // 比较之前和新的状态
      for(int i = 0; i < data.getSize(); ++i)
      {
         if(data[i] != temp[i])
         {
            // 将差异保存在 change 数组中
            if(changes == 0) Print(id);
            const E e = (E)MOD_GET_NAME(data.getKey(i));
            const int m = MOD_GET_INDEX(data.getKey(i));
            Print(EnumToString(e), (m > 0 ? (string)m : ""), " ", data[i], " -> ", temp[i]);
            change.put(data.getKey(i), temp[i]);
            changes++;
         }
      }
      
      // 将新状态保存为当前状态
      data = temp;
      return changes;
   }

这个方法重复了 ChartModeMonitor.mqh 中同名方法的所有逻辑,然而,为了在任何地方读取属性,必须首先使用 MOD_GET_NAME 从存储的键中提取属性名称,并使用 MOD_GET_INDEX 提取编号。

在 restore 方法中也必须进行类似的复杂处理。

c
   virtual void restore() override
   {
      data = store;
      for(int i = 0; i < data.getSize(); ++i)
      {
         const E e = (E)MOD_GET_NAME(data.getKey(i));
         const int m = MOD_GET_INDEX(data.getKey(i));
         set(e, data[i], m);
      }
   }

ObjectMonitorBase 最有趣的创新是它如何处理更改。

c
   MapArray<E,T> * const getChanges()
   {
      return &change;
   }
   
   virtual void applyChanges(ObjectMonitorInterface *intf) override
   {
      ObjectMonitorBase *reference = dynamic_cast<ObjectMonitorBase<T,E> *>(intf);
      if(reference)
      {
         MapArray<E,T> *event = reference.getChanges();
         if(event.getSize() > 0)
         {
            Print("Modifing ", id, " by ", event.getSize(), " changes");
            for(int i = 0; i < event.getSize(); ++i)
            {
               data.put(event.getKey(i), event[i]);
               const E e = (E)MOD_GET_NAME(event.getKey(i));
               const int m = MOD_GET_INDEX(event.getKey(i));
               Print(EnumToString(e), " ", m, " ", event[i]);
               set(e, event[i], m);
            }
         }
      }
   }

将另一个对象的监视器状态传递给 applyChanges 方法,我们可以采用它的所有最新更改。

为了支持所有三种基本类型(long、double、string)的属性,我们需要实现 ObjectMonitor 类(类似于 ChartModeMonitor.mqh 中的 ChartModeMonitor)。

c
class ObjectMonitor: public ObjectMonitorInterface
{
protected:
   AutoPtr<ObjectMonitorInterface> m[3];
   
   ObjectMonitorInterface *getBase(const int i)
   {
      return m[i][];
   }
   
public:
   ObjectMonitor(const string objid, const int &flags[]): ObjectMonitorInterface(objid)
   {
      m[0] = new ObjectMonitorBase<long,ENUM_OBJECT_PROPERTY_INTEGER>(objid, flags);
      m[1] = new ObjectMonitorBase<double,ENUM_OBJECT_PROPERTY_DOUBLE>(objid, flags);
      m[2] = new ObjectMonitorBase<string,ENUM_OBJECT_PROPERTY_STRING>(objid, flags);
   }
   ...

这里也保留了之前的代码结构,并且只添加了一些方法来支持更改和名称(我们记得,图表没有名称)。

c
   ...
   virtual string name() override
   {
      return m[0][].name();
   }
   
   virtual void name(const string objid) override
   {
      m[0][].name(objid);
      m[1][].name(objid);
      m[2][].name(objid);
   }
   
   virtual void applyChanges(ObjectMonitorInterface *intf) override
   {
      ObjectMonitor *monitor = dynamic_cast<ObjectMonitor *>(intf);
      if(monitor)
      {
         m[0][].applyChanges(monitor.getBase(0));
         m[1][].applyChanges(monitor.getBase(1));
         m[2][].applyChanges(monitor.getBase(2));
      }
   }

基于创建的对象监视器,很容易实现一些终端不支持的技巧。特别是,这包括对象的复制和对象的分组编辑。

脚本 ObjectCopy ​

ObjectCopy.mq5 脚本演示了如何复制选定的对象。在它的 OnStart 函数开始时,我们用连续的整数填充 flags 数组,这些整数是不同类型的 ENUM_OBJECT_PROPERTY_ 枚举元素的候选值。枚举元素的编号按目的有明显的分组,并且组之间有很大的间隔(显然是为将来的元素预留的空间),所以形成的数组相当大:有 2048 个元素。

c
#include <MQL5Book/ObjectMonitor.mqh>
   
#define PUSH(A,V) (A[ArrayResize(A, ArraySize(A) + 1) - 1] = V)
   
void OnStart()
{
   int flags[2048];
   // 用连续整数填充数组,这些整数将与对象属性枚举的元素进行检查,
   // 无效值将在监视器的 detect 方法中被丢弃
   for(int i = 0; i < ArraySize(flags); ++i)
   {
      flags[i] = i;
   }
   ...

接下来,我们将当前在图表上选定的对象的名称收集到一个数组中。为此,我们使用 OBJPROP_SELECTED 属性。

c
   string selected[];
   const int n = ObjectsTotal(0);
   for(int i = 0; i < n; ++i)
   {
      const string name = ObjectName(0, i);
      if(ObjectGetInteger(0, name, OBJPROP_SELECTED))
      {
         PUSH(selected, name);
      }
   }
   ...

最后,在选定元素的主循环中,我们读取每个对象的属性,形成其副本的名称,并使用相同的属性集在该名称下创建一个对象。

c
   for(int i = 0; i < ArraySize(selected); ++i)
   {
      const string name = selected[i];
      
     // 使用监视器对当前对象的属性进行备份
      ObjectMonitor object(name, flags);
      object.print();
      object.backup();
      // 为副本形成一个正确、合适的名称
      const string copy = GetFreeName(name);
      
      if(StringLen(copy) > 0)
      {
         Print("Copy name: ", copy);
         // 创建一个相同类型的对象 OBJPROP_TYPE
         ObjectCreate(0, copy,
            (ENUM_OBJECT)ObjectGetInteger(0, name, OBJPROP_TYPE),
            ObjectFind(0, name), 0, 0);
         // 在监视器中将对象的名称更改为新名称
         object.name(copy);
         // 将所有属性从备份恢复到新对象
         object.restore();
      }
      else
      {
         Print("Can't create copy name for: ", name);
      }
   }
}

这里需要注意的是,OBJPROP_TYPE 属性是少数几个只读属性之一,因此首先创建一个所需类型的对象至关重要。

辅助函数 GetFreeName 尝试将字符串 "/Copy #x" 附加到对象名称上,其中 x 是副本编号。因此,多次运行该脚本可以创建第 2 个、第 3 个等等副本。

c
string GetFreeName(const string name)
{
   const string suffix = "/Copy №";
   // 检查名称中是否存在后缀形式的副本
   const int pos = StringFind(name, suffix);
   string prefix;
   int n;
   
   if(pos <= 0)
   {
      // 如果未找到后缀,假设副本编号为 1
      const string candidate = name + suffix + "1";
      // 检查副本名称是否可用,如果是则返回它
      if(ObjectFind(0, candidate) < 0)
      {
         return candidate;
      }
      // 否则,准备一个循环来迭代副本编号
      prefix = name;
      n = 0;
   }
   else
   {
      // 如果找到后缀,选择不带后缀的名称
      prefix = StringSubstr(name, 0, pos);
      // 并在字符串中找到副本编号
      n = (int)StringToInteger(StringSubstr(name, pos + StringLen(suffix)));
   }
   
   Print("Found: ", prefix, " ", n);
   // 循环尝试找到一个大于 n 的可用副本编号,但不超过 1000
   for(int i = n + 1; i < 1000; ++i)
   {
      const string candidate = prefix + suffix + (string)i;
      // 检查是否存在名称以 "Copy #i" 结尾的对象
      if(ObjectFind(0, candidate) < 0)
      {
         return candidate; // 返回可用的副本名称
      }
   }
   return NULL; // 副本数量过多
}

终端会记住特定类型对象的最后设置,如果它们一个接一个地创建,这相当于复制。然而,在使用不同图表的过程中,设置通常会发生变化,如果过一段时间后需要复制某个“旧”对象,那么通常必须完全重新设置它的属性。对于具有大量属性的对象类型,例如斐波那契工具,这尤其麻烦。在这种情况下,这个脚本会很有用。

本章中的一些包含相同类型对象的图片就是使用这个脚本创建的。

对象分组编辑指示器 ​

使用 ObjectMonitor 的第二个示例是 ObjectGroupEdit.mq5 指示器,它允许一次编辑一组选定对象的属性。

想象一下,我们在图表上选择了几个对象(不一定是相同类型的),需要统一更改它们的一个或另一个属性。接下来,我们打开这些对象中任何一个的属性对话框,进行配置,然后点击“确定”,这些更改将应用于所有选定的对象。这就是我们下一个 MQL 程序的工作方式。

我们需要一个指示器作为一种程序类型,因为它涉及图表事件。对于 MQL5 编程的这一方面,将有一整个专门的章节,但我们现在就来了解一些基础知识。

由于指示器没有图表,#property 指令包含零值,并且 OnCalculate 函数几乎为空。

c
#property indicator_chart_window
#property indicator_buffers 0
#property indicator_plots   0
   
int OnCalculate(const int rates_total,
                const int prev_calculated,
                const int begin,
                const double &price[])
{
   return rates_total;
}

为了自动生成一个对象的所有属性的完整集合,我们将再次使用一个包含 2048 个连续整数值的数组。我们还将提供一个用于存储选定元素名称的数组和一个 ObjectMonitor 类的监视器对象数组。

c
int consts[2048];
string selected[];
ObjectMonitor *objects[];

在 OnInit 处理程序中,我们初始化数字数组并启动定时器。

c
void OnInit()
{
   for(int i = 0; i < ArraySize(consts); ++i)
   {
      consts[i] = i;
   }
   
   EventSetTimer(1);
}

在定时器处理程序中,我们将选定对象的名称保存到一个数组中。如果选择列表发生了变化,需要重新配置监视器对象,为此调用辅助函数 TrackSelectedObjects。

c
void OnTimer()
{
   string updates[];
   const int n = ObjectsTotal(0);
   for(int i = 0; i < n; ++i)
   {
      const string name = ObjectName(0, i);
      if(ObjectGetInteger(0, name, OBJPROP_SELECTED))
      {
         PUSH(updates, name);
      }
   }
   
   if(ArraySize(selected) != ArraySize(updates))
   {
      ArraySwap(selected, updates);
      Comment("Selected objects: ", ArraySize(selected));
      TrackSelectedObjects();
   }
}

TrackSelectedObjects 函数本身相当简单:删除旧的监视器并创建新的监视器。如果你愿意,可以通过保留选择中未更改的部分使其更智能。

c
void TrackSelectedObjects()
{
   for(int j = 0; j < ArraySize(objects); ++j)
   {
      delete objects[j];
   }
   
   ArrayResize(objects, 0);
   
   for(int i = 0; i < ArraySize(selected); ++i)
   {
      const string name = selected[i];
      PUSH(objects, new ObjectMonitor(name, consts));
   }
}

回想一下,在创建监视器对象时,它会立即对相应图形对象的所有属性进行“快照”。

现在我们终于到了事件起作用的部分。正如在事件函数概述中已经提到的,处理程序负责图表上的 OnChartEvent 事件。在这个示例中,我们对特定的 CHARTEVENT_OBJECT_CHANGE 事件感兴趣:当用户在对象的属性对话框中更改任何属性时会发生该事件。被修改对象的名称通过 sparam 参数传递。

如果这个名称与其中一个被监视的对象匹配,我们要求监视器对其属性进行新的快照,即我们调用 objects[i].snapshot()。

c
void OnChartEvent(const int id,
   const long &lparam, const double &dparam, const string &sparam)
{
   if(id == CHARTEVENT_OBJECT_CHANGE)
   {
      Print("Object changed: ", sparam);
      for(int i = 0; i < ArraySize(selected); ++i)
      {
         if(sparam == selected[i])
         {
            const int changes = objects[i].snapshot();
            if(changes > 0)
            {
               for(int j = 0; j < ArraySize(objects); ++j)
               {
                  if(j != i)
                  {
                     objects[j].applyChanges(objects[i]);
                  }
               }
            }
            ChartRedraw();
            break;
         }
      }
   }
}

如果确认有更改(否则不太可能),changes 变量中的更改数量将大于 0。然后会对所有选定对象启动一个循环,并将检测到的更改应用到除原始对象之外的每个对象。

由于我们可能会更改多个对象,因此我们调用 ChartRedraw 来请求重绘图表。

在 OnDeinit 处理程序中,我们移除所有监视器。

c
void OnDeinit(const int)
{
   for(int j = 0; j < ArraySize(objects); ++j)
   {
      delete objects[j];
   }
   Comment("");
}

至此,新工具就准备好了。 这个指标允许你在 “定义对象锚点” 部分中自定义几组标签对象的外观。 顺便说一下,根据类似的原理,借助 ObjectMonitor,你可以制作另一个终端中没有的常用工具:撤销对对象属性的编辑,因为现在 restore 方法已经就绪。

主要对象属性 ​

所有对象都有一些通用属性。主要属性如下表所示。我们稍后会看到其他一些具有特定用途的通用属性(请参阅 “对象状态管理”“Z 序” 以及 “时间周期上下文中对象的可见性” 部分)。

标识符描述类型
OBJPROP_NAME对象名称string(字符串)
OBJPROP_TYPE对象类型(只读)ENUM_OBJECT(枚举类型)
OBJPROP_CREATETIME对象创建时间(只读)datetime(日期时间)
OBJPROP_TEXT对象的描述(对象中包含的文本)string(字符串)
OBJPROP_TOOLTIP鼠标悬停时的提示文本string(字符串)

OBJPROP_NAME 属性是对象的标识符。编辑该属性等同于删除旧对象并创建一个新对象。

对于某些能够显示文本的对象类型(如标签或按钮),OBJPROP_TEXT 属性会始终直接显示在图表上的对象内部。对于其他对象(例如线条),该属性包含一个描述,只有在图表设置中启用了 “显示对象描述选项” 时,才会在对象旁边的图表上显示该描述。在任何一种情况下,OBJPROP_TEXT 都会显示在工具提示中。

OBJPROP_CREATETIME 属性仅在当前会话结束前有效,并且不会写入 .chr 文件中。

你可以通过编程方式或手动方式(在对象的属性对话框中)更改对象的名称,而其创建时间将保持不变。先提前说明一下,通过编程方式重命名不会引发图表上与对象相关的任何事件。正如我们将在下一章中学习到的,手动重命名会触发三个事件:

  1. 删除旧名称下的对象(CHARTEVENT_OBJECT_DELETE);
  2. 创建新名称下的对象(CHARTEVENT_OBJECT_CREATE);
  3. 修改新对象(CHARTEVENT_OBJECT_CHANGE)。

如果未设置 OBJPROP_TOOLTIP 属性,则会为对象显示一个由终端自动生成的工具提示。要禁用工具提示,可将其值设置为 "\n"(换行符)。

让我们修改 “查找对象” 部分中的 ObjectFinder.mq5 脚本,以便记录当前图表上对象的上述所有属性。我们将新脚本命名为 ObjectListing.mq5。

在 OnStart 函数的最开始,我们将创建或修改一条位于最后一根 K 线处(即脚本启动时)的垂直线。如果在图表设置中有显示对象描述的选项,那么我们将在右侧垂直线旁看到 “Latest Bar At The Moment”(当前时刻的最后一根 K 线)的文本。

c++
void OnStart()
{
    const string vline = ObjNamePrefix + "current";
    ObjectCreate(0, vline, OBJ_VLINE, 0, iTime(NULL, 0, 0), 0);
    ObjectSetString(0, vline, OBJPROP_TEXT, "Latest Bar At The Moment");
   ...

接下来,在遍历子窗口的循环中,我们将查询最多到 ObjectsTotal 数量的所有对象及其主要属性。

c++
    int count = 0;
    const long id = ChartID();
    const int win = (int)ChartGetInteger(id, CHART_WINDOWS_TOTAL);
    // 遍历子窗口
    for(int k = 0; k < win; ++k)
    {
        PrintFormat("  Window %d", k);
        const int n = ObjectsTotal(id, k);
        // 遍历对象
        for(int i = 0; i < n; ++i)
        {
            const string name = ObjectName(id, i, k);
            const ENUM_OBJECT type =
                (ENUM_OBJECT)ObjectGetInteger(id, name, OBJPROP_TYPE);
            const datetime created =
                (datetime)ObjectGetInteger(id, name, OBJPROP_CREATETIME);
            const string description = ObjectGetString(id, name, OBJPROP_TEXT);
            const string hint = ObjectGetString(id, name, OBJPROP_TOOLTIP);
            PrintFormat("    %s %s %s %s %s", EnumToString(type), name,
                TimeToString(created), description, hint);
            ++count;
        }
    }

    PrintFormat("%d objects found", count);
}

我们在日志中会得到如下记录:

  Window 0
    OBJ_VLINE ObjShow-current 2021.12.21 20:20 Latest Bar At The Moment 
    OBJ_VLINE abc 2021.12.21 19:25  
    OBJ_VLINE xyz 1970.01.01 00:00  
3 objects found

OBJPROP_CREATETIME 值为零(1970.01.01 00:00)表示该对象不是在当前会话期间创建的,而是在更早的时候创建的。

价格和时间坐标 ​

对于存在于报价坐标系统中的对象类型,MQL5 API 支持一些用于指定时间和价格绑定关系的属性。如果一个对象有多个锚点,在调用 ObjectSet 和 ObjectGet 函数时,这些属性要求指定一个包含锚点索引的修饰符参数。

标识符描述值类型
OBJPROP_TIME时间坐标datetime
OBJPROP_PRICE价格坐标double

这些属性对于所有对象都是可用的,但对于具有屏幕坐标的对象,设置或读取它们是没有意义的。

为了演示如何使用坐标,让我们分析一下无缓冲区指标 ObjectHighLowChannel.mq5。对于给定的一段柱线,它会绘制两条趋势线。它们在时间轴上的起点和终点与该段柱线的第一根和最后一根柱线重合,而在价格轴上,每条线的值计算方式不同:上线使用最高价的最高值和最低值,下线使用最低价的最高值和最低值。随着图表的更新,我们临时绘制的通道应该随着价格移动。

柱线范围通过两个输入变量设置:初始柱线编号 BarOffset 和柱线数量 BarCount。默认情况下,这些线绘制在最新的价格处,因为柱线偏移量 bar offset = 0。

c
input int BarOffset = 0;
input int BarCount = 10;
   
const string Prefix = "HighLowChannel-";

对象有一个共同的名称前缀“HighLowChannel-”。

在 OnCalculate 处理函数中,我们通过第 0 根柱线的 iTime 时间来监测新柱线的出现。一旦柱线形成,就会分析指定段上的价格,获取两种价格类型(MODE_HIGH,MODE_LOW)中每种价格的最大值和最小值,并为它们调用辅助函数 DrawFigure,正是在这个函数中进行对象的操作:创建和修改坐标。

c
int OnCalculate(const int rates_total,
                const int prev_calculated,
                const int begin,
                const double &price[])
{
   static datetime now = 0;
   if(now != iTime(NULL, 0, 0))
   {
      const int hh = iHighest(NULL, 0, MODE_HIGH, BarCount, BarOffset);
      const int lh = iLowest(NULL, 0, MODE_HIGH, BarCount, BarOffset);
      const int ll = iLowest(NULL, 0, MODE_LOW, BarCount, BarOffset);
      const int hl = iHighest(NULL, 0, MODE_LOW, BarCount, BarOffset);
   
      datetime t[2] = {iTime(NULL, 0, BarOffset + BarCount), iTime(NULL, 0, BarOffset)};
      double ph[2] = {iHigh(NULL, 0, fmax(hh, lh)), iHigh(NULL, 0, fmin(hh, lh))};
      double pl[2] = {iLow(NULL, 0, fmax(ll, hl)), iLow(NULL, 0, fmin(ll, hl))};
    
      DrawFigure(Prefix + "Highs", t, ph, clrBlue);
      DrawFigure(Prefix + "Lows", t, pl, clrRed);
   
      now = iTime(NULL, 0, 0);
   }
   return rates_total;
}

下面是 DrawFigure 函数本身。

c
bool DrawFigure(const string name, const datetime &t[], const double &p[],
   const color clr)
{
   if(ArraySize(t) != ArraySize(p)) return false;
   
   ObjectCreate(0, name, OBJ_TREND, 0, 0, 0);
   
   for(int i = 0; i < ArraySize(t); ++i)
   {
      ObjectSetInteger(0, name, OBJPROP_TIME, i, t[i]);
      ObjectSetDouble(0, name, OBJPROP_PRICE, i, p[i]);
   }
   
   ObjectSetInteger(0, name, OBJPROP_COLOR, clr);
   return true;
}

在调用 ObjectCreate 确保对象存在之后,针对所有锚点(在这种情况下是两个)调用适用于 OBJPROP_TIME 和 OBJPROP_PRICE 的 ObjectSet 函数。

下面的图片展示了该指标的结果。

基于最高价和最低价的两条趋势线构成的通道

基于最高价和最低价的两条趋势线构成的通道

你可以在可视化测试器中运行该指标,以查看线条坐标是如何实时变化的。

锚定窗口角点与屏幕坐标 ​

对于使用图表上以点(像素)为形式的坐标系统的对象,你必须从窗口的四个角点中选择一个,以该角点为基准,计算从该点到对象上锚点在水平 X 轴和垂直 Y 轴方向上的距离值。这些方面由下表中的属性来控制。

标识符描述类型
OBJPROP_CORNER用于锚定图形对象的图表角点ENUM_BASE_CORNER
OBJPROP_XDISTANCE从锚定角点沿 X 轴方向以像素为单位的距离int
OBJPROP_YDISTANCE从锚定角点沿 Y 轴方向以像素为单位的距离int

OBJPROP_CORNER 的有效选项总结在 ENUM_BASE_CORNER 枚举中。

标识符坐标中心位置
CORNER_LEFT_UPPER窗口的左上角
CORNER_LEFT_LOWER窗口的左下角
CORNER_RIGHT_LOWER窗口的右下角
CORNER_RIGHT_UPPER窗口的右上角

默认值是左上角。

下图展示了四个大小相同且与窗口中锚定角点距离相同的按钮对象。这些对象中的每一个仅在其绑定的角点上有所不同。请记住,按钮有一个锚点,它始终位于按钮的左上角。

绑定到主窗口不同角点的对象排列情况

绑定到主窗口不同角点的对象排列情况

目前图表上的这四个对象均处于选中状态,所以它们的锚点以对比色突出显示。

当我们提到窗口角点时,指的是对象所在的特定窗口或子窗口,而不是整个图表。换句话说,对于子窗口中的对象,Y 坐标是从该子窗口的顶部或底部边界开始测量的。

下面的插图展示了子窗口中类似的对象,它们吸附在子窗口的各个角点上。

绑定到子窗口不同角点的对象位置情况

绑定到子窗口不同角点的对象位置情况

使用 ObjectCornerLabel.mq5 脚本,用户可以测试文本标签的移动情况,其中窗口中的锚定角点在输入参数 Corner 中指定。

c
#property script_show_inputs
   
input ENUM_BASE_CORNER Corner = CORNER_LEFT_UPPER;

坐标会定期变化,并显示在文本标签本身的文本中。这样,文本标签就在窗口中移动,当它到达边界时,会从边界反弹回来。该对象是在通过鼠标放置脚本的窗口或子窗口中创建的。

c
void OnStart()
{
   const int t = ChartWindowOnDropped();
   const string legend = EnumToString(Corner);
   
   const string name = "ObjCornerLabel-" + legend;
   int h = (int)ChartGetInteger(0, CHART_HEIGHT_IN_PIXELS, t);
   int w = (int)ChartGetInteger(0, CHART_WIDTH_IN_PIXELS);
   int x = w / 2;
   int y = h / 2;
   ...

为了正确定位,我们获取窗口的尺寸(然后检查它们是否发生了变化),并找到对象初始放置的中间位置:即表示坐标的变量 x 和 y。

接下来,我们创建并设置一个文本标签,此时还没有设置坐标。重要的是要注意,我们启用了选择对象的功能(OBJPROP_SELECTABLE)并将其选中(OBJPROP_SELECTED),因为这样我们就能看到对象本身的锚点,从窗口角点(坐标中心)到该锚点的距离就是以此为基准测量的。这两个属性在“对象状态管理”部分有更详细的描述。

c
   ObjectCreate(0, name, OBJ_LABEL, t, 0, 0);
   ObjectSetInteger(0, name, OBJPROP_SELECTABLE, true);
   ObjectSetInteger(0, name, OBJPROP_SELECTED, true);
   ObjectSetInteger(0, name, OBJPROP_CORNER, Corner);
   ...

在变量 px 和 py 中,我们将记录用于模拟移动的坐标增量。坐标的修改本身将在一个无限循环中执行,直到被用户中断。迭代计数器将允许每隔 50 次迭代,随机改变移动的方向。

c
   int px = 0, py = 0;
   int pass = 0;
   
   for( ;!IsStopped(); ++pass)
   {
      if(pass % 50 == 0)
      {
         h = (int)ChartGetInteger(0, CHART_HEIGHT_IN_PIXELS, t);
         w = (int)ChartGetInteger(0, CHART_WIDTH_IN_PIXELS);
         px = rand() * (w / 20) / 32768 - (w / 40);
         py = rand() * (h / 20) / 32768 - (h / 40);
      }
   
      // 从窗口边界反弹,以免对象隐藏
      if(x + px > w || x + px < 0) px = -px;
      if(y + py > h || y + py < 0) py = -py;
      // 重新计算标签位置
      x += px;
      y += py;
      
      // 更新对象的坐标并将其添加到文本中
      ObjectSetString(0, name, OBJPROP_TEXT, legend
         + "[" + (string)x + "," + (string)y + "]");
      ObjectSetInteger(0, name, OBJPROP_XDISTANCE, x);
      ObjectSetInteger(0, name, OBJPROP_YDISTANCE, y);
   
      ChartRedraw();
      Sleep(100);
   }
   
   ObjectDelete(0, name);
}

尝试多次运行该脚本,并指定不同的锚定角点。

在下一节中,我们将增强这个脚本,使其也能控制对象上的锚点。

定义对象上的锚点 ​

某些类型的对象允许选择锚点。属于此类的对象类型包括与报价关联的文本标签(OBJ_TEXT)和位图图像(OBJ_BITMAP),以及以屏幕坐标定位的标题(OBJ_LABEL)和带图像的面板(OBJ_BITMAP_LABEL)。

要读取和设置锚点,可使用 ObjectGetInteger 和 ObjectSetInteger 函数,并结合 OBJPROP_ANCHOR 属性。

所有的点选择选项都收集在 ENUM_ANCHOR_POINT 枚举中。

标识符锚点位置
ANCHOR_LEFT_UPPER左上角
ANCHOR_LEFT左侧中间
ANCHOR_LEFT_LOWER左下角
ANCHOR_LOWER底部中间
ANCHOR_RIGHT_LOWER右下角
ANCHOR_RIGHT右侧中间
ANCHOR_RIGHT_UPPER右上角
ANCHOR_UPPER顶部中间
ANCHOR_CENTER恰好在对象的中心

这些点在下面的图片中清晰可见,其中在图表上应用了几个标签对象。

上面一组四个标签具有相同的一对坐标(X,Y),但是,由于锚定到对象的不同角,它们位于该点的不同侧。第二组四个文本标签也有类似的情况,不过,在那里是锚定到对象不同边的中点。最后,底部单独显示的标题锚定在其中心,因此该点在对象内部。

按钮(OBJ_BUTTON)、矩形面板(OBJ_RECTANGLE_LABEL)、输入字段(OBJ_EDIT)和图表对象(OBJ_CHART)在左上角(ANCHOR_LEFT_UPPER)有一个固定的锚点。

单价格标记组中的一些图形对象(OBJ_ARROW、OBJ_ARROW_THUMB_UP、OBJ_ARROW_THUMB_DOWN、OBJ_ARROW_UP、OBJ_ARROW_DOWN、OBJ_ARROW_STOP、OBJ_ARROW_CHECK)有两种锚定其坐标的方式,由另一个枚举 ENUM_ARROW_ANCHOR 的标识符指定。

标识符锚点位置
ANCHOR_TOP顶部中间
ANCHOR_BOTTOM底部中间

该组中的其余对象具有预定义的锚点:买入箭头(OBJ_ARROW_BUY)和卖出箭头(OBJ_ARROW_SELL)分别位于上侧和下侧的中间,价格标签(OBJ_ARROW_RIGHT_PRICE、OBJ_ARROW_LEFT_PRICE)分别在左侧和右侧。

与上一节中的 ObjectCornerLabel.mq5 脚本类似,我们来创建 ObjectAnchorLabel.mq5 脚本。在新版本中,除了移动文本内容外,我们还将随机更改其上的锚点。

锚定的窗口角将像以前一样,在脚本启动时由用户选择。

c++
input ENUM_BASE_CORNER Corner = CORNER_LEFT_UPPER;

我们将在图表上以注释的形式显示角的名称。

c++
void OnStart()
{
    Comment(EnumToString(Corner));
   ...

在无限循环中,在选定的时间生成 9 个可能的锚点值之一。

c++
    ENUM_ANCHOR_POINT anchor = 0;
    for( ;!IsStopped(); ++pass)
    {
        if(pass % 50 == 0)
        {
           ...
            anchor = (ENUM_ANCHOR_POINT)(rand() * 9 / 32768);
            ObjectSetInteger(0, name, OBJPROP_ANCHOR, anchor);
        }
       ...

锚点的名称与当前坐标一起成为标签的文本内容。

c++
        ObjectSetString(0, name, OBJPROP_TEXT, EnumToString(anchor)
            + "[" + (string)x + "," + (string)y + "]");

其余的代码片段在很大程度上保持不变。

编译并运行脚本后,请注意文本内容如何根据所选的锚点相对于当前坐标(x,y)改变其位置。

目前,我们控制并防止锚点本身超出窗口范围。然而,对象具有一定的尺寸,因此可能会出现大部分文本内容被截断的情况。在将来,学习了相关属性之后,我们将处理这个问题(请参阅 “确定对象宽度和高度” 部分中的 ObjectSizeLabel.mq5 示例)。

定义对象上的锚点 ​

某些类型的对象允许选择锚点。属于此类的对象类型包括与报价关联的文本标签(OBJ_TEXT)和位图图像(OBJ_BITMAP),以及以屏幕坐标定位的标题(OBJ_LABEL)和带图像的面板(OBJ_BITMAP_LABEL)。

要读取和设置锚点,可使用 ObjectGetInteger 和 ObjectSetInteger 函数,并结合 OBJPROP_ANCHOR 属性。

所有的点选择选项都收集在 ENUM_ANCHOR_POINT 枚举中。

标识符锚点位置
ANCHOR_LEFT_UPPER左上角
ANCHOR_LEFT左侧中间
ANCHOR_LEFT_LOWER左下角
ANCHOR_LOWER底部中间
ANCHOR_RIGHT_LOWER右下角
ANCHOR_RIGHT右侧中间
ANCHOR_RIGHT_UPPER右上角
ANCHOR_UPPER顶部中间
ANCHOR_CENTER恰好在对象的中心

这些点在下面的图片中清晰可见,其中在图表上应用了几个标签对象。

上面一组四个标签具有相同的一对坐标(X,Y),但是,由于锚定到对象的不同角,它们位于该点的不同侧。第二组四个文本标签也有类似的情况,不过,在那里是锚定到对象不同边的中点。最后,底部单独显示的标题锚定在其中心,因此该点在对象内部。

按钮(OBJ_BUTTON)、矩形面板(OBJ_RECTANGLE_LABEL)、输入字段(OBJ_EDIT)和图表对象(OBJ_CHART)在左上角(ANCHOR_LEFT_UPPER)有一个固定的锚点。

单价格标记组中的一些图形对象(OBJ_ARROW、OBJ_ARROW_THUMB_UP、OBJ_ARROW_THUMB_DOWN、OBJ_ARROW_UP、OBJ_ARROW_DOWN、OBJ_ARROW_STOP、OBJ_ARROW_CHECK)有两种锚定其坐标的方式,由另一个枚举 ENUM_ARROW_ANCHOR 的标识符指定。

标识符锚点位置
ANCHOR_TOP顶部中间
ANCHOR_BOTTOM底部中间

该组中的其余对象具有预定义的锚点:买入箭头(OBJ_ARROW_BUY)和卖出箭头(OBJ_ARROW_SELL)分别位于上侧和下侧的中间,价格标签(OBJ_ARROW_RIGHT_PRICE、OBJ_ARROW_LEFT_PRICE)分别在左侧和右侧。

与上一节中的 ObjectCornerLabel.mq5 脚本类似,我们来创建 ObjectAnchorLabel.mq5 脚本。在新版本中,除了移动文本内容外,我们还将随机更改其上的锚点。

锚定的窗口角将像以前一样,在脚本启动时由用户选择。

c++
input ENUM_BASE_CORNER Corner = CORNER_LEFT_UPPER;

我们将在图表上以注释的形式显示角的名称。

c++
void OnStart()
{
    Comment(EnumToString(Corner));
   ...

在无限循环中,在选定的时间生成 9 个可能的锚点值之一。

c++
    ENUM_ANCHOR_POINT anchor = 0;
    for( ;!IsStopped(); ++pass)
    {
        if(pass % 50 == 0)
        {
           ...
            anchor = (ENUM_ANCHOR_POINT)(rand() * 9 / 32768);
            ObjectSetInteger(0, name, OBJPROP_ANCHOR, anchor);
        }
       ...

锚点的名称与当前坐标一起成为标签的文本内容。

c++
        ObjectSetString(0, name, OBJPROP_TEXT, EnumToString(anchor)
            + "[" + (string)x + "," + (string)y + "]");

其余的代码片段在很大程度上保持不变。

编译并运行脚本后,请注意文本内容如何根据所选的锚点相对于当前坐标(x,y)改变其位置。

目前,我们控制并防止锚点本身超出窗口范围。然而,对象具有一定的尺寸,因此可能会出现大部分文本内容被截断的情况。在将来,学习了相关属性之后,我们将处理这个问题(请参阅 “确定对象宽度和高度” 部分中的 ObjectSizeLabel.mq5 示例)。

对象状态管理 ​

在对象的通用属性中,有几个属性用于控制对象的状态。所有这些属性都是布尔类型,这意味着它们可以开启(true)或关闭(false),因此需要使用 ObjectGetInteger 和 ObjectSetInteger 函数。

标识符描述
OBJPROP_HIDDEN禁止在相关对话框(从图表的上下文菜单调用或按 Ctrl+B 组合键)的对象列表中显示图形对象的名称。
OBJPROP_SELECTED对象的选中状态
OBJPROP_SELECTABLE对象是否可供选择

当 OBJPROP_HIDDEN 的值为 true 时,可以将不必要的对象从用户的对象列表中隐藏起来。默认情况下,显示日历事件、交易历史的对象以及由 MQL 程序创建的对象,该属性的值都设置为 true。要查看此类图形对象并访问它们的属性,可以按下“对象列表”对话框中的“全部”按钮。

在列表中隐藏的对象在图表上仍然可见。要在不删除对象的情况下在图表上隐藏该对象,可以使用“在时间周期上下文中的对象可见性”设置。

如果 OBJPROP_SELECTABLE 的值为 false,用户将无法选择和更改对象的属性。默认情况下,通过编程方式创建的对象不允许被选择。正如我们在前面章节的 ObjectCornerLabel.mq5 和 ObjectAnchorLabel.mq5 脚本中看到的,有必要显式地将 OBJPROP_SELECTABLE 设置为 true,以解锁设置 OBJPROP_SELECTED 的功能。我们就是通过这种方式突出显示对象上的锚点的。

通常,只有当 MQL 程序创建的对象用作控件时,MQL 程序才允许选择这些对象。例如,具有预定义名称的趋势线,用户可以随意移动它,当价格穿过该趋势线时,它可以作为发送交易订单的一个条件。

对象的优先级(Z 序) ​

图表上的对象不仅用于呈现信息,还能通过事件与用户和 MQL 程序进行交互,这一点我们将在下一章详细讨论。其中一个事件源是鼠标指针。具体来说,图表能够跟踪鼠标的移动以及鼠标按钮的按下情况。

如果鼠标下方有一个对象,就可以针对该对象执行特定的事件处理。然而,对象之间可能会相互重叠(当它们的坐标在考虑大小的情况下重叠时)。在这种情况下,OBJPROP_ZORDER 整数属性就会发挥作用。它设置了图形对象接收鼠标事件的优先级。当对象重叠时,只有优先级高于其他对象的那个对象会接收该事件。

默认情况下,创建对象时其 Z 序为零,但如果有需要,你可以提高它的 Z 序。

需要注意的是,Z 序仅会影响鼠标事件的处理,而不会影响对象的绘制。对象始终按照它们添加到图表中的顺序进行绘制。这可能会导致一些误解。例如,对于一个在视觉上位于另一个对象之上的对象,其工具提示可能不会显示,这是因为被重叠的对象具有更高的 Z 优先级(请参见示例)。

在 ObjectZorder.mq5 脚本中,我们将创建 12 个 OBJ_RECTANGLE_LABEL 类型的对象,将它们放置成一个圆形,就像在钟面上一样。添加对象的顺序对应着小时数:从 1 到 12。为了更清晰地展示,所有矩形都将被赋予随机颜色(关于 OBJPROP_BGCOLOR 属性,请参见下一节),以及随机的优先级。通过将鼠标移动到对象上,用户将能够通过工具提示确定鼠标所在的对象。

为了方便设置对象的属性,我们定义了一个特殊的类 ObjectBuilder,它派生自 ObjectSelector。

c
#include "ObjectPrefix.mqh"
#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);
   }
   
   // 禁止更改名称和图表
   virtual void name(const string _id) override = delete;
   virtual void chart(const long _chart) override = delete;
};

对象标识符(id)和图表(host)的字段已经存在于 ObjectSelector 类中。在派生类中,我们添加了一个对象类型(ENUM_OBJECT type)和一个窗口编号(int window)。构造函数调用了 ObjectCreate。

设置和读取属性完全作为一组 get 和 set 方法从 ObjectSelector 类继承而来。

与之前的测试脚本一样,我们确定脚本放置的窗口、窗口的尺寸以及窗口中心的坐标。

c
void OnStart()
{
   const int t = ChartWindowOnDropped();
   int h = (int)ChartGetInteger(0, CHART_HEIGHT_IN_PIXELS, t);
   int w = (int)ChartGetInteger(0, CHART_WIDTH_IN_PIXELS);
   int x = w / 2;
   int y = h / 2;
   ...

由于 OBJ_RECTANGLE_LABEL 对象类型支持显式的像素尺寸,我们将每个矩形的宽度 dx 和高度 dy 计算为窗口的四分之一。我们使用它们来设置在“确定对象宽度和高度”部分讨论的 OBJPROP_XSIZE 和 OBJPROP_YSIZE 属性。

c
   const int dx = w / 4;
   const int dy = h / 4;
   ...

接下来,在循环中,我们创建 12 个对象。变量 px 和 py 包含了“表盘”上下一个“标记”相对于中心(x, y)的偏移量。z 的优先级是随机选择的。对象的名称及其工具提示(OBJPROP_TOOLTIP)包含一个类似 “XX - YYY” 的字符串,XX 是“小时”数(表盘上的位置从 1 到 12),YYY 是优先级。

c
   for(int i = 0; i < 12; ++i)
   {
      const int px = (int)(MathSin((i + 1) * 30 * M_PI / 180) * dx) - dx / 2;
      const int py = -(int)(MathCos((i + 1) * 30 * M_PI / 180) * dy) - dy / 2;
      
      const int z = rand();
      const string text = StringFormat("%02d - %d", i + 1, z);
   
      ObjectBuilder *builder =
         new ObjectBuilder(ObjNamePrefix + text, OBJ_RECTANGLE_LABEL);
      builder.set(OBJPROP_XDISTANCE, x + px).set(OBJPROP_YDISTANCE, y + py)
      .set(OBJPROP_XSIZE, dx).set(OBJPROP_YSIZE, dy)
      .set(OBJPROP_TOOLTIP, text)
      .set(OBJPROP_ZORDER, z)
      .set(OBJPROP_BGCOLOR, (rand() << 8) | rand());
      delete builder;
   }

在调用 ObjectBuilder 构造函数之后,对于新的 builder 对象,对不同属性的重载 set 方法的调用是链式的(set 方法返回指向对象本身的指针)。

由于在创建和配置图形对象之后,不再需要 MQL 对象,所以我们立即删除 builder。

脚本执行的结果是,图表上大约会出现以下对象。

对象重叠和 Z 序优先级工具提示 ​

对象重叠和 Z 序优先级工具提示

每次运行脚本时,颜色和优先级都会不同,但矩形的视觉重叠情况将始终相同,按照创建顺序,从底部的 1 到顶部的 12(这里我们指的是对象的重叠,而不是说 12 位于表盘的顶部这一事实)。

在图片中,鼠标光标位于存在两个对象的位置,即 01(荧光柠檬绿)和 12(沙色)。在这种情况下,虽然在视觉上对象 12 显示在对象 01 的上方,但对象 01 的工具提示是可见的。这是因为 01 被随机生成的优先级高于 12。

一次只会显示一个工具提示,所以你可以通过将鼠标光标移动到其他没有对象重叠的区域来检查优先级关系,在这些区域中,工具提示中的信息属于光标下方的单个对象。

当下一章我们学习鼠标事件处理时,我们可以改进这个示例,并测试 Z 序对对象上鼠标点击的影响。

要删除创建的对象,你可以使用 ObjectCleanup1.mq5 脚本。

对象显示设置:颜色、样式和边框 ​

对象的外观可以通过多种属性进行更改,在本节中我们将探讨这些属性,首先从颜色、样式、线宽和边框开始。其他格式方面,如字体、倾斜度和文本对齐方式,将在后续章节中介绍。

以下表格中的所有属性类型都与整数兼容,因此可通过 ObjectGetInteger 和 ObjectSetInteger 函数进行管理。

标识符描述属性类型
OBJPROP_COLOR线条颜色以及对象的主要元素颜色(例如,字体或填充色)color(颜色)
OBJPROP_STYLE线条样式ENUM_LINE_STYLE(线条样式枚举类型)
OBJPROP_WIDTH线条的像素宽度int(整数)
OBJPROP_FILL用颜色填充对象(适用于 OBJ_RECTANGLE、OBJ_TRIANGLE、OBJ_ELLIPSE、OBJ_CHANNEL、OBJ_STDDEVCHANNEL、OBJ_REGRESSION)bool(布尔值)
OBJPROP_BACK对象在背景中显示bool(布尔值)
OBJPROP_BGCOLOROBJ_EDIT、OBJ_BUTTON、OBJ_RECTANGLE_LABEL 的背景颜色color(颜色)
OBJPROP_BORDER_TYPE矩形面板 OBJ_RECTANGLE_LABEL 的边框类型ENUM_BORDER_TYPE(边框类型枚举)
OBJPROP_BORDER_COLOR输入字段 OBJ_EDIT 和按钮 OBJ_BUTTON 的边框颜色color(颜色)

与大多数带线条的对象(如单独的垂直线和水平线、趋势线、周期线、通道等)不同,在这些对象中 OBJPROP_COLOR 属性定义线条的颜色,而对于 OBJ_BITMAP_LABEL 和 OBJ_BITMAP 图像,它定义边框颜色,OBJPROP_STYLE 定义边框的绘制类型。

我们在指标章节的 “绘图设置” 部分已经见过用于 OBJPROP_STYLE 的 ENUM_LINE_STYLE 枚举类型。

有必要区分由前景色 OBJPROP_COLOR 进行的填充和背景色 OBJPROP_BGCOLOR。不同的对象类型组支持这两种属性,具体已列在表格中。

OBJPROP_BACK 属性需要单独解释一下。实际上,对象和指标默认显示在价格图表的上方。用户可以通过进入图表的 “设置” 对话框,再到 “共享” 选项卡,选择 “图表在上方” 选项来更改整个图表的这种显示行为。这个标志在软件层面也有对应的属性,即 CHART_FOREGROUND 属性(请参阅 “图表显示模式”)。然而,有时我们希望不是将所有对象都移到背景中,而只是将选定的对象移到背景中。那么对于这些对象,可以将 OBJPROP_BACK 设置为 true。在这种情况下,如果图表上启用了网格和周期分隔线,该对象甚至会被它们覆盖。

当 OBJPROP_FILL 填充模式启用时,落在形状内部的柱状图的颜色取决于 OBJPROP_BACK 属性。默认情况下,当 OBJPROP_BACK 等于 false 时,与对象重叠的柱状图将以与 OBJPROP_COLOR 相反的颜色绘制(通过将颜色值中的所有位取反得到相反的颜色,例如,0xFF0080 的反色是 0x00FF7F)。当 OBJPROP_BACK 等于 true 时,柱状图将以常规方式绘制,因为对象显示在背景中,即在图表的 “下方”(见下面的示例)。

ENUM_BORDER_TYPE 枚举包含以下元素:

标识符外观
BORDER_FLAT平的
BORDER_RAISED凸起的
BORDER_SUNKEN凹陷的

当边框为平的(BORDER_FLAT)时,它将根据 OBJPROP_COLOR、OBJPROP_STYLE、OBJPROP_WIDTH 属性绘制为具有相应颜色、样式和宽度的线条。凸起和凹陷的版本会用 OBJPROP_BGCOLOR 的色调来模拟对象周边的立体倒角。

当未设置边框颜色 OBJPROP_BORDER_COLOR 时(默认值,对应 clrNone),输入字段将用主色 OBJPROP_COLOR 的线条框起来,按钮周围将绘制一个带有 OBJPROP_BGCOLOR 色调倒角的三维边框。

为了测试这些新属性,我们来看一下 ObjectStyle.mq5 脚本。在这个脚本中,我们将创建 5 个 OBJ_RECTANGLE 类型的矩形,也就是与时间和价格相关的矩形。它们将均匀分布在窗口的整个宽度上,突出显示五个时间段中每个时间段内的最高价 High 和最低价 Low 之间的范围。对于所有对象,我们将调整并定期更改线条颜色、样式和粗细,以及填充和在图表后面显示的选项。

我们再次使用从 ObjectSelector 派生的辅助类 ObjectBuilder。与上一节不同的是,我们在 ObjectBuilder 中添加一个析构函数,在其中调用 ObjectDelete。

c++
#include <MQL5Book/ObjectMonitor.mqh>
#include <MQL5Book/AutoPtr.mqh>

class ObjectBuilder: public ObjectSelector
{
...
public:
    ~ObjectBuilder()
    {
        ObjectDelete(host, id);
    }
   ...
};

这将使我们不仅可以为这个类分配对象的配置,还能在脚本完成时自动删除对象。

在 OnStart 函数中,我们要找出可见柱状图的数量和第一根柱状图的索引,同时计算一个矩形所跨越的柱状图宽度。

c++
#define OBJECT_NUMBER 5

void OnStart()
{
    const string name = "ObjStyle-";
    const int bars = (int)ChartGetInteger(0, CHART_VISIBLE_BARS);
    const int first = (int)ChartGetInteger(0, CHART_FIRST_VISIBLE_BAR);
    const int rectsize = bars / OBJECT_NUMBER;
   ...

我们为对象预留一个智能指针数组,以确保调用 ObjectBuilder 的析构函数。

c++
    AutoPtr<ObjectBuilder> objects[OBJECT_NUMBER];

定义一个调色板并创建 5 个矩形对象。

c++
    color colors[OBJECT_NUMBER] = {clrRed, clrGreen, clrBlue, clrMagenta, clrOrange};

    for(int i = 0; i < OBJECT_NUMBER; ++i)
    {
        // 找到确定第 i 个子时间范围内价格范围的柱状图索引
        const int h = iHighest(NULL, 0, MODE_HIGH, rectsize, i * rectsize);
        const int l = iLowest(NULL, 0, MODE_LOW, rectsize, i * rectsize);
        // 在第 i 个子范围内创建并设置一个对象
        ObjectBuilder *object = new ObjectBuilder(name + (string)(i + 1), OBJ_RECTANGLE);
        object.set(OBJPROP_TIME, iTime(NULL, 0, i * rectsize), 0);
        object.set(OBJPROP_TIME, iTime(NULL, 0, (i + 1) * rectsize), 1);
        object.set(OBJPROP_PRICE, iHigh(NULL, 0, h), 0);
        object.set(OBJPROP_PRICE, iLow(NULL, 0, l), 1);
        object.set(OBJPROP_COLOR, colors[i]);
        object.set(OBJPROP_WIDTH, i + 1);
        object.set(OBJPROP_STYLE, (ENUM_LINE_STYLE)i);
        // 保存到数组
        objects[i] = object;
    }
   ...

在这里,为每个对象计算了两个锚点的坐标,并设置了初始颜色、样式和线宽。

接下来,在一个无限循环中,我们更改对象的属性。当 ScrollLock 键开启时,动画可以暂停。

c++
    const int key = TerminalInfoInteger(TERMINAL_KEYSTATE_SCRLOCK);
    int pass = 0;
    int offset = 0;

    for( ;!IsStopped(); ++pass)
    {
        Sleep(200);
        if(TerminalInfoInteger(TERMINAL_KEYSTATE_SCRLOCK) != key) continue;
        // 时不时地更改颜色/样式/宽度/填充/背景
        if(pass % 5 == 0)
        {
            ++offset;
            for(int i = 0; i < OBJECT_NUMBER; ++i)
            {
                objects[i][].set(OBJPROP_COLOR, colors[(i + offset) % OBJECT_NUMBER]);
                objects[i][].set(OBJPROP_WIDTH, (i + offset) % OBJECT_NUMBER + 1);
                objects[i][].set(OBJPROP_FILL, rand() > 32768 / 2);
                objects[i][].set(OBJPROP_BACK, rand() > 32768 / 2);
            }
        }
        ChartRedraw();
    }

这是图表上的显示效果。

最左边的红色矩形填充模式开启且位于前景。所以,它内部的柱状图以对比鲜明的亮蓝色(clrAqua,通常也称为青色,是 clrRed 的反色)显示。紫色矩形也有填充,但设置了背景选项,所以其中的柱状图以标准方式显示。

请注意,由于线条宽度较大且显示在图表上方,橙色矩形完全覆盖了其时间段起始和结束处的柱状图。

当填充开启时,线宽将不被考虑。当边框宽度大于 1 时,一些虚线样式将不被应用。

对象形状绘制 ​

对于本节的第二个示例,回想一下我们在学习面向对象编程(OOP)的第三部分中勾勒出的假设形状绘制程序。我们当时的进展停留在虚拟绘制方法(当时它被称为 draw)中,我们只能向日志打印一条消息,表明我们正在绘制一个特定的形状。现在,在熟悉了图形对象之后,我们有机会实现绘制功能。

我们以 Shapes5stats.mq5 脚本作为起点。更新后的版本将被称为 ObjectShapesDraw.mq5。

回想一下,除了基类 Shape 之外,我们还描述了几个形状类:Rectangle(矩形)、Ellipse(椭圆)、Triangle(三角形)、Square(正方形)、Circle(圆形)。它们都可以很好地与 OBJ_RECTANGLE、OBJ_ELLIPSE、OBJ_TRIANGLE 类型的图形对象对应。但也有一些细微差别。

所有指定的对象都与时间和价格坐标相关联,而我们的绘制程序假设的是具有点定位的统一 X 和 Y 轴。在这方面,我们需要以特殊方式设置绘图的图表,并使用 ChartXYToTimePrice 函数将屏幕点重新计算为时间和价格坐标。

此外,OBJ_ELLIPSE 和 OBJ_TRIANGLE 对象允许任意旋转(特别是椭圆的短半径和长半径可以旋转),而 OBJ_RECTANGLE 的边始终是水平和垂直方向的。为了简化示例,我们将所有形状都限制为标准位置。

从理论上讲,新的实现应该被视为图形对象的演示,而不是一个绘图程序。对于完整的绘图,更正确的方法是使用图形资源,这样可以避免图形对象所带来的限制(因为它们总体上是为其他目的设计的,比如图表标记)。因此,我们将在资源章节中重新思考这个绘图程序。

在新的 Shape 类中,我们去掉带有对象坐标的嵌套结构 Pair:这个结构曾是展示 OOP 几个原则的一种方式,但现在更简单的做法是将字段 int x, y 的原始描述直接放回 Shape 类中。我们还将添加一个带有对象名称的字段。

c++
class Shape
{
   ...
protected:
    int x, y;
    color backgroundColor;
    const string type;
    string name;

    Shape(int px, int py, color back, string t) :
        x(px), y(py),
        backgroundColor(back),
        type(t)
    {
    }

public:
    ~Shape()
    {
        ObjectDelete(0, name);
    }
   ...

name 字段对于设置图形对象的属性以及从图表中删除对象是必要的,在析构函数中进行删除操作是符合逻辑的。

由于不同类型的形状需要不同数量的点或特征尺寸,除了虚拟绘制方法 draw 之外,我们还将设置方法 setup 添加到 Shape 接口中:

c++
virtual void setup(const int &parameters[]) = 0;

回想一下,在脚本中我们实现了一个嵌套类 Shape::Registrator,它负责按类型统计形状的数量。现在是时候赋予它更重要的职责,让它充当形状的 “工厂”。“工厂” 类或方法的优点在于它们允许以统一的方式创建不同类的对象。

为此,我们向 Registrator 添加一个创建形状的方法,其参数包括第一个点的必填坐标、一种颜色以及一个附加参数数组(每个形状将能够根据自己的规则解释这些参数,并且在将来可以从文件中读取或写入文件)。

c++
virtual Shape *create(const int px, const int py, const color back,
        const int &parameters[]) = 0;

这个方法是抽象虚拟的,因为某些类型的形状只能由 Shape 派生类中描述的派生注册器类来创建。为了简化派生记录器类的编写,我们引入一个模板类 MyRegistrator,它实现了适用于所有情况的 create 方法。

c++
template<typename T>
class MyRegistrator : public Shape::Registrator
{
public:
    MyRegistrator() : Registrator(typename(T))
    {
    }

    virtual Shape *create(const int px, const int py, const color back,
        const int &parameters[]) override
    {
        T *temp = new T(px, py, back);
        temp.setup(parameters);
        return temp;
    }
};

在这里,我们调用某个之前未知的形状 T 的构造函数,通过调用 setup 对其进行设置,并将实例返回给调用代码。

下面是它在 Rectangle 类中的使用方式,Rectangle 类有两个用于宽度和高度的附加参数。

c++
class Rectangle : public Shape
{
    static MyRegistrator<Rectangle> r;

protected:
    int dx, dy; // 尺寸(宽度,高度)

    Rectangle(int px, int py, color back, string t) :
        Shape(px, py, back, t), dx(1), dy(1)
    {
    }

public:
    Rectangle(int px, int py, color back) :
        Shape(px, py, back, typename(this)), dx(1), dy(1)
    {
        name = typename(this) + (string)r.increment();
    }

    virtual void setup(const int &parameters[]) override
    {
        if(ArraySize(parameters) < 2)
        {
            Print("Insufficient parameters for Rectangle");
            return;
        }
        dx = parameters[0];
        dy = parameters[1];
    }
   ...
};

static MyRegistrator<Rectangle> Rectangle::r;

在创建形状时,它的名称不仅将包含类名(typename),还将包含实例的序号,该序号在 r.increment() 调用中计算。

其他形状类的描述方式类似。

现在是时候看看 Rectangle 的 draw 方法了。在这个方法中,我们使用 ChartXYToTimePrice 将一对点 (x,y) 和 (x + dx, y + dy) 转换为时间/价格坐标,并创建一个 OBJ_RECTANGLE 对象。

c++
    void draw() override
    {
        // Print("Drawing rectangle");
        int subw;
        datetime t;
        double p;
        ChartXYToTimePrice(0, x, y, subw, t, p);
        ObjectCreate(0, name, OBJ_RECTANGLE, 0, t, p);
        ChartXYToTimePrice(0, x + dx, y + dy, subw, t, p);
        ObjectSetInteger(0, name, OBJPROP_TIME, 1, t);
        ObjectSetDouble(0, name, OBJPROP_PRICE, 1, p);

        ObjectSetInteger(0, name, OBJPROP_COLOR, backgroundColor);
        ObjectSetInteger(0, name, OBJPROP_FILL, true);
    }

当然,不要忘记将颜色设置为 OBJPROP_COLOR 并将填充设置为 OBJPROP_FILL。

对于 Square 类,实际上不需要做任何更改:只需将 dx 和 dy 设置为相等即可。

对于 Ellipse 类,两个附加选项 dx 和 dy 确定相对于中心 (x,y) 绘制的短半径和长半径。相应地,在 draw 方法中,我们计算 3 个锚点并创建一个 OBJ_ELLIPSE 对象。

c++
class Ellipse : public Shape
{
    static MyRegistrator<Ellipse> r;
protected:
    int dx, dy; // 长半径和短半径 
   ...
public:
    void draw() override
    {
        // Print
```c++
        // Print("Drawing ellipse");
        int subw;
        datetime t;
        double p;
        
        // (x, y) center
        // p0: x + dx, y
        // p1: x - dx, y
        // p2: x, y + dy
        
        ChartXYToTimePrice(0, x + dx, y, subw, t, p);
        ObjectCreate(0, name, OBJ_ELLIPSE, 0, t, p);
        ChartXYToTimePrice(0, x - dx, y, subw, t, p);
        ObjectSetInteger(0, name, OBJPROP_TIME, 1, t);
        ObjectSetDouble(0, name, OBJPROP_PRICE, 1, p);
        ChartXYToTimePrice(0, x, y + dy, subw, t, p);
        ObjectSetInteger(0, name, OBJPROP_TIME, 2, t);
        ObjectSetDouble(0, name, OBJPROP_PRICE, 2, p);
        
        ObjectSetInteger(0, name, OBJPROP_COLOR, backgroundColor);
        ObjectSetInteger(0, name, OBJPROP_FILL, true);
    }
};

static MyRegistrator<Ellipse> Ellipse::r;

Circle(圆形)是半径相等的椭圆的特殊情况。

最后,在这个阶段只支持等边三角形:边长的大小包含在一个额外的字段 dx 中。建议你在源代码中自行学习它们的 draw 方法。

新的脚本将像以前一样,生成给定数量的随机形状。它们由函数 addRandomShape 创建。

c++
Shape *addRandomShape()
{
    const int w = (int)ChartGetInteger(0, CHART_WIDTH_IN_PIXELS);
    const int h = (int)ChartGetInteger(0, CHART_HEIGHT_IN_PIXELS);
    
    const int n = random(Shape::Registrator::getTypeCount());
    
    int cx = 1 + w / 4 + random(w / 2), cy = 1 + h / 4 + random(h / 2);
    int clr = ((random(256) << 16) | (random(256) << 8) | random(256));
    int custom[] = {1 + random(w / 4), 1 + random(h / 4)};
    return Shape::Registrator::get(n).create(cx, cy, clr, custom);
}

在这里,我们看到了工厂方法 create 的使用,它在一个随机选择的编号为 n 的注册器对象上被调用。如果我们以后决定添加其他形状类,无需在生成逻辑中更改任何内容。

所有形状都放置在窗口的中心部分,并且尺寸不超过窗口的四分之一。

现在还需要直接考虑对 addRandomShape 函数的调用,以及我们已经提到的特殊图表设置。

为了在屏幕上提供点的 “正方形” 表示,设置 CHART_SCALEFIX_11 模式。此外,我们将选择时间轴上最密集(压缩)的比例 CHART_SCALE (0),因为在这种比例下,一根柱状图占据 1 个水平像素(最高精度)。最后,通过将 CHART_SHOW 设置为 false 来禁用图表本身的显示。

c++
void OnStart()
{
    const int scale = (int)ChartGetInteger(0, CHART_SCALE);
    ChartSetInteger(0, CHART_SCALEFIX_11, true);
    ChartSetInteger(0, CHART_SCALE, 0);
    ChartSetInteger(0, CHART_SHOW, false);
    ChartRedraw();
   ...

为了存储形状,我们预留一个智能指针数组,并用随机形状填充它。

c++
#define FIGURES 21
...
void OnStart()
{
   ...
    AutoPtr<Shape> shapes[FIGURES];
    
    for(int i = 0; i < FIGURES; ++i)
    {
        Shape *shape = shapes[i] = addRandomShape();
        shape.draw();
    }
    
    ChartRedraw();
   ...

然后,我们运行一个无限循环,直到用户停止脚本。在循环中,我们使用 move 方法稍微移动这些形状。

c++
    while(!IsStopped())
    {
        Sleep(250);
        for(int i = 0; i < FIGURES; ++i)
        {
            shapes[i][].move(random(20) - 10, random(20) - 10);
            shapes[i][].draw();
        }
        ChartRedraw();
    }
   ...

最后,我们恢复图表设置。

c++
    // 仅禁用 CHART_SCALEFIX_11 是不够的,还需要设置 CHART_SCALEFIX
    ChartSetInteger(0, CHART_SCALEFIX, false);
    ChartSetInteger(0, CHART_SCALE, scale);
    ChartSetInteger(0, CHART_SHOW, true);
}

以下屏幕截图展示了绘制了形状的图表可能的样子。

绘制对象的特点是在它们重叠的地方颜色会 “叠加”。

因为 Y 轴是上下方向的,所有三角形都是倒置的,但这并不关键,因为无论如何我们都要基于资源重写绘图程序。

字体设置 ​

所有类型的对象都可以为其设置特定的文本(OBJPROP_TEXT)。其中许多对象会直接在图表上显示指定的文本,对于其他对象,该文本会成为工具提示的信息部分。

当文本显示在对象内部(如 OBJ_TEXT、OBJ_LABEL、OBJ_BUTTON 和 OBJ_EDIT 类型的对象)时,你可以选择字体名称和大小。对于其他类型的对象,字体设置不会生效,它们的描述信息始终以图表的标准字体显示。

标识符描述类型
OBJPROP_FONTSIZE字体大小(像素)int
OBJPROP_FONT字体string

这里不能以打印点为单位设置字体大小。

测试脚本 ObjectFont.mq5 会创建带有文本的对象,并更改字体名称和大小。我们将使用之前脚本中的 ObjectBuilder 类。

在 OnStart 函数开始时,脚本会计算窗口在屏幕坐标以及时间/价格轴上的中间位置。这是必要的,因为参与测试的不同类型的对象使用不同的坐标系统。

c
void OnStart()
{
    const string name = "ObjFont-";
    
    const int bars = (int)ChartGetInteger(0, CHART_WIDTH_IN_BARS);
    const int first = (int)ChartGetInteger(0, CHART_FIRST_VISIBLE_BAR);
    
    const datetime centerTime = iTime(NULL, 0, first - bars / 2);
    const double centerPrice =
        (ChartGetDouble(0, CHART_PRICE_MIN)
        + ChartGetDouble(0, CHART_PRICE_MAX)) / 2;
    
    const int centerX = (int)ChartGetInteger(0, CHART_WIDTH_IN_PIXELS) / 2;
    const int centerY = (int)ChartGetInteger(0, CHART_HEIGHT_IN_PIXELS) / 2;
    ...

要测试的对象类型列表在 types 数组中指定。对于其中一些对象,特别是 OBJ_HLINE 和 OBJ_VLINE,字体设置不会产生效果,尽管描述文本会显示在屏幕上(为确保这一点,我们开启了 CHART_SHOW_OBJECT_DESCR 模式)。

c
    ChartSetInteger(0, CHART_SHOW_OBJECT_DESCR, true);
    
    ENUM_OBJECT types[] =
    {
        OBJ_HLINE,
        OBJ_VLINE,
        OBJ_TEXT,
        OBJ_LABEL,
        OBJ_BUTTON,
        OBJ_EDIT,
    };
    int t = 0; // 游标
    ...

t 变量将用于依次从一种类型切换到另一种类型。

fonts 数组包含了最常用的 Windows 标准字体。

c
    string fonts[] =
    {
        "Comic Sans MS",
        "Consolas",
        "Courier New",
        "Lucida Console",
        "Microsoft Sans Serif",
        "Segoe UI",
        "Tahoma",
        "Times New Roman",
        "Trebuchet MS",
        "Verdana"
    };
    
    int f = 0; // 游标
    ...

我们将使用 f 变量来遍历这些字体。

在演示循环内部,我们指示 ObjectBuilder 在窗口中间创建当前类型 types[t] 的对象(为了统一,坐标在两种坐标系统中都进行了指定,这样代码就不会因对象类型不同而有所差异:对象不支持的坐标不会产生任何效果)。

c
    while(!IsStopped())
    {
        
        const string str = EnumToString(types[t]);
        ObjectBuilder *object = new ObjectBuilder(name + str, types[t]);
        object.set(OBJPROP_TIME, centerTime);
        object.set(OBJPROP_PRICE, centerPrice);
        object.set(OBJPROP_XDISTANCE, centerX);
        object.set(OBJPROP_YDISTANCE, centerY);
        object.set(OBJPROP_XSIZE, centerX / 3 * 2);
        object.set(OBJPROP_YSIZE, centerY / 3 * 2);
        ...

接下来,我们设置文本和字体(字体大小是随机选择的)。

c
        const int size = rand() * 15 / 32767 + 8;
        Comment(str + " " + fonts[f] + " " + (string)size);
        object.set(OBJPROP_TEXT, fonts[f] + " " + (string)size);
        object.set(OBJPROP_FONT, fonts[f]);
        object.set(OBJPROP_FONTSIZE, size);
        ...

为了进行下一轮循环,我们移动对象类型数组和字体名称数组的游标。

c
        t = ++t % ArraySize(types);
        f = ++f % ArraySize(fonts);
        ...

最后,我们更新图表,等待 1 秒钟,然后删除该对象以创建另一个对象。

c
        ChartRedraw();
        Sleep(1000);
        delete object;
    }
}

下面的图片展示了脚本运行时的场景。

以任意角度旋转文本 ​

文本类型的对象 —— 标签 OBJ_TEXT(在报价坐标中)和面板 OBJ_LABEL(在屏幕坐标中)—— 允许您以任意角度旋转文本标签。为此,有一个 double 类型的属性 OBJPROP_ANGLE。它包含相对于对象正常位置的角度(以度为单位)。正值使对象逆时针旋转,负值使对象顺时针旋转。

然而,应该记住,相差 360 度倍数的角度是相同的,也就是说,例如,+315 度和 -45 度是一样的。旋转是围绕对象上的锚点进行的(默认情况下是左上角)。

以 45 度的倍数旋转 OBJ_LABEL 和 OBJ_TEXT 对象 ​

您可以使用 ObjectAngle.mq5 脚本来检查 OBJPROP_ANGLE 属性对对象的影响。它在窗口的中心创建一个文本标签 OBJ_LABEL,然后开始定期旋转 45 度,直到用户停止该过程。

c
void OnStart()
{
   const string name = "ObjAngle";
   ObjectCreate(0, name, OBJ_LABEL, 0, 0, 0);
   const int centerX = (int)ChartGetInteger(0, CHART_WIDTH_IN_PIXELS) / 2;
   const int centerY = (int)ChartGetInteger(0, CHART_HEIGHT_IN_PIXELS) / 2;
   ObjectSetInteger(0, name, OBJPROP_XDISTANCE, centerX);
   ObjectSetInteger(0, name, OBJPROP_YDISTANCE, centerY);
   ObjectSetInteger(0, name, OBJPROP_ANCHOR, ANCHOR_CENTER);
   
   int angle = 0;
   while(!IsStopped())
   {
      ObjectSetString(0, name, OBJPROP_TEXT, StringFormat("Angle: %d°", angle));
      ObjectSetDouble(0, name, OBJPROP_ANGLE, angle);
      angle += 45;
     
      ChartRedraw();
      Sleep(1000);
   }
   ObjectDelete(0, name);
}

文本会显示当前的角度值。

确定对象的宽度和高度 ​

某些类型的对象允许你以像素为单位设置它们的尺寸。这些对象包括 OBJ_BUTTON、OBJ_CHART、OBJ_BITMAP、OBJ_BITMAP_LABEL、OBJ_EDIT 和 OBJ_RECTANGLE_LABEL。此外,OBJ_LABEL 对象支持读取(但不支持设置)尺寸,因为标签会自动扩展或收缩以适应其包含的文本。尝试访问其他类型对象的相关属性将导致 OBJECT_WRONG_PROPERTY(4203)错误。

标识符描述
OBJPROP_XSIZE对象沿 X 轴的宽度,单位为像素
OBJPROP_YSIZE对象沿 Y 轴的高度,单位为像素

这两个尺寸都是整数,因此通过 ObjectGetInteger/ObjectSetInteger 函数来处理。

对于 OBJ_BITMAP 和 OBJ_BITMAP_LABEL 对象,会执行特殊的尺寸处理。

在未指定图像的情况下,这些对象允许设置任意尺寸。同时,它们会被绘制为透明的(如果没有通过设置颜色 clrNone 来“隐藏”边框,那么只会显示边框),但它们会接收所有事件,特别是关于鼠标移动的事件(如果有文本描述,会在工具提示中显示)以及鼠标在对象上的点击事件。

当指定了图像时,图像默认会作为对象的高度和宽度。然而,MQL 程序可以设置较小的尺寸,并选择要显示的图像片段;关于这一点,更多内容将在“框架”部分介绍。如果你尝试设置的高度或宽度大于图像的尺寸,图像将停止显示,并且对象的尺寸不会改变。

例如,让我们开发一个改进版本的脚本 ObjectAnchorLabel.mq5,该脚本来自“定义对象上的锚点”部分。在该部分中,我们在窗口周围移动文本标签,并在标签到达窗口的任何边界时将其反转,但我们这样做时只考虑了锚点。因此,根据锚点在对象上的位置,可能会出现标签几乎完全超出窗口的情况。例如,如果锚点在对象的右侧,向左移动时,在锚点碰到窗口边缘之前,几乎所有的文本都会超出窗口的左边界。

在新脚本 ObjectSizeLabel.mq5 中,我们将考虑对象的尺寸,并在对象的任何一边碰到窗口边缘时立即改变移动方向。

为了正确实现这种模式,应该考虑到,用作对象上锚点坐标参考中心的每个窗口角落,都决定了 X 轴和 Y 轴的特征方向。例如,如果用户在 ENUM_BASE_CORNER 类型的 Corner 输入变量中选择了左上角,那么 X 轴从左到右递增,Y 轴从上到下递增。如果将右下角视为中心,那么 X 轴从右下角向左递增,Y 轴从下到上递增。

窗口中的锚点角落与对象上的锚点之间不同的相互组合,需要对对象边缘与窗口边界之间的距离进行不同的调整。特别是,当选择了右侧的某个角落以及对象右侧的某个锚点时,不需要对窗口的右边界进行修正,而在相对的左侧,我们必须考虑对象的宽度(这样它的尺寸就不会从左侧超出窗口)。

关于对象尺寸修正的规则可以概括为:

  • 在与锚点角落相邻的窗口边界上,当锚点位于对象相对于该角落的远侧时,需要进行修正;
  • 在与锚点角落相对的窗口边界上,当锚点位于对象相对于该角落的近侧时,需要进行修正。

换句话说,如果角落的名称(在 ENUM_BASE_CORNER 元素中)和锚点的名称(在 ENUM_ANCHOR_POINT 元素中)包含一个共同的单词(例如 RIGHT),则需要在窗口的远侧(即远离所选角落的一侧)进行修正。如果在 ENUM_BASE_CORNER 和 ENUM_ANCHOR_POINT 边的组合中发现相反的方向(例如 LEFT 和 RIGHT),则需要在窗口的最近一侧进行修正。这些规则在水平轴和垂直轴上的作用是相同的。

此外,应该考虑到锚点可以位于对象任何一边的中间。那么在垂直方向上,需要从窗口边界留出等于对象尺寸一半的缩进。

一个特殊情况是锚点位于对象的中心。对于这种情况,在任何方向上都应该始终留出等于对象尺寸一半的距离余量。

上述逻辑在一个名为 GetMargins 的特殊函数中实现。它将所选的角落和锚点,以及对象的尺寸(dx 和 dy)作为输入。该函数返回一个包含 4 个字段的结构体,这些字段包含了在窗口近边界和远边界方向上,应该从锚点留出的额外缩进大小,这样对象就不会超出可见范围。缩进根据对象本身的尺寸和相对位置来预留距离。

c
struct Margins
{
   int nearX; // 对象点与相邻于角落的窗口边界之间的 X 增量
   int nearY; // 对象点与相邻于角落的窗口边界之间的 Y 增量
   int farX;  // 对象点与窗口边界相对角落之间的 X 增量
   int farY;  // 对象点与窗口边界相对角落之间的 Y 增量
};
   
Margins GetMargins(const ENUM_BASE_CORNER corner, const ENUM_ANCHOR_POINT anchor,
   int dx, int dy)
{
   Margins margins = {}; // 默认修正值为零
   ...
   return margins;
}

为了统一算法,引入了以下方向(边)的宏定义:

c
   #define LEFT 0x1
   #define LOWER 0x2
   #define RIGHT 0x4
   #define UPPER 0x8
   #define CENTER 0x16

借助这些宏定义,定义了位掩码(组合),用于描述 ENUM_BASE_CORNER 和 ENUM_ANCHOR_POINT 枚举的元素。

c
   const int corner_flags[] = // ENUM_BASE_CORNER 元素的标志
   {
      LEFT | UPPER,
      LEFT | LOWER,
      RIGHT | LOWER,
      RIGHT | UPPER
   };
   
   const int anchor_flags[] = // ENUM_ANCHOR_POINT 元素的标志
   {
      LEFT | UPPER,
      LEFT,
      LEFT | LOWER,
      LOWER,
      RIGHT | LOWER,
      RIGHT,
      RIGHT | UPPER,
      UPPER,
      CENTER
   };

corner_flags 和 anchor_flags 这两个数组中的元素数量,与相应枚举中的元素数量完全相同。

接下来是主函数代码。首先,处理最简单的情况:中心锚点。

c
   if(anchor == ANCHOR_CENTER)
   {
      margins.nearX = margins.farX = dx / 2;
      margins.nearY = margins.farY = dy / 2;
   }
   else
   {
      ...
   }

为了分析其余情况,我们将使用上述数组中的位掩码,通过直接根据接收到的 corner 和 anchor 值来访问它们。

c
      const int mask = corner_flags[corner] & anchor_flags[anchor];
      ...

如果角落和锚点在同一水平边,以下条件将起作用,并且会调整窗口远边缘处的对象宽度。

c
      if((mask & (LEFT | RIGHT)) != 0)
      {
         margins.farX = dx;
      }
      ...

如果它们不在同一边,那么它们可能在相对的边,或者可能是锚点在水平边(顶部或底部)的中间。检查锚点是否在中间是通过表达式 (anchor_flags[anchor] & (LEFT | RIGHT)) == 0 来完成的 - 此时修正值等于对象宽度的一半。

c
      else
      {
         if((anchor_flags[anchor] & (LEFT | RIGHT)) == 0)
         {
            margins.nearX = dx / 2;
            margins.farX = dx / 2;
         }
         else
         {
            margins.nearX = dx;
         }
      }
      ...

否则,当角落和锚点的方向相反时,我们对窗口近边界处的对象宽度进行修正。

对 Y 轴也进行类似的检查。

c
      if((mask & (UPPER | LOWER)) != 0)
      {
         margins.farY = dy;
      }
      else
      {
         if((anchor_flags[anchor] & (UPPER | LOWER)) == 0)
         {
            margins.farY = dy / 2;
            margins.nearY = dy / 2;
         }
         else
         {
            margins.nearY = dy;
         }
      }

现在 GetMargins 函数已经准备好,我们可以进入脚本 OnStart 函数中的主代码部分。和以前一样,我们确定窗口的大小,计算中心的初始坐标,创建一个 OBJ_LABEL 对象,并选择它。

c
void OnStart()
{
   const int t = ChartWindowOnDropped();
   Comment(EnumToString(Corner));
   
   const string name = "ObjSizeLabel";
   int h = (int)ChartGetInteger(0, CHART_HEIGHT_IN_PIXELS, t) - 1;
   int w = (int)ChartGetInteger(0, CHART_WIDTH_IN_PIXELS) - 1;
   int x = w / 2;
   int y = h / 2;
      
   ObjectCreate(0, name, OBJ_LABEL, t, 0, 0);
   ObjectSetInteger(0, name, OBJPROP_SELECTABLE, true);
   ObjectSetInteger(0, name, OBJPROP_SELECTED, true);
   ObjectSetInteger(0, name, OBJPROP_CORNER, Corner);
   ...

对于动画效果,一个无限循环提供了变量 pass(迭代计数器)和 anchor(锚点,将定期随机选择)。

c
   int pass = 0;
   ENUM_ANCHOR_POINT anchor = 0;
   ...

但与 ObjectAnchorLabel.mq5 相比有一些变化。

我们不会生成对象的随机移动。相反,让我们设置一个对角线方向上 5 像素的恒定速度。

c
   int px = 5, py = 5;

为了存储文本标签的尺寸,我们将预留两个新变量。

c
   int dx = 0, dy = 0;

计算额外缩进的结果将存储在 Margins 类型的变量 m 中。

c
   Margins m = {};

接下来直接是移动和修改对象的循环。在这个循环中,每 75 次迭代(一次迭代为 100 毫秒,详见后文),我们随机选择一个新的锚点,根据它形成新的文本(对象的内容),并等待更改应用到对象上(调用 ChartRedraw)。后者是必要的,因为文本的尺寸会自动根据内容进行调整,而新的尺寸对我们来说很重要,以便在调用 GetMargins 时正确计算缩进。

我们通过调用带有 OBJPROP_XSIZE 和 OBJPROP_YSIZE 属性的 ObjectGetInteger 来获取尺寸。

c
   for( ;!IsStopped(); ++pass)
   {
      if(pass % 75 == 0)
      {
         // ENUM_ANCHOR_POINT 由 9 个元素组成:随机选择一个
         const int r = rand() * 8 / 32768 + 1;
         anchor = (ENUM_ANCHOR_POINT)((anchor + r) % 9);
         ObjectSetInteger(0, name, OBJPROP_ANCHOR, anchor);
         ObjectSetString(0, name, OBJPROP_TEXT, " " + EnumToString(anchor)
            + StringFormat("[%3d,%3d] ", x, y));
         ChartRedraw();
         Sleep(1);
   
         dx = (int)ObjectGetInteger(0, name, OBJPROP_XSIZE);
         dy = (int)ObjectGetInteger(0, name, OBJPROP_YSIZE);
         
         m = GetMargins(Corner, anchor, dx, dy);
      }
      ...

一旦我们知道了锚点和所有距离,我们就移动对象。如果它“撞到”了窗口边界,我们将移动方向改为相反的方向(根据边的情况,将 px 改为 -px 或 py 改为 -py)。

c
      // 从窗口边界弹回,对象完全可见
      if(x + px >= w - m.farX)
      {
         x = w - m.farX + px - 1;
         px = -px;
      }
      else if(x + px < m.nearX)
      {
         x = m.nearX + px;
         px = -px;
      }
      
      if(y + py >= h - m.farY)
      {
         y = h - m.farY + py - 1;
         py = -py;
      }
      else if(y + py < m.nearY)
      {
         y = m.nearY + py;
         py = -py;
      }
      
      // 计算标签的新位置
      x += px;
      y += py;
      ...

剩下的就是更新对象本身的状态:在文本标签中显示当前坐标,并将它们赋值给 OBJPROP_XDISTANCE 和 OBJPROP_YDISTANCE 属性。

c
      ObjectSetString(0, name, OBJPROP_TEXT, " " + EnumToString(anchor)
         + StringFormat("[%3d,%3d] ", x, y));
      ObjectSetInteger(0, name, OBJPROP_XDISTANCE, x);
      ObjectSetInteger(0, name, OBJPROP_YDISTANCE, y);
      ...

在更改对象之后,我们调用 ChartRedraw 并等待 100 毫秒,以确保动画效果相当平滑。

c
      ChartRedraw();
      Sleep(100);
      ...

在循环结束时,我们再次检查窗口大小,因为用户可能在脚本运行时更改窗口大小,并且我们也重复获取尺寸的请求。

c
      h = (int)ChartGetInteger(0, CHART_HEIGHT_IN_PIXELS, t) - 1;
      w = (int)ChartGetInteger(0, CHART_WIDTH_IN_PIXELS) - 1;
      
      dx = (int)ObjectGetInteger(0, name, OBJPROP_XSIZE);
      dy = (int)ObjectGetInteger(0, name, OBJPROP_YSIZE);
      m = GetMargins(Corner, anchor, dx, dy);
   }

为了保持解释简洁,我们省略了 ObjectSizeLabel.mq5 脚本的一些其他创新内容。有兴趣的人可以参考代码。特别是,文本使用了独特的颜色:每种特定颜色对应其自己的锚点,这使得切换点更加明显。此外,你可以在脚本运行时点击“删除”:这将从图表中移除所选对象,并且脚本将自动结束。

时间框架下对象的可见性 ​

MetaTrader 5 的用户都知道对象属性对话框中的“显示”选项卡,在那里可以通过开关来选择对象在哪些时间框架上显示,哪些时间框架上隐藏。特别地,你可以暂时在所有时间框架上完全隐藏对象。

MQL5 有一个类似的程序属性 OBJPROP_TIMEFRAMES,用于控制对象在某个时间框架上的可见性。该属性的值可以是特殊整数标志的任意组合:每个标志(常量)包含一个对应于一个时间框架的位(见下表)。要设置/获取 OBJPROP_TIMEFRAMES 属性,可使用 ObjectSetInteger/ObjectGetInteger 函数。

常量值时间框架下的可见性
OBJ_NO_PERIODS0对象在所有时间框架上隐藏
OBJ_PERIOD_M10x00000001M1
OBJ_PERIOD_M20x00000002M2
OBJ_PERIOD_M30x00000004M3
OBJ_PERIOD_M40x00000008M4
OBJ_PERIOD_M50x00000010M5
OBJ_PERIOD_M60x00000020M6
OBJ_PERIOD_M100x00000040M10
OBJ_PERIOD_M120x00000080M12
OBJ_PERIOD_M150x00000100M15
OBJ_PERIOD_M200x00000200M20
OBJ_PERIOD_M300x00000400M30
OBJ_PERIOD_H10x00000800H1
OBJ_PERIOD_H20x00001000H2
OBJ_PERIOD_H30x00002000H3
OBJ_PERIOD_H40x00004000H4
OBJ_PERIOD_H60x00008000H6
OBJ_PERIOD_H80x00010000H8
OBJ_PERIOD_H120x00020000H12
OBJ_PERIOD_D10x00040000D1
OBJ_PERIOD_W10x00080000W1
OBJ_PERIOD_MN10x00100000MN1
OBJ_ALL_PERIODS0x001fffff所有时间框架

这些标志可以使用按位或运算符(|)进行组合,例如,OBJ_PERIOD_M15 | OBJ_PERIOD_H4 标志的叠加意味着对象将在 15 分钟和 4 小时的时间框架上可见。

请注意,每个标志都可以通过将 1 左移与表中常量编号相等的位数来获得。当算法在多个时间框架而非特定一个时间框架上运行时,这使得动态生成标志变得更容易。

我们将在测试脚本 ObjectTimeframes.mq5 中使用此功能。其任务是在图表上创建大量带有时间框架名称的大文本标签,并且每个标题应仅在相应的时间框架上显示。例如,大标签“D1”将仅在日线图上可见,而切换到 4 小时图时,我们将看到“H4”。

为了获取时间框架的短名称(没有“PERIOD_”前缀),实现了一个简单的辅助函数。

c
string GetPeriodName(const int tf)
{
    const static int PERIOD_ = StringLen("PERIOD_");
    return StringSubstr(EnumToString((ENUM_TIMEFRAMES)tf), PERIOD_);
}

为了从 ENUM_TIMEFRAMES 枚举中获取所有时间框架的列表,我们将使用在“枚举转换”部分介绍的 EnumToArray 函数。

c
#include "ObjectPrefix.mqh"
#include <MQL5Book/EnumToArray.mqh>
 
void OnStart()
{
    ENUM_TIMEFRAMES tf = 0;
    int values[];
    const int n = EnumToArray(tf, values, 0, USHORT_MAX);
    ...

所有标签将在脚本启动时显示在窗口中心。脚本结束后调整窗口大小将导致创建的标题不再居中。这是因为 MQL5 仅支持锚定到窗口的角,而不支持锚定到中心。如果你想自动保持对象的位置,应该在指标中实现类似的算法并响应窗口大小调整事件。或者,我们可以将标签显示在一个角落,例如下右下角。

c
    const int centerX = (int)ChartGetInteger(0, CHART_WIDTH_IN_PIXELS) / 2;
    const int centerY = (int)ChartGetInteger(0, CHART_HEIGHT_IN_PIXELS) / 2;
    ...

在遍历时间框架的循环中,我们为每个时间框架创建一个 OBJ_LABEL 对象,并将其放置在窗口中间,锚定在对象的中心。

c
    for(int i = 1; i < n; ++i)
    {
        // 为每个时间框架创建并设置文本标签
        const string name = ObjNamePrefix + (string)i;
        ObjectCreate(0, name, OBJ_LABEL, 0, 0, 0);
        ObjectSetInteger(0, name, OBJPROP_XDISTANCE, centerX);
        ObjectSetInteger(0, name, OBJPROP_YDISTANCE, centerY);
        ObjectSetInteger(0, name, OBJPROP_ANCHOR, ANCHOR_CENTER);
        ...

接下来,我们设置文本(时间框架名称)、大字体大小、灰色和在背景中显示的属性。

c
        ObjectSetString(0, name, OBJPROP_TEXT, GetPeriodName(values[i]));
        ObjectSetInteger(0, name, OBJPROP_FONTSIZE, fmin(centerY, centerX));
        ObjectSetInteger(0, name, OBJPROP_COLOR, clrLightGray);
        ObjectSetInteger(0, name, OBJPROP_BACK, true);
        ...

最后,我们为第 i 个时间框架生成正确的可见性标志,并将其写入 OBJPROP_TIMEFRAMES 属性。

c
        const int flag = 1 << (i - 1);
        ObjectSetInteger(0, name, OBJPROP_TIMEFRAMES, flag);
    }

看看在切换时间框架时图表上会发生什么。

带有时间框架名称的标签

带有时间框架名称的标签

如果你打开“对象列表”对话框并启用列表中的“所有对象”,很容易确认所有时间框架都生成了标签,并检查它们的可见性标志。

要移除对象,可以运行 ObjectCleanup1.mq5 脚本。

为标签分配字符代码 ​

正如在 “与时间和价格相关的对象” 综述中提到的,OBJ_ARROW 标签允许在图表上显示任意的 “Wingdings” 字体符号(MQL5 文档中提供了可用符号的完整列表)。对象本身的字符代码由整数属性 OBJPROP_ARROWCODE 确定。

ObjectWingdings.mq5 脚本可以展示 “Wingdings” 字体的所有字符。在这个脚本中,我们在循环中创建带有不同字符的标签,并将它们逐个放置在柱线上。

c
#include "ObjectPrefix.mqh"
   
void OnStart()
{
   for(int i = 33; i < 256; ++i) // 字符代码
   {
      const int b = i - 33; // 柱线编号
      const string name = ObjNamePrefix + "Wingdings-"
         + (string)iTime(_Symbol, _Period, b);
      ObjectCreate(0, name, OBJ_ARROW,
         0, iTime(_Symbol, _Period, b), iOpen(_Symbol, _Period, b));
      ObjectSetInteger(0, name, OBJPROP_ARROWCODE, i);
   }
   
   PrintFormat("%d 个带箭头的对象已创建", 256 - 33);
}

它在图表上的显示情况如以下截图所示。

带有直线的对象的射线属性 ​

在图形对象中,有几种类型的对象,其锚点之间的线条既可以显示为线段(即严格在一对点之间),也可以显示为在整个窗口可见区域内朝一个或另一个方向无限延伸的直线。这些对象包括:

  • 趋势线
  • 角度趋势线
  • 所有类型的通道(等距通道、标准差通道、回归通道、安德鲁音叉通道)
  • 江恩线
  • 斐波那契线
  • 斐波那契通道
  • 斐波那契扩展线

对于这些对象,你可以分别使用 OBJPROP_RAY_LEFT 和 OBJPROP_RAY_RIGHT 属性来启用线条向左或向右的延伸。此外,对于垂直线,你可以指定它是应该绘制在所有图表子窗口中,还是仅绘制在当前窗口(即锚点所在的窗口)中:这由 OBJPROP_RAY 属性负责。所有这些属性都是布尔类型,意味着它们可以启用(true)或禁用(false)。

标识符描述
OBJPROP_RAY_LEFT射线向左延伸
OBJPROP_RAY_RIGHT射线向右延伸
OBJPROP_RAY垂直线延伸到所有图表窗口

你可以使用 ObjectRays.mq5 脚本来检查射线的操作。该脚本会创建 3 个具有不同射线设置的标准差通道。

辅助函数 SetupChannel 用于创建并配置一个特定的对象。通过其参数,可以设置通道的柱线长度、通道宽度(偏差),以及左右射线的显示选项和颜色。

c
#include "ObjectPrefix.mqh"
   
void SetupChannel(const int length, const double deviation = 1.0,
   const bool right = false, const bool left = false,
   const color clr = clrRed)
{
   const string name = ObjNamePrefix + "Channel"
      + (right ? "R" : "") + (left ? "L" : "");
   // 注意:锚点 0 的时间必须早于锚点 1 的时间,
   // 否则通道会退化
   ObjectCreate(0, name, OBJ_STDDEVCHANNEL, 0, iTime(NULL, 0, length), 0);
   ObjectSetInteger(0, name, OBJPROP_TIME, 1, iTime(NULL, 0, 0));
   // 偏差
   ObjectSetDouble(0, name, OBJPROP_DEVIATION, deviation);
   // 颜色和描述
   ObjectSetInteger(0, name, OBJPROP_COLOR, clr);
   ObjectSetString(0, name, OBJPROP_TEXT, StringFormat("%2.1", deviation)
      + ((!right && !left) ? " NO RAYS" : "")
      + (right ? " RIGHT RAY" : "") + (left ? " LEFT RAY" : ""));
   // 射线属性
   ObjectSetInteger(0, name, OBJPROP_RAY_RIGHT, right);
   ObjectSetInteger(0, name, OBJPROP_RAY_LEFT, left);
   // 通过高亮显示对象
   // (此外,用户更容易移除它们)
   ObjectSetInteger(0, name, OBJPROP_SELECTABLE, true);
   ObjectSetInteger(0, name, OBJPROP_SELECTED, true);
}

在 OnStart 函数中,我们针对 3 个不同的通道调用 SetupChannel 函数。

c
void OnStart()
{
   SetupChannel(24, 1.0, true);
   SetupChannel(48, 2.0, false, true, clrBlue);
   SetupChannel(36, 3.0, false, false, clrGreen);
}

结果,我们会得到一个类似下面形式的图表。

具有不同 OBJPROP_RAY_LEFT 和 OBJPROP_RAY_RIGHT 属性设置的通道 ​

具有不同 OBJPROP_RAY_LEFT 和 OBJPROP_RAY_RIGHT 属性设置的通道

当启用射线后,就可以使用我们将在“获取直线上给定点的时间或价格”部分中描述的函数,请求对象外推时间和价格值。

对象按下状态管理 ​

对于像按钮(OBJ_BUTTON)和带图像的面板(OBJ_BITMAP_LABEL)这类对象,终端支持一个特殊属性,该属性可以在视觉上让对象在正常(释放)状态和按下状态之间进行切换,反之亦然。OBJPROP_STATE 常量就是为此预留的。该属性为布尔类型:当值为 true 时,对象被视为处于按下状态;当值为 false(默认值)时,对象被视为处于释放状态。

对于 OBJ_BUTTON,终端自身会绘制出三维边框效果;而对于 OBJPROP_BITMAP_LABEL,程序员必须指定两张图片(以文件或资源形式),以此呈现出合适的外观。由于从技术层面来讲,这个属性只是一个开关,所以它很容易用于其他用途,而不局限于实现“按下”和“释放”效果。例如,借助合适的图片,你可以实现一个标志(选项)。

对象中图片的使用将在下一节进行讨论。

在交互式 MQL 程序里,对象状态通常会发生改变,这些程序会对用户操作(特别是鼠标点击)做出响应。我们将在关于事件的章节中探讨这种可能性。

现在,让我们以静态模式在简单按钮上测试这个属性。ObjectButtons.mq5 脚本会在图表上创建两个按钮:一个处于按下状态,另一个处于释放状态。

设置单个按钮的操作由 SetupButton 函数完成,该函数的参数包括按钮的名称、文本,以及其坐标、大小和状态。

c
#include "ObjectPrefix.mqh"
   
void SetupButton(const string button,
   const int x, const int y,
   const int dx, const int dy,
   const bool state = false)
{
   const string name = ObjNamePrefix + button;
   ObjectCreate(0, name, OBJ_BUTTON, 0, 0, 0);
   // 位置和大小
   ObjectSetInteger(0, name, OBJPROP_XDISTANCE, x);
   ObjectSetInteger(0, name, OBJPROP_YDISTANCE, y);
   ObjectSetInteger(0, name, OBJPROP_XSIZE, dx);
   ObjectSetInteger(0, name, OBJPROP_YSIZE, dy);
   // 按钮上的标签
   ObjectSetString(0, name, OBJPROP_TEXT, button);
   
   // 按下 (true) / 释放 (false)
   ObjectSetInteger(0, name, OBJPROP_STATE, state);
}

接着,在 OnStart 函数中我们会调用这个函数两次。

c
void OnStart()
{
   SetupButton("Pressed", 100, 100, 100, 20, true);
   SetupButton("Normal", 100, 150, 100, 20);
}

生成的按钮可能会呈现出如下样子。

按下和释放状态的 OBJ_BUTTON 按钮

按下和释放状态的 OBJ_BUTTON 按钮

有趣的是,你可以用鼠标点击任意一个按钮,按钮的状态就会发生改变。不过,我们尚未讨论如何截获关于此操作的通知。

需要着重注意的是,只有在对象属性中勾选了“禁用选择”选项时,这种自动状态切换才会执行,但对于所有通过编程方式创建的对象来说,这一条件是默认的。请记住,如果有必要,可以启用这种选择:你必须明确地将 OBJPROP_SELECTABLE 属性设置为 true。在之前的一些示例中我们已经使用过这个属性。

若要移除不再需要的按钮,可使用 ObjectCleanup1.mq5 脚本。

调整位图对象中的图像 ​

OBJ_BITMAP_LABEL 类型的对象(一个位于屏幕坐标中的带图片的面板)允许显示位图图像。位图图像指的是 BMP 图形格式:虽然原则上有许多其他光栅格式(例如 PNG 或 GIF),但目前在 MQL5 中它们和矢量格式一样不被支持。

字符串属性 OBJPROP_BMPFILE 允许你为对象指定一个图像。它必须包含 BMP 文件或资源的名称。

由于该对象支持双状态切换的可能性(见 OBJPROP_STATE),因此应为其使用一个修饰符参数:索引 0 下设置 “开启”/“按下” 状态的图片,索引 1 下设置 “关闭”/“释放” 状态的图片。如果你只指定一张图片(没有修饰符,这等同于索引 0),它将用于两种状态。对象的默认状态是 “关闭”/“释放”。

对象的大小会变得与图像的大小相等,但可以通过在 OBJPROP_XSIZE 和 OBJPROP_YSIZE 属性中指定较小的值来更改:在这种情况下,只会显示图像的一部分(详情见下一节关于裁剪的内容)。

OBJPROP_BMPFILE 字符串的长度不得超过 63 个字符。它不仅可以包含文件名,还可以包含文件的路径。如果字符串以路径分隔符(正斜杠 / 或双反斜杠 \\)开头,则会相对于 terminal_data_directory/MQL5/ 来搜索文件。否则,会相对于 MQL 程序所在的文件夹来搜索文件。

例如,字符串 \\Images\\euro.bmp(或 /Images/euro.bmp)指的是 MQL5/Images/euro.bmp 目录中的一个文件。标准的终端发行包在 MQL5 目录中包含 Images 文件夹,并且有几个测试文件 euro.bmp 和 dollar.bmp,因此这个路径是可行的。如果你指定字符串 Images\\euro.bmp 或 (Images/euro.bmp),那么对于从 MQL5/Scripts/MQL5Book/ 启动的脚本来说,这意味着包含 euro.bmp 文件的 Images 文件夹应该直接位于那里,也就是说,完整路径将是 MQL5/Scripts/MQL5Book/Images/euro.bmp。在我们的项目中没有这样的文件,这将导致加载图像时出错。然而,将图形文件放在程序旁边这种安排有其优点:更容易控制程序集,并且不会混淆不同程序的混合图片。

ObjectBitmap.mq5 脚本在图表上创建一个带图像的面板,并为其分配两张图像:\\Images\\dollar.bmp 和 \\Images\\euro.bmp。

c
#include "ObjectPrefix.mqh"
   
void SetupBitmap(const string button, const int x, const int y,
   const string imageOn, const string imageOff = NULL)
{
   // 创建一个面板
   const string name = ObjNamePrefix + "Bitmap";
   ObjectCreate(0, name, OBJ_BITMAP_LABEL, 0, 0, 0);
   // 设置位置
   ObjectSetInteger(0, name, OBJPROP_XDISTANCE, x);
   ObjectSetInteger(0, name, OBJPROP_YDISTANCE, y);
   // 包含图像
   ObjectSetString(0, name, OBJPROP_BMPFILE, 0, imageOn);
   if(imageOff != NULL) ObjectSetString(0, name, OBJPROP_BMPFILE, 1, imageOff);
}
   
void OnStart()
{
   SetupBitmap("image", 100, 100,
      "\\Images\\dollar.bmp", "\\Images\\euro.bmp");
}

和上一节脚本的结果一样,在这里你也可以点击图片对象,看到它在美元图像和欧元图像之间切换。

图像的裁剪(输出部分图像) ​

对于带有图片的图形对象(OBJ_BITMAP_LABEL 和 OBJ_BITMAP),MQL5 允许启用显示由 OBJPROP_BMPFILE 属性指定的图像的一部分。为此,你需要将对象的尺寸(OBJPROP_XSIZE 和 OBJPROP_YSIZE)设置为小于图像的尺寸,并使用整数属性 OBJPROP_XOFFSET 和 OBJPROP_YOFFSET 设置可见矩形片段的左上角坐标。这两个属性分别设置了从原始图像的左边界和上边界开始,沿 X 轴和 Y 轴的缩进(以像素为单位)。

将部分图像输出到对象上 ​

将部分图像输出到对象上

通常,使用大图像的一部分的类似技术用于工具栏图标(按钮组、菜单等):与许多包含单个图标的小文件相比,包含所有图标的单个文件能更高效地利用资源。

测试脚本 ObjectBitmapOffset.mq5 创建了几个带有图片的面板(OBJ_BITMAP_LABEL),并且对于所有这些面板,在 OBJPROP_BMPFILE 属性中都指定了相同的图形文件。然而,由于 OBJPROP_XOFFSET 和 OBJPROP_YOFFSET 属性的作用,所有对象显示的是图像的不同部分。

c
void SetupBitmap(const int i, const int x, const int y, const int size,
   const string imageOn, const string imageOff = NULL)
{
   // 创建一个对象
   const string name = ObjNamePrefix + "Tool-" + (string)i;
   ObjectCreate(0, name, OBJ_BITMAP_LABEL, 0, 0, 0);
   ObjectSetInteger(0, name, OBJPROP_CORNER, CORNER_RIGHT_UPPER);
   ObjectSetInteger(0, name, OBJPROP_ANCHOR, ANCHOR_RIGHT_UPPER);
   // 位置和尺寸
   ObjectSetInteger(0, name, OBJPROP_XDISTANCE, x);
   ObjectSetInteger(0, name, OBJPROP_YDISTANCE, y);
   ObjectSetInteger(0, name, OBJPROP_XSIZE, size);
   ObjectSetInteger(0, name, OBJPROP_YSIZE, size);
   // 原始图像中的偏移量,根据该偏移量读取第 i 个片段
   ObjectSetInteger(0, name, OBJPROP_XOFFSET, i * size);
   ObjectSetInteger(0, name, OBJPROP_YOFFSET, 0);
   // 通用图像(文件)
   ObjectSetString(0, name, OBJPROP_BMPFILE, imageOn);
}
   
void OnStart()
{
   const int icon = 46; // 一个图标的尺寸
   for(int i = 0; i < 7; ++i) // 遍历文件中的图标
   {
      SetupBitmap(i, 10, 10 + i * icon, icon,
         "\\Files\\MQL5Book\\icons-322-46.bmp");
   }
}

原始图像包含几个 46×46 像素的小图标。该脚本将它们逐个“裁剪”出来,并垂直放置在窗口的右边缘。

下面展示了通用文件(/Files/MQL5Book/icons-322-46.bmp),以及在图表上的显示效果。

输入字段属性:文本对齐方式和只读属性 ​

对于 OBJ_EDIT 类型(输入字段)的对象,MQL 程序可以设置两个特定属性,这些属性使用 ObjectSetInteger/ObjectGetInteger 函数来定义。

标识符描述值类型
OBJPROP_ALIGN文本水平对齐方式ENUM_ALIGN_MODE
OBJPROP_READONLY文本编辑能力(是否可编辑)bool

ENUM_ALIGN_MODE 枚举包含以下成员:

标识符描述
ALIGN_LEFT左对齐
ALIGN_CENTER居中对齐
ALIGN_RIGHT右对齐

请注意,与 OBJ_TEXT 和 OBJ_LABEL 对象不同,输入字段不会自动调整自身大小以适应输入的文本,所以对于较长的字符串,你可能需要显式设置 OBJPROP_XSIZE 属性。

在编辑模式下,输入字段内支持文本水平滚动。

ObjectEdit.mq5 脚本会创建四个 OBJ_EDIT 对象:其中三个是可编辑的,且具有不同的文本对齐方式,第四个处于只读模式。

c
#include "ObjectPrefix.mqh"
   
void SetupEdit(const int x, const int y, const int dx, const int dy,
   const ENUM_ALIGN_MODE alignment = ALIGN_LEFT, const bool readonly = false)
{
   // 创建一个带有属性描述的对象
   const string props = EnumToString(alignment)
      + (readonly? " read-only" : " editable");
   const string name = ObjNamePrefix + "Edit" + props;
   ObjectCreate(0, name, OBJ_EDIT, 0, 0, 0);
   // 位置和大小
   ObjectSetInteger(0, name, OBJPROP_XDISTANCE, x);
   ObjectSetInteger(0, name, OBJPROP_YDISTANCE, y);
   ObjectSetInteger(0, name, OBJPROP_XSIZE, dx);
   ObjectSetInteger(0, name, OBJPROP_YSIZE, dy);
   // 输入字段的特定属性
   ObjectSetInteger(0, name, OBJPROP_ALIGN, alignment);
   ObjectSetInteger(0, name, OBJPROP_READONLY, readonly);
   // 颜色(根据可编辑性不同而不同)
   ObjectSetInteger(0, name, OBJPROP_BGCOLOR, clrWhite);
   ObjectSetInteger(0, name, OBJPROP_COLOR, readonly? clrRed : clrBlue);
   // 内容
   ObjectSetString(0, name, OBJPROP_TEXT, props);
   // 可编辑字段的工具提示
   ObjectSetString(0, name, OBJPROP_TOOLTIP,
      (readonly? "\n" : "Click me to edit"));
}
   
void OnStart()
{
   SetupEdit(100, 100, 200, 20);
   SetupEdit(100, 120, 200, 20, ALIGN_RIGHT);
   SetupEdit(100, 140, 200, 20, ALIGN_CENTER);
   SetupEdit(100, 160, 200, 20, ALIGN_CENTER, true);
}

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

不同模式下的输入字段

不同模式下的输入字段

你可以点击任何可编辑的字段并更改其内容。

标准差通道宽度 ​

标准差通道 OBJ_STDDEVCHANNEL 有一个特殊属性,该属性将通道宽度定义为标准差(均方根偏差)的乘数。此属性名为 OBJPROP_DEVIATION,可以取正实数值(double 类型)。默认情况下,其值为 1.0。

在关于直线对象射线属性的章节中的 ObjectRays.mq5 脚本里,我们已经看到过使用该属性的示例。

在水平对象中设置水平 ​

一些图形对象是使用多个水平(重复的线条)构建的。这些对象包括:

  • 安德鲁音叉 OBJ_PITCHFORK
  • 斐波那契工具:
    • OBJ_FIBO 水平
    • 时间区间 OBJ_FIBOTIMES
    • 扇形 OBJ_FIBOFAN
    • 弧线 OBJ_FIBOARC
    • 通道 OBJ_FIBOCHANNEL
    • 扩展 OBJ_EXPANSION

MQL5 允许你为这些对象设置水平属性。这些属性包括水平的数量、颜色、值和标签。

标识符描述类型
OBJPROP_LEVELS水平的数量int
OBJPROP_LEVELCOLOR水平线条的颜色color
OBJPROP_LEVELSTYLE水平线条的样式ENUM_LINE_STYLE
OBJPROP_LEVELWIDTH水平线条的宽度int
OBJPROP_LEVELTEXT水平的描述string
OBJPROP_LEVELVALUE水平的值double

在调用 ObjectGet 和 ObjectSet 函数来处理除 OBJPROP_LEVELS 之外的所有属性时,需要提供一个额外的修饰符参数,用于指定特定水平的编号。

例如,让我们考虑指标 ObjectHighLowFibo.mq5。对于给定的柱线范围(由最后一根柱线的编号 baroffset 以及它左侧的柱线数量 BarCount 定义),该指标会找出最高价和最低价,然后为这些点创建 OBJ_FIBO 对象。随着新柱线的形成,斐波那契水平会向右移动,以对应更新的价格。

c
#property indicator_chart_window
#property indicator_buffers 0
#property indicator_plots   0
   
#include <MQL5Book/ColorMix.mqh>
   
input int BarOffset = 0;
input int BarCount = 24;
   
const string Prefix = "HighLowFibo-";
   
int OnCalculate(const int rates_total,
                const int prev_calculated,
                const int begin,
                const double &price[])
{
   static datetime now = 0;
   if(now != iTime(NULL, 0, 0))
   {
      const int hh = iHighest(NULL, 0, MODE_HIGH, BarCount, BarOffset);
      const int ll = iLowest(NULL, 0, MODE_LOW, BarCount, BarOffset);
   
      datetime t[2] = {iTime(NULL, 0, hh), iTime(NULL, 0, ll)};
      double p[2] = {iHigh(NULL, 0, hh), iLow(NULL, 0, ll)};
    
      DrawFibo(Prefix + "Fibo", t, p, clrGray);
   
      now = iTime(NULL, 0, 0);
   }
   return rates_total;
}

对象的直接设置是在辅助函数 DrawFibo 中完成的。在该函数中,特别是将水平绘制成彩虹色,并根据相应的值是否为“整数”(无小数部分)来确定它们的样式和粗细。

c
bool DrawFibo(const string name, const datetime &t[], const double &p[],
   const color clr)
{
   if(ArraySize(t) != ArraySize(p)) return false;
   
   ObjectCreate(0, name, OBJ_FIBO, 0, 0, 0);
   // 锚点
   for(int i = 0; i < ArraySize(t); ++i)
   {
      ObjectSetInteger(0, name, OBJPROP_TIME, i, t[i]);
      ObjectSetDouble(0, name, OBJPROP_PRICE, i, p[i]);
   }
   // 常规设置
   ObjectSetInteger(0, name, OBJPROP_COLOR, clr);
   ObjectSetInteger(0, name, OBJPROP_RAY_RIGHT, true);
   // 水平设置
   const int n = (int)ObjectGetInteger(0, name, OBJPROP_LEVELS);
   for(int i = 0; i < n; ++i)
   {
      const color gradient = ColorMix::RotateColors(ColorMix::HSVtoRGB(0),
         ColorMix::HSVtoRGB(359), n, i);
      ObjectSetInteger(0, name, OBJPROP_LEVELCOLOR, i, gradient);
      const double level = ObjectGetDouble(0, name, OBJPROP_LEVELVALUE, i);
      if(level - (int)level > DBL_EPSILON * level)
      {
         ObjectSetInteger(0, name, OBJPROP_LEVELSTYLE, i, STYLE_DOT);
         ObjectSetInteger(0, name, OBJPROP_LEVELWIDTH, i, 1);
      }
      else
      {
         ObjectSetInteger(0, name, OBJPROP_LEVELSTYLE, i, STYLE_SOLID);
         ObjectSetInteger(0, name, OBJPROP_LEVELWIDTH, i, 2);
      }
   }
   
   return true;
}

以下是该对象在图表上可能呈现的样子。

江恩(Gann)、斐波那契(Fibonacci)和艾略特(Elliot)对象的其他属性 ​

江恩、斐波那契和艾略特对象具有特定的、独特的属性。根据属性类型的不同,可使用 ObjectGetInteger/ObjectSetInteger 或 ObjectGetDouble/ObjectSetDouble 函数来操作这些属性。

标识符描述类型
OBJPROP_DIRECTION江恩对象的趋势(江恩扇形 OBJ_GANNFAN 和江恩网格 OBJ_GANNGRID)ENUM_GANN_DIRECTION
OBJPROP_DEGREE艾略特波浪的级别ENUM_ELLIOT_WAVE_DEGREE
OBJPROP_DRAWLINES显示艾略特波浪级别的线条bool
OBJPROP_SCALE每根柱线的点数比例(江恩对象和斐波那契弧线 Fibonacci Arcs 对象的属性)double
OBJPROP_ELLIPSE斐波那契弧线对象(OBJ_FIBOARC)的完整椭圆显示bool

ENUM_GANN_DIRECTION 枚举具有以下成员:

常量趋势方向
GANN_UP_TREND上升趋势线
GANN_DOWN_TREND下降趋势线

ENUM_ELLIOT_WAVE_DEGREE 用于设置艾略特波浪的规模(标记方法)。

常量描述
ELLIOTT_GRAND_SUPERCYCLE超大循环浪
ELLIOTT_SUPERCYCLE大循环浪
ELLIOTT_CYCLE循环浪
ELLIOTT_PRIMARY基本循环浪
ELLIOTT_INTERMEDIATE中级浪
ELLIOTT_MINOR小浪
ELLIOTT_MINUTE细浪
ELLIOTT_MINUETTE微浪
ELLIOTT_SUBMINUETTE次微浪

图表对象 ​

图表对象 OBJ_CHART 允许在当前图表内为其他交易品种和时间框架创建其他图表的缩略图。图表对象包含在总图表列表中,我们可以通过 ChartFirst 和 ChartNext 函数以编程方式获取该列表。正如在 “检查主窗口状态” 部分中提到的,特殊的图表属性 CHART_IS_OBJECT 可用于通过标识符来确定它是一个完整的窗口还是一个图表对象。对于后者情况,调用 ChartGetInteger(id, CHART_IS_OBJECT) 将返回 true。

图表对象有一组仅属于它的属性。

标识符描述类型
OBJPROP_CHART_ID图表 ID(只读)long
OBJPROP_PERIOD图表时间框架ENUM_TIMEFRAMES
OBJPROP_DATE_SCALE显示时间刻度bool
OBJPROP_PRICE_SCALE显示价格刻度bool
OBJPROP_CHART_SCALE缩放比例(取值范围 0 - 5)int
OBJPROP_SYMBOL交易品种string

通过 OBJPROP_CHART_ID 属性获取的标识符允许使用 “使用图表” 章节中的函数像管理常规图表一样管理该对象。然而,存在一些限制:

  • 不能使用 ChartClose 关闭该对象。
  • 无法使用 CartSetSymbolPeriod 函数在对象中更改交易品种/时间框架。
  • 对象中的 CHART_SCALE、CHART_BRING_TO_TOP、CHART_SHOW_DATE_SCALE 和 CHART_SHOW_PRICE_SCALE 属性不能被修改。

默认情况下,所有属性(除了 OBJPROP_CHART_ID)都与当前窗口的相应属性相等。

图表对象的演示通过无缓冲指标 ObjectChart.mq5 实现。它创建一个子窗口,其中包含两个图表对象,这两个图表对象与当前图表的交易品种相同,但时间框架分别为当前时间框架之上和之下相邻的时间框架。

对象会吸附到子窗口的右上角,并且具有相同的预定义大小:

c
#define SUBCHART_HEIGHT 150
#define SUBCHART_WIDTH  200

当然,在我们能够自适应地响应调整大小事件之前,子窗口的高度必须与对象的高度匹配。

c
#property indicator_separate_window
#property indicator_height SUBCHART_HEIGHT
#property indicator_buffers 0
#property indicator_plots   0

一个迷你图表在 SetupSubChart 函数中进行配置,该函数将对象编号、其尺寸以及所需的时间框架作为输入。SetupSubChart 的结果是图表对象的标识符,我们将其输出到日志中以供参考。

c
void OnInit()
{
   Print(SetupSubChart(0, SUBCHART_WIDTH, SUBCHART_HEIGHT, PeriodUp(_Period)));
   Print(SetupSubChart(1, SUBCHART_WIDTH, SUBCHART_HEIGHT, PeriodDown(_Period)));
}

宏 PeriodUp 和 PeriodDown 使用辅助函数 PeriodRelative。

c
#define PeriodUp(P) PeriodRelative(P, +1)
#define PeriodDown(P) PeriodRelative(P, -1)
   
ENUM_TIMEFRAMES PeriodRelative(const ENUM_TIMEFRAMES tf, const int step)
{
   static const ENUM_TIMEFRAMES stdtfs[] =
   {
      PERIOD_M1,  // =1 (1)
      PERIOD_M2,  // =2 (2)
     ...
      PERIOD_W1,  // =32769 (8001)
      PERIOD_MN1, // =49153 (C001)
   };
   const int x = ArrayBsearch(stdtfs, tf == PERIOD_CURRENT ? _Period : tf);
   const int needle = x + step;
   if(needle >= 0 && needle < ArraySize(stdtfs))
   {
      return stdtfs[needle];
   }
   return tf;
}

以下是主要的工作函数 SetupSubChart。

c
long SetupSubChart(const int n, const int dx, const int dy,
   ENUM_TIMEFRAMES tf = PERIOD_CURRENT, const string symbol = NULL)
{
   // 创建一个对象
   const string name = Prefix + "Chart-"
      + (symbol == NULL? _Symbol : symbol) + PeriodToString(tf);
   ObjectCreate(0, name, OBJ_CHART, ChartWindowFind(), 0, 0);
   
   // 锚定到子窗口的右上角
   ObjectSetInteger(0, name, OBJPROP_CORNER, CORNER_RIGHT_UPPER);
   // 位置和大小
   ObjectSetInteger(0, name, OBJPROP_XSIZE, dx);
   ObjectSetInteger(0, name, OBJPROP_YSIZE, dy);
   ObjectSetInteger(0, name, OBJPROP_XDISTANCE, (n + 1) * dx);
   ObjectSetInteger(0, name, OBJPROP_YDISTANCE, 0);
   
   // 特定的图表设置
   if(symbol != NULL)
   {
      ObjectSetString(0, name, OBJPROP_SYMBOL, symbol);
   }
   
   if(tf != PERIOD_CURRENT)
   {
      ObjectSetInteger(0, name, OBJPROP_PERIOD, tf);
   }
   // 禁用线条显示
   ObjectSetInteger(0, name, OBJPROP_DATE_SCALE, false);
   ObjectSetInteger(0, name, OBJPROP_PRICE_SCALE, false);
   // 仅为演示目的,通过其 ID 向对象添加移动平均线指标
   const long id = ObjectGetInteger(0, name, OBJPROP_CHART_ID);
   ChartIndicatorAdd(id, 0, iMA(NULL, tf, 10, 0, MODE_EMA, PRICE_CLOSE));
   return id;
}

对于图表对象,锚点始终固定在对象的左上角,所以当锚定到窗口的右上角时,需要加上对象的宽度(在 OBJPROP_XDISTANCE 的表达式 (n + 1) * dx 中通过 +1 来实现)。

以下截图显示了在 XAUUSD,H1 图表上该指标的结果。

指标子窗口中的两个图表对象

指标子窗口中的两个图表对象

如我们所见,迷你图表显示的是 M30 和 H2 时间框架。

需要注意的是,你可以向图表对象添加指标并应用模板 tpl,包括带有智能交易系统的模板。然而,不能在图表对象内部创建对象。

当图表对象由于在当前时间框架或所有时间框架上禁用了可视化而被隐藏时,内部图表的 CHART_WINDOW_IS_VISIBLE 属性仍然返回 true。

移动对象 ​

要在时间/价格坐标中移动对象,你不仅可以使用更改属性的 ObjectSet 函数,还可以使用特殊函数 ObjectMove,该函数用于更改对象指定锚点的坐标。

c
bool ObjectMove(long chartId, const string name, int index, datetime time, double price)

chartId 参数设置图表的 ID(0 表示当前图表)。对象的名称通过 name 参数传递。锚点的索引和坐标分别在 index、time 和 price 参数中指定。

该函数使用异步调用,也就是说,它会将命令发送到图表的事件队列中,并不等待对象实际完成移动。

该函数返回一个指示,表明命令是否成功入队(在这种情况下,如果成功则结果为 true)。对象的实际位置应该通过调用 ObjectGet 函数来获取。

在指标 ObjectHighLowFibo.mq5 中,我们修改 DrawFibo 函数,以便使用 ObjectMove 函数。在遍历锚点的循环中,我们现在用一个 ObjectMove 调用替代了两个 ObjectSet 函数的调用:

c
bool DrawFibo(const string name, const datetime &t[], const double &p[],
   const color clr)
{
   ...
   for(int i = 0; i < ArraySize(t); ++i)
   {
      // 之前是:
      // ObjectSetInteger(0, name, OBJPROP_TIME, i, t[i]);
      // ObjectSetDouble(0, name, OBJPROP_PRICE, i, p[i]);
      // 现在是:
      ObjectMove(0, name, i, t[i], p[i]);
   }
   ...
}

在锚点的两个坐标都发生变化的情况下,应用 ObjectMove 函数是有意义的。在某些情况下,只有一个坐标起作用(例如,在标准差通道和线性回归通道的锚点处,只有开始和结束日期/时间是重要的,并且通道会自动计算这些点的价格值)。在这种情况下,调用单个 ObjectSet 函数比调用 ObjectMove 函数更合适。

在指定线条点获取时间或价格 ​

许多图形对象包含一条或多条直线。MQL5 允许对这些直线上的点进行内插和外推,并从一个坐标得到另一个坐标,例如根据时间获取价格或根据价格获取时间。

内插始终是可行的:它在对象 “内部” 起作用,即在锚点之间。只有当为对象启用了相应方向的射线属性时(请参阅带有直线的对象的射线属性),才可以对对象外部进行外推。

ObjectGetValueByTime 函数返回指定时间的价格值。ObjectGetTimeByValue 函数返回指定价格的时间值。

c
double ObjectGetValueByTime(long chartId, const string name, datetime time, int line)
datetime ObjectGetTimeByValue(long chartId, const string name, double value, int line)

这些计算是针对具有 chartId 的图表上名为 name 的对象进行的。time 和 value 参数指定了一个已知坐标,据此计算未知坐标。由于一个对象可以有多条直线,一个坐标将对应多个值,因此必须在 line 参数中指定直线编号。

该函数返回具有指定初始坐标的点相对于直线的投影的价格或时间值。

如果发生错误,将返回 0,并且错误代码将写入 _LastError。例如,在射线属性被禁用的情况下尝试外推直线值会生成 OBJECT_GETVALUE_FAILED(4205)错误。

这些函数适用于以下对象:

  • 趋势线(OBJ_TREND)
  • 角度趋势线(OBJ_TRENDBYANGLE)
  • 江恩线(OBJ_GANNLINE)
  • 等距通道(OBJ_CHANNEL,2 条线)
  • 线性回归通道(OBJ_REGRESSION;3 条线)
  • 标准差通道(OBJ_STDDEVCHANNEL;3 条线)
  • 箭头线(OBJ_ARROWED_LINE)

让我们使用无缓冲区指标 ObjectChannels.mq5 来检查该函数的运行情况。它创建两个对象,分别是标准差通道和线性回归通道,然后请求并在注释中显示未来柱线上方和下方直线的价格。对于标准差通道,启用了 OBJPROP_RAY_RIGHT 属性,但对于回归通道,未启用该属性(故意为之)。因此,从第二个通道不会接收到值,并且屏幕上始终为其显示 0。

随着新柱线的形成,通道将自动向右移动。通道的长度在输入参数 WorkPeriod 中设置(默认 10 根柱线)。

c
input int WorkPeriod = 10;
   
const string Prefix = "ObjChnl-";
const string ObjStdDev = Prefix + "StdDev";
const string ObjRegr = Prefix + "Regr";
   
void OnInit()
{
   CreateObjects();
   UpdateObjects();
}

CreateObjects 函数创建 2 个通道并对它们进行初始设置。

c
void CreateObjects()
{
   ObjectCreate(0, ObjStdDev, OBJ_STDDEVCHANNEL, 0, 0, 0);
   ObjectCreate(0, ObjRegr, OBJ_REGRESSION, 0, 0, 0);
   ObjectSetInteger(0, ObjStdDev, OBJPROP_COLOR, clrBlue);
   ObjectSetInteger(0, ObjStdDev, OBJPROP_RAY_RIGHT, true);
   ObjectSetInteger(0, ObjRegr, OBJPROP_COLOR, clrRed);
   // 注意:故意未为回归通道启用射线属性
}

UpdateObjects 函数将通道移动到最后 WorkPeriod 根柱线处。

c
void UpdateObjects()
{
   const datetime t0 = iTime(NULL, 0, WorkPeriod);
   const datetime t1 = iTime(NULL, 0, 0);
   
   // 我们不使用 ObjectMove,因为通道仅与时间坐标配合使用(价格会自动计算)
   ObjectSetInteger(0, ObjStdDev, OBJPROP_TIME, 0, t0);
   ObjectSetInteger(0, ObjStdDev, OBJPROP_TIME, 1, t1);
   ObjectSetInteger(0, ObjRegr, OBJPROP_TIME, 0, t0);
   ObjectSetInteger(0, ObjRegr, OBJPROP_TIME, 1, t1);
}

在 OnCalculate 处理函数中,我们在新柱线上更新通道的位置,并且在每个报价变动时,调用 DisplayObjectData 来获取价格外推值并将其显示为注释。

c
int OnCalculate(const int rates_total,
                const int prev_calculated,
                const int begin,
                const double &price[])
{
   static datetime now = 0;
   if(now != iTime(NULL, 0, 0))
   {
      UpdateObjects();
      now = iTime(NULL, 0, 0);
   }
   
   DisplayObjectData();
   
   return rates_total;
}

在 DisplayObjectData 函数中,我们将在中间直线的锚点处找到价格(OBJPROP_PRICE)。此外,使用 ObjectGetValueByTime,我们将请求未来 WorkPeriod 根柱线处通道上方和下方直线的价格值。

c
void DisplayObjectData()
{
   const double p0 = ObjectGetDouble(0, ObjStdDev, OBJPROP_PRICE, 0);
   const double p1 = ObjectGetDouble(0, ObjStdDev, OBJPROP_PRICE, 1);
   
   // 由于通道计算算法,以下等式始终成立:
   // - 两个通道的中间直线是相同的,
   // - 锚点始终位于中间直线上,
   // ObjectGetValueByTime(0, ObjStdDev, iTime(NULL, 0, 0), 0) == p1
   // ObjectGetValueByTime(0, ObjRegr, iTime(NULL, 0, 0), 0) == p1
   
   // 尝试从上方和下方直线外推未来价格
   const double d1 = ObjectGetValueByTime(0, ObjStdDev, iTime(NULL, 0, 0)
      + WorkPeriod * PeriodSeconds(), 1);
   const double d2 = ObjectGetValueByTime(0, ObjStdDev, iTime(NULL, 0, 0)
      + WorkPeriod * PeriodSeconds(), 2);
   
   const double r1 = ObjectGetValueByTime(0, ObjRegr, iTime(NULL, 0, 0)
      + WorkPeriod * PeriodSeconds(), 1);
   const double r2 = ObjectGetValueByTime(0, ObjRegr, iTime(NULL, 0, 0)
      + WorkPeriod * PeriodSeconds(), 2);
   
   // 在注释中显示所有接收到的价格
   Comment(StringFormat("%.*f %.*f\ndev: up=%.*f dn=%.*f\nreg: up=%.*f dn=%.*f",
      _Digits, p0, _Digits, p1,
      _Digits, d1, _Digits, d2,
      _Digits, r1, _Digits, r2));
}

需要着重注意的是,由于未为回归通道启用射线属性,它在未来始终返回 0(不过,如果我们请求通道时间周期内的价格,我们将得到正确的值)。

通道及其直线点处的价格值

通道及其直线点处的价格值

在这里,对于长度为 10 根柱线的通道,外推也是针对未来 10 根柱线进行的,这就得到了 “dev:” 行中显示的未来值,大约对应于窗口的右边界。

Talk is cheap, show me the code!