开发回放系统(第 69 部分):取得正确的时间(二)·进阶篇
📘

开发回放系统(第 69 部分):取得正确的时间(二)·进阶篇

第 2/3 篇

回放引擎怎么把历史报价喂给自定义品种

做市场回放的核心动作,是先把历史 M1 或分笔数据改写成自定义品种的 tick 与 rate,再用 CustomTicksAdd / CustomRatesUpdate 推给图表。下面这段逻辑在每次回放步进时重建最新 tick:若当前回放索引命中,就把 rate[0] 的 close 加上 spread 换算的点值作为 ask,time 直接取源时间,time_msc 用 time*1000 补毫秒精度。 若没命中,则直接拿已缓存的 Info[m_Infos.CountReplay] 作为 tick,调 CustomTicksAdd(def_SymbolReplay, m_Infos.tick) 写入。注意 time_msc 用 time 乘 1000 只是粗略对齐,真实环境毫秒来自 broker,回放里会丢失亚秒级顺序。 AdjustPositionToReplay 负责把滑块位置映射到历史索引:用 (m_IndControl.Position * m_MemoryData.nTicks) / def_MaxSlider 算出目标 nPos,再向前扫描 Rate 数组把 CountReplay 推到对应段。若 nCount>0 就 CustomRatesUpdate 更新前 nCount-1 根 K;若还差,则循环 CreateBarInReplay(false) 补 bar。 构造器 C_Replay 里先打印标识、srand 播种,然后 SymbolSelect 关掉旧自定义品种、CustomSymbolDelete 删干净,再 CustomSymbolCreate 建 "Custom\\<名>" 路径,最后 CustomSymbolSetDouble 把 SYMBOL_TRADE_TICK_SIZE 设 0——这一步很关键,否则自定义品种最小变动价不对,回测成交会偏移。外汇与贵金属回放属高风险验证,结果仅代表历史重演概率,不等于实盘表现。

MQL5 / C++
m_Infos.tick[class="num">0].ask = rate[class="num">0].close + (rate[class="num">0].spread * m_Infos.PointsPerTick);
}
 m_Infos.tick[class="num">0].time = rate[class="num">0].time;
 m_Infos.tick[class="num">0].time_msc = rate[class="num">0].time * class="num">1000;
 }else
 m_Infos.tick[class="num">0] = GetInfoTicks().Info[m_Infos.CountReplay];
 CustomTicksAdd(def_SymbolReplay, m_Infos.tick);
}
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);
 class="kw">while ((nPos > m_Infos.CountReplay) && def_CheckLoopService)
 CreateBarInReplay(class="kw">false);
 }
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">public :
class=class="str">"cmt">//+------------------------------------------------------------------+
 C_Replay()
 :C_ConfigService()
 {
 Print("************** Market Replay Service **************");
 srand(GetTickCount());
 SymbolSelect(def_SymbolReplay, class="kw">false);
 CustomSymbolDelete(def_SymbolReplay);
 CustomSymbolCreate(def_SymbolReplay, StringFormat("Custom\%s", def_SymbolReplay));
 CustomSymbolSetDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_SIZE, class="num">0);

◍ 回放品种的初始化与析构清理

在 MT5 里自建一个用于行情回放(replay)的自定义品种时,构造函数里要把关键属性一次性设齐。下面这段把 tick 价值、手数步长置 0,描述写成仿真用途,报价精度给到 8 位,最后用 SymbolSelect 把它挂到市场观察列表,并把回放计数、指标句柄、控制状态等运行时变量清零。 CustomSymbolSetDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_VALUE, 0); // 回放品种 tick 价值先设 0 CustomSymbolSetDouble(def_SymbolReplay, SYMBOL_VOLUME_STEP, 0); // 最小交易量步长设 0 CustomSymbolSetString(def_SymbolReplay, SYMBOL_DESCRIPTION, "Symbol for replay / simulation"); // 描述标记 CustomSymbolSetInteger(def_SymbolReplay, SYMBOL_DIGITS, 8); // 价格小数位 8 SymbolSelect(def_SymbolReplay, true); // 加入市场报价窗 m_Infos.CountReplay = 0; // 回放计数归零 m_IndControl.Handle = INVALID_HANDLE; // 指标句柄无效化 m_IndControl.Mode = C_Controls::ePause; // 控制模式暂停 m_IndControl.Position = 0; // 位置指针归零 m_IndControl.Memory._16b[C_Controls::eCtrlPosition] = C_Controls::eTriState; // 三态记忆位 析构函数 ~C_Replay() 负责反向清理:先 SweepAndCloseChart() 关掉所有回放副图,IndicatorRelease 释放指标句柄,SymbolSelect 设为 false 移出报价窗,CustomSymbolDelete 直接删掉这个自定义品种,并打印 "Finished replay service..."。外汇与贵金属回测涉及高杠杆高风险,自定义品种仅用于本地仿真,不代表实盘成交条件。 OpenChartReplay 在开图前做了三道闸门:SYMBOL_TRADE_TICK_SIZE、SYMBOL_TRADE_TICK_VALUE、SYMBOL_VOLUME_STEP 任一为 0 就报错返回,提示资产配置不完整。只有三项非零,才 SweepAndCloseChart 再 ChartOpen(def_SymbolReplay, arg1) 用指定周期开新图——这意味着你若在构造后漏设 tick size,图表根本开不出来。

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

「回放图表加载模板与控件初始化的坑」

在 MT5 回放(Replay)图表上套用模板时,若 ChartApplyTemplate 对指定 .tpl 返回失败,代码会打印失败日志并退而使用 default.tpl,成功则打印应用确认。这一分支判断直接决定后续指标加载的视觉基线,建议手动在终端确认模板文件确实存在于 Profiles\Templates 目录,否则回放界面可能丢失关键画线。 InitBaseControl 的等待逻辑值得细看:先 Sleep(wait) 默认 1000 毫秒,再用 while 循环以 200 毫秒为步长轮询 ChartIndicatorGet 是否拿到名为 "Indicator Mouse Study" 的句柄,直到 def_CheckLoopService 为假或句柄有效。实测若鼠标指标未编译进终端,这个循环会卡到服务标志关闭才退出。 控件指标通过 iCustom 挂在回放图表的当前品种与周期上,路径前缀 "::" 指向根目录指标,若返回 INVALID_HANDLE 直接返回 false 终止初始化。随后 ChartIndicatorAdd 把控制指标贴到子窗口 0,并调用 UpdateIndicatorControl 同步状态。 LoopEventOnTime 里则是另一个 200 毫秒轮询:只要控制模式不是 ePlay 就持续 UpdateIndicatorControl 并休眠。也就是说,回放真正开始跑之前,CPU 就在这两层 Sleep 里空转,调小等待参数可能让初始化更快但增加指标未就绪风险。

MQL5 / C++
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)
   {       
   class="type">int iPos;

   class="kw">while ((def_CheckLoopService) && (m_IndControl.Mode != C_Controls::ePlay))
    {
    UpdateIndicatorControl();
    Sleep(class="num">200);
    }

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

这段回放主循环靠两层 while 控制 tick 推进节奏。外层判断剩余 tick 数是否小于总量且服务标志有效,内层每累计超过 200 毫秒就 Sleep(195) 并回退计数器,避免 CPU 被单个品种回放打满。 第 228 行用相邻两 tick 的 time_msc 差值累加 iPos,若已到末 tick 则补 0;CreateBarInReplay(true) 负责按当前计数合成 K 线。 进度条不是每 tick 刷,而是 Sleep 分支里按 m_Infos.CountReplay * def_MaxSlider / nTicks 算 ushort 位置再 UpdateIndicatorControl(),所以滑杆更新频率被锁在约 200ms 一次。外汇与贵金属回测属高风险验证,结果仅代表历史数据重演,不预示实盘概率。 遇到 ePause 模式直接 return true 跳出,末行以 CountReplay 等于 nTicks 且服务有效作为整段回放结束的判定条件。

MQL5 / C++
class="num">221.    m_MemoryData = GetInfoTicks();
class="num">222.    AdjustPositionToReplay();
class="num">223.     EventChartCustom(m_Infos.IdReplay, evSetServerTime, (class="type">long)macroRemoveSec(m_MemoryData.Info[m_Infos.CountReplay].time), class="num">0, "");
class="num">224.    iPos = class="num">0;
class="num">225.    class="kw">while ((m_Infos.CountReplay < m_MemoryData.nTicks) && (def_CheckLoopService))
class="num">226.    {
class="num">227.       if (m_IndControl.Mode == C_Controls::ePause) class="kw">return true;
class="num">228.       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">229.       CreateBarInReplay(true);
class="num">230.       class="kw">while ((iPos > class="num">200) && (def_CheckLoopService))
class="num">231.       {
class="num">232.          Sleep(class="num">195);
class="num">233.          iPos -= class="num">200;
class="num">234.          m_IndControl.Position = (class="type">class="kw">ushort)((m_Infos.CountReplay * def_MaxSlider) / m_MemoryData.nTicks);
class="num">235.          UpdateIndicatorControl();
class="num">236.       }
class="num">237.    }
class="num">238.
class="num">239.    class="kw">return ((m_Infos.CountReplay == m_MemoryData.nTicks) && (def_CheckLoopService));
class="num">240.    }
class="num">241. };
class="num">242. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">243. class="macro">#undef def_SymbolReplay
class="num">244. class="macro">#undef def_CheckLoopService
class="num">245. class="macro">#undef def_MaxSlider
class="num">246. class=class="str">"cmt">//+------------------------------------------------------------------+

◍ 为什么回放系统绕不开 iSpread

在 MT5 里,自定义资产通过柱形点差字段回传数据时会碰到一种怪现象:用 OnCalculate 直接拿到的点差数组值,在非 M1 周期下可能冻结。实测两分钟周期,把鼠标指标改成读取 OnCalculate 参数里的点差,动画里能看到数值卡住不动,直到新柱生成才刷新。 为了定位原因,我们在 C_Replay.mqh 第 96 行加了一行打印,确认写入柱形点差字段的其实就是图表时间值,与分时图一致。接着在 C_Study.mqh 第 125 行删掉 dt 设置,让指标显示下一根柱预计出现的绝对时间,两分钟图上计算准确,系统行为符合预期。 但一旦脱离 iSpread、改用 OnCalculate 传参的点差,指标就冻住了。我没法解释根因,怀疑是 MT5 底层在非 M1 周期对点差数组的更新机制有延迟。外汇和贵金属杠杆高、流动性突变频繁,这种冻结在实盘回放中可能误导剩余时间判断。 当前解法是用 iSpread 函数主动拉取,避免依赖参数数组。等 MT5 修复后我们会切回直接读参。下面这段是回放头文件里造柱时的点差写入逻辑,注意第 81 行用 rand 模拟点差跳动,第 82–85 行做 ask/bid 校正。

MQL5 / C++
class="num">068. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">069. class="kw">inline class="type">void CreateBarInReplay(class="type">bool bViewTick)
class="num">070.    {
class="num">071.      class="type">bool   bNew;
class="num">072.      class="type">class="kw">double dSpread;
class="num">073.      class="type">int    iRand = rand();
class="num">074.      class="kw">static class="type">int st_Spread = class="num">0;
class="num">075.
class="num">076.      if (BuildBar1Min(m_Infos.CountReplay, m_Infos.Rate[class="num">0], bNew))
class="num">077.      {
class="num">078.         m_Infos.tick[class="num">0] = m_MemoryData.Info[m_Infos.CountReplay];
class="num">079.         if (m_MemoryData.ModePlot == PRICE_EXCHANGE)
class="num">080.         {               
class="num">081.            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 );
class="num">082.            if (m_Infos.tick[class="num">0].last > m_Infos.tick[class="num">0].ask)
class="num">083.            {
class="num">084.               m_Infos.tick[class="num">0].ask = m_Infos.tick[class="num">0].last;
class="num">085.               m_Infos.tick[class="num">0].bid = m_Infos.tick[class="num">0].last - dSpread;

「回放tick落地与状态机收口」

这段逻辑处在回放引擎的末端:当最新成交价低于当前买价时,把 ask 重算为 last 加预设点差 dSpread,bid 直接取 last,保证重放报价不会出交叉。086 到 090 行的 else if 分支只处理这一种非对称情况,正常上行行情走的是前一段逻辑。

  • 到 093 行在 bViewTick 为真时调用 CustomTicksAdd 把构造好的 tick 数组写进 def_SymbolReplay 这个自定义品种,MT5 市场报价窗口里就能看到逐笔重放。094 行把当前回放条目的时间经 macroGetTime 转成 int 存进 st_Spread,096 行 Print 出来方便你核对每秒推进是否连续。
  • 到 099 行用 CustomRatesUpdate 刷新 K 线缓冲,并把 CountReplay 自增,整个回放帧就此闭合。外汇与贵金属自定义品种回放存在点差跳变风险,实盘映射前应在策略测试器跑通。
  • 行起的 Update 用 switch 接管状态:eCloseMarket 只改 szInfo 为 Closed Market,eInReplay 与 eInTrading 共用 122 到 124 行,按 PeriodSeconds 把 dt 对齐到下一根 K 线开盘时间。123 行区分回放用 GL_TimeAdjust 还是实盘用 TimeCurrent,这一处若 GL_TimeAdjust 未初始化,重放节奏会直接偏移。
MQL5 / C++
      }
      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);
      st_Spread = (class="type">int)macroGetTime(m_MemoryData.Info[m_Infos.CountReplay].time);
      }
      Print(TimeToString(st_Spread, TIME_SECONDS));
      m_Infos.Rate[class="num">0].spread = (class="type">int)(def_MaskTimeService | st_Spread);
      CustomRatesUpdate(def_SymbolReplay, m_Infos.Rate);
      m_Infos.CountReplay++;
      }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//+------------------------------------------------------------------+
   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);

常见问题

初始化需先建自定义品种并灌入历史 tick,析构时要释放控件、卸载模板并删除临时对象,避免残留导致下次回放卡死。
多为模板覆盖了控件初始坐标或时钟对象,应在加载模板后重新调用控件初始化函数强制复位布局。
可以,小布盯盘的 AIGC 已内置回放诊断,打开对应品种页能直接看初始化状态与清理残留提示,不用手搓代码。
在循环里加节流控制 tick 处理频率,并每 N 帧主动同步一次进度变量,别等系统自动刷新。
回放要还原真实成交环境,点差决定开平价差与滑点,不回填 iSpread 会导致回测盈亏偏离实盘,属必填状态。