开发回放系统(第 73 部分):不寻常的通信(二)·综合运用
回放引擎里的报价合成与滑块定位
这段逻辑干了两件事:把历史分笔重建成可交易的 bid/ask 流,再让回放进度跟滑块位置对齐。外汇与贵金属回测中滑点和高杠杆属高风险,以下机制只解决数据重建,不保证任何策略收益。 当非实时分支触发时,第163–164行直接用当前 bar 的 close 当 bid,ask 则在 close 上加 spread 乘 PointsPerTick。这样合成出来的点差在可视化回放里是固定结构,和真实毫秒级跳动不同,验证时别把它当真实盘口。 第170行 CustomTicksAdd 把 m_Infos.tick 写进 def_SymbolReplay 的自定义 tick 序列;若走 else 分支(第169行)则直接取已缓存的 Info 数组元素,省去重算。 AdjustPositionToReplay 里用 def_MaxSlider 做归一化:nPos = nTicks * Position / def_MaxSlider(第178行)。循环到第179行靠 spread 字段累进找到对应 CountReplay,再用 CustomRatesUpdate 只刷前 nCount-1 根。若 nPos 还超前,第181–182行用 while 持续 CreateBarInReplay(false) 补齐 bar。 WaitIndicatorLoad 是阻塞等待:每100毫秒轮询 ChartIndicatorGet 是否返回有效句柄(第188–190行),最多卡到服务停止标志位为真。实盘加载慢时这段代码会拖住回放线程,建议开 MT5 把 Sleep(100) 调到 250 观察 Indicator 挂载时序。
class="num">161. else class="num">162. { class="num">163. m_Infos.tick[class="num">0].bid = rate[class="num">0].close; class="num">164. m_Infos.tick[class="num">0].ask = rate[class="num">0].close + (rate[class="num">0].spread * m_Infos.PointsPerTick); class="num">165. } class="num">166. m_Infos.tick[class="num">0].time = rate[class="num">0].time; class="num">167. m_Infos.tick[class="num">0].time_msc = rate[class="num">0].time * class="num">1000; class="num">168. }else class="num">169. m_Infos.tick[class="num">0] = GetInfoTicks().Info[m_Infos.CountReplay]; class="num">170. CustomTicksAdd(def_SymbolReplay, m_Infos.tick); class="num">171. } class="num">172. class=class="str">"cmt">//+------------------------------------------------------------------+ class="num">173. class="type">void AdjustPositionToReplay(class="type">void) class="num">174. { class="num">175. class="type">int nPos, nCount; class="num">176. class="num">177. if (m_IndControl.Position == (class="type">int)((m_Infos.CountReplay * def_MaxSlider) / m_MemoryData.nTicks)) class="kw">return; class="num">178. nPos = (class="type">int)((m_MemoryData.nTicks * m_IndControl.Position) / def_MaxSlider); class="num">179. for (nCount = class="num">0; m_MemoryData.Rate[nCount].spread < nPos; m_Infos.CountReplay = m_MemoryData.Rate[nCount++].spread); class="num">180. if (nCount > class="num">0) CustomRatesUpdate(def_SymbolReplay, m_MemoryData.Rate, nCount - class="num">1); class="num">181. class="kw">while ((nPos > m_Infos.CountReplay) && def_CheckLoopService) class="num">182. CreateBarInReplay(class="kw">false); class="num">183. } class="num">184. class=class="str">"cmt">//+------------------------------------------------------------------+ class="num">185. class="type">void WaitIndicatorLoad(class="kw">const class="type">class="kw">string szArg, class="kw">const class="type">bool ViewCtrl = true) class="num">186. { class="num">187. Print("Waiting for ", szArg); class="num">188. class="kw">while ((def_CheckLoopService) && (ChartIndicatorGet(m_Infos.IdReplay, class="num">0, szArg) == INVALID_HANDLE)) class="num">189. { class="num">190. if (ViewCtrl) CheckIndicatorControl(); class="num">191. Sleep(class="num">100);
「回放服务的构造与析构里做了什么」
在 MT5 里做行情回放,核心是先造一个自定义符号当沙盒。C_Replay 的构造函数一进来就先打印服务横幅,再用 GetTickCount 做随机种子,避免每次回放的初始状态可预测。 接着它把同名旧符号从市场报价窗移除并彻底删除,然后以 Custom\ 前缀重建 def_SymbolReplay。这里把 TICK_SIZE、TICK_VALUE、VOLUME_STEP 全设成 0,意味着这个符号不参与真实报价计算,只承载回放数据。 符号精度被钉死在 8 位小数(SYMBOL_DIGITS=8),深度簿只留 1 层(TICKS_BOOKDEPTH=1),描述写成「Symbol for replay / simulation」。最后重新选入报价窗,并把回放计数、控制句柄、播放模式(初始 ePause)和位置指针清零。 析构时反向操作:先 SweepAndCloseChart 清掉图表,释放指标句柄,再把符号移出报价窗并删除,打印「Finished replay service...」收尾。外汇与贵金属回放属高仿真空环境,和实盘滑点、点差有本质差异,结论仅供策略验证参考。 [CODE] 逐行拆解: 192-193 } 闭合上层代码块
- //+--- 分隔注释
- public: 公开成员区开始
- //+--- 分隔注释
- C_Replay() 构造函数
- :C_ConfigService() 继承基类初始化
- { 函数体开始
- Print("**** Market Replay Service ****"); 打印服务启动横幅
- srand(GetTickCount()); 用系统节拍数播随机种子
- SymbolSelect(def_SymbolReplay, false); 从报价窗取消旧符号
- CustomSymbolDelete(def_SymbolReplay); 删除旧自定义符号
- CustomSymbolCreate(def_SymbolReplay, StringFormat("Custom\%s", def_SymbolReplay)); 建新符号于 Custom 组
- CustomSymbolSetDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_SIZE, 0); tick 尺寸设 0
- CustomSymbolSetDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_VALUE, 0); tick 价值设 0
- CustomSymbolSetDouble(def_SymbolReplay, SYMBOL_VOLUME_STEP, 0); 交易量步长设 0
- CustomSymbolSetString(def_SymbolReplay, SYMBOL_DESCRIPTION, "Symbol for replay / simulation"); 写描述
- CustomSymbolSetInteger(def_SymbolReplay, SYMBOL_DIGITS, 8); 精度 8 位
- CustomSymbolSetInteger(def_SymbolReplay, SYMBOL_TICKS_BOOKDEPTH, 1); 深度簿 1 层
- SymbolSelect(def_SymbolReplay, true); 重新选入报价窗
- m_Infos.CountReplay = 0; 回放计数清零
- m_IndControl.Handle = INVALID_HANDLE; 指标句柄置无效
- m_IndControl.Mode = C_Controls::ePause; 初始暂停模式
- m_IndControl.Position = 0; 位置指针归零
- m_IndControl.Memory._16b[C_Controls::eCtrlPosition] = C_Controls::eTriState; 控制位设三态
- } 构造函数结束
- //+--- 分隔注释
- ~C_Replay() 析构函数
- { 函数体开始
- SweepAndCloseChart(); 清图表
- IndicatorRelease(m_IndControl.Handle); 释放指标
- SymbolSelect(def_SymbolReplay, false); 移出报价窗
- CustomSymbolDelete(def_SymbolReplay); 删符号
- Print("Finished replay service..."); 打印结束
- } 析构结束
- //+--- 分隔注释
}
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">public :
class=class="str">"cmt">//+------------------------------------------------------------------+
C_Replay()
:C_ConfigService()
{
Print("************** Market Replay Service **************");
srand(GetTickCount());
SymbolSelect(def_SymbolReplay, class="kw">false);
CustomSymbolDelete(def_SymbolReplay);
CustomSymbolCreate(def_SymbolReplay, StringFormat("Custom\%s", def_SymbolReplay));
CustomSymbolSetDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_SIZE, class="num">0);
CustomSymbolSetDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_VALUE, class="num">0);
CustomSymbolSetDouble(def_SymbolReplay, SYMBOL_VOLUME_STEP, class="num">0);
CustomSymbolSetString(def_SymbolReplay, SYMBOL_DESCRIPTION, "Symbol for replay / simulation");
CustomSymbolSetInteger(def_SymbolReplay, SYMBOL_DIGITS, class="num">8);
CustomSymbolSetInteger(def_SymbolReplay, SYMBOL_TICKS_BOOKDEPTH, class="num">1);
SymbolSelect(def_SymbolReplay, true);
m_Infos.CountReplay = class="num">0;
m_IndControl.Handle = INVALID_HANDLE;
m_IndControl.Mode = C_Controls::ePause;
m_IndControl.Position = class="num">0;
m_IndControl.Memory._16b[C_Controls::eCtrlPosition] = C_Controls::eTriState;
}
class=class="str">"cmt">//+------------------------------------------------------------------+
~C_Replay()
{
SweepAndCloseChart();
IndicatorRelease(m_IndControl.Handle);
SymbolSelect(def_SymbolReplay, class="kw">false);
CustomSymbolDelete(def_SymbolReplay);
Print("Finished replay service...");
}
class=class="str">"cmt">//+------------------------------------------------------------------+◍ 回放图表初始化时的三道资产校验
在 MT5 里做历史行情回放,开图前如果不把回放品种的合约参数填全,后面加载控制指标会直接失效。下面这段逻辑在 OpenChartReplay 里先卡了三道关:tick size、tick value、volume step 任一为 0 就报错退出,分别对应「未声明跳价点数」「未声明每跳价值」「未声明最小交易量」。
class="type">bool OpenChartReplay(class="kw">const ENUM_TIMEFRAMES arg1, class="kw">const class="type">class="kw">string szNameTemplate) { if (SymbolInfoDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_SIZE) == class="num">0) class="kw">return MsgError("Asset configuration is not complete, it remains to declare the size of the ticket."); if (SymbolInfoDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_VALUE) == class="num">0) class="kw">return MsgError("Asset configuration is not complete, need to declare the ticket value."); if (SymbolInfoDouble(def_SymbolReplay, SYMBOL_VOLUME_STEP) == class="num">0) class="kw">return MsgError("Asset configuration not complete, need to declare the minimum volume."); SweepAndCloseChart(); m_Infos.IdReplay = ChartOpen(def_SymbolReplay, arg1); if (!ChartApplyTemplate(m_Infos.IdReplay, szNameTemplate + ".tpl")) Print("Failed apply class="kw">template: ", szNameTemplate, ".tpl Using class="kw">template class="kw">default.tpl"); else Print("Apply class="kw">template: ", szNameTemplate, ".tpl"); class="kw">return true; }
class="type">bool InitBaseControl(class="kw">const class="type">class="kw">ushort wait = class="num">1000) { class="type">int handle; Sleep(wait); AdjustViewDetails(); Print("Loading Control Indicator..."); if ((handle = iCustom(ChartSymbol(m_Infos.IdReplay), ChartPeriod(m_Infos.IdReplay), "::" + def_IndicatorControl, m_Infos.IdReplay)) == INVALID_HANDLE) class="kw">return class="kw">false; ChartIndicatorAdd(m_Infos.IdReplay, class="num">0, handle); IndicatorRelease(handle); WaitIndicatorLoad("Market Replay Control", class="kw">false);
class="type">bool OpenChartReplay(class="kw">const ENUM_TIMEFRAMES arg1, class="kw">const class="type">class="kw">string szNameTemplate) { if (SymbolInfoDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_SIZE) == class="num">0) class="kw">return MsgError("Asset configuration is not complete, it remains to declare the size of the ticket."); if (SymbolInfoDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_VALUE) == class="num">0) class="kw">return MsgError("Asset configuration is not complete, need to declare the ticket value."); if (SymbolInfoDouble(def_SymbolReplay, SYMBOL_VOLUME_STEP) == class="num">0) class="kw">return MsgError("Asset configuration not complete, need to declare the minimum volume."); SweepAndCloseChart(); m_Infos.IdReplay = ChartOpen(def_SymbolReplay, arg1); if (!ChartApplyTemplate(m_Infos.IdReplay, szNameTemplate + ".tpl")) Print("Failed apply class="kw">template: ", szNameTemplate, ".tpl Using class="kw">template class="kw">default.tpl"); else Print("Apply class="kw">template: ", szNameTemplate, ".tpl"); class="kw">return true; } class="type">bool InitBaseControl(class="kw">const class="type">class="kw">ushort wait = class="num">1000) { class="type">int handle; Sleep(wait); AdjustViewDetails(); Print("Loading Control Indicator..."); if ((handle = iCustom(ChartSymbol(m_Infos.IdReplay), ChartPeriod(m_Infos.IdReplay), "::" + def_IndicatorControl, m_Infos.IdReplay)) == INVALID_HANDLE) class="kw">return class="kw">false; ChartIndicatorAdd(m_Infos.IdReplay, class="num">0, handle); IndicatorRelease(handle); WaitIndicatorLoad("Market Replay Control", class="kw">false);
回放循环里的盘口与节拍控制
这段逻辑跑在 EA 的回放服务线程里,核心是把历史 tick 按原始时间间隔推送给自定义品种。LoopEventOnTime 先用 CustomBookAdd 往 def_SymbolReplay 塞一条 price=1.0、volume=1 的市价买单,触发一次自定义事件让指标刷新。 在正式回放前有个阻塞等待:while 循环里每 200 毫秒调用一次 UpdateIndicatorControl 和 CheckIndicatorControl,直到控制面板的 Mode 变成 ePlay 才往下走。这里 Sleep(200) 是硬节拍,改小了 CPU 占用会往上飘,改大了回放启动延迟明显。 正式循环依据 m_Infos.CountReplay 与总 tick 数 m_MemoryData.nTicks 比较推进。iPos 累加相邻 tick 的 time_msc 差,当累计超过 60000 毫秒就把盘口类型从 BOOK_TYPE_BUY 切到 BOOK_TYPE_BUY_MARKET——也就是说,静止超过 1 分钟的档口会被当成市价单注入,用来模拟流动性断层。外汇与贵金属回测中这类合成盘口存在滑点失真风险,结论仅限 MT5 本地验证。
class="type">bool LoopEventOnTime(class="type">void) { class="type">int iPos, iCycles; MqlBookInfo book[class="num">1]; ENUM_BOOK_TYPE typeMsg, memBook; book[class="num">0].price = class="num">1.0; book[class="num">0].volume = class="num">1; book[class="num">0].type = BOOK_TYPE_BUY_MARKET; CustomBookAdd(def_SymbolReplay, book, class="num">1); SendEventCustom(); class="kw">while ((def_CheckLoopService) && (m_IndControl.Mode != C_Controls::ePlay)) { UpdateIndicatorControl(); CheckIndicatorControl(); Sleep(class="num">200); } m_MemoryData = GetInfoTicks(); AdjustPositionToReplay(); iPos = iCycles = class="num">0; SendEventCustom(memBook = BOOK_TYPE_BUY); book[class="num">0].type = BOOK_TYPE_BUY; CustomBookAdd(def_SymbolReplay, book, class="num">1); class="kw">while ((m_Infos.CountReplay < m_MemoryData.nTicks) && (def_CheckLoopService)) { if (m_IndControl.Mode == C_Controls::ePause) class="kw">return true; iPos += (class="type">int)(m_Infos.CountReplay < (m_MemoryData.nTicks - class="num">1) ? m_MemoryData.Info[m_Infos.CountReplay + class="num">1].time_msc - m_MemoryData.Info[m_Infos.CountReplay].time_msc : class="num">0); if ((typeMsg = (iPos >= class="num">60000 ? BOOK_TYPE_BUY_MARKET : BOOK_TYPE_BUY)) != book[class="num">0].type)
「回放引擎里的逐笔撮合与限速」
这段逻辑干的事,是把内存里录好的 tick 流按位置往回放,并在回放过程中给深度簿和 K 线喂数据。iPos 超过 60000 时把挂单标记成 BOOK_TYPE_BUY_MARKET,否则归为普通 BOOK_TYPE_BUY,避免回放时把市价单误判成限价单。 每次类型切换会发一个自定义事件,再把单条 book 记录写进 def_SymbolReplay 对应的虚拟深度簿,随后调用 CreateBarInReplay(true) 强制刷一根 Bar。这里只推一条 book[0],说明回放只重建最优档,不还原全档口。 主循环用 while((iPos>200) && def_CheckLoopService && 未暂停) 做节流:每轮 Sleep(195) 毫秒、iPos 减 200,并把进度条位置按 CountReplay/总tick 映射。iCycles 每 4 轮才 RateUpdate(false) 一次,相当于约每 780 毫秒更新一次图表刷新率,防止 MT5 主线程被回放拖死。 结尾返回 (CountReplay==nTicks) && def_CheckLoopService,意味着只有把录下的 tick 全部播完且服务标志仍为真,才认为本次回放完整结束。外汇与贵金属回放仅用于历史验证,实盘高风险,结论只代表历史行为倾向。
class="num">293. if ((typeMsg = (iPos >= class="num">60000 ? BOOK_TYPE_BUY_MARKET : BOOK_TYPE_BUY)) != memBook) class="num">294. SendEventCustom(memBook = typeMsg); class="num">295. { class="num">296. book[class="num">0].type = typeMsg; class="num">297. CustomBookAdd(def_SymbolReplay, book, class="num">1); class="num">298. } class="num">299. CreateBarInReplay(true); class="num">300. class="kw">while ((iPos > class="num">200) && (def_CheckLoopService) && (m_IndControl.Mode != C_Controls::ePause)) class="num">301. { class="num">302. Sleep(class="num">195); class="num">303. iPos -= class="num">200; class="num">304. m_IndControl.Position = (class="type">class="kw">ushort)((m_Infos.CountReplay * def_MaxSlider) / m_MemoryData.nTicks); class="num">305. UpdateIndicatorControl(); class="num">306. CheckIndicatorControl(); class="num">307. iCycles = (iCycles == class="num">4 ? RateUpdate(class="kw">false) : iCycles + class="num">1); class="num">308. } class="num">309. } class="num">310. class="num">311. class="kw">return ((m_Infos.CountReplay == m_MemoryData.nTicks) && (def_CheckLoopService)); class="num">312. } class="num">313. }; class="num">314. class=class="str">"cmt">//+------------------------------------------------------------------+ class="num">315. class="macro">#undef def_SymbolReplay class="num">316. class="macro">#undef def_CheckLoopService class="num">317. class="macro">#undef def_MaxSlider class="num">318. class=class="str">"cmt">//+------------------------------------------------------------------+
◍ 从假设到迭代才是真系统
前面几篇里我们把编程语言往极限推,不是为了炫技。核心动作其实就两个:先形成一个可检验的假设,再用最小原型去撞墙。很多人看到第一次实现失败就默认此路不通,但那次服务读取时间框架的尝试只是返回了指标首次附加时的旧值,并非架构本身无效。 真正拉开差距的是第二次设计——不在初始化时抓死句柄,而是每次调用动态重新检索。这一改,指标缓冲区里的实时更新数据就能被服务层读到,时间范围变化的检测从「图上可见」变成「后台可捕获」。外汇与贵金属这类高波动品种,这种底层通道若断了,复盘与模拟的信号链会直接缺环。 附件里的 Versxo_Demo.zip(247.04 KB)就是回放/模拟器可执行文件,开 MT5 加载后你能直接验证上面说的句柄动态获取逻辑。下一篇会往模拟器里塞新功能,但这一篇落点很明确:第一次试错不丢人,丢人的是没在核心假设下重画架构。