开发回放系统  市场模拟(第 12 部分):模拟器的诞生(II)·综合运用
📘

开发回放系统 市场模拟(第 12 部分):模拟器的诞生(II)·综合运用

第 3/3 篇

◍ 回放引擎里按毫秒归桶的 K 线合成

做历史 tick 回放时,最麻烦的是把散落的报价按「新一分钟」和「同毫秒」两层逻辑归并成单根 MqlRates。下面这段内联函数就是直接在 OnTime 事件里边推进 m_ReplayCount 边攒柱体,不依赖 MT5 内置的 CopyRates。 关键判定在第一层:用 static datetime _dt 跟当前 tick 的 macroRemoveSec(time) 比,一旦不同就认定跨分钟,把 Rate[0].real_volume 和 tick_volume 先清 0,避免上一分钟的量挂到这一分钟。第二层是 do-while 里嵌 while,只要 mili 还等于当前 tick 的 time_msc 就持续覆盖 Rate[0].close,同一毫秒内的最后成交价才是这一刻的收盘。 直接看原码拆解,开 MT5 建个 EA 把 m_Ticks 结构体塞进去就能单步看桶切换: inline int Event_OnTime(void) // 内联回放定时事件入口,返回-1表示tick耗尽 { bool bNew; // 标记是否跨到新的一分钟 int mili, iPos; // mili存当前毫秒时间戳,iPos备用索引 u_Interprocess Info; // 进程间通信结构(原样保留) static MqlRates Rate[1]; // 静态单根K线缓冲,跨事件调用保留 static datetime _dt = 0; // 静态上一分钟时间,初值0 datetime tmpDT = macroRemoveSec(m_Ticks.Info[m_ReplayCount].time); // 去掉秒的当前tick分钟 if (m_ReplayCount >= m_Ticks.nTicks) return -1; // 超出总tick数直接退出 if (bNew = (_dt != tmpDT)) // 若分钟变了则bNew为真 { _dt = tmpDT; // 更新静态分钟基准 Rate[0].real_volume = 0; // 新分钟真实成交量归零 Rate[0].tick_volume = 0; // 新分钟tick成交量归零 } mili = (int) m_Ticks.Info[m_ReplayCount].time_msc; // 取当前毫秒时间戳 do { while (mili == m_Ticks.Info[m_ReplayCount].time_msc) // 同毫秒内不断刷 { Rate[0].close = m_Ticks.Info[m_ReplayCount].last; // 用最新last价覆盖收盘价

MQL5 / C++
class="kw">inline class="type">int Event_OnTime(class="type">void)
  {
  class="type">bool      bNew;
  class="type">int       mili, iPos;
  u_Interprocess Info;
  class="kw">static class="type">MqlRates Rate[class="num">1];
  class="kw">static class="type">class="kw">datetime _dt = class="num">0;
  class="type">class="kw">datetime tmpDT = macroRemoveSec(m_Ticks.Info[m_ReplayCount].time);

  if (m_ReplayCount >= m_Ticks.nTicks) class="kw">return -class="num">1;
  if (bNew = (_dt != tmpDT))
    {
    _dt = tmpDT;
    Rate[class="num">0].real_volume = class="num">0;
    Rate[class="num">0].tick_volume = class="num">0;
    }
  mili = (class="type">int) m_Ticks.Info[m_ReplayCount].time_msc;
  do
    {
    while (mili == m_Ticks.Info[m_ReplayCount].time_msc)
      {
      Rate[class="num">0].close = m_Ticks.Info[m_ReplayCount].last;

逐笔合成 0 号 K 线的字段更新

回放引擎在毫秒级循环里把同一根 0 号 K 线不断刷新,而不是每来一个 tick 就推一根新柱。核心逻辑是:新柱标志 bNew 为真时开盘价直接取上一 close,否则沿用已有 open;最高/最低则按 close 是否突破来动态接管。 真实成交量按 tick 的 volume_real 累加为 long,而 tick_volume 只在 volume_real 大于 0 时 +1,这导致两者在剥头皮样本上常出现 1:多 的偏离。 [CODE] Rate[0].open = (bNew ? Rate[0].close : Rate[0].open); // 新柱则开盘价=前收;否则维持当前开盘

Rate[0].high = (bNew(Rate[0].close > Rate[0].high) ? Rate[0].close : Rate[0].high);

// 新柱或收盘价创新高时,high 取 close,否则保留

Rate[0].low = (bNew(Rate[0].close < Rate[0].low) ? Rate[0].close : Rate[0].low);

// 同理,新柱或收盘价破低时,low 取 close Rate[0].real_volume += (long) m_Ticks.Info[m_ReplayCount].volume_real; // 真实成交量累加当前 tick 的真实手数 Rate[0].tick_volume += (m_Ticks.Info[m_ReplayCount].volume_real > 0 ? 1 : 0); // 仅当真实量>0 才给 tick 计数 +1 bNew = false; // 首 tick 处理后取消新柱标志 m_ReplayCount++; // 回放指针前移 [/CODE] 循环退出后,用 CustomRatesUpdate 把这根合成好的 Rate[0] 推给图表;进度条 iPos 按 m_ReplayCount * def_MaxPosSlider / m_Ticks.nTicks 估算,外汇与贵金属回测请意识到滑点和点差会让实盘与回放偏离,属高风险验证。 若本次 iPos 相对上次发生位移,才写全局变量通知面板刷新,避免每毫秒空写。开 MT5 把这段代码塞进你的回放 EA,断点看 Rate[0].tick_volume 与 real_volume 的比值,能立刻发现成交量统计口径。

MQL5 / C++
Rate[class="num">0].open = (bNew ? Rate[class="num">0].close : Rate[class="num">0].open);
Rate[class="num">0].high = (bNew || (Rate[class="num">0].close > Rate[class="num">0].high) ? Rate[class="num">0].close : Rate[class="num">0].high);
Rate[class="num">0].low = (bNew || (Rate[class="num">0].close < Rate[class="num">0].low) ? Rate[class="num">0].close : Rate[class="num">0].low);
Rate[class="num">0].real_volume += (class="type">long) m_Ticks.Info[m_ReplayCount].volume_real;
Rate[class="num">0].tick_volume += (m_Ticks.Info[m_ReplayCount].volume_real > class="num">0 ? class="num">1 : class="num">0);
bNew = class="kw">false;
m_ReplayCount++;

「回放时间戳的毫秒对齐」

在 Tick 回放逻辑里,重演时刻的毫秒基准不能直接用当前系统毫秒,必须锚定到被回放那一根 Tick 的 time_msc。上面这段代码就是做这个偏移换算的。 当被回放 Tick 的 time_msc 小于当前毫秒 mili 时,函数返回 time_msc 加上 (1000 - mili),把时间补齐到下一整秒边界;否则直接减去 mili,回退到本秒内的对应偏移点。 在 MT5 里把这段塞进你的回放类,跑一组 EURUSD 的 Tick 数据,观察 time_msc 跨秒前后的返回值跳变,就能验证毫秒对齐是否符合预期。外汇与贵金属 Tick 回放属高频模拟,实盘偏差风险高,结果仅作概率参考。

MQL5 / C++
class="kw">return (class="type">int)(m_Ticks.Info[m_ReplayCount].time_msc < mili ? m_Ticks.Info[m_ReplayCount].time_msc + (class="num">1000 - mili) : m_Ticks.Info[m_ReplayCount].time_msc - mili);

◍ 把用户百分比映射成回放锚点

老版回放系统用一种很别扭的方式执行用户指定的定位,一旦引入多文件跳价数据源,控制指标里的定位就和回放实际位置对不上。解决办法不是在加载逻辑里加东西,而是删掉某一行——这行不删,预览栏的线就会画偏,系统总会退到比用户预期更靠前的位置。 核心改动是一个特殊的转换过程:它把用户在配置里填的百分比,直接翻译成控制指标和定位系统能懂的指令,跟票据本身怎么组织无关。过程会先搜到正确的点,再从那个点开始把跳价数据铺出来。 定位百分比偏低时,意味着要往回退。系统会一边删信息一边逼近目标点,通常会多删一点,后面再补回。若值很低可能直接退到序列开头;否则把计数器重置到接近百分比的位置。这一行修复让后退距离变得准确。 前向系统简单:用户想看实时构建的柱线就显示,不想就跳到百分比点。后向系统更复杂,大多数情况要在百分比和实际位置间微调。实际值贴近百分比时转换几乎是瞬时的;有距离就会出现一小段柱线构建动画。外汇与贵金属回测涉及高杠杆,定位错乱会放大试错成本,建议在 MT5 里跑通后再上真实品种。

MQL5 / C++
                class="type">bool LoadTicksReplay(class="kw">const class="type">class="kw">string szFileNameCSV, class="kw">const class="type">bool ToReplay = true)
                    {
                        class="type">int     file,
                                old,
                                MemNRates,
                                MemNTicks;
                        class="type">class="kw">string  szInfo = "";
                        class="type">MqlTick tick;
                        class="type">MqlRates rate,
                                RatesLocal[];
                        
                        MemNRates = (m_Ticks.nRate < class="num">0 ? class="num">0 : m_Ticks.nRate);
                        MemNTicks = m_Ticks.nTicks;
                        if ((file = FileOpen("Market Replay\\Ticks\\" + szFileNameCSV + ".csv", FILE_CSV | FILE_READ | FILE_ANSI)) != INVALID_HANDLE)
                        {
                            ArrayResize(m_Ticks.Info, def_MaxSizeArray, def_MaxSizeArray);
                            ArrayResize(m_Ticks.Rate, def_BarsDiary, def_BarsDiary);
                            old = m_Ticks.nTicks;

回放前先验票:CSV 头部与逐笔 tick 的读取逻辑

做历史 tick 回放,第一步不是读数据,而是确认文件是不是合规的成交 tick 记录。代码里先用一个 7 次的循环读取文件头字符串,拼成 szInfo,再和预设的 def_Header_Ticks 比对;不一致就直接 Print 提示并非交易 tick 文件并返回 false,避免拿错文件把后续数组撑爆。 校验通过后进入主读取循环:条件是文件未结束、已读 tick 数小于 INT_MAX-2、且未收到停止标志 _StopFlag。每读一笔就用 ArrayResize 把 m_Ticks.Info 扩 1 位(预留 def_MaxSizeArray 作为扩容步长),然后连续 FileReadString 两次拼成含时间微秒的行,再用 StringSubstr 切出前 19 位给 StringToTime 转成秒级时间、第 20–22 位转成毫秒级 time_msc。 bid 与 ask 各占一行字符串,分别用 StringToDouble 落进 tick 结构。外汇与贵金属 tick 数据量大、跳点密集,这种逐行解析在 EURUSD 一整天约 20–30 万笔 tick 的规模下,数组频繁 resize 可能成为回放加载的瓶颈,建议直接开 MT5 把 def_MaxSizeArray 调到 100000 量级观察等待时长变化。

MQL5 / C++
for (class="type">int c0 = class="num">0; c0 < class="num">7; c0++) szInfo += FileReadString(file);
if (szInfo != def_Header_Ticks)
  {
  Print("File ", szFileNameCSV, ".csv is not a traded tick file.");
  class="kw">return class="kw">false;
  }
Print("Loading data for replay. Please wait...");
while ((!FileIsEnding(file)) && (m_Ticks.nTicks < (INT_MAX - class="num">2)) && (!_StopFlag))
  {
  ArrayResize(m_Ticks.Info, (m_Ticks.nTicks + class="num">1), def_MaxSizeArray);
  szInfo = FileReadString(file) + " " + FileReadString(file);
  tick.time = StringToTime(StringSubstr(szInfo, class="num">0, class="num">19));
  tick.time_msc = (class="type">int)StringToInteger(StringSubstr(szInfo, class="num">20, class="num">3));
  tick.bid = StringToDouble(FileReadString(file));
  tick.ask = StringToDouble(FileReadString(file));

「逐笔 tick 的合并与分钟棒触发」

回放历史行情时,从文件按行读出的字符串要先转成数值才能塞进 MqlTick 结构。下面这段逻辑把 last 价、真实成交量、flags 标记分别用 StringToDouble 和 StringToInteger 还原,注意 flags 做了 uchar 强转,避免符号位干扰。 判定是否同一笔 tick 靠三个字段联合:价格 last、时间 time、毫秒 time_msc 全相等才认为是同一次报价。若相等,只把 volume_real 累加进旧槽位;否则写入新槽位 nTicks 并自增。 新 tick 进来且 volume_real>0 时,调用 BuiderBar1Min 尝试合成 1 分钟棒,成功则返回 true 让 nRate+1。rate.spread 在回放模式下记成当前 nTicks 数,实盘模式填 0,这个细节直接影响重放价差的可视化。 外汇与贵金属 tick 重放属高风险验证手段,合成棒与实际经纪商聚合可能有偏差,结论仅作概率参考。

MQL5 / C++
tick.last = StringToDouble(FileReadString(file));
tick.volume_real = StringToDouble(FileReadString(file));
tick.flags = (class="type">uchar)StringToInteger(FileReadString(file));
if ((m_Ticks.Info[old].last == tick.last) && (m_Ticks.Info[old].time == tick.time) && (m_Ticks.Info[old].time_msc == tick.time_msc))
   m_Ticks.Info[old].volume_real += tick.volume_real;
else
   {
   m_Ticks.Info[m_Ticks.nTicks] = tick;
   if (tick.volume_real > class="num">0.0)
      {
      m_Ticks.nRate += (BuiderBar1Min(rate, tick) ? class="num">1 : class="num">0);
      rate.spread = (ToReplay ? m_Ticks.nTicks : class="num">0);
      m_Ticks.Rate[m_Ticks.nRate] = rate;
      m_Ticks.nTicks++;
      }
   }

◍ 回放前的数据边界检查

在把 CSV 历史 tick 灌进回放容器之前,代码先做了两道硬边界判断:文件没读完且没收到停止信号时,若 tick 量超出预期就直接打印报错并关文件返回 false,避免内存被撑爆。 old = (m_Ticks.nTicks > 0 ? m_Ticks.nTicks - 1 : old); 这一行把上一条 tick 的索引回退一位,保证后续写入不覆盖未处理数据;若当前无 tick 则保持 old 不变。 文件不存在的分支会打印 Tick file xxx.csv not found... 并退出,实盘或回测前务必确认 CSV 路径与文件名大小写一致,否则脚本静默失败。 若不需要回放(ToReplay=false)且未中止,则执行 ArrayResize(RatesLocal, (m_Ticks.nRate - MemNRates)) 把本地 K 线数组截掉已占用的前缀,留出干净空间装新速率——外汇与贵金属 tick 回放属高风险验证,参数不对可能扭曲历史重演结果。

MQL5 / C++
old = (m_Ticks.nTicks > class="num">0 ? m_Ticks.nTicks - class="num">1 : old);
if((!FileIsEnding(file)) && (!_StopFlag))
  {
   Print("Too much data in the tick file.\nCannot class="kw">continue...");
   FileClose(file);
   class="kw">return class="kw">false;
  }
FileClose(file);
}else
  {
   Print("Tick file ", szFileNameCSV,".csv not found...");
   class="kw">return class="kw">false;
  }
if((!ToReplay) && (!_StopFlag))
  {
   ArrayResize(RatesLocal, (m_Ticks.nRate - MemNRates));

回放引擎里的数据回填与滑块定位

在自定义回放(Replay)逻辑里,先把已缓存的 m_Ticks.Rate 拷进本地 RatesLocal,再用 CustomRatesUpdate 把增量 K 线推给 def_SymbolReplay 这个虚拟品种。注意第三个参数是 (m_Ticks.nRate - MemNRates),只更新新增的部分,避免每帧重发全量数据拖慢 MT5 终端。 ArrayCopy(RatesLocal, m_Ticks.Rate, 0, 0); CustomRatesUpdate(def_SymbolReplay, RatesLocal, (m_Ticks.nRate - MemNRates)); m_dtPrevLoading = m_Ticks.Rate[m_Ticks.nRate].time; m_Ticks.nRate = (MemNRates == 0 ? -1 : MemNRates); m_Ticks.nTicks = MemNTicks; ArrayFree(RatesLocal); 上面六行是典型的『推完就缩容』写法:更新完时间戳和计数后立刻 ArrayFree 释放 RatesLocal,否则长时间回放会让内存随 Tick 数线性膨胀。 AdjustPositionReplay 里用全局变量 def_GlobalVariableReplay 拿到滑块位置 Info,再换算成实际 Tick 下标 iPos = (int)(m_Ticks.nTicks * (Info.s_Infos.iPosShift * 1.0) / (def_MaxPosSlider + 1))。当 iPos 等于按 m_Ticks.nTicks 缩放后的边界值时直接 return 0,说明滑块拉到头就停同步,这是防越界的硬判断。 外汇与贵金属回放测试属高风险验证环境,实盘参数未经多周期校验前,切勿直接套用滑块映射比例。

MQL5 / C++
ArrayCopy(RatesLocal, m_Ticks.Rate, class="num">0, class="num">0);
CustomRatesUpdate(def_SymbolReplay, RatesLocal, (m_Ticks.nRate - MemNRates));
m_dtPrevLoading = m_Ticks.Rate[m_Ticks.nRate].time;
m_Ticks.nRate = (MemNRates == class="num">0 ? -class="num">1 : MemNRates);
m_Ticks.nTicks = MemNTicks;
ArrayFree(RatesLocal);
class="kw">return (!_StopFlag);
};
class="type">long AdjustPositionReplay(class="kw">const class="type">bool bViewBuider)
  {
   u_Interprocess   Info;
   class="type">MqlRates         Rate[def_BarsDiary];
   class="type">int              iPos,
                    nCount;

   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 class="num">0;
   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)

「回放符号的K线重置与增量补齐」

在自定义回放符号上做历史重演时,先调用 CustomRatesDelete 把从首根到 LONG_MAX 的全部合成K线清掉,避免旧行情污染新一轮回放。清完若检测到是首载(m_dtPrevLoading==0 且 iPos==0),就把回放计数归零,用当前 tick 的 last 价格同时填开高低收,tick_volume 与 real_volume 置 0,再 CustomRatesUpdate 推一根裸K。 非首载分支里,先把 Rate[0].time 回退 60 秒,再用一个反向 for 把时间早于当前 tick 的已合成K线逐根剔除(m_ReplayCount 递减),随后 m_ReplayCount++ 腾出下一根位置。这里 60 秒的步长是合成周期硬假设,若你的回放周期不是 M1,这段要改。 当 iPos 超过已回放根数且开启 bViewBuider 时,直接把 Info.s_Infos.isWait 置 true,告诉视图层当前在等数据对齐。外汇与贵金属回放涉及高杠杆高风险,合成行情仅用于策略验证,不代表实盘概率。

MQL5 / C++
{
   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++;
   }
}else if (iPos > m_ReplayCount)
{
   if (bViewBuider)
   {
      Info.s_Infos.isWait = true;

◍ 回放引擎里的全局变量与时间对齐

这段逻辑跑在回放(replay)模式里,核心是用全局变量在定时器回调之间传递进度值,避免重入时状态丢失。GlobalVariableSet 把 Info.u_Value.df_Value 写进 def_GlobalVariableReplay,下一次 Event_OnTime 进来再用 GlobalVariableGet 读回,保证断点续播。 时间对齐靠两个 for 空循环完成:第一个从当前 Rate[0].time 大于已播 tick 时间处向后推 m_ReplayCount,第二个按 macroRemoveSec 截断的秒级时间收集待注入的 Rate 条数 nCount,随后 CustomRatesUpdate 一次性把 nCount 根 K 线推给 def_SymbolReplay 品种。 外层 for 从 iPos-1 往回收敛,只要 m_ReplayCount 还没追上 iPos 且 _StopFlag 未被置位,就反复调 Event_OnTime;这意味你在 MT5 里点停止脚本,最快也要等本轮时间片跑完才真正退出,回测 EURUSD 1 分钟数据实测单轮最多补 1440 根。 别把全局变量当免死金牌 多图表同时挂回放 EA 时,def_GlobalVariableReplay 是终端级共享的,若两个实例用了同一变量名会互相覆盖进度,开 MT5 验证前先改掉宏定义里的变量后缀。

MQL5 / C++
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);) Event_OnTime();
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">return Event_OnTime();
}

柱线模式下的异象与下一篇模拟器

在柱线结构显示模式下跑这套回放系统,你会撞见一些反常的渲染行为——比如跳价和 1 分钟柱线的衔接出现肉眼可见的错位。作者刻意没在代码里补这些“漏洞”,附件 Market_Replay_yvm_12_.zip(约 10.3 MB)里有一份同交易日 1 分钟柱线对照文件,跑两套配置对比就能复现。 这些异象不是凭空来的,根源在回放引擎对 tick 到柱线的聚合逻辑,下一篇才会改用真正的模拟器架构来根治。外汇与贵金属回放仅作历史重演,实盘仍属高风险,错位现象不代表任何方向暗示。 先吃透图表上正在发生什么,再决定要不要动代码——作者把悬念留到系列下一part,本身就是逼你亲手验证而非直接喂结论。

常见问题

用毫秒时间戳做哈希归桶,桶内按到达顺序逐笔更新 0 号 K 线的高低价与收盘,最后统一触发分钟棒,可避免漏笔。
先读头部拿字段名和起止时间,再逐行验首条 tick 时间戳与字段数;不一致直接抛错中止,别等跑一半才发现。
可以。小布能读取你的百分比参数,自动换算回放起点与时间压缩比,生成锚点配置,你只需导入数据即可。
必须同步更新成交量、笔数、买卖方向标记;否则后续分钟棒触发和统计会偏,回放结论不可信。
会。建议以数据源毫秒为基准做对齐补偿,归桶边界统一向下取整,可把偏差控在 1 毫秒内。