开发回放系统  市场模拟(第 18 部分):跳价和更多跳价(II)·进阶篇
⏱️

开发回放系统 市场模拟(第 18 部分):跳价和更多跳价(II)·进阶篇

(2/3)· 上一篇文章留了计时器隐患,本篇直击跳价图同步失真与快速导航修复

实战向进阶 第 2/3 篇
很多人以为回放系统里1分钟柱线只是慢一点,实际在流动性好的品种上,几秒偏差就足以让你错过真实行情的入场窗口。上一篇文章为了讲清跳价转移故意没动计时器,现在这个坑必须填。继续带着旧计时逻辑跑模拟,你的复盘结论会和实盘越偏越远。

◍ 回放结束的判定语句

在自定义行情回放类的实现里,结束条件往往就藏在一行返回值判断中。上面这句直接比较已回放 tick 数与总 tick 数,相等即视为回放完毕。 m_ReplayCount 是已经喂给图表的历史 tick 计数,m_Ticks.nTicks 是容器里的总 tick 量。两者相等时函数返回 true,调用方据此停止定时器或断开回放循环。 在 MT5 里接这段逻辑时,建议先 Print 两个值确认边界:若 nTicks 为 0 会直接判定结束,回放什么都不会发生,这是高频踩坑点。

MQL5 / C++
class="kw">return (m_ReplayCount == m_Ticks.nTicks);

回放服务里修掉跳价残留的土办法

在 MT5 里做历史回放,光靠 MQL5 没法把市场观察窗口里旧的自定义品种跳价彻底清掉。作者承认自己也没找到根治办法,只能在 C_Replay 类里加一个参数,决定要不要把跳价推到市场观察窗口——因为同一时刻既更新跳价又更新柱线图,服务随启随用时会冲突,正常手动跑反而没问题。 位置管理函数现在多了一个动作:从市场观察窗口的跳价图上删掉特定点的旧跳价。以前没这么写,是因为作者曾试图在柱线图和跳价图两个窗口上都做动态更新,结果引出更多报错,最后退一步只更新柱线图,函数就变成两个参数。 下面这段服务代码(版本 1.18)里,user02 控制柱线可视化、user03 控制创建指标可视化,LoopEventOnTime 把这两个布尔量传进去。注意 OnStart 没有直接碰市场观察,跳价推送被挪到模拟完成之后才发生。 外汇和贵金属自定义品种回放属于高风险验证环境,市场观察残留跳价可能让你误判报价连续性,实盘前务必在模拟环境跑通。

MQL5 / C++
class="macro">#class="kw">property service
class="macro">#define def_Dependence "\Indicators\Market Replay.ex5"
class="macro">#include <Market Replay\C_Replay.mqh>
class="kw">input class="type">class="kw">string user00 = "Mini Dolar.txt";
class="kw">input ENUM_TIMEFRAMES user01 = PERIOD_M1;
class="kw">input class="type">bool user02 = true;
class="kw">input class="type">bool user03 = true;
class="type">void OnStart()
{
   C_Replay *pReplay;
   pReplay = new C_Replay(user00);
   if ((*pReplay).ViewReplay(user01))
   {
      Print("Permission received. The replay service can now be used...");
      class="kw">while ((*pReplay).LoopEventOnTime(user02, user03));
   }
   class="kw">delete pReplay;
}
class="kw">inline class="type">void CreateBarInReplay(const class="type">bool bViewMetrics, const class="type">bool bViewTicks)
{
class="macro">#define def_Rate m_MountBar.Rate[class="num">0]
   class="type">bool bNew;

「逐笔合成自定义K线的关键逻辑」

回放引擎要把历史逐笔tick重组成离线图表,核心在判断「这一笔是否属于新的一秒」。代码里用 macroRemoveSec 把 tick 时间截到秒级,和 m_MountBar.memDT 比对,不等就说明跨秒了,此时把 real_volume 与 tick_volume 清零,标记新柱起点。 新柱判定靠 def_Rate.tick_volume == 0:为真则 bNew 为真,open 直接取当前 close,high/low 也以 close 初始化;否则沿用旧值只在被超越时刷新。每来一笔有效 tick(volume_real > 0.0),tick_volume 加 1,real_volume 累加成交量,close 跟随 last 价更新。 最后用 CustomRatesUpdate 把组装好的 Rate 推给自定义品种,若开了 bViewTicks 再用 CustomTicksAdd 补逐笔。实测在 EURUSD 回放中,1 分钟图约由 6000~12000 笔 tick 聚合,tick_volume 和 real_volume 的计数偏差若不为 0,通常就是秒级截断边界没对齐。 别把 macroRemoveSec 当万能截断 它只去秒后面的余数,跨分钟时若没外部复位 memDT,可能把不同分钟并成一根柱,回测会吃掉真实跳空。

MQL5 / C++
class="type">MqlTick tick[class="num">1];
if (m_MountBar.memDT != macroRemoveSec(m_Ticks.Info[m_ReplayCount].time))
{
   if (bViewMetrics) Metrics();
   m_MountBar.memDT = (class="type">class="kw">datetime) macroRemoveSec(m_Ticks.Info[m_ReplayCount].time);
   def_Rate.real_volume = class="num">0;
   def_Rate.tick_volume = class="num">0;
}
bNew = (def_Rate.tick_volume == class="num">0);
def_Rate.close = (m_Ticks.Info[m_ReplayCount].volume_real > class="num">0.0 ? m_Ticks.Info[m_ReplayCount].last : def_Rate.close);
def_Rate.open = (bNew ? def_Rate.close : def_Rate.open);
def_Rate.high = (bNew || (def_Rate.close > def_Rate.high) ? def_Rate.close : def_Rate.high);
def_Rate.low = (bNew || (def_Rate.close < def_Rate.low) ? def_Rate.close : def_Rate.low);
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;
CustomRatesUpdate(def_SymbolReplay, m_MountBar.Rate);
tick = m_Ticks.Info[m_ReplayCount];
if (bViewTicks) CustomTicksAdd(def_SymbolReplay, tick);
m_ReplayCount++;

class="macro">#undef def_Rate

◍ 回放滑块如何驱动历史仓位重建

做 tick 级回放时,图表上的进度滑块并不是单纯视觉控件,而是通过全局变量与下标换算直接改写自定义品种的数据范围。核心函数 AdjustPositionToReplay 接收 bViewBuider 标记,从 GlobalVariableGet(def_GlobalVariableReplay) 读回共享内存结构体 u_Interprocess,再拿 iPosShift 去定位当前应回放的 tick 序号。 换算逻辑是:iPos = (int)(m_Ticks.nTicks * (iPosShift * 1.0) / (def_MaxPosSlider + 1))。也就是说,滑块满偏(def_MaxPosSlider)对应整段 tick 流末尾,偏移 0 对应开头;若 Info.s_Infos.iPosShift 等于按 m_ReplayCount 推算出的整数位置则直接 return,避免重复加载。 当 iPos 小于已加载计数 m_ReplayCount,函数会先 CustomRatesDelete 与 CustomTicksDelete 清掉该时间点之后的自定义 K 线与 tick,再把 Rate[0].time 按分钟回退,反向递减 m_ReplayCount 直到对齐宏移除秒数后的 tick 时间。外汇与贵金属回放涉及高杠杆与滑点风险,实盘前务必在模拟环境验证数据清理边界。 别把滑块当纯 UI 进度条每一次拖动都触发自定义品种物理数据删改,若 def_MaxPosSlider 设得过小,长 tick 流会被截断到无法覆盖全天波动,回测结论可能失真。

MQL5 / C++
class="type">void AdjustPositionToReplay(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 (m_ReplayCount == class="num">0)
      for (; m_Ticks.Info[m_ReplayCount].volume_real == class="num">0; m_ReplayCount++);
   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);
      CustomTicksDelete(def_SymbolReplay, m_Ticks.Info[iPos].time_msc, LONG_MAX);
      if ((m_dtPrevLoading == class="num">0) && (iPos == class="num">0)) FirstBarNULL(); 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--);

回放跳帧时的等待与补柱逻辑

当回放指针 iPos 超过已处理计数 m_ReplayCount 时,说明用户把历史进度往前拽了一段,引擎必须决定是挂起等界面还是直接追帧。若 bViewBuider 为真,只把 isWait 标记置 true 并写入全局变量 def_GlobalVariableReplay,交还给视图层去处理阻塞,自己不擅自动 K 线。 非视图模式下走的是硬追帧:先用空循环把 m_ReplayCount 推到当前 Rate[0].time 大于对应 Info 时间的位置,再算 nCount 到目标 iPos 的秒级截断点(macroRemoveSec 去秒),最后一次性 CustomRatesUpdate 把缺的 Tick 段塞进回放品种。这一帧补数据的动作在 MT5 里会直接反映到离线图表,肉眼能看到蜡烛突然变密。 收尾处有个倒推循环:从 iPos-1 往回,只要 m_ReplayCount 还没追上且没收到 _StopFlag,就持续 CreateBarInReplay(false,false) 补中间缺的柱。补完清掉 isWait 并回写全局变量,整个跳帧追赶才算闭环。外汇与贵金属回放测试杠杆高,追帧脚本若没拦 _StopFlag,终端强制退出时可能留脏全局变量。

MQL5 / C++
m_ReplayCount++;
}
      }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(false, false);
         Info.u_Value.df_Value = GlobalVariableGet(def_GlobalVariableReplay);
         Info.s_Infos.isWait = false;
         GlobalVariableSet(def_GlobalVariableReplay, Info.u_Value.df_Value);
      }

「用模拟数据补上跳价盲区」

回放真实行情时,价格偶尔会瞬间越过出价与要价之间的区域,这种突破通常极快且罕见,多出现在波动骤发阶段。但模拟数据天生缺了这类信息——不是不能造,而是得刻意去补,否则你的回放永远在点差内打转,和实盘对不上。 别把点差当铁牢。真实系统有时会越过买卖价差成交,开发订单逻辑时若默认“永远在 spread 内”,就容易写出脆弱的假设。订单簿里看到的真相,往往和大多数人想的不一样,这需要实盘经验才能嗅到问题。 把这类越界走势揉进模拟器,回放会更接近真实,但必须谨慎:你得先摸透该资产的历史跳价特征,再决定怎么造。思路是新增一个标记变量,区分当前跳价来自真实数据还是模拟数据,因为我们不真去模拟买卖盘走势,只按最后成交价反推 bid/ask 边界。 变量要在两处初始化:一是声明用模拟跳价时,二是声明用真实跳价时。之后改模拟器本身——真实数据载入时会校正毫秒字段,模拟器原本没做这步,不修的话 C_Replay 类读到的时间是错误的。补一段毫秒兼容代码后,再进 C_Replay 改单个函数,让它在模拟模式下基于最后一笔价生成并显示 bid/ask。 初版里若没造出价,就直接拿上次模拟价显示,这不对:交易本该在买卖价之间发生,价格却动了,等于无订单簿成交。修掉高亮段,让走势符合直觉。注意 bid/ask 不能交叉(交易所规则如此,外汇市场另说),交叉的该是最后成交价。 再加点随机性,就能模拟“不变买卖价、只成交”的直接订单——实盘不常这么显式出现,但加上后点差偶尔收窄,反而更像真实微观结构。为防止直接订单泛滥,最后一处改动是控住随机复杂度,留个小窗口让点差略大于最小值,不时冒出一两笔直接订单,以前的文章里做不到这个。 下面这段结构定义了回放数据容器与柱线转 tick 的入口,bTickReal 就是刚才说的真实/模拟标记: struct st00 { MqlTick Info[]; MqlRates Rate[]; int nTicks, nRate; bool bTickReal; }m_Ticks; bool BarsToTicks(const string szFileNameCSV) { C_FileBars *pFileBars; int iMem = m_Ticks.nTicks; MqlRates rate[1]; MqlTick local[]; pFileBars = new C_FileBars(szFileNameCSV); ArrayResize(local, def_MaxSizeArray); Print("Converting bars to ticks. Please wait..."); 逐行拆:struct st00 封装回放缓冲,Info[] 存 tick,Rate[] 存柱线;nTicks/nRate 是计数;bTickReal 标记真实还是模拟。BarsToTicks 接收 CSV 路径,建文件对象,扩 local 数组,打印等待提示——真正转换逻辑在后面,但容器和标记已就位,开 MT5 把这段塞进你的回放工程就能跑通框架。

MQL5 / C++
class="kw">struct st00
    {
        class="type">MqlTick  Info[];
        class="type">MqlRates Rate[];
        class="type">int      nTicks,
                 nRate;
        class="type">bool     bTickReal;
    }m_Ticks;
    class="type">bool BarsToTicks(const class="type">class="kw">string szFileNameCSV)
        {
            C_FileBars    *pFileBars;
            class="type">int           iMem = m_Ticks.nTicks;
            class="type">MqlRates      rate[class="num">1];
            class="type">MqlTick       local[];
            
            pFileBars = new C_FileBars(szFileNameCSV);
            ArrayResize(local, def_MaxSizeArray);
            Print("Converting bars to ticks. Please wait...");
把重复劳动交给小布
这些跳价同步与计时校准的脏活,小布盯盘的AIGC已内置部分诊断逻辑,打开对应品种页即可看到异常提示,你只需专注策略验证本身。

常见问题

旧版计时器没有按真实跳价节奏推进,只是在固定间隔强行生成柱线,流动性高时堆积延迟就会被放大,属于模拟层同步缺陷。
通常不影响文件内的跳价数据本身,修复主要针对运行时如何读取与渲染,但建议进阶用户备份配置再改代码。
目前小布盯盘的AIGC可提示部分品种页的异常同步状态,但深度MQL5计时器校准仍需你在代码层按本篇方法处理。
本篇方案是在模拟模式下接管显示,实盘报价需切换回正常环境,混用容易导致策略误判,外汇贵金属本身高风险,环境混淆更危险。
本篇只解决最明显的同步失真,部分极端跳价场景仍可能概率性偏移,后续第3篇会进一步覆盖。