开发回放系统  市场模拟(第 09 部分):自定义事件·进阶篇
⚙️

开发回放系统 市场模拟(第 09 部分):自定义事件·进阶篇

(2/3)· 改起点就闪构建中柱线惹人烦?本篇用自定义事件让看与不看皆可控

案例拆解 第 2/3 篇
很多回放系统一改模拟起点就狂闪正在画的柱线,爱看的人嫌乱、不爱看的人更烦。把显隐做成写死逻辑,等于同时得罪两拨人。本篇用自定义事件把选择权交回用户。

◍ 回放进度与历史清洗的耦合点

这段逻辑干了两件事:把全局变量里的滑块位置换算成 tick 索引,再按该索引截断并重写自定义品种的历史。iPos 先用 m_ReplayCount * def_MaxPosSlider / m_Ticks.nTicks 算百分比位置,随后反算回真实 tick 序号,说明回放进度条与总 tick 量强绑定。 若 Info.s_Infos.iPosShift == iPos 直接返回 0,避免重复加载;否则用 CustomRatesDelete(def_SymbolReplay, dt_Local, LONG_MAX) 把指定时间之后的所有自定义 K 线清掉,再从 m_Ticks.Rate 里找到 spread 小于 iPos 的最后一根,作为新的回放起点。外汇与贵金属回测属高风险场景,重播结果仅代表历史数据可能性,不预示实盘走向。 循环里 c0 从 1 起跳,比较的是 m_Ticks.Rate[c0 - 1].spread < iPos,也就是说 Rate 数组里的 spread 字段被借用来存累计 tick 数。改这个字段的写入方式,回放定位就会整体偏移。

MQL5 / C++
m_Ticks.nTicks++;
}
old = (m_Ticks.nTicks > class="num">0 ? m_Ticks.nTicks - class="num">1 : old);
}
}
if ((!FileIsEnding(file)) && (!_StopFlag))
{
Print("Too many data in the tick file.\nCannot class="kw">continue...");
class="kw">return class="kw">false;
}
}else
{
Print("Tick file ", szFileNameCSV,".csv not found...");
class="kw">return class="kw">false;
}
class="kw">return (!_StopFlag);
};
class="type">int AdjustPositionReplay(class="kw">const class="type">bool bViewBuider)
{
 u_Interprocess Info;
 class="type">MqlRates Rate[class="num">1];
 class="type">int iPos = (class="type">int)((m_ReplayCount * def_MaxPosSlider * class="num">1.0) / m_Ticks.nTicks);
 class="type">class="kw">datetime dt_Local;
 Info.u_Value.df_Value = GlobalVariableGet(def_GlobalVariableReplay);
 if (Info.s_Infos.iPosShift == iPos) class="kw">return class="num">0;
 iPos = (class="type">int)(m_Ticks.nTicks * ((Info.s_Infos.iPosShift * class="num">1.0) / def_MaxPosSlider));
 if (iPos < m_ReplayCount)
 {
 dt_Local = m_dtPrevLoading;
 m_ReplayCount = class="num">0;
 if (!bViewBuider) for (class="type">int c0 = class="num">1; (c0 < m_Ticks.nRate) && (m_Ticks.Rate[c0 - class="num">1].spread < iPos); c0++)
 {
 dt_Local = m_Ticks.Rate[c0].time;
 m_ReplayCount = m_Ticks.Rate[c0 - class="num">1].spread;
 }
 CustomRatesDelete(def_SymbolReplay, dt_Local, LONG_MAX);
 if (m_dtPrevLoading == class="num">0)
 {

「回放进度定位与历史报价清洗」

在 tick 回放引擎里,进度滑块的位置不是直接映射 tick 序号,而是按 m_ReplayCount * def_MaxPosSlider / m_Ticks.nTicks 做归一化折算,再反算回实际 tick 下标。这样即便总 tick 数变化,滑块百分比含义也稳定。 当全局变量里的目标位移小于已回放计数 m_ReplayCount,说明用户把进度往回拖了。此时必须先用宏 macroSearchPositionm_Ticks.Rate[] 里按 spread 边界找回对应的 dt_Localm_ReplayCount,否则后续删除会越界。 找回时间点后调用 CustomRatesDelete(def_SymbolReplay, dt_Local, LONG_MAX),把该时刻之后的所有自定义合成 K 线清空。外汇与贵金属回放属高风险模拟,清洗不干净会让 EA 读到错位的历史柱,回测结论可能严重偏离真实。 下面这段是回退分支里的关键代码,注意 Rate[0] 四条价格被强行等同为最后一笔 tick 的 last,且 tick_volume 置 0、time 回拨 60 秒,等于造了一根无实体的占位棒。

MQL5 / C++
Rate[class="num">0].close = Rate[class="num">0].open = Rate[class="num">0].high = Rate[class="num">0].low = m_Ticks.Info[m_ReplayCount].last;
Rate[class="num">0].tick_volume = class="num">0;
Rate[class="num">0].time = m_Ticks.Info[m_ReplayCount].time - class="num">60;
CustomRatesUpdate(def_SymbolReplay, Rate, class="num">1);
}
}
for (iPos = (iPos > class="num">0 ? iPos - class="num">1 : class="num">0); (m_ReplayCount < iPos) && (!_StopFlag); m_ReplayCount++) Event_OnTime();
class="kw">return Event_OnTime();
}
class="type">int AdjustPositionReplay(class="kw">const class="type">bool bViewBuider)
{
class="macro">#define macroSearchPosition      {                                                                         \
                  dt_Local = m_dtPrevLoading; m_ReplayCount = count = class="num">0;                                      \
                  if (!bViewBuider) for (count = class="num">1; (count < m_Ticks.nRate) && (m_Ticks.Rate[count - class="num">1].spread < iPos); count++)  \
                                        { dt_Local = m_Ticks.Rate[count].time;  m_ReplayCount = m_Ticks.Rate[count - class="num">1].spread; }       \
                                                                                                            }
                  u_Interprocess   Info;
                  class="type">MqlRates         Rate[def_BarsDiary];
                  class="type">int              iPos = (class="type">int)((m_ReplayCount * def_MaxPosSlider * class="num">1.0) / m_Ticks.nTicks),
                                   count;
                  class="type">class="kw">datetime         dt_Local;

                  Info.u_Value.df_Value = GlobalVariableGet(def_GlobalVariableReplay);
                  if (Info.s_Infos.iPosShift == iPos) class="kw">return class="num">0;
                  iPos = (class="type">int)(m_Ticks.nTicks * ((Info.s_Infos.iPosShift * class="num">1.0) / (def_MaxPosSlider + class="num">1)));
                  if (iPos < m_ReplayCount)
                  {
                        macroSearchPosition;
                        CustomRatesDelete(def_SymbolReplay, dt_Local, LONG_MAX);

回放首根 K 线的初始化写法

做历史 tick 回放时,第一根自定义 K 线必须手动铺底,否则后续 CustomRatesUpdate 会接到空结构。上面这段逻辑里,先用 m_dtPrevLoading == 0 判断是不是首帧,是的话把 Rate[0] 的 open/high/low/close 全部压成当前 tick 的 last 价,tick_volume 置 0,time 往前挪 60 秒,再推一笔长度为 1 的更新。 这里有个容易踩的坑:time 减 60 是为了让首根 K 线收盘时间早于首个回放 tick,避免 MT5 按时间排序时把这根丢出去。若你改回放周期,这个偏移量要跟着调,比如 M1 回放用 60,M5 就得用 300。 非首帧分支里,iPos 超过已回放计数且不在构建视图状态时,直接把整段 m_Ticks.Rate 按 count 批量推给 CustomRatesUpdate,比逐根更新省 CPU。最后那个 for 从 iPos-1 往回兜一格再向前跑 Event_OnTime,是保证断点续播不丢前一帧的关键。外汇与贵金属回放属高风险验证,结论仅代表代码行为倾向,不等于实盘必然一致。

MQL5 / C++
if (m_dtPrevLoading == class="num">0)
            {
               Rate[class="num">0].close = Rate[class="num">0].open = Rate[class="num">0].high = Rate[class="num">0].low = m_Ticks.Info[m_ReplayCount].last;
               Rate[class="num">0].tick_volume = class="num">0;
               Rate[class="num">0].time = m_Ticks.Info[m_ReplayCount].time - class="num">60;
               CustomRatesUpdate(def_SymbolReplay, Rate, class="num">1);
            }
         }if ((iPos > m_ReplayCount) && (!bViewBuider))
         {
            macroSearchPosition;            
            CustomRatesUpdate(def_SymbolReplay, m_Ticks.Rate, count);
         }
         for (iPos = (iPos > class="num">0 ? iPos - class="num">1 : class="num">0); (m_ReplayCount < iPos) && (!_StopFlag); m_ReplayCount++) Event_OnTime();
         class="kw">return Event_OnTime();
}

◍ 回放耗尽时如何通知用户

历史回放系统跑到没有更多 tick 可供模拟时,如果用户没收到任何提示,大概率会以为 EA 崩了或者终端异常。我们在回放引擎里补一层终止告警,就是为了避免这种误判。 触发场景其实有两种:最常见的是正常回放执行到数据尾部;另一种少见于用户手动把滚动条拖到末尾。两种情况下处理逻辑一致——读取全局终端变量里的暂停标志位,重新写入状态,再弹窗说明当前已无数据。 下面这段 OnStart 主干里,关键判断在 AdjustPositionReplay 返回负值后调用 AlertToUser()。正常回放间隙每轮 Sleep(750) 轮询全局变量 def_GlobalVariableReplay,一旦 isPlay 为假且延迟函数返回 <0,就说明数据耗尽,告警即触发。 别让终端静默假死 把 AlertToUser 换成你自己的弹窗或 PushNotification,在 MT5 里实测拖到尾部和自然跑完两种路径,确认都能在 1 秒内收到提示,否则用户仍可能误判。外汇与贵金属回测涉及高杠杆,终端异常误判可能让你漏掉实盘切换时机。

MQL5 / C++
class="type">void OnStart()
{
  class="type">class="kw">ulong t1;
  class="type">int delay = class="num">3;
  class="type">long id = class="num">0;
  u_Interprocess Info;
  class="type">bool bTest = class="kw">false;
  
  Replay.InitSymbolReplay();
  if (!Replay.SetSymbolReplay(user00))
  {
    Finish();
    class="kw">return;
  }
  Print("Wait for permission from [Market Replay] indicator to start replay ...");
  id = Replay.ViewReplay(user01);
  class="kw">while ((!GlobalVariableCheck(def_GlobalVariableReplay)) && (!_StopFlag) && (ChartSymbol(id) != "")) Sleep(class="num">750);
  if ((_StopFlag) || (ChartSymbol(id) == ""))
  {
    Finish();
    class="kw">return;
  }
  Print("Permission granted. The replay service can now be used...");
  t1 = GetTickCount64();
  class="kw">while ((ChartSymbol(id) != "") && (GlobalVariableGet(def_GlobalVariableReplay, Info.u_Value.df_Value)) && (!_StopFlag))
  {
    if (!Info.s_Infos.isPlay)
    {
      if (!bTest) bTest = true;
    }else
    {
      if (bTest)
      {
        if ((delay = Replay.AdjustPositionReplay(user02)) < class="num">0) AlertToUser(); else
        {
          delay = (delay >= class="num">0 ? class="num">3 : delay);
          bTest = class="kw">false;
          t1 = GetTickCount64();

「回放超时与收尾的底层处理」

这段逻辑卡在回放服务的计时分支上:当系统时间戳 GetTickCount64() 与上次触发时刻 t1 的差值超过预设 delay 时,才推进下一帧事件。若 Replay.Event_OnTime() 返回负值,说明回放数据源已耗尽,立即调用 AlertToUser() 向用户弹窗报警,并把进程间共享变量里的 isPlay 置为 false,终止可视化联动。 AlertToUser() 本身只做三件事:从全局变量 def_GlobalVariableReplay 读回联合结构、改写播放标志、用 MessageBox 提示“No more data to use in replay-simulation”。Finish() 则负责关闭回放接口并打印“The replay service completed...”,属于干净的退出路径。 定位滑块位置时用了 iPos = (int)(m_Ticks.nTicks * ((Info.s_Infos.iPosShift * 1.0) / (def_MaxPosSlider + 1))),意味着滑块最大偏移 def_MaxPosSlider 对应整段 tick 总数的线性映射;把 +1 漏掉会让末端帧永远够不到最后一根 tick。外汇与贵金属回放验证属高风险操作,实盘参数误用可能导致错判历史波动。

MQL5 / C++
   }
      }else if ((GetTickCount64() - t1) >= (class="type">uint)(delay))
      {
         if ((delay = Replay.Event_OnTime()) < class="num">0) AlertToUser();
         t1 = GetTickCount64();
      }
         }
      }
   Finish();
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void AlertToUser(class="type">void)
{
      u_Interprocess Info;
      
      Info.u_Value.df_Value = GlobalVariableGet(def_GlobalVariableReplay);
      Info.s_Infos.isPlay = class="kw">false;
      GlobalVariableSet(def_GlobalVariableReplay, Info.u_Value.df_Value);
      MessageBox("No more data to use in replay-simulation", "Service Replay", MB_OK);
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void Finish(class="type">void)
{
      Replay.CloseReplay();
      Print("The replay service completed...");
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">int AdjustPositionReplay(class="kw">const class="type">bool bViewBuider)
{
class=class="str">"cmt">// ... Code ...
      iPos = (class="type">int)(m_Ticks.nTicks * ((Info.s_Infos.iPosShift * class="num">1.0) / (def_MaxPosSlider + class="num">1)));
class=class="str">"cmt">// ...   Rest of the code ...

用等待信号替掉播放暂停按钮

回放系统允许用户监控柱线构建过程时,会暴露一个体验断层:进度看似随时停住,其实是回放服务还没抵达目标位置、系统被锁住。此时若仍显示“播放/暂停”按钮,交易者容易误以为卡死或能操作,实际任何点击都不会被响应。 解决办法不是在界面上堆文字,而是在服务忙时把按钮图像换成“请稍候”标识,且阻断按钮事件向服务发送请求。我们在 InterProcess.mqh 增加一个跨进程变量,优先级高于其它控件;服务忙时把它写入终端全局变量,控制指标读到后切图并忽略点击。 C_Replay 类里只在真正执行回放前发一次信号,之后无条件释放指标。控制指标侧用 C_Control.mqh 加一层抽象,内部私有变量 m_bWait 在构造时初始化为 false——因为服务启动总是空闲可响应的。拖动回放位置会触发按钮状态切换,我们用 DispatchMessage 里加一行 busy 检查,防止忙时生成播放/暂停事件。 图像切换不靠轮询。服务每往回放资源写一条新记录,MetaTrader 5 会触发 OnCalculate,指标借此读全局变量改 m_bWait,并调用 CreateBtnPlayPause 重绘成等待图或恢复正常按钮。自定义事件 Ev_WAIT_ON / Ev_WAIT_OFF 由 OnChartEvent 统一委派给 DispatchMessage 处理;触发时用 EventCustom 枚举值加 CHARTEVENT_CUSTOM 基数区分来源。 关键数据点:def_MaxPosSlider 设为 400,限定滑块最大位移;指标内应避免定时器,靠平台跳价调用 OnCalculate 即可,否则会拖累同图表其它指标。外汇与贵金属回放仅作历史重演,实盘波动具高风险,等待信号逻辑不改变任何成交概率。

MQL5 / C++
class="macro">#class="kw">property copyright "Daniel Jose"
class=class="str">"cmt">//+------------------------------------------------------------------+
class="macro">#define def_GlobalVariableReplay                "Replay Infos"
class="macro">#define def_GlobalVariableIdGraphics    "Replay ID"
class="macro">#define def_SymbolReplay                "RePlay"
class="macro">#define def_MaxPosSlider                class="num">400
class="macro">#define def_ShortName                   "Market Replay"
class=class="str">"cmt">//+------------------------------------------------------------------+
union u_Interprocess
{
        union u_0
        {
                class="type">class="kw">double  df_Value;      class=class="str">"cmt">// The value of the terminal&class="macro">#x27;s global variable...
                class="type">class="kw">ulong   IdGraphic;     class=class="str">"cmt">// Contains the asset chart ID....
        }u_Value;
        class="kw">struct st_0
        {
                class="type">bool    isPlay;        class=class="str">"cmt">// Specifies if we are in the play or pause mode ...
把显隐开关交给小布盯盘
这些回放显隐与请稍候警告的触发逻辑,小布盯盘的 AIGC 已内置到品种回放页,你只管调偏好,不用自己写事件分发。

常见问题

Event_OnTime 每次调用都有轻微延迟,而跳价调用频次很高,累积偏差会让柱线显隐不同步,必须改为逐跳价处理。
它在读跳价文件同时维护统一的一分钟柱线记录,避免新增或删除柱线破坏既有元素的一致性。
当跳价时间和柱线记录时间不一致时返回 true,由调用方做新柱线初始化与后续数值调整。
可以,小布盯盘品种页已内置回放显隐与等待警告的 AIGC 判定,导入配置即可用,不用重造轮子。
在批量删建柱线或重载起点时屏蔽误操作观感,降低用户以为卡死的误判概率。