开发回放系统(第 69 部分):取得正确的时间(二)·综合运用
📘

开发回放系统(第 69 部分):取得正确的时间(二)·综合运用

第 3/3 篇

状态机里的市场时钟对齐

这段 Update 函数把 MT5 里的市场状态分成收盘、回放、实时交易和竞价几类,靠 switch 分支写信息字符串并触发 Draw()。做面板或 HUD 时,这种写法能让你一眼看出当前会话处于哪种模式,而不是去轮询一大堆标志位。 关键在第 122–124 行:先取 PeriodSeconds() 得到当前图表周期秒数(比如 M5 就是 300),再把当前时间 dt 按周期向下取整后加一个周期,算出下一根 K 线的开盘时间。若 m_Info.Rate.time 已小于等于 dt,就更新为这个值,否则保留。这套对齐逻辑在回放(GL_TimeAdjust)和实盘(TimeCurrent())下都能用。 第 125 行把 dt 直接转成秒级字符串塞进 szInfo,原代码里减 Rate.time 的注释被划掉,说明作者最终选择显示当前时间而非剩余秒数。你在 MT5 里改这一行,就能在面板上切出「距下根 K 线倒计时」或「服务器时间」两种视图。 外汇与贵金属杠杆高、跳空频繁,状态切换瞬间可能漏掉一根 Bar,拿去跑 EA 前建议在策略测试器用不同周期回测确认对齐无偏移。

MQL5 / C++
class="type">void Update(class="kw">const eStatusMarket arg)
  {
   class="type">int i0;
   class="type">class="kw">datetime dt;
   
   class="kw">switch (m_Info.Status = (m_Info.Status != arg ? arg : m_Info.Status))
     {
      case eCloseMarket :
        m_Info.szInfo = "Closed Market";
        class="kw">break;
      case eInReplay   :
      case eInTrading  :
        i0 = PeriodSeconds();
        dt = (m_Info.Status == eInReplay ? (class="type">class="kw">datetime) GL_TimeAdjust : TimeCurrent());
        m_Info.Rate.time = (m_Info.Rate.time <= dt ? (class="type">class="kw">datetime)(((class="type">class="kw">ulong) dt / i0) * i0) + i0 : m_Info.Rate.time);
        if (dt > class="num">0) m_Info.szInfo = TimeToString((class="type">class="kw">datetime) dt, TIME_SECONDS);
        class="kw">break;
      case eAuction    :
        m_Info.szInfo = "Auction";
        class="kw">break;
      class="kw">default          :
        m_Info.szInfo = "ERROR";
     }
   Draw();
  }

◍ 指标主循环怎么把报价喂给状态机

这段是自定义指标里 OnCalculate 的实际落点,负责把每一根 K 的收盘价和 spread 低位掩码写进全局变量,再触发研究对象的 Update。 第 52 行取 close[rates_total-1],也就是当前未闭合 K 线的收盘价赋给 GL_PriceClose;第 54 行用 spread[rates_total-1] 和 ~def_MaskTimeService 做位与,把点差里的服务时间位剥掉后存进 GL_TimeAdjust,这种写法在回放券符号下常被注释掉的 iSpread 方案替代。 m_posBuff 在第 57 行被设为 rates_total,相当于告诉后续模块当前缓冲长度;第 58 行通过指针 (*Study).Update(m_Status) 把状态抛给上层逻辑,最后 return rates_total 让 MT5 认为已处理全部柱数。 前面 129–134 行是某个 switch 的收尾:default 分支把 m_Info.szInfo 标成 "ERROR" 后统一调用 Draw(),说明异常态也要重绘界面而不静默跳过。复制这段代码到 MT5 指标里,改掉 def_MaskTimeService 的宏定义,就能在 EURUSD 的 M1 上观察到 GL_TimeAdjust 随点差跳变——外汇和贵金属杠杆品种点差突变频繁,实盘验证需自担高风险。

MQL5 / C++
class="num">129.                class="kw">break;
class="num">130.                class="kw">default     :
class="num">131.                   m_Info.szInfo = "ERROR";
class="num">132.               }
class="num">133.               Draw();
class="num">134.            }
class="num">135. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">46. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">47. 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="num">48.                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="num">49.                class="kw">const class="type">long& volume[], class="kw">const class="type">int& spread[])
class="num">50. 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">int begin, class="kw">const class="type">class="kw">double& price[])
class="num">51. {
class="num">52.   GL_PriceClose = close[rates_total - class="num">1];
class="num">53. class=class="str">"cmt">//  GL_PriceClose = price[rates_total - class="num">1];
class="num">54.   GL_TimeAdjust = (spread[rates_total - class="num">1] & (~def_MaskTimeService);
class="num">55. class=class="str">"cmt">//  if (_Symbol == def_SymbolReplay)
class="num">56. class=class="str">"cmt">//      GL_TimeAdjust = iSpread(NULL, PERIOD_M1, class="num">0) & (~def_MaskTimeService);
class="num">57.   m_posBuff = rates_total;
class="num">58.   (*Study).Update(m_Status);  
class="num">59.   
class="num">60.   class="kw">return rates_total;
class="num">61. }
class="num">62. class=class="str">"cmt">//+------------------------------------------------------------------+

「回放卡死在长间隔等待里怎么破」

做 MT5 市场回放服务时有个隐蔽坑:原本把等待下一笔分时报价的循环放在柱形创建程序里,在流动性高的品种上一切正常,因为报价间隔通常小于 1 秒。但一旦切到外汇日盘开盘或贵金属竞价时段,两笔报价可能隔 40 秒甚至更久,服务会卡死在第 230~236 行的等待循环里——这时你点暂停、拖控制滑块再播放,系统完全不理你,直到那 40 秒跑完。 根因是旧逻辑没检查用户是否暂停。修复思路不是单独打补丁,而是顺手把「低流动性剩余时间反馈」一起做了。新代码在第 221 行加了计数器变量,第 230 行初始化,第 242 行让它从 0 数到 4 后调用 RateUpdate(false):这对应前面说的「每秒约 5 个周期」,用近似计时让鼠标指标感知 1 秒流逝,精度不求严丝合缝,只给用户一个「还要等多久」的大致概念。 RateUpdate 函数从第 69 行起,专门隔离时间更新逻辑,避免直接改时间导致漏掉报价(关联第 110 行)。第 73 行是关键:真实报价由 CreateBarInReplay 接管时 st_Spread 用真实时间戳;LoopEventOnTime 调用时 st_Spread 只 +1,相当于每秒走一步。等真实报价一来,st_Spread 立刻被修正回实时值。所以若流动性枯竭、报价隔了 50 秒,计时器可能略微领先或滞后,指标显示的数值跳变不是 bug,反而带来好处——你暂停再立刻恢复,就能跳过那段漫长等待。外汇与贵金属市场的高风险在于此类流动性断层很常见,回测前务必在 MT5 用真实稀疏数据验证该逻辑。 看第 223 行的新循环:它替代了原来卡死式的等待,允许用户中途重定位控制指标。第 236 行校正后,检测到暂停就退出内层循环,函数返回主控制流重新调用,体验在低密度资产上顺滑很多。打开 C_Replay.mqh 把第 230 行附近改成这种轮询结构,是你今天就能在 MT5 测的一件事。

MQL5 / C++
<span class="number">class="num">001</span>. <span class="comment">class=class="str">"cmt">//+------------------------------------------------------------------+</span>
<span class="number">class="num">002</span>. <span class="preprocessor">class="macro">#class="kw">property </span><span class="macro">copyright</span> <span class="class="type">class="kw">string">"Daniel Jose"</span>
<span class="number">class="num">003</span>. <span class="comment">class=class="str">"cmt">//+------------------------------------------------------------------+</span>
<span class="number">class="num">004</span>. <span class="preprocessor">class="macro">#include </span><span class="class="type">class="kw">string">"C_ConfigService.mqh"</span>
<span class="number">class="num">005</span>. <span class="preprocessor">class="macro">#include </span><span class="class="type">class="kw">string">"C_Controls.mqh"</span>
<span class="number">class="num">006</span>. <span class="comment">class=class="str">"cmt">//+------------------------------------------------------------------+</span>
<span class="number">class="num">007</span>. <span class="preprocessor">class="macro">#define </span>def_IndicatorControl&nbsp;&nbsp; <span class="class="type">class="kw">string">"Indicators\\Market Replay.ex5"</span>
<span class="number">class="num">008</span>. <span class="preprocessor">class="macro">#resource </span><span class="class="type">class="kw">string">"\\"</span> + def_IndicatorControl
<span class="number">class="num">009</span>. <span class="comment">class=class="str">"cmt">//+------------------------------------------------------------------+</span>
<span class="number">class="num">010</span>. <span class="preprocessor">class="macro">#define </span>def_CheckLoopService((!<span class="predefines">_StopFlag</span>) &amp;&amp; (<span class="functions">ChartSymbol</span>(m_Infos.IdReplay) != <span class="class="type">class="kw">string">""</span>))
<span class="number">class="num">011</span>. <span class="comment">class=class="str">"cmt">//+------------------------------------------------------------------+</span>
<span class="number">class="num">012</span>. <span class="preprocessor">class="macro">#define </span>def_ShortNameIndControl&nbsp;&nbsp; <span class="class="type">class="kw">string">"Market Replay Control"</span>
<span class="number">class="num">013</span>. <span class="preprocessor">class="macro">#define </span>def_MaxSlider&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; (def_MaxPosSlider + <span class="number">class="num">1</span>)
<span class="number">class="num">014</span>. <span class="comment">class=class="str">"cmt">//+------------------------------------------------------------------+</span>
<span class="number">class="num">015</span>. <span class="keyword">class</span> C_Replay : <span class="keyword">class="kw">public</span> C_ConfigService
<span class="number">class="num">016</span>. {
<span class="number">class="num">017</span>.&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="kw">private</span>&nbsp;&nbsp; :
<span class="number">class="num">018</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <span class="keyword">class="kw">struct</span> st00
<span class="number">class="num">019</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; {
<span class="number">class="num">020</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;C_Controls::eObjectControl Mode;
<span class="number">class="num">021</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;uCast_Double&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; Memory;
<span class="number">class="num">022</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="type">class="kw">ushort</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; Position;
<span class="number">class="num">023</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="type">int</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Handle;
<span class="number">class="num">024</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; }m_IndControl;
<span class="number">class="num">025</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <span class="keyword">class="kw">struct</span> st01
<span class="number">class="num">026</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; {
<span class="number">class="num">027</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="type">long</span>&nbsp;&nbsp;&nbsp;&nbsp; IdReplay;

控制面板的双向同步机制

回放系统的指标控制块用一段共享内存与控制面板通信,EA 侧通过 UpdateIndicatorControl 读状态、写位置,避免每帧都重建句柄。 当 m_IndControl.Handle 为 INVALID_HANDLE 时直接 return,说明指标未加载完就别碰缓冲区,否则 CopyBuffer 会吞掉一帧。 若面板内存里的 eCtrlPosition 与当前 Position 一致,则只做两件事:CopyBuffer 取 1 根数据写进 dValue(成功返回值为 1),以及当状态字节被置为 ePlay 时把 Position 同步回内存。 位置不一致的分支里,代码把 'D' 'M' 写进 _8b[7]、_8b[6],再发 EventChartCustom 通知图表初始化回放——这两个 ASCII 标记是调试时区分本 EA 控制块的依据。 外汇与贵金属回放涉及杠杆与滑点,实盘验证前请在 MT5 策略测试器用历史数据跑通事件链路,高波动品种可能丢自定义事件。

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 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];
   } else
   {
    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=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void SweepAndCloseChart(class="type">void)
  {
   class="type">long id;

◍ 回放图表清理与价差注入的实现细节

在回放引擎初始化阶段,需要先扫掉同名回放图表,避免旧窗口堆积。下面这段用 ChartFirst/ChartNext 遍历所有图表,遇到 Symbol 等于 def_SymbolReplay 的就 ChartClose 掉,逻辑直白但容易漏写 while 条件导致死循环。 [CODE] if ((id = ChartFirst()) > 0) do { if (ChartSymbol(id) == def_SymbolReplay) ChartClose(id); }while ((id = ChartNext(id)) > 0); [/CODE] RateUpdate 负责把合成价差写进自定义品种。st_Spread 用 static 保持状态:bCheck 为真时按宏取时间重置,否则自增 1;最终通过 CustomRatesUpdate 推给 def_SymbolReplay。注意 def_MaskTimeService 是按位或进去的,别当成普通加法。 CreateBarInReplay 里用 rand() 做微扰:当 iRand 落在 29080 到 32767 之间,有概率给 dSpread 加一个 PointsPerTick。这个区间约占 rand() 全正值域(0~32767)的 11.2%,意味着大约每九根 tick 才可能出现一次加点差,回测时价差分布会偏稀疏。外汇与贵金属回放带杠杆,实测价差模型偏差会放大滑点风险,上 MT5 跑前先确认 PointsPerTick 取值。 别把正态当圣经 rand() 的区间切割是人为硬阈值,不是统计分布拟合;直接用真实点差样本替换 iRand 判断,回放才接近实盘跳动。

MQL5 / C++
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();
  class="kw">static class="type">int st_Spread = class="num">0;

  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;

「回放引擎里的报价重建与图表线切换」

在自定义回放品种上,tick 的 bid/ask 并非直接来自历史,而是围绕 last 价格按固定点差 dSpread 反推。当 last 高于当前 bid 时,代码把 bid 设为 last 减点差、ask 维持 last;当 last 低于 bid 时,ask 设为 last 加点差、bid 取 last 本身。 [CODE] m_Infos.tick[0].bid = m_Infos.tick[0].last - dSpread; } else if (m_Infos.tick[0].last < m_Infos.tick[0].bid) { m_Infos.tick[0].ask = m_Infos.tick[0].last + dSpread; m_Infos.tick[0].bid = m_Infos.tick[0].last; } [/CODE] 这段逻辑意味着:回放流里的买卖边界完全由 last 相对位置决定,点差恒定,不反映真实流动性扰动。 重建完 tick 后,若开启可视开关,CustomTicksAdd 把 tick 推入 def_SymbolReplay,RateUpdate(true) 强制刷新。随后用 macroGetTime 取时间戳做掩码写进 spread 字段,CustomRatesUpdate 更新 M1 柱。 AdjustViewDetails 负责按品种模式切图表线:外汇模式(PRICE_FOREX)才显示 ask/bid 线,交易所模式(PRICE_EXCHANGE)显示 last 线。PointsPerTick 取自 SYMBOL_TRADE_TICK_SIZE,CopyRates 拉 1 根 M1 柱;若交易所模式且首根量为 0,则循环跳过前置零量 tick。外汇与贵金属回放属高风险验证,真实点差跳变不会被这套静态模型捕获。

MQL5 / C++
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;
}

回放引擎里的报价与仓位对齐逻辑

这段逻辑干了两件事:把当前 K 线收价塞进自定义回放品种的 tick 结构,以及按滑动条位置把历史速率数据推回图表。外汇与贵金属回放属高风险模拟,实盘参数偏差可能导致走势错位。 127~136 行处理 tick 赋值。若是最后一笔(index 0)且走 else 分支,就用 close 当 bid,ask 在 close 上加 spread 乘 PointsPerTick;time_msc 直接拿 time 乘 1000 毫秒。若不是实时合成,则从 GetInfoTicks 取历史 tick 再 CustomTicksAdd 写入 def_SymbolReplay。 140~150 行的 AdjustPositionToReplay 是滑动条联动核心。144 行先比对新旧位置,相等就 return;145 行把滑动条值按 def_MaxSlider 换算成 nPos 索引;146 行正向扫 Rate 数组,用 spread 字段累进 CountReplay;147 行若扫到数据就 CustomRatesUpdate 局部刷新。 148~149 行用 while 循环在 nPos 超过已回放数量时持续 CreateBarInReplay(false),直到追上滑动条。def_CheckLoopService 为假会跳出,避免卡死。开 MT5 把 def_MaxSlider 调到 1000 以上,拖滑动条能明显感到 147 行刷新粒度变化。

MQL5 / C++
class="num">127.                m_Infos.tick[class="num">0].last = rate[class="num">0].close;
class="num">128.                else
class="num">129.                {
class="num">130.                  m_Infos.tick[class="num">0].bid = rate[class="num">0].close;
class="num">131.                  m_Infos.tick[class="num">0].ask = rate[class="num">0].close + (rate[class="num">0].spread * m_Infos.PointsPerTick);
class="num">132.                }
class="num">133.                m_Infos.tick[class="num">0].time = rate[class="num">0].time;
class="num">134.                m_Infos.tick[class="num">0].time_msc = rate[class="num">0].time * class="num">1000;
class="num">135.             }else
class="num">136.                m_Infos.tick[class="num">0] = GetInfoTicks().Info[m_Infos.CountReplay];
class="num">137.             CustomTicksAdd(def_SymbolReplay, m_Infos.tick);
class="num">138.             }
class="num">139. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">140.     class="type">void AdjustPositionToReplay(class="type">void)
class="num">141.     {
class="num">142.       class="type">int nPos, nCount;
class="num">143.       
class="num">144.       if (m_IndControl.Position == (class="type">int)((m_Infos.CountReplay * def_MaxSlider) / m_MemoryData.nTicks)) class="kw">return;
class="num">145.       nPos = (class="type">int)((m_MemoryData.nTicks * m_IndControl.Position) / def_MaxSlider);
class="num">146.       for (nCount = class="num">0; m_MemoryData.Rate[nCount].spread < nPos; m_Infos.CountReplay = m_MemoryData.Rate[nCount++].spread);
class="num">147.       if (nCount > class="num">0) CustomRatesUpdate(def_SymbolReplay, m_MemoryData.Rate, nCount - class="num">1);
class="num">148.       class="kw">while ((nPos > m_Infos.CountReplay) && def_CheckLoopService)
class="num">149.          CreateBarInReplay(class="kw">false);
class="num">150.     }
class="num">151. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">152.   class="kw">public  :
class="num">153. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">154.    C_Replay()
class="num">155.    :C_ConfigService()
class="num">156.    {
class="num">157.       Print("************** Market Replay Service **************");
class="num">158.       srand(GetTickCount());
class="num">159.       SymbolSelect(def_SymbolReplay, class="kw">false);

◍ 自定义回放品种的清理与重建逻辑

在 MT5 里做历史回放,第一步往往是先把上一次残留的自定义品种清掉,再按固定规格重建。下面这段析构与初始化衔接的代码,把删除、创建、参数归零的动作串在了一起,读者可以直接对照开 MT5 的自定义品种管理器验证。 CustomSymbolDelete 先删掉名为 def_SymbolReplay 的旧品种;随后 CustomSymbolCreate 用 StringFormat 拼出 "Custom\\%s" 路径重建。紧接着连续三个 CustomSymbolSetDouble 把 tick size、tick value、volume step 全设为 0,说明这个回放品种在创建初期不绑定任何真实报价尺度,等后续 OpenChartReplay 再补。 重建后还设了 DIGITS=8、描述串 "Symbol for replay / simulation",并用 SymbolSelect 置为可见。控制结构体里 CountReplay 清零、指标句柄标 INVALID_HANDLE、回放模式锁成 ePause,相当于把状态机复位到待命点。 析构函数 ~C_Replay 反向操作:先 SweepAndCloseChart 关图,IndicatorRelease 放指标句柄,SymbolSelect(false) 取消可见,最后 CustomSymbolDelete 真删除,并打印 "Finished replay service..."。这套对称生命周期在外接 EA 反复调试时,能避免自定义品种越堆越多把终端拖慢。 OpenChartReplay 开场两道闸:若 SYMBOL_TRADE_TICK_SIZE 或 SYMBOL_TRADE_TICK_VALUE 仍为 0,直接 MsgError 返回,提示资产配置没补全。也就是说前面那三个 SetDouble 里至少后两个得在开图前补上非零值,否则回放图打不开。外汇与贵金属回测涉及高杠杆与滑点风险,自定义品种仅用于本地模拟,不代表实盘可能收益。

MQL5 / C++
CustomSymbolDelete(def_SymbolReplay);
CustomSymbolCreate(def_SymbolReplay, StringFormat("Custom\\%s", def_SymbolReplay));
CustomSymbolSetDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_SIZE, class="num">0);
CustomSymbolSetDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_VALUE, class="num">0);
CustomSymbolSetDouble(def_SymbolReplay, SYMBOL_VOLUME_STEP, class="num">0);
CustomSymbolSetString(def_SymbolReplay, SYMBOL_DESCRIPTION, "Symbol for replay / simulation");
CustomSymbolSetInteger(def_SymbolReplay, SYMBOL_DIGITS, class="num">8);
SymbolSelect(def_SymbolReplay, true);
m_Infos.CountReplay = class="num">0;
m_IndControl.Handle = INVALID_HANDLE;
m_IndControl.Mode = C_Controls::ePause;
m_IndControl.Position = class="num">0;
m_IndControl.Memory._16b[C_Controls::eCtrlPosition] = C_Controls::eTriState;
}
class=class="str">"cmt">//+------------------------------------------------------------------+
~C_Replay()
  {
   SweepAndCloseChart();
   IndicatorRelease(m_IndControl.Handle);
   SymbolSelect(def_SymbolReplay, class="kw">false);
   CustomSymbolDelete(def_SymbolReplay);
   Print("Finished replay service...");
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">bool OpenChartReplay(class="kw">const ENUM_TIMEFRAMES arg1, class="kw">const class="type">class="kw">string szNameTemplate)
  {
   if (SymbolInfoDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_SIZE) == class="num">0)
     class="kw">return MsgError("Asset configuration is not complete, it remains to declare the size of the ticket.");
   if (SymbolInfoDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_VALUE) == class="num">0)
     class="kw">return MsgError("Asset configuration is not complete, need to declare the ticket value.");

「回放图表初始化与控件挂载」

做历史回放前,EA 会先确认交易品种的最小成交量步长。若 SymbolInfoDouble 读到的 SYMBOL_VOLUME_STEP 为 0,说明资产配置没填完,直接弹错退出,不往下走。 通过校验后,脚本先清掉旧图、用 ChartOpen 开一张回放专用图,再套用同名 .tpl 模板;套用失败会在日志打印提示并退回 default.tpl,成功则打印确认。这套动作确保回放界面和指标布局一致。 InitBaseControl 里有个 1000 毫秒的等待,然后以 200 毫秒轮询去抓"Indicator Mouse Study"。抓不到句柄就一直睡,直到拿到或开关被关。接着挂自定义控制指标(iCustom 指向 def_IndicatorControl,并把回放图 ID 当参数传进去),再 ChartIndicatorAdd 到主窗口。 外汇与贵金属回放测试属高概率验证手段,但不代表实盘信号有效,实盘仍可能因点差滑点失效。

MQL5 / C++
if (SymbolInfoDouble(def_SymbolReplay, SYMBOL_VOLUME_STEP) == class="num">0)
   class="kw">return MsgError("Asset configuration not complete, need to declare the minimum volume.");
SweepAndCloseChart();
m_Infos.IdReplay = ChartOpen(def_SymbolReplay, arg1);
if (!ChartApplyTemplate(m_Infos.IdReplay, szNameTemplate + ".tpl"))
   Print("Failed apply class="kw">template: ", szNameTemplate, ".tpl Using class="kw">template class="kw">default.tpl");
else
   Print("Apply class="kw">template: ", szNameTemplate, ".tpl");

class="kw">return true;
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">bool InitBaseControl(class="kw">const class="type">class="kw">ushort wait = class="num">1000)
   {
   Print("Waiting for Mouse Indicator...");
   Sleep(wait);
   class="kw">while ((def_CheckLoopService) && (ChartIndicatorGet(m_Infos.IdReplay, class="num">0, "Indicator Mouse Study") == INVALID_HANDLE)) Sleep(class="num">200);
   if (def_CheckLoopService)
      {
      AdjustViewDetails();
      Print("Waiting for Control Indicator...");
      if ((m_IndControl.Handle = iCustom(ChartSymbol(m_Infos.IdReplay), ChartPeriod(m_Infos.IdReplay), "::" + def_IndicatorControl, m_Infos.IdReplay)) == INVALID_HANDLE) class="kw">return class="kw">false;
      ChartIndicatorAdd(m_Infos.IdReplay, class="num">0, m_IndControl.Handle);
      UpdateIndicatorControl();
      }
   
   class="kw">return def_CheckLoopService;
   }
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">bool LoopEventOnTime(class="type">void)
   {

回放引擎里的节拍控制逻辑

这段回放核心循环干的事很直接:先卡住等用户把控制状态切到播放(ePlay),没切就每 200 毫秒刷一次控件并 Sleep(200),避免空转吃 CPU。 真正跑数据时在 231 行起第二个 while:只要已回放 tick 数小于内存里的总 tick 数(m_MemoryData.nTicks)且服务标记有效,就逐 tick 推进。每次用相邻两 tick 的 time_msc 差值累加进 iPos,再调用 CreateBarInReplay(true) 生成棒线。 节奏节流点藏在 236 行:iPos 超过 200 毫秒当量后,内层循环每 195 毫秒 Sleep 一次、扣减 200,同时把滑块位置按 (已回放/总tick)*def_MaxSlider 映射到 ushort,并每 4 个周期调一次 RateUpdate(false) 刷新速率显示。外汇与贵金属回测属高风险场景,这类重播仅用于策略验证,不预示实盘盈亏。 末行返回判定很硬:只有回放 tick 数等于总 tick 数且服务标记仍有效,才视为正常结束;中途 ePause 会在 233 行直接 return true 交出控制权。

MQL5 / C++
class="type">int iPos, iCycles;

class="kw">while ((def_CheckLoopService) && (m_IndControl.Mode != C_Controls::ePlay))
{
UpdateIndicatorControl();
Sleep(class="num">200);
}
m_MemoryData = GetInfoTicks();
AdjustPositionToReplay();
iPos = iCycles = class="num">0;
class="kw">while ((m_Infos.CountReplay < m_MemoryData.nTicks) && (def_CheckLoopService))
{
if (m_IndControl.Mode == C_Controls::ePause) class="kw">return true;
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);
CreateBarInReplay(true);
class="kw">while ((iPos > class="num">200) && (def_CheckLoopService) && (m_IndControl.Mode != C_Controls::ePause))
{
Sleep(class="num">195);
iPos -= class="num">200;
m_IndControl.Position = (class="type">class="kw">ushort)((m_Infos.CountReplay * def_MaxSlider) / m_MemoryData.nTicks);
UpdateIndicatorControl();
iCycles = (iCycles == class="num">4 ? RateUpdate(class="kw">false) : iCycles + class="num">1);
}
}

class="kw">return ((m_Infos.CountReplay == m_MemoryData.nTicks) && (def_CheckLoopService));
}; }; class=class="str">"cmt">//+------------------------------------------------------------------+
class="macro">#undef def_SymbolReplay
class="macro">#undef def_CheckLoopService
class="macro">#undef def_MaxSlider
class=class="str">"cmt">//+------------------------------------------------------------------+

◍ 鼠标指标怎么识别竞价状态

想直观搞懂前面那套回放逻辑,可以对低流动性品种跑模拟分时报价,省掉实盘干等的耗时。配套的暂停播放演示视频里,已经把跳过空白时段的手法过了一遍。 还剩一个没拆干净的点:鼠标指标靠什么提示品种切进竞价模式。这事儿不简单,光讲清楚就够单独开一篇。指标在实盘挂实时数据时会明确标出竞价起点,但回放用的是自定义合成品种,信号判定就卡住了。 具体卡在哪个环节、回放里怎么绕过,留到下一篇接着说。外汇和贵金属本身高波动高杠杆,这类模拟结论只供验证思路,别直接当实盘依据。

常见问题

在主循环里用报价时间戳强制校正状态机时钟,别只靠定时器;每次新报价进来先对齐再推进状态,能避免错位。
在OnCalculate里直接取最新tick写入队列,状态机每帧从队列消费,别在定时器里另开线程取价,丢帧概率更低。
小布可接入你的回放日志,自动标出时钟跳变和长间隔等待,并提示哪段报价重建出了岔子,省去手动翻代码。
给等待设最大超时并注入伪报价唤醒状态机;同时清理面板残留同步锁,避免双向同步死锁导致假死。
在引擎退出时显式移除价差对象并重置图表线引用,重建报价前先判空,能防止线切换残留导致的乱跳。