开发回放系统 市场模拟(第 09 部分):自定义事件·进阶篇
(2/3)· 改起点就闪构建中柱线惹人烦?本篇用自定义事件让看与不看皆可控
◍ 回放进度与历史清洗的耦合点
这段逻辑干了两件事:把全局变量里的滑块位置换算成 tick 索引,再按该索引截断并重写自定义品种的历史。iPos 先用 m_ReplayCount * def_MaxPosSlider / m_Ticks.nTicks 算百分比位置,随后反算回真实 tick 序号,说明回放进度条与总 tick 量强绑定。
若 Info.s_Infos.iPosShift == iPos 直接返回 0,避免重复加载;否则用 CustomRatesDelete(def_SymbolReplay, dt_Local, LONG_MAX) 把指定时间之后的所有自定义 K 线清掉,再从 m_Ticks.Rate 里找到 spread 小于 iPos 的最后一根,作为新的回放起点。外汇与贵金属回测属高风险场景,重播结果仅代表历史数据可能性,不预示实盘走向。
循环里 c0 从 1 起跳,比较的是 m_Ticks.Rate[c0 - 1].spread < iPos,也就是说 Rate 数组里的 spread 字段被借用来存累计 tick 数。改这个字段的写入方式,回放定位就会整体偏移。
m_Ticks.nTicks++; } old = (m_Ticks.nTicks > class="num">0 ? m_Ticks.nTicks - class="num">1 : old); } } if ((!FileIsEnding(file)) && (!_StopFlag)) { Print("Too many data in the tick file.\nCannot class="kw">continue..."); class="kw">return class="kw">false; } }else { Print("Tick file ", szFileNameCSV,".csv not found..."); class="kw">return class="kw">false; } class="kw">return (!_StopFlag); }; class="type">int AdjustPositionReplay(class="kw">const class="type">bool bViewBuider) { u_Interprocess Info; class="type">MqlRates Rate[class="num">1]; class="type">int iPos = (class="type">int)((m_ReplayCount * def_MaxPosSlider * class="num">1.0) / m_Ticks.nTicks); class="type">class="kw">datetime dt_Local; Info.u_Value.df_Value = GlobalVariableGet(def_GlobalVariableReplay); if (Info.s_Infos.iPosShift == iPos) class="kw">return class="num">0; iPos = (class="type">int)(m_Ticks.nTicks * ((Info.s_Infos.iPosShift * class="num">1.0) / def_MaxPosSlider)); if (iPos < m_ReplayCount) { dt_Local = m_dtPrevLoading; m_ReplayCount = class="num">0; if (!bViewBuider) for (class="type">int c0 = class="num">1; (c0 < m_Ticks.nRate) && (m_Ticks.Rate[c0 - class="num">1].spread < iPos); c0++) { dt_Local = m_Ticks.Rate[c0].time; m_ReplayCount = m_Ticks.Rate[c0 - class="num">1].spread; } CustomRatesDelete(def_SymbolReplay, dt_Local, LONG_MAX); if (m_dtPrevLoading == class="num">0) {
「回放进度定位与历史报价清洗」
在 tick 回放引擎里,进度滑块的位置不是直接映射 tick 序号,而是按 m_ReplayCount * def_MaxPosSlider / m_Ticks.nTicks 做归一化折算,再反算回实际 tick 下标。这样即便总 tick 数变化,滑块百分比含义也稳定。
当全局变量里的目标位移小于已回放计数 m_ReplayCount,说明用户把进度往回拖了。此时必须先用宏 macroSearchPosition 从 m_Ticks.Rate[] 里按 spread 边界找回对应的 dt_Local 与 m_ReplayCount,否则后续删除会越界。
找回时间点后调用 CustomRatesDelete(def_SymbolReplay, dt_Local, LONG_MAX),把该时刻之后的所有自定义合成 K 线清空。外汇与贵金属回放属高风险模拟,清洗不干净会让 EA 读到错位的历史柱,回测结论可能严重偏离真实。
下面这段是回退分支里的关键代码,注意 Rate[0] 四条价格被强行等同为最后一笔 tick 的 last,且 tick_volume 置 0、time 回拨 60 秒,等于造了一根无实体的占位棒。
Rate[class="num">0].close = Rate[class="num">0].open = Rate[class="num">0].high = Rate[class="num">0].low = m_Ticks.Info[m_ReplayCount].last; Rate[class="num">0].tick_volume = class="num">0; Rate[class="num">0].time = m_Ticks.Info[m_ReplayCount].time - class="num">60; CustomRatesUpdate(def_SymbolReplay, Rate, class="num">1); } } for (iPos = (iPos > class="num">0 ? iPos - class="num">1 : class="num">0); (m_ReplayCount < iPos) && (!_StopFlag); m_ReplayCount++) Event_OnTime(); class="kw">return Event_OnTime(); } class="type">int AdjustPositionReplay(class="kw">const class="type">bool bViewBuider) { class="macro">#define macroSearchPosition { \ dt_Local = m_dtPrevLoading; m_ReplayCount = count = class="num">0; \ if (!bViewBuider) for (count = class="num">1; (count < m_Ticks.nRate) && (m_Ticks.Rate[count - class="num">1].spread < iPos); count++) \ { dt_Local = m_Ticks.Rate[count].time; m_ReplayCount = m_Ticks.Rate[count - class="num">1].spread; } \ } u_Interprocess Info; class="type">MqlRates Rate[def_BarsDiary]; class="type">int iPos = (class="type">int)((m_ReplayCount * def_MaxPosSlider * class="num">1.0) / m_Ticks.nTicks), count; class="type">class="kw">datetime dt_Local; Info.u_Value.df_Value = GlobalVariableGet(def_GlobalVariableReplay); if (Info.s_Infos.iPosShift == iPos) class="kw">return class="num">0; iPos = (class="type">int)(m_Ticks.nTicks * ((Info.s_Infos.iPosShift * class="num">1.0) / (def_MaxPosSlider + class="num">1))); if (iPos < m_ReplayCount) { macroSearchPosition; CustomRatesDelete(def_SymbolReplay, dt_Local, LONG_MAX);
回放首根 K 线的初始化写法
做历史 tick 回放时,第一根自定义 K 线必须手动铺底,否则后续 CustomRatesUpdate 会接到空结构。上面这段逻辑里,先用 m_dtPrevLoading == 0 判断是不是首帧,是的话把 Rate[0] 的 open/high/low/close 全部压成当前 tick 的 last 价,tick_volume 置 0,time 往前挪 60 秒,再推一笔长度为 1 的更新。 这里有个容易踩的坑:time 减 60 是为了让首根 K 线收盘时间早于首个回放 tick,避免 MT5 按时间排序时把这根丢出去。若你改回放周期,这个偏移量要跟着调,比如 M1 回放用 60,M5 就得用 300。 非首帧分支里,iPos 超过已回放计数且不在构建视图状态时,直接把整段 m_Ticks.Rate 按 count 批量推给 CustomRatesUpdate,比逐根更新省 CPU。最后那个 for 从 iPos-1 往回兜一格再向前跑 Event_OnTime,是保证断点续播不丢前一帧的关键。外汇与贵金属回放属高风险验证,结论仅代表代码行为倾向,不等于实盘必然一致。
if (m_dtPrevLoading == class="num">0) { Rate[class="num">0].close = Rate[class="num">0].open = Rate[class="num">0].high = Rate[class="num">0].low = m_Ticks.Info[m_ReplayCount].last; Rate[class="num">0].tick_volume = class="num">0; Rate[class="num">0].time = m_Ticks.Info[m_ReplayCount].time - class="num">60; CustomRatesUpdate(def_SymbolReplay, Rate, class="num">1); } }if ((iPos > m_ReplayCount) && (!bViewBuider)) { macroSearchPosition; CustomRatesUpdate(def_SymbolReplay, m_Ticks.Rate, count); } for (iPos = (iPos > class="num">0 ? iPos - class="num">1 : class="num">0); (m_ReplayCount < iPos) && (!_StopFlag); m_ReplayCount++) Event_OnTime(); class="kw">return Event_OnTime(); }
◍ 回放耗尽时如何通知用户
历史回放系统跑到没有更多 tick 可供模拟时,如果用户没收到任何提示,大概率会以为 EA 崩了或者终端异常。我们在回放引擎里补一层终止告警,就是为了避免这种误判。 触发场景其实有两种:最常见的是正常回放执行到数据尾部;另一种少见于用户手动把滚动条拖到末尾。两种情况下处理逻辑一致——读取全局终端变量里的暂停标志位,重新写入状态,再弹窗说明当前已无数据。 下面这段 OnStart 主干里,关键判断在 AdjustPositionReplay 返回负值后调用 AlertToUser()。正常回放间隙每轮 Sleep(750) 轮询全局变量 def_GlobalVariableReplay,一旦 isPlay 为假且延迟函数返回 <0,就说明数据耗尽,告警即触发。 别让终端静默假死 把 AlertToUser 换成你自己的弹窗或 PushNotification,在 MT5 里实测拖到尾部和自然跑完两种路径,确认都能在 1 秒内收到提示,否则用户仍可能误判。外汇与贵金属回测涉及高杠杆,终端异常误判可能让你漏掉实盘切换时机。
class="type">void OnStart() { class="type">class="kw">ulong t1; class="type">int delay = class="num">3; class="type">long id = class="num">0; u_Interprocess Info; class="type">bool bTest = class="kw">false; Replay.InitSymbolReplay(); if (!Replay.SetSymbolReplay(user00)) { Finish(); class="kw">return; } Print("Wait for permission from [Market Replay] indicator to start replay ..."); id = Replay.ViewReplay(user01); class="kw">while ((!GlobalVariableCheck(def_GlobalVariableReplay)) && (!_StopFlag) && (ChartSymbol(id) != "")) Sleep(class="num">750); if ((_StopFlag) || (ChartSymbol(id) == "")) { Finish(); class="kw">return; } Print("Permission granted. The replay service can now be used..."); t1 = GetTickCount64(); class="kw">while ((ChartSymbol(id) != "") && (GlobalVariableGet(def_GlobalVariableReplay, Info.u_Value.df_Value)) && (!_StopFlag)) { if (!Info.s_Infos.isPlay) { if (!bTest) bTest = true; }else { if (bTest) { if ((delay = Replay.AdjustPositionReplay(user02)) < class="num">0) AlertToUser(); else { delay = (delay >= class="num">0 ? class="num">3 : delay); bTest = class="kw">false; t1 = GetTickCount64();
「回放超时与收尾的底层处理」
这段逻辑卡在回放服务的计时分支上:当系统时间戳 GetTickCount64() 与上次触发时刻 t1 的差值超过预设 delay 时,才推进下一帧事件。若 Replay.Event_OnTime() 返回负值,说明回放数据源已耗尽,立即调用 AlertToUser() 向用户弹窗报警,并把进程间共享变量里的 isPlay 置为 false,终止可视化联动。
AlertToUser() 本身只做三件事:从全局变量 def_GlobalVariableReplay 读回联合结构、改写播放标志、用 MessageBox 提示“No more data to use in replay-simulation”。Finish() 则负责关闭回放接口并打印“The replay service completed...”,属于干净的退出路径。
定位滑块位置时用了 iPos = (int)(m_Ticks.nTicks * ((Info.s_Infos.iPosShift * 1.0) / (def_MaxPosSlider + 1))),意味着滑块最大偏移 def_MaxPosSlider 对应整段 tick 总数的线性映射;把 +1 漏掉会让末端帧永远够不到最后一根 tick。外汇与贵金属回放验证属高风险操作,实盘参数误用可能导致错判历史波动。
}
}else if ((GetTickCount64() - t1) >= (class="type">uint)(delay))
{
if ((delay = Replay.Event_OnTime()) < class="num">0) AlertToUser();
t1 = GetTickCount64();
}
}
}
Finish();
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void AlertToUser(class="type">void)
{
u_Interprocess Info;
Info.u_Value.df_Value = GlobalVariableGet(def_GlobalVariableReplay);
Info.s_Infos.isPlay = class="kw">false;
GlobalVariableSet(def_GlobalVariableReplay, Info.u_Value.df_Value);
MessageBox("No more data to use in replay-simulation", "Service Replay", MB_OK);
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void Finish(class="type">void)
{
Replay.CloseReplay();
Print("The replay service completed...");
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">int AdjustPositionReplay(class="kw">const class="type">bool bViewBuider)
{
class=class="str">"cmt">// ... Code ...
iPos = (class="type">int)(m_Ticks.nTicks * ((Info.s_Infos.iPosShift * class="num">1.0) / (def_MaxPosSlider + class="num">1)));
class=class="str">"cmt">// ... Rest of the code ...用等待信号替掉播放暂停按钮
回放系统允许用户监控柱线构建过程时,会暴露一个体验断层:进度看似随时停住,其实是回放服务还没抵达目标位置、系统被锁住。此时若仍显示“播放/暂停”按钮,交易者容易误以为卡死或能操作,实际任何点击都不会被响应。 解决办法不是在界面上堆文字,而是在服务忙时把按钮图像换成“请稍候”标识,且阻断按钮事件向服务发送请求。我们在 InterProcess.mqh 增加一个跨进程变量,优先级高于其它控件;服务忙时把它写入终端全局变量,控制指标读到后切图并忽略点击。 C_Replay 类里只在真正执行回放前发一次信号,之后无条件释放指标。控制指标侧用 C_Control.mqh 加一层抽象,内部私有变量 m_bWait 在构造时初始化为 false——因为服务启动总是空闲可响应的。拖动回放位置会触发按钮状态切换,我们用 DispatchMessage 里加一行 busy 检查,防止忙时生成播放/暂停事件。 图像切换不靠轮询。服务每往回放资源写一条新记录,MetaTrader 5 会触发 OnCalculate,指标借此读全局变量改 m_bWait,并调用 CreateBtnPlayPause 重绘成等待图或恢复正常按钮。自定义事件 Ev_WAIT_ON / Ev_WAIT_OFF 由 OnChartEvent 统一委派给 DispatchMessage 处理;触发时用 EventCustom 枚举值加 CHARTEVENT_CUSTOM 基数区分来源。 关键数据点:def_MaxPosSlider 设为 400,限定滑块最大位移;指标内应避免定时器,靠平台跳价调用 OnCalculate 即可,否则会拖累同图表其它指标。外汇与贵金属回放仅作历史重演,实盘波动具高风险,等待信号逻辑不改变任何成交概率。
class="macro">#class="kw">property copyright "Daniel Jose" class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#define def_GlobalVariableReplay "Replay Infos" class="macro">#define def_GlobalVariableIdGraphics "Replay ID" class="macro">#define def_SymbolReplay "RePlay" class="macro">#define def_MaxPosSlider class="num">400 class="macro">#define def_ShortName "Market Replay" class=class="str">"cmt">//+------------------------------------------------------------------+ union u_Interprocess { union u_0 { class="type">class="kw">double df_Value; class=class="str">"cmt">// The value of the terminal&class="macro">#x27;s global variable... class="type">class="kw">ulong IdGraphic; class=class="str">"cmt">// Contains the asset chart ID.... }u_Value; class="kw">struct st_0 { class="type">bool isPlay; class=class="str">"cmt">// Specifies if we are in the play or pause mode ...