开发回放系统 市场模拟(第 01 部分):首次实验(I)·进阶篇
(2/3)· 标准 MT5 缺失回放模块,靠外部 DLL 又埋隐患,这篇用纯 MQL5 啃下首次实验的硬骨头
「毫秒级定时器的真实触发边界」
MT5 文档写明:实时模式下受硬件限制,1–10 毫秒区间内的计时器事件每秒最多触发 16 次。也就是说,哪怕你把间隔设成 1 毫秒,实际事件也不会高于约每 62.5 毫秒一次,硬设更小值没有意义。 为了验证这一点,EA 里用 EventSetMillisecondTimer(20) 把 OnTimer 挂在约 20 毫秒周期上。代码里只暴露了两个外部输入,核心回放逻辑被收进 C_Replay 类。 类定义里有一行 #define def_MaxSizeArray 134217727,即 128 MB 的跳价文件上限。这是内存映射的硬约束,所有 tick 数据会先全量读进内存再处理,因为机械盘 IO 远慢于内存,提前确认文件完整比中途报错更划算。 CustomSymbolCreate 动态建了一个名为 Replay 的自定义品种,ChartOpen 跟着把它的图表打开;随后逐行 FileReadString 读 CSV 跳价文件,遇到 0D 0A 换行或 09 制表符就断行,函数自身处理分隔符,调用方只管类型转换。 过滤掉异常 tick 是为后续建 1 分钟柱线减负——平台最低周期就是 M1,若柱线生成超过 1 分钟说明回放系统有瓶颈,不足 1 分钟则倾向可用。外汇与贵金属回放测试仍属高风险的模拟环境,实盘偏差概率不低。
class="macro">#class="kw">property copyright "Daniel Jose" class="macro">#class="kw">property icon "Resources\\App.ico" class="macro">#class="kw">property description "Expert Advisor - Market Replay" class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#include "Include\\C_Replay.mqh" class=class="str">"cmt">//+------------------------------------------------------------------+ class="kw">input class="type">class="kw">string user01 = "WINZ21_202110220900_202110221759"; class=class="str">"cmt">//Tick archive class=class="str">"cmt">//+------------------------------------------------------------------+ C_Replay Replay; class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">int OnInit() { Replay.CreateSymbolReplay(user01); EventSetMillisecondTimer(class="num">20); class="kw">return INIT_SUCCEEDED; } class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void OnDeinit(class="kw">const class="type">int reason) { EventKillTimer(); } class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void OnTick() {} class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void OnTimer() { Replay.Event_OnTime(); } class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#class="kw">property copyright "Daniel Jose" class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#define def_MaxSizeArray class="num">134217727 class=class="str">"cmt">// class="num">128 Mbytes of positions class="macro">#define def_SymbolReplay "Replay" class=class="str">"cmt">//+------------------------------------------------------------------+ class C_Replay { }; class="type">void CreateSymbolReplay(class="kw">const class="type">class="kw">string FileTicksCSV) { SymbolSelect(def_SymbolReplay, class="kw">false); CustomSymbolDelete(def_SymbolReplay); CustomSymbolCreate(def_SymbolReplay, StringFormat("Custom\\Replay\\%s", def_SymbolReplay), _Symbol); CustomRatesDelete(def_SymbolReplay, class="num">0, LONG_MAX); CustomTicksDelete(def_SymbolReplay, class="num">0, LONG_MAX); SymbolSelect(def_SymbolReplay, true);
◍ 回放图表与逐笔Ticks载入的底层接线
做历史逐笔回放的第一步,是开一张独立的 M1 图表承载重播行情,并避开主图干扰。下面这行把回放专用图表句柄存进 m_IdReplay,符号用 def_SymbolReplay、周期锁死 PERIOD_M1,之后所有模拟报价都往这张图推。 m_IdReplay = ChartOpen(def_SymbolReplay, PERIOD_M1); LoadFile(FileTicksCSV); Print("Running speed test.");
| LoadFile 负责把 CSV 里的 tick 流读进内存数组。文件路径硬编码在 "Market Replay\\Ticks\\" 下,后缀 .csv,用 FILE_CSV | FILE_READ | FILE_ANSI 方式打开;若返回 INVALID_HANDLE 就直接跳过,不崩。 |
|---|
进文件后先丢前 7 行表头(for 循环 c0<7 调 FileReadString),再 Print 提示等待。主体 while 受两重闸控:FileIsEnding 没到尾,且 m_ArrayCount 小于 def_MaxSizeArray——这意味着数组上限一旦撞满,回放数据就截断,实盘前你得确认 def_MaxSizeArray 够不够装一天 tick。 宏 macroRemoveSec(A) 用 (A - (A % 60)) 把时间戳秒数抹掉,回放时若按整分对齐事件,这个减法能省掉不少边界判断。外汇与贵金属杠杆高、滑点跳变频繁,回放结果仅代表历史数据重演,不预示实盘概率。
m_IdReplay = ChartOpen(def_SymbolReplay, PERIOD_M1); LoadFile(FileTicksCSV); Print("Running speed test."); } class="macro">#define macroRemoveSec(A) (A - (A % class="num">60)) class="type">void LoadFile(class="kw">const class="type">class="kw">string szFileName) { class="type">int file; class="type">class="kw">string szInfo; class="type">class="kw">double last; class="type">long vol; class="type">uchar flag; if ((file = FileOpen("Market Replay\\Ticks\\" + szFileName + ".csv", FILE_CSV | FILE_READ | FILE_ANSI)) != INVALID_HANDLE) { ArrayResize(m_ArrayInfoTicks, def_MaxSizeArray); m_ArrayCount = class="num">0; last = class="num">0; vol = class="num">0; for (class="type">int c0 = class="num">0; c0 < class="num">7; c0++) FileReadString(file); Print("Loading data for Replay.\nPlease wait ...."); class="kw">while ((!FileIsEnding(file)) && (m_ArrayCount < def_MaxSizeArray)) { szInfo = FileReadString(file);
逐行拆解 tick 回放文件的字段映射
把历史 tick 文件灌进数组,核心在字符串切分与类型转换的精确对应。下面这段是回放引擎里典型的逐 tick 读取逻辑,可直接贴进 MT5 脚本验证。 szInfo += " " + FileReadString(file); 先把一行时间戳文本读进来拼到缓冲串。 m_ArrayInfoTicks[m_ArrayCount].dt = macroRemoveSec(StringToTime(StringSubstr(szInfo, 0, 19))); 取前 19 字符(形如 2024.01.02 13:45:30)转成时间并去掉秒级精度。 m_ArrayInfoTicks[m_ArrayCount].milisec = (int)StringToInteger(StringSubstr(szInfo, 20, 3)); 第 20 位起 3 字符是毫秒,转整型存入。 后续 Bid / Ask / Last / Vol 各占独立行,都用 FileReadString 单行读取后转 double 或 integer;flag 用 uchar 强转,并做位与判断:若含 TICK_FLAG_ASK 或 TICK_FLAG_BID 则 continue 跳过,不计入 m_ArrayCount。 文件读完 FileClose 后 Print 输出 Loading completed. 回放即就绪。外汇与贵金属 tick 重放属高风险验证,实盘参数可能偏离历史样本。
szInfo += " " + FileReadString(file); m_ArrayInfoTicks[m_ArrayCount].dt = macroRemoveSec(StringToTime(StringSubstr(szInfo, class="num">0, class="num">19))); m_ArrayInfoTicks[m_ArrayCount].milisec = (class="type">int)StringToInteger(StringSubstr(szInfo, class="num">20, class="num">3)); m_ArrayInfoTicks[m_ArrayCount].Bid = StringToDouble(FileReadString(file)); m_ArrayInfoTicks[m_ArrayCount].Ask = StringToDouble(FileReadString(file)); m_ArrayInfoTicks[m_ArrayCount].Last = StringToDouble(FileReadString(file)); m_ArrayInfoTicks[m_ArrayCount].Vol = StringToInteger(FileReadString(file)); flag = m_ArrayInfoTicks[m_ArrayCount].flag = (class="type">uchar)StringToInteger(FileReadString(file)); if (((flag & TICK_FLAG_ASK) == TICK_FLAG_ASK) || ((flag & TICK_FLAG_BID) == TICK_FLAG_BID)) class="kw">continue; m_ArrayCount++; } FileClose(file); Print("Loading completed.\nReplay started."); class="kw">return;
「回放引擎里的tick过滤与K线合成」
这段逻辑干两件事:一是踢掉只带 ASK 或 BID 标志的 tick,避免用单边报价去刷新 K 线;二是用宏把秒级时间戳折算成分钟序号,方便后续按分钟切分回放节奏。 宏 macroGetMin 里用 3600 和 60 两个常数做整除,本质是把 datetime 里的「小时内秒数」剥掉,再除以 60 得到「小时内第几分钟」。如果你要改回放粒度(比如按 5 分钟一根),这里的两个魔数就得同步重算。 Event_OnTime 是回放心跳:m_ReplayCount 超过 m_ArrayCount 就直接 return,说明 tick 数组播完即停。首次进入时 m_dt==0,会把首 tick 的 Last 价格同时塞进 open/high/low/close,并把 _dt 记成 TimeLocal(),这一步决定了回放基准时钟从本机时间起跳。 isNew 判断当前 tick 的时间戳是否跨了根 K 线:跨了就把 m_dt 更新为新时间,且新 K 线的 open 取当前 close;没跨就保留老 open,只动 close。外汇与贵金属 tick 回放本身不涉及实盘资金,但用历史 tick 验证策略时仍须意识到样本外漂移的高风险。
if (((flag & TICK_FLAG_ASK) == TICK_FLAG_ASK) || ((flag & TICK_FLAG_BID) == TICK_FLAG_BID)) class="kw">continue; class="macro">#define macroGetMin(A) (class="type">int)((A - (A - ((A % class="num">3600) - (A % class="num">60)))) / class="num">60) class="type">void Event_OnTime(class="type">void) { class="type">bool isNew; class="kw">static class="type">class="kw">datetime _dt = class="num">0; if (m_ReplayCount >= m_ArrayCount) class="kw">return; if (m_dt == class="num">0) { m_Rate[class="num">0].close = m_Rate[class="num">0].open = m_Rate[class="num">0].high = m_Rate[class="num">0].low = m_ArrayInfoTicks[m_ReplayCount].Last; m_Rate[class="num">0].tick_volume = class="num">0; _dt = TimeLocal(); } isNew = m_dt != m_ArrayInfoTicks[m_ReplayCount].dt; m_dt = (isNew ? m_ArrayInfoTicks[m_ReplayCount].dt : m_dt); m_Rate[class="num">0].close = m_ArrayInfoTicks[m_ReplayCount].Last; m_Rate[class="num">0].open = (isNew ? m_Rate[class="num">0].close : m_Rate[class="num">0].open);
◍ 回放里实时更新 K 线与时间戳
在自定义回放引擎中,每来一个 tick 都要重写 0 号柱的 high / low / tick_volume 和时间,再调用 CustomRatesUpdate 把这一根推给图表。 m_Rate[0].high 的写法是:若是新柱(isNew)或当前 close 高于原 high,就取 close,否则保留原 high;low 同理取两者较小值。tick_volume 在新柱时直接赋为本次成交量,否则累加。 更新完 m_Rate[0].time = m_dt 后,CustomRatesUpdate(def_SymbolReplay, m_Rate, 1) 只刷一根,开销可控;随后 m_ReplayCount++ 推进队列下标。 下面这段带底色逻辑用于打点:当分钟数为 1 且 _dt 大于 0 时,把历史时间与本地时间打印出来,再把 _dt 清零,方便下一次跨分钟再触发。外汇与贵金属回放属高风险验证环境,结果仅作逻辑核对。
m_Rate[class="num">0].high = (isNew || (m_Rate[class="num">0].close > m_Rate[class="num">0].high) ? m_Rate[class="num">0].close : m_Rate[class="num">0].high); m_Rate[class="num">0].low = (isNew || (m_Rate[class="num">0].close < m_Rate[class="num">0].low) ? m_Rate[class="num">0].close : m_Rate[class="num">0].low); m_Rate[class="num">0].tick_volume = (isNew ? m_ArrayInfoTicks[m_ReplayCount].Vol : m_Rate[class="num">0].tick_volume + m_ArrayInfoTicks[m_ReplayCount].Vol); m_Rate[class="num">0].time = m_dt; CustomRatesUpdate(def_SymbolReplay, m_Rate, class="num">1); m_ReplayCount++; if ((macroGetMin(m_dt) == class="num">1) && (_dt > class="num">0)) { Print(TimeToString(_dt, TIME_DATE | TIME_SECONDS), " ---- ", TimeToString(TimeLocal(), TIME_DATE | TIME_SECONDS)); _dt = class="num">0; } class="macro">#undef macroGetMin
毫秒计时下的补丁与天花板
回放系统跑起来后,最扎眼的是数据里反复出现「时间走了、价格没动」或「价格微动 1 毫秒、时间却跳了 1 分钟」的空窗。硬件层面 EventSetMillisecondTimer 的粒度有限,这不是 MQL5 的锅,但我们可以在 EA 里合并这类冗余事件,少调几次柱线创建函数,长期看能抠出不少耗时。 第一版修改后实测:生成 1 分钟级别的柱线数据,大概要花 3 分钟。这个速度对外汇或贵金属的历史回放验证来说,基本不可接受,属于能跑但没法用的状态。 加了一行关键判断后,耗时压到约 2 分 29 秒。提升幅度不大,但说明前面合并空窗的逻辑确实 decisive。不过 EventSetMillisecondTimer 最小事件间隔改不了,这条路到头了。 还有个视觉坑:不加那两行高亮代码,第一根柱线总被截断,图表读起来别扭;补上之后从首根柱线起就能正常显示。小事,但直接影响你肉眼校验回放。 即便把 OnTime 事件间隔压到极限、把 1 分钟内无变化的跳价压缩掉,EA 架构也成不了正经回放系统。得换思路,不过上面这些补丁你在 MT5 里改完,至少能让伪回放勉强能看。
class="macro">#define macroRemoveSec(A) (A - (A % class="num">60)) class="type">void LoadFile(class="kw">const class="type">class="kw">string szFileName) { class=class="str">"cmt">// ... Internal code ... FileClose(file); Print("Loading completed. ", m_ArrayCount, " movement positions were generated. Starting Replay."); class="kw">return; } Print("Failed to access tick data file."); ExpertRemove(); }; class="macro">#undef macroRemoveSec class="macro">#define macroRemoveSec(A) (A - (A % class="num">60)) class="type">void LoadFile(class="kw">const class="type">class="kw">string szFileName) { class="type">int file; class="type">class="kw">string szInfo; class="type">class="kw">double last; class="type">long vol; class="type">uchar flag;