开发回放系统  市场模拟(第 16 部分):新的类系统(基础篇)
📘

开发回放系统 市场模拟(第 16 部分):新的类系统(基础篇)

第 1/3 篇

「用类重构把回放引擎拆干净」

在 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 认这种写法,它本质就是个指针调用,不会崩。

MQL5 / C++
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 系列参数就能直接验证对象生命周期。

MQL5 / C++
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 路径就能验证类是否会正确拒掉非柱线文件。

MQL5 / C++
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_CSVFILE_READFILE_ANSI 读 ANSI 编码;若返回 INVALID_HANDLE 就打印缺失提示,否则进入 CheckFileIsBar() 校验列结构。析构时只要句柄有效就 FileClose,避免 MT5 终端退出后文件锁残留。

ReadBar() 每次先判句柄与 FileIsEnding,到尾返回 false 让调用方停止重播。随后连续 FileReadString 按 时间 日期 tick_volumereal_volume 顺序填充 rate[0]——注意时间字段是两次读串拼成 'YYYY.MM.DD HH:MI' 再 StringToTime,少一列就会错位。 实盘接这段时,把 CSV 放到 MQL5/Files/Market Replay/Bars 下,文件名与 m_szFileName 对齐,黄金与外汇品种回放都存在点差跳变风险,验证前先用 EURUSD 1 分钟数据跑通再换 XAUUSD。

MQL5 / C++
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));

常见问题

函数堆久了状态分散难维护,用类把棒线读取、事件循环、生命周期收进各自对象,改一处不影响全局,后续加品种更省事。
服务文件只留对象句柄和调用入口,具体读写交给类内方法;原直接操作数组的代码改为通过指针访问实例成员即可。
可以。小布能扫描你粘贴的代码结构,标出 NEW/DELETE 不配对的位置,并提示哪段循环可能重复申请了 C_FileBars 实例。
在事件循环里反复 NEW 却不 DELETE 会吃光内存;正确做法是在初始化时 NEW 一次,程序退出或切换品种时统一 DELETE。
按设定的回放速度逐根读取 CSV 棒线,通过类方法推入图表缓冲区并触发刷新事件,速度参数可调,慢放便于观察价格行为。