开发回放系统(第 44 部分):Chart Trader 项目(三)(基础篇)
📘

开发回放系统(第 44 部分):Chart Trader 项目(三)(基础篇)

第 1/3 篇

「给 MT5 图表交易面板打底」

MetaTrader 5 的 Chart Trader 项目进入第三期,核心是把回放系统的交互层接到图表上。2024 年 11 月 6 日发布的这一节,在站内有 619 次查看、0 条评论,说明还处在少量内部验证阶段,没经过大规模实盘反馈。 外汇与贵金属品种波动剧烈、杠杆风险高,这类面板只解决下单路径,不解决信号质量。开 MT5 用本地历史数据跑一遍回放,先确认控件不卡顿,再谈策略接驳。

◍ 为什么旧模板数据撑不起复杂回放

上一版 Chart Trader 里,OBJ_CHART 的模板数据只是浅层处理:能跑通,但很多任务做不了。根子在数据建模太简陋,不补这一层,后面加功能会越来越别扭。 概念没变,还是那套东西,只是代码要写得更密一点。把基础建模补上,原本卡住的操作才有落点。 另一个实打实的问题是效率。旧实现里设置调用(property set)次数偏多,MT5 里这类调用走的是图表事件队列,攒多了会拖回放帧率。下一节会动几行代码,把调用量压下来,同时把数据模型填实。外汇与贵金属回放测试请先在模拟盘验证,高风险品种参数错配可能直接卡死图表。

把模板当机器拆:C_AdjustTemplate 的构造与析构逻辑

做模板动态改写时,与其把它当成 MQL5 类,不如当成一台待拆装的机器。C_AdjustTemplate 就是这台机器:构造函数等于「打开机器外壳」,析构函数等于「装回去让 MT5 认这个模板」,中间那些方法全是拧螺丝的工具。 构造函数从第 19 行起,第一个字符串参数必填(源模板路径),第二个可选(目标文件名)。用重载而非写两个构造函数,省事但容易让调用者误用,实战里建议显式传全参。第 20–23 行先手动预初始化变量,编译器虽会隐式清零,显式写清楚能避免后续调试时猜初始值。 第 25 行先重置 _LastError,这点很关键:若构造前已有错误残留,不清掉就会在后面误判。第 26 行尝试打开源文件,失败就写进 _LastError;成功则第 27 行检查通过,第 29 行看目标名有没有给,没给就基于源文件名造个临时文件,第 30 行建目标文件拿句柄。句柄没关之前,别在外面动这两个文件——相当于机器开着盖,乱碰会出事。 析构从第 34 行起:第 36 行关输入文件(句柄有效才关),第 37 行确认输出文件开着才继续。第 40 行若没报错且有名,就把临时文件重命名成指标期望的模板名;第 41 行删掉出错时的临时文件。第 43–45 行释放之前分配的内存,很多人漏这步,MT5 跑久了会无意义吃资源。 真正改模板内容在 Execute(第 66 行起)。它脆弱:读源失败、写目标失败、StringSplit 失败都会改 _LastError,第 71 行 while 直接断,模板不更新,指标可能根本不上图。循环里第 73 行读一行,第 74–75 行判断是不是对象定义块;不是就第 86 行原样写回。是对象就第 76 行按等号拆属性,第 79 行拿 Add 里登记的名单比对,命中就进二级分析;第 80 行循环里确认属性存在后,第 82 行决定是保留原值还是写你指定的值。第 59 行那个看似多余的函数,就是给「不知道填啥就报原值」这种用法留的口子。 这套机制让你能复用同一份模板底稿,运行时按需改对象参数。外汇与贵金属模板改写涉及实时加载,高风险,建议在策略测试器里先跑空图表验证文件句柄释放是否正常再上实盘。

MQL5 / C++
<span class="number">class="num">01</span>. <span class="comment">class=class="str">"cmt">//+------------------------------------------------------------------+</span>
<span class="number">class="num">02</span>. <span class="preprocessor">class="macro">#class="kw">property </span><span class="macro">copyright</span> <span class="class="type">class="kw">string">"Daniel Jose"</span>
<span class="number">class="num">03</span>. <span class="comment">class=class="str">"cmt">//+------------------------------------------------------------------+</span>
<span class="number">class="num">04</span>. <span class="preprocessor">class="macro">#include </span><span class="class="type">class="kw">string">"../Auxiliar/C_Terminal.mqh"</span>
<span class="number">class="num">05</span>. <span class="comment">class=class="str">"cmt">//+------------------------------------------------------------------+</span>
<span class="number">class="num">06</span>. <span class="keyword">class</span> C_AdjustTemplate
<span class="number">class="num">07</span>. {
<span class="number">class="num">08</span>.&nbsp;&nbsp;&nbsp;&nbsp; <span class="keyword">class="kw">private</span> :
<span class="number">class="num">09</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <span class="keyword">class="type">class="kw">string</span> m_szName[];
<span class="number">class="num">10</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <span class="keyword">class="type">class="kw">string</span> m_szFind[];
<span class="number">class="num">11</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <span class="keyword">class="type">class="kw">string</span> m_szReplace[];
<span class="number">class="num">12</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <span class="keyword">class="type">class="kw">string</span> m_szFileName;
<span class="number">class="num">13</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <span class="keyword">class="type">int</span> m_maxIndex;
<span class="number">class="num">14</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <span class="keyword">class="type">int</span> m_FileIn;
<span class="number">class="num">15</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <span class="keyword">class="type">int</span> m_FileOut;
<span class="number">class="num">16</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <span class="keyword">class="type">bool</span> m_bSimple;
<span class="number">class="num">17</span>.&nbsp;&nbsp;&nbsp;&nbsp; <span class="keyword">class="kw">public</span>&nbsp;&nbsp;:
<span class="number">class="num">18</span>. <span class="comment">class=class="str">"cmt">//+------------------------------------------------------------------+</span>

「模板调整类的构造与析构细节」

在 MT5 里做模板自动化改写,核心往往落在一个封装类上。下面这段构造逻辑直接决定了文件能否安全打开:传入源模板名,可选输出名;若输出名为空,则默认在源名后追加「_T」生成新文件。 构造函数里先 ResetLastError() 清掉残留错误码,再用 FileOpen 以 FILE_TXT|FILE_READ 打开源文件。若句柄等于 INVALID_HANDLE,立刻 SetUserError 抛出自定的 ERR_FileAcess,避免后续空读。 输出文件同理:当且仅当 _LastError 为 ERR_SUCCESS 时才尝试写文件,且 m_bSimple 标记用来区分「用户指定输出」与「自动追加后缀」两种模式。 析构函数负责收尾,FileClose(m_FileIn) 无条件执行;m_FileOut 仅当不等于 INVALID_HANDLE 才关闭,防止对未成功打开的句柄调用关闭函数导致日志刷屏。 别把句柄当万能钥匙:外汇与贵金属模板改动涉及实盘参数,误写文件可能引发 EA 加载异常,动手前务必在策略测试器里用小周期样本验证。

MQL5 / C++
C_AdjustTemplate(class="kw">const class="type">class="kw">string szFileNameIn, class="type">class="kw">string szFileNameOut = NULL)
  :m_maxIndex(class="num">0),
   m_szFileName(szFileNameIn),
   m_FileIn(INVALID_HANDLE),
   m_FileOut(INVALID_HANDLE)
   {
   ResetLastError();
   if ((m_FileIn = FileOpen(szFileNameIn, FILE_TXT | FILE_READ)) == INVALID_HANDLE) SetUserError(C_Terminal::ERR_FileAcess);
   if (_LastError == ERR_SUCCESS)
     {
     if (!(m_bSimple = (StringLen(szFileNameOut) > class="num">0))) szFileNameOut = szFileNameIn + "_T";
     if ((m_FileOut = FileOpen(szFileNameOut, FILE_TXT | FILE_WRITE)) == INVALID_HANDLE) SetUserError(C_Terminal::ERR_FileAcess);
     }
   }
class=class="str">"cmt">//+------------------------------------------------------------------+
   ~C_AdjustTemplate()
   {
   FileClose(m_FileIn);
   if (m_FileOut != INVALID_HANDLE)
     {
     FileClose(m_FileOut);

◍ 模板替换类的收尾与动态增查

这段逻辑收在文件写操作的尾部:非简单模式下,若上次文件操作无报错(_LastError == ERR_SUCCESS),就把临时文件『_T』重命名为正式文件,覆盖旧内容;若报错则直接删掉临时文件,避免脏数据落地。 随后把三个动态数组 m_szName、m_szFind、m_szReplace 全部 ArrayResize 到 0,等于清空替换规则缓存,为下一次批次做准备。 Add 方法负责往里塞规则:先 m_maxIndex++,再把三个数组同步扩容,按索引写入名称、查找串、替换串。注意索引用的是 m_maxIndex-1,说明数组从 0 起算。 Get 方法做反向查询:遍历 0 到 m_maxIndex-1,当名称与查找串同时命中时返回对应的替换串;若没命中则隐式返回空串。开 MT5 把这段代码贴进类里,用 Print(Get("tag","old")) 就能验证查找是否生效。

MQL5 / C++
if ((!m_bSimple) && (_LastError == ERR_SUCCESS)) FileMove(m_szFileName + "_T", class="num">0, m_szFileName, FILE_REWRITE);
if ((!m_bSimple) && (_LastError != ERR_SUCCESS)) FileDelete(m_szFileName + "_T");
}
ArrayResize(m_szName, class="num">0);
ArrayResize(m_szFind, class="num">0);
ArrayResize(m_szReplace, class="num">0);
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void Add(class="kw">const class="type">class="kw">string szName, class="kw">const class="type">class="kw">string szFind, class="kw">const class="type">class="kw">string szReplace)
{
m_maxIndex++;
ArrayResize(m_szName, m_maxIndex);
ArrayResize(m_szFind, m_maxIndex);
ArrayResize(m_szReplace, m_maxIndex);
m_szName[m_maxIndex - class="num">1] = szName;
m_szFind[m_maxIndex - class="num">1] = szFind;
m_szReplace[m_maxIndex - class="num">1] = szReplace;
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">class="kw">string Get(class="kw">const class="type">class="kw">string szName, class="kw">const class="type">class="kw">string szFind)
{
for (class="type">int c0 = class="num">0; c0 < m_maxIndex; c0++) if ((m_szName[c0] == szName) && (m_szFind[c0] == szFind)) class="kw">return m_szReplace[c0];

用文件流把对象属性捞出来

这段 Execute 方法干的事很直接:从已打开的 m_FileIn 里逐行读字符串,碰到 <object> 就把标记 count0 置 1,碰到 </object> 置 0,等于只在对象块内部做解析。 第 71 行用 (m_FileIn != INVALID_HANDLE) && (m_FileOut != INVALID_HANDLE) 双重保险,避免句柄无效还硬读;循环条件里同时卡 !FileIsEnding(m_FileIn)_LastError == ERR_SUCCESS,任一处异常就停。 第 76 行 StringSplit(sz0, '=', res) > 1键=值 拆开,再用第 79 行那个紧凑 for 循环拿 res[1] 去比对 m_szName 数组,命中才把 count0 升到 2。 你在 MT5 里接这段时,先把 m_maxIndex、m_szName、m_szFind 三个成员按自己 XML 字段填好,否则 79–80 行比对永远走空;外汇与贵金属行情波动剧烈、杠杆风险高,这类解析脚本只建议用在本地历史数据回放,别挂实盘自动跑。

MQL5 / C++
      class="type">void Execute(class="type">void)
      {
         class="type">class="kw">string sz0, tmp, res[];
         class="type">int count0 = class="num">0, i0;

         if ((m_FileIn != INVALID_HANDLE) && (m_FileOut != INVALID_HANDLE)) class="kw">while ((!FileIsEnding(m_FileIn)) && (_LastError == ERR_SUCCESS))
         {
            sz0 = FileReadString(m_FileIn);
            if (sz0 == "<object>") count0 = class="num">1;
            if (sz0 == "</object>") count0 = class="num">0;
            if (count0 > class="num">0) if (StringSplit(sz0, &class="macro">#x27;=&class="macro">#x27;, res) > class="num">1)
            {
               i0 = (count0 == class="num">1 ? class="num">0 : i0);
               for (class="type">int c0 = class="num">0; (c0 < m_maxIndex) && (count0 == class="num">1); i0 = c0, c0++) count0 = (res[class="num">1] == (tmp = m_szName[c0]) ? class="num">2 : count0);
               for (class="type">int c0 = i0; (c0 < m_maxIndex) && (count0 == class="num">2); c0++) if ((res[class="num">0] == m_szFind[c0]) && (tmp == m_szName[c0]))

常见问题

旧模板多为静态字段,缺少对象属性和动态增查机制,遇到多品种多状态回放就会丢数据。打底时应把模板当机器拆,分离构造、析构与替换逻辑。
构造阶段加载基准模板并初始化对象容器,析构阶段释放资源并回写残留属性。重点是别在析构里漏掉动态新增的对象引用。
可以。把面板工程丢给小布,它能对照基础篇的构造析构规范,标出资源未释放或动态增查缺失的位置,省去人工逐行排查。
常见坑是流未正确关闭导致属性写半截,或字段顺序和模板定义不一致。建议每次捞完显式 flush 并核对键值对数量。
在析构前遍历容器计数,若计数大于初始基准数就触发清理循环。配合文件流导出日志,能直观看到哪些对象没释放。