回放系统服务重构基础篇:从全局变量迁移到新架构的平滑过渡(基础篇)
🔧

回放系统服务重构基础篇:从全局变量迁移到新架构的平滑过渡(基础篇)

(1/3)· 放弃全局终端变量后,旧功能如何不丢代码资产地重生?

新手友好 第 1/3 篇
很多开发者一换底层通信机制就把旧逻辑推倒重来,结果历史 bug 修好了新坑又冒出来。回放系统里快进这类老功能若硬迁,控制指标会悄悄失联。本篇先补基础缺口,别急着上复杂特性。

◍ 在 MT5 里搭一个可回放的历史服务

要把历史行情像录像一样回放,核心不是堆指标,而是写一个常驻的 MQL5 服务(Service),让它脱离图表独立跑。MetaTrader 5 的服务和 EA 不同,没有 OnTick 绑定到具体品种,适合做全局性的数据回放调度。 官方示例里这类回放服务的源码发布时间是 2025 年 6 月 18 日 07:11,原文标注阅读量 522、评论 0,说明这套玩法还很小众,多数人仍在用脚本硬拖 K 线。 服务跑起来后,你可以把任意一段历史 tick 或 M1 数据按自定义速度推送给指标或 EA,相当于自己造一个低成本的「复盘沙盒」。外汇与贵金属杠杆高、滑点诡异,回放结果只反映历史概率,实盘仍可能偏离。

「从全局变量切换到新技术后的残留适配」

上一篇文章里我们修掉了回放/模拟程序的两个崩溃 bug,但系统还没干净到能直接往前推新功能。原先依赖全局终端变量时那些小毛病,在换掉这套机制、改用新方案之后又冒了出来,得重新写实现把它们压住。 好消息是,现在功能水位已经基本回到用全局变量时的状态,剩下的都是相对轻的问题,不像之前那个内存崩溃——那个光贴修复代码行没用,不把『代码看着没错却崩』的根因讲透,很多人会懵,甚至产生虚假的安全感。 这类隐患真正爆发时往往不在你眼前,等类似状况出现又找不到出处,容易怀疑自己专业度。老手也会错,差别只在定位和收拾的速度。所以这一节先把切换后残留的第一个适配点拆开说清楚,方便你开 MT5 对照自己的回放服务代码查漏。

控制指标第48行补丁与快进基本模型

在把旧版回放系统迁移到去全局变量架构时,控制指标(C_Replay.mqh)必须先打一个小补丁:第48行新增了 else if (m_IndControl.Mode == C_Controls::ePause) 分支。没有它,若先暂停再尝试快进,控制指标的锁定杆会跟着快进一起移动,手动调位直接被锁死;更诡异的是,启动后按播放再快进,暂停键发出的更新不会立刻生效,要等第41行内存与缓冲区同步为真、且缓冲区标记暂停时才停,期间可能卡很久。 根源在第41行:控制指标缓冲区与引用内存空间不同步。LoopEventOnTime 在正常回放中不断读写控制指标,三种操作路径(启动-播放-快进 / 暂停-快进 / 播放后暂停)各有一坑,而第48行一个条件判断就把这类冲突全消了,后续只需专注快进本身。 基本快进不复杂:在暂停模式下 LoopEventOnTime 从第205到209行循环允许用户拖位;按播放后第210行缓存资产数据,第211行调第124行定义的过程。第128行比对目标与当前位,若在前方则第129行算偏移,第130行起循环反复触发第131行——后者调柱形生成程序(执行第69–95行),并传错误参数使第91行拦掉 CustomTicksAdd,即不推分时报价,但第92行仍逐根把柱形画上图。 这套逻辑跑起来没问题,但快进大跨度时你能肉眼看到柱形被画出,延迟主要来自第75行与第92行,其中第75行开销显著。若只想看生成效果,这套基本模型已够用;想要更猛的提速我们下节拆。

MQL5 / C++
class="num">35. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">36. class="kw">inline class="type">void UpdateIndicatorControl(class="type">void)
class="num">37.            {
class="num">38.             class="type">class="kw">double Buff[];
class="num">39.            
class="num">40.             if (m_IndControl.Handle == INVALID_HANDLE) class="kw">return;
class="num">41.             if (m_IndControl.Memory._16b[C_Controls::eCtrlPosition] == m_IndControl.Position)
class="num">42.             {
class="num">43.                if (CopyBuffer(m_IndControl.Handle, class="num">0, class="num">0, class="num">1, Buff) == class="num">1)
class="num">44.                   m_IndControl.Memory.dValue = Buff[class="num">0];
class="num">45.                if ((C_Controls::eObjectControl)m_IndControl.Memory._16b[C_Controls::eCtrlStatus] != C_Controls::eTriState)
class="num">46.                   if ((m_IndControl.Mode = (C_Controls::eObjectControl)m_IndControl.Memory._16b[C_Controls::eCtrlStatus]) == C_Controls::ePlay)
class="num">47.                      m_IndControl.Position = m_IndControl.Memory._16b[C_Controls::eCtrlPosition];
class="num">48.             }else if (m_IndControl.Mode == C_Controls::ePause)
class="num">49.             {
class="num">50.               m_IndControl.Memory._16b[C_Controls::eCtrlPosition] = m_IndControl.Position;
class="num">51.               m_IndControl.Memory._16b[C_Controls::eCtrlStatus] = (class="type">class="kw">ushort)m_IndControl.Mode;
class="num">52.               m_IndControl.Memory._8b[class="num">7] = &class="macro">#x27;D&class="macro">#x27;;

◍ 回放引擎与指标控制层的握手细节

Market Replay 的 EA 侧并不直接画控制按钮,而是把状态塞进一个 ex5 指标里:宏 def_IndicatorControl 指向 "Indicators\\Market Replay.ex5",并通过 #resource 预编译进 exe,避免运行时找不到文件。 初始化阶段会写 m_IndControl.Memory._8b[6]='M' 作为控制块标识,再调用 EventChartCustom 向图表派发 evCtrlReplayInit 事件,dValue 作为 double 型载荷传过去,字符串留空。 def_CheckLoopService 这个宏很关键:(!_StopFlag) && (ChartSymbol(m_Infos.IdReplay)!=""),意思是 EA 没被停、且回放子图确实挂了合法品种时才进循环,否则直接空转。 UpdateIndicatorControl 里先用 CopyBuffer 取指标 0 号缓冲的第 0 根数据(成功返回 1 才写回 Memory.dValue),再比对 eCtrlStatus 判断是播放还是三态;只有 Mode 为 ePause 时才走 else 分支做位置校正。外汇与贵金属回测属高风险,参数错配可能导致回放不同步。

MQL5 / C++
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, "");

class="macro">#define def_IndicatorControl   "Indicators\\Market Replay.ex5"
class="macro">#resource "\\" + def_IndicatorControl
class="macro">#define def_CheckLoopService((!_StopFlag) && (ChartSymbol(m_Infos.IdReplay) != ""))
class="macro">#define def_ShortNameIndControl "Market Replay Control"

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 ((C_Controls::eObjectControl)m_IndControl.Memory._16b[C_Controls::eCtrlStatus] != C_Controls::eTriState)
         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];
     }else if (m_IndControl.Mode == C_Controls::ePause)

「回放引擎里的内存标记与价差扰动」

这段逻辑干了两件事:先把控件状态写进 16 位内存区并发出自定义事件,再在回放里按概率给 ask/bid 叠一层微小价差。代码里 _8b[7]='D'、_8b[6]='M' 是往共享内存塞标识符,EventChartCustom 用 evCtrlReplayInit 通知图表层初始化回放控件。 CreateBarInReplay 里用 rand() 取随机数 iRand,当 iRand 落在 29080 到 32767 之间(约占总区间 11.3%)时,才可能对 dSpread 额外加一个 PointsPerTick。也就是说,绝大多数回放 K 线价差维持基准值,只有约一成概率出现跳动。 若最新价 last 高于 ask,则把 ask 拉到 last、bid 减 dSpread;若 last 低于 bid,则 ask 设为 last 加 dSpread。这种写法保证回放报价不出现交叉盘口,外汇与贵金属品种因杠杆高、点值小,这类模拟价差对策略回测结果可能有肉眼可见的偏移。

MQL5 / C++
{
   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, "");
}

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)
         {
            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;

回放引擎的进度对齐与循环节拍

这段逻辑解决一个很实际的问题:用户拖动进度滑块时,回放数据要跟手,不能跳帧也不能卡死。AdjustPositionToReplay 用 m_IndControl.Position 和 def_MaxPosSlider 算出目标 tick 序号 nPos,再和已回放的 m_Infos.CountReplay 比大小,不够就补 CreateBarInReplay(false)。 关键一行在 128 行:if (m_IndControl.Position == (int)((m_Infos.CountReplay * def_MaxPosSlider * 1.0) / m_MemoryData.nTicks)) return; 意思是当前滑块位置恰好对应已回放比例时就直接退出,避免无谓重算。分母用 1.0 强转浮点,防止整数除法把进度整没了。 LoopEventOnTime 是主循环入口。205–209 行在非播放模式下每 200 毫秒 Sleep 一次轮询控件状态,UpdateIndicatorControl 负责读滑块;进入播放后 210 行抓全量 tick 信息,211 行先做进度对齐,再从 213 行起按 CountReplay < nTicks 持续灌数据。外汇与贵金属回测中这种自定义符号重放属于高风险验证手段,实盘前务必在模拟环境跑通。 想验证也很直接:把 def_MaxPosSlider 设成 1000,nTicks 取 50000,拖到中间位置,nPos 会落在约 25000,此时 CountReplay 不足就会自动补帧——开 MT5 挂上这段改过的 EA 就能看到重放不脱节。

MQL5 / C++
class="num">088.         m_Infos.tick[class="num">0].bid = m_Infos.tick[class="num">0].last;
class="num">089.         }
class="num">090.      }
class="num">091.      if (bViewTick) CustomTicksAdd(def_SymbolReplay, m_Infos.tick);
class="num">092.      CustomRatesUpdate(def_SymbolReplay, m_Infos.Rate);
class="num">093.      }
class="num">094.      m_Infos.CountReplay++;
class="num">095.   }
class="num">096. class=class="str">"cmt">//+------------------------------------------------------------------+
   ...
class="num">123. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">124.    class="type">void AdjustPositionToReplay(class="type">void)
class="num">125.    {
class="num">126.       class="type">int nPos;
class="num">127.       
class="num">128.       if (m_IndControl.Position == (class="type">int)((m_Infos.CountReplay * def_MaxPosSlider * class="num">1.0) / m_MemoryData.nTicks)) class="kw">return;
class="num">129.       nPos = (class="type">int)(m_MemoryData.nTicks * ((m_IndControl.Position * class="num">1.0) / (def_MaxPosSlider + class="num">1)));
class="num">130.       while ((nPos > m_Infos.CountReplay) && def_CheckLoopService)
class="num">131.         CreateBarInReplay(class="kw">false);
class="num">132.    }
class="num">133. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">134.    class="kw">public  :
class="num">135. class=class="str">"cmt">//+------------------------------------------------------------------+
   ...
class="num">200. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">201.    class="type">bool LoopEventOnTime(class="type">void)
class="num">202.    {         
class="num">203.       class="type">int iPos;
class="num">204.
class="num">205.       while ((def_CheckLoopService) && (m_IndControl.Mode != C_Controls::ePlay))
class="num">206.       {
class="num">207.         UpdateIndicatorControl();
class="num">208.         Sleep(class="num">200);
class="num">209.       }
class="num">210.       m_MemoryData = GetInfoTicks();
class="num">211.       AdjustPositionToReplay();
class="num">212.       iPos = class="num">0;
class="num">213.       while ((m_Infos.CountReplay < m_MemoryData.nTicks) && (def_CheckLoopService))
class="num">214.       {
把旧实现对照交给小布
这些遗留代码与新架构的差异点,小布盯盘的 AIGC 已内置比对视图,打开对应品种页即可看到改动脉络,你只需确认逻辑边界。

常见问题

旧逻辑经过验证,直接复用可降低重写风险,只需适配消息读写层即可恢复同等功能水平。
两者通过控制指标收发消息耦合,根因在定时循环但修复点在前置更新程序,属间接依赖导致的表象错位。
建议从本基础篇起步,后续实战篇会基于此处敲定的细节做进阶封装,跳读易漏掉迁移前提。
可以,小布内置的 AIGC 对照与诊断能标出新旧架构差异与潜在失联点,减少人工逐行排查。
报价重放若时间轴错位可能造成策略误判,实盘映射存在滑点与断连概率,仅作历史推演参考。