回放系统服务开发基础:厘清最小分时报价与定时误差根源(基础篇)
⚙️

回放系统服务开发基础:厘清最小分时报价与定时误差根源(基础篇)

(1/3)· 为什么真实数据混模拟总出定时错?先搞懂最小分时报价数的约束

偏理论进阶 第 1/3 篇

不少人在 MT5 回放里把真实报价和模拟撮合混用,看到柱形 timing 飘了就怀疑代码写错。其实很多时候问题出在最小分时报价数没设对,系统定时机制被悄悄过载,纯模拟时根本暴露不出来。

「在 MT5 里搭一个会自己跑的服务」

MetaTrader 5 的服务(Service)是一种常驻后台、不绑定具体图表的计算单元,适合用来做回放系统的时钟与数据调度。2025 年 6 月 6 日发布的示例里,作者 Daniel Jose 把第 63 部分回放框架的服务模块拆出来单讲,说明这套玩法已经迭代到能独立跑通的程度。 服务与普通 EA 最大的区别是:它没有 OnTick,只有 OnStart 和全局事件回调,靠定时器或自定义事件驱动。想验证这一点,开 MT5 新建一个 Service 工程,编译后从「导航器—服务」拖进终端,它会常驻且不占用图表资源。 下面这段是服务骨架里最关键的启动与退出钩子,逐行看清楚生命周期怎么控:

MQL5 / C++
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 数。外汇与贵金属模拟属高风险环境,参数错配可能让回放失真,实盘前务必在策略测试器里用小周期样本验证。

MQL5 / C++
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 重跑,能直观看到逐笔序列稀疏程度的变化。

MQL5 / C++
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 用历史数据跑一遍该头文件,确认崩坏路径下无残留数组。

MQL5 / C++
<span class="number">class="num">01</span>. <span class="comment">class=class="str">"cmt">//+------------------------------------------------------------------+</span>
<span class="number">class="num">02</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <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>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{
<span class="number">class="num">04</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <span class="keyword">class="type">int</span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;MemNRates,
<span class="number">class="num">05</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;MemNTicks,
<span class="number">class="num">06</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;nDigits,
<span class="number">class="num">07</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;nShift;
<span class="number">class="num">08</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <span class="keyword">class="type">class="kw">datetime</span> dtRet = <span class="functions">TimeCurrent</span>();
<span class="number">class="num">09</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <span class="predefines">class="type">MqlRates</span> RatesLocal[],
<span class="number">class="num">10</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;rate;
<span class="number">class="num">11</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <span class="predefines">class="type">MqlTick</span>&nbsp;&nbsp;TicksLocal[];
<span class="number">class="num">12</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <span class="keyword">class="type">bool</span>&nbsp;&nbsp;&nbsp;&nbsp; bNew;
<span class="number">class="num">13</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<span class="number">class="num">14</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; MemNRates = (m_Ticks.nRate &lt; <span class="number">class="num">0</span> ? <span class="number">class="num">0</span> : m_Ticks.nRate);
<span class="number">class="num">15</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; nShift = MemNTicks = m_Ticks.nTicks;
<span class="number">class="num">16</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <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>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <span class="keyword">if</span> (!ReadAllsTicks()) <span class="keyword">class="kw">return</span> <span class="number">class="num">0</span>;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<span class="number">class="num">18</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; rate.time = <span class="number">class="num">0</span>;
<span class="number">class="num">19</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; nDigits = SetSymbolInfos();
<span class="number">class="num">20</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <span class="functions">ArrayResize</span>(TicksLocal, def_MaxSizeArray);
<span class="number">class="num">21</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; m_Ticks.bTickReal = <span class="macro">true</span>;
<span class="number">class="num">22</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <span class="keyword">for</span> (<span class="keyword">class="type">int</span> c0 = MemNTicks, c1, MemShift = nShift; c0 &lt; m_Ticks.nTicks; c0++, nShift++)
<span class="number">class="num">23</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; {
<span class="number">class="num">24</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">if</span> (!BuildBar1Min(c0, rate, bNew)) <span class="keyword">class="kw">continue</span>;
<span class="number">class="num">25</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">if</span> (bNew)
<span class="number">class="num">26</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{

回放模式下的大单拆分与自定义序列刷新

当处于回放模式且当前 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 变化。

MQL5 / C++
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;
};
把回放配置诊断交给小布
这些关于回放服务定时负载与报价数约束的诊断,小布盯盘的 AIGC 已内置,打开对应品种页即可看到负荷预警,你专注判断策略逻辑而非底层配置。

常见问题

因为真实数据结合模拟时,工作站能生成的报价吞吐不同,写死会导致要么精度不够要么卡顿,需按机器能力在配置中可调。
大概率不是代码 bug,而是定时机制在真实报价冲击下过载,属于架构早期实现的已知误差,可通过调整报价数缓解。
视具体回放实现而定,单报价可能够用但需保证窗口内时间推进逻辑不被跳过,具体看本篇后续小节推导。
小布目前提供负荷与配置风险预警,不直接改 MT5 配置文件,但会提示你当前值是否偏离机型合理区间。
它让回放系统失去实时校正基准,误差会累积且难溯源,本篇先不展开,后续系列会专门处理。