开发回放系统(第 69 部分):取得正确的时间(二)(基础篇)
回放系统里时间基准的坑
做 MT5 历史回放工具时,最容易被忽略的是时间基准不一致:本地时区、经纪商服务器时区、MQL5 内部 TimeCurrent() 返回的都是不同时区下的秒数。若直接拿本地时间做帧同步,回放进度会和 K 线对不上。 MQL5 里 TimeCurrent() 返回的是经纪商服务器时间(UTC+0 或经纪商设定偏移),而回放系统若依赖 Windows 本地时钟 GetTickCount(),两者在跨时区或夏令时切换时会差出数小时。实测在 UTC+8 终端上跑欧盘数据,未校正时回放偏移可达 28800 秒。 正确做法是统一以 TimeCurrent() 为唯一时间轴,所有回放帧用服务器时间换算,本地显示时再单独做时区偏移,别让显示层污染逻辑层。
◍ 低流动性下柱形剩余时间的传递逻辑
在资产流动性差的时段,MT5 常规图表可能不会触发 OnCalculate 事件,导致鼠标指标拿不到实时秒数,剩余时间计算直接失真。 上一篇文章里已经把鼠标指标侧的取数代码铺好了,但单看指标没意义——必须配合回放/模拟器服务把正确时间值喂过来,两侧才算闭环。 本阶段重心转到回放服务,具体落在 C_Replay.mqh 这个文件。接下来要改和要加的代码都围绕它展开,先把它里层的时间派发机制理清,才能谈指标侧怎么算剩余秒数。 外汇与贵金属市场高杠杆、低流动性时段滑点概率偏高,任何时间推算都只是辅助,实盘前请在 MT5 策略测试器用历史数据回放验证。
「回放服务里 C_Replay 的改动逻辑」
改动 C_Replay.mqh 不为炫技,核心是为 OnCalculate 里用 iSpread 而非直接读数组这件事铺路。文件负责往图表喂回放数据,删的行不多,但牵一发动全身。 第 74 行新加的变量只干一件事:累计流动性极低时的秒数。眼下没被调用,但搞懂它才能明白后面位掩码怎么接。 看执行节奏:原自定义事件在第 223 行被删,循环从第 225 行起每次迭代调 CreateBarInReplay(第 229 行)。由于第 232 行与循环开销,这个函数约每 195 毫秒跑一次,无延迟前提下每秒约 5 次。记住这个数——流动性差时回放服务就是这么蠕动的。 流动性足(每秒至少 5 次调用)时,第 76 行条件为真,执行 77–100 行。第 95 行原用来触发新一分钟柱的自定义事件,已删;第 97 行新插入变量初始化。关键在第 98、99 行被挪到 101、102 行无条件执行,且第 101 行改用位掩码 OR 操作,让鼠标指标认出点差来自回放服务。 位掩码有个坑:若 st_Spread 值侵入掩码保留位,指标会误读。正常不会,因为回放服务设计给日内分析用;仅当触及近 12 天(秒级)的绝对时限才可能越界,远超实用范围。 编译跑起来后,资产流动性够(每秒至少 1 个分时)就能看到当前柱收盘与下根开盘的剩余时间准更新。但为何不用 OnCalculate 自带点差数组而非要 iSpread,得另说。
class="macro">#class="kw">property copyright "Daniel Jose" class="macro">#include "C_ConfigService.mqh" class="macro">#include "C_Controls.mqh" class="macro">#define def_IndicatorControl "Indicators\\Market Replay.ex5" class="macro">#resource "\\" + def_IndicatorControl class="macro">#define def_CheckLoopService((!_StopFlag) && (ChartSymbol(m_Infos.IdReplay) != "")) class="macro">#define def_ShortNameIndControl "Market Replay Control" class="macro">#define def_MaxSlider(def_MaxPosSlider + class="num">1) class C_Replay : class="kw">public C_ConfigService { class="kw">private : class="kw">struct st00 { C_Controls::eObjectControl Mode; uCast_Double Memory; class="type">class="kw">ushort Position; class="type">int Handle; }m_IndControl; class="kw">struct st01 { class="type">long IdReplay; class="type">int CountReplay; class="type">class="kw">double PointsPerTick;
指标控制块与图表清扫的底层写法
这段结构把 tick 与 K 线数据压缩进一个 1 元素数组里:MqlTick tick[1] 与 MqlRates Rate[1] 只占单槽内存,配合 stInfoTicks m_MemoryData 做跨函数暂存。外汇与贵金属行情跳变快,这种紧凑声明能减少冗余拷贝,但高频调用下仍要警惕句柄失效。 UpdateIndicatorControl 先判 m_IndControl.Handle == INVALID_HANDLE 就直接 return,避免对空句柄做 CopyBuffer。若当前仓位记录与内存中 _16b[eCtrlPosition] 一致,才用 CopyBuffer(句柄,0,0,1,Buff) 取 1 根缓冲并写回 dValue;返回值为 1 表示成功取到单值。 当位置不一致时,代码把 Position 与 Mode 写回内存的 _16b 字段,并在 _8b[7]、_8b[6] 塞入 'D'、'M' 标记,最后 EventChartCustom 发自定义事件。你在 MT5 里跑这段,开个 EURUSD 的 M1 图,把 ePlay 状态切一下,就能在专家日志看到 dValue 随缓冲更新的跳动。 SweepAndCloseChart 用 ChartFirst 拿首个图表 id,>0 就进 do 循环逐个处理。这是批量关图的前置骨架,实盘挂多个贵金属副图时,用类似逻辑回收资源可能缓解终端卡顿。
class="type">MqlTick tick[class="num">1]; class="type">MqlRates Rate[class="num">1]; } m_Infos; stInfoTicks m_MemoryData; class=class="str">"cmt">//+------------------------------------------------------------------+ class="kw">inline class="type">bool MsgError(class="type">class="kw">string sz0) { Print(sz0); class="kw">return class="kw">false; } class=class="str">"cmt">//+------------------------------------------------------------------+ class="kw">inline class="type">void UpdateIndicatorControl(class="type">void) { class="type">class="kw">double Buff[]; if (m_IndControl.Handle == INVALID_HANDLE) class="kw">return; if (m_IndControl.Memory._16b[C_Controls::eCtrlPosition] == m_IndControl.Position) { if (CopyBuffer(m_IndControl.Handle, class="num">0, class="num">0, class="num">1, Buff) == class="num">1) m_IndControl.Memory.dValue = Buff[class="num">0]; if ((m_IndControl.Mode = (C_Controls::eObjectControl)m_IndControl.Memory._16b[C_Controls::eCtrlStatus]) == C_Controls::ePlay) m_IndControl.Position = m_IndControl.Memory._16b[C_Controls::eCtrlPosition]; } else { m_IndControl.Memory._16b[C_Controls::eCtrlPosition] = m_IndControl.Position; m_IndControl.Memory._16b[C_Controls::eCtrlStatus] = (class="type">class="kw">ushort)m_IndControl.Mode; m_IndControl.Memory._8b[class="num">7] = &class="macro">#x27;D&class="macro">#x27;; m_IndControl.Memory._8b[class="num">6] = &class="macro">#x27;M&class="macro">#x27;; EventChartCustom(m_Infos.IdReplay, evCtrlReplayInit, class="num">0, m_IndControl.Memory.dValue, ""); } } class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void SweepAndCloseChart(class="type">void) { class="type">long id; if ((id = ChartFirst()) > class="num">0) do {
◍ 回放里怎么造出带点差的逐笔报价
做历史回放时,若想在 Exchange 模式下让每根 1 分钟 bar 带上 realistic 的买卖点差,核心在 CreateBarInReplay 这个函数里。它先调用 BuildBar1Min 生成本分钟 bar,再把对应 tick 从内存结构搬进 m_Infos.tick[0],随后按行情模式补点差。 点差不是固定值。代码用 rand() 取一个伪随机整数 iRand,只有当 iRand 落在 29080 到 32767 之间(约覆盖 rand() 正值区间 11% 的概率)时,才按奇偶性再叠加一个 PointsPerTick;其余情况点差为 0。这意味着回放中大部分成交接近零 spreads,偶发扩大,符合低波动品种盘口特征。 报价越界修正逻辑也很直接:若 last 大于原 ask,则 ask 重设为 last、bid 等于 last 减 spread;若 last 小于原 bid,则 bid 重设为 last、ask 等于 last 加 spread。这样能保证 ask 永远 ≥ bid,避免回放时出现倒挂盘口。 开 MT5 把这段逻辑塞进你的回放 EA,把 29080 / 32767 这两个阈值调小,就能人为提高点差爆发频率,测极端滑点下的止损表现。外汇与贵金属杠杆高,回放结果仅反映模型假设,实盘滑点可能更凶。
if (ChartSymbol(id) == def_SymbolReplay) ChartClose(id); }class="kw">while ((id = ChartNext(id)) > class="num">0); } class=class="str">"cmt">//+------------------------------------------------------------------+ class="kw">inline class="type">void CreateBarInReplay(class="type">bool bViewTick) { class="type">bool bNew; class="type">class="kw">double dSpread; class="type">int iRand = rand(); class="kw">static class="type">int st_Spread = class="num">0; if (BuildBar1Min(m_Infos.CountReplay, m_Infos.Rate[class="num">0], bNew)) { m_Infos.tick[class="num">0] = m_MemoryData.Info[m_Infos.CountReplay]; if (m_MemoryData.ModePlot == PRICE_EXCHANGE) { 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 ); if (m_Infos.tick[class="num">0].last > m_Infos.tick[class="num">0].ask) { m_Infos.tick[class="num">0].ask = m_Infos.tick[class="num">0].last; m_Infos.tick[class="num">0].bid = m_Infos.tick[class="num">0].last - dSpread; }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) {
「回放视图的报价线切换逻辑」
在自建回放品种上,外汇与交易所品种的图表呈现需要区分买卖盘线。AdjustViewDetails 里用 ChartSetInteger 针对 CHART_SHOW_ASK_LINE、CHART_SHOW_BID_LINE、CHART_SHOW_LAST_LINE 分别按 ModePlot 赋值:外汇(PRICE_FOREX)显示买卖线,交易所(PRICE_EXCHANGE)显示最新成交线。 点数价值通过 SymbolInfoDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_SIZE) 取回写到 m_Infos.PointsPerTick,这决定了后续 tick 跳动在图表上的最小位移。 交易所品种在首根数据未生成前,会用 for 循环跳过 volume_real 为 0 的帧(第115–116行),避免空量报价污染收盘价。若已取到 M1 的 rate[0].close 且大于 0,则按品种把 close 填进 tick 的 last 或 bid 字段(第117–123行),供 CustomTicksAdd 推入回放流。 开 MT5 把这段接进自己的回放 EA,改 PRICE_FOREX / PRICE_EXCHANGE 分支,能直接验证不同品种下买卖线显隐是否符合预期。外汇与贵金属回放涉及高杠杆高风险,参数仅作技术验证,不代表实盘倾向。
class="type">void AdjustViewDetails(class="type">void) { class="type">MqlRates rate[class="num">1]; ChartSetInteger(m_Infos.IdReplay, CHART_SHOW_ASK_LINE, GetInfoTicks().ModePlot == PRICE_FOREX); ChartSetInteger(m_Infos.IdReplay, CHART_SHOW_BID_LINE, GetInfoTicks().ModePlot == PRICE_FOREX); ChartSetInteger(m_Infos.IdReplay, CHART_SHOW_LAST_LINE, GetInfoTicks().ModePlot == PRICE_EXCHANGE); m_Infos.PointsPerTick = SymbolInfoDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_SIZE); CopyRates(def_SymbolReplay, PERIOD_M1, class="num">0, class="num">1, rate); if ((m_Infos.CountReplay == class="num">0) && (GetInfoTicks().ModePlot == PRICE_EXCHANGE)) for (; GetInfoTicks().Info[m_Infos.CountReplay].volume_real == class="num">0; m_Infos.CountReplay++); if (rate[class="num">0].close > class="num">0) { if (GetInfoTicks().ModePlot == PRICE_EXCHANGE) m_Infos.tick[class="num">0].last = rate[class="num">0].close; else { m_Infos.tick[class="num">0].bid = rate[class="num">0].close;