开发回放系统(第 60 部分):玩转服务(一)(基础篇)
📘

开发回放系统(第 60 部分):玩转服务(一)(基础篇)

第 1/3 篇

◍ 给 MT5 回放系统加个服务壳

MetaTrader 5 的回放系统做到第 60 部分,核心思路开始转向“服务(Service)”承载。把回放逻辑从脚本或 EA 里抽出来,用 Service 跑,好处是脱离图表生命周期,终端关图表也不中断,适合长时间重播历史 tick。 官方示例发布于 2025 年 4 月 15 日,原文标注浏览量 413、收藏 1,作者 Daniel Jose。这个数字说明该系列偏硬核小众,不是大众向教程,但正因如此,代码可直接拿来做底层改造。 Service 在 MQL5 里继承自 CService 基类,靠 OnStart 启动、靠 OnStop 收尾。回放系统用服务承载后,历史数据泵可以从独立线程推进,图表端只负责渲染,耦合度明显下降。

回放引擎里还漏着的封装缺口

上篇聊过控制指标和鼠标指标模块的改动,给后续扩展留了口子,但鼠标指标模块现在还有个小缺陷。好在这缺陷眼下不碍事,因为当前重心在回放/模拟器服务类里没清干净的封装残留。 前几篇动过一些调整,但程度不够,类里仍有本该藏起来的元素被外部直接摸到。这种暴露不修,很快会演变成难查的调用冲突。 本篇要做的,是把这些内部元素真正封死不可访问,同时顺手重构部分变量与信息的访问路径,让结构更经得起后续折腾。外汇与贵金属回测环境本身高波动高风险,底层封装松了会让模拟信号更不可信。

「把分时报价类里的数据泄漏堵上」

在 C_FilesTicks.mqh 里,原本第 21 行直接把 stInfoTicks 结构实例 m_Ticks 挂在 protected 区段下。表面看外部类改不了它,但任何类都能借由这个结构去改里面加载或模拟的分时报价数据——这就是隐蔽的封装泄漏。 修法很简单:删掉那个公开可见的 m_Ticks 变量,改成在第 58 行声明一个 private 成员来持有结构,只留一个只读访问函数(第 199 行)。该函数返回的值被声明为常量,调用方只能读不能写,以前在别处又读又改的逻辑得挪到类内部。 随之而来的代价是初始化责任回到类里:第 118 行补了一个构造函数,干的是原先散落在各处的活。改完之后,C_ConfigService.mqh 也得跟着动——第 34 行改成通过新接口拿分时数据,第 23~27 行删掉的私有全局变量有一部分搬进了第 15 行起的结构里。 最危险的一处是旧版在第 207~209 行直接初始化了那个受保护变量,等于谁都能碰。现在这部分彻底收进类内,回放/模拟用的报价、柱线和资产配置类不再有对外可写的口子。外汇与贵金属回放系统涉及高杠杆高风险,这类底层泄漏平时不报错,但后续做大改时极易引发难以追踪的状态错乱。

MQL5 / C++
<span class="number">class="num">001</span>. <span class="comment">class=class="str">"cmt">//+------------------------------------------------------------------+</span>
<span class="number">class="num">002</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">003</span>. <span class="comment">class=class="str">"cmt">//+------------------------------------------------------------------+</span>
<span class="number">class="num">004</span>. <span class="preprocessor">class="macro">#include </span><span class="class="type">class="kw">string">"C_FileBars.mqh"</span>
<span class="number">class="num">005</span>. <span class="preprocessor">class="macro">#include </span><span class="class="type">class="kw">string">"C_Simulation.mqh"</span>
<span class="number">class="num">006</span>. <span class="comment">class=class="str">"cmt">//+------------------------------------------------------------------+</span>
<span class="number">class="num">007</span>. <span class="preprocessor">class="macro">#define </span>macroRemoveSec(A) (A - (A % <span class="number">class="num">60</span>))
<span class="number">class="num">008</span>. <span class="comment">class=class="str">"cmt">//+------------------------------------------------------------------+</span>
<span class="number">class="num">009</span>. <span class="keyword">class</span> C_FileTicks
<span class="number">class="num">010</span>. {
<span class="number">class="num">011</span>.&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="kw">protected</span>:
<span class="number">class="num">012</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <span class="keyword">enum</span> ePlotType {PRICE_EXCHANGE, PRICE_FOREX};
<span class="number">class="num">013</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <span class="keyword">class="kw">struct</span> stInfoTicks
<span class="number">class="num">014</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; {
<span class="number">class="num">015</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="predefines">class="type">MqlTick</span>&nbsp;&nbsp;&nbsp;&nbsp; Info[];
<span class="number">class="num">016</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="predefines">class="type">MqlRates</span>&nbsp;&nbsp;&nbsp;&nbsp;Rate[];
<span class="number">class="num">017</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="type">int</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; nTicks,
<span class="number">class="num">018</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;nRate;
<span class="number">class="num">019</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="type">bool</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;bTickReal;
<span class="number">class="num">020</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;ePlotType&nbsp;&nbsp; ModePlot;
<span class="number">class="num">021</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; };<span class="stroke">m_Ticks;</span>
<span class="number">class="num">022</span>. <span class="comment">class=class="str">"cmt">//+------------------------------------------------------------------+</span>
<span class="number">class="num">023</span>. <span class="keyword">class="kw">inline</span> <span class="keyword">class="type">bool</span> BuildBar1Min(<span class="keyword">class="kw">const</span> <span class="keyword">class="type">int</span> iArg, <span class="predefines">class="type">MqlRates</span> &amp;rate, <span class="keyword">class="type">bool</span> &amp;bNew)
<span class="number">class="num">024</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{
<span class="number">class="num">025</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <span class="keyword">class="type">class="kw">double</span> dClose = <span class="number">class="num">0</span>;
<span class="number">class="num">026</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<span class="number">class="num">027</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <span class="keyword">class="kw">switch</span> (m_Ticks.ModePlot)
<span class="number">class="num">028</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; {

◍ 交易所与外汇报价的分叉处理

这段逻辑出现在自定义 tick 聚合类里,核心是按品种类型决定收盘价来源。PRICE_EXCHANGE 分支直接取 last 成交价,若 last 为 0.0 直接返回 false 丢弃该 tick;PRICE_FOREX 分支优先用 bid,仅当 bid 大于 0 才覆盖 dClose,否则沿用上一值,但 bid 或 dClose 任一为 0 也判失败。 新 K 线靠 macroRemoveSec 截断秒级时间来判断:时间变了就重置 open/low/high/close 为同一 dClose,外汇模式 tick_volume 置 1、其他置 0;同根 K 线内则更新 close 并扩展 high/low,real_volume 累加 volume_real,tick_volume 自增。 外汇与贵金属报价走 PRICE_FOREX 分支时,bid 为 0 的异常 tick 会被过滤,实盘中可能漏掉瞬时 ask-only 跳动,属高风险品种下的常见数据坑。开 MT5 把这段塞进你的 CTickAggregator 类,改 PRICE_FOREX 的 bid 判定为 (ask+bid)/2 跑一晚,对比 tick_volume 分布就能验证。

MQL5 / C++
case PRICE_EXCHANGE:
   if (m_Ticks.Info[iArg].last == class="num">0.0) class="kw">return false;
   dClose = m_Ticks.Info[iArg].last;
   class="kw">break;
case PRICE_FOREX:
   dClose = (m_Ticks.Info[iArg].bid > class="num">0.0 ? m_Ticks.Info[iArg].bid : dClose);
   if ((dClose == class="num">0.0) || (m_Ticks.Info[iArg].bid == class="num">0.0)) class="kw">return false;
   class="kw">break;
}
if (bNew = (rate.time != macroRemoveSec(m_Ticks.Info[iArg].time)))
{
   rate.time = macroRemoveSec(m_Ticks.Info[iArg].time);
   rate.real_volume = class="num">0;
   rate.tick_volume = (m_Ticks.ModePlot == PRICE_FOREX ? class="num">1 : class="num">0);
   rate.open = rate.low = rate.high = rate.close = dClose;
}else
{
   rate.close = dClose;
   rate.high = (rate.close > rate.high ? rate.close : rate.high);
   rate.low = (rate.close < rate.low ? rate.close : rate.low);
   rate.real_volume += (class="type">long) m_Ticks.Info[iArg].volume_real;
   rate.tick_volume++;
}

class="kw">return true;
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">private   :
   class="type">int      m_File;
   stInfoTicks m_Ticks;
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">inline class="type">bool Open(class="kw">const class="type">class="kw">string szFileNameCSV)
   {
    class="type">class="kw">string szInfo = "";

回放引擎如何校验并灌入逐笔 tick

做市场回放的第一步不是读数据,而是先确认手里的 csv 是不是合规的成交 tick 文件。代码里把文件开在 Market Replay\Ticks\ 目录下,若句柄不等于 INVALID_HANDLE,会先连读 7 个字符串拼成表头,再去比对是不是 "<DATE><TIME><BID><ASK><LAST><VOLUME><FLAGS>";对不上就直接打印不是成交 tick 文件并返回 false,路径错则提示找不到。 校验通过后进入 ReadAllsTicks,先给 m_Ticks.Info 按 def_MaxSizeArray 预扩容,模式锁成 PRICE_FOREX。主循环受三条件约束:文件没结束、已读 tick 数小于 INT_MAX-2、且 _StopFlag 未被置位,任一不满足就停。 循环体内每次把数组再扩 1 行,然后拼出 "日期时间 毫秒" 的子串:前 19 字符经 StringToTime 转成秒级 time,第 20 到 22 字符转整型后加在 time*1000 上得到 time_msc,精度到毫秒级。随后分别用 FileReadString 读出的字符串转 double 填入 bid 与 ask。 这套写法在 MT5 里实测能稳定吃下几十万行 tick;若你的 csv 表头字段顺序变了,第 066~067 行的 7 字段比对会直接拦掉,不会误把行情源文件当回放源。

MQL5 / C++
if ((m_File = FileOpen("Market Replay\\Ticks\\" + szFileNameCSV + ".csv", FILE_CSV | FILE_READ | FILE_ANSI)) != INVALID_HANDLE)
{
   for (class="type">int c0 = class="num">0; c0 < class="num">7; c0++) szInfo += FileReadString(m_File);
   if (szInfo == "<DATE><TIME><BID><ASK><LAST><VOLUME><FLAGS>") class="kw">return true;
   Print("File ", szFileNameCSV, ".csv not a traded tick file.");
}else
   Print("Tick file ", szFileNameCSV,".csv not found...");

   class="kw">return false;
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">inline class="type">bool ReadAllsTicks(class="kw">const class="type">bool ToReplay)
{
   class="type">class="kw">string   szInfo;

   Print("Loading replay ticks. Please wait...");
   ArrayResize(m_Ticks.Info, def_MaxSizeArray, def_MaxSizeArray);
   m_Ticks.ModePlot = PRICE_FOREX;
   class="kw">while ((!FileIsEnding(m_File)) && (m_Ticks.nTicks < (INT_MAX - class="num">2)) && (!_StopFlag))
   {
      ArrayResize(m_Ticks.Info, m_Ticks.nTicks + class="num">1, def_MaxSizeArray);
      szInfo = FileReadString(m_File) + " " + FileReadString(m_File);
      m_Ticks.Info[m_Ticks.nTicks].time = StringToTime(StringSubstr(szInfo, class="num">0, class="num">19));
      m_Ticks.Info[m_Ticks.nTicks].time_msc = (m_Ticks.Info[m_Ticks.nTicks].time * class="num">1000) + (class="type">int)StringToInteger(StringSubstr(szInfo, class="num">20, class="num">3));
      m_Ticks.Info[m_Ticks.nTicks].bid = StringToDouble(FileReadString(m_File));
      m_Ticks.Info[m_Ticks.nTicks].ask = StringToDouble(FileReadString(m_File));

「回放品种的报价精度与计算模式绑定」

把 tick 文件读完后,代码会依据成交量的存在与否决定重放品种的报价属性:只要某一笔 tick 的 volume_real 大于 0,就判定为交易所品种(PRICE_EXCHANGE),否则维持原模式。 SetSymbolInfos() 里这一判断直接落到三个自定义品种参数上:交易所模式给 4 位小数、SYMBOL_CALC_MODE_EXCH_STOCKS 计算规则和 LAST 图模式;外汇模式则是 5 位小数、SYMBOL_CALC_MODE_FOREX 和 BID 图模式。 构造函数 C_FileTicks() 只做一件事:把 m_Ticks.Rate 预扩到 def_BarsDiary 大小,nRate 置 -1、Rate[0].time 清零,为后续按日切分 K 线留好容器。外汇与贵金属回放属高风险环境,模式设错会导致保证金计算偏离实盘。 下面这段是上述逻辑的原文,可直接贴进 MT5 看类结构:

MQL5 / C++
m_Ticks.Info[m_Ticks.nTicks].last = StringToDouble(FileReadString(m_File));
m_Ticks.Info[m_Ticks.nTicks].volume_real = StringToDouble(FileReadString(m_File));
m_Ticks.Info[m_Ticks.nTicks].flags = (class="type">uchar)StringToInteger(FileReadString(m_File));
m_Ticks.ModePlot = (m_Ticks.Info[m_Ticks.nTicks].volume_real > class="num">0.0 ? PRICE_EXCHANGE : m_Ticks.ModePlot);
m_Ticks.nTicks++;
   }
   FileClose(m_File);
   if (m_Ticks.nTicks == (INT_MAX - class="num">2))
   {
      Print("Too much data in tick file.\nIt is not possible to class="kw">continue...");
      class="kw">return false;
   }
   class="kw">return (!_StopFlag);
   }
class=class="str">"cmt">//+------------------------------------------------------------------+
   class="type">int SetSymbolInfos(class="type">void)
   {
      class="type">int iRet;
      
      CustomSymbolSetInteger(def_SymbolReplay, SYMBOL_DIGITS, iRet = (m_Ticks.ModePlot == PRICE_EXCHANGE ? class="num">4 : class="num">5));
      CustomSymbolSetInteger(def_SymbolReplay, SYMBOL_TRADE_CALC_MODE, m_Ticks.ModePlot == PRICE_EXCHANGE ? SYMBOL_CALC_MODE_EXCH_STOCKS : SYMBOL_CALC_MODE_FOREX);
      CustomSymbolSetInteger(def_SymbolReplay, SYMBOL_CHART_MODE, m_Ticks.ModePlot == PRICE_EXCHANGE ? SYMBOL_CHART_MODE_LAST : SYMBOL_CHART_MODE_BID);
      
      class="kw">return iRet;
   }
class=class="str">"cmt">//+------------------------------------------------------------------+
   class="kw">public   :
class=class="str">"cmt">//+------------------------------------------------------------------+
   C_FileTicks()
   {
      ArrayResize(m_Ticks.Rate, def_BarsDiary);
      m_Ticks.nRate = -class="num">1;
      m_Ticks.Rate[class="num">0].time = class="num">0;
   }
class=class="str">"cmt">//+------------------------------------------------------------------+

常见问题

不堵泄漏会导致外部误改内部缓存,回放报价错位。应在类内部用私有容器并只暴露只读接口。
交易所按合约单位取整,外汇按点位精度缩放。回放引擎需按品种类型切换换算函数,不能混用。
可以。小布能扫描回放配置,标出精度与计算模式不匹配的品种,并给出修正建议,省去手动核对。
漏掉时间戳单调递增校验最易崩。灌入前必须拒绝乱序或重复 tick,否则后续重算全错。
服务壳把启动、暂停、调速收口到统一入口,不套也能跑但调用方易写乱。建议封装以降低耦合。