回放系统服务重构基础篇:从全局变量迁移到新架构的平滑过渡(基础篇)
(1/3)· 放弃全局终端变量后,旧功能如何不丢代码资产地重生?
◍ 在 MT5 里搭一个可回放的历史服务
要把历史行情像录像一样回放,核心不是堆指标,而是写一个常驻的 MQL5 服务(Service),让它脱离图表独立跑。MetaTrader 5 的服务和 EA 不同,没有 OnTick 绑定到具体品种,适合做全局性的数据回放调度。 官方示例里这类回放服务的源码发布时间是 2025 年 6 月 18 日 07:11,原文标注阅读量 522、评论 0,说明这套玩法还很小众,多数人仍在用脚本硬拖 K 线。 服务跑起来后,你可以把任意一段历史 tick 或 M1 数据按自定义速度推送给指标或 EA,相当于自己造一个低成本的「复盘沙盒」。外汇与贵金属杠杆高、滑点诡异,回放结果只反映历史概率,实盘仍可能偏离。
「从全局变量切换到新技术后的残留适配」
上一篇文章里我们修掉了回放/模拟程序的两个崩溃 bug,但系统还没干净到能直接往前推新功能。原先依赖全局终端变量时那些小毛病,在换掉这套机制、改用新方案之后又冒了出来,得重新写实现把它们压住。 好消息是,现在功能水位已经基本回到用全局变量时的状态,剩下的都是相对轻的问题,不像之前那个内存崩溃——那个光贴修复代码行没用,不把『代码看着没错却崩』的根因讲透,很多人会懵,甚至产生虚假的安全感。 这类隐患真正爆发时往往不在你眼前,等类似状况出现又找不到出处,容易怀疑自己专业度。老手也会错,差别只在定位和收拾的速度。所以这一节先把切换后残留的第一个适配点拆开说清楚,方便你开 MT5 对照自己的回放服务代码查漏。
控制指标第48行补丁与快进基本模型
在把旧版回放系统迁移到去全局变量架构时,控制指标(C_Replay.mqh)必须先打一个小补丁:第48行新增了 else if (m_IndControl.Mode == C_Controls::ePause) 分支。没有它,若先暂停再尝试快进,控制指标的锁定杆会跟着快进一起移动,手动调位直接被锁死;更诡异的是,启动后按播放再快进,暂停键发出的更新不会立刻生效,要等第41行内存与缓冲区同步为真、且缓冲区标记暂停时才停,期间可能卡很久。
根源在第41行:控制指标缓冲区与引用内存空间不同步。LoopEventOnTime 在正常回放中不断读写控制指标,三种操作路径(启动-播放-快进 / 暂停-快进 / 播放后暂停)各有一坑,而第48行一个条件判断就把这类冲突全消了,后续只需专注快进本身。
基本快进不复杂:在暂停模式下 LoopEventOnTime 从第205到209行循环允许用户拖位;按播放后第210行缓存资产数据,第211行调第124行定义的过程。第128行比对目标与当前位,若在前方则第129行算偏移,第130行起循环反复触发第131行——后者调柱形生成程序(执行第69–95行),并传错误参数使第91行拦掉 CustomTicksAdd,即不推分时报价,但第92行仍逐根把柱形画上图。
这套逻辑跑起来没问题,但快进大跨度时你能肉眼看到柱形被画出,延迟主要来自第75行与第92行,其中第75行开销显著。若只想看生成效果,这套基本模型已够用;想要更猛的提速我们下节拆。
class="num">35. class=class="str">"cmt">//+------------------------------------------------------------------+ class="num">36. class="kw">inline class="type">void UpdateIndicatorControl(class="type">void) class="num">37. { class="num">38. class="type">class="kw">double Buff[]; class="num">39. class="num">40. if (m_IndControl.Handle == INVALID_HANDLE) class="kw">return; class="num">41. if (m_IndControl.Memory._16b[C_Controls::eCtrlPosition] == m_IndControl.Position) class="num">42. { class="num">43. if (CopyBuffer(m_IndControl.Handle, class="num">0, class="num">0, class="num">1, Buff) == class="num">1) class="num">44. m_IndControl.Memory.dValue = Buff[class="num">0]; class="num">45. if ((C_Controls::eObjectControl)m_IndControl.Memory._16b[C_Controls::eCtrlStatus] != C_Controls::eTriState) class="num">46. if ((m_IndControl.Mode = (C_Controls::eObjectControl)m_IndControl.Memory._16b[C_Controls::eCtrlStatus]) == C_Controls::ePlay) class="num">47. m_IndControl.Position = m_IndControl.Memory._16b[C_Controls::eCtrlPosition]; class="num">48. }else if (m_IndControl.Mode == C_Controls::ePause) class="num">49. { class="num">50. m_IndControl.Memory._16b[C_Controls::eCtrlPosition] = m_IndControl.Position; class="num">51. m_IndControl.Memory._16b[C_Controls::eCtrlStatus] = (class="type">class="kw">ushort)m_IndControl.Mode; class="num">52. m_IndControl.Memory._8b[class="num">7] = &class="macro">#x27;D&class="macro">#x27;;
◍ 回放引擎与指标控制层的握手细节
Market Replay 的 EA 侧并不直接画控制按钮,而是把状态塞进一个 ex5 指标里:宏 def_IndicatorControl 指向 "Indicators\\Market Replay.ex5",并通过 #resource 预编译进 exe,避免运行时找不到文件。 初始化阶段会写 m_IndControl.Memory._8b[6]='M' 作为控制块标识,再调用 EventChartCustom 向图表派发 evCtrlReplayInit 事件,dValue 作为 double 型载荷传过去,字符串留空。 def_CheckLoopService 这个宏很关键:(!_StopFlag) && (ChartSymbol(m_Infos.IdReplay)!=""),意思是 EA 没被停、且回放子图确实挂了合法品种时才进循环,否则直接空转。 UpdateIndicatorControl 里先用 CopyBuffer 取指标 0 号缓冲的第 0 根数据(成功返回 1 才写回 Memory.dValue),再比对 eCtrlStatus 判断是播放还是三态;只有 Mode 为 ePause 时才走 else 分支做位置校正。外汇与贵金属回测属高风险,参数错配可能导致回放不同步。
m_IndControl.Memory._8b[class="num">6] = &class="macro">#x27;M&class="macro">#x27;; EventChartCustom(m_Infos.IdReplay, evCtrlReplayInit, class="num">0, m_IndControl.Memory.dValue, ""); class="macro">#define def_IndicatorControl "Indicators\\Market Replay.ex5" class="macro">#resource "\\" + def_IndicatorControl class="macro">#define def_CheckLoopService((!_StopFlag) && (ChartSymbol(m_Infos.IdReplay) != "")) class="macro">#define def_ShortNameIndControl "Market Replay Control" class="kw">inline class="type">void UpdateIndicatorControl(class="type">void) { class="type">class="kw">double Buff[]; if (m_IndControl.Handle == INVALID_HANDLE) class="kw">return; if (m_IndControl.Memory._16b[C_Controls::eCtrlPosition] == m_IndControl.Position) { if (CopyBuffer(m_IndControl.Handle, class="num">0, class="num">0, class="num">1, Buff) == class="num">1) m_IndControl.Memory.dValue = Buff[class="num">0]; if ((C_Controls::eObjectControl)m_IndControl.Memory._16b[C_Controls::eCtrlStatus] != C_Controls::eTriState) if ((m_IndControl.Mode = (C_Controls::eObjectControl)m_IndControl.Memory._16b[C_Controls::eCtrlStatus]) == C_Controls::ePlay) m_IndControl.Position = m_IndControl.Memory._16b[C_Controls::eCtrlPosition]; }else if (m_IndControl.Mode == C_Controls::ePause)
「回放引擎里的内存标记与价差扰动」
这段逻辑干了两件事:先把控件状态写进 16 位内存区并发出自定义事件,再在回放里按概率给 ask/bid 叠一层微小价差。代码里 _8b[7]='D'、_8b[6]='M' 是往共享内存塞标识符,EventChartCustom 用 evCtrlReplayInit 通知图表层初始化回放控件。 CreateBarInReplay 里用 rand() 取随机数 iRand,当 iRand 落在 29080 到 32767 之间(约占总区间 11.3%)时,才可能对 dSpread 额外加一个 PointsPerTick。也就是说,绝大多数回放 K 线价差维持基准值,只有约一成概率出现跳动。 若最新价 last 高于 ask,则把 ask 拉到 last、bid 减 dSpread;若 last 低于 bid,则 ask 设为 last 加 dSpread。这种写法保证回放报价不出现交叉盘口,外汇与贵金属品种因杠杆高、点值小,这类模拟价差对策略回测结果可能有肉眼可见的偏移。
{
m_IndControl.Memory._16b[C_Controls::eCtrlPosition] = m_IndControl.Position;
m_IndControl.Memory._16b[C_Controls::eCtrlStatus] = (class="type">class="kw">ushort)m_IndControl.Mode;
m_IndControl.Memory._8b[class="num">7] = &class="macro">#x27;D&class="macro">#x27;;
m_IndControl.Memory._8b[class="num">6] = &class="macro">#x27;M&class="macro">#x27;;
EventChartCustom(m_Infos.IdReplay, evCtrlReplayInit, class="num">0, m_IndControl.Memory.dValue, "");
}
class="kw">inline class="type">void CreateBarInReplay(class="type">bool bViewTick)
{
class="type">bool bNew;
class="type">class="kw">double dSpread;
class="type">int iRand = rand();
if (BuildBar1Min(m_Infos.CountReplay, m_Infos.Rate[class="num">0], bNew))
{
m_Infos.tick[class="num">0] = m_MemoryData.Info[m_Infos.CountReplay];
if (m_MemoryData.ModePlot == PRICE_EXCHANGE)
{
dSpread = m_Infos.PointsPerTick + ((iRand > class="num">29080) && (iRand < class="num">32767) ? ((iRand & class="num">1) == class="num">1 ? m_Infos.PointsPerTick : class="num">0 ) : class="num">0 );
if (m_Infos.tick[class="num">0].last > m_Infos.tick[class="num">0].ask)
{
m_Infos.tick[class="num">0].ask = m_Infos.tick[class="num">0].last;
m_Infos.tick[class="num">0].bid = m_Infos.tick[class="num">0].last - dSpread;
}else if (m_Infos.tick[class="num">0].last < m_Infos.tick[class="num">0].bid)
{
m_Infos.tick[class="num">0].ask = m_Infos.tick[class="num">0].last + dSpread;回放引擎的进度对齐与循环节拍
这段逻辑解决一个很实际的问题:用户拖动进度滑块时,回放数据要跟手,不能跳帧也不能卡死。AdjustPositionToReplay 用 m_IndControl.Position 和 def_MaxPosSlider 算出目标 tick 序号 nPos,再和已回放的 m_Infos.CountReplay 比大小,不够就补 CreateBarInReplay(false)。 关键一行在 128 行:if (m_IndControl.Position == (int)((m_Infos.CountReplay * def_MaxPosSlider * 1.0) / m_MemoryData.nTicks)) return; 意思是当前滑块位置恰好对应已回放比例时就直接退出,避免无谓重算。分母用 1.0 强转浮点,防止整数除法把进度整没了。 LoopEventOnTime 是主循环入口。205–209 行在非播放模式下每 200 毫秒 Sleep 一次轮询控件状态,UpdateIndicatorControl 负责读滑块;进入播放后 210 行抓全量 tick 信息,211 行先做进度对齐,再从 213 行起按 CountReplay < nTicks 持续灌数据。外汇与贵金属回测中这种自定义符号重放属于高风险验证手段,实盘前务必在模拟环境跑通。 想验证也很直接:把 def_MaxPosSlider 设成 1000,nTicks 取 50000,拖到中间位置,nPos 会落在约 25000,此时 CountReplay 不足就会自动补帧——开 MT5 挂上这段改过的 EA 就能看到重放不脱节。
class="num">088. m_Infos.tick[class="num">0].bid = m_Infos.tick[class="num">0].last; class="num">089. } class="num">090. } class="num">091. if (bViewTick) CustomTicksAdd(def_SymbolReplay, m_Infos.tick); class="num">092. CustomRatesUpdate(def_SymbolReplay, m_Infos.Rate); class="num">093. } class="num">094. m_Infos.CountReplay++; class="num">095. } class="num">096. class=class="str">"cmt">//+------------------------------------------------------------------+ ... class="num">123. class=class="str">"cmt">//+------------------------------------------------------------------+ class="num">124. class="type">void AdjustPositionToReplay(class="type">void) class="num">125. { class="num">126. class="type">int nPos; class="num">127. class="num">128. if (m_IndControl.Position == (class="type">int)((m_Infos.CountReplay * def_MaxPosSlider * class="num">1.0) / m_MemoryData.nTicks)) class="kw">return; class="num">129. nPos = (class="type">int)(m_MemoryData.nTicks * ((m_IndControl.Position * class="num">1.0) / (def_MaxPosSlider + class="num">1))); class="num">130. while ((nPos > m_Infos.CountReplay) && def_CheckLoopService) class="num">131. CreateBarInReplay(class="kw">false); class="num">132. } class="num">133. class=class="str">"cmt">//+------------------------------------------------------------------+ class="num">134. class="kw">public : class="num">135. class=class="str">"cmt">//+------------------------------------------------------------------+ ... class="num">200. class=class="str">"cmt">//+------------------------------------------------------------------+ class="num">201. class="type">bool LoopEventOnTime(class="type">void) class="num">202. { class="num">203. class="type">int iPos; class="num">204. class="num">205. while ((def_CheckLoopService) && (m_IndControl.Mode != C_Controls::ePlay)) class="num">206. { class="num">207. UpdateIndicatorControl(); class="num">208. Sleep(class="num">200); class="num">209. } class="num">210. m_MemoryData = GetInfoTicks(); class="num">211. AdjustPositionToReplay(); class="num">212. iPos = class="num">0; class="num">213. while ((m_Infos.CountReplay < m_MemoryData.nTicks) && (def_CheckLoopService)) class="num">214. {