以 delta 指标为例开发股票交易量控制指标·进阶篇
(2/3)· 许多开发者卡在服务器选型与历史回算,本文拆开 MetaTrader 5 实际成交量的底层获取逻辑
不少交易者把终端里的成交量直接当真相,却没意识到外汇是场外市场根本没有真实成交分布,而股票端的实际交易量若服务器不广播时间和买卖,指标算出来也是空中楼阁。开发 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。
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 里按自己品种活跃度调大该参数验证。
_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 的拦截就是给这种异常留的后门,实盘前应在策略测试器故意造一段休市数据验证。
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 里把这段接进自定义量差指标,能肉眼看出哪些柱被静默丢弃。外汇与贵金属属高杠杆品种,量价跟踪仅作概率参考,不代表方向确定性。
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 索引的原型,注意静态变量归零只在首次启动做。
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复盘验证。
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__); }