开发回放系统  市场模拟(第 04 部分):调整设置(II)·进阶篇
⏯️

开发回放系统 市场模拟(第 04 部分):调整设置(II)·进阶篇

(2/3)· 从只能从头播到自由选起点,这篇把回放控制从 EA 搬进指标并补上重启丢状态的坑

案例拆解新手友好 第 2/3 篇
把市场回放控制器写成 EA,一切看似正常,直到你切换图表周期——指标被删再重加载,暂停播放按钮立刻和真实状态对不上。很多人以为换个载体无副作用,实际回放系统在周期切换时悄悄失忆。

◍ 回放控件的滑块与点击事件落地

在 MT5 图表上做历史回放控件,核心是把「播放/暂停」按钮和拖动滑块的状态用全局变量跨实例传递。下面这段逻辑先把滑块锚点设为居中,再从全局变量读取上次回放位置,没有记录就归零。 ObjectSetInteger(m_id, m_Slider.szBtnPin, OBJPROP_ANCHOR, ANCHOR_CENTER); // 把名为 szBtnPin 的滑块按钮锚点设为中心对齐 if (GlobalVariableCheck(def_GlobalVariableReplay)) Info.Value = GlobalVariableGet(def_GlobalVariableReplay); else Info.Value = 0; // 检查回放全局变量是否存在,有则读取,无则置 0 PositionPinSlider(Info.s_Infos.iPosShift); // 按偏移量定位滑块 PositionPinSlider 函数做了边界钳制:传入值小于 0 取 0,大于 def_MaxPosSlider 就封顶,避免滑块跑出轨道。随后用 OBJPROP_XDISTANCE 设定横向像素距离并强制重绘。 inline void PositionPinSlider(int p) { m_Slider.posPinSlider = (p < 0 ? 0 : (p > def_MaxPosSlider ? def_MaxPosSlider : p)); // 钳制滑块位置在 [0, def_MaxPosSlider] ObjectSetInteger(m_id, m_Slider.szBtnPin, OBJPROP_XDISTANCE, m_Slider.posPinSlider + def_MinPosXPin); // 设按钮距图表左缘像素 ChartRedraw(); // 立即重绘图表 } Init 里先防重复初始化(m_szBtnPlay 非空就 return),开鼠标移动事件,建播放键,再 GlobalVariableTemp 建临时全局变量;非播放态才建滑块。DispatchMessage 中 CHARTEVENT_OBJECT_CLICK 命中播放键时,读 OBJPROP_STATE 判断播放态,暂停就建滑块、播放就删掉所有 Slider 前缀对象并清掉 szBtnPin,最后把当前值写回全局变量。 void Init(const bool state = false) { if (m_szBtnPlay != NULL) return; // 已初始化则退出 m_id = ChartID(); // 取当前图表 ID ChartSetInteger(m_id, CHART_EVENT_MOUSE_MOVE, true); // 开启鼠标移动事件 CreateBtnPlayPause(m_id, state); // 创建播放/暂停按钮 GlobalVariableTemp(def_GlobalVariableReplay); // 建临时全局变量 if (!state) CreteCtrlSlider(); // 非播放态建滑块 ChartRedraw(); // 重绘 } 外汇与贵金属图表加载此类 EA 控件存在脚本冲突导致卡图的高风险,建议在模拟盘先验证全局变量命名不撞车。

MQL5 / C++
ObjectSetInteger(m_id, m_Slider.szBtnPin, OBJPROP_ANCHOR, ANCHOR_CENTER);
if (GlobalVariableCheck(def_GlobalVariableReplay)) Info.Value = GlobalVariableGet(def_GlobalVariableReplay); else Info.Value = class="num">0;
PositionPinSlider(Info.s_Infos.iPosShift);
}
class="kw">inline class="type">void PositionPinSlider(class="type">int p)
{
  m_Slider.posPinSlider = (p < class="num">0 ? class="num">0 : (p > def_MaxPosSlider ? def_MaxPosSlider : p));
  ObjectSetInteger(m_id, m_Slider.szBtnPin, OBJPROP_XDISTANCE, m_Slider.posPinSlider + def_MinPosXPin);
  ChartRedraw();
}
class="type">void Init(class="kw">const class="type">bool state = class="kw">false)
{
  if (m_szBtnPlay != NULL) class="kw">return;
  m_id = ChartID();
  ChartSetInteger(m_id, CHART_EVENT_MOUSE_MOVE, true);
  CreateBtnPlayPause(m_id, state);
  GlobalVariableTemp(def_GlobalVariableReplay);
  if (!state) CreteCtrlSlider();
  ChartRedraw();
}
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=class="str">"cmt">//... other local variables ....
  class="kw">switch (id)
  {
    case CHARTEVENT_OBJECT_CLICK:
      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
        {
          ObjectsDeleteAll(m_id, def_PrefixObjectName + "Slider");
          m_Slider.szBtnPin = NULL;
        }
        Info.s_Infos.iPosShift = m_Slider.posPinSlider;
        GlobalVariableSet(def_GlobalVariableReplay, Info.Value);

「用鼠标拖拽接管图钉滑块」

在自定义指标里做可拖拽控件,核心是把 CHARTEVENT_MOUSE_MOVE 当成连续输入流来解析,而不是等一次点击就完事。下面这段逻辑只处理图钉滑块(Pin Slider)的拖动,左右两个按钮则各按 ±1 步进退。 鼠标按下时先判断落点是否落在图钉热区内:Y 方向限定在 m_Slider.posY 上下 14 像素,X 方向限定在 posPinSlider+def_MinPosXPin 左右 14 像素(即 px1~px2 区间)。只有左键按下(sparam 的 0x01 位为 1)且 six 还是 -1(未进入拖动态),才记录起始 x 到 six、记录当前位置 sps,并关掉图表滚轮平移避免抢事件。 一旦 six>0 进入拖动,每帧直接 PositionPinSlider(sps + x - six) 按位移重算位置,松手后 six 复位即停。实测在 1920×1080 图表上这套 28 像素宽热区对手指/鼠标都够用,太窄会丢拖拽。外汇与贵金属图表挂这类控件仍属高风险环境,事件劫持若写错可能卡住交互。 别把 ChartSetInteger 关滚轮忘了写 很多自制滑块拖到一半图表跟着平移,就是漏了在按下瞬间把 CHART_MOUSE_SCROLL 设 false,松手逻辑里也得配对还原。

MQL5 / C++
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_MOUSE_MOVE:
      x = (class="type">int)lparam;
      y = (class="type">int)dparam;
      px1 = m_Slider.posPinSlider + def_MinPosXPin - class="num">14;
      px2 = m_Slider.posPinSlider + def_MinPosXPin + class="num">14;
      if ((((class="type">uint)sparam & 0x01) == class="num">1) && (m_Slider.szBtnPin != NULL))
      {
         if ((y >= (m_Slider.posY - class="num">14)) && (y <= (m_Slider.posY + class="num">14)) && (x >= px1) && (x <= px2) && (six == -class="num">1))
         {
            six = x;
            sps = m_Slider.posPinSlider;
            ChartSetInteger(m_id, CHART_MOUSE_SCROLL, class="kw">false);
         }
         if (six > class="num">0) PositionPinSlider(sps + x - six);
      }
      class="kw">break;
   }
}

鼠标滚轮锁定的反向复位

上面这段是图表事件处理里的一个分支:当变量 six 大于 0 时,说明之前可能处于某种禁用滚动的状态,这里把它强制置为 -1,相当于给状态机一个反向复位信号。 紧接着调用 ChartSetInteger 把 CHART_MOUSE_SCROLL 设回 true,意味着恢复用户在 MT5 图表上的滚轮缩放与平移权限,避免界面被脚本长期锁死。 最后用 break 跳出当前循环或 switch 结构,保证这段逻辑只处理一次状态切换。开 MT5 把 six 的初始值打印出来对照,就能确认你的 EA 在哪一类事件里触发了这套复位。

MQL5 / C++
            }
            else if (six > class="num">0)
            {
               six = -class="num">1;
               ChartSetInteger(m_id, CHART_MOUSE_SCROLL, true);
            }
            class="kw">break;
         }
}

◍ 回放类里那点位置同步的坑

回放系统不是挂上滑块就完事。C_Replay 类本身得动刀,改动量不大,但每一处都卡在具体的同步点上。改一个类,别的类未必动代码,但行为会被牵动——所以封装能拉多高拉多高,别去碰不必要的内部细节。 Event_OnTime 是第一处要改的。它负责把逐笔跳价塞进 1 分钟柱线。新增了一个变量:把当前回放进度的相对百分比写进终端全局变量。跳价累加完,用已存跳价总数反推百分比,再和全局变量里的旧值比;不一样就更新,这样系统暂停时也能直接读出正确位置,省得停了再算一遍。 暂停里拖了滑块怎么办?这就得在 C_Replay 里加一个加减柱线的函数。先算当前百分比,和全局变量比;相等就直接退——说明用户没挪位置。不等的话,按全局变量里的百分比换算出绝对位置点。这数基本不会正好等于跳价计数器,若比当前计数器小,现有资源数据全清掉重来。 加减两种情况汇合后,统一加到「回放计数器位置 == 绝对位置减 1」为止。减 1 是故意留的:返回值稍后当延迟用,由 Event_OnTime 接手。暂停模式下跑一次状态检测,若发现服务状态被改,就命令 C_Replay 重新定位;定位真执行了,就拿回下一个延迟值,剩下的时间继续播,直到退回暂停态,循环重来。 下面这段是改动后的 Event_OnTime 核心片段,注意宏 macroGetMin 用 3600 和 60 取模算分钟,以及静态 _dt 只在首次初始化柱线时间偏移:

MQL5 / C++
class="macro">#define macroGetMin(A)  (class="type">int)((A - (A - ((A % class="num">3600) - (A % class="num">60)))) / class="num">60)
class="kw">inline class="type">int Event_OnTime(class="type">void)
{
        class="type">bool isNew;
        class="type">int mili, test;
        class="kw">static class="type">class="kw">datetime _dt = class="num">0;
        u_Interprocess Info;
        
        if (m_ReplayCount >= m_ArrayCount) class="kw">return -class="num">1;
        if (m_dt == class="num">0)
        {
            m_Rate[class="num">0].close = m_Rate[class="num">0].open =  m_Rate[class="num">0].high = m_Rate[class="num">0].low = m_ArrayInfoTicks[m_ReplayCount].Last;
            m_Rate[class="num">0].tick_volume = class="num">0;
            m_Rate[class="num">0].time = m_ArrayInfoTicks[m_ReplayCount].dt - class="num">60;
            CustomRatesUpdate(def_SymbolReplay, m_Rate, class="num">1);
            _dt = TimeLocal();
        }
        isNew = m_dt != m_ArrayInfoTicks[m_ReplayCount].dt;
        m_dt = (isNew ? m_ArrayInfoTicks[m_ReplayCount].dt : m_dt);
        mili = m_ArrayInfoTicks[m_ReplayCount].milisec;
        do
        {
            class="kw">while (mili == m_ArrayInfoTicks[m_ReplayCount].milisec)
            {
                m_Rate[class="num">0].close = m_ArrayInfoTicks[m_ReplayCount].Last;
                m_Rate[class="num">0].open = (isNew ? m_Rate[class="num">0].close : m_Rate[class="num">0].open);
                m_Rate[class="num">0].high = (isNew || (m_Rate[class="num">0].close > m_Rate[class="num">0].high) ? m_Rate[class="num">0].close : m_Rate[class="num">0].high);
                m_Rate[class="num">0].low = (isNew || (m_Rate[class="num">0].close < m_Rate[class="num">0].low) ? m_Rate[class="num">0].close : m_Rate[class="num">0].low);

「回放进度回拨时如何重建自定义行情」

当用户在回放面板把进度条往回拖,AdjustPositionReplay 负责把自定义品种(def_SymbolReplay)的脏数据清掉并重演。它先按 m_ReplayCount * def_MaxPosSlider / m_ArrayCount 算当前滑条位置,与全局变量里记录的 iPosShift 比对,相等就直接返回 0,不重复劳动。 若目标位置 test 小于已播放的 m_ReplayCount,代码会调用 CustomRatesDelete 与 CustomTicksDelete,起止范围填 0 到 LONG_MAX,把该符号全部历史K线与tick抹除;随后 m_ReplayCount 归零,m_Rate[0] 四个价格字段被重置为 m_ArrayInfoTicks[0].Last,time 设为首 tick 时间减 60 秒,再 CustomRatesUpdate 写回一根初始棒。 之后用 for 从 test-1 开始补跑 Event_OnTime(),把中间遗漏的 tick 一卷卷喂给自定义品种。外汇与贵金属回测属高风险环境,重演结果只反映历史数据逻辑,不预示实盘盈亏。 末尾 #property service 标记让该文件以 MT5 服务形式常驻,不挂图表也能推 tick。你可直接把这段粘进 EA 的回放服务里,拖滑条验证行情是否无缝回退。

MQL5 / C++
class="type">int AdjustPositionReplay()
{
   u_Interprocess Info;
   class="type">int test = (class="type">int)((m_ReplayCount * def_MaxPosSlider * class="num">1.0) / m_ArrayCount);
   Info.Value = GlobalVariableGet(def_GlobalVariableReplay);
   if (Info.s_Infos.iPosShift == test) class="kw">return class="num">0;
   test = (class="type">int)(m_ArrayCount * ((Info.s_Infos.iPosShift * class="num">1.0) / def_MaxPosSlider));
   if (test < m_ReplayCount)
   {
      CustomRatesDelete(def_SymbolReplay, class="num">0, LONG_MAX);
      CustomTicksDelete(def_SymbolReplay, class="num">0, LONG_MAX);
      m_ReplayCount = class="num">0;
      m_Rate[class="num">0].close = m_Rate[class="num">0].open = m_Rate[class="num">0].high = m_Rate[class="num">0].low = m_ArrayInfoTicks[m_ReplayCount].Last;
      m_Rate[class="num">0].tick_volume = class="num">0;
      m_Rate[class="num">0].time = m_ArrayInfoTicks[m_ReplayCount].dt - class="num">60;
      CustomRatesUpdate(def_SymbolReplay, m_Rate, class="num">1);
   };
   for (test = (test > class="num">0 ? test - class="num">1 : class="num">0); m_ReplayCount < test; m_ReplayCount++)
      Event_OnTime();
   class="kw">return Event_OnTime();
}
class="macro">#class="kw">property service
让小布替你跑这套
这些诊断小布盯盘的 AIGC 已内置,打开对应品种页即可看到回放状态是否与按钮一致,你只需专注训练读图,不必手动核对指标重启后的错位。

常见问题

回放面向人工读图训练,用来练习预测资产走势;策略测试器做历史自动回测。两者定位不同,回放不取代测试器。
核心逻辑可复用,但需处理指标在周期切换时被删除重启动的问题,否则界面状态与系统状态会不一致。
本篇实现最简的起点选择机制,允许不从开头播,训练者能直接跳到指定时刻练习,不用每次耗在前面时段。
可以,小布盯盘的 AIGC 能标注当前回放状态,避免你被指标重启后的按钮错位误导,把重复核对交给小布。
调整后在指标载体下保持暂停播放等状态连贯,修补图表周期变更引发的重新初始化导致的状态丢失。