开发回放系统(第 73 部分):不寻常的通信(二)·进阶篇
🧩

开发回放系统(第 73 部分):不寻常的通信(二)·进阶篇

(2/3)· 当自定义指标在初始化时莫名返回无关图表的错误,回放系统的通信层远比想象中古怪

偏理论进阶 第 2/3 篇
很多人以为指标初始化失败就是代码写错,反复 ResetLastError 却仍读到别的品种报错。实际上不同图表间可能存在信息泄漏,硬删指标反而让回放系统失联。先隔离错误再谈功能,否则你连鼠标事件都接不住。

◍ 从图表抓取指标缓冲区的真实写法

想在 EA 里直接读图表上某个指标的缓冲区,而不是重新计算一遍,核心就是 ChartIndicatorGet 配合 CopyBuffer。上面这段循环每 250 毫秒尝试一次,把名为 TEST 的指标第 0 条缓冲的最新 1 根数据读进 Buff 数组。 第 20 行先用 ChartIndicatorGet 拿图表 id 上副图指标 TEST 的句柄,再交给 CopyBuffer 取 0 号缓冲、从 0 偏移取 1 个值。第 21 行用 PrintFormat 把返回值 ret 和 Buff[0] 打进日志,ret 大于 0 才代表真正取到了数据。 第 22 行 Sleep(250) 是故意让出 CPU,避免死循环把终端卡死;第 23 行靠 _StopFlag 和用户停止信号退出,ret 小于等于 0 也会 break。外汇与贵金属行情跳动快、重连频繁,这种轮询方式在真实账户上可能因句柄失效返回 -1,开 MT5 挂个自定义指标名叫 TEST 就能当场验证返回值和日志输出。

MQL5 / C++
class="num">20.     ret = CopyBuffer(ChartIndicatorGet(id, class="num">0, "TEST"), class="num">0, class="num">0, class="num">1, Buff);
class="num">21.     PrintFormat("CopyBuffer: [ %d ]  Value: [ %f ]", ret, Buff[class="num">0]);
class="num">22.     Sleep(class="num">250);
class="num">23. } class="kw">while ((!_StopFlag) && (ret > class="num">0));
class="num">24. }
class="num">25. class=class="str">"cmt">//+------------------------------------------------------------------+

拆解 C_Replay 头文件的重构点

重构 C_Replay.mqh 时,第一刀砍在第 23 行:原先的静态句柄变量 Handle 被整个删除,因为它依赖静态句柄机制,而上一节已论证这在回放引擎里不可靠。连带第 75–93 行的 UpdateIndicatorControl 过程及其所有引用一并清除,功能必须找新路子补上。 InitBaseControl 从第 243 行起有细微但关键的改动。第 253–255 行尝试加载控制指标,但指标加载有轻微延迟,不会瞬间完成;第 253 行先做存在性检查,第 256、258 行写死期望加载的指标名,真正触发在第 185 行。第 188 行循环死等指标挂上图表,而第 190 行在等鼠标指标时却去查控制指标缓冲区——原因是用户随时可能切图表周期。 周期切换的致命弱点暴露在第 52 行起的读取过程。第 54 行静态变量初始化为 0;若第 58 行缓冲区读取返回负值,说明控制指标已被删、图表基本废了,应用直接终止。第 64 行的时间框架锁定检查若返回 true(用户没切周期)就什么都不做,返回 false 则第 69–70 行存新周期和最后状态,第 72 行调另一过程。第 37 行才是真正向 MT5 发图表事件的地方,和缓冲区校验拆开,因为有时只发事件、有时要确认值变。 LoopEventOnTime 的修改虽不结构性,但解释了拆分原因:第 275 行首播前发自定义事件重验鼠标与控制指标,暂停后重跑;第 279 行只观察不触发;第 285 行再发事件让鼠标指标显剩余时间;第 293–294 行更新鼠标状态以侦测竞价模式进出;第 306 行查周期变化并重载。外汇与贵金属回放涉及高杠杆高风险,切周期逻辑跑错可能让模拟完全失真。

MQL5 / C++
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//|                                                C_Replay.mqh         |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="macro">#class="kw">property copyright "Daniel Jose"
class=class="str">"cmt">//+------------------------------------------------------------------+
class="macro">#include "C_ConfigService.mqh"
class="macro">#include "C_Controls.mqh"
class=class="str">"cmt">//+------------------------------------------------------------------+
class="macro">#define def_IndicatorControl   "Indicators\\Market Replay.ex5"
class="macro">#resource "\\" + def_IndicatorControl
class=class="str">"cmt">//+------------------------------------------------------------------+
class="macro">#define def_CheckLoopService((!_StopFlag) && (ChartSymbol(m_Infos.IdReplay) != ""))
class=class="str">"cmt">//+------------------------------------------------------------------+
class="macro">#define def_ShortNameIndControl    "Market Replay Control"
class="macro">#define def_MaxSlider(def_MaxPosSlider + class="num">1)
class=class="str">"cmt">//+------------------------------------------------------------------+
class C_Replay : class="kw">public C_ConfigService
{
  class="kw">private  :
    class="kw">struct st00
    {
      C_Controls::eObjectControl Mode;
      uCast_Double                Memory;
      class="type">class="kw">ushort                     Position;
      class="type">int                        Handle;
    }m_IndControl;
    class="kw">struct st01
    {
      class="type">long    IdReplay;

「回放控制结构体的字段与事件派发」

这段声明把回放引擎需要的运行时状态压进一个结构体:CountReplay 记录已重播的 tick 数,PointsPerTick 存每个 tick 对应的点数步长,tick 和 Rate 分别用长度为 1 的数组承接实时 tick 与 K 线数据。注意 MqlTick 与 MqlRates 都只开 1 格,说明回放是逐笔推,不是批量灌。 SendEventCustom 负责把控制面板的狀态塞进 16 位/8 位分段内存再抛出自定义图表事件。第 43–44 行写死 '_8b[7]='D'、_8b[6]='M'',相当于打了一个 'DM' 指纹,方便接收端判别是不是本回放器发的包。 派发完事件后用 CustomBookAdd 往虚拟品种 def_SymbolReplay 挂一笔 volume=1、price=1.0 的市价单快照。这一步不是真成交,只是让订单簿有内容可刷,MT5 里开对应的回放副图就能看到簿面被激活。 CheckIndicatorControl 用 static 变量记忆上次的 TimeFrame 与模式,避免每 tick 重读。第 58 行若 CopyBuffer 取 'Market Replay Control' 指标缓冲区失败就直接 ChartClose 关掉回放窗口——实盘外接该指标缺失时,这个保护会立刻杀窗,调试时别漏挂控件。

MQL5 / C++
class="type">int    CountReplay;
class="type">class="kw">double  PointsPerTick;
class="type">MqlTick  tick[class="num">1];
class="type">MqlRates Rate[class="num">1];
} m_Infos;
stInfoTicks m_MemoryData;
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">inline class="type">bool MsgError(class="type">class="kw">string sz0) { Print(sz0); class="kw">return class="kw">false; }
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">inline class="type">void SendEventCustom(class="kw">const ENUM_BOOK_TYPE Arg1 = BOOK_TYPE_BUY_MARKET)
    {
     MqlBookInfo book[class="num">1];

     m_IndControl.Memory._16b[C_Controls::eCtrlPosition] = m_IndControl.Position;
     m_IndControl.Memory._16b[C_Controls::eCtrlStatus] = (class="type">class="kw">ushort)m_IndControl.Mode;
     m_IndControl.Memory._8b[class="num">7] = &class="macro">#x27;D&class="macro">#x27;;
     m_IndControl.Memory._8b[class="num">6] = &class="macro">#x27;M&class="macro">#x27;;
     EventChartCustom(m_Infos.IdReplay, evCtrlReplayInit, class="num">0, m_IndControl.Memory.dValue, "");
     book[class="num">0].price = class="num">1.0;
     book[class="num">0].volume = class="num">1;
     book[class="num">0].type = Arg1;
     CustomBookAdd(def_SymbolReplay, book, class="num">1);
    }
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">inline class="type">void CheckIndicatorControl(class="type">void)
    {
     class="kw">static class="type">uchar memTimeFrame = class="num">0;
     class="kw">static C_Controls::eObjectControl memMode = m_IndControl.Mode;
     class="type">class="kw">double Buff[];

     if (CopyBuffer(ChartIndicatorGet(m_Infos.IdReplay, class="num">0, "Market Replay Control"), class="num">0, class="num">0, class="num">1, Buff) < class="num">0) ChartClose(m_Infos.IdReplay);
     m_IndControl.Memory.dValue = Buff[class="num">0];
     if (m_IndControl.Memory._16b[C_Controls::eCtrlPosition] >= m_IndControl.Position)
     {

◍ 指标控制块的缓冲同步与回放事件

这段逻辑负责把外部指标控制对象的状态搬进内存结构,并在回放模式下触发图表自定义事件。核心判断落在 m_IndControl.Memory._16b[eCtrlPosition] 与当前 Position 是否相等:相等才允许 CopyBuffer 取最新值,避免每帧无谓读取。 当状态字节被置为 ePlay 时,代码会把 Position 同步为内存里的目标位,并反向把当前模式和位置写回 _16b 数组,同时塞入 'D'、'M' 两个标记字节。这一手是给回放引擎发初始化信号用的。 EventChartCustomevCtrlReplayInitdValue 推到图表端,参数 0 和空串说明它只传一个双精度数值。外汇与贵金属行情受杠杆与跳空影响,这类自定义事件若频率过高,可能拖慢 MT5 主线程,实盘前建议在策略测试器里跑一遍帧率。 直接把下面这段贴进 EA 的 UpdateIndicatorControl 里就能对照看句柄无效分支是否提前返回,少踩 CopyBuffer 报 4011 的坑。

MQL5 / C++
class="kw">inline class="type">void UpdateIndicatorControl(class="type">void)
  {
    class="type">class="kw">double Buff[];
    if (m_IndControl.Handle == INVALID_HANDLE) class="kw">return;
    if (m_IndControl.Memory._16b[C_Controls::eCtrlPosition] == m_IndControl.Position)
    {
      if (CopyBuffer(m_IndControl.Handle, class="num">0, class="num">0, class="num">1, Buff) == class="num">1)
        m_IndControl.Memory.dValue = Buff[class="num">0];
      if ((m_IndControl.Mode = (C_Controls::eObjectControl)m_IndControl.Memory._16b[C_Controls::eCtrlStatus]) == C_Controls::ePlay)
        m_IndControl.Position = m_IndControl.Memory._16b[C_Controls::eCtrlPosition];
    {
      m_IndControl.Memory._16b[C_Controls::eCtrlPosition] = m_IndControl.Position;
      m_IndControl.Memory._16b[C_Controls::eCtrlStatus] = (class="type">class="kw">ushort)m_IndControl.Mode;
      m_IndControl.Memory._8b[class="num">7] = &class="macro">#x27;D&class="macro">#x27;;
      m_IndControl.Memory._8b[class="num">6] = &class="macro">#x27;M&class="macro">#x27;;
      EventChartCustom(m_Infos.IdReplay, evCtrlReplayInit, class="num">0, m_IndControl.Memory.dValue, "");
    }
  }

回放图表回收与模拟报价刷新

做历史逐笔回放时,先要把上一轮挂在终端里的回放图表清掉,否则重复开图会拖慢 MT5 响应。下面这段 SweepAndCloseChart 从 ChartFirst 起遍历,遇到符号等于 def_SymbolReplay 的图表直接 ChartClose,直到 ChartNext 返回 0 才停。

MQL5 / C++
class="type">void SweepAndCloseChart(class="type">void)
{
   class="type">long id;
   if ((id = ChartFirst()) > class="num">0) do
   {
      if (ChartSymbol(id) == def_SymbolReplay)
         ChartClose(id);
   } class="kw">while ((id = ChartNext(id)) > class="num">0);
}
清图只是准备动作,真正让回放品种「动起来」靠 RateUpdate。它用静态变量 st_Spread 记录 Spread 计数:bCheck 为真时把当前回放时间宏转成整型重置计数,否则每次自增 1;随后把 def_MaskTimeService | st_Spread 写进 m_Infos.Rate[0].spread,调 CustomRatesUpdate 推给 def_SymbolReplay。
MQL5 / C++
class="kw">inline class="type">int RateUpdate(class="type">bool bCheck)
{
   class="kw">static class="type">int st_Spread = class="num">0;
   st_Spread = (bCheck ? (class="type">int)macroGetTime(m_MemoryData.Info[m_Infos.CountReplay].time) : st_Spread + class="num">1);
   m_Infos.Rate[class="num">0].spread = (class="type">int)(def_MaskTimeService | st_Spread);
   CustomRatesUpdate(def_SymbolReplay, m_Infos.Rate);
   class="kw">return class="num">0;
}
CreateBarInReplay 负责按 1 分钟聚合生成 bar。注意 128 行对点差的随机扰动:当 iRand 落在 29080 到 32767 之间时,有约 50% 概率在 PointsPerTick 基础上再加一个 tick 点差。外汇与贵金属回放仅用于策略验证,实盘点差跳变可能更剧烈,属高风险环境,该随机区间不代表真实报价分布。
MQL5 / C++
class="kw">inline class="type">void CreateBarInReplay(class="type">bool bViewTick)
{
   class="type">bool   bNew;
   class="type">class="kw">double dSpread;
   class="type">int    iRand = rand();
   if (BuildBar1Min(m_Infos.CountReplay, m_Infos.Rate[class="num">0], bNew))
   {
      m_Infos.tick[class="num">0] = m_MemoryData.Info[m_Infos.CountReplay];
      if (m_MemoryData.ModePlot == PRICE_EXCHANGE)
      {             
         dSpread = m_Infos.PointsPerTick + ((iRand > class="num">29080) && (iRand < class="num">32767) ? ((iRand & class="num">1) == class="num">1 ? m_Infos.PointsPerTick : class="num">0 ) : class="num">0 );
         if (m_Infos.tick[class="num">0].last > m_Infos.tick[class="num">0].ask)
别把正态当圣经 回放里的点差扰动是均匀截断随机,不是真实经纪商的高峰滑点模型;直接拿它测 scalper 成本,可能低估极端时段损耗。

MQL5 / C++
class="type">void SweepAndCloseChart(class="type">void)
{
   class="type">long id;
   if ((id = ChartFirst()) > class="num">0) do
   {
      if (ChartSymbol(id) == def_SymbolReplay)
         ChartClose(id);
   } class="kw">while ((id = ChartNext(id)) > class="num">0);
}

class="kw">inline class="type">int RateUpdate(class="type">bool bCheck)
{
   class="kw">static class="type">int st_Spread = class="num">0;
   st_Spread = (bCheck ? (class="type">int)macroGetTime(m_MemoryData.Info[m_Infos.CountReplay].time) : st_Spread + class="num">1);
   m_Infos.Rate[class="num">0].spread = (class="type">int)(def_MaskTimeService | st_Spread);
   CustomRatesUpdate(def_SymbolReplay, m_Infos.Rate);
   class="kw">return class="num">0;
}

class="kw">inline class="type">void CreateBarInReplay(class="type">bool bViewTick)
{
   class="type">bool   bNew;
   class="type">class="kw">double dSpread;
   class="type">int    iRand = rand();
   if (BuildBar1Min(m_Infos.CountReplay, m_Infos.Rate[class="num">0], bNew))
   {
      m_Infos.tick[class="num">0] = m_MemoryData.Info[m_Infos.CountReplay];
      if (m_MemoryData.ModePlot == PRICE_EXCHANGE)
      {             
         dSpread = m_Infos.PointsPerTick + ((iRand > class="num">29080) && (iRand < class="num">32767) ? ((iRand & class="num">1) == class="num">1 ? m_Infos.PointsPerTick : class="num">0 ) : class="num">0 );
         if (m_Infos.tick[class="num">0].last > m_Infos.tick[class="num">0].ask)

「回放图表里的报价线怎么跟着品种切换」

上面那段代码干的事很具体:在回放引擎里,根据 last 价和当前 bid/ask 的相对位置,反向推算出另一侧的报价。若 last 高于 bid,就把 ask 设为 last、bid 设为 last 减点差;若 last 低于 bid,则 ask 设为 last 加点差、bid 设为 last。 落到图表显示,AdjustViewDetails 里用 ChartSetInteger 控制三条线:外汇模式(PRICE_FOREX)才显示 ask/bid 线,交易所模式(PRICE_EXCHANGE)显示 last 线。外汇和贵金属点差跳动快,这种显隐切换能避免看图时误把 last 当成交价。 还有个细节,CopyRates 取 PERIOD_M1 的 1 根 K 线,若 close 大于 0 且是交易所模式,就把 tick[0].last 同步成 rate[0].close。开 MT5 挂上回放脚本,改 ModePlot 参数从 PRICE_FOREX 切到 PRICE_EXCHANGE,能看到报价线从双线变单线,这是验证逻辑最直接的方式。

MQL5 / C++
{
 m_Infos.tick[class="num">0].ask = m_Infos.tick[class="num">0].last;
 m_Infos.tick[class="num">0].bid = m_Infos.tick[class="num">0].last - dSpread;
 }
else if (m_Infos.tick[class="num">0].last < m_Infos.tick[class="num">0].bid)
 {
 m_Infos.tick[class="num">0].ask = m_Infos.tick[class="num">0].last + dSpread;
 m_Infos.tick[class="num">0].bid = m_Infos.tick[class="num">0].last;
 }
 if (bViewTick)
 CustomTicksAdd(def_SymbolReplay, m_Infos.tick);
 RateUpdate(true);
 }
 m_Infos.CountReplay++;
}
class=class="str">"cmt">//+------------------------------------------------------------------+
 class="type">void AdjustViewDetails(class="type">void)
 {
 class="type">MqlRates rate[class="num">1];
 ChartSetInteger(m_Infos.IdReplay, CHART_SHOW_ASK_LINE, GetInfoTicks().ModePlot == PRICE_FOREX);
 ChartSetInteger(m_Infos.IdReplay, CHART_SHOW_BID_LINE, GetInfoTicks().ModePlot == PRICE_FOREX);
 ChartSetInteger(m_Infos.IdReplay, CHART_SHOW_LAST_LINE, GetInfoTicks().ModePlot == PRICE_EXCHANGE);
 m_Infos.PointsPerTick = SymbolInfoDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_SIZE);
 CopyRates(def_SymbolReplay, PERIOD_M1, class="num">0, class="num">1, rate);
 if ((m_Infos.CountReplay == class="num">0) && (GetInfoTicks().ModePlot == PRICE_EXCHANGE))
 for (; GetInfoTicks().Info[m_Infos.CountReplay].volume_real == class="num">0; m_Infos.CountReplay++);
 if (rate[class="num">0].close > class="num">0)
 {
 if (GetInfoTicks().ModePlot == PRICE_EXCHANGE)
 m_Infos.tick[class="num">0].last = rate[class="num">0].close;
交给小布盯盘看盘口泄漏
这些跨图表报错与初始化异常,小布盯盘的 AIGC 诊断已内置,打开对应品种页即可看到指标加载状态与潜在冲突提示,把重复排查交给小布,你专注决策。

常见问题

因为错误可能来自其他图表或品种,并非本指标产生。原文改用 SetUserError 隔离,只删真正由本应用设置的错误。
OnChartEvent 随鼠标或按键触发,OnCalculate 在新报价时必调,用后者能保证缓冲区处理更稳更快,减少事件丢失。
可以,小布盯盘的品种页会呈现指标加载与报错诊断,帮助快速定位是否因其他图表污染了 LastError。
第 25 行原先检查 ERR_SUCCESS,本篇改为只认 SetUserError 设置的错误,避免无关报错触发指标卸载。
并非不可想,而是用指标传非行情信息过于奇特,社区少有探索,细节坑都靠自己踩出来。