开发回放系统(第 56 部分):调整模块·进阶篇
📘

开发回放系统(第 56 部分):调整模块·进阶篇

第 2/3 篇

「滑条与播放控件的对象属性落地」

在 MT5 自定义面板里,滑块背景条用 OBJ_RECTANGLE_LABEL 画出来后,边框和填充的细节全靠 ObjectSetInteger 逐个塞属性。上面这段把滑块外框设成黑色 3 像素宽、BORDER_FLAT 平边,又在内部叠了一个高 19、距上沿 6 像素的 ros棕色凸起块(BORDER_RAISED),视觉上就是可拖动的仓位指示条。 播放键走的是另一套逻辑:SetPlay(bool state) 里首次调用才 new 一个 C_DrawImage,平时只切 Paint 的第 6 参——state 为真传 0 显示播放图、为假传 1 显示暂停图,按钮尺寸写死 20。 CreateCtrlSlider 把整套控件坐标定死:滑块条 CreteBarSlider(77,436) 意味着起点 X=77、Y=436;左右箭头和图钉按钮也在这里用 EnumToString(eLeft/eRight/ePin) 拼出唯一对象名,避免多实例重名。开 MT5 把这几行抄进 EA 的面板初始化段,就能直接看到控件成型。

MQL5 / C++
ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBarSlider, OBJPROP_BORDER_COLOR, clrBlack);
ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBarSlider, OBJPROP_WIDTH, class="num">3);
ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBarSlider, OBJPROP_BORDER_TYPE, BORDER_FLAT);
ObjectCreate(GetInfoTerminal().ID, m_Slider.szBarSliderBlock = def_PrefixCtrlName + "B2", OBJ_RECTANGLE_LABEL, class="num">0, class="num">0, class="num">0);
ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBarSliderBlock, OBJPROP_XDISTANCE, def_PosXObjects + x);
ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBarSliderBlock, OBJPROP_YDISTANCE, m_Section[ePin].y + class="num">6);
ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBarSliderBlock, OBJPROP_YSIZE, class="num">19);
ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBarSliderBlock, OBJPROP_BGCOLOR, clrRosyBrown);
ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBarSliderBlock, OBJPROP_BORDER_TYPE, BORDER_RAISED);
}
class=class="str">"cmt">//+------------------------------------------------------------------+
   class="type">void SetPlay(class="type">bool state)
      {
       if (m_Section[ePlay].Btn == NULL)
          m_Section[ePlay].Btn = new C_DrawImage(GetInfoTerminal().ID, class="num">0, def_PrefixCtrlName + EnumToString(ePlay), def_ColorFilter, "::" + def_ButtonPlay, "::" + def_ButtonPause);
       m_Section[ePlay].Btn.Paint(m_Section[ePlay].x, m_Section[ePlay].y, m_Section[ePlay].w, m_Section[ePlay].h, class="num">20, ((m_Section[ePlay].state = state) ? class="num">0 : class="num">1));
      }
class=class="str">"cmt">//+------------------------------------------------------------------+
   class="type">void CreateCtrlSlider(class="type">void)
      {
       CreteBarSlider(class="num">77, class="num">436);
       m_Section[eLeft].Btn = new C_DrawImage(GetInfoTerminal().ID, class="num">0, def_PrefixCtrlName + EnumToString(eLeft), def_ColorFilter, "::" + def_ButtonLeft, "::" + def_ButtonLeftBlock);
       m_Section[eRight].Btn = new C_DrawImage(GetInfoTerminal().ID, class="num">0, def_PrefixCtrlName + EnumToString(eRight), def_ColorFilter, "::" + def_ButtonRight, "::" + def_ButtonRightBlock);
       m_Section[ePin].Btn = new C_DrawImage(GetInfoTerminal().ID, class="num">0, def_PrefixCtrlName + EnumToString(ePin), def_ColorFilter, "::" + def_ButtonPin);

滑块控件的回收与定位逻辑

这段内联函数负责把图表上的滑块控制区整套拆掉再重画。RemoveCtrlSlider 先关掉 CHART_EVENT_OBJECT_DELETE 事件监听,避免删除对象时触发回调死循环,再遍历 ePlay+1 到 eNull 的按钮指针逐个 delete 并置 NULL,最后用 ObjectsDeleteAll 按前缀 'B' 清掉残留图形对象,重新打开事件监听。 PositionPinSlider 接收 short 型位置参数 p,先夹取到 [m_Slider.Minimal, def_MaxPosSlider] 区间内,再叠加 def_PosXObjects 与 95-(def_SizeButtons/2) 的偏移量。第 108 行那个 95 是按钮中心相对滑块原点的硬偏移,改按钮尺寸时这里必须同步算,否则 pin 会偏出轨道。 iL 与 iR 根据是否顶到最小/最大边界置 1,传给 Btn.Paint 的最后一个参数做禁用态渲染;第 111 行把滑条色块宽度锁成 m_Slider.Minimal+2,保证背景块不随拖动拉伸。外汇与贵金属图表加载这类自定义控件存在客户端卡顿风险,建议在 MT5 策略测试器外的实盘图表先单图表验证。 CheckPositionMouseClick 通过鼠标对象指针取 X_Graphics 图形坐标,x 被强转 short 回传,调用方拿到的就是相对于图表区域的点击列位置,用于判断命中了哪个控制段。

MQL5 / C++
class="kw">inline class="type">void RemoveCtrlSlider(class="type">void)
  {  
     ChartSetInteger(GetInfoTerminal().ID, CHART_EVENT_OBJECT_DELETE, class="kw">false);
     for (eObjectControl c0 = ePlay + class="num">1; c0 < eNull; c0++)
     {
       class="kw">delete m_Section[c0].Btn;
       m_Section[c0].Btn = NULL;
     }
     ObjectsDeleteAll(GetInfoTerminal().ID, def_PrefixCtrlName + "B");
     ChartSetInteger(GetInfoTerminal().ID, CHART_EVENT_OBJECT_DELETE, true);
  }
class="kw">inline class="type">void PositionPinSlider(class="type">class="kw">short p)
  {
     class="type">int iL, iR;
     
     m_Section[ePin].x = (p < m_Slider.Minimal ? m_Slider.Minimal : (p > def_MaxPosSlider ? def_MaxPosSlider : p));
     iL = (m_Section[ePin].x != m_Slider.Minimal ? class="num">0 : class="num">1);
     iR = (m_Section[ePin].x < def_MaxPosSlider ? class="num">0 : class="num">1);
     m_Section[ePin].x += def_PosXObjects;
       m_Section[ePin].x += class="num">95 - (def_SizeButtons / class="num">2);
       for (eObjectControl c0 = ePlay + class="num">1; c0 < eNull; c0++)
       m_Section[c0].Btn.Paint(m_Section[c0].x, m_Section[c0].y, m_Section[c0].w, m_Section[c0].h, class="num">20, (c0 == eLeft ? iL : (c0 == eRight ? iR : class="num">0)));
     ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBarSliderBlock, OBJPROP_XSIZE, m_Slider.Minimal + class="num">2);
  }
class="kw">inline eObjectControl CheckPositionMouseClick(class="type">class="kw">short &x, class="type">class="kw">short &y)
  {
     C_Mouse::st_Mouse InfoMouse;
     
     InfoMouse = (*m_MousePtr).GetInfoMouse();
     x = (class="type">class="kw">short) InfoMouse.Position.X_Graphics;

◍ 控件命中检测与构造时的坐标排布

鼠标点击后先取光标在图表图形坐标里的 Y 值,强制转成 short 避免浮点误差干扰后续比较。 接着用 for 循环从 ePlay 扫到 eNull,逐个判断鼠标坐标 (x,y) 是否落在该控件矩形内:左不小于 m_Section[c0].x、上不小于 m_Section[c0].y、右不超过 x+w、下不超过 y+h,命中即返回对应枚举。 若全部落空则返回 eNull,这套逻辑在 120–127 行完成,是 UI 事件分发的基础。 构造函数里先把图表对象删除事件关掉,清掉旧前缀控件再重新打开,防止初始化时误触发删除回调。 控件尺寸统一设为 def_SizeButtons,Y 坐标固定 25;ePlay 的 X 取 def_PosXObjects,eLeft 右移 47 像素,eRight 右移 511 像素,滑块下限直接压到 SHORT_MIN(见 141–151 行)。 实际在 MT5 里改 def_PosXObjects 或那两个偏移量,就能把按钮组整体挪位,验证布局是否符合你的看盘习惯。

MQL5 / C++
class="num">120.    y = (class="type">class="kw">short) InfoMouse.Position.Y_Graphics;
class="num">121.    for (eObjectControl c0 = ePlay; c0 < eNull; c0++)
class="num">122.    {  
class="num">123.        if ((m_Section[c0].Btn != NULL) && (m_Section[c0].x <= x) && (m_Section[c0].y <= y) && ((m_Section[c0].x + m_Section[c0].w) >= x) && ((m_Section[c0].y + m_Section[c0].h) >= y))
class="num">124.           class="kw">return c0;
class="num">125.    }
class="num">126.    
class="num">127.    class="kw">return eNull;
class="num">128.   }
class="num">129. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">130.   class="kw">public  :
class="num">131. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">132.    C_Controls(class="kw">const class="type">long Arg0, class="kw">const class="type">class="kw">string szShortName, C_Mouse *MousePtr)
class="num">133.       :C_Terminal(Arg0),
class="num">134.       m_MousePtr(MousePtr)
class="num">135.    {
class="num">136.       if ((!IndicatorCheckPass(szShortName)) || (CheckPointer(m_MousePtr) == POINTER_INVALID)) SetUserError(C_Terminal::ERR_Unknown);
class="num">137.       if (_LastError != ERR_SUCCESS) class="kw">return;
class="num">138.       ChartSetInteger(GetInfoTerminal().ID, CHART_EVENT_OBJECT_DELETE, class="kw">false);
class="num">139.       ObjectsDeleteAll(GetInfoTerminal().ID, def_PrefixCtrlName);
class="num">140.       ChartSetInteger(GetInfoTerminal().ID, CHART_EVENT_OBJECT_DELETE, true);
class="num">141.       for (eObjectControl c0 = ePlay; c0 < eNull; c0++)
class="num">142.       {
class="num">143.         m_Section[c0].h = m_Section[c0].w = def_SizeButtons;
class="num">144.         m_Section[c0].y = class="num">25;
class="num">145.         m_Section[c0].Btn = NULL;
class="num">146.       }
class="num">147.       m_Section[ePlay].x = def_PosXObjects;
class="num">148.       m_Section[eLeft].x = m_Section[ePlay].x + class="num">47;
class="num">149.       m_Section[eRight].x = m_Section[ePlay].x + class="num">511;
class="num">150.       m_Slider.Minimal = SHORT_MIN;
class="num">151.    }
class="num">152. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">153.    ~C_Controls()
class="num">154.    {

「回放控件的缓冲写入与消息分发」

这段实现把回放面板的状态塞进指标缓冲区的最后一根 K 线。uCast_Double 用联合体把 16 位短整型拼回双精度:低 16 位存滑块最小位置,高 16 位用 SHORT_MAX / SHORT_MIN 标记播放或暂停,写入时只动 Buff[rates_total-1],不重写历史。 DispatchMessage 接的是 CHARTEVENT_CUSTOM + evCtrlReplayInit 自定义事件。dparam 拆出两个 ushort:第一个复位滑块最小位 iPinPosX,第二个若为 0 直接 SetUserError 抛错,非 0 则按 SHORT_MAX 开播、SHORT_MIN 重建滑块控件。 在 MT5 里跑时,把这段贴进你的 CIndicator 派生类,用 CustomEvent 发 evCtrlReplayInit 就能看到缓冲末位被改写;外汇与贵金属波动剧烈,回放仅用于复盘验证,实盘信号失效概率不低。

MQL5 / C++
for (eObjectControl c0 = ePlay; c0 < eNull; c0++) class="kw">delete m_Section[c0].Btn;
ObjectsDeleteAll(GetInfoTerminal().ID, def_PrefixCtrlName);
class="kw">delete m_MousePtr;
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void SetBuffer(class="kw">const class="type">int rates_total, class="type">class="kw">double &Buff[])
{
 uCast_Double info;

 info._16b[class="num">0] = (class="type">class="kw">ushort) m_Slider.Minimal;
 info._16b[class="num">1] = (class="type">class="kw">ushort) (m_Section[ePlay].state ? SHORT_MAX : SHORT_MIN);
 if (rates_total > class="num">0)
   Buff[rates_total - class="num">1] = info.dValue;
}
class=class="str">"cmt">//+------------------------------------------------------------------+
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)
{
 class="type">class="kw">short x, y;
 class="kw">static class="type">class="kw">short iPinPosX = -class="num">1, six = -class="num">1, sps;
 uCast_Double info;

 class="kw">switch (id)
  {
   case (CHARTEVENT_CUSTOM + evCtrlReplayInit):
     info.dValue = dparam;
     iPinPosX = m_Slider.Minimal = (class="type">class="kw">short) info._16b[class="num">0];
     if (info._16b[class="num">1] == class="num">0) SetUserError(C_Terminal::ERR_Unknown); else
      {
       SetPlay((class="type">class="kw">short)(info._16b[class="num">1]) == SHORT_MAX);
       if ((class="type">class="kw">short)(info._16b[class="num">1]) == SHORT_MIN) CreateCtrlSlider();
      }
     class="kw">break;

图表对象删除与鼠标点击的事件分支

在自定义控件面板里,用户手动删掉一个图形对象不会静默发生,MT5 会抛出 CHARTEVENT_OBJECT_DELETE。代码第187–201行处理该事件:先判断被删对象名是否带控件前缀 def_PrefixCtrlName(第188行),避免误伤普通画线。 若删的是播放按钮本身(sparam 等于前缀拼接 ePlay 枚举字符串,第190行),则释放 Btn 指针置 NULL(192–193行),并调用 SetPlay 按当前 state 重建状态(194行);否则视为滑块类控件被删,执行 RemoveCtrlSlider 后再 CreateCtrlSlider 重画(197–198行)。 鼠标移动事件从202行开始。仅当左键处于按下状态(CheckClick(eClickLeft))才进入坐标判定 CheckPositionMouseClick(x,y)(203行)。命中 ePlay 区域时翻转播放状态(206行):状态为真则移除滑块并把 m_Slider.Minimal 锁到 iPinPosX(209–210行),否则重建滑块(211行)。 命中 eLeft 时(213–214行)把 iPinPosX 左移1像素但不低于 Minimal 下限,直接驱动 PositionPinSlider 刷新滑块位置。外汇与贵金属图表上跑这类 EA 属高风险操作,参数越界可能卡死控件重绘。

MQL5 / C++
case CHARTEVENT_OBJECT_DELETE:
   if (StringSubstr(sparam, class="num">0, StringLen(def_PrefixCtrlName)) == def_PrefixCtrlName)
   {
      if (sparam == (def_PrefixCtrlName + EnumToString(ePlay)))
      {
         class="kw">delete m_Section[ePlay].Btn;
         m_Section[ePlay].Btn = NULL;
         SetPlay(m_Section[ePlay].state);
      }else
      {
         RemoveCtrlSlider();
         CreateCtrlSlider();
      }
   }
   class="kw">break;
case CHARTEVENT_MOUSE_MOVE:
   if ((*m_MousePtr).CheckClick(C_Mouse::eClickLeft))  class="kw">switch (CheckPositionMouseClick(x, y))
   {
      case ePlay:
         SetPlay(!m_Section[ePlay].state);
         if (m_Section[ePlay].state)
         {
            RemoveCtrlSlider();
            m_Slider.Minimal = iPinPosX;
         }else CreateCtrlSlider();
         class="kw">break;
      case eLeft:
         PositionPinSlider(iPinPosX = (iPinPosX > m_Slider.Minimal ? iPinPosX - class="num">1 : m_Slider.Minimal));

◍ 拖拽定位时如何锁死图表滚动

这段收尾逻辑处理的是播放控件上「图钉」按钮被按下后的鼠标拖拽行为。当你在 ePin 分支里首次点下,six 为 -1 时,代码记录当前鼠标 x 与滑块位置 sps,并立刻用 ChartSetInteger 把 CHART_MOUSE_SCROLL 设为 false,相当于临时冻结图表随鼠标滚轮平移。 iPinPosX = sps + x - six 这行把滑块位置跟着鼠标实时偏移;下一行再用三目运算把它夹在 m_Slider.Minimal 与 def_MaxPosSlider 之间,越界就吸边。外汇与贵金属品种波动剧烈,冻结滚动能避免拖控件时图表乱跑,但操作完记得释放。 松开后 six 重置为 -1 并重新打开 CHART_MOUSE_SCROLL,最后 ChartRedraw 刷新一次。宏定义在 241–248 行统一 undef,避免污染其他 include 文件。 [CODE]

  • break;
  • case eRight:
  • PositionPinSlider(iPinPosX = (iPinPosX < def_MaxPosSlider ? iPinPosX + 1 : def_MaxPosSlider));
  • break;
  • case ePin:
  • if (six == -1)
  • {
  • six = x;
  • sps = iPinPosX;
  • ChartSetInteger(GetInfoTerminal().ID, CHART_MOUSE_SCROLL, false);
  • }
  • iPinPosX = sps + x - six;
  • PositionPinSlider(iPinPosX = (iPinPosX < m_Slider.Minimal ? m_Slider.Minimal : (iPinPosX > def_MaxPosSlider ? def_MaxPosSlider : iPinPosX)));
  • break;
  • }else if (six > 0)
  • {
  • six = -1;
  • ChartSetInteger(GetInfoTerminal().ID, CHART_MOUSE_SCROLL, true);
  • }
  • break;
  • }
  • ChartRedraw(GetInfoTerminal().ID);
  • }
  • //+------------------------------------------------------------------+
  • };
  • //+------------------------------------------------------------------+
  • #undef def_PosXObjects
  • #undef def_ButtonPlay
  • #undef def_ButtonPause
  • #undef def_ButtonLeft
  • #undef def_ButtonRight
  • #undef def_ButtonPin
  • #undef def_PrefixCtrlName
  • #undef def_PathBMP

[/CODE] 逐行拆解:215/218/228/234 的 break 只是退出对应 case;217 是右键步进滑块 +1 并封顶。220–225 记录拖拽起点并关滚动;226 算偏移;227 夹取边界。229–233 是鼠标抬起分支,six 归位并恢复滚动。236 重绘,241 往后清掉七个宏。开 MT5 把这段塞进你的面板类,点图钉拖一下就能验证滚动锁定是否生效。

MQL5 / C++
class="num">215.                class="kw">break;
class="num">216.              case eRight:
class="num">217.                  PositionPinSlider(iPinPosX = (iPinPosX < def_MaxPosSlider ? iPinPosX + class="num">1 : def_MaxPosSlider));
class="num">218.                  class="kw">break;
class="num">219.              case ePin:
class="num">220.                  if (six == -class="num">1)
class="num">221.                  {
class="num">222.                      six = x;
class="num">223.                      sps = iPinPosX;
class="num">224.                      ChartSetInteger(GetInfoTerminal().ID, CHART_MOUSE_SCROLL, class="kw">false);
class="num">225.                  }
class="num">226.                  iPinPosX = sps + x - six;
class="num">227.                  PositionPinSlider(iPinPosX = (iPinPosX < m_Slider.Minimal ? m_Slider.Minimal : (iPinPosX > def_MaxPosSlider ? def_MaxPosSlider : iPinPosX)));
class="num">228.                  class="kw">break;
class="num">229.            }else if (six > class="num">0)
class="num">230.            {
class="num">231.                six = -class="num">1;
class="num">232.                ChartSetInteger(GetInfoTerminal().ID, CHART_MOUSE_SCROLL, true);
class="num">233.            }
class="num">234.            class="kw">break;
class="num">235.        }
class="num">236.        ChartRedraw(GetInfoTerminal().ID);
class="num">237.      }
class="num">238. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">239. };
class="num">240. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">241. class="macro">#undef def_PosXObjects
class="num">242. class="macro">#undef def_ButtonPlay
class="num">243. class="macro">#undef def_ButtonPause
class="num">244. class="macro">#undef def_ButtonLeft
class="num">245. class="macro">#undef def_ButtonRight
class="num">246. class="macro">#undef def_ButtonPin
class="num">247. class="macro">#undef def_PrefixCtrlName
class="num">248. class="macro">#undef def_PathBMP

常见问题

在拖拽定位消息分支里置一个滚动锁标志,拖拽期间屏蔽图表滚动手势,松手再释放即可。
对象删除未清事件分支导致幽灵命中,删除图表对象时同步注销其点击回调就能解决。
可以,小布能按你贴出的控件坐标排布和命中检测逻辑做静态诊断,标出回收与定位的遗漏点。
多半是构造时没按基准锚点重排坐标,回收前记好相对偏移,重建时以此排布就不会错位。
把缓冲写入和界面消息分发解耦,写入走独立队列,仅在有帧刷新时派发绘制消息即可。