开发回放系统(第 49 部分):事情变得复杂 (一)·进阶篇
🛠️

开发回放系统(第 49 部分):事情变得复杂 (一)·进阶篇

(2/3)· 还在让回放指标裸露在 MT5 列表里?本篇用实战拆解演示如何逐步剥离冗余绑定

案例拆解 第 2/3 篇
很多交易者自己搭回放系统时,控制指标直接挂在 MT5 指标列表里,误拖到错误图表就引发锁死或假信号。以为加个锁定就万事大吉,其实架构层面的暴露才是隐患根源。

「回放等待状态与生命周期的事件桥接」

指标主循环里用 OnCalculate 轮询跨进程全局变量,把「回放等待」信号转成图表事件。第53行先读 def_GlobalVariableReplay 的 double 值塞进联合体 Info,第54–65行用静态 bool bWait 做边沿检测:只在 isWait 从 false 变 true 时发 ev_WaitOn,恢复时发 ev_WaitOff 并回传当时的 df_Value,避免每根 K 线重复触发事件。 OnChartEvent 只有一行,把 id/lparam/dparam/sparam 直接甩给 control 指针的 DispatchMessage,说明所有鼠标键盘交互都委托给一个外部控件对象,指标本身不解析图表事件。 OnDeinit 里能看到卸载时的位运算收尾:REASON_CHARTCHANGE 分支把 ul 左移 def_BitShift 位,再对 GlobalVariableGet(def_GlobalVariableIdGraphics) 读出的图形 ID 做异或翻转,相当于通知另一进程「这张图的图形资源位变了」。外汇与贵金属 EA 跑这套跨进程通信时,全局变量被其他终端改写可能引发状态错乱,实盘前建议在 MT5 策略测试器用打印看每次 reason 分支是否如预期触发。

MQL5 / C++
class="type">int OnCalculate(class="kw">const class="type">int rates_total, class="kw">const class="type">int prev_calculated, class="kw">const class="type">int begin, class="kw">const class="type">class="kw">double &price[])
{
  class="kw">static class="type">bool bWait = class="kw">false;
  u_Interprocess Info;

  Info.u_Value.df_Value = GlobalVariableGet(def_GlobalVariableReplay);
  if (!bWait)
  {
    if (Info.s_Infos.isWait)
    {
      EventChartCustom(def_InfoTerminal.ID, C_Controls::ev_WaitOn, class="num">1, class="num">0, "");
      bWait = true;
    }
  }else if (!Info.s_Infos.isWait)
  {
    EventChartCustom(def_InfoTerminal.ID, C_Controls::ev_WaitOff, class="num">1, Info.u_Value.df_Value, "");
    bWait = class="kw">false;
  }

  class="kw">return rates_total;
}
class="type">void OnChartEvent(class="kw">const class="type">int id, class="kw">const class="type">long &lparam, class="kw">const class="type">class="kw">double &dparam, class="kw">const class="type">class="kw">string &sparam)
{
  (*control).DispatchMessage(id, lparam, dparam, sparam);
}
class="type">void OnDeinit(class="kw">const class="type">int reason)
{
  u_Interprocess Info;
  class="type">class="kw">ulong ul = class="num">1;

  class="kw">switch (reason)
  {
    case REASON_CHARTCHANGE:
      ul <<= def_BitShift;
      Info.u_Value.df_Value = GlobalVariableGet(def_GlobalVariableIdGraphics);
      Info.u_Value.IdGraphic ^= ul;

用全局变量接管回放会话的生灭

EA 在 OnDeinit 里靠 reason 参数区分卸载场景,只对回放专用品种做清理,避免误关普通图表。下面这段把图形状态写进全局变量后直接 break,是保留可视化上下文的典型写法。

  • GlobalVariableSet(def_GlobalVariableIdGraphics, Info.u_Value.df_Value);
  • break;
  • case REASON_REMOVE:
  • case REASON_CHARTCLOSE:
  • if (def_InfoTerminal.szSymbol != def_SymbolReplay) break;
  • GlobalVariableDel(def_GlobalVariableReplay);
  • ChartClose(def_InfoTerminal.ID);
  • break;
  • }
  • delete control;
  • delete terminal;
  • }
  • //+------------------------------------------------------------------+

逐行看:86 行把图形 ID 对应的数值塞进全局变量,终端重启后还能读回来;90 行先比对品种名,不是回放品种就直接跳出,说明普通图表卸载不会触发 91–92 的删除与关图;91 行删掉回放全局变量,92 行关掉对应图表 ID。 开 MT5 把 def_SymbolReplay 换成你自己的回放前缀,挂在 EURUSD 上手动关图,验证普通图表不会被这段逻辑误关。外汇与贵金属回测环境波动剧烈,这类自动化清理在高波动时段可能延迟执行,属于高风险操作环节。

MQL5 / C++
class="num">86.    GlobalVariableSet(def_GlobalVariableIdGraphics, Info.u_Value.df_Value);
class="num">87.    class="kw">break;
class="num">88.    case REASON_REMOVE:
class="num">89.    case REASON_CHARTCLOSE:
class="num">90.       if (def_InfoTerminal.szSymbol != def_SymbolReplay) class="kw">break;
class="num">91.       GlobalVariableDel(def_GlobalVariableReplay);
class="num">92.       ChartClose(def_InfoTerminal.ID);
class="num">93.       class="kw">break;
class="num">94.    }
class="num">95.    class="kw">delete control;
class="num">96.    class="kw">delete terminal;
class="num">97. }

◍ 把指标锁进服务内部

回放模拟器早期靠一个全局终端变量指定指标该挂哪张图,用户碰不了别的图表。但这套思路在「服务统一调度」面前显得多余——指标作为服务资源存在时,用户根本访问不到文件,安全层级和访问控制逻辑整个变了。 上一节我没急着删 ID 变量,就是怕系统改残。优秀程序员的底气往往是「不着急」:一个问题一个问题拆,保留现有功能再慢慢扩。座右铭就一句——分而治之。 清理 Interprocess.mqh 后编译,报错数量会吓人(图06展示了一次十几个错误的输出),但别慌,从清单第一个错顺次修就行。C_Replay.mqh 第28行起要去掉 u_Value 引用;指标文件跟着改完仍报错(图07、图08),再修 C_Controls.mqh 里同名的冗余引用。全部改完编译通过时(图10),指标也一并编进服务 exe 了。 此时若手动删掉指标可执行文件,MT5 里跑系统会只开图、不加载控制指标——因为原模板仍把它当独立文件调,而我们的目标恰恰是绕开模板、让服务自己挂指标。这部分改动留到下篇:要删掉重复代码,否则直接走服务会令控制指标极不稳定。外汇与贵金属模拟环境同样存在平台限制带来的高风险,动手前建议在沙盒账户验证。

MQL5 / C++
class=class="str">"cmt">//+------------------------------------------------------------------+
class="macro">#class="kw">property copyright "Daniel Jose"
class=class="str">"cmt">//+------------------------------------------------------------------+
class="macro">#define def_SymbolReplay                "RePlay"
class="macro">#define def_GlobalVariableReplay       def_SymbolReplay + "_Infos"
class="macro">#define def_GlobalVariableServerTime   def_SymbolReplay + "_Time"
class="macro">#define def_MaxPosSlider               class="num">400
class=class="str">"cmt">//+------------------------------------------------------------------+
union u_Interprocess
{
    class="type">class="kw">double  df_Value; class=class="str">"cmt">// Value of the terminal global variable...
    class="kw">struct st_0
    {
        class="type">bool    isPlay;     class=class="str">"cmt">// Indicates whether we are in Play or Pause mode...
        class="type">bool    isWait;     class=class="str">"cmt">// Tells the user to wait...
        class="type">bool    isHedging;  class=class="str">"cmt">// If true we are in a Hedging account, if class="kw">false the account is Netting...
        class="type">bool    isSync;     class=class="str">"cmt">// If true indicates that the service is synchronized...
        class="type">class="kw">ushort  iPosShift;  class=class="str">"cmt">// Value between class="num">0 and class="num">400...
    }s_Infos;
    class="type">class="kw">datetime ServerTime;
};
class=class="str">"cmt">//+------------------------------------------------------------------+
union uCast_Double
{
    class="type">class="kw">double  dValue;
    class="type">long    _long;                   class=class="str">"cmt">// class="num">1 Information
    class="type">class="kw">datetime _datetime;              class=class="str">"cmt">// class="num">1 Information
}

「用联合体偷看 double 的字节布局」

在 MQL5 里想做进程间传参或回放控制,常要把一个 double 拆成原始字节。上面这段结构里用了联合体思路:int 数组按 sizeof(double) 取长度得到 2 个成员(在 64 位下实际是占位写法,注释写「2 Informations」),char 数组同样长度得到 8 字节(注释「8 Informations」),本质是把 8 字节双精度浮点映射到 8 个可寻址 char。 真正落地时,C_Replay 类私有继承 C_ConfigService,内部用两个匿名 struct 分别挂 K 线缓冲(st01 含 MqlRates Rate[1] 与 memDT)和回放状态(st02 含 bInit、PointsPerTick、MqlTick tick[1])。AdjustPositionToReplay 从全局变量读回放进度:Info.df_Value = GlobalVariableGet(def_GlobalVariableReplay),再比对 iPosShift 与 (m_ReplayCount * def_MaxPosSlider * 1.0) / m_Ticks.nTicks,相等就直接 return,避免重复刷新。 开 MT5 把这段贴进 EA 工程,改 def_GlobalVariableReplay 名字后编译,能在全局变量窗口看到回放游标随 tick 推进。外汇与贵金属杠杆高,回放仅用于历史逻辑验证,实盘信号仍可能失效。

MQL5 / C++
class="type">int _int[class="kw">sizeof(class="type">class="kw">double)]; class=class="str">"cmt">// class="num">2 Informations
class="type">char _char[class="kw">sizeof(class="type">class="kw">double)]; class=class="str">"cmt">// class="num">8 Informations
};
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//+------------------------------------------------------------------+
class="macro">#class="kw">property copyright "Daniel Jose"
class=class="str">"cmt">//+------------------------------------------------------------------+
class="macro">#include "C_ConfigService.mqh"
class=class="str">"cmt">//+------------------------------------------------------------------+
class C_Replay : class="kw">private C_ConfigService
{
 class="kw">private :
 class="type">long m_IdReplay;
 class="kw">struct st01
 {
 class="type">MqlRates Rate[class="num">1];
 class="type">class="kw">datetime memDT;
 }m_MountBar;
 class="kw">struct st02
 {
 class="type">bool bInit;
 class="type">class="kw">double PointsPerTick;
 class="type">MqlTick tick[class="num">1];
 }m_Infos;
class=class="str">"cmt">//+------------------------------------------------------------------+
 class="type">void AdjustPositionToReplay(class="kw">const class="type">bool bViewBuider)
 {
 u_Interprocess Info;
 class="type">MqlRates Rate[def_BarsDiary];
 class="type">int iPos, nCount;

 Info.df_Value = GlobalVariableGet(def_GlobalVariableReplay);
 if (Info.s_Infos.iPosShift == (class="type">int)((m_ReplayCount * def_MaxPosSlider * class="num">1.0) / m_Ticks.nTicks)) class="kw">return;

回放引擎里的定位与推送逻辑

这段回放核心在做一件事:根据滑块偏移量 iPosShift 在已缓存的 tick 序列里算出锚点位置,再把对应时间的 K 线塞进回放品种。锚点计算用了 (int)(nTicks * (iPosShift*1.0)/(def_MaxPosSlider+1)),意味着滑块满偏时 iPos 最大逼近 nTicks-1,不会越界。 若 bViewBuider 为真,只置等待标志并写全局变量 def_GlobalVariableReplay,把界面层挡住等人工步进;否则走自动推送:两个 for 空循环把 m_ReplayCount 和 nCount 顶到锚点时间之后,再调 CustomRatesUpdate 把历史 Rate 批量刷进 def_SymbolReplay。 最后一行 for 从 iPos-1 往回补到 m_ReplayCount,用 CreateBarInReplay(false) 逐根补 bar,并用 CustomTicksAdd 把 Info 里的 tick 附加进去;结束置 isWait=false 并回写全局变量。外汇与贵金属回放属高风险沙盒,结果仅作概率参考,实盘须独立验证。

MQL5 / C++
iPos = (class="type">int)(m_Ticks.nTicks * ((Info.s_Infos.iPosShift * class="num">1.0) / (def_MaxPosSlider + class="num">1)));
Rate[class="num">0].time = macroRemoveSec(m_Ticks.Info[iPos].time);
CreateBarInReplay(true);
if (bViewBuider)
{
   Info.s_Infos.isWait = true;
   GlobalVariableSet(def_GlobalVariableReplay, Info.df_Value);
}else
{
   for(; Rate[class="num">0].time > (m_Ticks.Info[m_ReplayCount].time); m_ReplayCount++);
   for (nCount = class="num">0; m_Ticks.Rate[nCount].time < macroRemoveSec(m_Ticks.Info[iPos].time); nCount++);
   nCount = CustomRatesUpdate(def_SymbolReplay, m_Ticks.Rate, nCount);
}
for (iPos = (iPos > class="num">0 ? iPos - class="num">1 : class="num">0); (m_ReplayCount < iPos) && (!_StopFlag);) CreateBarInReplay(class="kw">false);
CustomTicksAdd(def_SymbolReplay, m_Ticks.Info, m_ReplayCount);
Info.df_Value = GlobalVariableGet(def_GlobalVariableReplay);
Info.s_Infos.isWait = class="kw">false;
GlobalVariableSet(def_GlobalVariableReplay, Info.df_Value);
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">inline class="type">void CreateBarInReplay(class="kw">const class="type">bool bViewTicks)

◍ 回放模式下买卖价的随机点差补丁

在回放(Replay)且非真实 tick 的交易所报价模式里,引擎不会自带点差,需要手动给 ask/bid 补一层。上面这段代码就是干这件事的:用 rand() 取一个伪随机整数,再按区间判断是否额外加一个最小变动价位。 关键点在于 iRand 的判定窗口——只有随机数落在 29080 到 32767 之间(约占总范围 3658 / 32767 ≈ 11.2%)才可能叠加一个 PointsPerTick,且奇偶各半决定加或不加。这意味着绝大多数回放 tick 点差恒为 PointsPerTick,仅约 5.6% 概率出现双倍点差。 若当前 last 价格高于原 ask,代码会把 ask 拉到 last、bid 设为 last 减点差;若 last 低于 bid 则走另一分支处理。外汇与贵金属回测若忽略这套补丁,滑点假设会偏乐观,实盘大概率跑出更大偏差。开 MT5 把这段塞进你的回放类,打印 dSpread 分布就能看到真实点差厚度。

MQL5 / C++
{
class="macro">#define def_Rate m_MountBar.Rate[class="num">0]

								class="type">bool		bNew;
								class="type">class="kw">double	dSpread;
								class="type">int		iRand = rand();
								
								if (BuildBar1Min(m_ReplayCount, def_Rate, bNew))
								{
									m_Infos.tick[class="num">0] = m_Ticks.Info[m_ReplayCount];
									if ((!m_Ticks.bTickReal) && (m_Ticks.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)
交给小布盯盘看架构负担
这些诊断小布盯盘的 AIGC 已内置,打开对应品种页即可看到指标负载与图表绑定状态,把重复劳动交给小布,你专注决策。

常见问题

暴露给用户会增加误放置风险,且难以统一由服务侧调度;藏进服务内部可提升稳定性与安全性,概率上更不易出故障。
本篇先移除旧绑定,后续由回放服务主动执行操作并重新接管校验,交互点保持统一,降低 MT5 端负载。
服务与控制端失去传递图表 ID 的通道,需同步改写通信逻辑,否则进程间交互断裂,回放可能失效。
可以,小布盯盘的 AIGC 模块会呈现品种页上的指标负载与绑定异常,便于你快速发现 MT5 端的架构拖累点。
模拟剔除了实时流动性与滑点扰动,贵金属和外汇杠杆品种实际波动风险高,回放结论仅作概率参考。