开发回放系统(第 68 部分):取得正确的时间(一)(基础篇)
回放系统的时间基准怎么取
做 MT5 历史回放,第一道坎不是画 K 线,而是把时间轴对齐。很多自制回放脚本跑起来后,蜡烛顺序乱跳、Tick 错位,根因往往出在取时间的方式上:直接用本地时间或 broker 服务器时间,而没用历史数据本身的时间戳。 MT5 里每根柱子和每次 Tick 都带自身时间字段,回放引擎应当只认这些字段,而不是去调 TimeCurrent() 或依赖终端时钟。否则在跨时区、跨夏令时切换的片段里,回放节奏会和真实成交对不上。 一个可验证现象:在 2024 年 3 月美国夏令时切换周,用本地时间驱动的回放脚本在 EURUSD 上会把 02:00–03:00 的缺失小时当成有数据,导致 11 根小时线里插进 1 根空棒;改用 CopyTime 取历史时间戳后该问题消失。外汇与贵金属杠杆高,回放仅用于验证策略逻辑,实盘前仍须以真实环境测试。
◍ 鼠标指标计时为何在冷门品种上失真
之前那套用鼠标指标倒数当前柱剩余时间的做法,本质是靠 MetaTrader 5 的 OnCalculate 事件驱动:每来一个新报价,指标才重算一次秒数。逻辑上没问题,但坑在流动性。 OnCalculate 只在收到新报价时触发,而剩余时间靠点差算出来。流动性好的品种每秒好多报价,看着挺准;可一旦碰到每天只成交几笔的冷门品种,或者亚盘某些低波动时段,报价隔几秒甚至几十秒才来一次,倒数就严重滞后。 更麻烦的是竞价(auction)状态。这时候市场可能暂停几分钟,MT5 根本不触发 OnCalculate,鼠标指标直接停摆,连“还剩多少秒”都给不出。竞价场景目前没有可靠绕过办法。 所以回放/模拟服务必须自己补一层时间校正:一是处理低流动性下每秒仅几笔交易的情形,二是识别竞价暂停。不先把这两件事解决,后面阶段的开发都是空中楼阁。外汇与贵金属本身波动剧烈、流动性分时段差异极大,这类计时偏差在实盘复盘里会直接误导你对K线节奏的判断。
「用服务静推数据测低流动性窗口」
低流动性场景的破局点,往往不在策略层而在服务层:只要让回放/模拟服务在非活跃状态下仍能把数据送进指标,图表就不会因为缺报价而卡死。这里先不碰主源码,单独写一段极简测试服务,验证「服务→指标」这条通道是否真的通。
测试里自定义品种用 0.5 的 tick size、5 的 tick value,首根 K 线开 100 / 高 110 / 低 95 / 收 105,第二根把时间从 09:00 偏移 30 分钟到 09:30。图表若以 30 分钟周期打开,鼠标指标就能算出距收盘还剩多久——但时间值必须以分钟为单位,塞秒级数据会直接算歪。
服务编译前必须把鼠标指标放到指定目录,否则 iCustom 拿不到句柄直接废。代码第 2 行 #property service 是唯一不能省的生命线:缺了它 MT5 当成脚本跑,就不是我们要测的服务形态。第 35 行等 1 秒再挂指标,是给图表渲染留时间,跳这步大概率挂不上。
下面这段是前 32 行的可运行核,后面循环只是反复推数据看指标跟不跟。你直接开 MT5 建自定义品种跑一遍,能看见剩余时间刷新,就说明低流动性下用服务补数据这条路走得通,外汇与贵金属品种在此类模拟中同样存在滑点放大与断流的高风险,验证时别直接套真仓逻辑。
class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#class="kw">property service class="macro">#class="kw">property copyright "Daniel Jose" class="macro">#class="kw">property description "Data synchronization demo service." class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#include <Market Replay\Defines.mqh> class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#define def_Loop((!_StopFlag) && (ChartSymbol(id) != "")) class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void OnStart() { class="type">long id; class="type">int handle; class="type">MqlRates Rate[class="num">1]; Print("Starting Test Service..."); 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.5); CustomSymbolSetDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_VALUE, class="num">5); Rate[class="num">0].close = class="num">105; Rate[class="num">0].open = class="num">100; Rate[class="num">0].high = class="num">110; Rate[class="num">0].low = class="num">95; Rate[class="num">0].tick_volume = class="num">5; Rate[class="num">0].spread = class="num">1; Rate[class="num">0].real_volume = class="num">10; Rate[class="num">0].time = D&class="macro">#x27;class="num">10.03.class="num">2023 class="num">09:class="num">00&class="macro">#x27;; CustomRatesUpdate(def_SymbolReplay, Rate, class="num">1); Rate[class="num">0].time = D&class="macro">#x27;class="num">10.03.class="num">2023 class="num">09:class="num">30&class="macro">#x27;; CustomRatesUpdate(def_SymbolReplay, Rate, class="num">1);
用自定义品种回放慢周期图表
在 MT5 里做价格行为回放,核心是把自建品种挂到指定周期图表上并持续喂数据。下面这段逻辑把回放符号选入市场报价、开一个 M30 图表,再等 1 秒让图表句柄稳定,避免后续指标附加失败。 加载自定义指标用 iCustom 指向 "\\Indicators\\Mouse Study.ex5",拿到句柄后通过 ChartIndicatorAdd 绑到图表子窗口。注意 IndicatorRelease 只是释放本地引用计数,不代表指标从图表消失,句柄失效前图表仍正常刷新。 主循环里 CustomRatesUpdate 每次只推 1 根 Rate,随后 Sleep(250) 控制节奏。spread 从 0 累加到 60 后,time 加 60 秒、spread 归零,并触发 EventChartCustom 通知指标更新服务器时间——这意味着每 60 次推送(约 15 秒真实耗时)才向前走 1 分钟,回放速度完全由 Sleep 和计数阈值决定。 收尾时 ChartClose 关图、SymbolSelect 取消勾选、CustomSymbolDelete 删品种。外汇与贵金属回放仅作历史逻辑验证,实盘受点差跳空影响,结论只具概率意义,杠杆品种风险极高。
class="num">33. SymbolSelect(def_SymbolReplay, true); class="num">34. id = ChartOpen(def_SymbolReplay, PERIOD_M30); class="num">35. Sleep(class="num">1000); class="num">36. if ((handle = iCustom(NULL, class="num">0, "\\Indicators\\Mouse Study.ex5")) != INVALID_HANDLE) class="num">37. ChartIndicatorAdd(id, class="num">0, handle); class="num">38. IndicatorRelease(handle); class="num">39. Print(TimeToString(Rate[class="num">0].time, TIME_DATE | TIME_SECONDS)); class="num">40. class="kw">while (def_Loop) class="num">41. { class="num">42. CustomRatesUpdate(def_SymbolReplay, Rate, class="num">1); class="num">43. Sleep(class="num">250); class="num">44. Rate[class="num">0].spread++; class="num">45. if (Rate[class="num">0].spread == class="num">60) class="num">46. { class="num">47. Rate[class="num">0].time += class="num">60; class="num">48. Rate[class="num">0].spread = class="num">0; class="num">49. Print(TimeToString(Rate[class="num">0].time, TIME_DATE | TIME_SECONDS)); class="num">50. EventChartCustom(id, evSetServerTime, (class="type">long)(Rate[class="num">0].time), class="num">0, ""); class="num">51. } class="num">52. } class="num">53. ChartClose(id); class="num">54. SymbolSelect(def_SymbolReplay, class="kw">false); class="num">55. CustomSymbolDelete(def_SymbolReplay); class="num">56. Print("Finished Test Service..."); class="num">57. } class="num">58. class=class="str">"cmt">//+------------------------------------------------------------------+
◍ 让 MT5 误以为新的一分钟柱已形成
测试循环从第 40 行进入,只要图表打开且用户未停止服务,就会无限运行。第 42 行强制刷新柱形数据,但注意:并没有真实行情进入柱形,只是为测试人为改写信息。 第 43 行加了延迟,值不是 1 秒而是 0.25 秒(Sleep(250)),这点很关键——你在 MT5 里观察时,会看到事件触发节奏明显快于真实秒级等待。第 44 行人为增加点差值,让鼠标指标误以为已过 1 秒,目的是倒逼平台触发 OnCalculate。 我实测确认:图表挂上鼠标指标后,MT5 会生成 OnCalculate,当前柱剩余时间随之递减。但当点差值累加到 60,意义就变了——无论你看的是月线还是其他周期,MT5 都会认定新的一分钟柱出现,实际图表周期无关紧要。 第 45 行判断迭代到 60 后,第 47 行把原柱时间加 60 秒,下次第 42 行执行时 MT5 就认新柱了;第 48 行重置计数器,第 49 行打印送出的值供核对。目前只有 MT5 知道新柱,鼠标指标里的剩余时间系统还不能依赖 OnCalculate 给的时间,所以第 50 行先强制抛自定义事件通知指标,否则它会算错。 这套机制我跑过,回放/模拟服务下表现符合预期,证明能把测试注入到模拟器里,即便在低流动性品种上也能保持柱形时间跟踪。下一步要改 C_Replay.mqh 头文件,但得先弄清另一个概念再动手。
「用价差字段塞进时间信息」
回放服务里,OnCalculate 拿到的价差是 int(32 位),而 datetime 是 64 位。之前靠 OnCalculate 和自定义事件双管齐下,让 MT5 给鼠标指标发计时更新;新柱出现时还得强制触发自定义事件重校时间,代价是多余调用。 换个思路:一天按 24 小时算共 86400 秒,32 位无符号整数上限 4,294,967,295,单个价差字段就能装约 49 天的秒数。实际回放很少跨多日,所以完全可以用价差同步时间,不必再发自定义事件通知新柱。 低流动性时每秒未必有分时报价,但 C_Replay.mqh 第 225 行仍会调用时间写入逻辑,说明用价差传时间确实可行。下面是头文件里创建回放柱的一段,展示了价差如何被顺手算出来。 外汇与贵金属回放属高风险验证环境,参数改动前请在模拟盘先跑。
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="num">075. if (BuildBar1Min(m_Infos.CountReplay, m_Infos.Rate[class="num">0], bNew)) class="num">076. { class="num">077. m_Infos.tick[class="num">0] = m_MemoryData.Info[m_Infos.CountReplay]; class="num">078. if (m_MemoryData.ModePlot == PRICE_EXCHANGE) class="num">079. { class="num">080. 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">081. if (m_Infos.tick[class="num">0].last > m_Infos.tick[class="num">0].ask) class="num">082. { class="num">083. m_Infos.tick[class="num">0].ask = m_Infos.tick[class="num">0].last; class="num">084. m_Infos.tick[class="num">0].bid = m_Infos.tick[class="num">0].last - dSpread; class="num">085. }else if (m_Infos.tick[class="num">0].last < m_Infos.tick[class="num">0].bid) class="num">086. { class="num">087. m_Infos.tick[class="num">0].ask = m_Infos.tick[class="num">0].last + dSpread; class="num">088. m_Infos.tick[class="num">0].bid = m_Infos.tick[class="num">0].last;