开发回放系统 市场模拟(第 25 部分):为下一步做准备·进阶篇
(2/3)· 控件回溯看似好玩,实则会让后续功能踩坑,本篇教你如何干净地关掉它
「给回放滑块加一面左墙」
做回放/模拟控件时,只把左侧按钮置灰还不够。用户拖滑块或连点左键,数值仍可能往下掉,直到服务端的停止点才真正卡住,这种「按钮说不能动、滑块却还能拖」的错位会让使用者困惑。 解法是在界面上画一条「墙」:用 OBJ_RECTANGLE_LABEL 建一个受限区色块,位置由 minimal 变量锁定。minimal 设为 0 时墙在零点;若改成 80 或 100,左限栏就右移,左侧按钮同步禁用,但拖拽递减逻辑本身不拦截——卡位靠的是滑块坐标被钳在 minimal 之上。 回放/模拟服务会随进度自动改 minimal,那时墙的宽度和位置要跟着变。下面这段是核心实现,重点看 PositionPinSlider 里如何用三元表达式把 p 钳在 [minimal, def_MaxPosSlider] 区间,以及 CreteBarSlider 如何用两个矩形拼出左墙指示。 别把 minimal 当写死常量 minimal 若硬编码为 0,用户永远回不到更早历史;让它由服务动态赋值,左墙才会随数据推进而移动。开 MT5 把 minimal 改成 80 跑一遍,能直接看到左侧按钮灭掉、色块右移的现象。
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,属于高风险环境下的性能权衡。
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 把回放挂起十几次就能看出对象重建不再闪断。外汇与贵金属回测环境高杠杆、滑点随机,验证时请用模拟盘跑。
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 终端响应。
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 分钟到日线甚至周线周期都正常——月线就别指望了。对外汇与贵金属而言,这类回放仅用于策略验证,实盘受滑点和点差影响,高风险依旧。
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); } } }