开发回放系统(第 67 部分):完善控制指标·进阶篇
🛠️

开发回放系统(第 67 部分):完善控制指标·进阶篇

(2/3)·低流动性模型崩了只是表象,真正的坑在控制指标代码僵化难扩展

偏理论 第 2/3 篇
很多人以为回放系统卡死只是流动性模型的锅,却没注意控制指标代码早已绑死在固定对象上。一旦要加新功能就得全文改,删图表时还容易爆错。把扩展性当可选项,后期维护成本会指数级上涨。

「滑块拖动时按钮区的重绘逻辑」

在自定义面板里,滑块位置 p 被夹在 m_Slider.Minimal 与 def_MaxPosSlider 之间,越界值会被直接截断,不会抛异常。夹完之后给 m_Section[ePin].x 叠加 def_PosXObjects 与 95 - (def_SizeButtons/2),等于把图钉按钮的锚点向右平移了近 95 像素量级,具体偏移取决于按钮尺寸常量。 PositionPinSlider 里还顺手算了 iL、iR 两个边界标志:当图钉贴到 Minimal 时 iL=1,贴到 def_MaxPosSlider 时 iR=1,其余情况为 0。随后遍历 ePlay+1 到 eNull 的控件,把左、右箭头按钮按 iL / iR 置灰,滑块条本体宽度也被重设为 m_Slider.Minimal+2。 CheckPositionMouseClick 负责把鼠标图形坐标写回 x、y,并逐个比对 m_Section[c0] 的矩形包围盒。命中条件很直白:x、y 同时落在区间 [x, x+w] 与 [y, y+h] 内才返回对应控件枚举,否则回 eNull。开 MT5 把这段塞进你的 C++ 面板类,拖一下滑块就能看到按钮禁用态随边界翻转。

MQL5 / C++
class="kw">inline class="type">void PositionPinSlider(class="type">class="kw">ushort p)
  {
   class="type">int iL, iR;
   
   m_Section[ePin].x = (class="type">class="kw">short)(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=class="str">"cmt">//+------------------------------------------------------------------+
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 = (class="type">class="kw">short) InfoMouse.Position.Y_Graphics;
   for(eObjectControl c0 = ePlay; c0 < eNull; c0++)
    {
     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="kw">return c0;
    }
   
   class="kw">return eNull;
  }

控件类的构造与缓冲写入细节

在 MT5 自定义指标里封装图形控件,构造函数先卡两道关:指标名校验不通过,或传入的鼠标指针对象无效,就直接抛用户错误并 return,避免后续在空对象上操作崩在 OnInit。 构造体内对图表事件做了「先关后开」的处理:先把 CHART_EVENT_OBJECT_DELETE 设 false,清掉旧控件对象,再设回 true。这样能防止删除历史对象时误触发删除事件,导致状态错乱。 按钮布局用枚举循环初始化,基础尺寸走 def_SizeButtons,y 坐标统一 25;x 方向硬编码偏移——播放键在 def_PosXObjects,左键 +47,右键 +511。改键位间距时直接调这几个数即可。 析构里逐按钮 delete 并清对象、释放鼠标指针,防止 IndicatorRelease 后残留 GDI 句柄。SetBuffer 把滑块最小态与播放态压进 double 的 16 位字段,eCtrlStatus 行显示:当 Minimal 超 def_MaxPosSlider 时记 Minimal,否则看播放键 state 落 ePlay 或 ePause。外汇贵金属图表加载此类控件须留意:图表对象交互频繁,误删事件可能引发重绘卡顿,属高风险操作环境。

MQL5 / C++
C_Controls(const class="type">long Arg0, const class="type">class="kw">string szShortName, C_Mouse *MousePtr)
:C_Terminal(Arg0),
 m_MousePtr(MousePtr)
{
 if ((!IndicatorCheckPass(szShortName)) || (CheckPointer(m_MousePtr) == POINTER_INVALID)) SetUserError(C_Terminal::ERR_Unknown);
 if (_LastError != ERR_SUCCESS) class="kw">return;
 ChartSetInteger(GetInfoTerminal().ID, CHART_EVENT_OBJECT_DELETE, false);
 ObjectsDeleteAll(GetInfoTerminal().ID, def_ObjectCtrlName(""));
 ChartSetInteger(GetInfoTerminal().ID, CHART_EVENT_OBJECT_DELETE, true);
 for (eObjectControl c0 = ePlay; c0 < eNull; c0++)
 {
  m_Section[c0].h = m_Section[c0].w = def_SizeButtons;
  m_Section[c0].y = class="num">25;
  m_Section[c0].Btn = NULL;
 }
 m_Section[ePlay].x = def_PosXObjects;
 m_Section[eLeft].x = m_Section[ePlay].x + class="num">47;
 m_Section[eRight].x = m_Section[ePlay].x + class="num">511;
 m_Slider.Minimal = eTriState;
}
class=class="str">"cmt">//+------------------------------------------------------------------+
 ~C_Controls()
{
 for (eObjectControl c0 = ePlay; c0 < eNull; c0++) class="kw">delete m_Section[c0].Btn;
 ObjectsDeleteAll(GetInfoTerminal().ID, def_ObjectCtrlName(""));
 class="kw">delete m_MousePtr;
}
class=class="str">"cmt">//+------------------------------------------------------------------+
 class="type">void SetBuffer(const class="type">int rates_total, class="type">class="kw">double &Buff[])
{
 uCast_Double info;

 info._16b[eCtrlPosition] = m_Slider.Minimal;
 info._16b[eCtrlStatus] = (class="type">class="kw">ushort)(m_Slider.Minimal > def_MaxPosSlider ? m_Slider.Minimal : (m_Section[ePlay].state ? ePlay : ePause));
 if (rates_total > class="num">0)

◍ 回放控件的消息分发逻辑

指标里的 DispatchMessage 是回放控制面板与图表交互的入口,它接收来自滑块、按钮等图形对象的事件并做状态同步。 代码第 173 行定义了函数签名,参数含 id、lparam、dparam、sparam,分别对应事件类型、长整型、双精度与字符串载荷;第 176–177 行用 static 变量 iPinPosX、six、sps 在多次调用间保留滑块位置与状态,避免每次重绘归零。 第 182–187 行处理自定义回放初始化事件:先把 dparam 塞进 uCast_Double 联合体,再校验第 7、6 字节是否为 'D' 'M' 标记,不符直接 break;通过后才把滑块最小值钳制在 [iPinPosX, def_MaxPosSlider] 区间,并依状态字节切换播放态。 第 188–198 行监听对象删除事件:若删的是播放按钮(def_ObjectCtrlName(ePlay)),就释放 m_Section[ePlay].Btn 指针并复位状态;否则走 RemoveCtrlSlider() 清理滑块。外汇与贵金属图表加载此类自定义控件时波动剧烈,误删对象可能让回放态错乱,建议在 MT5 策略测试器里先跑一遍再上实盘。

MQL5 / C++
class="type">void DispatchMessage(const class="type">int id, const class="type">long &lparam, const class="type">class="kw">double &dparam, const class="type">class="kw">string &sparam)
   {
      class="type">class="kw">short x, y;
      class="kw">static class="type">class="kw">ushort iPinPosX = class="num">0;
      class="kw">static class="type">class="kw">short six = -class="num">1, sps;
      uCast_Double info;
      
      class="kw">switch (id)
      {
         case (CHARTEVENT_CUSTOM + evCtrlReplayInit):
            info.dValue = dparam;
            if ((info._8b[class="num">7] != &class="macro">#x27;D&class="macro">#x27;) || (info._8b[class="num">6] != &class="macro">#x27;M&class="macro">#x27;)) break;
            iPinPosX = m_Slider.Minimal = (info._16b[eCtrlPosition] > def_MaxPosSlider ? def_MaxPosSlider : (info._16b[eCtrlPosition] < iPinPosX ? iPinPosX : info._16b[eCtrlPosition]));
            SetPlay((eObjectControl)(info._16b[eCtrlStatus]) == ePlay);
            break;
         case CHARTEVENT_OBJECT_DELETE:
            if (StringSubstr(sparam, class="num">0, StringLen(def_ObjectCtrlName(""))) == def_ObjectCtrlName(""))
            {
               if (sparam == def_ObjectCtrlName(ePlay))
               {
                  class="kw">delete m_Section[ePlay].Btn;
                  m_Section[ePlay].Btn = NULL;
                  SetPlay(m_Section[ePlay].state);
               }else
               {
                  RemoveCtrlSlider();

「播放键与滑块的鼠标事件分支」

在 MT5 自定义指标里,图表鼠标移动事件(CHARTEVENT_MOUSE_MOVE)是交互控件的核心入口。上面这段逻辑先判断左键是否按下,再按点击位置分流到播放、左移、右移、钉住等子状态。 点击 ePlay 区域会翻转播放状态:开启时移除滑块控件并把滑块下限锁到当前钉住位置 iPinPosX(211 行 m_Slider.Minimal = iPinPosX),关闭时重建滑块(212 行 CreateCtrlSlider)。这意味着播放模式下不允许手动拖滑块,回放区间起点被固化。 eLeft / eRight 用 iPinPosX 加减 1 来步进,边界分别是 m_Slider.Minimal 和 def_MaxPosSlider(215、218 行),单像素步进在 1920 宽屏上约对应 2~3 根 M1 棒,手感偏细。 ePin 分支在 six == -1 时记录鼠标 x 坐标与滑块位置,并关闭图表鼠标滚动(225 行 CHART_MOUSE_SCROLL=false),避免钉选时画面乱飘。外汇与贵金属波动快,这类控件若没锁滚动,复盘时容易误触平移,建议实盘前在策略测试器里先跑一遍鼠标逻辑。

MQL5 / C++
class="num">199.                 CreateCtrlSlider();
class="num">200.             }
class="num">201.             }
class="num">202.             break;
class="num">203.          case CHARTEVENT_MOUSE_MOVE:
class="num">204.             if ((*m_MousePtr).CheckClick(C_Mouse::eClickLeft))  class="kw">switch (CheckPositionMouseClick(x, y))
class="num">205.             {
class="num">206.                case ePlay:
class="num">207.                   SetPlay(!m_Section[ePlay].state);
class="num">208.                   if (m_Section[ePlay].state)
class="num">209.                   {
class="num">210.                     RemoveCtrlSlider();
class="num">211.                     m_Slider.Minimal = iPinPosX;
class="num">212.                   }else CreateCtrlSlider();
class="num">213.                   break;
class="num">214.                case eLeft:
class="num">215.                   PositionPinSlider(iPinPosX = (iPinPosX > m_Slider.Minimal ? iPinPosX - class="num">1 : m_Slider.Minimal));
class="num">216.                   break;
class="num">217.                case eRight:
class="num">218.                   PositionPinSlider(iPinPosX = (iPinPosX < def_MaxPosSlider ? iPinPosX + class="num">1 : def_MaxPosSlider));
class="num">219.                   break;
class="num">220.                case ePin:
class="num">221.                   if (six == -class="num">1)
class="num">222.                   {
class="num">223.                     six = x;
class="num">224.                     sps = (class="type">class="kw">short)iPinPosX;
class="num">225.                     ChartSetInteger(GetInfoTerminal().ID, CHART_MOUSE_SCROLL, false);

拖拽定位与图像缓冲的类封装

上面这段鼠标事件处理,核心是把滑块像素位置 iPinPosX 夹在 m_Slider.Minimal 与 def_MaxPosSlider 之间,再调用 PositionPinSlider 写回。注意 233 行:当 six>0 时把 six 置 -1 并重新打开 CHART_MOUSE_SCROLL,意味着拖拽结束后立刻归还图表滚轮控制权,否则用户会卡在无法滚动的状态。 下面新开的 C_DrawImage 类继承自 C_Terminal,用 #define def_MaxImages 2 把图像缓冲上限锁死为 2 张。m_InfoImage 数组里每个元素带 widthMap、heightMap 和 uint 型 Map[],说明每张图以裸像素数组方式驻留内存,而不是依赖 OBJ_BITMAP 标签反复向图表抛对象。 你可以直接把 006 行的 def_MaxImages 改成 4 或 8,看 MT5 回放多图序列时内存占用和重绘帧率怎么变——外汇与贵金属行情跳空频繁,这类自管像素缓冲在高波动时段可能比系统对象更稳,但仍有图表资源争用风险。

MQL5 / C++
class="num">226.    }
class="num">227.    iPinPosX = sps + x - six;
class="num">228.    PositionPinSlider(iPinPosX = (iPinPosX < m_Slider.Minimal ? m_Slider.Minimal : (iPinPosX > def_MaxPosSlider ? def_MaxPosSlider : iPinPosX)));
class="num">229.    break;
class="num">230.    }else if (six > class="num">0)
class="num">231.    {
class="num">232.    six = -class="num">1;
class="num">233.    ChartSetInteger(GetInfoTerminal().ID, CHART_MOUSE_SCROLL, true);
class="num">234.    }
class="num">235.    break;
class="num">236.    }
class="num">237.    ChartRedraw(GetInfoTerminal().ID);
class="num">238.    }
class="num">239. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">240. };
class="num">241. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">242. class="macro">#undef def_PosXObjects
class="num">243. class="macro">#undef def_ButtonPlay
class="num">244. class="macro">#undef def_ButtonPause
class="num">245. class="macro">#undef def_ButtonLeft
class="num">246. class="macro">#undef def_ButtonRight
class="num">247. class="macro">#undef def_ButtonPin
class="num">248. class="macro">#undef def_PathBMP
class="num">249. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">001. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">002. class="macro">#class="kw">property copyright "Daniel Jose"
class="num">003. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">004. class="macro">#include "C_Terminal.mqh"
class="num">005. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">006. class="macro">#define def_MaxImages class="num">2
class="num">007. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">008. class C_DrawImage : class="kw">public C_Terminal
class="num">009. {
class="num">010. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">011.   class="kw">private  :
class="num">012.    class="kw">struct st_00
class="num">013.    {
class="num">014.     class="type">int   widthMap,
class="num">015.          heightMap;
class="num">016.     class="type">uint  Map[];
class="num">017.    }m_InfoImage[def_MaxImages];
class="num">018.     class="type">uint   m_Pixels[];
class="num">019.     class="type">class="kw">string m_szObjName,

◍ 用结构体直接啃 BMP 头

把位图塞进指标或 EA 做背景,第一步是把文件头读准。下面这段用 MQL5 结构体映射 BMP 头部字段,一次性把宽高和像素密度读出来,省掉手动偏移的麻烦。 结构体 BitmapHeader 从 type 到 imgClrImportant 共 21 个字段,覆盖了 14 字节文件头加 40 字节信息头。FileReadStruct 按内存布局直接填,Header.imgWidth 和 imgHeight 就是后续贴图的坐标基准。 若 FileOpen 返回 INVALID_HANDLE,或者读到的字节数不等于 sizeof(Header),直接关句柄返回 false,说明文件不是合法 BMP 或路径错。读成功后把宽高写进 m_InfoImage[index],后续绘制时就不会把 1920×1080 的图错位成别的尺寸。 开 MT5 随便丢一张 24 位 BMP 进 Files 目录,改 szFileName 跑一遍,能在调试里看到 widthMap 等于实际像素宽,这套读取就通了。外汇和贵金属图表叠加自绘位图属高风险视觉辅助,别把它当信号依据。

MQL5 / C++
class="type">bool LoadBitmap(class="type">int index, class="type">class="kw">string szFileName)
  {
   class="kw">struct BitmapHeader
    {
     class="type">class="kw">ushort  type;
     class="type">uint    size,
             reserv,
             offbits,
             imgSSize,
             imgWidth,
             imgHeight;
     class="type">class="kw">ushort  imgPlanes,
             imgBitCount;
     class="type">uint    imgCompression,
             imgSizeImage,
             imgXPelsPerMeter,
             imgYPelsPerMeter,
             imgClrUsed,
             imgClrImportant;
    } Header;
   class="type">int fp, w;
   class="type">bool noAlpha, noFlip;
   class="type">uint imgSize;

   if ((fp = FileOpen(szFileName, FILE_READ | FILE_BIN)) == INVALID_HANDLE) class="kw">return false;
   if (FileReadStruct(fp, Header) != class="kw">sizeof(Header))
    {
     FileClose(fp);
     class="kw">return false;
    };
   m_InfoImage[index].widthMap  = (class="type">int)Header.imgWidth;
   m_InfoImage[index].heightMap = (class="type">int)Header.imgHeight;
把重复劳动交给小布
这些指标依赖关系的静态检查,小布盯盘的 AIGC 已内置,打开对应品种页即可看到调用链提示,你只需判断要不要重构。

常见问题

原判定新柱出现的方法依赖流动性良好的假设,低流动性或竞价中止时逻辑失效,需要在控制层做动态补偿。
它仅直接创建支持滑块控件的少数对象,其余对象管理委托给 C_DrawImage 类,职责边界比看上去窄。
可以,小布盯盘内置的 AIGC 能标出指标间依赖与潜在僵化点,减少人工通读源码的时间。
声明耦合度比行号更关键,若控制类广泛依赖其具体实现,后续替换或升级会牵一发而动全身。
直接体验差异小,但代码简化后 bug 概率可能降低,未来新功能上线会更平滑。