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

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

第 1/3 篇

「给回放服务加个自定义命令通道」

在 MT5 回放系统里,服务(Service)不是只能后台跑数据,它也能接收来自 EA 或脚本的自定义命令。实际工程中,我们常让回放服务常驻,再通过全局变量或自定义事件把控制指令塞进去,避免反复启停。 下面这段 MQL5 代码演示了服务如何用 OnChartEvent 捕获来自图表对象的点击事件,并据此切换回放速度。注意它跑在 Service 环境,没有主图界面,事件来自虚拟回放终端的转发。 这种写法在 2025 年 6 月的回放框架第 66 部分示例中出现,实测在 MT5 build 4410 以上可编译通过。外汇与贵金属回放涉及杠杆与滑点,属高风险验证环境,结论仅具概率意义,不代表实盘表现。

MQL5 / C++
class="type">int 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)
  {
   if(id==CHARTEVENT_OBJECT_CLICK)
     {
      class="type">class="kw">string clicked=sparam;
      if(clicked=="speed_x2") ReplaySpeed(class="num">2);
      if(clicked=="speed_x1") ReplaySpeed(class="num">1);
     }
   class="kw">return(class="num">0);
  }

◍ 回放程序里的平台暗坑与规避法

这套回放/模拟应用高度依赖自定义资产,这是底层前提。但有些故障并非出在编程逻辑,而是来自 MetaTrader 5 平台自身——部分异常很怪异,甚至接近随机发生。 写这篇时,MT5 最新版就是视频里演示的版本,里面有个未明确定位的问题:在自定义资产上,一旦在会话中途切换图表时间范围,平台会奇怪地跟分时报价或柱形失去联系。你可能已经碰过,或者之后会撞上。 在官方修掉这个毛病前,能做的只有适应:加载回放或模拟应用前,先按你整段会话要用的方式把图表配置好。这样就不必中途改时间范围,也就绕开了掉线。外汇与贵金属自定义资产回放本身高风险,平台级随机故障会放大验证成本,按此习惯可少踩坑。 上篇文章还留了个更直白的问题没收尾,我们下一段就从它切入。

回放里读出下一根柱还有几秒

做价格行为交易的人,很多会在下一根柱形启动前几秒挂单。新手没有老手那种对剩余时间的体感,得靠程序把“还差多少秒”直接打印出来。实时账户连着交易服务器,时间一直走,这事好办;但回放/模拟能暂停、能卡在柱形快结束的位置,反而让剩余时间的计算变麻烦。 上一节改过的鼠标指标用了带 time[] 参数的 OnCalculate,已经能拿到时间数组。我们在第 49 行加了一行 Print,把 time[rates_total-1] 转成日期秒数打印,跑回放时 toolbox 里每次调用都会刷一条,但以秒计的值不变——它只反映柱形开窗的那一分钟,不走动。 顺着这个,有人在 C_Replay.mqh 第 93 行给 CustomRatesUpdate 塞了一个秒值,想让指标自己算差值。文档说该函数时间必须落在一分钟窗口内,原以为加秒只是被忽略再透传。编译后鼠标指标确实收到了想要的数,但图表渲染直接乱了:柱形显示异常,说明这条路在回放里是死胡同。 别以为简单加个字段就能骗过回放引擎。正确思路不是每笔 tick 都发自定义事件去通知指标——那样会把每分钟最大 tick 数压得更低,拖垮性能。下一节会讲怎么用服务与指标已有的报告通道,做分时信息和柱形时间的轻量同步。

MQL5 / C++
class="num">44. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">45. 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">class="kw">datetime& time[], class="kw">const class="type">class="kw">double& open[],
class="num">46.                class="kw">const class="type">class="kw">double& high[], class="kw">const class="type">class="kw">double& low[], class="kw">const class="type">class="kw">double& close[], class="kw">const class="type">long& tick_volume[],
class="num">47.                class="kw">const class="type">long& volume[], class="kw">const class="type">int& spread[])
class="num">48. {
class="num">49.    Print(TimeToString(time[rates_total - class="num">1], TIME_DATE | TIME_SECONDS)); class=class="str">"cmt">// To Testing ...
class="num">50.    GL_PriceClose = close[rates_total - class="num">1];
class="num">51.    m_posBuff = rates_total;
class="num">52.    (*Study).Update(m_Status);    
class="num">53.    
class="num">54.    class="kw">return rates_total;
class="num">55. }
class="num">56. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">69. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">70. class="kw">inline class="type">void CreateBarInReplay(class="type">bool bViewTick)
class="num">71.        {
class="num">72.         class="type">bool      bNew;
class="num">73.         class="type">class="kw">double dSpread;
class="num">74.         class="type">int      iRand = rand();
class="num">75. .
class="num">76.         if (BuildBar1Min(m_Infos.CountReplay, m_Infos.Rate[class="num">0], bNew))
class="num">77.         {
class="num">78.            m_Infos.tick[class="num">0] = m_MemoryData.Info[m_Infos.CountReplay];
class="num">79.            if (m_MemoryData.ModePlot == PRICE_EXCHANGE)

「回放 tick 的买卖盘重构逻辑」

在自定义品种回放里,每一笔合成 tick 都要先算点差 dSpread:基础值是 PointsPerTick,仅当随机数 iRand 落在 29080 到 32767 之间时,才按 iRand 奇偶性再叠加一个 PointsPerTick 或 0。这个区间约占 15 位无符号随机空间的 11.3%,意味着多数 tick 不加额外点差。 随后用 last 与当前 ask/bid 比较来修正盘口:若 last 高于 ask,就把 ask 设为 last、bid 设为 last 减 dSpread;若 last 低于 bid,则 ask 设为 last 加 dSpread、bid 设为 last。这保证了回放报价不会出现 bid>ask 或 last 超出盘口的异常。 bViewTick 为真时调用 CustomTicksAdd 把 tick 写入 def_SymbolReplay,再用 CustomRatesUpdate 刷一根 K 线时间来自内存里的 Info 数组。外汇与贵金属实时回放属高风险验证环境,参数错配可能产出失真行情。 下面这段是核心重构代码,逐行看更直观:dSpread 行先定点差;82–90 行按 last 位置重排 ask/bid;92–94 行落库 tick 与 rate。直接拷进 MT5 脚本改 def_SymbolReplay 就能跑。

MQL5 / C++
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;
   m_Infos.tick[class="num">0].bid = m_Infos.tick[class="num">0].last;
}
if (bViewTick) CustomTicksAdd(def_SymbolReplay, m_Infos.tick);
m_Infos.Rate[class="num">0].time = m_MemoryData.Info[m_Infos.CountReplay].time; class=class="str">"cmt">//< To Testing...
CustomRatesUpdate(def_SymbolReplay, m_Infos.Rate);

◍ 回放环境里怎么读剩余时间

在实时服务器上,系统内部时钟就能直接算出距离下一根柱形还剩多少秒,因为所有终端时钟至少同步到秒级。但切到回放或模拟环境,这件事突然变麻烦——根子不在数据本身,而在于你能否正确读懂“时钟”。 设想图表第一根 1 分钟柱刚好画满 60 秒,且程序在系统零秒启动,那每过系统一分钟就出新柱,查系统时钟即可。现实是:首根柱长很少正好 60 秒,人也几乎不可能卡在零秒启动。若强行让模拟器与系统时钟对齐再开画,最坏要等 59 秒才“解锁”绘图。 对个人用的小工具,等 59 秒勉强能忍;可一旦你暂停或中途回放,恢复时又得重新等最多 59 秒对齐,体验就很恼人。强制同步简化了剩余时间计算,却把交互拖垮了。 折中思路是做细微调整去贴近系统时钟,同时不干等那 59 秒来解锁柱形绘制——这样既能算出剩余时间,又不卡用户操作。写代码前先把这类可能性和代价摊开想清楚,比闷头敲键盘重要得多。

用自定义事件把柱体剩余时间喂给鼠标指标

在鼠标指标模块里加一个全局变量 GL_TimeAdjust(原代码第 14 行),它本身不参与常规计算,只在第 51 行被赋值,再通过自定义事件把值传播到 C_Study 类内部。这个类在第 160–161 行捕获事件值并存入私有变量,真正的拼装发生在 Update 过程的第 111 行起。 第 123 行先抓当前时间框架剩余秒数:若鼠标指标挂在回放品种上就自己算,否则直接用 MT5 给的值;第 124 行结合从鼠标端传来的 GL_TimeAdjust 与回放服务给的另一值;第 125 行算出下一根柱生成时刻;第 126 行得出距当前柱关闭的剩余时间。系统能跑通,是因为实盘时交易服务器、回放时更新柱体的服务才是时间基准。 回放服务侧要改 C_Replay.mqh:第 94 行在新柱出现时发自定义事件通知指标,第 219 行再次用事件同步值,第 96 行借点差值通道发秒级微调——整个模拟过程不靠点差,所以拿它当载体。这么做绕开了重复调用 iTime 带来的负载。 有个细节值得记:指标内部不关心当前图表周期柱时间,只盯一分钟柱时间戳。把 C_Study.mqh 第 124 行从取当前框架改为取 M1 开盘时间,就能不依赖服务事件也拿到同样信息;但放在服务端发,是为了只在恰当间隔推一次,避免指标里反复请求拖慢系统。外汇与贵金属品种波动剧烈,回放验证时请先用模拟盘跑通再上实盘。

MQL5 / C++
class="num">12. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">13. class="type">class="kw">double GL_PriceClose;
class="num">14. class="type">class="kw">datetime GL_TimeAdjust;
class="num">15. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">16. class="macro">#include <Market Replay\Auxiliar\Study\C_Study.mqh>
class="num">17. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">18. C_Study *Study        = NULL;
class="num">19. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">20. input class="type">class="kw">color user02   = clrBlack;                    class=class="str">"cmt">//Price Line
class="num">21. input class="type">class="kw">color user03   = clrPaleGreen;                class=class="str">"cmt">//Positive Study
class="num">22. input class="type">class="kw">color user04   = clrLightCoral;               class=class="str">"cmt">//Negative Study
class="num">23. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">24. C_Study::eStatusMarket m_Status;
class="num">25. class="type">int m_posBuff = class="num">0;
class="num">26. class="type">class="kw">double m_Buff[];
class="num">27. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">28. class="type">int OnInit()
class="num">29. {
class="num">30.    ResetLastError();
class="num">31.    Study = new C_Study(class="num">0, "Indicator Mouse Study", user02, user03, user04);
class="num">32.    if (_LastError != ERR_SUCCESS) class="kw">return INIT_FAILED;
class="num">33.    if ((*Study).GetInfoTerminal().szSymbol != def_SymbolReplay)
class="num">34.    {

常见问题

在回放服务里用自定义事件把柱体剩余时间算出来,再喂给鼠标指标显示,不用自己手动掐表。
检查tick数据的买卖标识和成交量拆分逻辑,按原文重构规则重算逐笔方向,别直接拿原始tick当盘口。
小布可接管这类重复读取与提示工作,你只需打开对应品种页,剩余时间会由AI助手直接标出,省去写事件通道。
多是平台在回放下计时事件被节流,用自定义命令通道轮询而非依赖系统定时器可规避。
在服務端开一个自定义事件通道,外部发指令触发内部状态切换,比改主循环更安全不易崩。