开发回放系统(第 69 部分):取得正确的时间(二)·进阶篇
回放引擎怎么把历史报价喂给自定义品种
做市场回放的核心动作,是先把历史 M1 或分笔数据改写成自定义品种的 tick 与 rate,再用 CustomTicksAdd / CustomRatesUpdate 推给图表。下面这段逻辑在每次回放步进时重建最新 tick:若当前回放索引命中,就把 rate[0] 的 close 加上 spread 换算的点值作为 ask,time 直接取源时间,time_msc 用 time*1000 补毫秒精度。 若没命中,则直接拿已缓存的 Info[m_Infos.CountReplay] 作为 tick,调 CustomTicksAdd(def_SymbolReplay, m_Infos.tick) 写入。注意 time_msc 用 time 乘 1000 只是粗略对齐,真实环境毫秒来自 broker,回放里会丢失亚秒级顺序。 AdjustPositionToReplay 负责把滑块位置映射到历史索引:用 (m_IndControl.Position * m_MemoryData.nTicks) / def_MaxSlider 算出目标 nPos,再向前扫描 Rate 数组把 CountReplay 推到对应段。若 nCount>0 就 CustomRatesUpdate 更新前 nCount-1 根 K;若还差,则循环 CreateBarInReplay(false) 补 bar。 构造器 C_Replay 里先打印标识、srand 播种,然后 SymbolSelect 关掉旧自定义品种、CustomSymbolDelete 删干净,再 CustomSymbolCreate 建 "Custom\\<名>" 路径,最后 CustomSymbolSetDouble 把 SYMBOL_TRADE_TICK_SIZE 设 0——这一步很关键,否则自定义品种最小变动价不对,回测成交会偏移。外汇与贵金属回放属高风险验证,结果仅代表历史重演概率,不等于实盘表现。
m_Infos.tick[class="num">0].ask = rate[class="num">0].close + (rate[class="num">0].spread * m_Infos.PointsPerTick); } m_Infos.tick[class="num">0].time = rate[class="num">0].time; m_Infos.tick[class="num">0].time_msc = rate[class="num">0].time * class="num">1000; }else m_Infos.tick[class="num">0] = GetInfoTicks().Info[m_Infos.CountReplay]; CustomTicksAdd(def_SymbolReplay, m_Infos.tick); } class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void AdjustPositionToReplay(class="type">void) { class="type">int nPos, nCount; if (m_IndControl.Position == (class="type">int)((m_Infos.CountReplay * def_MaxSlider) / m_MemoryData.nTicks)) class="kw">return; nPos = (class="type">int)((m_MemoryData.nTicks * m_IndControl.Position) / def_MaxSlider); for (nCount = class="num">0; m_MemoryData.Rate[nCount].spread < nPos; m_Infos.CountReplay = m_MemoryData.Rate[nCount++].spread); if (nCount > class="num">0) CustomRatesUpdate(def_SymbolReplay, m_MemoryData.Rate, nCount - class="num">1); class="kw">while ((nPos > m_Infos.CountReplay) && def_CheckLoopService) CreateBarInReplay(class="kw">false); } 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);
◍ 回放品种的初始化与析构清理
在 MT5 里自建一个用于行情回放(replay)的自定义品种时,构造函数里要把关键属性一次性设齐。下面这段把 tick 价值、手数步长置 0,描述写成仿真用途,报价精度给到 8 位,最后用 SymbolSelect 把它挂到市场观察列表,并把回放计数、指标句柄、控制状态等运行时变量清零。 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 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 释放指标句柄,SymbolSelect 设为 false 移出报价窗,CustomSymbolDelete 直接删掉这个自定义品种,并打印 "Finished replay service..."。外汇与贵金属回测涉及高杠杆高风险,自定义品种仅用于本地仿真,不代表实盘成交条件。 OpenChartReplay 在开图前做了三道闸门:SYMBOL_TRADE_TICK_SIZE、SYMBOL_TRADE_TICK_VALUE、SYMBOL_VOLUME_STEP 任一为 0 就报错返回,提示资产配置不完整。只有三项非零,才 SweepAndCloseChart 再 ChartOpen(def_SymbolReplay, arg1) 用指定周期开新图——这意味着你若在构造后漏设 tick size,图表根本开不出来。
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); 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">//+------------------------------------------------------------------+ 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);
「回放图表加载模板与控件初始化的坑」
在 MT5 回放(Replay)图表上套用模板时,若 ChartApplyTemplate 对指定 .tpl 返回失败,代码会打印失败日志并退而使用 default.tpl,成功则打印应用确认。这一分支判断直接决定后续指标加载的视觉基线,建议手动在终端确认模板文件确实存在于 Profiles\Templates 目录,否则回放界面可能丢失关键画线。 InitBaseControl 的等待逻辑值得细看:先 Sleep(wait) 默认 1000 毫秒,再用 while 循环以 200 毫秒为步长轮询 ChartIndicatorGet 是否拿到名为 "Indicator Mouse Study" 的句柄,直到 def_CheckLoopService 为假或句柄有效。实测若鼠标指标未编译进终端,这个循环会卡到服务标志关闭才退出。 控件指标通过 iCustom 挂在回放图表的当前品种与周期上,路径前缀 "::" 指向根目录指标,若返回 INVALID_HANDLE 直接返回 false 终止初始化。随后 ChartIndicatorAdd 把控制指标贴到子窗口 0,并调用 UpdateIndicatorControl 同步状态。 LoopEventOnTime 里则是另一个 200 毫秒轮询:只要控制模式不是 ePlay 就持续 UpdateIndicatorControl 并休眠。也就是说,回放真正开始跑之前,CPU 就在这两层 Sleep 里空转,调小等待参数可能让初始化更快但增加指标未就绪风险。
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=class="str">"cmt">//+------------------------------------------------------------------+ class="type">bool InitBaseControl(class="kw">const class="type">class="kw">ushort wait = class="num">1000) { Print("Waiting for Mouse Indicator..."); Sleep(wait); class="kw">while ((def_CheckLoopService) && (ChartIndicatorGet(m_Infos.IdReplay, class="num">0, "Indicator Mouse Study") == INVALID_HANDLE)) Sleep(class="num">200); if (def_CheckLoopService) { AdjustViewDetails(); Print("Waiting for Control Indicator..."); if ((m_IndControl.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, m_IndControl.Handle); UpdateIndicatorControl(); } class="kw">return def_CheckLoopService; } class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">bool LoopEventOnTime(class="type">void) { class="type">int iPos; class="kw">while ((def_CheckLoopService) && (m_IndControl.Mode != C_Controls::ePlay)) { UpdateIndicatorControl(); Sleep(class="num">200); }
回放循环里的节流与进度同步
这段回放主循环靠两层 while 控制 tick 推进节奏。外层判断剩余 tick 数是否小于总量且服务标志有效,内层每累计超过 200 毫秒就 Sleep(195) 并回退计数器,避免 CPU 被单个品种回放打满。 第 228 行用相邻两 tick 的 time_msc 差值累加 iPos,若已到末 tick 则补 0;CreateBarInReplay(true) 负责按当前计数合成 K 线。 进度条不是每 tick 刷,而是 Sleep 分支里按 m_Infos.CountReplay * def_MaxSlider / nTicks 算 ushort 位置再 UpdateIndicatorControl(),所以滑杆更新频率被锁在约 200ms 一次。外汇与贵金属回测属高风险验证,结果仅代表历史数据重演,不预示实盘概率。 遇到 ePause 模式直接 return true 跳出,末行以 CountReplay 等于 nTicks 且服务有效作为整段回放结束的判定条件。
class="num">221. m_MemoryData = GetInfoTicks(); class="num">222. AdjustPositionToReplay(); class="num">223. EventChartCustom(m_Infos.IdReplay, evSetServerTime, (class="type">long)macroRemoveSec(m_MemoryData.Info[m_Infos.CountReplay].time), class="num">0, ""); class="num">224. iPos = class="num">0; class="num">225. class="kw">while ((m_Infos.CountReplay < m_MemoryData.nTicks) && (def_CheckLoopService)) class="num">226. { class="num">227. if (m_IndControl.Mode == C_Controls::ePause) class="kw">return true; class="num">228. 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); class="num">229. CreateBarInReplay(true); class="num">230. class="kw">while ((iPos > class="num">200) && (def_CheckLoopService)) class="num">231. { class="num">232. Sleep(class="num">195); class="num">233. iPos -= class="num">200; class="num">234. m_IndControl.Position = (class="type">class="kw">ushort)((m_Infos.CountReplay * def_MaxSlider) / m_MemoryData.nTicks); class="num">235. UpdateIndicatorControl(); class="num">236. } class="num">237. } class="num">238. class="num">239. class="kw">return ((m_Infos.CountReplay == m_MemoryData.nTicks) && (def_CheckLoopService)); class="num">240. } class="num">241. }; class="num">242. class=class="str">"cmt">//+------------------------------------------------------------------+ class="num">243. class="macro">#undef def_SymbolReplay class="num">244. class="macro">#undef def_CheckLoopService class="num">245. class="macro">#undef def_MaxSlider class="num">246. class=class="str">"cmt">//+------------------------------------------------------------------+
◍ 为什么回放系统绕不开 iSpread
在 MT5 里,自定义资产通过柱形点差字段回传数据时会碰到一种怪现象:用 OnCalculate 直接拿到的点差数组值,在非 M1 周期下可能冻结。实测两分钟周期,把鼠标指标改成读取 OnCalculate 参数里的点差,动画里能看到数值卡住不动,直到新柱生成才刷新。 为了定位原因,我们在 C_Replay.mqh 第 96 行加了一行打印,确认写入柱形点差字段的其实就是图表时间值,与分时图一致。接着在 C_Study.mqh 第 125 行删掉 dt 设置,让指标显示下一根柱预计出现的绝对时间,两分钟图上计算准确,系统行为符合预期。 但一旦脱离 iSpread、改用 OnCalculate 传参的点差,指标就冻住了。我没法解释根因,怀疑是 MT5 底层在非 M1 周期对点差数组的更新机制有延迟。外汇和贵金属杠杆高、流动性突变频繁,这种冻结在实盘回放中可能误导剩余时间判断。 当前解法是用 iSpread 函数主动拉取,避免依赖参数数组。等 MT5 修复后我们会切回直接读参。下面这段是回放头文件里造柱时的点差写入逻辑,注意第 81 行用 rand 模拟点差跳动,第 82–85 行做 ask/bid 校正。
class="num">068. class=class="str">"cmt">//+------------------------------------------------------------------+ class="num">069. class="kw">inline class="type">void CreateBarInReplay(class="type">bool bViewTick) class="num">070. { class="num">071. class="type">bool bNew; class="num">072. class="type">class="kw">double dSpread; class="num">073. class="type">int iRand = rand(); class="num">074. class="kw">static class="type">int st_Spread = class="num">0; class="num">075. class="num">076. if (BuildBar1Min(m_Infos.CountReplay, m_Infos.Rate[class="num">0], bNew)) class="num">077. { class="num">078. m_Infos.tick[class="num">0] = m_MemoryData.Info[m_Infos.CountReplay]; class="num">079. if (m_MemoryData.ModePlot == PRICE_EXCHANGE) class="num">080. { class="num">081. dSpread = m_Infos.PointsPerTick + ((iRand > class="num">29080) && (iRand < class="num">32767) ? ((iRand & class="num">1) == class="num">1 ? m_Infos.PointsPerTick : class="num">0 ) : class="num">0 ); class="num">082. if (m_Infos.tick[class="num">0].last > m_Infos.tick[class="num">0].ask) class="num">083. { class="num">084. m_Infos.tick[class="num">0].ask = m_Infos.tick[class="num">0].last; class="num">085. m_Infos.tick[class="num">0].bid = m_Infos.tick[class="num">0].last - dSpread;
「回放tick落地与状态机收口」
这段逻辑处在回放引擎的末端:当最新成交价低于当前买价时,把 ask 重算为 last 加预设点差 dSpread,bid 直接取 last,保证重放报价不会出交叉。086 到 090 行的 else if 分支只处理这一种非对称情况,正常上行行情走的是前一段逻辑。
- 到 093 行在 bViewTick 为真时调用 CustomTicksAdd 把构造好的 tick 数组写进 def_SymbolReplay 这个自定义品种,MT5 市场报价窗口里就能看到逐笔重放。094 行把当前回放条目的时间经 macroGetTime 转成 int 存进 st_Spread,096 行 Print 出来方便你核对每秒推进是否连续。
- 到 099 行用 CustomRatesUpdate 刷新 K 线缓冲,并把 CountReplay 自增,整个回放帧就此闭合。外汇与贵金属自定义品种回放存在点差跳变风险,实盘映射前应在策略测试器跑通。
- 行起的 Update 用 switch 接管状态:eCloseMarket 只改 szInfo 为 Closed Market,eInReplay 与 eInTrading 共用 122 到 124 行,按 PeriodSeconds 把 dt 对齐到下一根 K 线开盘时间。123 行区分回放用 GL_TimeAdjust 还是实盘用 TimeCurrent,这一处若 GL_TimeAdjust 未初始化,重放节奏会直接偏移。
}
else if (m_Infos.tick[class="num">0].last < m_Infos.tick[class="num">0].bid)
{
m_Infos.tick[class="num">0].ask = m_Infos.tick[class="num">0].last + dSpread;
m_Infos.tick[class="num">0].bid = m_Infos.tick[class="num">0].last;
}
}
if (bViewTick)
CustomTicksAdd(def_SymbolReplay, m_Infos.tick);
st_Spread = (class="type">int)macroGetTime(m_MemoryData.Info[m_Infos.CountReplay].time);
}
Print(TimeToString(st_Spread, TIME_SECONDS));
m_Infos.Rate[class="num">0].spread = (class="type">int)(def_MaskTimeService | st_Spread);
CustomRatesUpdate(def_SymbolReplay, m_Infos.Rate);
m_Infos.CountReplay++;
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void Update(class="kw">const eStatusMarket arg)
{
class="type">int i0;
class="type">class="kw">datetime dt;
class="kw">switch (m_Info.Status = (m_Info.Status != arg ? arg : m_Info.Status))
{
case eCloseMarket :
m_Info.szInfo = "Closed Market";
class="kw">break;
case eInReplay :
case eInTrading :
i0 = PeriodSeconds();
dt = (m_Info.Status == eInReplay ? (class="type">class="kw">datetime) GL_TimeAdjust : TimeCurrent());
m_Info.Rate.time = (m_Info.Rate.time <= dt ? (class="type">class="kw">datetime)(((class="type">class="kw">ulong) dt / i0) * i0) + i0 : m_Info.Rate.time);