开发回放系统 市场模拟(第 04 部分):调整设置(II)·进阶篇
(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 控件存在脚本冲突导致卡图的高风险,建议在模拟盘先验证全局变量命名不撞车。
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,松手逻辑里也得配对还原。
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 在哪一类事件里触发了这套复位。
}
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 只在首次初始化柱线时间偏移:
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 的回放服务里,拖滑条验证行情是否无缝回退。
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