回放系统服务开发基础:厘清最小分时报价与定时误差根源(基础篇)
(1/3)· 为什么真实数据混模拟总出定时错?先搞懂最小分时报价数的约束
不少人在 MT5 回放里把真实报价和模拟撮合混用,看到柱形 timing 飘了就怀疑代码写错。其实很多时候问题出在最小分时报价数没设对,系统定时机制被悄悄过载,纯模拟时根本暴露不出来。
「在 MT5 里搭一个会自己跑的服务」
MetaTrader 5 的服务(Service)是一种常驻后台、不绑定具体图表的计算单元,适合用来做回放系统的时钟与数据调度。2025 年 6 月 6 日发布的示例里,作者 Daniel Jose 把第 63 部分回放框架的服务模块拆出来单讲,说明这套玩法已经迭代到能独立跑通的程度。 服务与普通 EA 最大的区别是:它没有 OnTick,只有 OnStart 和全局事件回调,靠定时器或自定义事件驱动。想验证这一点,开 MT5 新建一个 Service 工程,编译后从「导航器—服务」拖进终端,它会常驻且不占用图表资源。 下面这段是服务骨架里最关键的启动与退出钩子,逐行看清楚生命周期怎么控:
class="type">int OnInit() { class=class="str">"cmt">// 服务初始化:建定时器、注册事件 EventSetTimer(class="num">1); class="kw">return(INIT_SUCCEEDED); } class="type">void OnTimer() { class=class="str">"cmt">// 每秒触发:回放帧推进 ReplayTick(); } class="type">void OnDeinit(const class="type">int reason) { class=class="str">"cmt">// 退出清理:撤定时器 EventKillTimer(); }
◍ 回放系统的定时误差从哪来
上一阶段我们把分时报价当真实数据接入回放,本意是验证系统能否在一分钟窗口内把柱形完整构建。但纯模拟里没出现的误差,在模拟叠加真实数据后冒出来了——这不是代码写错,而是两套时序对齐时必然产生的偏移。 这类误差未必是坏事。它反而给了早期可行性测试一个标尺:如果连基础定时都对不齐,说明架构方向可能要推倒,及时停手比硬修更省时间。我们上一篇文章已拆过成因,这里先不动代码,而是补一层原本在定时不超载时不需要的机制。 当前最该先啃的,是真实数据下到底要模拟多少笔最小分时报价。这个数定不准,后面所有定时补偿都是空中楼阁。 外汇与贵金属回放测试属高风险验证环境,任何时序误差都可能放大实盘逻辑漏洞,请在 MT5 策略测试器内隔离校验。
一根分钟 K 线最少要喂几个 tick
做 MT5 回放或模拟时,一根 MqlRates 分钟棒到底要生成几个分时报价(tick),不是拍脑袋给个固定数。逻辑取决于这根棒的 OHLC 形态:若开高低收四价全相等,1 个 tick 就够;若开盘等于其中一个边界、收盘等于另一个边界,需要 2 个;若开或收贴一边、另一边越界,要 3 个;若四价互不相同或开收相等但不与高/低重合,则需 4 个。 这套判定直接决定模拟器能否还原真实微观结构。原文片段里第 135–137 行就是按上面规则给 dm 赋值:全相等得 1,贴两边得 2,贴一边越界得 3,其余落 4;若 dm 算出 0 会在第 137 行直接 return -1,调用方必须检查返回值,否则 tick[] 数组可能无效。 还有一个隐蔽点:第 138 行用 MaxTickVolume 和 dm 比大小,若传入上限小于 dm,则强制用 dm;第 139 行再拿 rate.tick_volume 校验,真实成交量低于 dm 时也优先 dm。也就是说,工作站跑不动高 tick 数时,调低配置能更平稳,但绝不会低于 OHLC 形态要求的最小 tick 数。外汇与贵金属模拟属高风险环境,参数错配可能让回放失真,实盘前务必在策略测试器里用小周期样本验证。
class="num">128. class=class="str">"cmt">//+------------------------------------------------------------------+ class="num">129. class="kw">inline class="type">int Simulation(const class="type">MqlRates &rate, class="type">MqlTick &tick[], const class="type">int MaxTickVolume) class="num">130. { class="num">131. class="type">int i0, i1, i2, dm = class="num">0; class="num">132. class="type">bool b0; class="num">133. class="num">134. m_Marks.iMax = (MaxTickVolume <= class="num">0 ? class="num">1 : (MaxTickVolume >= def_MaxTicksVolume ? def_MaxTicksVolume : MaxTickVolume)); class="num">135. dm = (dm == class="num">0 ? ((rate.open == rate.high == rate.low == rate.close) ? class="num">1 : dm) : dm); class="num">136. dm = (dm == class="num">0 ? (((rate.open == rate.high) && (rate.low == rate.close)) || ((rate.open == rate.low) && (rate.close == rate.high)) ? class="num">2 : dm) : dm); class="num">137. if ((dm == class="num">0 ? ((rate.open == rate.close == rate.high) || (rate.open == rate.close == rate.low) ? class="num">3 : class="num">4) : dm) == class="num">0) class="kw">return -class="num">1; class="num">138. m_Marks.iMax = (MaxTickVolume <= dm ? dm : MaxTickVolume); class="num">139. m_Marks.iMax = (((class="type">int)rate.tick_volume > m_Marks.iMax) || ((class="type">int)rate.tick_volume < dm) ? m_Marks.iMax : (class="type">int)rate.tick_volume - class="num">1); class="num">140. m_Marks.bHigh = (rate.open == rate.high) || (rate.close == rate.high); class="num">141. m_Marks.bLow = (rate.open == rate.low) || (rate.close == rate.low); class="num">142. Simulation_Time(rate, tick); class="num">143. MountPrice(class="num">0, rate.open, rate.spread, tick); class="num">144. if (m_Marks.iMax > class="num">10) class="num">145. { class="num">146. i0 = (class="type">int)(MathMin(m_Marks.iMax / class="num">3.0, m_Marks.iMax * class="num">0.2)); class="num">147. i1 = m_Marks.iMax - i0;
「用随机游走把单根 K 线拆成逐笔tick」
这段逻辑干的事,是把一根已经知道开高低收的 K 线,反向工程成一串符合形态的逐笔成交 tick。核心在于先算这根 K 线跨了多少个最小变动单位,再决定要插值出多少笔。 第148行用 (high-low)/TickSize 除以 i0 得到 i2,即每段随机游走的步数;第149行做个保护,i2 为 0 时强制置 1,避免除零或空循环。第150行决定价格先从哪头走:若标记总数超 1000 就抛硬币随机选方向,否则比较上下影长度,影线短的那头倾向作为起点。 随后三次调用 RandomWalk 把整根 K 从 open 走到 high/low,再回到另一端,最后收向 close,tick 数组被分段填充。第154行把高低点标记置真,若之前没挂过高低价,158、159行用 MountPrice 补挂,160行再挂收盘价,161行 CorretTime 校正时间戳。 外汇与贵金属 tick 模拟含高杠杆风险,这段代码只解决「形态合理」的回测数据构造,不预示任何真实行情方向。 把 i0 调小会让每步跨度变大、tick 数更少;在 MT5 里改 i0 重跑,能直观看到逐笔序列稀疏程度的变化。
class="num">148. i2 = (class="type">int)(((rate.high - rate.low) / m_TickSize) / i0); class="num">149. i2 = (i2 == class="num">0 ? class="num">1 : i2); class="num">150. b0 = (m_Marks.iMax >= class="num">1000 ? ((rand() & class="num">1) == class="num">1) : (rate.high - rate.open) < (rate.open - rate.low)); class="num">151. i0 = RandomWalk(class="num">1, i0, rate.open, (b0 ? rate.high : rate.low), rate.high, rate.low, rate.spread, tick, class="num">0, i2); class="num">152. RandomWalk(i0, i1, (m_IsPriceBID ? tick[i0].bid : tick[i0].last), (b0 ? rate.low : rate.high), rate.high, rate.low, rate.spread, tick, class="num">1, i2); class="num">153. RandomWalk(i1, m_Marks.iMax, (m_IsPriceBID ? tick[i1].bid : tick[i1].last), rate.close, rate.high, rate.low, rate.spread, tick, class="num">2, i2); class="num">154. m_Marks.bHigh = m_Marks.bLow = true; class="num">155. class="num">156. }else Random_Price(rate, tick); class="num">157. if (!m_IsPriceBID) DistributeVolumeReal(rate, tick); class="num">158. if (!m_Marks.bLow) MountPrice(Unique(rate.high, tick), rate.low, rate.spread, tick); class="num">159. if (!m_Marks.bHigh) MountPrice(Unique(rate.low, tick), rate.high, rate.spread, tick); class="num">160. MountPrice(m_Marks.iMax, rate.close, rate.spread, tick); class="num">161. CorretTime(tick); class="num">162. class="num">163. class="kw">return m_Marks.iMax; class="num">164. } class="num">165. class=class="str">"cmt">//+------------------------------------------------------------------+
◍ 分时报价模拟失败时的内存回收顺序
上一版 LoadTicks 在模拟分时报价失败时直接 return,没有显式释放已分配的内存和调用模拟类析构函数,导致图表重绘时出现游离报价。修正版把析构与释放挪到返回之前,按固定次序执行,系统初始化前的性能损耗可以承受。 关键改动在第 34、35 行:故障路径下先调析构再释放,仅第 36 行才返回;成功路径则在第 33 行借库函数返回值更新偏移。这样模拟崩了也不会漏内存。 第 23 行那句「计数器与偏移值比较」初看多余,实则必要。模拟跑起来后偏移会和计数器脱节,不重定位真实分时报价,第 52 行执行时真实 tick 可能整批消失,模拟柱和实盘柱之间还会串价。补上这次检查,图表就不会再出诡异缺口。 外汇与贵金属回测属高风险验证,建议在 MT5 用历史数据跑一遍该头文件,确认崩坏路径下无残留数组。
<span class="number">class="num">01</span>. <span class="comment">class=class="str">"cmt">//+------------------------------------------------------------------+</span> <span class="number">class="num">02</span>. <span class="keyword">class="type">class="kw">datetime</span> LoadTicks(<span class="keyword">const</span> <span class="keyword">class="type">class="kw">string</span> szFileNameCSV, <span class="keyword">const</span> <span class="keyword">class="type">bool</span> ToReplay, <span class="keyword">const</span> <span class="keyword">class="type">int</span> MaxTickVolume) <span class="number">class="num">03</span>. { <span class="number">class="num">04</span>. <span class="keyword">class="type">int</span> MemNRates, <span class="number">class="num">05</span>. MemNTicks, <span class="number">class="num">06</span>. nDigits, <span class="number">class="num">07</span>. nShift; <span class="number">class="num">08</span>. <span class="keyword">class="type">class="kw">datetime</span> dtRet = <span class="functions">TimeCurrent</span>(); <span class="number">class="num">09</span>. <span class="predefines">class="type">MqlRates</span> RatesLocal[], <span class="number">class="num">10</span>. rate; <span class="number">class="num">11</span>. <span class="predefines">class="type">MqlTick</span> TicksLocal[]; <span class="number">class="num">12</span>. <span class="keyword">class="type">bool</span> bNew; <span class="number">class="num">13</span>. <span class="number">class="num">14</span>. MemNRates = (m_Ticks.nRate < <span class="number">class="num">0</span> ? <span class="number">class="num">0</span> : m_Ticks.nRate); <span class="number">class="num">15</span>. nShift = MemNTicks = m_Ticks.nTicks; <span class="number">class="num">16</span>. <span class="keyword">if</span> (!Open(szFileNameCSV)) <span class="keyword">class="kw">return</span> <span class="number">class="num">0</span>; <span class="number">class="num">17</span>. <span class="keyword">if</span> (!ReadAllsTicks()) <span class="keyword">class="kw">return</span> <span class="number">class="num">0</span>; <span class="number">class="num">18</span>. rate.time = <span class="number">class="num">0</span>; <span class="number">class="num">19</span>. nDigits = SetSymbolInfos(); <span class="number">class="num">20</span>. <span class="functions">ArrayResize</span>(TicksLocal, def_MaxSizeArray); <span class="number">class="num">21</span>. m_Ticks.bTickReal = <span class="macro">true</span>; <span class="number">class="num">22</span>. <span class="keyword">for</span> (<span class="keyword">class="type">int</span> c0 = MemNTicks, c1, MemShift = nShift; c0 < m_Ticks.nTicks; c0++, nShift++) <span class="number">class="num">23</span>. { <span class="number">class="num">24</span>. <span class="keyword">if</span> (!BuildBar1Min(c0, rate, bNew)) <span class="keyword">class="kw">continue</span>; <span class="number">class="num">25</span>. <span class="keyword">if</span> (bNew) <span class="number">class="num">26</span>. {
回放模式下的大单拆分与自定义序列刷新
当处于回放模式且当前 tick 成交量超过 MaxTickVolume 时,这段逻辑会把异常大单交给模拟器拆成若干常规 tick,再写回本地缓冲。第 27~35 行是触发与拆分的核心:先备份偏移量 MemShift 到 nShift,新建 C_Simulation 实例并按小数位 nDigits 初始化,调用 Simulation() 拿到拆分后的 tick 数 c1,若返回负值直接退出函数。 拆分结果通过 ArrayCopy 从 TicksLocal 搬到 m_Ticks.Info,偏移 nShift 同步累加 c1,随后释放模拟器对象。注意第 37 行用 ArrayResize 把 m_Ticks.Rate 扩到 nRate+2 或默认日线柱数 def_BarsDiary,避免缓冲越界。 非回放分支(第 42~50 行)走另一条路:把新增的 nRate-MemNRates 根 K 线截出,经 CustomRatesUpdate 推到 def_SymbolReplay 自定义品种,再把 nRate、nTicks 复位。外汇与贵金属自定义回放涉及杠杆与滑点,属于高风险操作,实盘映射前建议在 MT5 策略测试器用历史数据跑通这套缓冲逻辑。 最后第 51 行回放模式把 nShift 赋给 nTicks 即结束,函数返回 dtRet 时间戳。开 MT5 把 MaxTickVolume 调到小于真实tick_volume,能直接观察大单被拆的次数 c1 变化。
if ((m_Ticks.nRate >= class="num">0) && (ToReplay)) if (m_Ticks.Rate[m_Ticks.nRate].tick_volume > MaxTickVolume) { nShift = MemShift; C_Simulation *pSimulator = new C_Simulation(nDigits); if ((c1 = (*pSimulator).Simulation(m_Ticks.Rate[m_Ticks.nRate], TicksLocal, MaxTickVolume)) < class="num">0) class="kw">return class="num">0; ArrayCopy(m_Ticks.Info, TicksLocal, nShift, class="num">0, c1); nShift += c1; class="kw">delete pSimulator; } MemShift = nShift; ArrayResize(m_Ticks.Rate, (m_Ticks.nRate > class="num">0 ? m_Ticks.nRate + class="num">2 : def_BarsDiary), def_BarsDiary); }; m_Ticks.Rate[(m_Ticks.nRate += (bNew ? class="num">1 : class="num">0))] = rate; } ArrayFree(TicksLocal); if (!ToReplay) { ArrayResize(RatesLocal, (m_Ticks.nRate - MemNRates)); ArrayCopy(RatesLocal, m_Ticks.Rate, class="num">0, class="num">0); CustomRatesUpdate(def_SymbolReplay, RatesLocal, (m_Ticks.nRate - MemNRates)); dtRet = m_Ticks.Rate[m_Ticks.nRate].time; m_Ticks.nRate = (MemNRates == class="num">0 ? -class="num">1 : MemNRates); m_Ticks.nTicks = MemNTicks; ArrayFree(RatesLocal); }else m_Ticks.nTicks = nShift; class="kw">return dtRet; };