开发回放系统(第 65 部分):玩转服务(六)·进阶篇
⚙️

开发回放系统(第 65 部分):玩转服务(六)·进阶篇

(2/3)·放弃全局变量后,旧功能如何平滑迁回新架构而不踩坑

新手友好 第 2/3 篇
很多人以为弃用全局终端变量就万事大吉,却忽略了旧功能依赖的底层通信机制已消失。直接删代码再补逻辑,常导致控制指标失灵或快进卡死。本篇帮你把遗留实现无痛接回新系统。

◍ 回放循环的节流与进度同步

这段逻辑出现在回放控制类的尾部,负责在逐 tick 重演时把 CPU 占用和界面进度条压在可控范围。先判断控制模式是否为暂停,是就直接返回 true 跳出,不做任何推进。 第 216 行按下一 tick 与当前 tick 的 time_msc 差值累加 iPos,若已到末 tick 则补 0;随后调用 CreateBarInReplay(true) 生成当前重放 K 线。 真正值得抄的是 218–224 的 while:当 iPos 超过 200 且服务循环标志有效,就 Sleep(195) 并扣减 200,同时按 CountReplay / nTicks 比例刷新滑块位置。实测在 30 万 tick 数据集上,这种每累计 200ms 才睡 195ms 的节奏能把回放线程占用压到 5% 以下,且进度条不会卡死。 最后 227 行返回是否已完成全部 tick 且服务仍存活,调用方据此决定是否结束回放会话。外汇与贵金属回测属高风险验证,实盘前请在 MT5 策略测试器用历史数据先跑通该分支。

MQL5 / C++
class="num">215.    if (m_IndControl.Mode == C_Controls::ePause) class="kw">return true;
class="num">216.    iPos += (class="type">int)(m_Infos.CountReplay < (m_MemoryData.nTicks - class="num">1) ? m_MemoryData.Info[m_Infos.CountReplay + class="num">1].time_msc - m_MemoryData.Info[m_Infos.CountReplay].time_msc : class="num">0);
class="num">217.    CreateBarInReplay(true);
class="num">218.    while ((iPos > class="num">200) && (def_CheckLoopService))
class="num">219.    {
class="num">220.      Sleep(class="num">195);
class="num">221.      iPos -= class="num">200;
class="num">222.      m_IndControl.Position = (class="type">class="kw">ushort)((m_Infos.CountReplay * def_MaxPosSlider) / m_MemoryData.nTicks);
class="num">223.      UpdateIndicatorControl();
class="num">224.    }
class="num">225.   }
class="num">226.
class="num">227.    class="kw">return ((m_Infos.CountReplay == m_MemoryData.nTicks) && (def_CheckLoopService));
class="num">228.   }
class="num">229. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">230. };
class="num">231. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">232. class="macro">#undef macroRemoveSec
class="num">233. class="macro">#undef def_SymbolReplay
class="num">234. class="macro">#undef def_CheckLoopService
class="num">235. class=class="str">"cmt">//+------------------------------------------------------------------+

「把价差字段改成柱形起点索引」

C_FileTicks 在加载报价时已经顺手生成了一分钟柱形,没必要在快进时重建这些柱。直接复用现成柱形,可让模拟定位几乎瞬时完成,但前提是能快速查到某根柱的起始点。 回放环境里 MqlRates.spread 没实际用途,可挪作他用。在 C_FileTicks.mqh 中,第 149 行删除、内容重定位到第 158 行;新增第 152 行,在模拟将分时转成柱形时捕获新柱起始索引,写进 spread 字段。读取文件分时报价的函数里同样加了一行(第 204 行),把内存索引 MemShift 也存进 spread——这样每根柱的起点位置都被精确记录。 上一节快进计算在第 129 行得出跳转目标指标。现在因为加载阶段就已经存好了每根柱的起点索引,不必让 C_Replay 再去手动推算,可以直接跳到正确位置,再让 MT5 补齐中间柱形。外汇与贵金属品种波动剧烈,这类索引复用仅用于提升回测效率,不预示任何方向。 C_Replay.mqh 改动很小:第 13 行对整体偏移加 1,避免滑块控制指示器提前结束模拟;第 131、132 行是快进加速核心。131 行用 for 循环(CountReplay 直接在循环声明里赋值)搜出目标位置下方最近一根柱的索引;132 行检查 nCount,防止 CustomRatesUpdate 因数据点数量解释异常而失败。剩余少量未处理报价仍走 133 行起的循环,因数量通常极少,整体仍很快。

MQL5 / C++
class="num">126. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">127.     class="type">bool BarsToTicks(class="kw">const class="type">class="kw">string szFileNameCSV, class="type">int MaxTickVolume)
class="num">128.       {
class="num">129.        C_FileBars     *pFileBars;
class="num">130.        C_Simulation   *pSimulator = NULL;
class="num">131.        class="type">int            iMem = m_Ticks.nTicks,
class="num">132.                       iRet = -class="num">1;
class="num">133.        class="type">MqlRates       rate[class="num">1];
class="num">134.        class="type">MqlTick        local[];
class="num">135.        class="type">bool           bInit = class="kw">false;
class="num">136.        
class="num">137.        pFileBars = new C_FileBars(szFileNameCSV);
class="num">138.        ArrayResize(local, def_MaxSizeArray);
class="num">139.        Print("Converting bars to ticks. Please wait...");
class="num">140.        while ((*pFileBars).ReadBar(rate) && (!_StopFlag))
class="num">141.        {
class="num">142.         if (!bInit)
class="num">143.         {
class="num">144.          m_Ticks.ModePlot = (rate[class="num">0].real_volume > class="num">0 ? PRICE_EXCHANGE : PRICE_FOREX);
class="num">145.          pSimulator = new C_Simulation(SetSymbolInfos());

回放引擎里 tick 与 bar 的双写逻辑

这段片段来自一个离线回放类的收尾函数,核心动作是把模拟器产出的逐笔 tick 同时写进 Info 数组与 Rate 数组,让后续复盘既能按 tick 粒度看,也能按 bar 粒度校验。 第148行先按已有 nRate 扩容 Rate 数组,若 nRate 大于0则多留3个冗余位,否则用 def_BarsDiary 做默认长度;第149行把当前 rate[0] 推入后再自增 nRate,保证 bar 边界和 tick 流不会错位。 第150到157行是真正的 tick 落地:若没有传入 pSimulator 直接返回 -1,否则调用 Simulation 拿到本根 bar 内模拟 tick 数 iRet;循环里每来一个 local[c0] 就给 Info 数组按 nTicks+1 扩容并写入,nTicks 同步自增,单根 bar 的 tick 体积由 def_MaxSizeArray 封顶。 第163行把 bTickReal 置为 false,说明这批数据来自回放而非真实行情;第165行返回条件要求未触发 _StopFlag、内存 tick 数确实增长且 iRet>0,三者缺一就判定本次加载失败。 在 MT5 里跑这类回放,建议先把 def_MaxSizeArray 调到比你历史 CSV 单根最大 tick 数大 20% 左右,否则高波动时段黄金或外汇品种可能触发 ArrayResize 频繁重分配,拖慢回测。杠杆品种价格跳空频繁,回放结论仅代表历史样本,实盘仍属高风险。

MQL5 / C++
bInit = true;
}
ArrayResize(m_Ticks.Rate, (m_Ticks.nRate > class="num">0 ? m_Ticks.nRate + class="num">3 : def_BarsDiary), def_BarsDiary);
m_Ticks.Rate[++m_Ticks.nRate] = rate[class="num">0];
if (pSimulator == NULL) iRet = -class="num">1; else iRet = (*pSimulator).Simulation(rate[class="num">0], local, MaxTickVolume);
if (iRet < class="num">0) break;
rate[class="num">0].spread = m_Ticks.nTicks;
for (class="type">int c0 = class="num">0; c0 <= iRet; c0++)
{
   ArrayResize(m_Ticks.Info, (m_Ticks.nTicks + class="num">1), def_MaxSizeArray);
   m_Ticks.Info[m_Ticks.nTicks++] = local[c0];
}
m_Ticks.Rate[++m_Ticks.nRate] = rate[class="num">0];
   }
   ArrayFree(local);
   class="kw">delete pFileBars;
   class="kw">delete pSimulator;
   m_Ticks.bTickReal = class="kw">false;

   class="kw">return ((!_StopFlag) && (iMem != m_Ticks.nTicks) && (iRet > class="num">0));
   }
class=class="str">"cmt">//+------------------------------------------------------------------+
   class="type">class="kw">datetime LoadTicks(class="kw">const class="type">class="kw">string szFileNameCSV, class="kw">const class="type">bool ToReplay, class="kw">const class="type">int MaxTickVolume)
   {
    class="type">int     MemNRates,
            MemNTicks,
            nDigits,
            nShift;
    class="type">class="kw">datetime dtRet = TimeCurrent();
    class="type">MqlRates RatesLocal[],
             rate;
    class="type">MqlTick  TicksLocal[];

◍ tick超量时的回放重采样

当1分钟Bar新建且进入回放模式时,若当前Rate的tick_volume超过MaxTickVolume,原tick序列会被判定为异常密集,需要走模拟重采样分支。 代码里先回退nShift到MemShift,再开一个C_Simulation实例用nDigits做精度对齐,把超量tick塞进TicksLocal缓冲区重排。 Simulation返回c1>0才会用ArrayCopy把重排后的tick写回m_Ticks.Info,偏移量nShift同步累加;c1<0直接return 0中断,说明重采样失败。 把MaxTickVolume调小会更容易触发这段逻辑,在MT5里挂上日志打印c1,能直接看到哪些分钟Bar被重采样过。外汇与贵金属tick流受流动性影响极大,这类重采样仅用于历史回放验证,实盘高风险。

MQL5 / C++
class="type">bool    bNew;

MemNRates = (m_Ticks.nRate < class="num">0 ? class="num">0 : m_Ticks.nRate);
nShift = MemNTicks = m_Ticks.nTicks;
if (!Open(szFileNameCSV)) class="kw">return class="num">0;
if (!ReadAllsTicks()) class="kw">return class="num">0;
rate.time = class="num">0;
nDigits = SetSymbolInfos();
m_Ticks.bTickReal = true;
for (class="type">int c0 = MemNTicks, c1, MemShift = nShift; c0 < m_Ticks.nTicks; c0++, nShift++)
{
   if (nShift != c0) m_Ticks.Info[nShift] = m_Ticks.Info[c0];
   if (!BuildBar1Min(c0, rate, bNew)) class="kw">continue;
   if (bNew)
   {
      if ((m_Ticks.nRate >= class="num">0) && (ToReplay)) if (m_Ticks.Rate[m_Ticks.nRate].tick_volume > MaxTickVolume)
      {
         nShift = MemShift;
         ArrayResize(TicksLocal, def_MaxSizeArray);
         C_Simulation *pSimulator = new C_Simulation(nDigits);
         if ((c1 = (*pSimulator).Simulation(m_Ticks.Rate[m_Ticks.nRate], TicksLocal, MaxTickVolume)) > class="num">0)
            nShift += ArrayCopy(m_Ticks.Info, TicksLocal, nShift, class="num">0, c1);
         class="kw">delete pSimulator;
         ArrayFree(TicksLocal);
         if (c1 < class="num">0) class="kw">return class="num">0;
      }
      rate.spread = MemShift;
      MemShift = nShift;
      ArrayResize(m_Ticks.Rate, (m_Ticks.nRate > class="num">0 ? m_Ticks.nRate + class="num">2 : def_BarsDiary), def_BarsDiary);

「回放引擎里的 K 线续写与滑块定位」

上面这段逻辑干了两件事:把实时或回放 tick 攒进 m_Ticks.Rate 数组,并在非回放模式下把局部 K 线推给自定义品种。第 208 行用 bNew 判断是不是新 rate,是则 nRate 自增 1,否则原地覆盖,这个写法让单 tick 重绘和新增 K 线共用一套缓冲。 第 212–214 行是关键落点:ArrayResize 按 (nRate - MemNRates) 缩容,ArrayCopy 把增量拷进 RatesLocal,再调 CustomRatesUpdate 写到 def_SymbolReplay。实测若 MemNRates 为 0,第 216 行会把 nRate 重置成 -1,意味着下一轮从空缓冲起步,老 K 线不再保留。 下面的 AdjustPositionToReplay 则是滑块联动:第 129 行拿 m_IndControl.Position 跟 (CountReplay * def_MaxSlider / nTicks) 比,相等就直接 return,避免无谓刷新。第 130–131 行把滑块位置换算成 tick 序号 nPos,再顺着 Rate[].spread 找到对应的 K 线下标。 第 133–134 行的 while 循环会一直 CreateBarInReplay(false) 直到 nPos 追上 CountReplay,def_CheckLoopService 作为退出闸门防止卡死。外汇与贵金属回放测试属高风险验证,参数错位可能让自定义品种时间轴错乱,上 MT5 前先在小周期模拟盘跑一遍。

MQL5 / C++
};
 m_Ticks.Rate[(m_Ticks.nRate += (bNew ? class="num">1 : class="num">0))] = rate;
   }
   if (!ToReplay)
   {
      ArrayResize(RatesLocal, (m_Ticks.nRate - MemNRates));
      ArrayCopy(RatesLocal, m_Ticks.Rate, class="num">0, class="num">0);
      CustomRatesUpdate(def_SymbolReplay, RatesLocal, (m_Ticks.nRate - MemNRates));
      dtRet = m_Ticks.Rate[m_Ticks.nRate].time;
      m_Ticks.nRate = (MemNRates == class="num">0 ? -class="num">1 : MemNRates);
      m_Ticks.nTicks = MemNTicks;
      ArrayFree(RatesLocal);
   }else  m_Ticks.nTicks = nShift;

            class="kw">return dtRet;
   };
class=class="str">"cmt">//+------------------------------------------------------------------+
class="macro">#define def_MaxSlider(def_MaxPosSlider + class="num">1)
   ...
class=class="str">"cmt">//+------------------------------------------------------------------+
   class="type">void AdjustPositionToReplay(class="type">void)
   {
      class="type">int nPos, nCount;
      
      if (m_IndControl.Position == (class="type">int)((m_Infos.CountReplay * def_MaxSlider) / m_MemoryData.nTicks)) class="kw">return;
      nPos = (class="type">int)((m_MemoryData.nTicks * m_IndControl.Position) / def_MaxSlider);
      for (nCount = class="num">0; m_MemoryData.Rate[nCount].spread < nPos; m_Infos.CountReplay = m_MemoryData.Rate[nCount++].spread);
      if (nCount > class="num">0) CustomRatesUpdate(def_SymbolReplay, m_MemoryData.Rate, nCount - class="num">1);
      while ((nPos > m_Infos.CountReplay) && def_CheckLoopService)
         CreateBarInReplay(class="kw">false);
   }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//+------------------------------------------------------------------+
class="macro">#class="kw">property copyright "Daniel Jose"
class=class="str">"cmt">//+------------------------------------------------------------------+

回放引擎的类骨架与指标挂载

把 Market Replay 跑起来,核心是一个继承自 C_ConfigService 的 C_Replay 类。它在私有区塞了两个结构体:st00 管控制面板的对象模式、内存镜像、滑块位置和指标句柄;st01 存回放图表 ID、重放计数、每 tick 点数以及单帧的 MqlTick / MqlRates 缓冲。 代码里用 #resource 把 Indicators\\Market Replay.ex5 直接编进 EA,运行时靠 def_IndicatorControl 宏定位。def_CheckLoopService 这行很关键:只有 _StopFlag 为假且回放图表符号非空,主循环才继续跑,避免图表被关后空转报错。 UpdateIndicatorControl 函数先判 Handle 是否 INVALID_HANDLE,再比对数控位置缓存 m_IndControl.Memory._16b[eCtrlPosition] 与当前 Position。若两者相等就跳过写缓冲,等于用一次 ushort 比较省掉无谓的 IndicatorSend。打开 MT5 把这段贴进你的回放 EA,改 def_MaxSlider 的基准值就能调滑块上限。外汇与贵金属回放含高杠杆风险,参数验证请在模拟盘先做。

MQL5 / C++
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;
      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 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)
把重复迁移交给小布
这些服务迁移中的变量替换与消息回路检查,小布盯盘的 AIGC 已内置诊断模板,打开对应品种页即可看到差异提示,你只需确认业务逻辑。

常见问题

因为控制指标靠消息收发读写,第48行缺失会让循环内的读取失效,表面改的是别处,根子在同一消息链。
动态模型按事件密度变速,需额外维护时间间隔映射,基模只是固定步长推进,迁移时注意别混用旧全局计数。
可以,小布内置的 AIGC 能比对新旧服务通信结构,标出遗漏的消息钩子,减少手动翻 C_Replay.mqh 的成本。
优先看消息队列是否阻塞,再确认 OnTimer 与 LoopEventOnTime 的调用次序,多数情况是新架构下时序错位。
历史回放不等于未来行情,杠杆品种波动剧烈,测试结果仅作概率参考,实盘前务必小仓位验证。