开发回放系统  市场模拟(第 25 部分):为下一步做准备·进阶篇

开发回放系统 市场模拟(第 25 部分):为下一步做准备·进阶篇

(2/3)· 控件回溯看似好玩,实则会让后续功能踩坑,本篇教你如何干净地关掉它

实战向 第 2/3 篇
很多人在写回放系统时会保留控件时间回溯,觉得「能退回去重看」很方便。但当你后续要加新模块,这种能力会悄悄制造状态冲突,调试时让人头脑发胀。本篇直接动手删掉这个隐患。

「给回放滑块加一面左墙」

做回放/模拟控件时,只把左侧按钮置灰还不够。用户拖滑块或连点左键,数值仍可能往下掉,直到服务端的停止点才真正卡住,这种「按钮说不能动、滑块却还能拖」的错位会让使用者困惑。 解法是在界面上画一条「墙」:用 OBJ_RECTANGLE_LABEL 建一个受限区色块,位置由 minimal 变量锁定。minimal 设为 0 时墙在零点;若改成 80 或 100,左限栏就右移,左侧按钮同步禁用,但拖拽递减逻辑本身不拦截——卡位靠的是滑块坐标被钳在 minimal 之上。 回放/模拟服务会随进度自动改 minimal,那时墙的宽度和位置要跟着变。下面这段是核心实现,重点看 PositionPinSlider 里如何用三元表达式把 p 钳在 [minimal, def_MaxPosSlider] 区间,以及 CreteBarSlider 如何用两个矩形拼出左墙指示。 别把 minimal 当写死常量 minimal 若硬编码为 0,用户永远回不到更早历史;让它由服务动态赋值,左墙才会随数据推进而移动。开 MT5 把 minimal 改成 80 跑一遍,能直接看到左侧按钮灭掉、色块右移的现象。

MQL5 / C++
class="kw">inline class="type">void PositionPinSlider(class="type">int p, class="kw">const class="type">int minimal = class="num">0)
  {
    m_Slider.posPinSlider = (p < minimal ? minimal : (p > def_MaxPosSlider ? def_MaxPosSlider : p));
    ObjectSetInteger(m_id, m_Slider.szBtnPin, OBJPROP_XDISTANCE, m_Slider.posPinSlider + def_MinPosXPin);
    ObjectSetInteger(m_id, m_Slider.szBtnLeft, OBJPROP_STATE, m_Slider.posPinSlider != minimal);
    ObjectSetInteger(m_id, m_Slider.szBtnRight, OBJPROP_STATE, m_Slider.posPinSlider < def_MaxPosSlider);
    ChartRedraw();
  }
class="kw">inline class="type">void CreteBarSlider(class="type">int x, class="type">int size)
  {
    ObjectCreate(m_id, m_Slider.szBarSlider, OBJ_RECTANGLE_LABEL, class="num">0, class="num">0, class="num">0);
    ObjectSetInteger(m_id, m_Slider.szBarSlider, OBJPROP_XDISTANCE, x);
    ObjectSetInteger(m_id, m_Slider.szBarSlider, OBJPROP_YDISTANCE, m_Slider.posY - class="num">4);
    ObjectSetInteger(m_id, m_Slider.szBarSlider, OBJPROP_XSIZE, size);
    ObjectSetInteger(m_id, m_Slider.szBarSlider, OBJPROP_YSIZE, class="num">9);
    ObjectSetInteger(m_id, m_Slider.szBarSlider, OBJPROP_BGCOLOR, clrLightSkyBlue);
    ObjectSetInteger(m_id, m_Slider.szBarSlider, OBJPROP_BORDER_COLOR, clrBlack);
    ObjectSetInteger(m_id, m_Slider.szBarSlider, OBJPROP_WIDTH, class="num">3);
    ObjectSetInteger(m_id, m_Slider.szBarSlider, OBJPROP_BORDER_TYPE, BORDER_FLAT);
class=class="str">"cmt">//---
    ObjectCreate(m_id, m_Slider.szBarSliderBlock, OBJ_RECTANGLE_LABEL, class="num">0, class="num">0, class="num">0);
    ObjectSetInteger(m_id, m_Slider.szBarSliderBlock, OBJPROP_XDISTANCE, x);
    ObjectSetInteger(m_id, m_Slider.szBarSliderBlock, OBJPROP_YDISTANCE, m_Slider.posY - class="num">9);
    ObjectSetInteger(m_id, m_Slider.szBarSliderBlock, OBJPROP_YSIZE, class="num">19);
    ObjectSetInteger(m_id, m_Slider.szBarSliderBlock, OBJPROP_BGCOLOR, clrRosyBrown);
    ObjectSetInteger(m_id, m_Slider.szBarSliderBlock, OBJPROP_BORDER_TYPE, BORDER_RAISED);
  }
class="kw">inline class="type">void PositionPinSlider(class="type">int p, class="kw">const class="type">int minimal = class="num">0)
  {
    m_Slider.posPinSlider = (p < minimal ? minimal : (p > def_MaxPosSlider ? def_MaxPosSlider : p));

◍ 滑块位置与按钮状态的同步写法

在自定义滑块控件里,拖动后的界面刷新不能只改坐标,还得顺手把左右箭头的可点击状态一起算清楚。上面这段片段就是典型的「拖完即同步」逻辑:先把图钉按钮按偏移量重定位,再根据当前位置判断左右按钮该不该亮。 具体看,posPinSlider 若不等于 minimal,左侧按钮 OBJPROP_STATE 置真,否则灭;只要没到 def_MaxPosSlider,右侧按钮就保持可点。滑条底块宽度被钉死在 minimal+2 像素,最后调 ChartRedraw 强制重绘,避免 MT5 上出现「数据变了图没动」的滞后。 直接把这段代码贴进你的面板类 OnEvent 里就能验证:拖到最左端时左箭头自动灰掉,到右端则右箭头失效,比手动判断边界省事。外汇与贵金属图表加载这类控件时需注意,高频重绘在tick密集行情下可能吃掉少量 CPU,属于高风险环境下的性能权衡。

MQL5 / C++
ObjectSetInteger(m_id, m_Slider.szBtnPin, OBJPROP_XDISTANCE, m_Slider.posPinSlider + def_MinPosXPin);
ObjectSetInteger(m_id, m_Slider.szBtnLeft, OBJPROP_STATE, m_Slider.posPinSlider != minimal);
ObjectSetInteger(m_id, m_Slider.szBtnRight, OBJPROP_STATE, m_Slider.posPinSlider < def_MaxPosSlider);
ObjectSetInteger(m_id, m_Slider.szBarSliderBlock, OBJPROP_XSIZE, minimal + class="num">2);
ChartRedraw();
}

回放暂停时控件如何拿到时间锁

核心限制已经定下:控件提示出现后用户不能往回拖时间,那么回放/模拟服务必须主动告诉控件指示器「当前禁止回溯到哪个时刻」。比起前面搭核心逻辑,这里只是补一个状态通报,重点在于每次暂停时读取服务的当前位置。 代码上先动刀的是控件类。把原本散在函数调用里的变量收进一个 struct(m_Slider),初始化一次后各处都能直接访问,免得后面改逻辑时满代码找引用。每次回放挂起都会调 CreteCtrlSlider,哪怕旧对象被销毁也照调不误,创建路径反而更干净。 回放服务侧目前多是「美容级」修改——先把平稳运行保住,再碰复杂问题。下面这段是控件类里限制栏与按钮的重构骨架,注意 m_Slider.posY 写死 40、CreteBarSlider 传 82 与 436 这两个像素边界,直接决定了滑块轨道在图表上的占位。 别把结构体内移当多余动作:它让 CreateCtrlSlider 在对象销毁后重调也不丢配置,你开 MT5 把回放挂起十几次就能看出对象重建不再闪断。外汇与贵金属回测环境高杠杆、滑点随机,验证时请用模拟盘跑。

MQL5 / C++
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,
              szBarSliderBlock;
      class="type">int     posPinSlider,
              posY,
              Minimal;
    }m_Slider;
class=class="str">"cmt">//+------------------------------------------------------------------+
    class="type">void CreteCtrlSlider(class="type">void)
      {
        u_Interprocess Info;
        m_Slider.szBarSlider       = def_NameObjectsSlider + " Bar";
        m_Slider.szBarSliderBlock = def_NameObjectsSlider + " Bar Block";
        m_Slider.szBtnLeft         = def_NameObjectsSlider + " BtnL";
        m_Slider.szBtnRight        = def_NameObjectsSlider + " BtnR";
        m_Slider.szBtnPin          = def_NameObjectsSlider + " BtnP";
        m_Slider.posY = class="num">40;
        CreteBarSlider(class="num">82, class="num">436);
        CreateObjectBitMap(class="num">52, class="num">25, m_Slider.szBtnLeft, def_ButtonLeft, def_ButtonLeftBlock);
        CreateObjectBitMap(class="num">516, class="num">25, m_Slider.szBtnRight, def_ButtonRight, def_ButtonRightBlock);
        CreateObjectBitMap(def_MinPosXPin, m_Slider.posY, m_Slider.szBtnPin, def_ButtonPin);
        ObjectSetInteger(m_id, m_Slider.szBtnPin, OBJPROP_ANCHOR, ANCHOR_CENTER);
        if (GlobalVariableCheck(def_GlobalVariableReplay)) Info.u_Value.df_Value = GlobalVariableGet(def_GlobalVariableReplay); else Info.u_Value.df_Value = class="num">0;
        m_Slider.Minimal = Info.s_Infos.iPosShift;
        PositionPinSlider(Info.s_Infos.iPosShift);
      }
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">inline class="type">void PositionPinSlider(class="type">int p, class="kw">const class="type">int minimal = class="num">0)
      {
        m_Slider.posPinSlider = (p < minimal ? minimal : (p > def_MaxPosSlider ? def_MaxPosSlider : p));

「滑块位置越界与按钮状态的同步写法」

在自定义滑块控件里,传入的目标像素位置 p 必须先夹在可用区间:小于 m_Slider.Minimal 就吸到最小,大于 def_MaxPosSlider 就锁到最大,否则直接用 p。这一步决定滑块不会画出轨道边界。 夹完位置后,用 ObjectSetInteger 把 Pin 按钮的 XDISTANCE 设为 posPinSlider + def_MinPosXPin,左箭头按钮的 STATE 在 posPinSlider 不等于 Minimal 时为 true(可左移),右箭头在小于 def_MaxPosSlider 时为 true(可右移)。 轨道色块宽度要跟着 Minimal + 2 走,最后 ChartRedraw 一次性重绘。外汇与贵金属图表上做这类 UI 改动时行情跳动快,建议在 OnChartEvent 里节流,避免高频重绘拖慢 MT5 终端响应。

MQL5 / C++
m_Slider.posPinSlider = (p < m_Slider.Minimal ? m_Slider.Minimal : (p > def_MaxPosSlider ? def_MaxPosSlider : p));
ObjectSetInteger(m_id, m_Slider.szBtnPin, OBJPROP_XDISTANCE, m_Slider.posPinSlider + def_MinPosXPin);
ObjectSetInteger(m_id, m_Slider.szBtnLeft, OBJPROP_STATE, m_Slider.posPinSlider != m_Slider.Minimal);
ObjectSetInteger(m_id, m_Slider.szBtnRight, OBJPROP_STATE, m_Slider.posPinSlider < def_MaxPosSlider);
ObjectSetInteger(m_id, m_Slider.szBarSliderBlock, OBJPROP_XSIZE, m_Slider.Minimal + class="num">2);
ChartRedraw();

◍ 回放服务里图表错位与缺价格线的修法

在 MT5 回放/模拟服务里,如果刚开启服务不先播放、直接把图表往前拖一段再启动,图表绘制就会出错。根因是系统没在重播前执行“虚假播放”就跳到了滑块位置,必须强制补一次虚假播放调用,否则柱线和价格线都会显示异常。 顺手加的一行是把缺失的跳价补进市场观察窗口,让回放更像真实行情。原代码里那些阻止用户回溯时间的检查因此可删——这正是当初不允许回退时间的直接后果。 另一个纯美观但影响体验的问题是:用历史文件加载柱线时,图表打开瞬间不画价格线。功能不受影响,但盯盘时不方便。修法是删掉旧逻辑、在合适位置调用通用指标函数去抓回放服务放上去的最后一根柱线。 抓到柱线后,若收盘价大于 0 就按构造模式设特殊跳价;若为 0 则从加载列表取第一个有效跳价。这段代码在“最后成交价绘制模式”下尤其有用,因为出价模式首跳已生效,最终跳价进市场观察后,价格线会立刻出现在图表上。 还有个不可靠但存在的场景:呈现数据集前没指向任何柱线,第一根柱线构造被截断。解决方法是用一天减一分钟的时间校正造一根前柱,让系统在 1 分钟到日线甚至周线周期都正常——月线就别指望了。对外汇与贵金属而言,这类回放仅用于策略验证,实盘受滑点和点差影响,高风险依旧。

MQL5 / C++
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.u_Value.df_Value = GlobalVariableGet(def_GlobalVariableReplay);
   if ((m_ReplayCount == class="num">0) && (m_Ticks.ModePlot == PRICE_EXCHANGE))
      for (; m_Ticks.Info[m_ReplayCount].volume_real == class="num">0; m_ReplayCount++);
   if (Info.s_Infos.iPosShift == (class="type">int)((m_ReplayCount * def_MaxPosSlider * class="num">1.0) / m_Ticks.nTicks)) class="kw">return;
   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);
   if (iPos < m_ReplayCount)
   {
      CustomRatesDelete(def_SymbolReplay, Rate[class="num">0].time, LONG_MAX);
      CustomTicksDelete(def_SymbolReplay, m_Ticks.Info[iPos].time_msc, LONG_MAX);
      if ((m_dtPrevLoading == class="num">0) && (iPos == class="num">0)) FirstBarNULL(); else
      {
         for(Rate[class="num">0].time -= class="num">60; (m_ReplayCount > class="num">0) && (Rate[class="num">0].time <= macroRemoveSec(m_Ticks.Info[m_ReplayCount].time)); m_ReplayCount--);
         m_ReplayCount++;
      }
   }else if (iPos > m_ReplayCount)
   {
      CreateBarInReplay(true);
      if (bViewBuider)
      {
         Info.s_Infos.isWait = true;
         GlobalVariableSet(def_GlobalVariableReplay, Info.u_Value.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);
      }
   }
}
把重复劳动交给小布
这些诊断小布盯盘的 AIGC 已内置,打开对应品种页即可看到回放状态与控件限制提示,你只需专注模拟逻辑本身。

常见问题

回溯功能在单独使用时看似有趣,但后续扩展新功能时会造成状态不一致与冲突,提前移除能避免大量无用调试。
可以,小布盯盘内置的 AIGC 会标注当前品种页的回放限制与控件可用性,省去你手动核对 indicator 状态的功夫。
可以,只是不能通过控件回溯,前向推进与既有调速逻辑仍保留,重启服务可从头开始模拟。
在控件面板或日志做一次性显式提示即可,避免反复弹窗打断模拟流程,具体实现见本节示例。