等待数据和管理可见性 (DRAW_NONE)

在上一章的 使用真实分时报价数组 一节(MqlTick结构体)中,我们使用了脚本SeriesTicksDeltaVolume.mq5 来计算每根柱线的 Delta 成交量。当时,我们将结果显示到日志中,但分析此类技术信息的更便捷、更合理的方式是通过指标实现。在本节中,我们将创建一个这样指标,即 IndDeltaVolume.mq5

在此过程中,我们需要处理指标开发中常见的但在之前示例未涉及的两个因素:

第一个因素是,分时报价数据不会引用终端通过OnCalculate参数传递给指标的标准价格时间序列。这意味着,指标必须自行请求这些数据,并在等到获取这些数据之后才能在窗口中显示内容。

第二个因素涉及到买入和卖出的成交量通常远大于其 Delta 值的情况,因此在同一窗口中显示时,Delta 值将难以区分。但 Delta 值本身是基准参考值,通常需要结合价格走势进行分析。例如,柱线与 Delta 值成交量可以形成 4 种最典型的组合:

  • 阳线柱线 + 正 Delta 值 = 确认上升趋势
  • 阴线柱线 + 负 Delta 值 = 确认下降趋势
  • 阳线柱线 + 负 Delta 值 = 可能出现向下反转
  • 阴线柱线 + 正 Delta 值 = 可能出现向上反转

要显示 Delta 值直方图,我们需要提供一种禁用“大型”直方图(买入量和卖出量)的模式,为此我们将使用 DRAW_NONE 类型。它会禁用特定绘图的绘制,并避免其对窗口自动选择的缩放比例产生影响(但会在Data Window中保留缓冲区)。因此,不考虑大型绘图,剩余的 Delta 值图表将获得更大的自动缩放空间。另一种通过将缓冲区标记为辅助类型( INDICATOR_CALCULATIONS模式)来隐藏缓冲区的方法,将在下一节中讨论。

成交量 Delta 值设计用于分别计算分时报价中的买入量和卖出量,然后我们便可以求出两者的差值。相应地,我们将得到三个时间序列:买入量、卖出量及其差值。由于这些信息与价格刻度不兼容,该指标应显示在独立窗口中,并且我们将选择从零开始的直方图 (DRAW_HISTOGRAM),以此作为显示这三个时间序列的方式。

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

#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"

我们将使用前一个脚本中的输入变量。由于分时报价数据量相当庞大,我们会限制历史计算的柱线数量 (BarCount)。此外,根据特定金融工具的分时报价中是否存在真实成交量,我们可以通过两种不同方式计算 Delta 值,为此我们将使用 tick type参数(COPY_TICKS 枚举在头文件 TickEnum.mqh 中定义,我们已在脚本中使用过该头文件)。

#include <MQL5Book/TickEnum.mqh>
 
input int BarCount = 100;
input COPY_TICKS TickType = INFO_TICKS;
input bool ShowBuySell = true;

OnInit处理程序中,我们根据用户选择的 ShowBuySell 参数(默认值为true,表示显示全部三个直方图)在 DRAW_HISTOGRAM 和 DRAW_NONE 模式之间切换前两个直方图的显示模式。请注意,通过 PlotIndexSetInteger进行的动态配置会覆盖使用 #property 指令嵌入到可执行文件中的静态设置(在这种情况下,仅覆盖部分设置)。

int OnInit()
{
   PlotIndexSetInteger(0PLOT_DRAW_TYPEShowBuySell ? DRAW_HISTOGRAM : DRAW_NONE);
   PlotIndexSetInteger(1PLOT_DRAW_TYPEShowBuySell ? DRAW_HISTOGRAM : DRAW_NONE);
   
   return INIT_SUCCEEDED;
}

但指标缓冲区的注册在何处?我们将在后面几段讨论该问题。现在让我们开始准备 OnCalculate函数。

int OnCalculate(ON_CALCULATE_STD_FULL_PARAM_LIST)
{
   if(prev_calculated == 0)
   {
      // TODO(1): initialization, padding with zeros
   }
   
   // on each new bar or set of new bars on first run
   if(prev_calculated != rates_total)
   {
      // process all or new bars
      for(int i = fmax(prev_calculatedfmax(1rates_total - BarCount));
         i < rates_total && !IsStopped(); ++i)
      {
         // TODO(2): try to get the data and calculate the i-th bar,
         // if it doesn't work, do something! 
      }
   }
   else // ticks on the current bar
   {
      // TODO(3): updating the current bar
   }
   
   return rates_total;
}

主要技术问题出现在标记为 TODO (2) 的代码块中。分时报价请求算法(在脚本中使用,将以最小改动移植到指标中)通过CopyTicksRange函数请求分时报价。此类调用将返回分时报价数据库中可用的数据。但如果对给定历史柱线尚不可用,该请求将触发分时报价数据的异步下载和同步(即在后台模式下进行)。这种情况下,调用代码收到 0 条分时报价。在这方面,当指标收到这样的“空”响应时,应中断计算并返回失败(而非错误)标志,稍后再重新请求分时报价。在市场正常开市期间,我们会定期收到分时报价,因此OnCalculate函数可能很快会被再次调用,并基于更新后的分时报价库重新计算。但在周末没有分时报价的情况下该如何处理?

为正确处理这种情况,MQL5 提供了 计时器。我们将在后续章节中深入学习计时器,但目前我们将其作为一个“黑盒”使用。特殊函数 EventSetTimer 用于“请求”内核在指定秒数后调用我们的 MQL 程序。此类调用的入口点是预留的 OnTimer处理函数,我们在 事件处理函数概述章节的总表中已经见过它。因此,如果在接收分时报价数据时出现延迟,应使用 EventSetTimer启动计时器(最小周期设为 1 秒即可),并从 OnCalculate 返回零。

int OnCalculate(ON_CALCULATE_STD_FULL_PARAM_LIST)
{
      ...
      for(int i = fmax(prev_calculatedfmax(1rates_total - BarCount));
         i < rates_total && !IsStopped(); ++i)
      {
         // TODO(2): try to get the data and calculate the i-th bar,
         if(/*if no data*/)
         {
            Print("No data on bar "i", at "TimeToString(time[i]),
               ". Setting up timer for refresh...");
            EventSetTimer(1); // please call us in 1 second
            return 0// don't show anything in the window yet
         }
      }
      ...
}

OnTimer处理程序中,我们使用 EventKillTimer 函数停止计时器(如果不这样做,系统会继续每秒调用我们的处理程序)。此外,我们需要以某种方式启动指标的重新计算。为此,我们将应用另一个函数即 ChartSetSymbolPeriod,我们在图表章节尚学习该函数(请参阅 切换交易品种和时间范围章节)。该函数允许你为指定标识符的图表设置新的交易品种和时间范围组合(0 表示当前图表)。但如果通过传递 _Symbol _Period(请参阅 预定义变量)保持它们不变,将仅执行图表更新(指标会重新计算)。

void OnTimer()
{
   EventKillTimer();
   ChartSetSymbolPeriod(0_Symbol_Period); // auto-updating of the chart
}

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

bool calcDone = false;
 
int OnCalculate(ON_CALCULATE_STD_FULL_PARAM_LIST)
{
   calcDone = false;
   ...
         if(/*if no data*/)
         {
            ...
            return 0// exit with calcDone = false
         }
   ...
   calcDone = true;
   return rates_total;
}

然后在 OnTimer中,仅当 calcDone 等于false 时,才会启动图表自动更新。

void OnTimer()
{
   EventKillTimer();
   if(!calcDone)
   {
      ChartSetSymbolPeriod(0_Symbol_Period);
   }
}

现在来看TODO(1,2,3)注释部分,我们需要在这里执行计算并填充指标缓冲区。我们将所有这些操作整合到一个名为CalcDeltaVolume的类中。因此,每个操作都将分配一个单独的方法,同时保持 OnCalculate处理程序简洁(注释将被方法替代)。

在该类中,我们将提供成员变量来接收用户设置的已处理历史柱线数量、增量计算方法,同时还会包含三个指标缓冲区的数组。我们将在构造函数中对它们进行初始化。

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)
   {
      // register internal arrays as indicator buffers
      SetIndexBuffer(0buy);
      SetIndexBuffer(1sell);
      SetIndexBuffer(2delta);
   }

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

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

已经创建了reset方法来初始化缓冲区。大多数数组元素会被填充为空值 EMPTY_VALUE,而最后 limit根柱线会被填充为 0,因为我们会在这些位置分别累加买入量和卖出量。

   void reset()
   {
      // fill in the buys array and copy the rest from it
      // empty value in all elements except the last limit bars with 0
      ArrayInitialize(buyEMPTY_VALUE);
      ArrayFill(buyArraySize(buy) - limitlimit0);
      
      // duplicate the initial state into other arrays
      ArrayCopy(sellbuy);
      ArrayCopy(deltabuy);
   }

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

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

然后,我们需要确定第 i 根柱线的时间限制:prevnext,其中 next 是通过在 prev 的基础上累加 PeriodSeconds 函数的值来计算的,后者是我们新接触的函数。它返回当前时间范围的秒数。通过累加该数值,我们可以计算出下一根柱线的理论起始时间。在历史数据中,当 i不等于最后一根柱线编号时,我们本可以用time[i + 1] 来替代计算下一个时间戳。然而,该指标也应适用于仍在形成过程中且没有下一根柱线的最后一根柱线。因此,一般情况下禁止使用 time[i + 1]

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

当我们在脚本中进行类似计算时,无需使用 PeriodSeconds函数,因为脚本不考虑最后一根(当前)柱线,因此可以直接通过 iTime(WorkSymbol, TimeFrame, i)iTime(WorkSymbol, TimeFrame, i + 1) 分别获取 nextprev

接下来,在createDeltaBar方法中,我们会在已找到的时间戳范围内请求分时报价(从右侧时间戳减去 1 毫秒,以免触及下一根柱线)。分时报价将到达 ticks数组中,并通过辅助方法calc 进行处理。该方法包含的脚本算法几乎无需修改。我们不得不将其分离为一个专门的方法,因为计算将在两种不同的情况下执行:使用历史柱线(记录注释 TODO(2))和使用当前柱线上的分时报价(注释TODO(3))。下面我们来考虑第二种情况。

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

如果请求成功,该方法将返回已处理的分时报价数量;如果发生错误,则返回带负号的错误代码。请注意,如果数据库中尚未有该柱线的分时报价(严格来说这并非错误,但会导致指标无法继续可视化运行),该方法将返回 0(0 的符号不会改变其数值)。因此,在 OnCalculate函数中,我们需要检查该方法的返回值是否“小于或等于”0。

calc方法实际上包含了脚本SeriesTicksDeltaVolume.mq5 中的有效代码行,此处不再赘述。需要回顾的读者可以查阅 IndDeltaVolume.mq5文件了解具体实现方式。

要在不断更新的最后一根柱线上计算 Delta 值,我们需要以毫秒精度记录最后一个已处理分时报价的时间戳。然后,在下一次调用 OnCalculate时,我们可以查询该时间戳之后的所有分时报价。

请注意,系统无法保证在每个实时分时报价更新时都能及时调用我们的OnCalculate处理程序。如果我们执行大量计算,或者其他 MQL 程序使终端计算负载过重,又或者分时报价来得非常快(例如在重要新闻发布后),事件可能无法进入指标队列(队列中每种类型的事件最多存储一个,包括最多一个分时报价通知)。因此,如果程序希望获取所有分时报价,则必须使用 CopyTicksRangeCopyTicks 请求它们。

但仅最后一个已处理分时报价的时间戳是不够的。即使考虑到毫秒级,不同的分时报价也可能具有相同的时间戳。因此,我们不能简单地在时间戳上加 1 毫秒来排除“旧”分时报价:因为相同时间戳的“新”分时报价可能会在其后出现。

为此,你不仅需要记录时间戳,还记录具有该时间戳的最后几个分时报价的数量。然后,下次请求分时报价时,我们可以从记录的时间开始请求(即包括“旧”分时报价),但准确跳过上次已经处理过的数量。

为了实现该算法,类中声明了两个变量:last timelast count

   ulong last time// millisecond marker of the last processed online tick
   int last count;  // number of ticks with this label at that moment

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

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

现在我们可以完善 createDeltaBar方法:在处理最后一根柱线时,我们第一次调用 updateLastTime 方法。

   int createDeltaBar(const int iconst datetime &time[])
   {
      ...
      const int size = ArraySize(time);
      const int n = CopyTicksRange(_SymbolticksCOPY_TICKS_ALL,
         prev * 1000next * 1000 - 1);
      if(n > -1 && _LastError == 0)
      {
         if(i == size - 1// last bar
         {
            updateLastTime(nticks);
         }
         calc(iticks);
      }
      ...
   }

获得最新的 last timelast count 值后,我们可以实现一个在线计算当前柱线 Delta 值的方法。

   int updateLastDelta(const int total)
   {
      MqlTick ticks[];
      ResetLastError();
      const int n = CopyTicksRange(_SymbolticksCOPY_TICKS_ALLlasttime);
      if(n > -1 && _LastError == 0)
      {
         const int skip = lastcount;
         updateLastTime(nticks);
         calc(total - 1ticksskip);
         return n - skip;
      }
      return -_LastError;
   }

为了实现该模式,我们在 calc方法中引入了一个额外的可选参数skip。它允许跳过对指定数量的“旧”分时报价的计算。

   void calc(const int iconst MqlTick &ticks[], const int skip = 0)
   {
      const int n = ArraySize(ticks);
      for(int j = skipj < n; ++j)
      ...
   }

用于计算的类已准备就绪。现在,我们只需在 OnCalculate中插入对三个公共方法的调用。

int OnCalculate(ON_CALCULATE_STD_FULL_PARAM_LIST)
{
   if(prev_calculated == 0)
   {
      deltas.reset(); // initialization, padding with zeros
   }
   
   calcDone = false;
   
   // on each new bar or set of new bars on first run
   if(prev_calculated != rates_total)
   {
      // process all or new bars
      for(int i = fmax(prev_calculatedfmax(1rates_total - BarCount));
         i < rates_total && !IsStopped(); ++i)
      {
         // try to get data and calculate the i-th bar,
         if((deltas.createDeltaBar(itime)) <= 0)
         {
            Print("No data on bar "i", at "TimeToString(time[i]),
               ". Setting up timer for refresh...");
            EventSetTimer(1); // call us in 1 second
            return 0// don't show anything in the window yet
         }
      }
   }
   else // ticks on the current bar
   {
      if((deltas.updateLastDelta(rates_total)) <= 0)
      {
         return 0// error
      }
   }
   
   calcDone = true;
   return rates_total;
}

我们来编译并运行该指标。首先,建议选择不高于 H1 的时间范围,并将BarCount中的柱线数量默认设置为 100。等待指标构建完成后,结果应大致如下:

包含所有直方图的 Delta 值成交量指标(包括买入量和卖出量)

包含所有直方图的 Delta 值成交量指标(包括买入量和卖出量)

现在对比一下将 ShowBuySell参数设置为 false 时的情况:

包含单一 Delta 值直方图的成交量指标(单独的买入量和卖出量已隐藏)

包含单一 Delta 值直方图的成交量指标(单独的买入量和卖出量已隐藏)

因此,在该指标中,我们通过计时器实现了对当前金融工具分时报价数据加载的等待机制,因为处理分时报价可能需要大量资源。在下一节中,我们将讨论在报价层面运行的多货币指标,对于这类指标,使用ChartSetSymbolPeriod发起简化的异步图表更新请求即可满足需求。稍后,我们将需要实现另一种等待机制,以确保另一个指标的时间序列准备就绪。