开发回放系统  市场模拟(第 09 部分):自定义事件·综合运用
📘

开发回放系统 市场模拟(第 09 部分):自定义事件·综合运用

第 3/3 篇

◍ 回放定位里的滑块换算与越界保护

在 MT5 回放引擎里,用户拖动的滑块值 iPosShift 取值范围是 0 到 400(def_MaxPosSlider),它并不直接等于 tick 序号,而是要先换算成实际回放位置。代码用 iPos = (int)(m_Ticks.nTicks * ((Info.s_Infos.iPosShift * 1.0) / (def_MaxPosSlider + 1))) 做归一化,避免除零同时把 400 档映射到整段 tick 流。 换算完若 iPos < m_ReplayCount,说明目标位置还在已加载历史区内,这时会调用宏 macroSearchPosition 重算 dt_Local 与 m_ReplayCount,再用 CustomRatesDelete 清掉该时间之后的自定义 K 线。注意 m_ReplayCount 来自之前已回放的 tick 数,若滑块回拖就可能触发删除重算,外汇与贵金属 tick 重播中这种越界保护缺失会直接让图表错乱。 首次加载时 m_dtPrevLoading == 0,代码把 Rate[0] 的四价全部设为 m_Ticks.Info[m_ReplayCount].last,tick_volume 置 0。这一步等于用当前回放点的 last 价造出一根零成交 K 线当作锚点,后续 CustomRates 追加才有基准。

MQL5 / C++
class="type">bool  isWait;       class=class="str">"cmt">// Asks the user to wait...
class="type">class="kw">ushort iPosShift;     class=class="str">"cmt">// A value between class="num">0 and class="num">400 ...
};
};
class=class="str">"cmt">//+------------------------------------------------------------------+
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);
      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;

「回放引擎的自定义事件与按钮资源绑定」

这段逻辑处在行情回放类的收尾段:当重放计数落后于界面游标且未处于构建视图状态时,先执行 macroSearchPosition 定位,再一次性把 m_Ticks.Rate 中 count 根自定义 K 线推给 def_SymbolReplay 品种。若 bViewBuider 为真,则把 Info.s_Infos.isWait 置 true 并通过 GlobalVariableSet 把状态写进全局变量 def_GlobalVariableReplay,让 UI 线程感知阻塞。 循环段用 for(iPos = iPos>0?iPos-1:0; m_ReplayCount<iPos && !_StopFlag; m_ReplayCount++) Event_OnTime() 逐根回放,每步都检查终端停止标志 _StopFlag,避免 MT5 退出时死循环。回放结束后从全局变量读回 Info.u_Value.df_Value,清掉 isWait 标志,再 return Event_OnTime() 交还控制权。 下方枚举 EventCustom 只定义了 Ev_WAIT_ON / Ev_WAIT_OFF 两个自定义事件,用于播放器在「等待数据」与「继续」之间切换。六个 #define 把按钮位图路径收敛成 def_ButtonPlay、def_ButtonPause、def_ButtonLeft、def_ButtonRight、def_ButtonPin、def_ButtonWait,并逐一用 #resource 编译进 ex5;路径都挂在 Images\Market Replay\ 下,少一个文件编译就会报 cannot open resource。 C_Controls 类 private 区先存 m_szBtnPlay、m_id、m_bWait,再用 st_00 结构打包左移、右移、钉住、滑条四个子控件名与 posPinSlider 整数。开盘前你可直接在 MT5 的 MQL5\Images\Market Replay 目录塞入这六张 bmp,否则回放面板按钮会加载成空白。

MQL5 / C++
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);
}
if (bViewBuider)
{
    Info.s_Infos.isWait = true;
    GlobalVariableSet(def_GlobalVariableReplay, Info.u_Value.df_Value);
}
for (iPos = (iPos > class="num">0 ? iPos - class="num">1 : class="num">0); (m_ReplayCount < iPos) && (!_StopFlag); m_ReplayCount++) Event_OnTime();
Info.u_Value.df_Value = GlobalVariableGet(def_GlobalVariableReplay);
Info.s_Infos.isWait = class="kw">false;
GlobalVariableSet(def_GlobalVariableReplay, Info.u_Value.df_Value);
class="kw">return Event_OnTime();
}
enum EventCustom {Ev_WAIT_ON, Ev_WAIT_OFF};
class="macro">#define def_ButtonPlay  "Images\\Market Replay\\Play.bmp"
class="macro">#define def_ButtonPause "Images\\Market Replay\\Pause.bmp"
class="macro">#define def_ButtonLeft  "Images\\Market Replay\\Left.bmp"
class="macro">#define def_ButtonRight "Images\\Market Replay\\Right.bmp"
class="macro">#define def_ButtonPin   "Images\\Market Replay\\Pin.bmp"
class="macro">#define def_ButtonWait  "Images\\Market Replay\\Wait.bmp"
class="macro">#resource "\\" + def_ButtonPlay
class="macro">#resource "\\" + def_ButtonPause
class="macro">#resource "\\" + def_ButtonLeft
class="macro">#resource "\\" + def_ButtonRight
class="macro">#resource "\\" + def_ButtonPin
class="macro">#resource "\\" + def_ButtonWait
class C_Controls
{
    class="kw">private :
class=class="str">"cmt">//+------------------------------------------------------------------+
        class="type">class="kw">string  m_szBtnPlay;
        class="type">long    m_id;
        class="type">bool    m_bWait;
        class="kw">struct st_00
        {
            class="type">class="kw">string  szBtnLeft,
                    szBtnRight,
                    szBtnPin,
                    szBarSlider;
            class="type">int     posPinSlider;

播放键触发滑条控件的开关逻辑

在自定义指标或 EA 的 C_Controls 类里,构造函数把播放按钮和各滑条子对象指针先置为 NULL,避免未创建时误访问。m_id 初始化为 0、m_bWait 为 false,意味着启动阶段不拦截任何图表事件。 DispatchMessage 接管图表事件分发,用静态变量 six 与 sps 记录滑条状态,局部变量 x、y、px1、px2 留给坐标换算。CHARTEVENT_OBJECT_CLICK 分支先判断 m_bWait,若正处于等待态直接 break,防止重复触发。 当点击对象名等于 m_szBtnPlay 时,通过 ObjectGetInteger 读 OBJPROP_STATE 得到按钮是否按下,赋给进程间结构体的 isPlay 字段。若 isPlay 为 false 就 CreteCtrlSlider 创建滑条;为 true 则 RemoveCtrlSlider 销毁并把 szBtnPin 清空——这一正一反就是播放/暂停切换滑条面板的硬开关。 实盘接这套代码时注意:外汇与贵金属杠杆高、滑点随机,图表对象点击响应依赖主图重绘节奏,回测里点按钮不会触发真实事件,只能在 MT5 可视化回测或实盘手动验证。

MQL5 / C++
C_Controls() : m_id(class="num">0), m_bWait(class="kw">false)
  {
    m_szBtnPlay       = NULL;
    m_Slider.szBarSlider = NULL;
    m_Slider.szBtnPin   = NULL;
    m_Slider.szBtnLeft  = NULL;
    m_Slider.szBtnRight = NULL;
  }
class="type">void DispatchMessage(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)
  {
    u_Interprocess Info;
    class="kw">static class="type">int six = -class="num">1, sps;
    class="type">int x, y, px1, px2;
    class="kw">switch (id)
    {
      case CHARTEVENT_OBJECT_CLICK:
        if (m_bWait) class="kw">break;
        if (sparam == m_szBtnPlay)
        {
          Info.s_Infos.isPlay = (class="type">bool) ObjectGetInteger(m_id, m_szBtnPlay, OBJPROP_STATE);
          if (!Info.s_Infos.isPlay) CreteCtrlSlider(); else
          {
            RemoveCtrlSlider();
            m_Slider.szBtnPin = NULL;
          }
        }
    }
  }

◍ 回放滑块与播放暂停的消息派发

回放控制面板的滑块拖动依赖全局变量传递位移量。当滑块位置变更时,先把物理像素位移强转成 ushort 写进 Info.s_Infos.iPosShift,再用 GlobalVariableSet 把打包后的 8 字节值刷进 def_GlobalVariableReplay,最后 ChartRedraw 强制重绘,界面才会跟着动。 左右微调按钮走的是另一分支:收到 szBtnLeft 就给 posPinSlider 减 1,收到 szBtnRight 则加 1,步长固定为 1 个像素单位,靠 PositionPinSlider 内部做边界 clamp。 播放/等待状态的切换由自定义图表事件驱动。Ev_WAIT_ON 把 m_bWait 置 true 并画成等待位图;Ev_WAIT_OFF 则从全局变量读回当前回放结构体,按 Info.s_Infos.isPlay 还原按钮状态,避免子窗口重启后状态丢失。 所有鼠标与自定义事件最终在 OnChartEvent 里转交 Control.DispatchMessage 处理,EA 主体不直接碰控件逻辑,分层清晰便于在 MT5 里单独改控制类而不动框架。

MQL5 / C++
Info.s_Infos.iPosShift = (class="type">class="kw">ushort) m_Slider.posPinSlider;
GlobalVariableSet(def_GlobalVariableReplay, Info.u_Value.df_Value);
ChartRedraw();
}else if (sparam == m_Slider.szBtnLeft) PositionPinSlider(m_Slider.posPinSlider - class="num">1);
else if (sparam == m_Slider.szBtnRight) PositionPinSlider(m_Slider.posPinSlider + class="num">1);
class="kw">break;
class=class="str">"cmt">// ... The rest of the code ....
class="type">void CreateBtnPlayPause(class="type">bool state)
{
    m_szBtnPlay = def_PrefixObjectName + "Play";
    CreateObjectBitMap(class="num">5, class="num">25, m_szBtnPlay, (m_bWait ? def_ButtonWait : def_ButtonPause), (m_bWait ? def_ButtonWait : def_ButtonPlay));
    ObjectSetInteger(m_id, m_szBtnPlay, OBJPROP_STATE, state);
}
class="type">void DispatchMessage(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)
{
    u_Interprocess Info;
    class="kw">static class="type">int six = -class="num">1, sps;
    class="type">int x, y, px1, px2;
    class="kw">switch (id)
    {
        case (CHARTEVENT_CUSTOM + Ev_WAIT_ON):
            m_bWait = true;
            CreateBtnPlayPause(true);
            class="kw">break;
        case (CHARTEVENT_CUSTOM + Ev_WAIT_OFF):
            m_bWait = class="kw">false;
            Info.u_Value.df_Value = GlobalVariableGet(def_GlobalVariableReplay);
            CreateBtnPlayPause(Info.s_Infos.isPlay);
            class="kw">break;
class=class="str">"cmt">// ... The rest of the code ...
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);
}

「用全局变量在回放里切等待态」

在 MT5 自定义指标里,想让图表知道「回放引擎正在等信号」还是「已经放行」,靠的是进程间全局变量而不是本地变量。上面这段 OnCalculate 就是典型接法:每次报价进来都去读一个名叫 def_GlobalVariableReplay 的全局变量,把它塞进联合体 u_Interprocess,再取里面的 s_Infos.isWait 标志位。 静态布尔 bWait 只在本指标实例内记忆「上一次是不是已经发过等待事件」,避免每根 K 线都重复推 EventChartCustom。第一次发现 isWait 为真,就向图表推 Ev_WAIT_ON 并把 bWait 置真;之后若标志翻回假,推 Ev_WAIT_OFF 并回写当时的 df_Value 作为自定义事件参数,同时 bWait 归位。 实盘或复盘里你可以直接打开 MT5 全局变量窗口看 def_GlobalVariableReplay 的实时数值,若它长时间停在 isWait=1 对应的位模式,说明回放脚本卡在等待条件,手动排查比猜指标为何不动要快得多。外汇与贵金属品种波动受杠杆与流动性影响,这类等待态切换频繁,属正常高风险现象。

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(m_id, Ev_WAIT_ON, class="num">0, class="num">0, "");
                  bWait = true;
            }
      }else if (!Info.s_Infos.isWait)
      {
            EventChartCustom(m_id, Ev_WAIT_OFF, class="num">0, Info.u_Value.df_Value, "");
            bWait = class="kw">false;
      }
      
      class="kw">return rates_total;
}

记住这一条就够了

控制指标在 OnCalculate 里每次都把当前状态写进终端全局变量,同时用局部静态变量标记回放服务是否繁忙;一旦发现服务忙就发自定义事件告警,状态翻转后再发就绪事件,这套握手逻辑靠 EventChartCustom 就能闭环。 EventChartCustom 并不只限当前图表和指标,把参数填对,就能向任意图表、指标或 EA 抛事件,剩下交由 MT5 内核分发,这点很多老手都误以为平台做不到。 外汇与贵金属仿真回放属于高风险验证环境,实盘前务必在策略测试器跑通。把静态变量和自定义事件的联动吃透,你就能自己扩出一套可控的行情重演框架。

常见问题

在滑块换算函数里加越界保护,超出最小或最大值时强制夹回边界,避免索引越界崩溃。
检查播放键是否只触发了滑条控件的开关标志,消息派发要把开关态同步给滑块重绘逻辑才行。
可以,小布能扫描你的回放面板资源绑定,标出未接事件的按钮和滑条,省去手动核对。
用独立全局变量存等待态,进入时置位、退出时复位,别和播放标志混用就不会串。
只要自定义事件 ID 避开系统保留段,并在派发里分清来源,就不会干扰播放暂停消息。