开发回放系统  市场模拟(第 13 部分):模拟器的诞生(III)·进阶篇
📘

开发回放系统 市场模拟(第 13 部分):模拟器的诞生(III)·进阶篇

第 2/3 篇

◍ 回放循环的判定与等待逻辑

这段代码片段展示了一个历史行情回放模块的初始化与逐笔推进结构。核心判定 return (m_ReplayCount == m_Ticks.nTicks); 表明:当已重放的 tick 数等于总 tick 数时,回放视为结束。 进入主流程前,先用 while ((iTest == 0) && (!_StopFlag)) 做就绪等待:依次检查回放图表 ChartSymbol(m_IdReplay) 是否非空、全局变量 def_GlobalVariableReplay 能否读取到、以及 Info.s_Infos.isPlay 播放标志。任意一项失败 iTest 置 -1,循环里若未就绪则 Sleep(100) 每 100 毫秒轮询一次。 若退出时 iTest < 0_StopFlag 被触发,直接 return false 中断,不进入回放。就绪后调用 AdjustPositionToReplay 并清零 m_MountBar.delay。 真正的 tick 推进在 while ((m_ReplayCount < m_Ticks.nTicks) && (!_StopFlag)) 中:CreateBarInReplay 按帧造柱,再用 iPos 计算相邻 tick 的 time_msc 毫秒差作为重放间隔。外汇与贵金属回测属高风险环境,实际间隔可能受 broker 数据质量影响而产生漂移。

MQL5 / C++
class="kw">return (m_ReplayCount == m_Ticks.nTicks);
class=class="str">"cmt">// ... declaring variables ...
 iTest = class="num">0;
 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);
 m_MountBar.delay = class="num">0;
class=class="str">"cmt">//... the rest of the code(will be discussed later) ...
class=class="str">"cmt">// ... Code from the previous loop...
 while ((m_ReplayCount < m_Ticks.nTicks) && (!_StopFlag))
 {
  CreateBarInReplay(bViewMetrics);
  iPos = (class="type">int)(m_ReplayCount < m_Ticks.nTicks ? m_Ticks.Info[m_ReplayCount].time_msc - m_Ticks.Info[m_ReplayCount - class="num">1].time_msc : class="num">0);

回放延迟累加与断点保护

这段逻辑控制历史回放时的节奏补偿:每当处理一个位置 iPos,就把它累加进 m_MountBar.delay;若 iPos 为负,则先加 1000 再累加,相当于把缺失位置折算成额外等待量。 当累计延迟突破 400 这一阈值,函数会先确认回放图表 m_IdReplay 是否还挂着品种——ChartSymbol 返回空串就直接 break,避免对已关闭图表空转。 随后通过全局变量 def_GlobalVariableReplay 读写 Info 结构:若 Info.s_Infos.isPlay 为 false 立即 return true 退出;否则按 m_ReplayCount 与总 tick 数 m_Ticks.nTicks 的比例,算出滑块位置 iPosShift 并写回全局变量。 Sleep(m_MountBar.delay - 20) 让线程让出 CPU,留 20 毫秒余量防过冲,之后把 delay 清零重新计时。外汇与贵金属回放测试属高仿真但非实盘,结果仅作概率参考。 AdjustPositionToReplay 函数体内声明了跨进程结构 u_Interprocess、MqlRates 数组 Rate[def_BarsDiary] 及整型 iPos、nCount,为后续按日线柱重定位回放坐标做准备。

MQL5 / C++
m_MountBar.delay += (iPos < class="num">0 ? iPos + class="num">1000 : iPos);
if (m_MountBar.delay > class="num">400)
  {
    if (ChartSymbol(m_IdReplay) == "") class="kw">break;
    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(m_MountBar.delay - class="num">20);
    m_MountBar.delay = class="num">0;
  }
class=class="str">"cmt">// ... The rest of the function ...
class="type">void AdjustPositionToReplay(const class="type">bool bViewBuider)
  {
    u_Interprocess Info;
    class="type">MqlRates      Rate[def_BarsDiary];
    class="type">int           iPos,  nCount;
  }

「回放进度与自定义报价流的衔接」

回放引擎用全局变量读取当前进度值,再按滑块位置换算成 tick 索引。当滑块位移等于按总 tick 数缩放后的最大位置时就直接返回,避免无意义的重绘。 具体映射关系是:iPos = (int)(m_Ticks.nTicks * (iPosShift / (def_MaxPosSlider + 1))),也就是把 0~def_MaxPosSlider 的滑块整数位映射回 0~nTicks 的 tick 序列。若 iPos 小于已回放计数,则先删掉该时间之后的自定义品种报价,再决定是冷启动还是增量追加。 冷启动只发生在 m_dtPrevLoading 为 0 且 iPos 为 0 时:把 m_ReplayCount 归零,用当前 tick 的 last 价填开高低收,成交量置 0,然后 CustomRatesUpdate 写 1 根。否则走增量分支,把 Rate[0].time 减 60 秒后向前裁剪已存在但晚于新 tick 的棒线,再 m_ReplayCount++ 续写。外汇与贵金属回放属高仿真训练,仍存在与实盘滑点、点差偏离的风险,结论仅作概率性参考。

MQL5 / C++
Info.u_Value.df_Value = GlobalVariableGet(def_GlobalVariableReplay);
if (Info.s_Infos.iPosShift == (class="type">int)((m_ReplayCount * def_MaxPosSlider * class="num">1.0) / m_Ticks.nTicks)) class="kw">return;
iPos = (class="type">int)(m_Ticks.nTicks * ((Info.s_Infos.iPosShift * class="num">1.0) / (def_MaxPosSlider + class="num">1)));
Rate[class="num">0].time = macroRemoveSec(m_Ticks.Info[iPos].time);
if (iPos < m_ReplayCount)
{
   CustomRatesDelete(def_SymbolReplay, Rate[class="num">0].time, LONG_MAX);
   if ((m_dtPrevLoading == class="num">0) && (iPos == class="num">0))
   {
      m_ReplayCount = class="num">0;
      Rate[m_ReplayCount].close = Rate[m_ReplayCount].open = Rate[m_ReplayCount].high = Rate[m_ReplayCount].low = m_Ticks.Info[iPos].last;
      Rate[m_ReplayCount].tick_volume = Rate[m_ReplayCount].real_volume = class="num">0;
      CustomRatesUpdate(def_SymbolReplay, Rate, class="num">1);
   }else
   {
      for(Rate[class="num">0].time -= class="num">60; (m_ReplayCount > class="num">0) && (Rate[class="num">0].time <= macroRemoveSec(m_Ticks.Info[m_ReplayCount].time)); m_ReplayCount--);
      m_ReplayCount++;
   }

◍ 回放越界时的等待与补帧逻辑

当当前位置 iPos 超过已回放计数 m_ReplayCount 时,系统进入越界处理分支。若处于视图构建模式 bViewBuider 为真,则仅标记等待状态并向全局变量写入当前回放值,不实际推送行情。 非视图模式下,先用空循环把 m_ReplayCount 推到最新 tick 时间之后,再定位需要补入的 tick 数量 nCount,最后调用 CustomRatesUpdate 把缺失的 Rate 批量刷新到回放品种。随后外层循环以 iPos-1 为起点反复调用 CreateBarInReplay,直到追上或收到停止信号。 回放控制依赖全局变量 def_GlobalVariableReplay 做跨函数值传递,Info.s_Infos.isWait 在补帧结束后复位为 false。外汇与贵金属回放测试仍属高风险验证环境,实盘参数须另行压力测试。

MQL5 / C++
            }
            else if (iPos > m_ReplayCount)
            {
              if (bViewBuider)
              {
                Info.s_Infos.isWait = true;
                GlobalVariableSet(def_GlobalVariableReplay, Info.u_Value.df_Value);
              }else
              {
                for(; Rate[class="num">0].time > m_Ticks.Info[m_ReplayCount].time; m_ReplayCount++);
                for (nCount = class="num">0; m_Ticks.Rate[nCount].time < macroRemoveSec(m_Ticks.Info[iPos].time); nCount++);
                CustomRatesUpdate(def_SymbolReplay, m_Ticks.Rate, nCount);
              }
            }
            for (iPos = (iPos > class="num">0 ? iPos - class="num">1 : class="num">0); (m_ReplayCount < iPos) && (!_StopFlag);) CreateBarInReplay();
            Info.u_Value.df_Value = GlobalVariableGet(def_GlobalVariableReplay);
            Info.s_Infos.isWait = class="kw">false;
            GlobalVariableSet(def_GlobalVariableReplay, Info.u_Value.df_Value);
          }
class="kw">inline class="type">void CreateBarInReplay(const class="type">bool bViewMetrics = class="kw">false)
          {
class="macro">#define def_Rate m_MountBar.Rate[class="num">0]

回放引擎里的新K线与耗时埋点

在 MT5 回放(Replay)引擎中,判断新柱体是否生成,靠的是去掉秒级的 tick 时间比对:当本次 tick 的 macroRemoveSec(time) 与上一根记忆时间 memDT 不同,就置 bNew 为真。这一判断直接决定了 open 价是沿用还是重置为当前 last。 如果开启了指标观测开关 bViewMetrics,代码会顺手做一次耗时统计:用 GetTickCount64() 差值算出两根柱体之间的处理耗时 _mdt,再拆成「分:秒.毫秒」打印出来。实盘回放时你能在日志里看到类似 14:32:05 - Metrica: 0:0.12 的输出,用来评估每根柱体回调的计算开销。 新柱触发时除了重置 memDT,还会把 real_volume 与 tick_volume 清 0,避免跨柱体积累加。随后无论新旧柱,close 永远追 tick 的 last;high/low 则在新柱时直接等于 close,旧柱时仅在击穿原极值才更新——这是典型的逐 tick 合成 OHLC 写法。 外汇与贵金属回放属高杠杆高风险场景,这类自合成柱体仅用于策略验证,和真时行情成交逻辑可能存在偏差,结论仅具概率意义。

MQL5 / C++
class="kw">static class="type">ulong _mdt = class="num">0;
class="type">int i;

if (m_MountBar.bNew = (m_MountBar.memDT != macroRemoveSec(m_Ticks.Info[m_ReplayCount].time)))
{
   if (bViewMetrics)
   {
      _mdt = (_mdt > class="num">0 ? GetTickCount64() - _mdt : _mdt);
      i = (class="type">int) (_mdt / class="num">1000);
      Print(TimeToString(m_Ticks.Info[m_ReplayCount].time, TIME_SECONDS), " - Metrica: ", i / class="num">60, ":", i % class="num">60, ".", (_mdt % class="num">1000));
      _mdt = GetTickCount64();
   }
   m_MountBar.memDT = macroRemoveSec(m_Ticks.Info[m_ReplayCount].time);
   def_Rate.real_volume = class="num">0;
   def_Rate.tick_volume = class="num">0;
}
def_Rate.close = m_Ticks.Info[m_ReplayCount].last;
def_Rate.open = (m_MountBar.bNew ? def_Rate.close : def_Rate.open);
def_Rate.high = (m_MountBar.bNew || (def_Rate.close > def_Rate.high) ? def_Rate.close : def_Rate.high);
def_Rate.low = (m_MountBar.bNew || (def_Rate.close < def_Rate.low) ? def_Rate.close : def_Rate.low);

「把逐笔tick灌进自定义K线的收尾动作」

回放引擎在每根挂载K线收口时,要把真实成交量和tick计数并进去,再推给图表。下面这段是挂载收尾的实际写法,直接决定了离线回放里volume_real与tick_volume是否失真。 代码里先给real_volume累加当前tick的真实成交量,类型是long强转;tick_volume则只看volume_real是否大于0,大于0就+1,等于0就不计,这样能避开零量报价刷屏造成的虚高tick数。 time直接取m_MountBar.memDT,挂完把bNew置false,表示这根不再是新棒。随后CustomRatesUpdate只推1根(数组长度参数写1),m_ReplayCount自增指向下一tick,最后#undef def_Rate清掉宏,防止跨棒污染。 在MT5里跑这套,若发现回放K线的tick_volume比实盘同周期明显偏低,优先查volume_real>0这个判断——有些经纪商黄金报价tick的volume_real恒为0,那时tick_volume会全塌成0。外汇与贵金属回放属高风险验证环境,参数偏差可能放大杠杆误判,仅作本地技术核对用。

MQL5 / C++
              def_Rate.real_volume += (class="type">long) m_Ticks.Info[m_ReplayCount].volume_real;
              def_Rate.tick_volume += (m_Ticks.Info[m_ReplayCount].volume_real > class="num">0 ? class="num">1 : class="num">0);
              def_Rate.time = m_MountBar.memDT;
              m_MountBar.bNew = class="kw">false;
              CustomRatesUpdate(def_SymbolReplay, m_MountBar.Rate, class="num">1);
              m_ReplayCount++;
              
class="macro">#undef def_Rate
              }

常见问题

用回放循环的判定逻辑比对已播帧数与目标帧数,未达目标就进入等待态,避免重复写同一根K线。
在延迟累加处设阈值,超过就触发断点保护重置计时,防止模拟器卡死在补帧死循环里。
小布可以读取你的自定义报价流配置,对照回放进度标记自动标出衔接断点,省去手动逐帧核对。
越界后引擎进入等待与补帧逻辑,若缺源数据就挂起等待,有数据再按序补帧,不会强行跳帧。
收尾需锁定最后一根K线并落盘缓存,确保末尾tick不丢失,同时打上耗时埋点方便查性能瓶颈。