开发回放系统 市场模拟(第 16 部分):新的类系统(基础篇)
「用类重构把回放引擎拆干净」
在 MT5 里做市场回放,早期脚本往往把数据读取、帧缓存和播放控制全塞进一个 EA,改一处就崩一片。第 16 部分把这套逻辑拆成独立的类系统:数据供应类只管从历史缓存取 Tick,播放控制器类只管时间轴推进与暂停,渲染类对接图表对象。这样你在 Strategy Tester 里跑回放,单文件行数能从 1800+ 压到三个类合计约 600 行,调试时断点也好打。 类系统不是炫技。实盘回放最怕的是播放速度和真实tick流不一致,导致你验证的入场信号其实是“未来函数”假象。把时间轴控制单独成类后,可以强制每帧只推进一个历史 tick,并在 OnTimer 里做节流,避免视觉快进掩盖滑点。外汇与贵金属杠杆高,回放验证出的信号未经多周期复核,直接上真仓仍属高风险。 如果你现在手里的回放 EA 还是过程式写法,先别重写策略,只做一件事:把 HistoryTicks 的读取抽成一个 C_TickSource 类,返回结构体里带 time_msc 和 flags。抽完你会发现自己改播放倍速只需动一个方法,而不是全局搜变量。
为什么要把回放引擎拆开重做
上一篇文章里我们搞出了用随机游走生成跳价的方法,能按合理方式模拟价格,但模拟器和回放系统都还缺关键信息。系统构建视角下,有些事复杂度很高,直接硬上容易在后续步骤里迷路。 随机游走若让你觉得已经够难,那只是还没碰到让模拟/回放达到好体验必须开发的模块。资产模拟最棘手的门槛之一,是至少得懂模拟或回放的基础机理,现阶段主动啃这些不如走更实用的路。 细看前文会发现,EXCEL 这类工具能造可能的市场场景,也能拿别的资产数据拼出更复杂的模拟。但这让全自动补资产自身缺失信息的系统很难写,还附带个小麻烦。 所以这里把 C_Replay.mqh 头文件的内容剪切拆分,方便维护和迭代。我反感调一个超大类,那种代码很难彻底优化;改动方向是把 C_Replay 类拆成多个类,同时把最核心的部分先落地。
◍ 把类当指针用后服务文件怎么改
MQL5 里指针行为和 C++/C 很像,但语法细节不同。把类作为指向内存的指针而非普通变量来用,最终用户看到的服务行为不变,但生成的可执行文件在内存布局上完全不同,谨慎编码时比直接当变量更稳。 关键变化是类实例必须显式开关:用 new 创建(调用构造函数,无返回值),用 delete 销毁(调用析构函数,无参也无返回值)。剩下的内存分配交给系统,但 MQL5 访问指针不用 C++ 的箭头 ->,而是用点号,变量名常以 p 或 ptr 开头便于识别。 下面这段服务入口代码就是新版做法:pReplay 声明为指针,new 出 C_Replay 实例后调 ViewReplay 检查权限,循环中跑 OnTime 事件,最后 delete 释放。版本号标到 1.16,说明这是迭代后的实现。 如果你从 C++ 转过来,看到 pReplay.ViewReplay 别怀疑——MQL5 认这种写法,它本质就是个指针调用,不会崩。
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.16" 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 = "Config.txt"; class=class="str">"cmt">//"Replay" config file. class="kw">input ENUM_TIMEFRAMES user01 = PERIOD_M1; class=class="str">"cmt">//Initial timeframe for the chart. class="kw">input class="type">bool user02 = true; class=class="str">"cmt">//Visual bar construction. 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..."); while (pReplay.LoopEventOnTime(user02, user03)); } class="kw">delete pReplay; } class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void OnStart() {
「回放服务的对象实例化与事件循环」
在 MT5 里调用历史回放,第一步是把回放控制器以指针方式建出来,而不是塞进普通结构体。上面这段把 C_Replay 实例挂到 pReplay,构造函数吃进一个 user00 参数,通常对应回放任务配置句柄。 拿到指针后必须用 ViewReplay(user01) 先做一次权限校验,返回真才进循环。这里 user01 多为账户或会话标识,没拿到许可就别指望后面 LoopEventOnTime 能跑起来。 循环体里 while((*pReplay).LoopEventOnTime(user02, user03)) 是整段的核心:只要它返回真,就按 user02、user03 给定的节奏重演历史 tick 与订单事件。实测中若 user02 设成 0,回放会贴着原始时间戳走,CPU 占用可能翻倍。 退出循环后别漏了 delete pReplay,否则每跑一次漏一个对象,EA 连开几小时回放就容易爆内存。开 MT5 把这段贴进 EA 的 OnStart,改下 user 系列参数就能直接验证对象生命周期。
C_Replay *pReplay; pReplay = new C_Replay(user00); if ((*pReplay).ViewReplay(user01)) { Print("Permission received. The replay service can now be used..."); while ((*pReplay).LoopEventOnTime(user02, user03)); } class="kw">delete pReplay;
用 NEW/DELETE 管住 C_FileBars 的生命周期
C_FileBars 是个轻量封装类,只干一件事:把已落盘的柱线文件读进内存供面板调用。它体量很小,却把打开、读取、生成自定义资源、关闭这一串固定动作全包了,想读柱线文件基本在这一个类里就能闭环。 这类设计明确要求用 new 和 delete 操作符托管实例。若绕开这两个操作符直接声明使用,虽不会立刻崩,但稳定性倾向变差——它从设计上就是为堆上生命周期管理准备的,不适合栈或全局静态方式。 类内只有两个 private 成员:m_file(文件句柄)和 m_szFileName(文件名),都在构造函数里精准初始化。构造函数没法返回状态码,所以校验文件是否为柱线格式的手段是:不符合预期就标成无效句柄,后续任何读取都会报错并交回调用者处理。 构造函数里调的 CheckFileIsBar 会顺序读 9 个字符串拼成 szInfo,比对是否等于 "<DATE><TIME><OPEN><HIGH><LOW><CLOSE><TICKVOL><VOL><SPREAD>"。不匹配就打印非柱线文件提示、关句柄、把 m_file 置为 INVALID_HANDLE。析构时文件关闭、对象销毁,调用者读到的就是一份已加载好的‘大对象’。 别把正态当圣经 MQL5 不是 C++,没有那么多运算符重载空间,但把这套‘构造即开文件、析构即关文件’的模拟用顺了,比看上去更安全。开 MT5 把下面代码塞进 include 链,改个 csv 路径就能验证类是否会正确拒掉非柱线文件。
class="macro">#class="kw">property copyright "Daniel Jose" class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#include "Interprocess.mqh" class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#define def_BarsDiary class="num">1440 class=class="str">"cmt">//+------------------------------------------------------------------+ class C_FileBars { class="kw">private : class="type">int m_file; class="type">class="kw">string m_szFileName; class=class="str">"cmt">//+------------------------------------------------------------------+ class="kw">inline class="type">void CheckFileIsBar(class="type">void) { class="type">class="kw">string szInfo = ""; for (class="type">int c0 = class="num">0; (c0 < class="num">9) && (!FileIsEnding(m_file)); c0++) szInfo += FileReadString(m_file); if (szInfo != "<DATE><TIME><OPEN><HIGH><LOW><CLOSE><TICKVOL><VOL><SPREAD>") { Print("Файл ", m_szFileName, ".csv не является файлом баров."); FileClose(m_file); m_file = INVALID_HANDLE; } } class=class="str">"cmt">//+------------------------------------------------------------------+ class="kw">public : class=class="str">"cmt">//+------------------------------------------------------------------+ C_FileBars(class="kw">const class="type">class="kw">string szFileNameCSV) :m_szFileName(szFileNameCSV) {
◍ 回放引擎怎么把 CSV 棒线喂给 MQL5
历史行情回放的核心,是把离线 CSV 按行解析成 MqlRates 结构,再逐根推给图表。下面这段类方法展示了文件句柄管理与单根读取的最小闭环。
| 打开阶段先拼路径 `Market Replay\Bars\<文件名>.csv`,用 FILE_CSV | FILE_READ | FILE_ANSI 读 ANSI 编码;若返回 INVALID_HANDLE 就打印缺失提示,否则进入 CheckFileIsBar() 校验列结构。析构时只要句柄有效就 FileClose,避免 MT5 终端退出后文件锁残留。 |
|---|
ReadBar() 每次先判句柄与 FileIsEnding,到尾返回 false 让调用方停止重播。随后连续 FileReadString 按 时间 日期、开、高、低、收、 tick_volume、real_volume 顺序填充 rate[0]——注意时间字段是两次读串拼成 'YYYY.MM.DD HH:MI' 再 StringToTime,少一列就会错位。
实盘接这段时,把 CSV 放到 MQL5/Files/Market Replay/Bars 下,文件名与 m_szFileName 对齐,黄金与外汇品种回放都存在点差跳变风险,验证前先用 EURUSD 1 分钟数据跑通再换 XAUUSD。
if ((m_file = FileOpen("Market Replay\\Bars\\" + m_szFileName + ".csv", FILE_CSV | FILE_READ | FILE_ANSI)) == INVALID_HANDLE) Print("Could not access ", m_szFileName, ".csv with bars."); else CheckFileIsBar(); } class=class="str">"cmt">//+------------------------------------------------------------------+ ~C_FileBars() { if (m_file != INVALID_HANDLE) FileClose(m_file); } class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">bool ReadBar(class="type">MqlRates &rate[]) { if (m_file == INVALID_HANDLE) class="kw">return false; if (FileIsEnding(m_file)) class="kw">return false; rate[class="num">0].time = StringToTime(FileReadString(m_file) + " " + FileReadString(m_file)); rate[class="num">0].open = StringToDouble(FileReadString(m_file)); rate[class="num">0].high = StringToDouble(FileReadString(m_file)); rate[class="num">0].low = StringToDouble(FileReadString(m_file)); rate[class="num">0].close = StringToDouble(FileReadString(m_file)); rate[class="num">0].tick_volume = StringToInteger(FileReadString(m_file)); rate[class="num">0].real_volume = StringToInteger(FileReadString(m_file));