Skip to content

创建自定义指标

指标是最受欢迎的MQL程序类型之一。它们是进行技术分析的一种简单而强大的工具。其主要使用机制是运用公式对初始价格数据进行处理,以创建衍生时间序列。这使得对市场过程的特定特征进行评估和可视化成为可能。任何时间序列,包括通过指标计算得到的时间序列,都可以作为输入传递给另一个指标,依此类推。许多知名指标(例如,MACD指标)的公式实际上由对几个相互关联的指标的调用组成。

终端用户无疑熟悉许多内置指标,并且他们也知道可以使用MQL5语言来扩展可用指标的列表。从用户的角度来看,内置指标和用MQL5实现的自定义指标的工作方式完全相同。

通常情况下,指标以线条、柱状图和其他图形结构的形式在价格图表窗口中显示其运算结果。每个这样的图表都是基于计算出的时间序列进行可视化的,这些时间序列存储在指标内部的特殊数组中,这些数组被称为指标缓冲区:它们与开盘价(Open)、最高价(High)、最低价(Low)和收盘价(Close)(OHLC)价格一起可在终端的数据窗口中查看。然而,除了缓冲区之外,指标还可以提供额外的功能,或者可能根本没有缓冲区。例如,指标经常用于解决需要创建图形对象、管理图表及其属性以及与用户进行交互的问题(请参阅OnChartEvent)。

在本章中,我们将学习在MQL5中创建指标的基本原理。这样的指标通常被称为“自定义”指标,因为用户可以从头开始编写它们,或者从现成的源代码编译它们。在下一章中,我们将探讨自定义指标和内置指标的编程管理问题,这将使我们能够构建更复杂的指标,并为基于指标的交易信号和智能交易系统的过滤器铺平道路。

稍后,我们将掌握以资源形式将指标引入可执行MQL程序的技术。

指标的主要特征

指标实现了一种特定的计算算法,该算法按K线应用于给定的初始时间序列或多个时间序列。所有这些时间序列都是终端自身的数组(请参阅函数ArrayIsSeries):终端为它们分配内存,并在新K线形成时添加新元素。自然地,在这些数组中,不同时间框架下包含交易品种报价的数组起着基础性作用,因为它们是由终端填充的。然而,已启动的指标可以显著扩展可供分析的时间序列集合。

指标通常将其运算结果保存在动态数组中,这些数组通过特殊函数(SetIndexBuffer)注册为指标缓冲区,并且也成为终端自身的数组。除了为它们分配内存之外,终端还提供对这些数组的公共访问,将其视为新的时间序列,其他指标可以基于这些时间序列进行计算。

指标计算部分的入口点是OnCalculate函数——一个同名的事件处理程序。在“事件处理函数概述”中,我们已经提到过这个函数:仅其在源代码中的存在就足以让终端将MQL程序视为一个指标。OnCalculate函数将在下一节中详细描述。特别是,OnCalculate函数的主要特点是存在两种不同的形式。程序员应该在指标设计的最开始就选择其中一种形式,因为这决定了指标的用途和可能的使用场景。

OnCalculate函数并不是指标的唯一显著特征。除了它之外,还有一组专门为指标设计的特殊预处理器指令#property——我们将在本章的几个相关部分逐步探讨它们。之前我们已经了解了一些通用的程序属性,当然,这些指令也同样适用于指标。

正如MetaTrader 5的用户所知道的,每个指标都有一种显示其图形结构(时间序列)的方式:要么在显示交易品种价格的主窗口中,要么在一个单独的子窗口中。当将特定指标(或一组指标)添加到图表中时,如果该指标设计为在子窗口中工作,就会在窗口的下部创建这样一个子窗口。例如,标准的移动平均线(MA)指标绘制在价格图表上,而威廉指标(WPR)则绘制在一个单独的子窗口中。

从开发者的角度来看,这意味着最初就应该确定指标是在主窗口中显示还是在子窗口中显示,因为这两种模式不能同时使用。此外,这一特征以及指标缓冲区的数量只能使用#property指令设置一次(请参阅“两种类型的指标”和“设置缓冲区数量和图形绘图”),然后就无法通过调用MQL5 API函数来更改它们,因为根本就没有提供这样的函数。与这些不可变的属性不同,大多数其他指标属性可以通过特殊函数进行动态调整。因此,在我们研究指标编程的技术方面时,我们将能够在#property属性和MQL5函数之间建立对应关系。

此外,指标通常会实现OnInitOnDeinit处理程序(请参阅“指标和智能交易系统的参考事件”)。OnInit对于分配将用作指标缓冲区的数组尤为重要,也就是说,用于积累中间和最终计算的结果,这些结果对用户可见并且可供其他程序(如智能交易系统)使用。

指标是交互式MQL程序的一种,如果有必要,它可以处理由用户或其他程序产生的定时器事件(OnTimer)和图表变化(OnChartEvent)。这些技术特性对于指标来说是可选的,并且基于图表事件队列。我们将在关于图表的章节中单独讨论它们。

指标的主要事件:OnCalculate

OnCalculate函数是MQL5指标代码的主要入口点。每当OnCalculate事件发生时,该函数就会被调用,而OnCalculate事件是在价格数据发生变化时生成的。例如,当一个交易品种的新报价点到达时,或者当旧价格发生变化(填补历史数据中的缺口或从服务器下载缺失数据)时,就会触发该事件。

该函数有两种变体,它们在计算的源数据方面有所不同:

  1. 完整形式:在参数中提供一组标准的价格时间序列(开盘价、最高价、最低价、收盘价(OHLC)价格、成交量、点差)。
  2. 简化形式:针对一个任意的时间序列(不一定是标准的)。

一个指标只能使用这两种形式中的一种,并且不可能在一个指标中同时结合使用它们。

在使用OnCalculate的简化形式时,当将指标放置在图表上时,其属性对话框中会出现一个额外的选项卡。该选项卡提供一个下拉列表“应用于”(Apply to),你应该在其中选择作为指标计算基础的初始时间序列。默认情况下,如果未选择任何时间序列,则计算基于收盘价(Close)价格值。

使用简化形式的OnCalculate为指标选择初始时间序列

下拉列表中总是会提供标准类型的价格,但如果图表上还有其他指标,此设置允许你选择其中一个作为另一个指标的数据源,从而构建一个由指标组成的处理链。我们将在“跳过初始K线的绘制”部分尝试从一个指标构建另一个指标。当使用完整形式时,此选项不可用。

禁止将指标应用于以下内置指标:分形指标(Fractals)、鳄鱼指标(Gator)、一目均衡表指标(Ichimoku)和抛物线转向指标(Parabolic SAR)。

OnCalculate的简化形式具有以下原型:

c
int OnCalculate(const int rates_total, const int prev_calculated, const int begin,
  const double &data[])

data数组包含用于计算的初始数据。这可以是价格时间序列之一,或者是另一个指标的计算缓冲区。rates_total参数指定data数组的大小。调用ArraySize(data)iBars(NULL, 0)应该得到与rates_total相同的值。

prev_calculated参数旨在有效地在少量新K线(通常是一根,即最后一根)上重新计算指标,而不是对所有K线进行完整计算。prev_calculated的值等于上一次函数调用时从OnCalculate函数返回给运行时的结果。例如,如果在接收到下一个报价点时,指标已经为所有K线计算了公式,它应该从OnCalculate返回rates_total A的值(这里的索引A表示初始时刻)。然后,在下一个报价点时,在接收到OnCalculate事件后,终端会将prev_calculated设置为先前的值rates_totalA。然而,在此期间K线的数量可能已经发生了变化,新的值rates_total将会增加;我们将其称为rates_totalB。因此,只会计算从prev_calculated(也就是rates_totalA)到rates_totalB的K线。

然而,最常见的情况是新的报价点适合当前的零号K线,也就是说,rates_total不会改变,因此在大多数OnCalculate调用中,我们有prev_calculated == rates_total。在这种情况下我们是否需要重新计算呢?这取决于计算的性质。例如,如果指标是基于不会改变的K线开盘价进行计算的,那么重新计算任何内容都没有意义。但是,如果指标使用收盘价(实际上是最后一个已知报价点的价格)或任何其他依赖于收盘价的汇总价格,那么最后一根K线应该始终重新计算。

OnCalculate函数第一次被调用时,prev_calculated的值等于0。

如果自上一次调用OnCalculate函数以来,价格数据发生了变化(例如,上传了更深的历史数据或填补了缺口),那么终端也会将prev_calculated参数的值设置为0。因此,指标将收到一个信号,要求对整个可用历史数据进行完整的重新计算。

如果OnCalculate函数返回一个空值,指标将不会绘制,并且其在数据窗口中的缓冲区的名称和值将被隐藏。

请注意,返回完整的K线数量rates_total是告知终端和其他将使用该指标的MQL程序其数据已准备好的唯一标准方式。即使一个指标被设计为仅计算和显示有限数量的数据,它也应该返回rates_total

data数组的索引方向可以通过调用ArraySetAsSeries来选择(默认值为false,可以通过调用ArrayGetAsSeries来验证)。同时,如果我们对该数组应用ArrayIsSeries函数,它将返回true。这意味着这个数组是一个由终端管理的内部数组。指标无法以任何方式更改它,而只能读取它,特别是因为参数描述中有一个const修饰符。

begin参数报告应该从计算中排除的data数组的初始值的数量。当用户以从另一个指标接收数据的方式配置我们的指标时,系统会设置该参数(见上文图片)。例如,如果所选的数据源指标计算一个周期为N的移动平均线,那么根据定义,前N - 1根K线不包含源数据,因为在那里不可能对N根K线进行平均。如果开发者在这个源指标中设置了一个特殊属性,它将在begin参数中正确地传递给我们。我们很快将在实践中检验这一方面(见“跳过初始K线的绘制”部分)。

让我们尝试创建一个使用简化形式OnCalculate的空指标。它目前还无法执行任何操作,但将为进一步的实验做准备。原始文件IndStub.mq5可以在文件夹MQL5/Indicators/MQL5Book/p5/中找到。为了确保指标正常工作,我们在OnCalculate中添加以下内容:将prev_calculatedrates_total的值输出到日志的功能,以及计算函数调用次数的功能。

c
int OnCalculate(const int rates_total, 
                const int prev_calculated, 
                const int begin, 
                const double &data[])
{
   static int count = 0;
   ++count;
   // 比较上一次调用和当前调用的K线数量
   if(prev_calculated != rates_total)
   {
      // 仅在有差异时发出信号
      PrintFormat("calculated=%d rates=%d; %d ticks", 
         prev_calculated, rates_total, count);
   }
   return rates_total; // 返回处理的K线数量
}

prev_calculatedrates_total不相等的条件确保了该消息只会在指标第一次放置在图表上时以及有新K线出现时出现。在当前K线形成过程中到来的所有报价点都不会改变K线的数量,因此prev_calculatedrates_total将相等。然而,我们将在count变量中计算报价点的总数。

其余参数目前尚未使用,但我们将逐步利用所有这些可能性。

此源代码可以成功编译,但会生成两个警告:

  1. no indicator window property is defined, indicator_chart_window is applied:没有定义指标窗口属性,应用了indicator_chart_window
  2. no indicator plot defined for indicator:没有为指标定义绘图。

这些警告表明缺少一些#property指令,尽管这些指令不是必需的,但它们设置了指标的基本属性。特别是,第一个警告表示没有为指标选择绑定方法:主窗口还是子窗口,因此默认将使用主图表窗口。第二个警告与我们尚未设置要显示的图表数量这一事实有关。如前所述,一些指标是特意设计为没有缓冲区的,因为它们旨在执行其他操作,但在我们的情况下,这是一个提醒,以便稍后添加可视化部分。

我们将在接下来的几段中处理消除这些警告的问题,但现在,我们将在欧元兑美元(EURUSD)、1分钟(M1)图表上启动该指标。我们使用M1时间框架,因为这样我们可以快速看到新K线的形成以及日志中消息的出现。

calculated=0 rates=10002; 1 ticks
calculated=10002 rates=10003; 30 ticks
calculated=10003 rates=10004; 90 ticks
calculated=10004 rates=10005; 167 ticks
calculated=10005 rates=10006; 240 ticks

因此,我们看到OnCalculate处理程序按预期被调用,并且你可以在其中对每个报价点和K线进行计算。可以通过从图表上下文菜单中调用“指标列表”对话框来从图表中删除该指标:选择所需的指标并按“删除”(Delete)键。

现在让我们回到OnCalculate函数的另一个原型。我们已经在实践中尝试了简化版本,但我们也可以为完整形式实现完全相同的空白指标。

完整形式是为基于标准价格时间序列的计算而设计的,具有以下原型:

c
int OnCalculate(const int rates_total, const int prev_calculated, const datetime &time[],
  const double &open[], const double &high[], const double &low[], const double &close[],
  const long &tick_volume[], const long &volume[], const int &spread[])

rates_totalprev_calculated参数的含义与OnCalculate的简单形式中的相同:rates_total设置传输的时间序列的大小(所有数组具有相同的长度,因为这是图表上K线的总数),而prev_calculated包含上一次调用时处理的K线数量(即OnCalculate函数之前使用return语句返回给终端的值)。

openhighlowclose数组包含当前图表K线的相关价格:工作交易品种和时间框架的时间序列。time数组包含每根K线的开盘时间,而tick_volumevolume包含每根K线的交易成交量(报价成交量和实际成交量)。

在上一章中,我们研究了终端通过一组函数为MQL程序提供的具有标准价格和成交量类型的时间序列。因此,为了方便计算指标,这些时间序列作为数组直接通过引用传递给OnCalculate处理程序。这消除了调用这些函数并将报价复制(重复)到内部数组的需要。当然,这种技术仅适用于那些在与当前图表匹配的工作交易品种和时间框架的一种组合上进行计算的指标。然而,MQL5允许创建多货币、多时间框架的指标,以及针对当前图表之外的交易品种和时间框架的指标。在所有这些情况下,不使用访问时间序列的函数就已经不可能了。稍后我们将看到这是如何实现的。

如果我们使用ArrayIsSeries检查所有传递的数组是否属于终端,该函数将返回true。所有数组都是只读的。参数描述中的const修饰符也强调了这一点。

根据计算算法需要的数据在完整形式和简化形式之间进行选择。例如,要使用移动平均算法平滑一个数组,只需要一个输入数组,因此可以为用户选择的任何价格类型构建指标。然而,知名指标抛物线转向指标(ParabolicSAR)或之字形指标(ZigZag)需要最高价和最低价,因此必须使用OnCalculate的完整版本。在接下来的部分中,我们将看到使用OnCalculate简单版本和完整版本的指标示例。

两种类型的指标:主窗口指标和子窗口指标

如你所知,MetaTrader 5中的指标可以在两个位置显示其线条:在显示报价的主图表窗口中,位于报价之上;或者在价格图表下方创建的单独窗口中。这两种模式是相互排斥的:每个指标要么是为在主窗口中显示而设计,要么是为在子窗口中显示而设计,不能同时结合这两种方式。

对于那些需要在两个窗口中可视化数据的情况,有几种替代解决方案。例如,可以以两个相互交互的指标的形式来实现一个项目(交互的技术方面有多种可能:可以是资源、文件、数据库管理系统,或者通过动态链接库(DLL)访问的共享内存)。另一种方法是在其中一个窗口(例如在底部面板)中使用指标缓冲区,并在主图表上使用图形对象进行可视化。

多个指标既可以应用于主窗口,也可以应用于子窗口。如果一个指标是为在单独的窗口中工作而设计的,那么用鼠标将其从“导航器”拖到主窗口时,将自动为该指标创建一个新窗口。然而,如果该窗口已经有一个包含另一个指标的子窗口,那么新的指标可以被拖到相同的位置,从而使两个或更多指标对齐。在这种情况下,在一个窗口中对指标进行各种缩放模式是可行的。默认情况下,每个指标的图形结构会自动且相互独立地缩放到面板的全高,但这是可以更改的(请参阅关于键盘事件部分的示例SubScaler.mq5)。

指标的显示窗口是通过两个编译指令之一来选择的:

c
#property indicator_chart_window     // 在图表窗口(主窗口)中显示指标
#property indicator_separate_window  // 在单独的窗口(子窗口)中显示指标

指标开发者应该在源代码的开头插入其中一个指令。如果这两个指令都不存在,默认选项会将指标输出到主窗口,但编译器会生成一个警告。我们在上一节中已经看到了这种情况。在接下来的示例中,我们一定会指定#property indicator_chart_window#property indicator_separate_window

上一节中IndStub.mq5的第二个编译警告涉及缺少缓冲区和图表设置。我们将在下一节中处理这些问题。

指标设置中“应用于”(Apply to)下拉列表的作用取决于该指标所设计的窗口。

  • 为单独窗口(子窗口)设计的指标可以应用于子窗口中的指标,但不能应用于主窗口中的指标。
  • 然而,为主窗口设计的指标可以应用于任何指标,无论是主窗口中的指标还是子窗口中的指标。

设置缓冲区和图形绘图的数量

为了让指标在图表上显示其计算结果,它必须定义一个或多个数组,并将它们声明为指标缓冲区。缓冲区的数量通过以下指令设置:

c
#property indicator_buffers N

这里的N是一个介于1到512之间的整数。该指令设置了在代码中可用于指标计算的缓冲区数量。

N必须是一个整数常量(字面量)或等效的宏定义。由于这是一个预处理器指令,在源代码预处理阶段还不存在变量(即使带有const修饰符)。

然而,仅有缓冲区还不足以可视化计算数据。在MQL5中,可视化系统是两级的。第一级由指标缓冲区构成,它们是存储用于显示数据的动态数组。第二级用于管理这些数据的显示方式。它基于被称为图形结构(或图表,或绘图)的新实体构建。关键在于,不同的数据显示方式可能需要不同数量的指标缓冲区。例如,移动平均线每个K线只有一个值,因此对于这样的折线图,一个指标缓冲区就足够了。但是,要显示蜡烛图,每个K线需要4个值(开盘价、最高价、最低价、收盘价)。因此,一个这样的图形绘图需要4个指标缓冲区。

图表的数量(P)也必须在源代码中使用特殊指令进行定义:

c
#property indicator_plots   P

在最简单的情况下,缓冲区和图表的数量是相同的。但我们很快会分析需要比图形结构更多缓冲区的示例。除了特定类型的图形结构需要预定数量的缓冲区这种情况外,我们有时还需要为中间计算分配一个或多个数组。这样的数组并不直接参与渲染,但包含用于构建渲染缓冲区的数据。当然,你可以为此目的使用简单的动态数组而不将它们声明为缓冲区。但那样的话,我们就必须自己控制和调整它们的大小。将它们设为缓冲区会方便得多,这样就可以指示终端分配内存。

缓冲区和图形绘图的数量只能使用预处理器指令设置;这些属性不能使用MQL5函数动态更改。

在确定了缓冲区和图表的数量之后,应该在源代码中描述那些将成为指标缓冲区的数组本身。

让我们开始开发一个新的指标示例IndReplica1.mq5,以展示源代码中必要的部分。该指标的本质很简单:在它唯一的缓冲区中,我们将显示接收到的data参数数组的值。正如我们之前所说,在将指标应用到图表时,用户会选择要传递给data数组的特定时间序列;默认情况下会提供包含K线收盘价的时间序列。

让我们添加描述一个缓冲区和一个图表的指令:

c
#property indicator_chart_window
#property indicator_buffers 1
#property indicator_plots 1

这些指令并不会分配缓冲区本身,而只是设置指标的属性,并让运行时系统为程序进一步确定和配置指定数量的数组做好准备。接下来,我们将看看如何将一个数组注册为缓冲区。

将数组指定为缓冲区:SetIndexBuffer

从程序启动到停止的整个生命周期内,任何double类型的动态数组都能充当指标缓冲区。最常见的定义此类数组的方式是在全局层面进行。不过,在某些情形下,把数组设为类的成员,然后创建带有数组的全局对象会更便利。在实现多货币指标(请参阅“多货币和多时间框架指标”部分的示例IndUnityPercent.mq5)和Delta交易量指标(请参阅“等待数据和管理可见性”部分的IndDeltaVolume.mq5)时,我们会探讨这种方法的实例。

那么,让我们在全局层面描述一个动态数组缓冲区(不指定大小):

c
double buffer[];

可以使用终端中的特殊SetIndexBuffer函数将其注册为缓冲区。通常,它会在OnInit处理程序中被调用,就像许多其他用于设置指标的函数一样,我们稍后会讨论这些函数。

c
bool SetIndexBuffer(int index, double buffer[],
  ENUM_INDEXBUFFER_TYPE mode = INDICATOR_DATA)

该函数将由index指定的指标缓冲区与buffer动态数组关联起来。index的值必须介于0到N - 1之间,其中N是由#property indicator_buffers指令指定的缓冲区数量。

绑定操作完成后,数组还不能立即处理数据,甚至其大小也不会改变,所以初始化和所有计算都应在OnCalculate函数中进行。将动态数组指定为指标缓冲区后,就不能再更改其大小了。对于指标缓冲区,所有调整大小的操作都由终端自行完成。

数组与指标缓冲区绑定后,索引方向默认与普通数组相同。如有需要,可以使用ArraySetAsSeries函数更改索引方向。

SetIndexBuffer函数在成功时返回true,出错时返回false

可选的mode参数告知系统缓冲区将如何使用。可能的值在ENUM_INDEXBUFFER_TYPE枚举中提供:

标识符描述
INDICATOR_DATA用于渲染的数据
INDICATOR_COLOR_INDEX渲染颜色
INDICATOR_CALCULATIONS中间计算的内部结果

默认情况下,指标缓冲区用于绘制数据(INDICATOR_DATA)。这个值除了能在图表上显示数组外,还有另一个作用:鼠标指针下K线对应的每个缓冲区的值会显示在数据窗口中。不过,这种行为可以通过一些指标设置来改变(请参阅“图形绘图设置”部分的PLOT_SHOW_DATA属性)。本章的大多数示例都使用INDICATOR_DATA模式。

如果指标计算需要为每个K线存储中间结果,可以为它们分配一个辅助的、不显示的缓冲区(INDICATOR_CALCULATIONS)。这比使用普通数组实现相同目的更方便,因为这样程序员就无需自行控制其大小。本章将给出两个使用INDICATOR_CALCULATIONS的示例:IndTripleEMA.mq5(请参阅“跳过初始K线的绘制”)和IndSubChartSimple.mq5(请参阅“多货币和多时间框架指标”)。

某些图形结构允许为每个K线设置显示颜色。颜色缓冲区(INDICATOR_COLOR_INDEX)用于存储颜色信息。颜色由整数类型color表示,但所有指标缓冲区都必须是double类型,在这种情况下,它们存储的是开发者设置的特殊调色板中的颜色编号(请参阅“图表逐元素着色”部分及其示例指标IndColorWPR.mq5)。

颜色和辅助缓冲区的值不会显示在数据窗口中,也不能使用CopyBuffer函数获取,我们将在“从MQL5使用内置和自定义指标”一章中探讨该函数。

指标缓冲区不会用任何值进行初始化。如果由于某种原因,其某些元素未被计算(例如,指标设置中对最大K线数量有限制,或者图形结构本身意味着重要元素之间应该有间隔,就像之字形指标的顶点之间那样),那么应该用一个特殊的“空”值显式填充它们。这个空值不会在图表上显示,也不会在数据窗口中显示。默认情况下,有一个EMPTY_VALUEDBL_MAX)常量用于此目的,但如有需要,可以用任何其他值替换,例如0。这可以使用PlotIndexSetDouble函数来完成。

基于对SetIndexBuffer函数的新认识,让我们完成上一节开始的示例IndReplica1.mq5。特别是,我们需要OnInit处理程序:

c
#property indicator_chart_window
#property indicator_buffers 1
#property indicator_plots 1
 
#include <MQL5Book/PRTF.mqh>
 
double buffer[]; // global dynamic array
 
int OnInit()
{
   // register an array as an indicator buffer
   PRTF(SetIndexBuffer(0, buffer)); // true / ok
   // the second incorrect call is made here intentionally to show an error
   PRTF(SetIndexBuffer(1, buffer)); // false / BUFFERS_WRONG_INDEX(4602)
   // check size: still 0
   PRTF(ArraySize(buffer)); // 0
   return INIT_SUCCEEDED;
}

缓冲区的数量由指令定义为1,所以为单个缓冲区分配数组时使用索引0(SetIndexBuffer的第一个参数)。第二次函数调用是错误的,添加它只是为了演示问题:由于索引1意味着声明了两个缓冲区,它会生成一个BUFFERS_WRONG_INDEX(4602)错误。

OnCalculate函数的开头,让我们再次打印数组的大小。此时,它已经根据K线数量进行了分配:

c
int OnCalculate(const int rates_total, 
                const int prev_calculated, 
                const int begin, 
                const double &data[])
{
   // after starting, check that the platform automatically manages the size of the array
   if(prev_calculated == 0)
   {
      PRTF(ArraySize(buffer)); // 10189 - actual number of bars
   }
   ...

现在让我们来看看我们的指标将进行什么计算。如前所述,我们暂时不会使用复杂的公式,只是尝试将data参数中传递的时间序列复制到缓冲区中。这反映在指标的名称中:

c
   ...
   // on each new bar or set of bars (including the first calculation)
   if(prev_calculated != rates_total)
   {
      // fill in all new bars
      ArrayCopy(buffer, data, prev_calculated, prev_calculated);
   }
   else // ticks on the current bar
   {
      // update the last bar
      buffer[rates_total - 1] = data[rates_total - 1];
   }
   
   // we report the number of processed bars to ourselves in the future
   return rates_total;
}

现在,指标编译时没有任何警告。我们可以在图表上运行它,在默认设置下,它应该在缓冲区中复制K线的收盘价。这是由于OnCalculate的简化形式,我们在“指标的主要事件:OnCalculate”部分讨论过这个方面。

然而,有一个奇怪的现象:我们的缓冲区显示在数据窗口中,并且包含正确的值,但图表上没有线条。这是因为负责显示的是图形结构,而不是缓冲区。在当前版本的指标中,我们只配置了缓冲区。在下一节中,我们将创建一个新的版本IndReplica2.mq5,并补充必要的指令。

同时,上述效果对于创建“隐藏”指标很有用,这些指标不会在图表上显示其线条,但可以被其他MQL程序以编程方式读取。如果需要,开发者甚至可以在数据窗口中隐藏对指标缓冲区的提及(请参阅下一节的PLOT_SHOW_DATA)。

如何从MQL5代码管理指标将在下一章讨论。

绘图设置:PlotIndexSetInteger

MQL5 API提供了以下用于配置绘图的函数:PlotIndexSetIntegerPlotIndexSetDoublePlotIndexSetString。整型属性也可以通过 PlotIndexGetInteger 来读取。我们主要关注整型属性。

PlotIndexSetInteger 函数有两种形式。稍后我们会看到它们的区别。

c++
bool PlotIndexSetInteger(int index, ENUM_PLOT_PROPERTY_INTEGER property, int value)
bool PlotIndexSetInteger(int index, ENUM_PLOT_PROPERTY_INTEGER property, int modifier, int value)

该函数用于设置指定索引处图形绘图的属性值。index 的值必须在 0 到 P - 1 之间,其中 P 是由 #property indicator_plots 指令指定的绘图数量。属性本身由 property 参数标识:允许的值应从 ENUM_PLOT_PROPERTY_INTEGER 枚举中选取(见下文)。属性值通过 value 参数传递。

函数的第二种形式用于适用于多个组件的属性(尽管它们属于同一属性)。特别是对于某些类型的图表,可以分配一组颜色而不是一种颜色。在这种情况下,你可以使用 modifier 参数来更改此集合中的任何颜色。

如果成功,函数返回 true;否则返回 false

以下表格提供了可用的 ENUM_PLOT_PROPERTY_INTEGER 属性。

标识符描述属性类型
PLOT_ARROW用于 DRAW_ARROW 图表的 Wingdings 字体箭头代码uchar
PLOT_ARROW_SHIFTDRAW_ARROW 图表的箭头垂直偏移量int
PLOT_DRAW_BEGIN数据开始的第一个柱线(从左到右)的索引int
PLOT_DRAW_TYPE绘图(图表)类型ENUM_DRAW_TYPE
PLOT_SHOW_DATA在数据窗口中显示绘图值的标志(true — 可见,false — 不可见)bool
PLOT_SHIFT指标图形沿时间轴的柱线偏移量(正值向右偏移,负值向左偏移)int
PLOT_LINE_STYLE线条绘制样式ENUM_LINE_STYLE
PLOT_LINE_WIDTH线条粗细(以像素为单位,范围 1 - 5)int
PLOT_COLOR_INDEXES颜色数量(1 - 64)int
PLOT_LINE_COLOR渲染颜色(modifier — 颜色编号)color

我们会逐步了解所有属性,但目前我们将重点关注三个主要属性:PLOT_DRAW_TYPEPLOT_LINE_STYLEPLOT_LINE_COLOR

MetaTrader 5 中的指标支持几种预定义的绘图类型。它们决定了视觉表示形式以及用于显示的初始数据缓冲区的所需结构。

总共有 10 种这样的基本绘图,在 MQL5 级别,它们由 ENUM_DRAW_TYPE 枚举中的标识符描述。应该将 ENUM_DRAW_TYPE 的值之一赋给 PLOT_DRAW_TYPE 属性。

可视化类型,示例描述缓冲区数量
DRAW_NONE
IndDeltaVolume.mq5
图表上不显示任何内容,但相应缓冲区的值在数据窗口中可用1
DRAW_LINE
IndLabelHighLowClose.mq5, IndWPR.mq5, IndUnityPercent.mq5
根据缓冲区值绘制曲线(“空”元素会在曲线上形成间隙)1
DRAW_SECTION在“非空”缓冲区元素之间形成折线的直线段(如果没有间隙,与 DRAW_LINE 类似)1
DRAW_ARROW
IndReplica3.mq5, IndFractals.mq5
字符(标签)1
DRAW_HISTOGRAM
IndDeltaVolume.mq5
从零线到缓冲区值的直方图1
DRAW_HISTOGRAM2
IndLabelHighLowClose.mq5
两个指标缓冲区的配对元素值之间的直方图2
DRAW_ZIGZAG
IndFractalsZigZag.mq5
在两个缓冲区中连续出现的“非空”元素之间形成折线的直线段(类似于 DRAW_SECTION,但与它不同的是,允许在一个柱线上有垂直段)2
DRAW_FILLING通过两个缓冲区中的配对值对两条线之间的通道进行颜色填充2
DRAW_BARS
IndSubChartSimple.mq5
以柱状图形式显示:每个柱线的四个价格按 OHLC 顺序显示在四个相邻的缓冲区中4
DRAW_CANDLES
IndSubChartSimple.mq5
以蜡烛图形式显示:每个柱线的四个价格按 OHLC 顺序显示在四个相邻的缓冲区中4

此表未列出所有 ENUM_DRAW_TYPE 元素。有一些相同绘图类型的变体支持对单个元素(柱状图)进行着色。我们将在单独的“图表逐元素着色”部分介绍它们。MQL5 文档为所有类型提供了示例,在本书范围内有一些例外情况:在类型名称旁边标明了演示指标的存在。

在所有情况下,包括 DRAW_NONE,其他程序都可以通过 CopyBuffer 函数访问缓冲区中的数据。

DRAW_NONE 类型的一个额外特性是,此类缓冲区的值不参与自动图表缩放,而默认情况下,显示在子窗口中的指标会启用自动图表缩放。

线条的样式由 PLOT_LINE_STYLE 属性决定,该属性也有一个包含有效 ENUM_LINE_STYLE 值的枚举。

标识符描述
STYLE_SOLID实线
STYLE_DASH虚线
STYLE_DOT点线
STYLE_DASHDOT点划线
STYLE_DASHDOTDOT双点划线

最后,线条的颜色由 PLOT_LINE_COLOR 属性设置。在最简单的情况下,此属性包含整个图表的单一颜色。对于某些图表类型,特别是 DRAW_CANDLES,可以使用 modifier 参数指定多种颜色。我们稍后会讨论这个问题(见“多货币和多时间框架指标”部分的示例 IndSubChartSimple.mq5)。

上述三个属性足以演示指标 IndReplica2.mq5。让我们分别添加两个 ENUM_DRAW_TYPEENUM_LINE_STYLE 类型的输入参数 DrawTypeLineStyle,然后在 OnInit 中多次调用 PlotIndexSetInteger 函数来设置指标的渲染属性。

cpp
#property indicator_chart_window
#property indicator_buffers 1
#property indicator_plots 1

input ENUM_DRAW_TYPE DrawType = DRAW_LINE;
input ENUM_LINE_STYLE LineStyle = STYLE_SOLID;

double buffer[];

int OnInit()
{
    // 将数组注册为指标缓冲区
    SetIndexBuffer(0, buffer);
    
    // 设置编号为 0 的图表的属性
    PlotIndexSetInteger(0, PLOT_DRAW_TYPE, DrawType);
    PlotIndexSetInteger(0, PLOT_LINE_STYLE, LineStyle);
    PlotIndexSetInteger(0, PLOT_LINE_COLOR, clrBlue);
    
    return INIT_SUCCEEDED;
}

对于 PLOT_LINE_COLOR 属性,我们没有创建输入变量,因为此属性和其他一些属性可以直接从任何指标的属性对话框的“颜色”选项卡中访问。默认情况下,即指标启动后立即,线条颜色将为蓝色。但是,颜色以及线条粗细和样式可以在对话框(在指定选项卡上)中更改。我们的 LineStyle 参数部分重复了“颜色”表中相应的“样式”单元格。然而,它提供了额外的优势。对话框的标准控件在线条宽度大于 1 时不允许选择样式。当使用输入变量 LineStyle 时,我们可以得到例如宽度为 3 像素的点划线。

OnCalculate 中填充缓冲区数据与 IndReplica1.mq5 相比保持不变。

在编译并在图表上启动指标后,我们得到了预期的结果:图表上收盘价处有一条蓝色线条,并且数据窗口中显示了相应的柱线收盘价。

通过更改 DrawType 输入参数,我们可以更改缓冲区中数据的显示方式。在这种情况下,你应该只选择需要单个缓冲区的类型。任何其他图形类型(DRAW_HISTOGRAM2DRAW_ZIGZAGDRAW_FILLINGDRAW_BARSDRAW_CANDLES)在单个缓冲区上根本无法工作,并且不会显示任何内容。选择带有着色的构造类型(以“Color”开头)也没有意义,因为它们需要一个额外的缓冲区,其中包含每个柱线的颜色编号(正如已经提到的,我们将在“图表逐元素着色”部分了解这种可能性)。

下面展示了 DRAW_LINEDRAW_SECTIONDRAW_HISTOGRAMDRAW_ARROW 的显示选项。

单缓冲区图表类型

如果不是特意选择了不同的样式,DRAW_LINE 使用 STYLE_SOLIDDRAW_SECTION 使用 STYLE_DOT,这些绘图类型将是相同的,因为我们缓冲区中的所有元素都有“非空”值。默认情况下,“空”值意味着特殊常量 EMPTY_VALUE,我们没有使用它。DRAW_SECTION 中的线段会绕过“空”元素绘制,只有在存在“空”元素时这一点才会变得明显。我们将在“数据间隙可视化”部分讨论“空”元素的设置。

从零开始的直方图 DRAW_HISTOGRAM 通常用于具有自己窗口的指标,但这里为了演示目的展示了它。我们将在“等待数据和管理可见性”部分创建一个具有这种渲染类型的子窗口指标(见示例 IndDeltaVolume.mq5)。

对于 DRAW_ARROW 类型,系统默认使用填充圆圈字符(代码 159),但你可以通过调用 PlotIndexSetInteger(index, PLOT_ARROW, code) 将其更改为其他字符。

Wingdings 字体符号的代码和外观可以在 MQL5 帮助文档中找到。

IndReplica3.mq5 指标的另一个修改版本中,我们添加了输入参数来选择“箭头”符号(ArrowCode),以及在图表上垂直(Arrow padding)和水平(TimeShift)移动这些标签。

cpp
input uchar ArrowCode = 159;
input int ArrowPadding = 0;
input int TimeShift = 0;

沿价格刻度的垂直偏移量以像素为单位指定(正值表示向下偏移,负值表示向上偏移)。沿时间刻度的水平偏移量以柱线为单位设置(正值表示向右偏移,即向未来偏移,负值表示向左偏移,即向过去偏移)。新的输入变量在 OnInit 中传递给 PlotIndexSetInteger 调用。

cpp
int OnInit()
{
    ...
    PlotIndexSetInteger(0, PLOT_DRAW_TYPE, DRAW_ARROW);
    PlotIndexSetInteger(0, PLOT_ARROW, ArrowCode);
    PlotIndexSetInteger(0, PLOT_ARROW_SHIFT, ArrowPadding);
    PlotIndexSetInteger(0, PLOT_SHIFT, TimeShift);
    ...
}

以下截图展示了 IndReplica3.mq5 在图表上的一个示例,设置为 117(菱形)、-50(向上 50 点)、3(向右/向前 3 个柱线)。

带有垂直和水平标签偏移的散点图

我们的默认指标基于收盘价类型(尽管用户可以在属性对话框的“应用于”下拉列表中更改此设置)。如果需要,你可以使用以下指令分配不同的初始设置:

c++
#property indicator_applied_price PRICE_TYPE

这里,应将 ENUM_APPLIED_PRICE 枚举中的任何常量替换 PRICE_TYPE。其中也包括 PRICE_CLOSE,它对应于默认值。例如,将以下指令添加到源代码中,将使指标默认基于典型价格。

c++
#property indicator_applied_price PRICE_TYPICAL

再次强调,此设置仅指定默认值。内置的 _AppliedTo 变量允许你了解指标所基于的实际价格类型。如果指标是根据另一个指标的描述符构建的,那么只能知道这一事实,而无法知道提供数据的具体指标的名称。

要在源代码中了解 ENUM_PLOT_PROPERTY_INTEGER 枚举中属性的当前状态,请使用 PlotIndexGetInteger 函数。

c++
int PlotIndexGetInteger(int index, ENUM_PLOT_PROPERTY_INTEGER property)
int PlotIndexGetInteger(int index, ENUM_PLOT_PROPERTY_INTEGER property, int modifier)

该函数通常与 PlotIndexSetInteger 一起使用,用于将绘图属性从一条线复制到另一条线,或者从包含在各种指标源代码中的通用 .mqh 文件的代码中读取属性。

遗憾的是,没有提供类似的 PlotIndexGetDoublePlotIndexGetString 函数。

缓冲区和图表的映射规则

在使用 PlotIndexSetInteger(i, PLOT_DRAW_TYPE, type) 注册图表时,每次调用都会根据渲染类型所需的数量,依次为第 i 个图表分配一定数量的缓冲区(见上一节的 ENUM_DRAW_TYPE 表格)。因此,在将缓冲区与后续图表关联时(在后续的 PlotIndexSetInteger 调用期间),这些数量的缓冲区将不再被考虑。

例如,如果第一个绘图(索引为 0)是 DRAW_CANDLES 类型,它需要 4 个指标缓冲区,那么恰好会有 4 个缓冲区与之关联。这样,索引从 0 到 3(包含)的缓冲区将被绑定,下一个可绑定的空闲缓冲区的索引将是 4。

如果接下来注册一个简单的折线图 DRAW_LINE(它在图表序列中的索引是 1),它只需要 1 个缓冲区,即索引为 4 的缓冲区。

如果进一步配置一个 DRAW_ZIGZAG 图表(下一个图表索引是 2),由于它使用两个缓冲区,那么索引为 5 和 6 的缓冲区将分配给它。

当然,缓冲区的数量必须足够满足所有已注册的绘图需求。上述示例在下面的表格中进行了说明。该示例只有 7 个缓冲区和 3 个绘图(图表)。

SetIndexBuffer 中的缓冲区索引0123456
PlotIndexSetInteger 中的图表索引012
渲染类型DRAW_CANDLESDRAW_LINEDRAW_ZIGZAG

缓冲区和图表的索引是相互独立的,也就是说,缓冲区索引不必与图表索引相同。同时,随着图表索引的增加,与之绑定的缓冲区索引也会增加,如果使用的渲染类型需要多个缓冲区,那么索引之间的差异可能会越来越大。

虽然通常习惯在调用 PlotIndexSetInteger 之前调用 SetIndexBuffer 函数,但这并不是强制要求。唯一重要的是缓冲区索引和图表索引的正确对应关系。当使用指令(见下一节)时,这些指令是 PlotIndexSetInteger 的替代方式,无论如何,指令都会在 OnInit 处理程序之前执行。

为了演示缓冲区索引和图表索引之间的区别,我们来看一个简单的示例 IndHighLowClose.mq5。在这个文件中,我们将以 DRAW_HISTOGRAM2 类型的直方图形式绘制每个蜡烛图的最高价和最低价之间的范围,并使用简单的折线 DRAW_LINE 来标记收盘价。为了访问不同类型价格的时间序列数据,我们还需要将 OnCalculate 函数的形式从简化版改为完整版。

由于直方图需要 2 个缓冲区,再加上用于绘制收盘价折线的缓冲区,我们应该定义三个缓冲区。

cpp
#property indicator_chart_window
#property indicator_buffers 3
#property indicator_plots 2

double highs[];
double lows[];
double closes[];

OnInit 函数中按优先级对它们进行注册。

cpp
int OnInit()
{
    // 为 3 种价格类型的缓冲区分配数组
    SetIndexBuffer(0, highs);
    SetIndexBuffer(1, lows);
    SetIndexBuffer(2, closes);

    // 在索引 0 处绘制最高价和最低价之间的直方图
    PlotIndexSetInteger(0, PLOT_DRAW_TYPE, DRAW_HISTOGRAM2);
    PlotIndexSetInteger(0, PLOT_LINE_WIDTH, 5);
    PlotIndexSetInteger(0, PLOT_LINE_COLOR, clrBlue);

    // 在索引 1 处绘制收盘价折线
    PlotIndexSetInteger(1, PLOT_DRAW_TYPE, DRAW_LINE);
    PlotIndexSetInteger(1, PLOT_LINE_WIDTH, 2);
    PlotIndexSetInteger(1, PLOT_LINE_COLOR, clrRed);

    return INIT_SUCCEEDED;
}

顺便提一下,直方图的宽度被设置为 5 像素,折线的宽度被设置为 2 像素。没有显式指定样式,默认使用 STYLE_SOLID

现在让我们来看一下实际的 OnCalculate 函数。

cpp
int OnCalculate(const int rates_total, 
                const int prev_calculated, 
                const datetime &time[],
                const double &open[],
                const double &high[],
                const double &low[],
                const double &close[],
                const long &tick_volume[],
                const long &volume[],
                const int &spread[])
{
    // 在每个新的柱线或一组柱线(包括首次计算)上
    if(prev_calculated != rates_total)
    {
        // 填充所有新的柱线
        ArrayCopy(highs, high, prev_calculated, prev_calculated);
        ArrayCopy(lows, low, prev_calculated, prev_calculated);
        ArrayCopy(closes, close, prev_calculated, prev_calculated);
    }
    else // 当前柱线上的报价
    {
        // 更新最后一个柱线
        highs[rates_total - 1] = high[rates_total - 1];
        lows[rates_total - 1] = low[rates_total - 1];
        closes[rates_total - 1] = close[rates_total - 1];
    }
    // 返回下一次调用时已处理的柱线数量
    return rates_total;
}

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

高低价直方图和收盘价折线

请注意一个重要的点。图表会按照其索引的顺序绘制在图表上,因此有些图表在视觉上会高于其他图表(覆盖其他图表)。在这种情况下,索引为 0 的直方图会首先绘制,然后索引为 1 的折线会绘制在其上方。有时,更改图表的注册顺序是有意义的,这样可以让较小的图形结构更容易被看到,因为它们可能会被较大(较宽)的绘图所覆盖。

在假想的 Z 轴上设置这种优先级(Z 轴垂直于屏幕并深入屏幕内部)被称为 Z 顺序。在学习图形对象时,我们还会再次遇到这种技术。

另外,请记住,默认情况下,指标会显示在价格图表的上方,但可以在设置中更改此行为:在“图表属性”对话框的“常规”选项卡中,有“图表在前景”选项。在软件界面中也有类似的选项(ChartSetInteger(CHART_FOREGROUND),见“图表显示模式”部分)。

使用指令自定义绘图

到目前为止,我们一直使用 PlotIndexSetInteger 函数调用来自定义图形绘图。MQL5 允许你使用 #property 预处理器指令来实现相同的功能。这两种方法的主要区别在于,指令在编译时进行处理,使用它们描述的属性在加载可执行文件时就会被读取,甚至在 OnInit 处理程序(如果存在)执行之前。也就是说,指令提供了一些默认值,如果你不需要更改这些值,就可以直接使用。

另一方面,PlotIndexSetInteger 函数调用允许你在程序执行期间动态更改属性。使用函数动态更改属性可以让你创建更灵活的指标使用场景。下面的表格展示了这些指令以及相关的 PlotIndexSetInteger 函数调用。

指令函数描述
indicator_colorNPlotIndexSetInteger(N - 1, PLOT_LINE_COLOR, color)绘图的线条颜色
indicator_styleNPlotIndexSetInteger(N - 1, PLOT_LINE_STYLE, type)ENUM_LINE_STYLE 枚举中的绘图样式
indicator_typeNPlotIndexSetInteger(N - 1, PLOT_DRAW_TYPE, type)ENUM_DRAW_TYPE 枚举中的绘图类型
indicator_widthNPlotIndexSetInteger(N - 1, PLOT_LINE_WIDTH, width)线条粗细(以像素为单位,范围 1 - 5)

请注意,指令中绘图的编号从 1 开始,而在函数中从 0 开始。例如,指令 #property indicator_type1 DRAW_ZIGZAG 等同于调用 PlotIndexSetInteger(0, PLOT_DRAW_TYPE, DRAW_ZIGZAG)

还值得注意的是,通过函数可以设置的属性比通过指令设置的要多得多:ENUM_PLOT_PROPERTY_INTEGER 枚举提供了十个元素。

即使首次将指标放置在图表上,指令描述的属性在指标设置对话框中也是可用的(用户可以看到并编辑)。特别是,这包括线条的粗细、颜色和样式(“颜色”选项卡),以及水平线条的数量和位置(“水平线条”选项卡)。由函数设置的相同属性(如果指令中没有默认值)只会在第二次及以后出现在对话框中。

让我们调整 IndHighLowClose.mq5 指标以使用指令。新版本在 IndPropHighLowClose.mq5 文件中。使用指令简化了 OnInit 处理程序;OnCalculate 保持不变。

cpp
#property indicator_chart_window
#property indicator_buffers 3
#property indicator_plots 2

// 高低价直方图渲染设置(在指令中将索引 0 改为 1)
#property indicator_type1   DRAW_HISTOGRAM2
#property indicator_style1  STYLE_SOLID // 默认值,可以省略
#property indicator_color1  clrBlue
#property indicator_width1  5

// 收盘价折线绘图设置(在指令中将索引 1 改为 2)
#property indicator_type2   DRAW_LINE
#property indicator_style2  STYLE_SOLID // 默认值,可以省略
#property indicator_color2  clrRed
#property indicator_width2  2

double highs[];
double lows[];
double closes[];

int OnInit()
{
    // 为 3 种价格类型的缓冲区分配数组
    SetIndexBuffer(0, highs);
    SetIndexBuffer(1, lows);
    SetIndexBuffer(2, closes);

    return INIT_SUCCEEDED;
}

新指标的外观与旧指标完全相同。

设置绘图名称

在本章前面的示例中,数据窗口中的指标缓冲区是以指标本身的名称来标识的,这缺乏信息性。MQL5 API 提供了为每个缓冲区设置自定义名称的功能。这可以通过我们已经了解的两种方式来实现:使用 #property 指令和调用特殊的 PlotIndexSetString 函数。

c++
bool PlotIndexSetString(int index, ENUM_PLOT_PROPERTY_STRING property, string value)

该函数原型与 PlotIndexSetInteger 类似,只是属性的类型(value 参数)为字符串。该函数仅支持 PLOT_LABEL 属性(它是 ENUM_PLOT_PROPERTY_STRING 枚举常量)。index 参数中的自定义图表索引必须介于 0 到 N - 1 之间,其中 N#property indicator_plots N 中指定的绘图总数。

使用指令时,图表索引应加 1,因为指令中绘图的编号从 1 开始,而函数参数中的编号从 0 开始。

指令函数描述
#property indicator_labelNPlotIndexSetString(N - 1, PLOT_LABEL, string)指定要在数据窗口和工具提示中显示的文本标签

对于需要多个指标缓冲区的图形序列(例如 DRAW_CANDLESDRAW_FILLING 等),标签名称使用 ; 分隔符指定。

当鼠标悬停在图表上时,标签也会显示在工具提示中。

IndLabelHighLowClose.mq5 示例中,我们添加了两个指令(与 IndPropHighLowClose.mq5 的区别)。

cpp
#property indicator_label1  "High;Low"
#property indicator_label2  "Close"

现在,在数据窗口中显示指标时,理解出现的值变得容易多了。

可视化数据间隙(空元素)

在许多情况下,指标读数应该仅在某些柱线上显示,而其余柱线保持不变(从视觉上看,没有额外的线条或标签)。例如,许多信号指标会在出现买入或卖出建议的柱线上显示向上或向下的箭头。但信号是很少出现的。

不显示在图表或数据窗口中的空值是使用 PlotIndexSetDouble 函数来设置的。

c++
bool PlotIndexSetDouble(int index, ENUM_PLOT_PROPERTY_DOUBLE property, double value)

该函数为指定索引处的绘图设置双精度属性。此类属性的集合总结在 ENUM_PLOT_PROPERTY_DOUBLE 枚举中,但目前它只有一个元素:PLOT_EMPTY_VALUE,它也用于设置空值。该值本身通过最后一个参数 value 传递。

作为一个具有稀少值的指标示例,我们将考虑一个分形检测器。它会在图表上标记出比其两侧 N 个相邻柱线的最高价更高的最高价(High),以及比其两侧 N 个相邻柱线的最低价更低的最低价(Low)。该指标文件名为 IndFractals.mq5

该指标将有两个缓冲区和两个 DRAW_ARROW 类型的图形绘图。

cpp
#property indicator_chart_window
#property indicator_buffers 2
#property indicator_plots   2

// 渲染设置
#property indicator_type1   DRAW_ARROW
#property indicator_type2   DRAW_ARROW
#property indicator_color1  clrBlue
#property indicator_color2  clrRed
#property indicator_label1  "Fractal Up"
#property indicator_label2  "Fractal Down"

// 指标缓冲区
double UpBuffer[];
double DownBuffer[];

FractalOrder 输入变量将允许你设置相邻柱线的数量,通过这个数量来确定最高价或最低价的极值。

cpp
input int FractalOrder = 3;

为了使箭头符号更清晰可见,我们将让箭头符号与极值之间有 10 像素的缩进。

cpp
const int ArrowShift = 10;

OnInit 函数中,声明数组作为缓冲区并将它们绑定到图形绘图上。

cpp
int OnInit()
{
    // 绑定缓冲区
    SetIndexBuffer(0, UpBuffer, INDICATOR_DATA);
    SetIndexBuffer(1, DownBuffer, INDICATOR_DATA);

    // 向上和向下箭头的字符代码
    PlotIndexSetInteger(0, PLOT_ARROW, 217);
    PlotIndexSetInteger(1, PLOT_ARROW, 218);

    // 箭头的缩进
    PlotIndexSetInteger(0, PLOT_ARROW_SHIFT, -ArrowShift);
    PlotIndexSetInteger(1, PLOT_ARROW_SHIFT, +ArrowShift);

    // 设置空值(可以省略,因为默认空值是 EMPTY_VALUE)
    PlotIndexSetDouble(0, PLOT_EMPTY_VALUE, EMPTY_VALUE);
    PlotIndexSetDouble(1, PLOT_EMPTY_VALUE, EMPTY_VALUE);

    return FractalOrder > 0? INIT_SUCCEEDED : INIT_PARAMETERS_INCORRECT;
}

请注意,默认的空值是特殊常量 EMPTY_VALUE,所以上面的 PlotIndexSetDouble 调用是可选的。

OnCalculate 处理程序中,在第一次调用时,我们用 EMPTY_VALUE 初始化两个数组,然后在柱线形成时将其赋给新的元素。进行初始化是必要的,因为分配给缓冲区的内存可能包含任意数据(无用数据)。

cpp
int OnCalculate(const int rates_total, 
                const int prev_calculated, 
                const datetime &time[],
                const double &open[],
                const double &high[],
                const double &low[],
                const double &close[],
                const long &tick_volume[],
                const long &volume[],
                const int &spread[])
{
    if(prev_calculated == 0)
    {
        // 开始时,完全填充数组
        ArrayInitialize(UpBuffer, EMPTY_VALUE);
        ArrayInitialize(DownBuffer, EMPTY_VALUE);
    }
    else
    {
        // 在新的柱线上,我们也要清空元素
        for(int i = prev_calculated; i < rates_total; ++i)
        {
            UpBuffer[i] = EMPTY_VALUE;
            DownBuffer[i] = EMPTY_VALUE;
        }
    }
   ...

在主循环中,逐柱比较最高价和最低价与相邻柱线的同类型价格,并在每侧的 FractalOrder 个柱线中找到极值的位置设置标记。

cpp
    // 查看所有或新的柱线,这些柱线在 FractalOrder 范围内有柱线
    for(int i = fmax(prev_calculated - FractalOrder - 1, FractalOrder);
        i < rates_total - FractalOrder; ++i)
    {
        // 检查最高价是否高于相邻柱线
        UpBuffer[i] = high[i];
        for(int j = 1; j <= FractalOrder; ++j)
        {
            if(high[i] <= high[i + j] || high[i] <= high[i - j])
            {
                UpBuffer[i] = EMPTY_VALUE;
                break;
            }
        }
        
        // 检查最低价是否低于相邻柱线
        DownBuffer[i] = low[i];
        for(int j = 1; j <= FractalOrder; ++j)
        {
            if(low[i] >= low[i + j] || low[i] >= low[i - j])
            {
                DownBuffer[i] = EMPTY_VALUE;
                break;
            }
        }
    }
    
    return rates_total;
}

让我们看看这个指标在图表上的样子。

分形指标

现在让我们将绘图类型从 DRAW_ARROW 更改为 DRAW_ZIGZAG,并比较两种选项下空值的效果。结果应该是在分形上绘制出一个之字形折线。修改后的指标版本在 IndFractalsZigZag.mq5 文件中。

主要的变化之一涉及图表的数量:现在是一个,因为 DRAW_ZIGZAG “占用” 了两个缓冲区。

cpp
#property indicator_chart_window
#property indicator_buffers 2
#property indicator_plots   1

// 渲染设置
#property indicator_type1   DRAW_ZIGZAG
#property indicator_color1  clrMediumOrchid
#property indicator_width1  2
#property indicator_label1  "ZigZag Up;ZigZag Down"
...

所有与设置箭头相关的函数调用都从 OnInit 中移除了。

cpp
int OnInit()
{
    SetIndexBuffer(0, UpBuffer, INDICATOR_DATA);
    SetIndexBuffer(1, DownBuffer, INDICATOR_DATA);
    
    PlotIndexSetDouble(0, PLOT_EMPTY_VALUE, EMPTY_VALUE);
    
    return FractalOrder > 0? INIT_SUCCEEDED : INIT_PARAMETERS_INCORRECT;
}

其余的源代码保持不变。

下面的图片展示了一个图表,在这个图表上除了分形之外还应用了之字形折线:这样,你可以直观地比较它们的结果。两个指标完全独立工作,但由于算法相同,找到的极值也是相同的。

基于分形的之字形指标

需要注意的是,如果连续出现相同类型的极值,之字形折线会使用其中的第一个极值。这是因为分形被用作极值的结果。当然,在标准的之字形折线中不会出现这种情况。如果有需要,有兴趣的人可以先对分形序列进行细化,从而改进算法。

还应该注意的是,对于 DRAW_ZIGZAG(以及 DRAW_SECTION)的渲染,可见的线段连接的是非空元素,因此,严格来说,在每个柱线上都会绘制线段的一部分,包括那些值为 EMPTY_VALUE(或其他指定的空值)的柱线。然而,你可以在数据窗口中看到,空元素确实是空的:它们没有显示任何值。

独立子窗口中的指标:大小与水平位置

到目前为止,我们仅探讨了在主图表窗口中运行的指标,即带有 #property indicator_chart_window 指令的指标。现在,是时候研究一下放置在价格图表下方独立子窗口中的指标了。请记住,此类指标应使用 #property indicator_separate_window 指令进行声明。

我们之前所学的一切同样适用于子窗口中的指标,包括描述和固定缓冲区、设置绘图类型和样式,以及可以选择使用完整或简化形式的 OnCalculate 函数。不过,它们也有一些特性和额外的设置。

由于子窗口有自己的值刻度,MQL5 允许为其设置最大值和最小值(用户可以在指标设置对话框的“刻度”选项卡中设置类似的限制)。这可以通过 IndicatorSetDouble 函数以编程方式实现,该函数有以下两种原型:

c++
bool IndicatorSetDouble(ENUM_CUSTOMIND_PROPERTY_DOUBLE property, double value)
bool IndicatorSetDouble(ENUM_CUSTOMIND_PROPERTY_DOUBLE property, int modifier, double value)

该函数用于为指标设置双精度属性值。之所以需要两种形式,是因为某些属性可能有多个,特别是水平位置线(稍后会详细讨论)。可用的属性收集在 ENUM_CUSTOMIND_PROPERTY_DOUBLE 枚举中。

标识符描述
INDICATOR_MINIMUM垂直轴上的最小值
INDICATOR_MAXIMUM垂直轴上的最大值
INDICATOR_LEVELVALUE水平位置线的值(编号在 modifier 参数中设置)

许多振荡指标(如威廉指标(WPR))会使用固定的刻度范围。我们将通过它来看一个示例,涵盖本节中的所有函数(属性)。

若函数执行成功则返回 true,否则返回 false

除了控制刻度,如我们所知,子窗口中的指标还可以有水平位置线。要设置其数量和属性,需使用另一个函数 IndicatorSetInteger。用户也可以在指标设置对话框的“水平位置线”选项卡中执行类似操作。

c++
bool IndicatorSetInteger(ENUM_CUSTOMIND_PROPERTY_INTEGER property, int value)
bool IndicatorSetInteger(ENUM_CUSTOMIND_PROPERTY_INTEGER property, int modifier, int value)

该函数同样有两种形式,可用于为指标设置 int 类型或等效类型(如颜色或枚举)的属性值。可用的属性收集在 ENUM_CUSTOMIND_PROPERTY_INTEGER 枚举中。除了与水平位置线相关的属性外,它还包含 INDICATOR_DIGITS 属性,这是所有类型指标通用的属性,我们将在下一节讨论。

标识符描述
INDICATOR_DIGITS指标值的显示精度(小数点后的位数)
INDICATOR_HEIGHT指标自身窗口的固定高度(以像素为单位,使用预处理器命令 #property indicator_height
INDICATOR_LEVELS指标窗口中水平位置线的数量
INDICATOR_LEVELCOLOR水平位置线的颜色(为 color 类型,modifier 参数设置水平位置线的编号)
INDICATOR_LEVELSTYLE水平位置线的样式(为 ENUM_LINE_STYLE 类型,modifier 参数设置水平位置线的编号)
INDICATOR_LEVELWIDTH水平位置线的粗细(1 - 5)(modifier 参数设置水平位置线的编号)

水平位置线可以有文本标签。要为其分配标签,可使用 IndicatorSetString 函数。

c++
bool IndicatorSetString(ENUM_CUSTOMIND_PROPERTY_STRING property, string value)
bool IndicatorSetString(ENUM_CUSTOMIND_PROPERTY_STRING property, int modifier, string value)

ENUM_CUSTOMIND_PROPERTY_STRING 包含指标的字符串参数列表。请注意,INDICATOR_SHORTNAME 属性与水平位置线无关,它也是所有指标通用的,将在下一节讨论。

标识符描述
INDICATOR_SHORTNAME指标的公开标题
INDICATOR_LEVELTEXT水平位置线的描述(编号在 modifier 中指定)

所有提到的用于 intdouble 数值类型的函数都有对应的特殊指令(以下是总结表格)。

水平位置线属性的指令等效函数属性类型描述
indicator_levelNIndicatorSetDouble(INDICATOR_LEVELVALUE, N - 1, value)double垂直轴上第 N 条水平位置线的值
indicator_levelcolorIndicatorSetInteger(INDICATOR_LEVELCOLOR, N - 1, color)color水平位置线的颜色(不同编号的水平位置线只能使用函数设置不同颜色)
indicator_levelwidthIndicatorSetInteger(INDICATOR_LEVELWIDTH, N - 1, width)int水平位置线的像素粗细(不同编号的水平位置线只能使用函数设置不同粗细)
indicator_levelstyleIndicatorSetInteger(INDICATOR_LEVELSTYLE, N - 1, style)ENUM_LINE_STYLE水平位置线的样式(不同编号的水平位置线只能使用函数设置不同样式)
indicator_minimumIndicatorSetDouble(INDICATOR_MINIMUM, minimum)double固定的最小值,垂直轴上的刻度下限
indicator_maximumIndicatorSetDouble(INDICATOR_MAXIMUM, maximum)double固定的最大值,垂直轴上的刻度上限

请注意,使用 #property 指令时,属性实例(修饰符)的编号从 1 开始,而函数使用的编号从 0 开始。

细心的读者会注意到,有些属性没有对应的指令。这些属性包括 INDICATOR_LEVELTEXTINDICATOR_SHORTNAMEINDICATOR_DIGITS。假定这些属性应根据输入变量和指标所在的图表,从 MQL 代码中动态填充。INDICATOR_LEVELS 可通过指定多个水平位置线的指令间接设置。

最后,子窗口中指标的一个显著特点是,程序可以“固定”其窗口的垂直大小。

子窗口大小的指令等效函数描述
indicator_heightIndicatorSetInteger(INDICATOR_HEIGHT, height)指标子窗口的固定高度(以像素为单位,用户将无法更改高度)

固定的子窗口高度通常仅用于使用图形对象实现的控制面板(按钮、标志、输入字段)。

遗憾的是,属性设置函数没有对应的获取函数(IndicatorGetIntegerIndicatorGetDoubleIndicatorGetString)。这也导致无法确定用户更改后的水平位置线的数量和值。

作为使用固定刻度和水平位置线的示例,我们来看 IndWPR.mq5 指标。在这个指标中,我们将使用标准的 WPR 算法:在给定数量的过去 K 线(WPR 周期)内,找出价格的最高价 H 和最低价 L(即价格范围)。然后,计算当前价格 C 与最低价 L 的差值 C - L(或差值 -(H - C),带负号)与整个价格范围的比率,并将结果转换到 0 到 -100 的范围内。以下是计算 WPR 的标准公式:

plaintext
R% = (-(H - C) / (H - L)) * 100

让我们在源代码开头添加一些指令。除了将指标放置在独立窗口的属性外,还设置值刻度范围为 0 到 -100。

cpp
#property indicator_separate_window
#property indicator_maximum    0.0
#property indicator_minimum    -100.0

一个缓冲区和一个折线图就足以存储值并显示指标。

cpp
#property indicator_buffers    1
#property indicator_plots      1
#property indicator_type1      DRAW_LINE
#property indicator_color1     clrDodgerBlue

在 WPR 指标中,通常会划分出两条水平位置线:-20 和 -80,分别作为超买和超卖区域的边界。让我们为它们创建两条水平位置线。

cpp
#property indicator_level1     -20.0
#property indicator_level2     -80.0
#property indicator_levelstyle STYLE_DOT
#property indicator_levelcolor clrSilver
#property indicator_levelwidth 1

唯一的输入变量允许设置 WPR 的计算周期。

cpp
input int WPRPeriod = 14; // 周期

缓冲区数组在全局级别声明,并在 OnInit 函数中注册。

cpp
double WPRBuffer[];

void OnInit()
{
    // 检查输入是否正确
    if (WPRPeriod < 1)
    {
        Alert(StringFormat("周期值 (%d) 不正确。应大于等于 1", WPRPeriod));
    }

    // 将数组绑定为缓冲区
    SetIndexBuffer(0, WPRBuffer);
}

OnInit 处理函数声明为 void 类型,这意味着隐式初始化成功。但是,如果周期设置小于 1,则无法进行计算,并会向用户发出警告。

为了简化 OnCalculate 函数的头文件,为指标准备了 IndCommon.mqh 头文件,其中包含两个宏,用于描述事件处理函数两种形式的标准参数列表。

cpp
#define ON_CALCULATE_STD_FULL_PARAM_LIST \
const int rates_total,     \
const int prev_calculated, \
const datetime &time[],    \
const double &open[],      \
const double &high[],      \
const double &low[],       \
const double &close[],     \
const long &tick_volume[], \
const long &volume[],      \
const int &spread[]

#define ON_CALCULATE_STD_SHORT_PARAM_LIST \
const int rates_total,     \
const int prev_calculated, \
const int begin,           \
const double &data[]

现在,我们可以在这个指标和其他指标中使用简洁的 OnCalculate 定义(前提是我们对宏中提议的参数名称满意)。

cpp
#include <MQL5Book/IndCommon.mqh>

int OnCalculate(ON_CALCULATE_STD_FULL_PARAM_LIST)
{
    if (rates_total < WPRPeriod || WPRPeriod < 1) return 0;
    ...
    return rates_total;
}

OnCalculate 函数开始时,我们检查是否可以使用当前的 WPRPeriodrates_total 值进行计算。如果数据不足或周期过短,则返回 0,这将使指标窗口为空。

接下来,我们用空值填充前几个无法计算给定周期 WPR 的 K 线。

cpp
int OnCalculate(ON_CALCULATE_STD_FULL_PARAM_LIST)
{
    ...
    if (prev_calculated == 0)
    {
        ArrayFill(WPRBuffer, 0, WPRPeriod - 1, EMPTY_VALUE);
    }
    ...
}

最后,我们进行 WPR 计算并将结果存储在缓冲区中。请注意,最后一根 K 线会在每个报价更新时更新:这通过从 prev_calculated - 1 开始循环实现。

cpp
int OnCalculate(ON_CALCULATE_STD_FULL_PARAM_LIST)
{
    ...
    for (int i = fmax(prev_calculated - 1, WPRPeriod - 1);
         i < rates_total && !IsStopped(); i++)
    {
        double max_high = high[fmax(ArrayMaximum(high, i - WPRPeriod + 1, WPRPeriod), 0)];
        double min_low = low[fmax(ArrayMinimum(low, i - WPRPeriod + 1, WPRPeriod), 0)];
        if (max_high != min_low)
        {
            WPRBuffer[i] = -(max_high - close[i]) * 100 / (max_high - min_low);
        }
        else
        {
            WPRBuffer[i] = WPRBuffer[i - 1];
        }
    }
    return rates_total;
}

ArrayMaximumArrayMinimum 函数可用于查找最高价和最低价的索引。

该指标在独立窗口中的显示如下:

WPR 指标

WPR 指标

在接下来的章节中,我们将继续改进这个指标,逐步添加其他常用属性。

指标的通用属性:标题和数值精度

对于所有指标,都支持一些重要属性,这些属性虽与计算无关,但能提升用户体验。在 OnInit 处理程序中正确设置这些属性,已成为指标开发标准的一部分。

可以使用之前讨论过的 IndicatorSetInteger 函数来设置整数属性 INDICATOR_DIGITS,该属性会影响图表和数据窗口中实数的显示精度。默认情况下,终端会输出小数点后 6 位数字。若指标读数与当前交易品种的价格相关,那么将此属性设置为价格显示精度是合理的,即 IndicatorSetInteger(INDICATOR_DIGITS, _Digits)

以威廉指标(WPR)为例,其数值类似于百分比,因此将显示值限制为小数点后两位是合理的。

cpp
IndicatorSetInteger(INDICATOR_DIGITS, 2);

另一个常用属性是字符串属性 INDICATOR_SHORTNAME,需使用 IndicatorSetString 函数来设置。这是指标的标题,会显示在工具提示中,若指标有独立窗口,还会显示在子窗口的左上角。若未明确指定,将使用指标文件的名称。例如,在上一节的截图中,我们看到的标题是 IndWPR

通常会在指标标题中显示主要输入变量和操作模式(若有多种模式)。

例如,对于 WPR 指标,通常会在标题中包含用户选择的周期。

此外,标题可以进行简化。这很重要,因为标题长度限制为 63 个字符。

对于更新后的 WPR 指标,我们将使用以下设置:

cpp
IndicatorSetString(INDICATOR_SHORTNAME, "%R" + "(" + (string)WPRPeriod + ")");

在给超买和超卖区域设置不同颜色后(见示例 IndColorWPR.mq5),我们将在下一节中验证这些改进的效果。

逐点图表着色

除了之前在 ENUM_DRAW_TYPE 中列出的标准绘图类型外,平台还提供了可以为每根 K 线的值单独着色的变体。为此,需要使用一个额外的指标缓冲区来存储颜色编号。这些编号对应于一个特殊数组中的元素,该数组包含程序员定义的一组颜色。颜色的最大数量为 64 种。

以下表格列出了支持颜色设置的 ENUM_DRAW_TYPE 元素,以及绘制它们所需的缓冲区数量,其中包括一个用于存储颜色索引的缓冲区。

可视化类型描述缓冲区数量
DRAW_COLOR_LINE多色线条1 + 1
DRAW_COLOR_SECTION多色线段1 + 1
DRAW_COLOR_ARROW多色箭头1 + 1
DRAW_COLOR_HISTOGRAM从零线开始的多色直方图1 + 1
DRAW_COLOR_HISTOGRAM2两个指标缓冲区成对值之间的多色直方图2 + 1
DRAW_COLOR_ZIGZAG多色锯齿线2 + 1
DRAW_COLOR_BARS多色柱状图4 + 1
DRAW_COLOR_CANDLES多色蜡烛图4 + 1

在将缓冲区绑定到图表时,要注意额外的颜色缓冲区必须在 SetIndexBuffer 的第一个参数中指定,其编号应紧跟在数据缓冲区之后。例如,若要使用一个数据缓冲区和一个颜色缓冲区为线条着色,数据缓冲区编号为 0,颜色缓冲区编号为 1:

cpp
double ColorLineData[];
double ColorLineColors[];

void OnInit()
{
    SetIndexBuffer(0, ColorLineData, INDICATOR_DATA);
    SetIndexBuffer(1, ColorLineColors, INDICATOR_COLOR_INDEX);
    PlotIndexSetInteger(0, PLOT_DRAW_TYPE, DRAW_COLOR_LINE);
    ...
}

可以通过 #property indicator_colorN 指令为图表 N 指定调色板中的初始颜色集。该指令使用逗号分隔所需的颜色,可以使用命名常量或颜色字面量。例如,在指标中使用以下代码可以为第 0 个图表(指令中的编号从 1 开始)选择 6 种标准颜色进行着色:

cpp
#property indicator_color1   clrRed,clrBlue,clrGreen,clrYellow,clrMagenta,clrCyan

在程序中,后续只需指定颜色的索引,而不是实际要显示图形构造的颜色。调色板中的编号与普通数组一样,从 0 开始。因此,如果需要为第 i 根 K 线设置绿色,只需在颜色缓冲区中设置调色板中绿色的索引,在这种情况下是 2。

cpp
ColorLineColors[i] = 2; // 引用颜色为 clrGreen 的元素

用于着色的颜色集并非一成不变,可以使用 PlotIndexSetInteger(index, PLOT_LINE_COLOR, color) 函数动态更改。

例如,要将上述调色板中的 clrGreen 颜色替换为 clrGray,可使用以下调用:

cpp
PlotIndexSetInteger(0, PLOT_LINE_COLOR, clrGray);

让我们在 WPR 指标中应用着色功能。新文件为 IndColorWPR.mq5,更改涉及以下几个方面。

缓冲区数量增加了 1 个,颜色从 1 种变为 3 种。

cpp
#property indicator_buffers    2
#property indicator_plots      1
#property indicator_type1      DRAW_COLOR_LINE
#property indicator_color1     clrDodgerBlue,clrGreen,clrRed

添加了一个新数组用于颜色缓冲区,并在 OnInit 函数中进行注册。

cpp
double WPRColors[];

void OnInit()
{
    ...
    SetIndexBuffer(1, WPRColors, INDICATOR_COLOR_INDEX);
    ...
}

如果不设置 INDICATOR_COLOR_INDEX 缓冲区类型(即使用 SetIndexBuffer(1, WPRColors) 调用,默认会将其视为 INDICATOR_DATA),它将在数据窗口中可见。

OnCalculate 函数的工作循环内,根据第 i 根 K 线的值分析添加着色逻辑。默认情况下,使用索引为 0 的颜色,即之前的 clrDodgerBlue。如果指标读数进入上区域,则用颜色 2(clrRed)突出显示;如果进入下区域,则用颜色 1(clrGreen)着色。

cpp
int OnCalculate(ON_CALCULATE_STD_FULL_PARAM_LIST)
{
    ...
    for (int i = fmax(prev_calculated - 1, WPRPeriod - 1);
         i < rates_total && !IsStopped(); i++)
    {
        ...
        WPRColors[i] = 0;
        if (WPRBuffer[i] > -20) WPRColors[i] = 2;
        else if (WPRBuffer[i] < -80) WPRColors[i] = 1;
    }
    return rates_total;
}

在屏幕上的显示效果如下:

带超买和超卖区域着色的 WPR 指标

带超买和超卖区域着色的 WPR 指标

请注意,如果线段的终点(K 线)位于上区域或下区域,该线段将用替代颜色绘制。在这种情况下,前一个读数可能位于中间区域,这可能会让人觉得颜色设置有误。但这是符合当前实现和平台颜色使用方式的正确行为。

DRAW_COLOR_LINE 折线图中相邻两根 K 线之间线段的颜色由右侧(最近)的 K 线颜色决定。

如果只想对相邻两根 K 线都在同一区域的线段进行颜色突出显示,可以将代码修改为:

cpp
WPRColors[i] = 0;
if (WPRBuffer[i] > -20 && WPRBuffer[i - 1] > -20) WPRColors[i] = 2;
else if (WPRBuffer[i] < -80 && WPRBuffer[i - 1] < -80) WPRColors[i] = 1;

此外,记得我们在源代码中添加了标题设置和值表示精度(2 位小数)的设置。对比新旧图像,你会注意到这些视觉差异。特别是,标题现在显示为 "%R(14)",垂直值刻度变得更加紧凑。

我们在 IndColorWPR.mq5 指标中要更改的最后一个方面是跳过初始 K 线的绘制。

跳过初始柱线的绘制

在很多情形下,依据算法条件,指标数值的计算无法从第一个(最左侧可用)柱线开始,因为需要确保历史数据中有最少指定数量的前序柱线。例如,许多平滑处理类型都意味着当前值的计算要用到前 N 个柱线的价格数组。

在这种情况下,可能无法在最开始的柱线上计算指标值,或者这些值不打算显示在图表上,仅用于辅助计算后续的值。

若要禁止指标在历史数据的前 N - 1 个柱线上显示,可将 PLOT_DRAW_BEGIN 属性为相应的图形绘图索引设置为 N,即 PlotIndexSetInteger(index, PLOT_DRAW_BEGIN, N)。默认情况下,此属性值为 0,这意味着从一开始就显示数据。

我们可以通过将必要柱线设置为空值(默认是 EMPTY_VALUE)来禁止线条显示。不过,调用 PlotIndexSetInteger 函数并设置 PLOT_DRAW_BEGIN 属性有不同作用。我们借此告知外部程序指标缓冲区中前几个无意义值的数量。特别是,其他可能基于我们指标的时间序列构建的指标,会在其 OnCalculate 处理程序的 begin 参数中获取 PLOT_DRAW_BEGIN 属性的值,从而有机会跳过这些柱线。

IndColorWPR.mq5 指标示例里,我们在 OnInit 函数中添加类似设置:

cpp
input int WPRPeriod = 14; // 周期

void OnInit()
{
    ...
    PlotIndexSetInteger(0, PLOT_DRAW_BEGIN, WPRPeriod - 1);
    ...
}

现在,在 OnCalculate 函数中,可以移除对初始柱线的强制清空操作,因为它们总会被隐藏:

cpp
if(prev_calculated == 0)
{
    ArrayFill(WPRBuffer, 0, WPRPeriod - 1, EMPTY_VALUE);
}

但这仅在用户手动选择我们的指标作为另一个指标的时间序列源时能正常工作。如果有程序员想在其开发中使用我们的指标,会有不同的数据获取机制(我们将在下一章讨论),这种机制无法获取 PLOT_DRAW_BEGIN 属性。所以,最好使用显式的缓冲区初始化。

为展示如何在另一个基于我们指标数据计算的指标中使用此属性,我们准备另一个指标,即著名的三重指数移动平均线(Triple Exponential Moving Average)算法,封装在 IndTripleEMA.mq5 指标中。完成后,就可以轻松将其应用于价格时间序列和任意指标,如之前的 IndColorWPR.mq5 指标。

此外,我们会了解为计算描述辅助缓冲区(INDICATOR_CALCULATIONS)的技术可能性。

三重 EMA 公式包含多个计算步骤。对初始时间序列 T 进行周期为 P 的简单指数平滑可表示为:

plaintext
K = 2.0 / (P + 1)
A[i] = T[i] * K + A[i - 1] * (1 - K)

其中,K 是考虑原始序列元素的权重因子,在给定周期 P 后计算得出;(1 - K) 是应用于平滑序列 A 元素的惯性系数。为得到序列 A 的第 i 个元素,我们将原始序列 T[i] 的第 i 个元素的 K 部分与前一个元素 A[i - 1](1 - K) 部分相加。

若将上述公式表示的平滑操作记为 E 运算符,那么三重 EMA 正如其名,需应用三次 E 操作,然后以特殊方式组合得到的三个平滑序列:

c++
EMA1 = E(A, P), 对所有 i
EMA2 = E(EMA1, P), 对所有 i
EMA3 = E(EMA2, P), 对所有 i
TEMA = 3 * EMA1 - 3 * EMA2 + EMA3, 对所有 i

与相同周期的常规 EMA 相比,三重 EMA 对原始序列的滞后更小。不过,它的响应性更强,这可能导致结果线出现不规则情况并给出错误信号。

EMA 平滑处理从序列的第二个元素开始就能得到大致的平均值估算,且无需更改算法。这是 EMA 与其他平滑方法的区别,其他方法需要前 P 个元素,或者在可用元素少于 P 时需要修改初始样本的算法。一些开发者即使使用 EMA,也倾向于使平滑序列的前 P - 1 个元素无效。但需注意,在 EMA 公式中,序列过去元素的影响不限于 P 个元素,只有当元素数量趋于无穷大时,这种影响才会变得微不足道(在其他著名的 MA 算法中,恰好是前 P 个元素有影响)。

为探究跳过初始数据的影响,在本书中我们不会禁止输出初始 EMA 值。

为计算三个 EMA 级别,我们需要辅助缓冲区,再加上一个用于最终序列的缓冲区,它将以折线图形式显示:

cpp
#property indicator_chart_window
#property indicator_buffers 4
#property indicator_plots   1

#property indicator_type1   DRAW_LINE
#property indicator_color1  Orange
#property indicator_width1  1
#property indicator_label1  "EMA³"

double TemaBuffer[];
double Ema[];
double EmaOfEma[];
double EmaOfEmaOfEma[];

void OnInit()
{
    ...
    SetIndexBuffer(0, TemaBuffer, INDICATOR_DATA);
    SetIndexBuffer(1, Ema, INDICATOR_CALCULATIONS);
    SetIndexBuffer(2, EmaOfEma, INDICATOR_CALCULATIONS);
    SetIndexBuffer(3, EmaOfEmaOfEma, INDICATOR_CALCULATIONS);
    ...
}

输入变量 InpPeriodEMA 可用于设置平滑周期。第二个变量 InpHandleBegin 是一个模式开关,通过它我们可以探究指标在 OnCalculate 处理程序中考虑或忽略 begin 参数时的反应。可用模式总结在 BEGIN_POLICY 枚举中,含义如下(按排列顺序):

  • STRICT:根据 begin 严格偏移。
  • CUSTOM:自定义验证初始数据,不考虑 begin
  • NONE:不处理,即忽略 begin 并直接对所有数据进行计算。
cpp
enum BEGIN_POLICY
{
    STRICT, // 严格
    CUSTOM, // 自定义
    NONE    // 不处理
};

input int InpPeriodEMA = 14;                 // EMA 周期
input BEGIN_POLICY InpHandleBegin = STRICT;  // 处理 'begin' 参数

第二个 CUSTOM 模式基于对每个源元素与 EMPTY_VALUE 的初步比较,并将其替换为适合算法的值。这仅适用于那些诚实地初始化缓冲区未使用部分而不留下无用数据的指标。我们的 IndColorWPR 指标按要求填充了缓冲区,所以可以预期 STRICTCUSTOM 模式的结果几乎相同。

基于 InpPeriodEMA 为计算 EMA 准备常量 K

cpp
const double K = 2.0 / (InpPeriodEMA + 1);

EMA 函数本身很简单(这里省略了 CUSTOM 变体中对 EMPTY_VALUE 检查的保护片段):

cpp
void EMA(const double &source[], double &result[], const int pos, const int begin = 0)
{
    ...
    if(pos <= begin)
    {
        result[pos] = source[pos];
    }
    else
    {
        result[pos] = source[pos] * K + result[pos - 1] * (1 - K);
    }
}

以下是 OnCalculate 中完整的三重平滑计算:

cpp
int OnCalculate(const int rates_total,
                const int prev_calculated,
                const int begin,
                const double &price[])
{
    const int _begin = InpHandleBegin == STRICT ? begin : 0;
    // 全新启动或历史数据更新
    if(prev_calculated == 0)
    {
        Print("begin=", begin, " ", EnumToString(InpHandleBegin));

        // 我们可以动态更改图表设置
        PlotIndexSetInteger(0, PLOT_DRAW_BEGIN, _begin);

        // 初始化数组
        ArrayInitialize(Ema, EMPTY_VALUE);
        ArrayInitialize(EmaOfEma, EMPTY_VALUE);
        ArrayInitialize(EmaOfEmaOfEma, EMPTY_VALUE);
        ArrayInitialize(TemaBuffer, EMPTY_VALUE);
        Ema[_begin] = EmaOfEma[_begin] = EmaOfEmaOfEma[_begin] = price[_begin];
    }

    // 主循环,考虑从 _begin 开始
    for(int i = fmax(prev_calculated - 1, _begin);
        i < rates_total && !IsStopped(); i++)
    {
        EMA(price, Ema, i, _begin);
        EMA(Ema, EmaOfEma, i, _begin);
        EMA(EmaOfEma, EmaOfEmaOfEma, i, _begin);

        if(InpHandleBegin == CUSTOM) // 防止初始空元素
        {
            if(Ema[i] == EMPTY_VALUE
            || EmaOfEma[i] == EMPTY_VALUE
            || EmaOfEmaOfEma[i] == EMPTY_VALUE)
                continue;
        }

        TemaBuffer[i] = 3 * Ema[i] - 3 * EmaOfEma[i] + EmaOfEmaOfEma[i];
    }
    return rates_total;
}

在首次启动或历史数据更新时,接收到的 begin 参数值以及用户选择的处理模式会被写入日志。

成功编译后,就可以进行实验了。

首先,运行 IndColorWPR 指标(默认其周期为 14,根据源代码,由于索引从 0 开始,所以 PLOT_DRAW_BEGIN 属性会被设置为 13,第 13 个柱线将是第一个有值出现的柱线)。然后将 IndTripleEMA 指标拖到显示 WPR 的子窗口中。在打开的属性设置对话框的“选项”选项卡中,在“应用于”下拉列表中选择“上一个指标数据”。在“输入”选项卡中保留默认值。

以下图像展示了图表的开头部分。日志中会有如下记录:begin=13 STRICT

考虑数据起始点的三重 EMA 指标应用于 WPR

注意,平均线与 WPR 一样,从离起始点有一定距离的位置开始。

注意:用于指标计算的可用柱线数量 rates_total(或 iBars(_Symbol, _Period))可能会超过终端设置中允许的图表上最大柱线数量,如果有更长的本地报价历史。在这种情况下,WPR 线(或任何其他跳过前几个元素的指标,如 MA)开头的空元素将变得不可见,它们会被图表的左边界遮挡。若要重现初始柱线上没有线条的情况,要么需要增加图表上的柱线数量,要么关闭终端并删除特定交易品种的本地历史数据。

现在,在 IndTripleEMA 指标设置中切换到 CUSTOM 模式(日志将显示 begin=0 CUSTOM)。指标读数应该不会有太大变化。

最后,激活 NONE 模式。日志将输出:begin=0 NONE

此时,图表上的情况会看起来很奇怪,因为线条实际上会消失。在数据窗口中,可以看到元素值非常大。

不考虑数据起始点的三重 EMA 指标应用于 WPR

这是因为 EMPTY_VALUE 等于最大实数 DBL_MAX。因此,不考虑 begin 参数时,使用这些值进行计算也会生成非常大的数字。根据计算的具体情况,溢出可能会导致我们得到特殊的 NaN(非数字,参见“检查实数的正常性”)。其中一个 -nan(ind) 在图像中被突出显示(数据窗口已经知道如何输出某些类型的 NaN,例如 inf-inf,但目前还不包括 -nan(ind))。如我们所知,这样的 NaN 值很危险,因为涉及它们的计算会继续产生 NaN。如果没有生成 NaN,那么随着在柱线上向右移动,大数计算中的“过渡过程”会逐渐减弱(由于 EMA 公式中的缩减因子 (1 - K)),结果会稳定下来并变得合理。如果将图表滚动到当前时间,就会看到正常的三重 EMA。

考虑 begin 参数是一种良好的实践,但这并不能保证数据提供者(如果是第三方指标)正确填充了此属性。因此,最好在代码中提供一些保护措施。在 IndTripleEMA 的这个实现中,保护措施在初始级别实现。

如果我们在价格图表上运行 IndTripleEMA 指标,总是会得到 begin = 0,因为价格时间序列从最开始的柱线就填充了真实数据。

等待数据与管理可见性(DRAW_NONE

在上一章 “使用 MqlTick 结构体中的实时报价数组” 部分,我们使用了脚本 SeriesTicksDeltaVolume.mq5,它用于计算每根 K 线的成交量差(Delta 成交量)。当时,我们将结果显示在日志中,但分析此类技术信息更为方便且合理的方式是使用指标。在本节中,我们将创建这样一个指标 —— IndDeltaVolume.mq5

在这里,我们必须处理在开发指标时经常遇到但在之前的示例中未讨论过的两个因素。

第一个因素是,报价数据不属于终端在 OnCalculate 参数中发送给指标的标准价格时间序列。这意味着指标本身必须请求这些数据,并在能够在窗口中显示内容之前等待。

第二个因素与这样一个事实有关,即通常情况下,买入和卖出的成交量远大于它们的成交量差,并且当在一个窗口中显示时,很难区分成交量差。然而,成交量差是一个指示性数值,通常会与价格走势一起进行分析。例如,K 线和成交量差配置有 4 种最明显的组合:

  • 阳线且正成交量差 = 上涨趋势的确认
  • 阴线且负成交量差 = 下跌趋势的确认
  • 阳线且负成交量差 = 可能出现下跌反转
  • 阴线且正成交量差 = 可能出现上涨反转

为了查看成交量差的直方图,我们需要提供一种禁用 “大” 直方图(买入和卖出成交量)的模式,为此我们将使用 DRAW_NONE 类型。它会禁用特定绘图的绘制,并防止其对自动选择的窗口刻度产生影响(但会在数据窗口中保留缓冲区)。因此,通过不考虑大的绘图,我们将为剩余的成交量差图表实现更大的自动缩放。另一种通过将缓冲区标记为辅助(INDICATOR_CALCULATIONS 模式)来隐藏缓冲区的方法将在下一节讨论。

成交量差的概念是分别计算报价中的买入和卖出成交量,然后我们可以求出这些成交量之间的差值。相应地,我们得到了三个时间序列,分别是买入成交量、卖出成交量以及它们之间的差值。由于这些信息不适合用价格刻度来表示,所以该指标应显示在其自身的窗口中,并且我们将选择从零线开始的直方图(DRAW_HISTOGRAM)作为显示这三个时间序列的方式。

根据这一点,让我们用指令描述指标的属性:位置、缓冲区和绘图的数量,以及它们的类型。

cpp
#property indicator_separate_window
#property indicator_buffers 3
#property indicator_plots   3
#property indicator_type1   DRAW_HISTOGRAM
#property indicator_color1  clrBlue
#property indicator_width1  1
#property indicator_label1  "Buy"
#property indicator_type2   DRAW_HISTOGRAM
#property indicator_color2  clrRed
#property indicator_width2  1
#property indicator_label2  "Sell"
#property indicator_type3   DRAW_HISTOGRAM
#property indicator_color3  clrMagenta
#property indicator_width3  3
#property indicator_label3  "Delta"

我们将使用之前脚本中的输入变量。由于报价数据量相当大,我们将限制用于计算历史数据的 K 线数量(BarCount)。此外,根据特定金融工具的报价中是否存在实际成交量,我们可以用两种不同的方式计算成交量差,为此我们将使用报价类型参数(COPY_TICKS 枚举在头文件 TickEnum.mqh 中定义,我们在脚本中已经使用过该头文件)。

cpp
#include <MQL5Book/TickEnum.mqh>

input int BarCount = 100;
input COPY_TICKS TickType = INFO_TICKS;
input bool ShowBuySell = true;

OnInit 处理函数中,我们根据用户选择的 ShowBuySell 参数,在 DRAW_HISTOGRAMDRAW_NONE 之间切换前两个直方图的操作模式(默认 true 表示显示所有三个直方图)。请注意,通过 PlotIndexSetInteger 进行的动态配置会覆盖使用 #property 指令嵌入可执行文件中的静态设置(在这种情况下,只是其中一些设置)。

cpp
int OnInit()
{
    PlotIndexSetInteger(0, PLOT_DRAW_TYPE, ShowBuySell? DRAW_HISTOGRAM : DRAW_NONE);
    PlotIndexSetInteger(1, PLOT_DRAW_TYPE, ShowBuySell? DRAW_HISTOGRAM : DRAW_NONE);

    return INIT_SUCCEEDED;
}

但是指标缓冲区的注册在哪里呢?我们将在接下来的几段中再回到这个问题。现在让我们开始准备 OnCalculate 函数。

cpp
int OnCalculate(ON_CALCULATE_STD_FULL_PARAM_LIST)
{
    if (prev_calculated == 0)
    {
        // TODO(1): 初始化,用零填充
    }

    // 在每次新的 K 线或首次运行时的一组新 K 线
    if (prev_calculated != rates_total)
    {
        // 处理所有或新的 K 线
        for (int i = fmax(prev_calculated, fmax(1, rates_total - BarCount));
             i < rates_total &&!IsStopped(); ++i)
        {
            // TODO(2): 尝试获取数据并计算第 i 根 K 线,
            // 如果失败,做些什么!
        }
    }
    else // 当前 K 线的报价
    {
        // TODO(3): 更新当前 K 线
    }

    return rates_total;
}

主要的技术问题在于标记为 TODO(2) 的代码块。在脚本中使用的、并将以最小改动移植到指标中的报价请求算法,使用 CopyTicksRange 函数来请求报价数据。这样的调用会返回报价数据库中可用的数据。但是,如果对于给定的历史 K 线,数据尚未可用,该请求会导致报价数据以异步方式(在后台模式下)下载并同步。在这种情况下,调用代码会收到 0 条报价数据。因此,在收到这样的 “空” 响应时,指标应该以失败(但不是错误)的标志中断计算,并在一段时间后重新请求报价数据。在正常的公开市场情况下,我们会定期收到报价数据,所以 OnCalculate 函数可能很快会被调用,并使用更新后的报价数据基础重新计算。但是在周末没有报价数据时该怎么办呢?

为了正确处理这种情况,MQL5 提供了一个定时器。我们将在后续的某一章中学习它,但目前我们将把它当作一个 “黑匣子” 来使用。特殊的 EventSetTimer 函数 “请求” 内核在指定的秒数后调用我们的 MQL 程序。这样的调用的入口点是一个保留的 OnTimer 处理函数,我们在 “事件处理函数概述” 部分的总表中已经见过它。因此,如果在接收报价数据时出现延迟,应该使用 EventSetTimer 启动定时器(最小周期 1 秒就足够了),并从 OnCalculate 返回 0。

cpp
int OnCalculate(ON_CALCULATE_STD_FULL_PARAM_LIST)
{
   ...
    for (int i = fmax(prev_calculated, fmax(1, rates_total - BarCount));
         i < rates_total &&!IsStopped(); ++i)
    {
        // TODO(2): 尝试获取数据并计算第 i 根 K 线,
        if (/*如果没有数据*/)
        {
            Print("K 线 ", i, " 上没有数据,时间为 ", TimeToString(time[i]),
                  ". 设置定时器以刷新...");
            EventSetTimer(1); // 请在 1 秒后调用我们
            return 0; // 目前不在窗口中显示任何内容
        }
    }
   ...
}

OnTimer 处理函数中,我们使用 EventKillTimer 函数来停止定时器(如果不这样做,系统将继续每秒调用我们的处理函数)。此外,我们需要以某种方式启动指标的重新计算。为此,我们将应用另一个我们尚未在图表章节中学习的函数 —— ChartSetSymbolPeriod(请参阅 “切换交易品种和时间周期” 部分)。它允许为具有给定标识符的图表设置交易品种和时间周期的新组合(0 表示当前图表)。然而,如果通过传递 _Symbol_Period(请参阅预定义变量)不改变它们,那么图表将简单地被更新(指标将被重新计算)。

cpp
void OnTimer()
{
    EventKillTimer();
    ChartSetSymbolPeriod(0, _Symbol, _Period); // 图表自动更新
}

这里需要注意的另一点是,在公开市场中,如果在下一次 OnTimer 调用之前出现了下一条报价数据,定时器事件和图表自动更新可能是多余的。因此,我们将创建一个全局变量(calcDone)来切换计算准备就绪的标志。在 OnCalculate 开始时,我们将其重置为 false;在正常计算完成时,我们将其设置为 true

cpp
bool calcDone = false;

int OnCalculate(ON_CALCULATE_STD_FULL_PARAM_LIST)
{
    calcDone = false;
   ...
    if (/*如果没有数据*/)
    {
       ...
        return 0; // 以 calcDone = false 退出
    }
   ...
    calcDone = true;
    return rates_total;
}

然后在 OnTimer 中,我们只有在 calcDone 等于 false 时才可以启动图表自动更新。

cpp
void OnTimer()
{
    EventKillTimer();
    if (!calcDone)
    {
        ChartSetSymbolPeriod(0, _Symbol, _Period);
    }
}

现在让我们转到 TODO(1,2,3) 注释部分,我们应该在那里执行计算并填充指标缓冲区。我们将在一个 CalcDeltaVolume 类中组合所有这些操作。因此,每个操作将分配一个单独的方法,同时我们将保持 OnCalculate 处理函数简单(方法调用将代替注释出现)。

在类中,我们将提供成员变量,这些变量将接受用户对处理的历史 K 线数量和成交量差计算方法的设置,以及用于指标缓冲区的三个数组。我们将在构造函数中初始化它们。

cpp
class CalcDeltaVolume
{
    const int limit;
    const COPY_TICKS tickType;

    double buy[];
    double sell[];
    double delta[];

public:
    CalcDeltaVolume(
        const int bars,
        const COPY_TICKS type)
        : limit(bars), tickType(type), lasttime(0), lastcount(0)
    {
        // 将内部数组注册为指标缓冲区
        SetIndexBuffer(0, buy);
        SetIndexBuffer(1, sell);
        SetIndexBuffer(2, delta);
    }

我们可以将成员数组指定为缓冲区,因为我们接下来要创建这个类的全局对象。为了正确显示数据,我们只需要确保在绘制时附加到图表的数组存在即可。可以动态更改缓冲区绑定(请参阅下一节中 IndSubChartSimple.mq5 的示例)。

请注意,指标缓冲区必须是 double 类型,而成交量是 ulong 类型。因此,对于非常大的值(例如,在非常大的时间周期上),理论上可能会有精度损失。

已经创建了 reset 方法来初始化缓冲区。数组的大多数元素用空值 EMPTY_VALUE 填充,最后 limit 根 K 线用零填充,因为在那里我们将分别累加买入和卖出的成交量。

cpp
void reset()
{
    // 填充买入数组,并从它复制其余部分
    // 除了最后 limit 根 K 线为 0 外,所有元素为空值
    ArrayInitialize(buy, EMPTY_VALUE);
    ArrayFill(buy, ArraySize(buy) - limit, limit, 0);

    // 将初始状态复制到其他数组
    ArrayCopy(sell, buy);
    ArrayCopy(delta, buy);
}

i 根历史 K 线的计算由 createDeltaBar 方法执行。它的输入接受 K 线编号以及指向包含 K 线时间戳的数组的引用(我们作为 OnCalculate 参数接收它)。数组的第 i 个元素初始化为 0。

cpp
int createDeltaBar(const int i, const datetime &time[])
{
    delta[i] = buy[i] = sell[i] = 0;
   ...

然后我们需要确定第 i 根 K 线的时间限制:prevnext,其中 next 通过添加我们新接触的 PeriodSeconds 函数的值来计算,即 prev 右侧的时间。该函数返回当前时间周期的秒数。通过加上这个时间量,我们找到了下一根 K 线的理论起始时间。在历史数据中,当 i 不等于最后一根 K 线的编号时,我们可以用 time[i + 1] 来代替找到 next 时间戳。然而,指标也应该在仍在形成过程中的最后一根 K 线上起作用,而这根 K 线没有下一根 K 线。因此,一般来说,禁止使用 time[i + 1]

cpp
...
const datetime prev = time[i];
const datetime next = prev + PeriodSeconds();

当我们在脚本中进行类似计算时,我们不必使用 PeriodSeconds 函数,因为我们不计算最后一根当前 K 线,并且可以分别使用 iTime(WorkSymbol, TimeFrame, i)iTime(WorkSymbol, TimeFrame, i + 1) 来找到 nextprev

此外,在 createDeltaBar 方法中,我们在找到的时间戳范围内请求报价数据(从右侧时间减去 1 毫秒,以免触及下一根 K 线)。报价数据存储在 ticks 数组中,由辅助方法 calc 处理。它包含的脚本算法几乎没有变化。我们不得不将其分离成一个指定的方法,因为计算将在两种不同的情况下进行:使用历史 K 线(记住 TODO(2) 注释)和使用当前 K 线的报价(TODO(3) 注释)。我们将在下面考虑第二种情况。

cpp
ResetLastError();
MqlTick ticks[];
const int n = CopyTicksRange(_Symbol, ticks, COPY_TICKS_ALL,
    prev * 1000, next * 1000 - 1);
if (n > -1 && _LastError == 0)
{
    calc(i, ticks);
}
else
{
    return -_LastError;
}
return n;

如果请求成功,该方法返回处理的报价数据数量;如果出现错误,则返回带有负号的错误代码。请注意,如果数据库中尚未有该 K 线的报价数据(严格来说,这不是错误,但它不允许指标的可视化操作继续进行),该方法将返回 0(0 的符号不会改变其值)。因此,在 OnCalculate 函数中,我们需要检查该方法的结果是否 “小于或等于” 0。

calc 方法实际上由脚本 SeriesTicksDeltaVolume.mq5 的有效代码行组成,所以我们这里不再展示。如果有人想回顾一下,可以查看 IndDeltaVolume.mq5

为了计算不断更新的最后一根 K 线上的成交量差,我们需要以毫秒精度固定最后处理的报价的时间戳。然后,在下一次调用 OnCalculate 时,我们将能够查询此标签之后的所有报价数据。

请注意,不能保证系统会实时在每一条报价数据到达时都调用我们的 OnCalculate 处理函数。如果我们执行繁重的计算,或者如果某些其他 MQL 程序因计算而使终端负载过重,或者如果报价数据到达非常快(例如,在重要新闻发布之后),事件可能无法进入指标队列(队列中每种类型的事件最多存储一个,包括最多一个报价通知)。因此,如果程序想要获取所有报价数据,它必须使用 CopyTicksRangeCopyTicks 请求它们。

然而,仅靠最后处理的报价的时间戳是不够的。即使考虑到毫秒,报价数据也可能具有相同的时间。因此,我们不能在标签上加上 1 毫秒来排除 “旧” 的报价数据:具有相同标签的 “新” 报价数据可能会在它之后出现。

因此,我们不仅应该记住标签,还应该记住具有该标签的最后报价数据的数量。然后,下次我们请求报价数据时,我们可以从记住的时间开始请求(即包括 “旧” 的报价数据),但要准确跳过上次已经处理的数量。

为了实现这个算法,在类中声明了两个变量 lasttimelastcount

cpp
ulong lasttime; // 最后处理的在线报价的毫秒标记
int lastcount;  // 此时具有此标签的报价数量

从系统接收的报价数组中,我们使用辅助方法 updateLastTime 找到这些变量的值。

cpp
void updateLastTime(const int n, const MqlTick &ticks[])
{
    lasttime = ticks[n - 1].time_msc;
    lastcount = 0;
    for (int k = n - 1; k >= 0; --k)
    {
        if (ticks[k].time_msc == ticks[n - 1].time_msc) ++lastcount;
    }
}

现在我们可以完善 createDeltaBar

多货币和多时间框架指标

到目前为止,我们所讨论的指标都是基于当前图表品种的报价或tick数据进行运算的。但有时,需要对多个金融产品进行分析,或者分析一个与当前品种不同的产品。在这种情况下,正如我们在tick分析中所看到的,通过OnCalculate参数传递给指标的标准时间序列是不够用的。此时,就需要以某种方式请求“外部”报价,等待其构建完成,然后再基于这些报价来计算指标。

请求并构建与当前图表时间框架不同的报价,其机制与处理其他品种的机制并无差异。所以,在本节中,我们将探讨多货币指标的创建,而多时间框架指标也可以依照类似的原则来构建。

我们需要解决的问题之一是K线在时间上的同步。特别是对于不同的品种,可能存在不同的交易时间表、周末等情况,总体而言,主图表上的K线编号和“外部”品种报价中的K线编号可能不同。

首先,我们简化一下任务,仅考虑一个任意的品种,它可能与当前品种不同。交易者常常需要同时查看多个不同品种的图表(例如,相关品种对中的领先品种和跟随品种)。我们来创建IndSubChartSimple.mq5指标,用于在子窗口中显示用户所选品种的报价。

IndSubChartSimple

为了与主图表的外观保持一致,我们不仅要在输入参数中指定品种,还要指定绘图模式:DRAW_CANDLESDRAW_BARSDRAW_LINE。前两种模式需要四个缓冲区,它们会输出全部四个价格:开盘价(Open)、最高价(High)、最低价(Low)和收盘价(Close)(日本蜡烛图或K线),而最后一种模式则使用单个缓冲区来显示收盘价的线条。为了支持所有模式,我们将使用所需的最大缓冲区数量。

c++
#property indicator_separate_window
#property indicator_buffers 4
#property indicator_plots   1
#property indicator_type1   DRAW_CANDLES
#property indicator_color1  clrBlue,clrGreen,clrRed // 边框色、阳线色、阴线色

用于缓冲区的数组通过价格类型名称来描述。

c++
double open[];
double high[];
double low[];
double close[];

默认开启日本蜡烛图显示。在这种模式下,MQL5允许指定多个颜色,而不只是一个。在#property indicator_colorN指令中,颜色用逗号分隔。如果有两种颜色,那么第一种颜色决定蜡烛图的轮廓颜色,第二种颜色决定填充颜色。如果有三种颜色,就像我们这里的情况,第一种颜色决定轮廓颜色,第二种和第三种颜色分别决定阳线和阴线的实体颜色。

在关于图表的章节中,我们会了解ENUM_CHART_MODE枚举,它描述了三种可用的图表绘制模式。

名称元素
ENUM_CHART_MODE 元素ENUM_DRAW_TYPE 元素
CHART_CANDLESDRAW_CANDLES
CHART_BARSDRAW_BARS
CHART_LINEDRAW_LINE

这些元素与我们选择的绘图模式相对应,因为我们有意选择了与标准模式相同的绘图方法。这里使用ENUM_CHART_MODE很方便,因为它只包含我们需要的三个元素,而ENUM_DRAW_TYPE有很多其他的绘图方法。

因此,输入变量的定义如下。

c++
input string SubSymbol = ""; // 品种
input ENUM_CHART_MODE Mode = CHART_CANDLES;

实现了一个简单的函数,用于将ENUM_CHART_MODE转换为ENUM_DRAW_TYPE

c++
ENUM_DRAW_TYPE Mode2Style(const ENUM_CHART_MODE m)
{
   switch(m)
   {
      case CHART_CANDLES: return DRAW_CANDLES;
      case CHART_BARS: return DRAW_BARS;
      case CHART_LINE: return DRAW_LINE;
   }
   return DRAW_NONE;
}

SubSymbol输入参数中的空字符串表示当前图表的品种。然而,由于MQL5不允许编辑输入变量,我们需要添加一个全局变量来存储实际使用的品种,并在OnInit处理程序中进行赋值。

c++
string symbol;
...
int OnInit()
{
   symbol = SubSymbol;
   if(symbol == "") symbol = _Symbol;
   else
   {
      // 确保品种存在并在市场报价窗口中被选中
      if(!SymbolSelect(symbol, true))
      {
         return INIT_PARAMETERS_INCORRECT;
      }
   }
   ...
}

我们还需要检查用户输入的品种是否存在,并将其添加到市场报价窗口中,这可以通过SymbolSelect函数来完成,我们将在关于品种的章节中学习这个函数。

为了统一缓冲区和图表的设置,源代码中有几个辅助函数:

  • InitBuffer - 设置一个缓冲区
  • InitBuffers - 设置整个缓冲区集
  • InitPlot - 设置一个图表

单独的函数将注册相同实体时重复的多个操作组合在一起。它们也为在关于图表的章节中进一步开发此指标开辟了道路:我们将支持根据用户对图表的操作交互式更改绘图设置(请参阅章节“图表显示模式”中完整版本的指标IndSubChart.mq5)。

c++
void InitBuffer(const int index, double &buffer[],
   const ENUM_INDEXBUFFER_TYPE style = INDICATOR_DATA,
   const bool asSeries = false)
{
   SetIndexBuffer(index, buffer, style);
   ArraySetAsSeries(buffer, asSeries);
}
   
string InitBuffers(const ENUM_CHART_MODE m)
{
   string title;
   if(m == CHART_LINE)
   {
      InitBuffer(0, close, INDICATOR_DATA, true);
      // 隐藏折线图未使用的所有缓冲区
      InitBuffer(1, high, INDICATOR_CALCULATIONS, true);
      InitBuffer(2, low, INDICATOR_CALCULATIONS, true);
      InitBuffer(3, open, INDICATOR_CALCULATIONS, true);
      title = symbol + " Close";
   }
   else
   {
      InitBuffer(0, open, INDICATOR_DATA, true);
      InitBuffer(1, high, INDICATOR_DATA, true);
      InitBuffer(2, low, INDICATOR_DATA, true);
      InitBuffer(3, close, INDICATOR_DATA, true);
      title = "# Open;# High;# Low;# Close";
      StringReplace(title, "#", symbol);
   }
   return title;
}

请注意,当开启折线图模式时,仅使用close数组,它被分配索引0。由于INDICATOR_CALCULATIONS属性,其余三个数组对用户完全隐藏。在蜡烛图和K线模式下,使用所有四个数组,并且它们的编号符合OHLC标准,这是DRAW_CANDLESDRAW_BARS绘图类型所要求的。所有数组都被赋予“串行”属性,即从右到左进行索引。

InitBuffers函数返回数据窗口中缓冲区的标题。

所有必需的绘图属性都在InitPlot函数中设置。

c++
void InitPlot(const int index, const string name, const int style,
   const int width = -1, const int colorx = -1,
   const double empty = EMPTY_VALUE)
{
  PlotIndexSetInteger(index, PLOT_DRAW_TYPE, style);
  PlotIndexSetString(index, PLOT_LABEL, name);
  PlotIndexSetDouble(index, PLOT_EMPTY_VALUE, empty);
  if(width != -1) PlotIndexSetInteger(index, PLOT_LINE_WIDTH, width);
  if(colorx != -1) PlotIndexSetInteger(index, PLOT_LINE_COLOR, colorx);
}

OnInit处理程序中,使用新函数完成单个图表(索引为0)的初始设置。

c++
int OnInit()
{
   ...
   InitPlot(0, InitBuffers(Mode), Mode2Style(Mode));
   IndicatorSetString(INDICATOR_SHORTNAME, "SubChart (" + symbol + ")");
   IndicatorSetInteger(INDICATOR_DIGITS, (int)SymbolInfoInteger(symbol, SYMBOL_DIGITS));
     
   return INIT_SUCCEEDED;
}

虽然在这个指标版本中设置只执行一次,但它是动态进行的,会考虑到mode输入参数,这与#property指令提供的静态设置不同。在未来,在指标的完整版本中,我们可以多次调用InitPlot,“即时”更改指标的外观。

缓冲区在OnCalculate中填充。在最简单的情况下,当给定的品种与图表相同时,我们可以简单地使用以下实现。

c++
int OnCalculate(const int rates_total, const int prev_calculated,
   const datetime &time[],
   const double &op[], const double &hi[], const double &lo[], const double &cl[],
   const long &[], const long &[], const int &[]) // 未使用
{
   if(prev_calculated ==0) // 需要澄清(见下文)
   {
      ArrayInitialize(open, EMPTY_VALUE);
      ArrayInitialize(high, EMPTY_VALUE);
      ArrayInitialize(low, EMPTY_VALUE);
      ArrayInitialize(close, EMPTY_VALUE);
   }
   
   if(_Symbol != symbol)
   {
      // 正在开发
      ...
   }
   else
   {
      ArraySetAsSeries(op, true);
      ArraySetAsSeries(hi, true);
      ArraySetAsSeries(lo, true);
      ArraySetAsSeries(cl, true);
      for(int i = 0; i < MathMax(rates_total - prev_calculated, 1); ++i)
      {
         open[i] = op[i];
         high[i] = hi[i];
         low[i] = lo[i];
         close[i] = cl[i];
      }
   }
   
   return rates_total;
}

然而,在处理任意品种时,数组参数不包含所需的报价,并且可用K线的总数可能不同。此外,当首次将指标放置在图表上时,如果没有提前为“外部”品种打开另一个图表,其报价可能根本未准备好。而且,第三方品种的报价将异步加载,因此新的K线批次可能随时“到达”,需要进行完整的重新计算。

因此,让我们创建一些变量来控制另一个品种的K线数量(lastAvailable)、一个可编辑的常量参数prev_calculated的“副本”,以及一个报价准备就绪的标志。

c++
static bool initialized; // 品种报价准备就绪标志
static int lastAvailable; // 品种(和当前时间框架)的K线数量
int _prev_calculated = prev_calculated; // 可编辑的prev_calculated副本

OnCalculate开始时,我们添加一个检查,以查看是否同时出现了多个K线:我们使用lastAvailable变量,该变量在函数上一次正常退出之前(即在成功计算的情况下)根据iBars(symbol, _Period)的值进行填充。如果加载了额外的历史数据,我们应该将_prev_calculated和K线数量重置为0,并移除准备就绪标志,以便重新计算指标。

c++
int OnCalculate(const int rates_total, const int prev_calculated,
   const datetime &time[],
   const double &op[], const double &hi[], const double &lo[], const double &cl[],
   const long &[], const long &[], const int &[]) // 未使用
{
   ...
   if(iBars(symbol, _Period) - lastAvailable > 1)
   {
      // 加载额外的历史数据或首次启动
      _prev_calculated = 0;
      initialized = false;
      lastAvailable = 0;
   }
   
   // 然后在各处使用_prev_calculated的副本
   if(_prev_calculated == 0)
   {
      ArrayInitialize(open, EMPTY_VALUE);
      ArrayInitialize(high, EMPTY_VALUE);
      ArrayInitialize(low, EMPTY_VALUE);
      ArrayInitialize(close, EMPTY_VALUE);
   }
   
   if(_Symbol != symbol)
   {
      // 请求报价并“等待”它们准备好
      ...
      // 主要计算(填充缓冲区)
      ...
   }
   else
   {
      ... // 保持原样
   } 
   lastAvailable = iBars(symbol, _Period);
   return rates_total;
}

注释中的“等待”一词加引号并非偶然。我们知道,在指标中不能真正地等待(以免减慢终端的界面线程)。相反,如果数据不足,我们应该简单地退出函数。因此,“等待”意味着等待下一个计算事件:在tick到达时或响应图表更新请求时。

以下代码将检查报价是否准备好。

c++
int OnCalculate(const int rates_total, const int prev_calculated,
   const datetime &time[],
   const double &op[], const double &hi[], const double &lo[], const double &cl[],
   const long &[], const long &[], const int &[]) // 未使用
{
   ...
   if(_Symbol != symbol)
   {
      if(!initialized)
      {
         Print("Host ", _Symbol, " ", rates_total, " bars up to ", (string)time[0]);
         Print("Updating ", symbol, " ", lastAvailable, " -> ", iBars(symbol, _Period), " / ",
            (iBars(symbol, _Period) > 0 ?
               (string)iTime(symbol, _Period, iBars(symbol, _Period) - 1) : "n/a"),
            "... Please wait");
         if(QuoteRefresh(symbol, _Period, time[0]))
         {
            Print("Done");
            initialized = true;
         }
         else
         {
            // 异步请求更新图表
            ChartSetSymbolPeriod(0, _Symbol, _Period);
            return 0; // 目前没有可显示的内容
         }
      }
      ...

主要工作由特殊的QuoteRefresh函数完成。它接收所需的品种、时间框架和当前图表上第一个(最旧)K线的时间作为参数——我们对更早的日期不感兴趣,但请求的品种可能没有这么深的历史数据。这就是为什么将所有检查的复杂性隐藏在一个单独的函数中很方便。

一旦数据下载并同步到可用程度,该函数将返回true。我们稍后将研究其内部结构。

同步完成后,我们使用iBarShift函数找到同步的K线,并复制它们的OHLC值(iOpeniHighiLowiClose函数)。

c++
ArraySetAsSeries(time, true); // 从现在到过去
for(int i = 0; i < MathMax(rates_total - _prev_calculated, 1); ++i)
{
   int x = iBarShift(symbol, _Period, time[i], true);
   if(x != -1)
   {
      open[i] = iOpen(symbol, _Period, x);
      high[i] = iHigh(symbol, _Period, x);
      low[i] = iLow(symbol, _Period, x);
      close[i] = iClose(symbol, _Period, x);
   }
   else
   {
      open[i] = high[i] = low[i] = close[i] = EMPTY_VALUE;
   }
}

一种看似更高效的替代方法是使用Copy函数复制整个价格数组,但这里并不适用,因为不同品种上具有相同索引的K线可能对应不同的时间戳。因此,复制后,你必须分析日期并在缓冲区内移动元素,使其与当前图表上的时间相匹配。

由于在iBarShift函数中最后一个参数传递的是true,该函数将查找K线时间的精确匹配。如果另一个品种中没有K线,我们将得到 -1,并在图表上显示空白(EMPTY_VALUE)。

成功完成完整计算后,新的K线将以经济模式进行计算,即考虑_prev_calculatedrates_total

现在让我们来看看QuoteRefresh函数。这是一个通用且有用的函数,因此将其放在头文件QuoteRefresh.mqh中。

在一开始,我们检查是否从指标类型的MQL程序中请求当前品种和当前时间框架的时间序列。此类请求是被禁止的,因为指标运行所依赖的“原生”时间序列已经由终端构建或准备好:再次请求它可能会导致循环或阻塞。因此,我们只需返回同步标志(SERIES_SYNCHRONIZED),如果尚未准备好,指标应稍后检查数据(在下一个tick、通过定时器或其他方式)。

c++
bool QuoteRefresh(const string asset, const ENUM_TIMEFRAMES period,
   const datetime start)
{
   if(MQL5InfoInteger(MQL5_PROGRAM_TYPE) == PROGRAM_INDICATOR
      && _Symbol == asset && _Period == period)
   {
      return (bool)SeriesInfoInteger(asset, period, SERIES_SYNCHRONIZED);
   }
   ...

第二个检查涉及K线数量:如果已经达到图表上允许的最大数量,继续下载任何内容就没有意义了。

c++
if(Bars(asset, period) >= TerminalInfoInteger(TERMINAL_MAXBARS))
{
   return (bool)SeriesInfoInteger(asset, period, SERIES_SYNCHRONIZED);
}
...

接下来的代码部分按顺序从终端请求可用报价的起始日期:

  • 按给定的时间框架(SERIES_FIRSTDATE
  • 不关联时间框架(SERIES_TERMINAL_FIRSTDATE),在终端的本地数据库中
  • 不关联时间框架(SERIES_SERVER_FIRSTDATE),在服务器上

如果在任何阶段,请求的日期已经在可用数据区域内,我们将得到true作为准备就绪的标志。否则,将从终端的本地数据库或服务器请求数据,然后构建时间序列(所有这些都是异步自动响应我们的CopyTime调用完成的;也可以使用其他Copy函数)。

c++
datetime times[1];
datetime first = 0, server = 0;
if(PRTF(SeriesInfoInteger(asset, period, SERIES_FIRSTDATE, first)))
{
   if(first > 0 && first <= start)
   {
      // 应用程序数据存在,它已经准备好或正在准备中
      return (bool)SeriesInfoInteger(asset, period, SERIES_SYNCHRONIZED);
   }
   else
   if(PRTF(SeriesInfoInteger(asset, period, SERIES_TERMINAL_FIRSTDATE, first)))
   {
      if(first > 0 && first <= start)
      {
         // 终端数据库中存在技术数据,
         // 启动时间序列的构建或立即获取所需数据
         return PRTF(CopyTime(asset, period, first, 1, times)) == 1;
      }
      else
      {
         if(PRTF(SeriesInfoInteger(asset, period, SERIES_SERVER_FIRSTDATE, server)))
         {
            // 服务器上存在技术数据,我们来请求它
            if(first > 0 && first < server)
               PrintFormat(
                 "Warning: %s first date %s on server is less than on terminal ",
                  asset, TimeToString(server), TimeToString(first));
            // 不能请求超过服务器拥有的数据 - 所以取最大值
            return PRTF(CopyTime(asset, period, fmax(start, server), 1, times)) == 1;
         }
      }
   }
}
   
return false;
}

指标已经完成。我们来编译并运行它,例如在EURUSD的H1图表上,指定USDRUB作为额外的品种。日志将显示如下内容:

c++
Host EURUSD 20001 bars up to 2018.08.09 13:00:00
Updating USDRUB 0 -> 14123 / 2014.12.22 11:00:00... Please wait
SeriesInfoInteger(symbol,period,SERIES_FIRSTDATE,first)=false / HISTORY_NOT_FOUND(4401)
Host EURUSD 20001 bars up to 2018.08.09 13:00:00
Updating USDRUB 0 -> 14123 / 2014.12.22 11:00:00... Please wait
SeriesInfoInteger(symbol,period,SERIES_FIRSTDATE,first)=true / ok
Done

处理完成(显示“Done”消息)后,子窗口将显示另一个图表的蜡烛图。

IndSubChartSimple指标 - 带有第三方品种报价的DRAW_CANDLES

重要的是要注意,由于交易时段缩短,USDRUB的有效K线仅占据每个日间隔的日间部分。

IndUnityPercent

我们在本节中创建的第二个指标是一个真正的多货币(多资产)指标IndUnityPercent.mq5。其理念是显示给定金融产品中所有独立货币(资产)的相对强度。例如,如果我们交易包含两个品种EURUSD和XAUUSD的篮子,那么篮子价值中会考虑美元、欧元和黄金,这些资产中的每一个相对于其他资产都有一个相对价值。

在每个时间点,都有当前价格,用以下公式描述:

plaintext
EUR / USD = EURUSD
XAU / USD = XAUUSD

其中,变量EURUSDXAU是资产的一些独立“价值”,而EURUSDXAUUSD是常量(已知报价)。

为了求解这些变量,我们向方程组中添加另一个方程,将变量的平方和限制为1(因此指标名称中的第一个词是“Unity”):

plaintext
EUR * EUR + USD * USD + XAU * XAU = 1

可能会有更多的变量,我们可以将它们表示为xi。请注意,x0是所有产品共有的主要货币,是必需的。

那么,一般来说,计算变量的公式如下(我们将省略其推导过程):

plaintext
x0 = sqrt(1 / (1 + sum(C(xi, x0)^2))), i = 1..n
xi = C(xi, x0) * x0, i = 1..n

其中,n是变量的数量,C(xi,x0)是第i个品种对的报价。请注意,变量的数量比产品的数量多1。

由于计算中涉及的报价通常差异很大(例如,EURUSD和XAUUSD的情况),并且仅通过彼此表示(即不参考任何稳定的基准),因此从绝对值转换为百分比变化是有意义的。因此,在根据上述公式编写算法时,我们将采用报价C(xi,x0)[0] / C(xi,x0)[1]的比率,其中方括号中的索引表示当前[0]和前一个[1]K线。此外,为了加快计算速度,可以去掉平方和开方运算。

为了可视化线条,我们将提供一个最大允许的货币数量和指标缓冲区。当然,如果用户输入的品种较少,计算中可以只使用其中一些缓冲区。但不能动态增加限制,需要更改指令并重新编译指标。

c++
#define BUF_NUM 15
#property indicator_separate_window
#property indicator_buffers BUF_NUM
#property indicator_plots BUF_NUM

在实现这个指标时,我们将顺便解决一个麻烦的问题。由于会有许多相同类型的缓冲区,标准方法是通过大量“复制粘贴”来进行编码,这是不可取的。

c++
double buffer1[];
...
double buffer15[];
   
void OnInit()
{
   SetIndexBuffer(0, buffer1);
   ...
   SetIndexBuffer(14, buffer15);
}

这种方式不方便、效率低且容易出错。相反,我们采用面向对象编程(OOP)。我们将创建一个类,该类将存储指标缓冲区的数组,并负责统一设置,因为我们的缓冲区应该是相同的(除了颜色,对于构成当前图表品种的货币,线条可能会加粗,但这是在用户输入参数后进行调整的)。

有了这样的类,我们可以简单地分配一个其对象的数组,指标缓冲区将自动连接并按所需数量进行配置。示意性地,这种方法由以下伪代码说明。

c++
// 支持统一指标缓冲区数组的“引擎”代码
class Buffer
{
   static int count; // 全局缓冲区计数器
   double array[];   // 此缓冲区的数组
   int cursor;       // 分配元素的指针
public:
   // 构造函数设置并连接数组
   Buffer()
   {
      SetIndexBuffer(count++, array);
      ArraySetAsSeries(array, ...);
   }
   // 重载以设置感兴趣的元素编号
   Buffer *operator[](int index)
   {
      cursor = index;
      return &this;
   }
   // 重载以将值写入所选元素
   double operator=(double x)
   {
      buffer[cursor] = x;
      return x;
   }
   ...
};
   
static int Buffer::count;

通过运算符重载,我们可以使用熟悉的语法为缓冲区对象的元素赋值:buffer[i] = value

在指标代码中,只需定义一个“数组的数组”,而不是许多单独数组的描述行。

c++
// 指标代码
// 构造15个缓冲区对象,自动注册和配置
Buffer buffers[15];
...

实现此机制的类的完整版本可在文件IndBufArray.mqh中找到。请注意,它仅支持缓冲区,不支持图表。理想情况下,应该用新的类扩展类集,允许创建现成的图表对象,这些对象将根据特定图表的类型占用缓冲区数组中所需数量的缓冲区。我们建议你自己研究并补充该文件。特别是,代码中包含一个管理指标缓冲区数组的类BufferArray,用于创建具有相同属性值的“数组的数组”,如ENUM_INDEXBUFFER_TYPE类型、索引方向、空值。我们在新指标中如下使用它:

c++
BufferArray buffers(BUF_NUM, true);

这里,构造函数的第一个参数传递所需的缓冲区数量,第二个参数传递是否按时间序列进行索引的指示(下面会详细说明)。

定义之后,我们可以在代码的任何地方使用方便的表示法来设置第i个缓冲区的第j个K线的值(它使用缓冲区对象和缓冲区数组中[]运算符的双重重载):

c++
buffers[i][j] = value;

在指标的输入变量中,我们允许用户指定一个用逗号分隔的品种列表,并限制历史计算的K线数量,以控制潜在大量产品的加载和同步。如果你决定显示整个可用历史,应该识别并应用不同产品可用的最小K线数量,并控制从服务器加载额外的历史数据。

c++
input string Instruments = "EURUSD,GBPUSD,USDCHF,USDJPY,AUDUSD,USDCAD,NZDUSD";
input int BarLimit = 500;

程序启动时,解析品种列表并形成一个单独的Symbols数组,其大小为SymbolCount

c++
string Symbols[];
int direction[]; // 与通用货币的直接(+1)/反向(-1)汇率
int SymbolCount;

所有品种必须有相同的通用货币(通常是美元),以便揭示相互关联。根据特定品种中的通用货币是基础货币(在外汇对中排在第一位)还是报价货币(在外汇对中排在第二位),计算中使用其直接或反向报价(1.0 / 汇率)。这个方向将存储在Direction数组中。

让我们看看执行上述操作的InitSymbols函数。如果列表解析成功,它将返回通用货币的名称。内置的SymbolInfoString函数允许获取任何金融产品的基础货币和报价货币,我们将在关于金融产品的章节中学习它。

c++
string InitSymbols()
{
   SymbolCount = fmin(StringSplit(Instruments, ',', Symbols), BUF_NUM - 1);
   ArrayResize(Symbols, SymbolCount);
   ArrayResize(Direction, SymbolCount);
   ArrayInitialize(Direction, 0);
   
   string common = NULL; // 通用货币
   
   for(int i = 0; i < SymbolCount; i++)
   {
      // 确保品种在市场报价窗口中存在
      if(!SymbolSelect(Symbols[i], true)) 
      {
         Print("Can't select ", Symbols[i]);
         return NULL;
      }
      
      // 获取构成品种的货币
      string first, second;
      first = SymbolInfoString(Symbols[i], SYMBOL_CURRENCY_BASE);
      second = SymbolInfoString(Symbols[i], SYMBOL_CURRENCY_PROFIT);
    
      // 统计每种货币的出现次数
      if(first != second)
      {
         workCurrencies.inc(first);
         workCurrencies.inc(second);
      }
      else
      {
         workCurrencies.inc(Symbols[i]);
      }
   }
   ...

循环使用辅助模板类MapArray跟踪每种货币在所有产品中的出现次数。这样的对象在指标的全局级别进行描述,需要包含头文件MapArray.mqh

c++
#include <MQL5Book/MapArray.mqh>
...
// 键值对数组 [名称; 数量]
// 用于计算货币使用统计
MapArray<string,int> workCurrencies;
...
string InitSymbols()
{
   ...
}

由于这个类起辅助作用,这里不详细描述。你可以查看源代码以获取更多详细信息。其要点是,当为新的货币名称调用其inc方法时,它会将其添加到内部数组中,计数器的初始值为1,如果该名称已经出现过,则计数器加1。

随后,我们将计数器大于1的货币确定为通用货币。如果设置正确,其余货币应该恰好出现一次。以下是InitSymbols函数的继续部分。

c++
...   
// 根据货币使用统计找到通用货币
for(int i = 0; i < workCurrencies.getSize(); i++)
{
   if(workCurrencies[i] > 1) // 计数器大于1
   {
      if(common == NULL)
      {
         common = workCurrencies.getKey(i); // 获取第i种货币的名称
      }
      else
      {
         Print("Collision: multiple common symbols");
         return NULL;
      }
   }
}
   
if(common == NULL) common = workCurrencies.getKey(0);
   
// 知道通用货币后,确定每个品种的“方向”
for(int i = 0; i < SymbolCount; i++)
{
   if(SymbolInfoString(Symbols[i], SYMBOL_CURRENCY_PROFIT) == common)
      Direction[i] = +1;
   else if(SymbolInfoString(Symbols[i], SYMBOL_CURRENCY_BASE) == common)
      Direction[i] = -1;
   else
   {
      Print("Ambiguous symbol direction ", Symbols[i], ", defaults used");
      Direction[i] = +1;
   }
}
   
return common;
}

有了InitSymbols函数,我们可以编写OnInit(简化版)。

c++
int OnInit()
{
   const string common = InitSymbols();
   if(common == NULL) return INIT_PARAMETERS_INCORRECT;
   
   string base = SymbolInfoString(_Symbol, SYMBOL_CURRENCY_BASE);
   string profit = SymbolInfoString(_Symbol, SYMBOL_CURRENCY_PROFIT);
   
   // 根据货币数量(品种数量 + 1)设置线条
   for(int i = 0; i <= SymbolCount; i++)
   {
      string name = workCurrencies.getKey(i);
      PlotIndexSetString(i, PLOT_LABEL, name);
      PlotIndexSetInteger(i, PLOT_DRAW_TYPE, DRAW_LINE);
      PlotIndexSetInteger(i, PLOT_SHOW_DATA, true);
      PlotIndexSetInteger(i, PLOT_LINE_WIDTH, 1 + (name == base || name == profit));
   }
  
   // 隐藏数据窗口中多余的缓冲区
   for(int i = SymbolCount + 1; i < BUF_NUM; i++)
   {
      PlotIndexSetInteger(i, PLOT_SHOW_DATA, false);
   }
  
   // 在1.0处设置一个水平参考线
   IndicatorSetInteger(INDICATOR_LEVELS, 1);
   IndicatorSetDouble(INDICATOR_LEVELVALUE, 0, 1.0);
  
   // 带有参数的名称
   IndicatorSetString(INDICATOR_SHORTNAME,
      "Unity [" + (string)workCurrencies.getSize() + "]");
  
   // 精度
   IndicatorSetInteger(INDICATOR_DIGITS, 5);
   
   return INIT_SUCCEEDED;
}

现在让我们来看看主要的事件处理程序OnCalculate

重要的是要注意,主循环中遍历K线的顺序是反向的,就像在时间序列中一样,从现在到过去。这种方法对于多货币指标更方便,因为不同品种的历史深度可能不同,从当前K线往回计算直到任何一个品种没有数据的第一个时刻是有意义的。在这种情况下,提前终止循环不应被视为错误,我们应该返回rates_total以在图表上显示已经计算好的最相关K线的值。

然而,在这个简化版的IndUnityPercent中,我们不这样做,而是采用一种更简单、更严格的方法:用户必须使用BarLimit参数定义无条件的历史查询深度。换句话说,对于所有品种,必须有数据直到图表品种上编号为BarLimit的K线的时间戳。否则,指标将尝试下载缺失的数据。

c++
int OnCalculate(const int rates_total,
                const int prev_calculated,
                const int begin,
                const double& price[])
{
   if(prev_calculated == 0)
   {
      buffers.empty(); // 将全部清理工作委托给BufferArray类
   }
  
   // 主循环,按照“时间序列”的方向从现在到过去
   const int limit = MathMin(rates_total - prev_calculated + 1, BarLimit);
   for(int i = 0; i < limit; i++)
   {
      if(!calculate(i))
      {
         EventSetTimer(1); // 再给1秒时间来上传和准备数据
         return 0; // 下次调用时再尝试重新计算
      }
   }
   
   return rates_total;
}

Calculate函数(见下文)计算第i个K线上所有缓冲区的值。如果数据缺失,它将返回false,我们将启动一个定时器,以便有时间为所有所需产品构建时间序列。在定时器处理程序中,我们将以通常的方式向终端发送更新图表的请求。

c++
void OnTimer()
{
   EventKillTimer();
   ChartSetSymbolPeriod(0, _Symbol, _Period);
}

Calculate 函数中,我们首先确定当前 K 线和前一根 K 线的日期范围,将根据这个范围来计算变化。

c++
bool Calculate(const int bar)
{
   const datetime time0 = iTime(_Symbol, _Period, bar);
   const datetime time1 = iTime(_Symbol, _Period, bar + 1);
   ...

需要这两个日期来调用下一个函数 CopyClose 的特定版本,在这个版本中会指定日期区间。在这个指标中,我们不能使用基于 K 线数量的选项,因为任何一个品种都可能存在与其他品种不同的任意 K 线间隔。例如,如果一个品种上有 K 线 t(当前)和 t-1(前一个),那么就可以正确计算变化值 Close[t]/Close[t-1]。然而,在另一个品种上,K 线 t 可能不存在,并且请求两根 K 线将返回左边“最近”的 K 线(在过去),而这个过去可能与“现在”相差很远(例如,如果该品种不是全天候交易,可能对应于前一天的交易时段)。

为了避免这种情况,指标严格在指定区间内请求报价,如果对于某个特定品种,该区间为空,这意味着没有变化。

同时,可能会出现这样的情况,即这种查询返回的 K 线数量可能超过两根,在这种情况下,总是取最后两根(右边)的 K 线作为最相关的 K 线。例如,当放置在 USDRUB 的 H1 图表上时,指标会“看到”每个工作日 17:00 的 K 线之后,会有下一个工作日 10:00 的 K 线。然而,对于像 EURUSD 这样的主要外汇货币对,在它们之间会有 16 根晚上、夜间和早上的 H1 K 线。

c++
bool Calculate(const int bar)
{
   ...
   double w[]; // 接收报价的数组(按 K 线)
   double v[]; // 特征变化值
   ArrayResize(v, SymbolCount);
   
   // 找到每个品种的报价变化
   for(int j = 0; j < SymbolCount; j++)
   {
      // 尝试获取第 j 个品种至少 2 根 K 线,
      // 对应于当前图表品种的两根 K 线
      int x = CopyClose(Symbols[j], _Period, time0, time1, w);
      if(x < 2)
      {
         // 如果没有 K 线,尝试从过去获取前一根 K 线
         if(CopyClose(Symbols[j], _Period, time0, 1, w) != 1)
         {
            return false;
         }
         // 然后将其复制以表示没有变化
         // (原则上,可以两次写入任何常量)
         x = 2;
         ArrayResize(w, 2);
         w[1] = w[0];
      }
   
      // 在需要时找到反向汇率
      if(Direction[j] == -1)
      {
         w[x - 1] = 1.0 / w[x - 1];
         w[x - 2] = 1.0 / w[x - 2];
      }
   
      // 计算变化值,即两个值的比率
      v[j] = w[x - 1] / w[x - 2]; // 最后一根 / 前一根
   }
   ...

当获取到变化值后,算法按照前面给出的公式进行计算,并将值写入指标缓冲区。

c++
   double sum = 1.0;
   for(int j = 0; j < SymbolCount; j++)
   {
      sum += v[j];
   }
   
   const double base_0 = (1.0 / sum);
   buffers[0][bar] = base_0 * (SymbolCount + 1);
   for(int j = 1; j <= SymbolCount; j++)
   {
      buffers[j][bar] = base_0 * v[j - 1] * (SymbolCount + 1);
   }
   
   return true;
}

让我们看看在默认设置下,该指标在一组基本外汇产品上的运行情况(在首次放置时,如果之前没有为这些产品打开图表,可能需要花费一些时间来接收时间序列)。

包含主要外汇货币的多货币指标 IndUnityPercent

指标窗口中两种货币线条之间的距离等于相应报价的百分比变化(在两个连续的收盘价 Close 之间)。这就是指标名称中第二个词“Percent”的由来。

在下一章关于指标的编程使用中,我们将介绍 IndUnityPercentPro.mq5 的高级版本,在这个版本中,Copy 函数将被调用内置指标 iMA 所取代,这将使我们能够轻松实现对任意价格类型的平滑处理和计算,而无需任何额外的工作。

跟踪K线形成

上一节讨论的 IndUnityPercent.mq5 指标在每一个报价时都会对最后一根K线进行重新计算,因为它使用了收盘价。一些指标和智能交易系统是以更节省资源的方式专门开发的,即每根K线只进行一次计算。例如,我们可以根据开盘价来计算Unity的公式,这样的话就可以跳过报价数据。有几种方法可以检测新的K线:

  1. 记住当前第0根K线的时间(通过 OnCalculate 函数的 time 参数 —— time[0],或者一般来说,iTime(symbol, period, 0)),并等待其变化。
  2. 记住K线数量 rates_total(或 iBars(symbol, period)),并对增加1做出反应(向任何一个方向变化为不同的数量都值得怀疑,可能表明历史数据被修改了)。
  3. 等待一根报价成交量等于1的K线(该K线的第一个报价)。

然而,由于指标的多货币特性,新K线形成的概念变得不那么明确了。

在每个交易品种上,下一根K线会在其自身的报价到达时出现,而且它们的到达时间通常不同。在这种情况下,指标开发者必须决定如何操作:是等待所有交易品种上出现时间相同的K线,还是在任何一个交易品种上出现新K线后,对最后几根K线多次重新计算指标。

在本节中,我们将引入一个简单的类 MultiSymbolMonitor(见文件 MultiSymbolMonitor.mqh),用于根据给定的交易品种列表跟踪新K线的形成。

所需的时间周期可以传递给类的构造函数。默认情况下,它会跟踪程序运行所在的当前图表的时间周期。

cpp
class MultiSymbolMonitor
{
protected:
    ENUM_TIMEFRAMES period;

public:
    MultiSymbolMonitor(): period(_Period) {}
    MultiSymbolMonitor(const ENUM_TIMEFRAMES p): period(p) {}
   ...

为了存储跟踪的交易品种列表,我们将使用上一节中的辅助类 MapArray。在这个数组中,我们将写入 [交易品种名称; 最后一根K线的时间戳] 对,即模板类型 <string, datetime>attach 方法用于填充该数组。

cpp
protected:
    MapArray<string,datetime> lastTime;
...
public:
    void attach(const string symbol)
    {
        lastTime.put(symbol, NULL);
    }

对于给定的数组,该类可以在 check 方法中通过在交易品种循环中调用 iTime 函数来更新和检查时间戳。

cpp
ulong check(const bool refresh = false)
{
    ulong flags = 0;
    for(int i = 0; i < lastTime.getSize(); i++)
    {
        const string symbol = lastTime.getKey(i);
        const datetime dt = iTime(symbol, period, 0);

        if(dt != lastTime[symbol]) // 有变化吗?
        {
            flags |= 1 << i;
        }

        if(refresh) // 更新时间戳
        {
            lastTime.put(symbol, dt);
        }
    }
    return flags;
}

调用代码应该自行决定何时调用 check 方法,通常是在报价到达时或通过定时器调用。严格来说,这两种选择都不能对其他交易品种上报价(和新K线)的出现提供即时反应,因为 OnCalculate 事件只在图表的工作交易品种的报价时出现,如果在这些报价之间有其他交易品种的报价,我们在下次 “自己的” 报价之前都不会知道。

我们将在关于交互式图表事件的章节中考虑对多个交易品种的报价进行实时监控(请参阅 “自定义事件生成” 部分的间谍指标 EventTickSpy.mq5)。

目前,我们将以可用的精度检查K线。那么,让我们继续看 check 方法。

每个时间点都由为数组中所有交易品种设置的时间戳的各自状态来表征。例如,新的K线可能只在流动性最强的交易品种上在12:00形成,而对于其他几个交易品种,报价可能会在几毫秒甚至几秒后出现。在这个时间间隔内,数组中的一个元素将被更新,其余的仍然是旧的。然后,所有交易品种将逐渐都出现12:00的K线。

对于最后一根K线的开盘时间与保存的时间不相等的所有交易品种,该方法会设置该交易品种编号对应的位,从而形成一个带有变化的位掩码。该列表中包含的交易品种不能超过64个。

如果返回值为零,则表示没有注册到变化。

refresh 参数指定 check 方法是只注册变化(false),还是根据当前市场情况更新状态(true)。

describe 方法允许通过位掩码获取已变化的交易品种列表。

cpp
string describe(ulong flags = 0)
{
    string message = "";
    if(flags == 0) flags = check();
    for(int i = 0; i < lastTime.getSize(); i++)
    {
        if((flags & (1 << i)) != 0)
        {
            message += lastTime.getKey(i) + "\t";
        }
    }
    return message;
}

接下来,我们将使用 inSync 方法来确定数组中的所有交易品种是否具有相同的最后一根K线时间。只有对于具有相同交易时段的一组货币,使用这个方法才有意义。

cpp
bool inSync() const
{
    if(lastTime.getSize() == 0) return false;
    const datetime first = lastTime[0];
    for(int i = 1; i < lastTime.getSize(); i++)
    {
        if(first != lastTime[i]) return false;
    }
    return true;
}

使用上述描述的类,我们实现了一个简单的多货币指标 IndMultiSymbolMonitor.mq5,它唯一的任务是检测给定交易品种列表的新K线。

由于该指标不提供绘图功能,所以缓冲区和图表的数量为0。

cpp
#property indicator_chart_window
#property indicator_buffers 0
#property indicator_plots   0

交易品种列表在相应的输入变量中指定,然后转换为在监控对象中注册的数组。

cpp
input string Instruments = "EURUSD,GBPUSD,USDCHF,USDJPY,AUDUSD,USDCAD,NZDUSD";

#include <MQL5Book/MultiSymbolMonitor.mqh>

MultiSymbolMonitor monitor;

void OnInit()
{
    string symbols[];
    const int n = StringSplit(Instruments, ',', symbols);
    for(int i = 0; i < n; ++i)
    {
        monitor.attach(symbols[i]);
    }
}

OnCalculate 处理函数在报价时调用监控器,并将状态变化输出到日志中。

cpp
int OnCalculate(const int rates_total,
                const int prev_calculated,
                const int begin,
                const double &price[])
{
    const ulong changes = monitor.check(true);
    if(changes != 0)
    {
        Print("新K线出现在: ", monitor.describe(changes),
              ", 同步状态:", monitor.inSync());
    }
    return rates_total;
}

要检查这个指标,我们需要在终端上花费大量的在线时间。然而,MetaTrader 5 提供了一种更简单的方法 —— 借助测试器。我们将在下一节中进行介绍。

测试指标

MetaTrader 5 内置的测试器支持两种类型的 MQL 程序:智能交易系统(Expert Advisors)和指标(Indicators)。指标总是在可视化窗口中进行测试。但这仅适用于对独立指标的测试。如果指标是由智能交易系统以编程方式创建和调用的,那么这个智能交易系统连同指标可以根据用户的意愿在无可视化的情况下进行测试。我们将在下一章学习从 MQL 代码中使用指标的技术。同样的技术也将用于与智能交易系统的集成。

同时,指标开发者应该注意,在无可视化的情况下,测试器对从智能交易系统调用的指标使用一种加速计算方法。数据并非在每个报价时都进行计算,而是仅在从指标缓冲区请求相关数据时才计算(请参阅 CopyBuffer 函数)。

如果指标在当前报价时尚未进行计算,那么在首次访问其数据时会计算一次。如果在同一报价期间产生了其他请求,则会以已计算好的形式返回数据。如果在当前报价时未读取指标缓冲区,则不会对指标进行计算。指标的按需计算在测试和优化过程中能显著提高速度。

如果某个指标需要精确计算且不能跳过报价,MQL5 可以指示测试器在每个报价时都重新计算指标。这可以通过以下指令来实现:

cpp
#property tester_everytick_calculate

该指令中的 “everytick” 专门指的是指标的计算,并且不会影响报价生成模式。换句话说,报价是指测试器生成的价格变化,无论是每个报价、OHLC M1 价格,还是 K 线开盘价格,并且测试器的这个设置仍然有效。

对于本章中我们所考虑的指标,这个属性并非至关重要。还应注意的是,它仅适用于策略测试器中的操作。在终端中,指标总是在每个传入的报价时接收 OnCalculate 事件(前提是如果 OnCalculate 中的计算耗时过长,无法在新报价到达之前完成计算,那么指标有可能会跳过某些报价)。

至于测试器,在以下任何一种情况下,指标都会在每个报价时进行计算:

  1. 在可视化模式下。
  2. 如果存在 tester_everytick_calculate 指令。
  3. 如果指标有 EventChartCustom 调用,或者有 OnChartEventOnTimer 函数。

让我们尝试测试上一节中的 IndMultiSymbolMonitor.mq5 指标。

我们选择 EURUSD 货币对,时间周期为 H1 图表的主要交易品种和时间周期。报价生成方法为 “基于真实报价”。

开始测试后,我们应该在可视化模式窗口的日志中看到以下记录:

2021.10.20 00:00:00   新K线出现在: EURUSD USDCHF USDJPY , 同步状态:false
2021.10.20 00:00:00   新K线出现在: AUDUSD , 同步状态:false
2021.10.20 00:00:00   新K线出现在: GBPUSD , 同步状态:false
2021.10.20 00:00:02   新K线出现在: USDCAD , 同步状态:false
2021.10.20 00:00:11   新K线出现在: NZDUSD , 同步状态:true
2021.10.20 01:00:04   新K线出现在: EURUSD GBPUSD USDCHF USDJPY AUDUSD USDCAD NZDUSD , 同步状态:true
2021.10.20 02:00:00   新K线出现在: EURUSD USDJPY NZDUSD , 同步状态:false
2021.10.20 02:00:00   新K线出现在: USDCHF , 同步状态:false
2021.10.20 02:00:01   新K线出现在: AUDUSD , 同步状态:false
2021.10.20 02:00:15   新K线出现在: GBPUSD USDCAD , 同步状态:true
2021.10.20 03:00:00   新K线出现在: EURUSD AUDUSD NZDUSD , 同步状态:false
2021.10.20 03:00:00   新K线出现在: GBPUSD USDJPY USDCAD , 同步状态:false
2021.10.20 03:00:12   新K线出现在: USDCHF , 同步状态:true

如你所见,新的 K 线在不同的交易品种上逐渐出现。通常,在 “同步” 标志设置为 true 之前会发生几个事件。

你也可以对本章中的其他指标进行测试。请注意,如果一个 MQL 程序查询报价历史记录,请在测试器中选择 “基于真实报价” 的生成方法。

“按开盘价测试” 仅适用于为支持此模式而开发的指标和智能交易系统,例如,它们仅根据开盘价进行计算,或者从第 1 根 K 线开始分析已完成的 K 线。

注意!在测试器中测试指标时,OnDeinit 事件不起作用。此外,不会执行其他的清理操作,例如,全局对象的析构函数不会被调用。

指标的局限性和优势

本章讨论的所有专用函数仅在指标的源代码中可用。在其他类型的 MQL 程序中使用它们是没有意义的,因为这些函数会返回错误。

还有一些函数在指标中是被禁止使用的:

  • OrderCalcMargin
  • OrderCalcProfit
  • OrderCheck
  • OrderSend
  • SendFTP
  • WebRequest
  • Socket***
  • Sleep
  • MessageBox
  • ExpertRemove

其中一些函数(带有 Order- 前缀的)涉及交易计算,仅允许在智能交易系统和脚本中使用。其他函数用于执行请求,这些请求会阻塞线程的执行,直到返回结果,而指标不允许这样做,因为指标是在终端的界面线程中执行的。出于类似的原因,SleepMessageBox 函数也被禁止使用。

指标主要负责数据的可视化,奇怪的是,它并不适合进行大规模计算。特别是,如果你决定创建一个在过程中训练神经网络或决策树的指标,这很可能会对终端的正常运行产生负面影响。

长时间计算的影响可以通过指标 IndBarIndex.mq5 来展示,在正常模式下,该指标旨在在其缓冲区元素中显示 K 线编号。然而,使用输入参数 SimulateCalculation(应设置为 true),你可以在定时器上启动一个无限循环。

cpp
// 设置为 true 将冻结同一工作交易品种图表上指标的绘制
// 注意!实验结束后不要忘记移除该指标!
input bool SimulateCalculation = false;

void OnInit()
{
   ...
    if(SimulateCalculation)
    {
        EventSetTimer(1);
    }
}
...  
void OnTimer()
{
    Comment("计算开始于 ", TimeLocal());
    while(!IsStopped())
    {
        // 模拟计算的无限循环
    }
    Comment("");
}

在这种模式下,正如预期的那样,该指标开始完全占用 1 个处理器核心,但也会出现另一个副作用。放置了 IndBarIndex 指标的同一交易品种上的任何其他指标都会停止更新。例如,我们可以在 EURUSD(任何时间周期)上运行 IndBarIndex,然后在任何其他 EURUSD 图表上尝试应用常规移动平均线:在从第一个图表中移除 IndBarIndex 之前,它将不会显示。

在这方面,所有耗时较长的计算都应该放在单独的线程中,即脚本或非交易类的智能交易系统中,并且指标中只应使用它们的计算结果。MQL5 API 允许创建新的图表或带有图表的对象,在其中可以应用包含所需智能交易系统或脚本的 tpl 模板。

在 MQL 向导中创建指标初稿

至此,我们已经了解了指标的内部结构,并且能够明白源代码中的某些语法结构是如何影响指标的外部呈现和计算的。有了这样的知识基础,你可以开始研究他人的代码,并根据自己的需求对其进行修改。或者,你也可以尝试创建属于自己的指标。为了避免从零开始,你可以使用 MQL 向导。特别是,它还可以用于创建指标初稿。

要启动该向导,在 MetaEditor 导航器中,针对 “指标” 分支调用上下文菜单,然后运行 “新建文件” 命令(快捷键 Ctrl + N)。在本书的第一部分 “MQL 向导和程序初稿” 章节中,我们使用该向导创建了第一个脚本,并了解了这一步骤的具体操作。

在这种情况下(从上下文菜单启动时),向导的第一步会自动选择 “自定义指标” 选项。

点击 “下一步” 进入第二步,此时你需要指定文件名。在这里,你可以添加指标输入参数。这一步骤与创建脚本时的情况并无不同。

在第三步中,向导会提供选择 OnCalculate 处理函数的一种形式,以及其他可选的事件处理函数。

MQL5 向导:创建指标时选择事件处理函数

MQL 向导:创建指标时选择事件处理函数

最后一步允许你定义将在图表的哪个部分显示线条:可以是主窗口(默认情况),也可以是图表下方的独立子窗口(如果你启用 “指标在独立窗口中” 标志)。

MQL5 向导:创建指标时的窗口选择和图表列表

MQL 向导:创建指标时的窗口选择和图表列表

使用 “添加” 按钮,你可以列出几个图形结构,并设置它们的基本属性。

所有这些术语我们都已经从 “内部” 有所了解,因此你可以有意识地选择这样或那样的选项。

尝试生成几个启用了不同选项的指标版本,并评估它们对最终生成的程序文本的影响。

当然,在获得源代码初稿后,开发者可以自由地进行任意更改,修改在向导中设置的任何方面。这一点尤为重要,因为向导的设置范围非常有限。具体来说,输入参数类型列表仅限于标准的 MQL5 类型,没有水平位置线、调色板等设置。至于其他的事件处理函数,向导仅提供了 OnTimerOnChartEvent,而没有包括 OnBookEventOnDeinit。但是,基于本章的内容,你可以逐步为初稿补充所需的一切内容。