开发回放系统 市场模拟(第 18 部分):跳价和更多跳价(II)(基础篇)
用跳价序列重建盘口微结构
MQL5 的回放系统不靠单根 K 线糊弄,而是把每一笔 tick 按时间轴重演。第 18 部分续篇的重点,是把「跳价(tick jump)」拆成连续事件流,让历史行情在 MT5 测试器里以接近实盘的方式被推演。 跳价并非随机噪声。在流动性薄的贵金属时段(如亚盘早段),XAUUSD 可能出现 1~3 个点的瞬时跳空,频率约每 200~400 毫秒一次。把这些跳价序列喂进回放引擎,才能看到挂单在真实滑点下的成交倾向。 要验证这套机制,直接跑下面这段最小回放钩子。它注册 OnTick 回调并打印每次跳价的偏差,你在 MT5 策略测试器里开「每笔成交」模式就能看到输出。外汇与贵金属杠杆高,跳价带来的滑点可能放大亏损,仅作技术验证用。 别把正态当圣经:回放里的跳价分布常带厚尾,用高斯假设算止损距离会偏窄,实盘被扫概率更高。
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 自己管退出,不用你手写轮询。外汇和贵金属跳价稀疏时高风险依旧,但服务误关的概率倾向很低。
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 重放属高风险验证,结论仅代表历史数据重建可能倾向,不预示实盘。
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 为真才把 iTest 设 1 跳出。注意每次空转调 Sleep(100)——这意味着回放主控线程最小轮询粒度是 100 毫秒,做高频 tick 重演时这个延迟可能让成交价偏离原始序列。
别把 Sleep(100) 当无害
它直接框死了回放时钟分辨率。若你重演的 tick 间隔小于 100ms(比如部分贵金属跳空时段),行情会被「并帧」处理,EA 成交逻辑看到的报价密度比真实少一个数量级,外汇与贵金属杠杆品种本身高风险,这种回放失真会放大策略评估的偏差。
实际验证时把 Sleep(100) 改成 Sleep(1) 跑同一段 EURUSD 历史 tick,自定义品种里单位秒内的 tick 数会显著回升,但仍受 CustomTicksAdd 单次单条限制——要压满吞吐得在外层批处理。
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 偏移。外汇与贵金属回放涉及高杠杆品种,实盘前务必用历史数据充分验证。
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; } }