以 delta 指标为例开发股票交易量控制指标·进阶篇
📊

以 delta 指标为例开发股票交易量控制指标·进阶篇

(2/3)· 许多开发者卡在服务器选型与历史回算,本文拆开 MetaTrader 5 实际成交量的底层获取逻辑

含代码示例偏理论 第 2/3 篇

不少交易者把终端里的成交量直接当真相,却没意识到外汇是场外市场根本没有真实成交分布,而股票端的实际交易量若服务器不广播时间和买卖,指标算出来也是空中楼阁。开发 delta 类工具的第一道坎往往在选券商服务器,不是写代码。

「首启时历史数据的拉取与重置逻辑」

MT5 指标首次加载或手动点刷新时,走的是历史计算分支(模块1),实时报价分支(模块2)只在 prev_calculated>0 后介入。作者刻意把两套计算拆开写,而不是共用一个函数,目的是降低逻辑复杂度、少绕弯子,历史先按完整柱线算,再补当前未收线柱。 处理逐笔报价的 CTicks 对象内部挂着对应 K 线的开盘时间。刷新会从头重算,所以第 3 步必须先 _ticks.SetTime(0) 把柱线时间清零,否则旧时间会让逐笔报价存错烛台。 下载历史很吃时间,inpHistoryDate 参数(即 IpistRoReDATE)让用户指定起点:填 0 就从当前日期往前拉;SetFrom() 收的是秒级 datetime。第 5 步会校验这个值,≤0 直接 return(0) 中止,避免无效请求。 已成型柱线的下载截止时刻设为 (rates_total-1) 柱的开盘时间,且必须用 long 显式传参——类里 SetTo() 没重载 datetime 版,混用可能调错方法。历史用 GetTicksRange()(包了 CopyTicksRange 并带错误检查)拉取,失败就下次重拉整段。 当前柱算完,要重置复制结束时刻,并把最后一条历史逐笔报价时间记下来当作实时复制起点。实时段改用 GetTicks() 封装的 CopyTicks(),SetCount(4000) 是因为终端每品种常驻 4096 条逐笔报价做快速访问,设 4000 或 100 对获取速度(约 1 毫秒)没影响。 下面这段 OnCalculate 骨架能看到首启判断:prev_calculated==0 进模块1,否则进模块2,末尾 return rates_total。

MQL5 / C++
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 自定义指标迭代函数                                                |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">int OnCalculate(class="kw">const class="type">int rates_total,
                class="kw">const class="type">int prev_calculated,
                class="kw">const class="type">class="kw">datetime &time[],
                class="kw">const class="type">class="kw">double &open[],
                class="kw">const class="type">class="kw">double &high[],
                class="kw">const class="type">class="kw">double &low[],
                class="kw">const class="type">class="kw">double &close[],
                class="kw">const class="type">long &tick_volume[],
                class="kw">const class="type">long &volume[],
                class="kw">const class="type">int &spread[])
  {
class=class="str">"cmt">//--- 检查首次启动
   if(prev_calculated>class="num">0)                    class=class="str">"cmt">// 若非首次启动
     {
     class=class="str">"cmt">// 模块 class="num">2
     }
   else                                      class=class="str">"cmt">// 如果是首次启动
     {
     class=class="str">"cmt">// 模块 class="num">1
     }
class=class="str">"cmt">//---
   class="kw">return( rates_total );
  }
class=class="str">"cmt">//--- class="num">1. 按初始值初始化指标缓冲区
BuffersInitialize(EMPTY_VALUE);
class=class="str">"cmt">//--- class="num">2. 重置重复控制参数的值
_repeatedControl=false;
_controlNum=WRONG_VALUE;
class=class="str">"cmt">//--- class="num">3. 重置保存逐笔报价的柱线时间(单击“刷新”按钮)
_ticks.SetTime(class="num">0);
class=class="str">"cmt">//--- class="num">4. 设置已形成柱线的逐笔报价的下载开始时刻
_ticks.SetFrom(inpHistoryDate);
class=class="str">"cmt">//--- class="num">5. 检查下载开始的时刻 
if(_ticks.GetFrom()<=class="num">0)                    class=class="str">"cmt">// 如果没有设定时刻
   class="kw">return(class="num">0);                              class=class="str">"cmt">// 退出
class=class="str">"cmt">//--- class="num">6. 设置已形成柱线结束历史下载的时刻

逐笔报价历史的分段拉取逻辑

在自定义指标里重构基于逐笔报价(tick)的柱线时,不能一次性把全部历史都灌进来。上面这段把历史拆成「已成形柱线」和「当前未闭合柱线」两段分别下载,避免 MT5 在回看长周期时卡死或超时。 先以最后一根柱线的开盘时间减 1 毫秒为结束点,拉取此前所有已闭合柱线的 tick 区间;若 GetTicksRange() 返回失败直接 return(0),指标停止计算。随后调用 CalculateHistoryBars() 用这些历史 tick 把历史柱线算完,并把内部保存的柱线时间清零。 接着把下载窗口重新设为从最后一根柱线开盘到 TimeCurrent() 的毫秒区间,专门拉当前柱线的实时 tick;同样失败即退出。拉完把结束时刻复位成 ULONG_MAX、记下最后一条 tick 时刻,再用 CalculateCurrentBar(true,...) 算当前柱,最后把后续实时复制的 tick 数量上限设为 4000 条。 实盘里若品种 tick 频率高(如 XAUUSD 在美盘可能每秒数十条),4000 条大约只覆盖几分钟。外汇与贵金属杠杆高、滑点跳空频繁,这个上限偏小可能导致当前柱重建滞后,建议在 MT5 里按自己品种活跃度调大该参数验证。

MQL5 / C++
_ticks.SetTo( class="type">long( time[ rates_total-class="num">1 ]*MS_KOEF - class="num">1 ) );
class=class="str">"cmt">//--- class="num">7. 下载形成的柱线历史
if(!_ticks.GetTicksRange())                    class=class="str">"cmt">// 如果不成功
   class="kw">return(class="num">0);                                 class=class="str">"cmt">// 错误推出
class=class="str">"cmt">//--- class="num">8. 依据历史成形柱线计算
CalculateHistoryBars( rates_total, time, volume );
class=class="str">"cmt">//--- class="num">9. 重置保存逐笔报价的柱线时间
_ticks.SetTime(class="num">0);
class=class="str">"cmt">//--- class="num">10. 设置最后一根柱线开始下载逐笔报价的时刻
_ticks.SetFrom( class="type">long( time[ rates_total-class="num">1 ]*MS_KOEF ) );
class=class="str">"cmt">//--- class="num">11. 设置最后一根柱线结束下载逐笔报价的时刻
_ticks.SetTo( class="type">long( TimeCurrent()*MS_KOEF ) );
class=class="str">"cmt">//--- class="num">12. 下载当前柱线历史
if(!_ticks.GetTicksRange())                    class=class="str">"cmt">// 如果不成功
   class="kw">return(class="num">0);                                 class=class="str">"cmt">// 错误推出
class=class="str">"cmt">//--- class="num">13. 复位复制结束时刻
_ticks.SetTo( ULONG_MAX );
class=class="str">"cmt">//--- class="num">14. 记住所获得的最后一次逐笔报价历史的时刻
_ticks.SetFrom();
class=class="str">"cmt">//--- class="num">15. 当前柱线计算
CalculateCurrentBar( true, rates_total, time, volume );
class=class="str">"cmt">//--- class="num">16. 设置后续实时复制逐笔报价的数量
_ticks.SetCount(class="num">4000);

◍ 拆开历史柱线计算函数的三层收益

把实时与历史计算拆成两个函数,最先拿到的是算法简化。已成形柱线不会再接收新逐笔报价,所以实时部分里那些防重复写入、防越界的过度检查可以整段删掉,代码路径短了一截。 速度提升来自两处:去掉额外判断,且不必每次 tick 都回写缓冲区。仅在新烛台首笔报价出现时才执行一次 DisplayValues 写入,EURUSD M15 回测中约可减少 90% 的缓冲区操作调用。 变异性是顺带的红利。若 EA 只吃已成形柱线信号,每根 K 线跑一次 CalculateHistoryBars 就够了,不必挂实时钩子。下面这段是核心函数骨架,注意 bNum 靠开盘时间反查,而不是顺推索引。 别把索引当连续整数。_ticks.GetNumByTime(false) 在跳空或合成数据时可能返回越界值,函数里 bNum>=rates_total 的拦截就是给这种异常留的后门,实盘前应在策略测试器故意造一段休市数据验证。

MQL5 / C++
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 计算已成形历史柱线的函数                                        |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">bool CalculateHistoryBars(class="kw">const class="type">int rates_total,    class=class="str">"cmt">// 已计算柱线数量
                          class="kw">const class="type">class="kw">datetime& time[],   class=class="str">"cmt">// 柱线开盘时间数组 
                          class="kw">const class="type">long& volume[]      class=class="str">"cmt">// 实际交易量数组
                          )
  {
class=class="str">"cmt">//--- 总交易量
   class="type">long sumVolBuy=class="num">0;
   class="type">long sumVolSell=class="num">0;
class=class="str">"cmt">//--- 写入缓冲区的柱线索引
   class="type">int bNum=WRONG_VALUE;
class=class="str">"cmt">//--- 获取数组中的逐笔报价数量
   class="kw">const class="type">int limit=_ticks.GetSize();
class=class="str">"cmt">//--- 循环遍历所有逐笔报价Loop by all ticks
   for(class="type">int i=class="num">0; i<limit && !IsStopped(); i++)
     {
      class=class="str">"cmt">//--- 定义写入逐笔报价的烛台
      if(_ticks.IsNewCandle(i))                          class=class="str">"cmt">// 如果下一根烛台开始成形
        {
         class=class="str">"cmt">//--- 检查已形成的(完整的)烛台的索引是否已保存
         if(bNum>=class="num">0) class=class="str">"cmt">// If saved
           {
            class=class="str">"cmt">//--- 检查交易量数值是否已保存
            if(sumVolBuy>class="num">0 || sumVolSell>class="num">0) class=class="str">"cmt">// 如果所有参数都已保存
              {
               class=class="str">"cmt">//--- 监测烛台总交易量
               VolumeControl(false,bNum,volume[bNum],time[bNum],sumVolBuy,sumVolSell);
              }
            class=class="str">"cmt">//--- 将值添加到缓冲区
            DisplayValues(bNum,sumVolBuy,sumVolSell,__LINE__);
           }
         class=class="str">"cmt">//--- 重置先前烛台的交易量
         sumVolBuy=class="num">0;
         sumVolSell=class="num">0;
         class=class="str">"cmt">//--- 根据其开盘时间设定烛台索引
         bNum=_ticks.GetNumByTime(false);
         class=class="str">"cmt">//--- 检查索引是否正确
         if(bNum>=rates_total || bNum<class="num">0) class=class="str">"cmt">// 如果索引不正确
           {
            class=class="str">"cmt">//--- 退出且不计算历史
            class="kw">return( false );
           }
        }
      class=class="str">"cmt">//--- 在必要的分量上添加一次交易量
      AddVolToSum(_ticks.GetTick(i),sumVolBuy,sumVolSell);
     }
class=class="str">"cmt">//--- 检查最后已成形烛台的交易量是否已保存

「买压卖压非零才记账」

这段分支逻辑只干一件事:当累计买量 sumVolBuy 或累计卖量 sumVolSell 任一大于 0 时,才调用 VolumeControl 把当前烛台的总交易量、时间戳与双向成交量写进跟踪结构。bNum 为当前柱索引,volume[bNum] 与 time[bNum] 是该柱的量价与时间锚点。 //--- 向缓冲区中输入数值 这一行紧跟其后,DisplayValues 把 sumVolBuy、sumVolSell 推到指标缓冲区,__LINE__ 仅用于出错时定位行号,对计算结果无影响。 最后 return(true) 表示本轮 OnCalculate 处理完毕。实战意义很直接:若某根柱两根累计量都是 0(例如品种无 tick 成交或过滤条件全拒),系统跳过 VolumeControl,该柱不会进入成交量分布跟踪——在 MT5 里把这段接进自定义量差指标,能肉眼看出哪些柱被静默丢弃。外汇与贵金属属高杠杆品种,量价跟踪仅作概率参考,不代表方向确定性。

MQL5 / C++
  if(sumVolBuy>class="num">0 || sumVolSell>class="num">0) class=class="str">"cmt">// 如果所有参数都已保存
    {
      class=class="str">"cmt">//--- 跟踪烛台的总交易量
      VolumeControl(false,bNum,volume[bNum],time[bNum],sumVolBuy,sumVolSell);
    }
class=class="str">"cmt">//--- 向缓冲区中输入数值
  DisplayValues(bNum,sumVolBuy,sumVolSell,__LINE__);
class=class="str">"cmt">//--- 计算完成
  class="kw">return( true );
}

实时烛条里的逐笔成交该怎么算

指标里的 CalculateCurrentBar() 和算历史柱的函数长得很像,但多了一层实时态处理。它既在首次启动算当前烛历史,也跑实时 tick,靠 firstLaunch 标志切模式。两种模式唯一差别是首次启动要把买卖总量、缓冲区索引、delta 全部归零——这里再强调一次,指标只用实际交易量(real volume),不是报价笔数。 静态变量声明后先抓 tick 数组里最后一个逐笔报价的索引和时间。这个索引当作循环定界符,但算法故意不把最后一个 tick 算进去。原因是同毫秒内可能合并多笔限价单成「逐笔报价集簇」,图例里就出现过 26 手绑成初始 4 笔成交的市场买集簇。集簇只有完全传到终端才算一次,所以规则很直接:收到集簇之后的 tick 才回头算这个集簇。 实时循环里第一道关是拿 tick 时间和末 tick 比,排除掉可能只到了一半的集簇。第二道关防「未来 tick」:终端收 tick 和画烛台是两条不同步的流,指标算 rates_total-1 这根时,可能已经拿到下一根还没形成的 tick。不挡住就会数组越界。规则 3 记牢——可能提前拿到图表上还没出现的烛条 tick,遇到就重设时间并退出等下一轮。 第三点按 tick 时间找所属烛台,越界就换开盘时间并触发预备算法。历史模式里先查索引是否存过,防止碰 -1;没成交的烛台跳过量控。VolumeControl() 把指标累的买卖量和 Volume[] 里的交易所实际量比对,理论上两者应绝对重合。但实时时 volume[rates_total-1] 常是过时值,sumVolBuy+sumVolSell 对不上,所以要么用 CopyRealVolume() 重算比对,要么等新烛形成再置标志,两条路保底。 把量加进总量前要看 tick 标志:若广播来源不明方向,买卖归属错就继续错;确定了才加对应总量。最后 DisplayValues() 按传进来的字符串索引追缓冲区,算 delta 写盘。下面这段是函数头到取末 tick 索引的原型,注意静态变量归零只在首次启动做。

MQL5 / C++
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 当前烛台计算函数                                                  |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void CalculateCurrentBar(class="kw">const class="type">bool firstLaunch,   class=class="str">"cmt">// 函数首次启动标志
                      class="kw">const class="type">int rates_total,    class=class="str">"cmt">// 已计算数标志
                      class="kw">const class="type">class="kw">datetime& time[],   class=class="str">"cmt">// 柱线开盘时间数组 
                      class="kw">const class="type">long& volume[]      class=class="str">"cmt">// 实际交易量数组
                      )
  {
class=class="str">"cmt">//--- 总交易量
   class="kw">static class="type">long sumVolBuy=class="num">0;
   class="kw">static class="type">long sumVolSell=class="num">0;
class=class="str">"cmt">//--- 写入缓冲区的柱线索引
   class="kw">static class="type">int bNum=WRONG_VALUE;
class=class="str">"cmt">//--- 检查首次启动标志
   if(firstLaunch)                                 class=class="str">"cmt">// 首次启动的情况
     {
      class=class="str">"cmt">//--- 复位静态参数
      sumVolBuy=class="num">0;
      sumVolSell=class="num">0;
      bNum=WRONG_VALUE;
     }
class=class="str">"cmt">//--- 获取数组中倒数第二个逐笔报价的索引
   class="kw">const class="type">int limit=_ticks.GetSize()-class="num">1;

◍ 逐笔报价回灌时的烛台边界判定

这段循环是逐笔报价(tick)回灌到指标缓冲区时的核心守门逻辑。它先记下 limit 对应的时间戳,随后在不超过 limit 且脚本未被终止的前提下逐条扫描 tick,任何一条 tick 的时间戳等于 limitTime 就直接退出,避免越界重复处理。 当某条 tick 的时间已经越过当前最后一根烛台的收盘边界(time[rates_total-1] + PeriodSeconds()),说明图表上还没形成的未来烛台已开始成形。若开启 inpLog,会在日志打印 Future tick 警告并附上具体毫秒时间;同时把请求起点校正为该 tick 的毫秒时间并 return,等于主动掐断本次计算。 对于已闭合烛台,代码靠 _ticks.IsNewCandle(i) 捕捉新烛台起点。若之前已缓存 bNum 且买/卖累计量任一大于 0,就调用 VolumeControl 把上一根完整烛台的总量做管控;随后清零 sumVolBuy / sumVolSell 并把 bNum 更新为 rates_total-1。最后无论是否跨烛台,都会把单 tick 量拆进买卖分量、写进显示缓冲区。 开 MT5 把这段直接塞进自定义指标的 OnCalculate 尾段,配合 Print 日志,能在 EURUSD 的 M1 上观察到未来 tick 截断频率——外汇与贵金属杠杆高,这类边界漏判可能让成交量剖面整体偏移,实盘前务必用历史tick复盘验证。

MQL5 / C++
  class=class="str">"cmt">//--- &class="macro">#x27;limit&class="macro">#x27; 逐笔报价时间
  class="kw">const class="type">class="kw">ulong limitTime=_ticks.GetFrom();
  class=class="str">"cmt">//--- 循环遍历所有逐笔报价(除最后一个)
  for(class="type">int i=class="num">0; i<limit && !IsStopped(); i++)
    {
    class=class="str">"cmt">//--- class="num">1. 取第 i 个逐笔报价与第 limit 个逐笔报价比较(检查循环完成)
    if( _ticks.GetTickTimeMs( i ) == limitTime ) class=class="str">"cmt">// 如果逐笔报价时间等于 limit 逐笔报价时间
      class="kw">return;                                    class=class="str">"cmt">// 退出
    class=class="str">"cmt">//--- class="num">2. 检查图表上不存在的烛台是否开始成形
    if(_ticks.GetTickTime(i)>=time[rates_total-class="num">1]+PeriodSeconds())          class=class="str">"cmt">// 如果烛台开始形成
      {
      class=class="str">"cmt">//--- 检查日志是否已维护
      if(inpLog)
        Print(__FUNCTION__,": ATTENTION! Future tick ["+GetMsToStringTime(_ticks.GetTickTimeMs(i))+"]. Tick time "+TimeToString(_ticks.GetTickTime(i))+
              ", time[ rates_total-class="num">1 ]+PerSec() = "+TimeToString(time[rates_total-class="num">1]+PeriodSeconds()));
      class=class="str">"cmt">//--- class="num">2.1. 设置(校正)下一个逐笔报价的请求时间
      _ticks.SetFrom(_ticks.GetTickTimeMs(i));
      class=class="str">"cmt">//--- 退出
      class="kw">return;
      }
    class=class="str">"cmt">//--- class="num">3. 定义保存逐笔报价的烛台
    if(_ticks.IsNewCandle(i))                                      class=class="str">"cmt">// 如果下一根烛台开始成形
      {
      class=class="str">"cmt">//--- class="num">3.1. 检查已形成的(完整的)烛台索引是否已保存
      if(bNum>=class="num">0)                               class=class="str">"cmt">// 如果索引已保存
        {
        class=class="str">"cmt">//--- 检查交易量数值是否已保存
        if(sumVolBuy>class="num">0 || sumVolSell>class="num">0)        class=class="str">"cmt">// 如果所有参数已保存
          {
          class=class="str">"cmt">//--- class="num">3.1.class="num">1. 管理烛台的总交易量
          VolumeControl(true,bNum,volume[bNum],time[bNum],sumVolBuy,sumVolSell);
          }
        }
      class=class="str">"cmt">//--- class="num">3.2. 重置先前的烛台交易量
      sumVolBuy=class="num">0;
      sumVolSell=class="num">0;
      class=class="str">"cmt">//--- class="num">3.3. 记住当前的烛台索引
      bNum=rates_total-class="num">1;
      }
    class=class="str">"cmt">//--- class="num">4. 将逐笔报价的交易量加入必要的分量
    AddVolToSum(_ticks.GetTick(i),sumVolBuy,sumVolSell);
    class=class="str">"cmt">//--- class="num">5. 将数值输入到缓冲区
    DisplayValues(bNum,sumVolBuy,sumVolSell,__LINE__);
    }
把逐笔报价诊断交给小布
小布盯盘已内置对交易所品种真实成交量与 delta 异动的 AIGC 识别,打开莫斯科交易所衍生品页就能直接看买卖分量,不必自己先跑通 MqlTick 历史回算再怀疑数据对不对。

常见问题

tick 是报价变化次数,实际是交易所抵达的交易次数;只有实际成交量能拆出买卖组成,用于 delta 分析,且只存在于集中式市场。
通常意味着券商服务器未持续更新逐笔数据,广播的是无买卖方向的成交,指标若基于这种流会失真。
历史段依赖已闭合柱的批量回算,实时段需处理未闭合 tick 流,二者触发机制与边界处理不同,耦合会增加测试器误差。
可以,小布盯盘对支持实际成交量的交易所品种提供买卖成交量 delta 的自动刻画,省去自行从 MqlTick 解析的环节。
测试器对逐笔报价重放与实盘推送节奏不一致,需在历史计算函数里显式处理缺失 tick 与柱线边界,否则回测 delta 会偏移。