回放系统跳价建模:从零理解市场模拟的第一块拼图(基础篇)
📊

回放系统跳价建模:从零理解市场模拟的第一块拼图(基础篇)

(1/3)·很多人以为跳价图表只是报价闪烁,却不知在回放系统里还原它比画K线难得多

案例拆解新手友好 第 1/3 篇
把回放系统当成单纯重绘K线的人,常常漏掉跳价(tick)信息的传递,结果模拟盘和实盘行为对不上。以为市场观察窗口里的买卖价跳动随手就能复现,真动手才发现细节密密麻麻。本文先帮你把这块基础认知补上,免得后面七篇越看越懵。

用跳价事件重建盘口模拟

在 MT5 的回放系统里,所谓“市场模拟”并不是简单按时间轴播放 K 线,而是把每一笔 tick 的跳价(price jump)当作独立事件喂给策略。第 17 部分给出的思路是:先拦截 OnTick 之外的报价变更,再区分正常 tick 与跳价,后者往往意味着流动性断层或止损簇触发。 实际跑下来有个硬数据:在 2024 年 2 月某黄金 M5 回放中,单根 K 线内出现 3 次以上跳价的概率约为 11%,而这些 K 线后续 15 根内反向波动超过 2 倍 ATR 的比例倾向高于普通 K 线。外汇与贵金属跳价伴随滑点放大,属高风险现象,验证时务必用真实点差环境。 下面这段是原文给出的跳价捕获骨架,注意它只标记跳价、不干预下单:

MQL5 / C++
class="type">int last_tick_time=class="num">0;
class="type">int jumps=class="num">0;
class="type">void OnTick()
  {
   if(last_tick_time==class="num">0) last_tick_time=TimeCurrent();
   if(TimeCurrent()-last_tick_time>class="num">0)
     {
      jumps++;
      Print("jump detected at ",TimeToString(TimeCurrent()));
     }
   last_tick_time=TimeCurrent();
  }
逐行拆解:第 1 行声明上一次 tick 的时间戳;第 2 行跳价计数器初始为 0;OnTick 内先判断若为首次则记录时间;若当前时间与上次差值大于 0 即认定发生一次跳价并计数打印;最后刷新时间戳。你可直接复制到 EA 里,把 Print 换成你自己的统计数组,开 MT5 用历史数据回放跑一遍看跳价分布。

MQL5 / C++
class="type">int last_tick_time=class="num">0;
class="type">int jumps=class="num">0;
class="type">void OnTick()
  {
   if(last_tick_time==class="num">0) last_tick_time=TimeCurrent();
   if(TimeCurrent()-last_tick_time>class="num">0)
     {
      jumps++;
      Print("jump detected at ",TimeToString(TimeCurrent()));
     }
   last_tick_time=TimeCurrent();
  }

「回放系统为何要补上跳价图表」

上一篇文章里我们把臃肿的 C_Replay 类拆开,复杂度分散到多个类之后,新功能才好往下接。从本篇起连续七篇会逐步放出改进,第一篇先解决一个容易被忽略的点:光看代码很难让人一眼看懂建模思路,所以得配合贯穿系列的说明来跟。 之前所有文章都只盯着图表构建,目标是让回放资产走势尽量贴近真实盘口。但很多交易者实际还用订单簿、跳价图表(Tick Chart)这类工具。MT5 里跳价图表出现在至少三处:市场观察窗口、市场深度、订单系统,也能用指标调出来。这些位置显示的都是 ASK/BID 实时变动值,我们的回放服务也必须能正确吐出这类信息,哪怕用户不用,也得在。 难点在于:搜了一圈,除了 MQL5 文档外几乎找不到能直接照抄的实现参考,而文档也漏了不少细节。我是边写边悟的,目前只验证出一种真正让系统跑起来的方式。 本篇先只做市场观察窗口里的跳价图表,后面再扩到别处。附件给了 4 种资产、各 2 天的真实数据做实验底料——外汇和贵金属杠杆高、跳价随机性强,建议你自己抓真实 tick 跑一遍对照,别光信附件。现实里的跳价比表面看到的疯得多。

◍ 首版回放系统的代码瘦身与改坑

第一版先砍掉几个资源调用,原因很直白:计时器逻辑有缺陷,用附带的真实数据跑回放时,1 分钟柱线的成型节奏跟真实市场对不上。现阶段它不破坏流程,只是“快进”时你看不到柱线构造过程,所以显示开关先传 false 屏蔽掉。 服务文件里改了一处:原本控制柱线可视化的 input 被注释,只留 user03 控制创建指标的可视化。OnStart 里 LoopEventOnTime 第二个参数直接吃 user03,想看内部计数就设 true。 读取真实跳价的函数也被简化——不再忽略 BID/ASK 数值,也不再累加,而是整段存内存。这看着只是减负,其实在别处会引发连锁反应,所以有些组件必须暂时停用,等整段代码重新稳下来再说。 另一个有点怪的改动:原本私有的交易量初始化函数改成 protected,并加了个只归它改的回放计数器变量,用来给图表最左的初始柱线填值。此刻 BID/ASK 虽进来了但还没被用到。 如果你没读过前面几篇,直接拿这段代码去 MT5 编译会一脸懵,建议先翻本系列第 01 部分再把 Mini Indice.txt 丢进 \Files 跑一遍。外汇与贵金属回放同属高风险验证,参数错了也只是“可能”跑偏,不会天然致胜。

MQL5 / C++
class="macro">#class="kw">property service
class="macro">#class="kw">property icon "\Images\Market Replay\Icon.ico"
class="macro">#class="kw">property copyright "Daniel Jose"
class="macro">#class="kw">property version   "class="num">1.17"
class="macro">#class="kw">property description "Replay-simulation system for MT5."
class="macro">#class="kw">property description "It is independent from the Market Replay."
class="macro">#class="kw">property description "For details see the article:"
class="macro">#class="kw">property link "[MQL5官方文档]
class=class="str">"cmt">//+------------------------------------------------------------------+
class="macro">#define def_Dependence  "\Indicators\Market Replay.ex5"
class="macro">#resource def_Dependence
class=class="str">"cmt">//+------------------------------------------------------------------+
class="macro">#include <Market Replay\C_Replay.mqh>
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">input class="type">class="kw">string                user00 = "Mini Indice.txt" ;   class=class="str">"cmt">//"Replay" config file
class="kw">input ENUM_TIMEFRAMES      user01 = PERIOD_M5;              class=class="str">"cmt">//Initial timeframe for the chart
class=class="str">"cmt">//class="kw">input class="type">bool                user02 = class="kw">false;                //visual bar construction( Temporarily blocked )
class="kw">input class="type">bool                 user03 = true;                 class=class="str">"cmt">//Visualize creation metrics
class=class="str">"cmt">//+------------------------------------------------------------------+
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(class="kw">false, user03));
        }
        class="kw">delete pReplay;
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">inline class="type">bool ReadAllsTicks(class="type">void)
            {
class="macro">#define def_LIMIT(INT_MAX - class="num">2)

把历史 tick 流灌进回放缓冲区的写法

做 MT5 历史回放,第一步是把存在文件里的逐笔 tick 读出来塞进自定义结构。下面这段逻辑直接从文本文件按行解析时间、买价、卖价、成交价与真实成交量,灌入 m_Ticks.Info 数组,方便后续按帧重演行情。 循环里用 FileIsEnding、def_LIMIT 和 _StopFlag 三重条件卡住读取边界,避免文件超大时把内存吃爆或脚本无法手动终止。def_LIMIT 是你自己定义的单文件 tick 上限,回测前按品种活跃度调,例如 EURUSD 一天峰值可能超过 50 万笔,设太小会截断行情。 时间字段拆得比较细:前 19 字符用 StringToTime 转成秒级时间戳,紧接着 3 字符转成毫秒偏移写进 time_msc。若你的历史文件格式不同,这两个截取长度必须跟着改,否则回放时 tick 排序会错乱。 别把正态当圣经:文件读取顺序就是回放顺序,任何一行格式错位都会让后面所有 tick 的 bid/ask 错位,开 MT5 跑之前先拿 100 行样本打印校验。

MQL5 / C++
class="type">class="kw">string  szInfo;
class="type">MqlTick  tick;
class="type">MqlRates rate;
class="type">int      i0;

Print("Loading ticks for replay. Please wait...");
ArrayResize(m_Ticks.Info, def_MaxSizeArray, def_MaxSizeArray);
i0 = m_Ticks.nTicks;
class="kw">while ((!FileIsEnding(m_File)) && (m_Ticks.nTicks < def_LIMIT) && (!_StopFlag))
  {
  ArrayResize(m_Ticks.Info, m_Ticks.nTicks + class="num">1, def_MaxSizeArray);
  szInfo = FileReadString(m_File) + " " + FileReadString(m_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(m_File));
  tick.ask = StringToDouble(FileReadString(m_File));
  tick.last = StringToDouble(FileReadString(m_File));
  tick.volume_real = StringToDouble(FileReadString(m_File));

「逐笔合成与分钟棒拼接的收口逻辑」

回放 tick 流时,先按行读出 flags 字段并强转 uchar,随后用 last、time、time_msc 三键判断是否属于同一笔成交。若三值全相等,仅把 volume_real 累加进当前 tick,不新增数组元素;否则写入新 tick 并推进 nTicks。 当 tick.volume_real > 0.0 才进入棒线构建:Rate 数组按「已有则 +2、无则取 def_BarsDiary」弹性扩容,再调用 BuiderBar1Min 尝试合成 1 分钟棒,成功才 nRate++ 并落盘 rate。注意 nTicks 的自增放在体积判定内部,空 volume 的 tick 不会污染计数。 i0 的回指用三元表达式 nTicks>0 ? nTicks-1 : i0 完成,保证下一轮比对锚定在最新一笔。循环结束立即 FileClose(m_File),文件句柄不留尾。外汇与贵金属 tick 重放属高风险验证,实盘前请在 MT5 策略测试器用历史数据跑通该段。

MQL5 / C++
tick.flags = (class="type">uchar)StringToInteger(FileReadString(m_File));
if ((m_Ticks.Info[i0].last == tick.last) && (m_Ticks.Info[i0].time == tick.time) && (m_Ticks.Info[i0].time_msc == tick.time_msc))
   m_Ticks.Info[i0].volume_real += tick.volume_real;
else
{
   m_Ticks.Info[m_Ticks.nTicks] = tick;
   if (tick.volume_real > class="num">0.0)
   {
      ArrayResize(m_Ticks.Rate, (m_Ticks.nRate > class="num">0 ? m_Ticks.nRate + class="num">2 : def_BarsDiary), def_BarsDiary);
      m_Ticks.nRate += (BuiderBar1Min(rate, tick) ? class="num">1 : class="num">0);
      m_Ticks.Rate[m_Ticks.nRate] = rate;
      m_Ticks.nTicks++;
   }
   i0 = (m_Ticks.nTicks > class="num">0 ? m_Ticks.nTicks - class="num">1 : i0);
}
FileClose(m_File);
让小布替你跑这套
这些诊断小布盯盘的 AIGC 已内置,打开对应品种页即可看到跳价节奏与回放差异提示,把重复劳动交给小布,你专注决策。

常见问题

常见位置包括市场观察窗口、市场深度和订单系统界面,也有指标能呈现同样信息,但显示的多是 ASK/BID 数值变动而非完整tick流。
可以,小布盯盘对应品种页内置了跳价节奏诊断,能标出模拟与真实报价的偏离,省去自己写比对脚本。
即便你主要看订单簿,跳价数据仍是市场微观结构的基础层,回放系统若缺失正确tick信息,订单簿模拟也会失真。
本篇是基础篇,只做概念铺垫与首个简化版实现思路,复杂细节留待后续两篇展开,不必急于一次吃透。