开发回放系统  市场模拟(第 01 部分):首次实验(I)·进阶篇
⏱️

开发回放系统 市场模拟(第 01 部分):首次实验(I)·进阶篇

(2/3)· 标准 MT5 缺失回放模块,靠外部 DLL 又埋隐患,这篇用纯 MQL5 啃下首次实验的硬骨头

进阶 第 2/3 篇
不少人为 MT5 补回放功能直接塞外部 DLL,代码黑盒且来源不明,整个终端安全性直接裸奔。其实纯 MQL5 就能搭模拟器,只是 EventSetMillisecondTimer 有暗坑,踩中就卡在基础演示都跑不顺。

「毫秒级定时器的真实触发边界」

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 分钟则倾向可用。外汇与贵金属回放测试仍属高风险的模拟环境,实盘偏差概率不低。

MQL5 / C++
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_CSVFILE_READFILE_ANSI 方式打开;若返回 INVALID_HANDLE 就直接跳过,不崩。

进文件后先丢前 7 行表头(for 循环 c0<7 调 FileReadString),再 Print 提示等待。主体 while 受两重闸控:FileIsEnding 没到尾,且 m_ArrayCount 小于 def_MaxSizeArray——这意味着数组上限一旦撞满,回放数据就截断,实盘前你得确认 def_MaxSizeArray 够不够装一天 tick。 宏 macroRemoveSec(A) 用 (A - (A % 60)) 把时间戳秒数抹掉,回放时若按整分对齐事件,这个减法能省掉不少边界判断。外汇与贵金属杠杆高、滑点跳变频繁,回放结果仅代表历史数据重演,不预示实盘概率。

MQL5 / C++
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 重放属高风险验证,实盘参数可能偏离历史样本。

MQL5 / C++
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 验证策略时仍须意识到样本外漂移的高风险。

MQL5 / C++
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 清零,方便下一次跨分钟再触发。外汇与贵金属回放属高风险验证环境,结果仅作逻辑核对。

MQL5 / C++
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 里改完,至少能让伪回放勉强能看。

MQL5 / C++
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;
                
把回放帧率诊断交给小布
这些定时器瓶颈和模拟帧率异常,小布盯盘的 AIGC 已内置诊断,打开对应品种页即可看到实时提示,你只管调策略逻辑。

常见问题

受系统调度和 MT5 架构限制,实际精度往往达不到参数值,过高频率还会拖垮主线程,倾向用折中间隔配合缓冲队列。
外部 DLL 来源与内部逻辑不可控,可能引入安全风险;纯 MQL5 虽需编程功底,但可审计、可移植,更适合长期研究。
可在代码层生成合成走势片段,拼接进回放流,MT5 高风险的贵金属跳空场景也能造出来做压力测试。
目前小布内置的是诊断与帧率视图,自写 EA 需你在终端运行,但异常参数小布会标红提醒,省去手动查日志。
上篇偏概念验证,本篇收口在绕过定时器限制并重构数据泵,完整跨篇衔接见《开发回放系统 市场模拟·基础篇》。