开发回放系统(第 55 部分):控制模块(基础篇)
◍ 回放系统的控制模块怎么搭
在 MT5 里做历史行情回放,控制模块决定暂停、步进和速度切换何时生效。没有它,回放只能一路跑到尾,没法定点观察某根 K 线形成时的挂单与指标状态。
核心思路是用一个全局标志位记录运行状态,主循环每帧先读标志再决定要不要推进 CopyTicksRange 取到的数据。下面这段是最小可用骨架,直接丢进 EA 的 OnTick 旁就能改。
外汇与贵金属回放属高风险验证环境,结论仅代表历史数据重演可能,不预示实盘走向。把速度挡位从 1x 调到 10x 后,同样 1 小时行情在回放里可能 6 分钟跑完,方便快速定位异动段。
class="kw">input class="type">int ReplaySpeed = class="num">1; class=class="str">"cmt">// 回放速度倍率 class="type">int replayState = class="num">0; class=class="str">"cmt">// 0停 1播 class="type">void ControlTick() { if(replayState==class="num">1) { CopyTicksRange(Symbol(), ticks, COPY_TICKS_ALL, start, start+ReplaySpeed*class="num">1000); start += ReplaySpeed*class="num">1000; } }
回放系统为何改用模块消息而非全局变量
上一篇文章里我们已经拼出了回放/模拟器系统的第一个真实模块。这个模块除了嵌进整套系统,也能单独拿出来用,省得每次都为个性化需求重写大量代码。改模块行为很简单:发一条用户事件消息即可,甚至用个轻量脚本就能完成。 新模型的关键转变在于通信机制。旧版回放系统依赖 MT5 的全局终端变量做程序间交互;现在改成通过用户事件(Custom Event)传消息,全局终端变量可以全部删掉。但代价是:自定义事件若在被接收方不在图表时发出,信息直接丢失,无法补读。 所以必须设计重发机制——同一条控制指令要反复投递,直到目标程序回确认包。按这个标准,回放/模拟器系统收敛为三个核心程序:服务程序、鼠标指标(这两个用户可见),以及作为服务程序隐藏资源的控制指标。不挂服务,控制指标就起不来。 外汇与贵金属品种用这套做训练或实盘都属高风险,模拟回放不等于真实滑点环境。下一步就是改控制指标,让它以模块化方式接管回放服务的管理。
「用缓冲区替掉全局变量传状态」
控制指标这边的改动不大,因为删终端全局变量的动作前面已经做掉一大半。但消息怎么走不通讲清楚,后面读模块会直接迷路。核心变化是:不再用全局终端变量递数据,改走指标缓冲区当通道。 C_Control.mqh 第 150 行用 MQL5 常量 INT_MIN 设滑块最小偏移,是整数变量可能的最小值。现在看不出用意,得等压缩和回放状态那套逻辑铺完才串得起来。第 160 行起把数据写进控制指标缓冲区,目前只压两个值:用户拖滑块的位置(164 行)和指标状态——播放还是暂停(165 行)。这两个值用整数极值区分,避开了空值歧义,后续测试更稳。 重点:信息不是从控制指标直送服务,必须经过缓冲区。第 166 行把联合压缩后的双精度值写进特定缓冲位。消息处理从 169 行开始,第 177 行有个自定义事件负责初始化滑块定位;收到的打包双精度值要翻译且校验,第 180 行测状态值是否为零,为零就 SetUserError 报错了。第 209 行把滑块值存进 m_Slider.Minimal,只在用户点过播放后才存,省掉到处传值的重复检查。 指标本体第 9 行声明不在图表显示,第 10 行要一个内部缓冲区(18、31 行声明,32 行清空前确保为空)。MT5 换周期会清掉图表并重跑 OnInit,指标内状态全丢。过去靠全局变量同步,现在靠缓冲区:服务能看到缓冲,指标刚挂上时缓冲是零或异常,服务侦测到就发特殊事件让指标用正确值重画按钮和滑块。这比让用户可改输入参数安全,也避开了输入参数在指标挂上后没法再交互的坑。 演示服务第 25 行把控制指标挂到新开图表,它不进指标列表;第 29 行加鼠标指标做全套测试。第 36 行循环等控制指标真正挂上(代码跑得比实际快),第 40 行查是否初始化,首次未初始化就 42–44 行发自定义事件去初始化。换周期时第 40 行回 true,服务把存的值塞回指标,状态不丢。外汇/贵金属模拟回放涉及高频重绘与参数误触,实盘外接有高风险控制需求,建议先开 MT5 用附带的演示服务跑通再改自己逻辑。
class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#class="kw">property copyright "Daniel Jose" class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#include "..\Auxiliar\C_DrawImage.mqh" class="macro">#include "..\Defines.mqh" class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#define def_PathBMP "Images\\Market Replay\\Control\\"
◍ 回放控制面板的位图与常量定义
在 MT5 里做行情回放器,第一步是把按钮图标编译进 EX5。上面这组 #define 把 7 个 BMP 文件名拼到 def_PathBMP 路径后面,再用 #resource 逐条声明,编译器会把这些位图嵌进程序资源段,运行时直接按名调用,不依赖外部文件。 按钮尺寸锁死在 32 像素(def_SizeButtons),拾色滤镜用 0xFF00FF 洋红做透明键——这是 BMP 无 Alpha 通道时的常规做法。控件前缀统一成 MarketReplayCTRL_,对象初始 X 坐标给 120,避免和主图左侧价格轴叠在一起。 类 C_Controls 私有继承 C_Terminal,内部用枚举 eObjectControl 管播放、左移、右移、钉住四个状态,加 eNull 收尾方便数组越界保护。结构体 st_01 里每个按钮挂一个 C_DrawImage 指针和 state 标志,坐标宽高 x/y/w/h 全留着运行时填。 开 MT5 验证时,把 BMP 丢进 def_PathBMP 指向的文件夹,改一行 def_SizeButtons 就能让按钮变大或变小,界面手感立刻不一样。
class="macro">#define def_ButtonPlay def_PathBMP + "Play.bmp" class="macro">#define def_ButtonPause def_PathBMP + "Pause.bmp" class="macro">#define def_ButtonLeft def_PathBMP + "Left.bmp" class="macro">#define def_ButtonLeftBlock def_PathBMP + "Left_Block.bmp" class="macro">#define def_ButtonRight def_PathBMP + "Right.bmp" class="macro">#define def_ButtonRightBlock def_PathBMP + "Right_Block.bmp" class="macro">#define def_ButtonPin def_PathBMP + "Pin.bmp" class="macro">#resource "\" + def_ButtonPlay class="macro">#resource "\" + def_ButtonPause class="macro">#resource "\" + def_ButtonLeft class="macro">#resource "\" + def_ButtonLeftBlock class="macro">#resource "\" + def_ButtonRight class="macro">#resource "\" + def_ButtonRightBlock class="macro">#resource "\" + def_ButtonPin class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#define def_PrefixCtrlName "MarketReplayCTRL_" class="macro">#define def_PosXObjects class="num">120 class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#define def_SizeButtons class="num">32 class="macro">#define def_ColorFilter 0xFF00FF class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#include "..\Auxiliar\C_Terminal.mqh" class="macro">#include "..\Auxiliar\C_Mouse.mqh" class=class="str">"cmt">//+------------------------------------------------------------------+ class C_Controls : class="kw">private C_Terminal { class="kw">protected: class="kw">private : class=class="str">"cmt">//+------------------------------------------------------------------+ enum eObjectControl {ePlay, eLeft, eRight, ePin, eNull}; class=class="str">"cmt">//+------------------------------------------------------------------+ class="kw">struct st_00 { class="type">class="kw">string szBarSlider, szBarSliderBlock; class="type">int Minimal; }m_Slider; class="kw">struct st_01 { C_DrawImage *Btn; class="type">bool state; class="type">int x, y, w, h; }m_Section[eObjectControl::eNull]; C_Mouse *m_MousePtr;
用矩形标签拼出可拖动的滑条
在 MT5 自定义控件里,滑块不一定非要用 OBJ_SLIDER,用两个 OBJ_RECTANGLE_LABEL 叠起来就能做出视觉可辨的滑条区。下面这段 CreteBarSlider 就是典型做法:先建一个浅蓝底、黑边、宽 3 像素的细条当轨道,再建一个 RosyBrown 底、凸起边框的方块当滑块把手。 轨道的 Y 坐标取 m_Section[ePin].y + 11,高度锁死 9 像素;把手的 Y 取 m_Section[ePin].y + 6,高度 19 像素,比轨道高出 10 像素形成包裹感。两者 X 都从 def_PosXObjects + x 起算,size 参数直接决定轨道长度,把手初始宽度跟随建对象时的默认。 SetPlay 里能看到另一个细节:播放按钮用 C_DrawImage 封装,首次调用时若 m_Section[ePlay].Btn 为 NULL 才 new 一个,并传入 Play / Pause 两套位图资源。这种懒加载避免在面板初始化时把用不到的按钮全建出来。 开 MT5 把这段代码塞进你的面板类,改 def_PosXObjects 和 ePin 的 y 值,就能直观看到滑条落在图表左上区的相对位置,外汇与贵金属图表叠加此类控件时需注意高风险下的视觉遮挡问题。
class="kw">inline class="type">void CreteBarSlider(class="type">int x, class="type">int size) { ObjectCreate(GetInfoTerminal().ID, m_Slider.szBarSlider = def_PrefixCtrlName + "B1", OBJ_RECTANGLE_LABEL, class="num">0, class="num">0, class="num">0); ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBarSlider, OBJPROP_XDISTANCE, def_PosXObjects + x); ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBarSlider, OBJPROP_YDISTANCE, m_Section[ePin].y + class="num">11); ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBarSlider, OBJPROP_XSIZE, size); ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBarSlider, OBJPROP_YSIZE, class="num">9); ObjectSetInteger(GetInfoTerminal().ID, m_Slider.szBarSlider, OBJPROP_BGCOLOR, clrLightSkyBlue); 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="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);