开发回放系统(第 60 部分):玩转服务(一)(基础篇)
◍ 给 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 行直接初始化了那个受保护变量,等于谁都能碰。现在这部分彻底收进类内,回放/模拟用的报价、柱线和资产配置类不再有对外可写的口子。外汇与贵金属回放系统涉及高杠杆高风险,这类底层泄漏平时不报错,但后续做大改时极易引发难以追踪的状态错乱。
<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>. <span class="keyword">class="kw">protected</span>: <span class="number">class="num">012</span>. <span class="keyword">enum</span> ePlotType {PRICE_EXCHANGE, PRICE_FOREX}; <span class="number">class="num">013</span>. <span class="keyword">class="kw">struct</span> stInfoTicks <span class="number">class="num">014</span>. { <span class="number">class="num">015</span>. <span class="predefines">class="type">MqlTick</span> Info[]; <span class="number">class="num">016</span>. <span class="predefines">class="type">MqlRates</span> Rate[]; <span class="number">class="num">017</span>. <span class="keyword">class="type">int</span> nTicks, <span class="number">class="num">018</span>. nRate; <span class="number">class="num">019</span>. <span class="keyword">class="type">bool</span> bTickReal; <span class="number">class="num">020</span>. ePlotType ModePlot; <span class="number">class="num">021</span>. };<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> &rate, <span class="keyword">class="type">bool</span> &bNew) <span class="number">class="num">024</span>. { <span class="number">class="num">025</span>. <span class="keyword">class="type">class="kw">double</span> dClose = <span class="number">class="num">0</span>; <span class="number">class="num">026</span>. <span class="number">class="num">027</span>. <span class="keyword">class="kw">switch</span> (m_Ticks.ModePlot) <span class="number">class="num">028</span>. {
◍ 交易所与外汇报价的分叉处理
这段逻辑出现在自定义 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 分布就能验证。
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 字段比对会直接拦掉,不会误把行情源文件当回放源。
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 看类结构:
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">//+------------------------------------------------------------------+