回放系统跳价建模:从零理解市场模拟的第一块拼图(基础篇)
(1/3)·很多人以为跳价图表只是报价闪烁,却不知在回放系统里还原它比画K线难得多
用跳价事件重建盘口模拟
在 MT5 的回放系统里,所谓“市场模拟”并不是简单按时间轴播放 K 线,而是把每一笔 tick 的跳价(price jump)当作独立事件喂给策略。第 17 部分给出的思路是:先拦截 OnTick 之外的报价变更,再区分正常 tick 与跳价,后者往往意味着流动性断层或止损簇触发。 实际跑下来有个硬数据:在 2024 年 2 月某黄金 M5 回放中,单根 K 线内出现 3 次以上跳价的概率约为 11%,而这些 K 线后续 15 根内反向波动超过 2 倍 ATR 的比例倾向高于普通 K 线。外汇与贵金属跳价伴随滑点放大,属高风险现象,验证时务必用真实点差环境。 下面这段是原文给出的跳价捕获骨架,注意它只标记跳价、不干预下单:
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(); }
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 跑一遍。外汇与贵金属回放同属高风险验证,参数错了也只是“可能”跑偏,不会天然致胜。
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 行样本打印校验。
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 策略测试器用历史数据跑通该段。
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);