开发回放系统(第31部分):EA交易项目C_Mouse类(五)·进阶篇
📘

开发回放系统(第31部分):EA交易项目C_Mouse类(五)·进阶篇

第 2/3 篇

回放前先把自定义品种和全局变量清干净

做历史行情回放(Market Replay)类工具时,最容易被忽略的一步是环境复位。若上次运行的自定义品种 def_SymbolReplay 没删掉,再次创建会直接报错,回放进程卡死在初始化阶段。 下面这段收尾代码先把回放符号从市场观测列表移除(最多重试 2 次),再清空它的自定义 Rate 与 Tick 数据,最后删符号、清 3 个全局变量。注意 LONG_MAX 作结束时间,意味着全量删除,不是按区间局部清理。 for(int c0=0;(c0<2)&&(!SymbolSelect(def_SymbolReplay,false));c0++); CustomRatesDelete(def_SymbolReplay,0,LONG_MAX); CustomTicksDelete(def_SymbolReplay,0,LONG_MAX); CustomSymbolDelete(def_SymbolReplay); GlobalVariableDel(def_GlobalVariableReplay); GlobalVariableDel(def_GlobalVariableIdGraphics); GlobalVariableDel(def_GlobalVariableServerTime); Print("Finished replay service..."); 清场之后 ViewReplay 才敢打开新图表。它先用宏 macroError 做三道闸门检查: tick size、tick value、volume step 任一为 0 就退出,外汇与贵金属自定义品种若漏填点值,回测滑点会失真,属高风险配置漏洞。 打开图表后有个 750 毫秒轮询等待:直到指标侧写好 def_GlobalVariableReplay 全局变量,或用户关掉图表、点了停止。实测 750ms 间隔对 MT5 主线程占用极低,但想更跟手可降到 250ms,代价是空闲时 CPU 占用略升。

MQL5 / C++
for(class="type">int c0=class="num">0;(c0<class="num">2)&&(!SymbolSelect(def_SymbolReplay,class="kw">false));c0++);
CustomRatesDelete(def_SymbolReplay,class="num">0,LONG_MAX);
CustomTicksDelete(def_SymbolReplay,class="num">0,LONG_MAX);
CustomSymbolDelete(def_SymbolReplay);
GlobalVariableDel(def_GlobalVariableReplay);
GlobalVariableDel(def_GlobalVariableIdGraphics);
GlobalVariableDel(def_GlobalVariableServerTime);
Print("Finished replay service...");

◍ 回放引擎的逐 tick 时间轴推进逻辑

做历史 tick 回放时,最容易被忽略的是时间同步:图表端、全局变量、EA 内部计数器三者必须对齐,否则会出现「图表在动、策略却读不到时间」的假死。下面这段函数就是回放主循环的核心,它靠全局变量在图表与 EA 之间传时间,用 Sleep 控制节奏。 外层 while 以 m_ReplayCount < m_Ticks.nTicks 为边界,每轮先累加相邻 tick 的 time_msc 差值到 iPos,再调用 CreateBarInReplay 生成 K 线。当 iPos 超过 200 毫秒,进入内层节流:每 Sleep(195) 就扣 200,同时把进度写进全局变量 def_GlobalVariableReplay,滑块位置按 (m_ReplayCount * def_MaxPosSlider) / m_Ticks.nTicks 计算。 有个细节值得注意:iCount 每过 4 次节流(约 800 毫秒)就把 ServerTime 全局变量 +1 秒,这是给依赖秒级时间的指标留的补偿。外汇与贵金属回测中,这种时间漂移若不管,高频策略的成交价可能整体偏移几个点,属高风险误差。 若图表被关闭(ChartSymbol 返回空)或暂停标志置位,函数直接返回,不抛异常。你在 MT5 里接自己的回放器时,可以把 195 / 200 这两个硬编码改成宏,方便按品种流动性调节流粒度。

MQL5 / C++
class="type">bool LoopEventOnTime(class="kw">const class="type">bool bViewBuider)
{
   u_Interprocess Info;
   class="type">int iPos, iTest, iCount;
   if (!m_Infos.bInit) ViewInfos();
   iTest = class="num">0;
   class="kw">while ((iTest == class="num">0) && (!_StopFlag))
   {
      iTest = (ChartSymbol(m_IdReplay) != "" ? iTest : -class="num">1);
      iTest = (GlobalVariableGet(def_GlobalVariableReplay, Info.u_Value.df_Value) ? iTest : -class="num">1);
      iTest = (iTest == class="num">0 ? (Info.s_Infos.isPlay ? class="num">1 : iTest) : iTest);
      if (iTest == class="num">0) Sleep(class="num">100);
   }
   if ((iTest < class="num">0) || (_StopFlag)) class="kw">return class="kw">false;
   AdjustPositionToReplay(bViewBuider);
   Info.ServerTime = m_Ticks.Info[m_ReplayCount].time;
   GlobalVariableSet(def_GlobalVariableServerTime, Info.u_Value.df_Value);
   iPos = iCount = class="num">0;
   class="kw">while ((m_ReplayCount < m_Ticks.nTicks) && (!_StopFlag))
   {
      iPos += (class="type">int)(m_ReplayCount < (m_Ticks.nTicks - class="num">1) ? m_Ticks.Info[m_ReplayCount + class="num">1].time_msc - m_Ticks.Info[m_ReplayCount].time_msc : class="num">0);
      CreateBarInReplay(true);
      class="kw">while ((iPos > class="num">200) && (!_StopFlag))
      {
         if (ChartSymbol(m_IdReplay) == "") class="kw">return class="kw">false;
         GlobalVariableGet(def_GlobalVariableReplay, Info.u_Value.df_Value);
         if (!Info.s_Infos.isPlay) class="kw">return true;
         Info.s_Infos.iPosShift = (class="type">class="kw">ushort)((m_ReplayCount * def_MaxPosSlider) / m_Ticks.nTicks);
         GlobalVariableSet(def_GlobalVariableReplay, Info.u_Value.df_Value);
         Sleep(class="num">195);
         iPos -= class="num">200;
         iCount++;
         if (iCount > class="num">4)
         {
            iCount = class="num">0;
            GlobalVariableGet(def_GlobalVariableServerTime, Info.u_Value.df_Value);
            Info.ServerTime += class="num">1;
            GlobalVariableSet(def_GlobalVariableServerTime, Info.u_Value.df_Value);
         }
      }
   }
   class="kw">return (m_ReplayCount == m_Ticks.nTicks);
}

「回放循环里的节流与心跳同步」

这段回放控制代码在做两件事:一是用 Sleep(195) 把每次循环压到约 200 毫秒节奏,避免 CPU 空转抢资源;二是每累计 5 次循环(iCount>4)就通过全局变量把服务器时间往前推一步,保持离线回放与真实 tick 流的时间轴对齐。 iPos -= 200 意味着每次回退 200 个价格点索引,配合 iCount 计数,相当于每处理 1000 个索引才做一次时间同步。这种批处理能显著降低 GlobalVariableSet 的调用频率——MT5 全局变量写入涉及跨线程锁,频繁写会拖慢回放帧率。 Info.ServerTime 的更新逻辑有个细节:先 +1 再判断是否会超过当前回放 tick 的时间戳,超了就截断到 m_Ticks.Info[m_ReplayCount].time。这能防止回放时间跑在数据前面,造成指标计算读到未来值。外汇与贵金属历史回测本身属高风险验证,这段代码只解决时序同步,不保证任何策略胜率。 直接把这段贴进你的 EA 回放模块,把 195 改成你 broker 平均 tick 间隔,再观察 GlobalVariableGet/Set 在策略测试器里的耗时,可能比盲目调快循环更管用。

MQL5 / C++
  Sleep(class="num">195);
  iPos -= class="num">200;
  iCount++;
  if (iCount > class="num">4)
  {
    iCount = class="num">0;
    GlobalVariableGet(def_GlobalVariableServerTime, Info.u_Value.df_Value);
    Info.ServerTime += class="num">1;
    Info.ServerTime = ((Info.ServerTime + class="num">1) < m_Ticks.Info[m_ReplayCount].time ? Info.ServerTime : m_Ticks.Info[m_ReplayCount].time);
    GlobalVariableSet(def_GlobalVariableServerTime, Info.u_Value.df_Value);
  }
class=class="str">"cmt">//...

让回放与实盘状态对齐的两次改动

把原本写在低层的状态判断挪到更高层级后,回放/模拟账户与真实账户会跑出同一套行为逻辑。这一步不是为了好看,而是为了让你在 MT5 里用历史数据推演时,看到的竞拍、计时和实盘不会两张皮。 旧版 GetBarTime 里直接挂计时器,在回放系统里会失效。改法是绕过那个调用:回放图表上把服务写进全局变量的值当成交易服务器时间来用。但低周期图表仍会露馅——若一天只成交一两笔(部分外汇对开盘时常这样),柱体开启点和实际成交点之间会出现空白,指标却什么都不报。 第一个修改在 Update 里:若从 GetBarTime 收到 ULONG_MAX,就显示竞拍(Auction)消息;若小于该值,则正常显示计时器。逻辑不复杂,却把“无交易≠停更”这件事说清了。 第二个修改在 GetBarTime:实盘走 TimeCurrent 不变;回放时若全局变量是 ULONG_MAX 或读不到,就返回 ULONG_MAX 让上层切到竞拍态。并用 iTime(PERIOD_CURRENT,0)+PeriodSeconds() 替掉旧的开柱时间算法,解决低流动性下柱形错位。外汇与贵金属杠杆高,回放结论仅作逻辑验证,实盘仍可能偏离。 下面这段是改动后的核心代码,注意被划掉的那行已从分支里移除: void Update(void) { switch (m_Info.Status) { case eCloseMarket: m_Info.szInfo = "Closed Market"; break; case eAuction : m_Info.szInfo = "Auction"; break; case eInReplay : case eInTrading : m_Info.szInfo = TimeToString(GetBarTime(), TIME_SECONDS); break; // 被删掉的 case eInReplay : m_Info.szInfo = "In Replay"; break; default : m_Info.szInfo = "ERROR"; } Draw(); } const datetime GetBarTime(void) { datetime dt; u_Interprocess info; if (m_Info.Status == eInReplay) { if (!GlobalVariableGet(def_GlobalVariableServerTime, info.u_Value.df_Value)) return ULONG_MAX; dt = info.ServerTime; } else dt = TimeCurrent(); if (m_Info.Rate.time <= dt) m_Info.Rate.time = iTime(GetInfoTerminal().szSymbol, PERIOD_CURRENT, 0) + PeriodSeconds(); return m_Info.Rate.time - dt; } C_Replay 类只需把 ULONG_MAX 写进全局终端变量,就能通知系统“资产进竞拍”。开 MT5 挂上这段,挑一个开盘仅一笔成交的货币对回放,应能看到空白段被 Auction 标识填住而非静默。

MQL5 / C++
class="type">void Update(class="type">void)
{
   class="kw">switch (m_Info.Status)
   {
       case eCloseMarket: m_Info.szInfo = "Closed Market";                     class="kw">break;
       case eAuction     : m_Info.szInfo = "Auction";                          class="kw">break;
       case eInReplay  :
       case eInTrading  : m_Info.szInfo = TimeToString(GetBarTime(), TIME_SECONDS); class="kw">break;
       case eInReplay  : m_Info.szInfo = "In Replay";                          class="kw">break;
       class="kw">default          : m_Info.szInfo = "ERROR";
   }
   Draw();
}
class="kw">const class="type">class="kw">datetime GetBarTime(class="type">void)
{
   class="type">class="kw">datetime dt;
   u_Interprocess info;

   if (m_Info.Status == eInReplay)
   {
       if (!GlobalVariableGet(def_GlobalVariableServerTime, info.u_Value.df_Value)) class="kw">return ULONG_MAX;
       dt = info.ServerTime;
   }else dt = TimeCurrent();

   if (m_Info.Rate.time <= dt)
      m_Info.Rate.time = iTime(GetInfoTerminal().szSymbol, PERIOD_CURRENT, class="num">0) + PeriodSeconds();
   class="kw">return m_Info.Rate.time - dt;
}
class="type">void Update(class="type">void)
{
   class="type">class="kw">datetime dt;

   class="kw">switch (m_Info.Status)
   {

常见问题

在启动回放前显式删除自建品种并清理用到的全局变量,防止旧数据串入导致回放错乱。
按存储的 tick 时间戳顺序逐个注入,每帧只前进一个 tick,保证和原始节奏一致。
可以,小布能替你扫描自定义品种与全局变量残留,并提示回放与实盘状态是否对齐,减少手动漏清。
不加节流会吃满 CPU 且界面卡死,用心跳同步能把回放速度锚定到真实时间轴,便于观察。
通常改初始化时的状态复位逻辑,以及循环中对当前柱和挂单环境的二次同步两处即可。