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

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

第 1/3 篇

用跳价序列重建盘口微结构

MQL5 的回放系统不靠单根 K 线糊弄,而是把每一笔 tick 按时间轴重演。第 18 部分续篇的重点,是把「跳价(tick jump)」拆成连续事件流,让历史行情在 MT5 测试器里以接近实盘的方式被推演。 跳价并非随机噪声。在流动性薄的贵金属时段(如亚盘早段),XAUUSD 可能出现 1~3 个点的瞬时跳空,频率约每 200~400 毫秒一次。把这些跳价序列喂进回放引擎,才能看到挂单在真实滑点下的成交倾向。 要验证这套机制,直接跑下面这段最小回放钩子。它注册 OnTick 回调并打印每次跳价的偏差,你在 MT5 策略测试器里开「每笔成交」模式就能看到输出。外汇与贵金属杠杆高,跳价带来的滑点可能放大亏损,仅作技术验证用。 别把正态当圣经:回放里的跳价分布常带厚尾,用高斯假设算止损距离会偏窄,实盘被扫概率更高。

MQL5 / C++
class="type">void OnTick()
{
   class="kw">static class="type">class="kw">double last_price = class="num">0;
   class="type">class="kw">double cur = SymbolInfoDouble(_Symbol, SYMBOL_BID);
   if(last_price != class="num">0)
   {
      class="type">class="kw">double jump = cur - last_price;
      Print("Tick jump: ", jump);
   }
   last_price = cur;
}

◍ 回放系统的计时同步痛点

上篇给市场观察窗口接上了跳价图表,但故意没动计时逻辑——那时重点是讲数据怎么从图表搬到观察窗,顺带我也发现社区里几乎找不到第二份讲清楚这件事的资料,论坛里一堆人在问都没得到正经答案。 真正烦人的是 1 分钟柱线的模拟耗时:资产流动性一高,时序就歪得离谱,几秒内就可能丢掉真实行情的对应段。从一开始我就想让回放体验尽量贴近真盘交易,目前这个度量离「理想的一分钟」差得远,得先扳回来。 修同步其实不复杂,上篇不碰它是怕打乱跳价转移那套讲解。这回我们先把明显的计时错误堵上,更绕的问题留到后面。我的想法一直是:文章不只发代码,而是逼大家去啃 MT5 平台和 MQL5 本身,别总走别人踩过的老路。外汇和贵金属回放测试高风险,参数没对齐前别直接上真仓映射。

「把计时器从假死里拽出来」

原本计时器在跳价回放里容易显得‘停摆’,其实只改了一行:删掉旧的时间赋值,换成基于文件读取时刻的计算。这一处小改动让计时器行为更贴近真实行情节奏,同时往市场观察塞跳价时省掉几个机器周期。 代价是 1 分钟柱线生成前要多算一步才能交给 MT5 绘制,但比起远程预计算,这套本地开销更低。低流动性资产可能几秒才出一跳,有人担心服务会冻住——其实不会,因为 Sleep 内置每 0.1 秒查一次 EA 停止标志(_StopFlag),文档写得很死:只有收到 STOP 请求才真停。 下面这段 ReadAllsTicks 已去掉旧远程计算,改为从文件加载跳价时就地处理。循环条件里直接挂了 !_StopFlag,MT5 自己管退出,不用你手写轮询。外汇和贵金属跳价稀疏时高风险依旧,但服务误关的概率倾向很低。

MQL5 / C++
class="kw">inline class="type">bool ReadAllsTicks(const class="type">bool ToReplay)
{
class="macro">#define def_LIMIT(INT_MAX - class="num">2)
class="macro">#define def_Ticks m_Ticks.Info[m_Ticks.nTicks]
						class="type">class="kw">string  szInfo;
						class="type">MqlRates rate;
						
						Print("Loading ticks for replay. Please wait...");
						ArrayResize(m_Ticks.Info, def_MaxSizeArray, def_MaxSizeArray);
						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);
								def_Ticks.time = StringToTime(StringSubstr(szInfo, class="num">0, class="num">19));
								def_Ticks.time_msc = (def_Ticks.time * class="num">1000) + (class="type">int)StringToInteger(StringSubstr(szInfo, class="num">20, class="num">3));
								def_Ticks.time_msc = (class="type">int)StringToInteger(StringSubstr(szInfo, class="num">20, class="num">3));
								def_Ticks.bid = StringToDouble(FileReadString(m_File));

逐笔成交落进分钟bar的读取逻辑

把 tick 文件里的每一行按字段拆开,是后续重建成交量分布的前提。下面这段从文件流里读 ask、last、真实成交量与标记位,再决定要不要写进 1 分钟序列。 def_Ticks.ask = StringToDouble(FileReadString(m_File)); // 读卖一价字符串并转 double def_Ticks.last = StringToDouble(FileReadString(m_File)); // 读最新成交价字符串并转 double def_Ticks.volume_real = StringToDouble(FileReadString(m_File)); // 读真实成交量并转 double def_Ticks.flags = (uchar)StringToInteger(FileReadString(m_File)); // 读整型标记强转 uchar 仅当 volume_real 大于 0.0 才视为有效成交参与聚合,过滤掉零量报价噪音。命中后先用 ArrayResize 给 Rate 数组按步长 +2(或首次用 def_BarsDiary)扩容,再调 BuiderBar1Min 把 tick 塞进分钟 bar。 m_Ticks.nTicks 每读一条自增;若累计触到 def_LIMIT 就 Print 报错并 return false,避免超大 tick 文件拖垮回测进程。函数最终返回 !_StopFlag,意味着用户中途点停止会干净退出。外汇与贵金属 tick 重放属高风险验证,结论仅代表历史数据重建可能倾向,不预示实盘。

MQL5 / C++
def_Ticks.ask = StringToDouble(FileReadString(m_File));
def_Ticks.last = StringToDouble(FileReadString(m_File));
def_Ticks.volume_real = StringToDouble(FileReadString(m_File));
def_Ticks.flags = (class="type">uchar)StringToInteger(FileReadString(m_File));
if (def_Ticks.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, def_Ticks) ? class="num">1 : class="num">0);
   m_Ticks.Rate[m_Ticks.nRate] = rate;
}
m_Ticks.nTicks++;
}
FileClose(m_File);
if (m_Ticks.nTicks == def_LIMIT)
{
   Print("Too much data in the tick file.\nCannot class="kw">continue...");
   class="kw">return false;
}
class="kw">return (!_StopFlag);

◍ 回放引擎里的逐笔注入与等待逻辑

做历史 tick 回放时,核心动作是把缓存里的单笔报价按毫秒时间重写进自定义品种。下面这段把 m_Ticks.Info[m_ReplayCount] 取出来,将 time*1000 + time_msc 拼成 time_msc 字段,再调 CustomTicksAdd 塞进 def_SymbolReplay——MT5 里自定义品种的 tick 流就是这样被一帧帧喂出来的。 [CODE] #undef def_Ticks #undef def_LIMIT } inline void ViewTick(void) { MqlTick tick[1]; tick[0] = m_Ticks.Info[m_ReplayCount]; tick[0].time_msc = (m_Ticks.Info[m_ReplayCount].time * 1000) + m_Ticks.Info[m_ReplayCount].time_msc; CustomTicksAdd(def_SymbolReplay, tick); } bool LoopEventOnTime(const bool bViewBuider, const bool bViewMetrics) { u_Interprocess Info; int iPos, iTest; iTest = 0; while ((iTest == 0) && (!_StopFlag)) { iTest = (ChartSymbol(m_IdReplay) != "" ? iTest : -1); iTest = (GlobalVariableGet(def_GlobalVariableReplay, Info.u_Value.df_Value) ? iTest : -1); iTest = (iTest == 0 ? (Info.s_Infos.isPlay ? 1 : iTest) : iTest); if (iTest == 0) Sleep(100); }

if ((iTest < 0)(_StopFlag)) return false;

AdjustPositionToReplay(bViewBuider); [/CODE] LoopEventOnTime 则是回放调度器:先清掉 iTest,进入 while 轮询,只要图表未绑定或全局变量取不到就置 -1 退出;若 isPlay 为真才把 iTest1 跳出。注意每次空转调 Sleep(100)——这意味着回放主控线程最小轮询粒度是 100 毫秒,做高频 tick 重演时这个延迟可能让成交价偏离原始序列。 别把 Sleep(100) 当无害 它直接框死了回放时钟分辨率。若你重演的 tick 间隔小于 100ms(比如部分贵金属跳空时段),行情会被「并帧」处理,EA 成交逻辑看到的报价密度比真实少一个数量级,外汇与贵金属杠杆品种本身高风险,这种回放失真会放大策略评估的偏差。 实际验证时把 Sleep(100) 改成 Sleep(1) 跑同一段 EURUSD 历史 tick,自定义品种里单位秒内的 tick 数会显著回升,但仍受 CustomTicksAdd 单次单条限制——要压满吞吐得在外层批处理。

MQL5 / C++
class="macro">#undef def_Ticks
class="macro">#undef def_LIMIT
            }
class="kw">inline class="type">void ViewTick(class="type">void)
                {
                class="type">MqlTick tick[class="num">1];
                tick[class="num">0] = m_Ticks.Info[m_ReplayCount];
                tick[class="num">0].time_msc = (m_Ticks.Info[m_ReplayCount].time * class="num">1000) + m_Ticks.Info[m_ReplayCount].time_msc;
                CustomTicksAdd(def_SymbolReplay, tick);
                }
                class="type">bool LoopEventOnTime(const class="type">bool bViewBuider, const class="type">bool bViewMetrics)
                {
                u_Interprocess Info;
                class="type">int iPos, iTest;
                
                iTest = class="num">0;
                class="kw">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 false;
                AdjustPositionToReplay(bViewBuider);

「回放循环的延时与步进控制」

回放引擎靠一个 while 循环把逐笔 tick 推进到图表上,循环条件同时检查已处理 tick 数是否小于总量、以及终端是否发了停止信号。 每轮先调用 CreateBarInReplay 构造当前棒,再算相邻两 tick 的毫秒时差 iPos:若时差为负(跨秒),就加 1000 修正,然后累加到 m_MountBar.delay 里。 当 delay 累计超过 400 毫秒,才做一次“对外同步”:检查回放图表符号是否为空(空就 break),从全局变量读控制结构,若标记 isPlay 为假直接返回 true;否则更新进度滑块位置 iPosShift = (m_ReplayCount * def_MaxPosSlider) / m_Ticks.nTicks,写回全局变量,并 Sleep(delay - 20) 让出 CPU,随后 delay 清零。 这套机制意味着回放不是每 tick 都刷新界面,而是攒够约 400ms 才同步一次,MT5 上跑自定义回放时若感觉卡顿或进度跳变,优先调这个 400 阈值和 Sleep 里的 20 偏移。外汇与贵金属回放涉及高杠杆品种,实盘前务必用历史数据充分验证。

MQL5 / C++
m_MountBar.delay = class="num">0;
class="kw">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);
   m_MountBar.delay += (iPos < class="num">0 ? iPos + class="num">1000 : iPos);
   if (m_MountBar.delay > class="num">400)
   {
      if (ChartSymbol(m_IdReplay) == "") 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;
   }
}

常见问题

按时间顺序把每一笔跳价当成独立事件重放,重建买卖挂单与成交的先后顺序,就能还原当时的盘口层次。
别让主线程空等,把计时器改成非阻塞的事件驱动,或者用独立线程发步进信号,假死基本能消掉。
小布可以加载回放引擎把跳价序列自动重放,并标出异常盘口微结构,你直接看结论不用自己写循环。
读取时按成交时间戳归桶,同一分钟的逐笔累进该bar的量和价,跨分钟再开新桶,就不会串。
用固定步进间隔加动态等待,行情密就放慢步长,空档就跳等待,观感接近实盘节奏。