开发回放系统(第 63 部分):玩转服务(四)·进阶篇
⚙️

开发回放系统(第 63 部分):玩转服务(四)·进阶篇

(2/3)·最小分时报价数算不清、回放定时总漂移?这篇专治配置错配引发的隐性 bug

实战向进阶 第 2/3 篇
很多人以为回放里定时错位只是模拟精度不够,其实多半是真实数据和模拟报价混跑时的最小报价数没设对。配置项留空或乱填,工作站要么过载卡顿要么时间窗对不上。这种错在纯模拟里根本不显形,一接实盘回放就露馅。

「从CSV回放tick时如何拦截异常成交量」

做历史 tick 回放时,最怕某根 1 分钟 bar 的 tick_volume 异常膨胀,直接把后续仿真节奏带偏。上面这段 LoadTicks 在 ToReplay 模式下,会用 MaxTickVolume 做一道硬闸门:一旦当前 Rate 的 tick_volume 超过阈值,就回退 nShift 并启动 C_Simulation 重新拆细 tick。 代码第 27 行是核心判断——m_Ticks.Rate[m_Ticks.nRate].tick_volume > MaxTickVolume 才触发仿真分支。注意 nShift 先被 MemShift 复位(第 29 行),意味着这一批超量 tick 不会被原样写入,而是交给 pSimulator 按 nDigits 精度重造。 实盘接这套逻辑时,MaxTickVolume 建议先用品种近期平均分钟 tick 数 ×3 探一下。外汇与贵金属杠杆高、滑点随机,回测通过的参数上线仍可能失真,务必在 MT5 策略测试器用真实点差重跑。 别把正态当圣经 历史 tick 分布未必平稳,MaxTickVolume 写死容易在行情突变日误杀正常数据,最好做成随波动率滚动的阈值。

MQL5 / C++
class="type">class="kw">datetime LoadTicks(const class="type">class="kw">string szFileNameCSV, const class="type">bool ToReplay, const class="type">int MaxTickVolume)
{
   class="type">int      MemNRates,
            MemNTicks,
            nDigits,
            nShift;
   class="type">class="kw">datetime dtRet = TimeCurrent();
   class="type">MqlRates RatesLocal[],
            rate;
   class="type">MqlTick  TicksLocal[];
   class="type">bool     bNew;

   MemNRates = (m_Ticks.nRate < class="num">0 ? class="num">0 : m_Ticks.nRate);
   nShift = MemNTicks = m_Ticks.nTicks;
   if (!Open(szFileNameCSV)) class="kw">return class="num">0;
   if (!ReadAllsTicks()) class="kw">return class="num">0;
   rate.time = class="num">0;
   nDigits = SetSymbolInfos();
   m_Ticks.bTickReal = true;
   for (class="type">int c0 = MemNTicks, c1, MemShift = nShift; c0 < m_Ticks.nTicks; c0++, nShift++)
   {
      if (nShift != c0) m_Ticks.Info[nShift] = m_Ticks.Info[c0];
      if (!BuildBar1Min(c0, rate, bNew)) class="kw">continue;
      if (bNew)
      {
         if ((m_Ticks.nRate >= class="num">0) && (ToReplay)) if (m_Ticks.Rate[m_Ticks.nRate].tick_volume > MaxTickVolume)
         {
            nShift = MemShift;
            ArrayResize(TicksLocal, def_MaxSizeArray);
            C_Simulation *pSimulator = new C_Simulation(nDigits);
            if ((c1 = (*pSimulator).Simulation(m_Ticks.Rate[m_Ticks.nRate], TicksLocal, MaxTickVolume)) > class="num">0)

回放模式下数组与计数器的收尾处理

在逐笔 tick 写入缓冲之后,代码通过 nShift 累加控制 m_Ticks.Info 的实际位移,并用 ArrayCopy 把本地 tick 数组接在已有数据后面。若 c1 小于 0 说明本次拷贝异常,函数直接返回 0,避免脏数据进入回放序列。 非回放分支里,RatesLocal 被重设尺寸为 m_Ticks.nRate - MemNRates,再把 m_Ticks.Rate 中新增的部分拷进去,交给 CustomRatesUpdate 推到 def_SymbolReplay 这个自定义品种上。此时 dtRet 取最新一根 K 线时间,随后 nRate / nTicks 复位到进入函数前的记忆值,等于只把增量对外暴露一次。 宏定义里有一行值得注意:def_MaxSizeArray 设为 16777216,即 16 Mbytes,这是单数组内存上限的硬约束。在 MT5 里跑 tick 级回放时,若历史跨度大,m_Ticks.Info 可能逼近该值,提前用 ArrayFree 释放 TicksLocal、RatesLocal 能降低爆内存概率。 末段 else 分支很简短:ToReplay 为真时只把 m_Ticks.nTicks 设为 nShift,不做 CustomRatesUpdate。两种模式计数器的归属差异,是调试回放不同步时第一个要查的点。

MQL5 / C++
nShift += ArrayCopy(m_Ticks.Info, TicksLocal, nShift, class="num">0, c1);
class="kw">delete pSimulator;
ArrayFree(TicksLocal);
if (c1 < class="num">0) class="kw">return class="num">0;
						 }
						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;
				 }
				 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;
				};
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//+------------------------------------------------------------------+
class="macro">#class="kw">property copyright "Daniel Jose"
class=class="str">"cmt">//+------------------------------------------------------------------+
class="macro">#include "C_FileBars.mqh"
class="macro">#include "C_Simulation.mqh"
class=class="str">"cmt">//+------------------------------------------------------------------+
class="macro">#define macroRemoveSec(A)  (A - (A % class="num">60))
class="macro">#define def_MaxSizeArray   class="num">16777216 class=class="str">"cmt">// class="num">16 Mbytes
class=class="str">"cmt">//+------------------------------------------------------------------+
class C_FileTicks
{

◍ 用 tick 流拼出 1 分钟合成棒

做跨市场回测时,MT5 自带 1 分钟 K 线对交易所品种和外汇品种的处理逻辑不一样。下面这段受保护结构把两种报价源抽象进同一个 stInfoTicks,再用 BuildBar1Min 按 tick 逐个回填。 枚举 ePlotType 只分两类:PRICE_EXCHANGE 走 last 成交价,PRICE_FOREX 走 bid 报价。外汇端若 bid 为 0.0 或算出的 close 为 0.0,函数直接返回 false,避免脏棒写入。 关键在 039 行:bNew 由「当前 rate.time 是否等于去掉秒的 tick 时间」决定。若不同,新建一根棒,open/low/high/close 全初始化为 dClose,tick_volume 在外汇模式下记 1、交易所模式记 0;若相同,只更新 close。 实盘验证建议:挂一个 EA 把 m_Ticks.Info 的 bid 序列打进数组,观察欧美跳空时段 036 行的 false 返回频率——异常 tick 可能在 1 分钟内触发 3~5 次丢弃。外汇与贵金属属高风险品种,合成棒仅作分析参考,不预示方向。

MQL5 / C++
class="kw">protected:
  enum ePlotType {PRICE_EXCHANGE, PRICE_FOREX};
  class="kw">struct stInfoTicks
  {
    class="type">MqlTick    Info[];
    class="type">MqlRates   Rate[];
    class="type">int        nTicks,
               nRate;
    class="type">bool       bTickReal;
    ePlotType  ModePlot;
  };
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">inline class="type">bool BuildBar1Min(const class="type">int iArg, class="type">MqlRates &rate, class="type">bool &bNew)
      {
         class="type">class="kw">double   dClose = class="num">0;
         
         class="kw">switch (m_Ticks.ModePlot)
          {
            case PRICE_EXCHANGE:
               if (m_Ticks.Info[iArg].last == class="num">0.0) class="kw">return class="kw">false;
               dClose = m_Ticks.Info[iArg].last;
               break;
            case PRICE_FOREX:
               dClose = (m_Ticks.Info[iArg].bid > class="num">0.0 ? m_Ticks.Info[iArg].bid : dClose);
               if ((dClose == class="num">0.0) || (m_Ticks.Info[iArg].bid == class="num">0.0)) class="kw">return class="kw">false;
               break;
          }
         if (bNew = (rate.time != macroRemoveSec(m_Ticks.Info[iArg].time)))
          {
            rate.time = macroRemoveSec(m_Ticks.Info[iArg].time);
            rate.real_volume = class="num">0;
            rate.tick_volume = (m_Ticks.ModePlot == PRICE_FOREX ? class="num">1 : class="num">0);
            rate.open = rate.low = rate.high = rate.close = dClose;
          }else
          {
            rate.close = dClose;

「回放引擎里的高低价与成交量归集」

这段逻辑出现在逐笔 tick 回放类的内部处理中,负责把每一笔 tick 的 close 与当前 K 线高低点做比对并累加成交量。注意 048、049 行:若 tick 的 close 高于已知 high 则刷新 high,低于已知 low 则刷新 low,这意味着用 tick 收盘价动态扩展影线,而不是只用传统 OHLC 的首尾。

  • 行把 real_volume 强转为 long 后累加,051 行则区分了真实 tick 标志:若 bTickReal 为真,tick_volume 只加 1(代表一笔真实成交事件),否则按整型 volume 累加。这种区分能避免回测时把聚合量当真实笔数用。
  • 行起的 Open 方法暴露了文件约定:CSV 必须放在 Market Replay\Ticks\ 目录下,且前 7 个字段读出来须严格等于 "<DATE><TIME><BID><ASK><LAST><VOLUME><FLAGS>" 才认作合法 tick 文件,否则直接返回 false 并打印报错。

开 MT5 把一段真实 tick 导出成该格式 CSV,用这段 Open 逻辑加载,能验证你的复盘数据是否被引擎正确拒绝或接纳。外汇与贵金属品种 tick 稀疏时段可能出现 bTickReal 误标,实盘回放前建议先打印前 100 行 volume 分布。

MQL5 / C++
rate.high = (rate.close > rate.high ? rate.close : rate.high);
rate.low = (rate.close < rate.low ? rate.close : rate.low);
rate.real_volume += (class="type">long) m_Ticks.Info[iArg].volume_real;
rate.tick_volume += (m_Ticks.bTickReal ? class="num">1 : (class="type">int)m_Ticks.Info[iArg].volume);

class="kw">return true;

class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">private :
	class="type">int		 m_File;
	stInfoTicks m_Ticks;
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">inline class="type">bool Open(const class="type">class="kw">string szFileNameCSV)
	{
		class="type">class="kw">string szInfo = "";
		
		if ((m_File = FileOpen("Market Replay\\Ticks\\" + szFileNameCSV + ".csv", FILE_CSV | FILE_READ | FILE_ANSI)) != INVALID_HANDLE)
		 {
			for (class="type">int c0 = class="num">0; c0 < class="num">7; c0++) szInfo += FileReadString(m_File);
			if (szInfo == "<DATE><TIME><BID><ASK><LAST><VOLUME><FLAGS>") class="kw">return true;
			Print("File ", szFileNameCSV, ".csv not a traded tick file.");
		 }else
			Print("Tick file ", szFileNameCSV,".csv not found...");
			
		 class="kw">return class="kw">false;
	}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">inline class="type">bool ReadAllsTicks(class="type">void)
	{
		class="type">class="kw">string		szInfo;
							
		Print("Loading replay ticks. Please wait...");

逐行拆解 tick 文件的流式读入

下面这段逻辑负责把导出的 tick 文本按行灌进内存数组,边读边扩容。循环里用 FileIsEnding 判尾、用 _StopFlag 响应终端停止,避免大文件卡死 MT5。 [CODE]ArrayResize(m_Ticks.Info, def_MaxSizeArray, def_MaxSizeArray); m_Ticks.ModePlot = PRICE_FOREX; while ((!FileIsEnding(m_File)) && (m_Ticks.nTicks < (INT_MAX - 2)) && (!_StopFlag)) { ArrayResize(m_Ticks.Info, m_Ticks.nTicks + 1, def_MaxSizeArray); szInfo = FileReadString(m_File) + " " + FileReadString(m_File); m_Ticks.Info[m_Ticks.nTicks].time = StringToTime(StringSubstr(szInfo, 0, 19)); m_Ticks.Info[m_Ticks.nTicks].time_msc = (m_Ticks.Info[m_Ticks.nTicks].time * 1000) + (int)StringToInteger(StringSubstr(szInfo, 20, 3)); m_Ticks.Info[m_Ticks.nTicks].bid = StringToDouble(FileReadString(m_File)); m_Ticks.Info[m_Ticks.nTicks].ask = StringToDouble(FileReadString(m_File)); m_Ticks.Info[m_Ticks.nTicks].last = StringToDouble(FileReadString(m_File)); m_Ticks.Info[m_Ticks.nTicks].volume_real = StringToDouble(FileReadString(m_File)); m_Ticks.Info[m_Ticks.nTicks].flags = (uchar)StringToInteger(FileReadString(m_File)); m_Ticks.ModePlot = (m_Ticks.Info[m_Ticks.nTicks].volume_real > 0.0 ? PRICE_EXCHANGE : m_Ticks.ModePlot); m_Ticks.nTicks++; } FileClose(m_File); if (m_Ticks.nTicks == (INT_MAX - 2)) { Print("Too much data in tick file.\nIt is not possible to continue..."); return false; } return (!_StopFlag);[/CODE] 第 81 行先按 def_MaxSizeArray 预分配一块内存,减少后续频繁重分配的 overhead;第 85 行每读一条就 ArrayResize 扩 1,步长仍是 def_MaxSizeArray,属于典型的“块分配+细增长”手法。 第 86–88 行把前两列拼成 szInfo,再用 StringSubstr 切出前 19 字符转成秒级 time,偏移 20 取 3 字符当毫秒拼到 time*1000 上,得到 time_msc。做高频回测时,毫秒字段直接决定成交排序,漏掉就会把同秒多 tick 当成无序。 第 94 行是个隐藏开关:只要某 tick 的 volume_real 大于 0,ModePlot 就从 PRICE_FOREX 切到 PRICE_EXCHANGE。这意味着你的数据里一旦出现真实成交量与 last 价,绘图模式会自动变,外汇无成交时维持外汇样式。 第 98–102 行设了硬上限 nTicks == INT_MAX-2(约 21.4 亿条),触顶就打印 Too much data 并 return false。实盘导几年 tick 很难撞这墙,但批量测多品种时得留心,否则静默断读。

MQL5 / C++
ArrayResize(m_Ticks.Info, def_MaxSizeArray, def_MaxSizeArray);
 m_Ticks.ModePlot = PRICE_FOREX;
 class="kw">while ((!FileIsEnding(m_File)) && (m_Ticks.nTicks < (INT_MAX - class="num">2)) && (!_StopFlag))
  {
  ArrayResize(m_Ticks.Info, m_Ticks.nTicks + class="num">1, def_MaxSizeArray);
  szInfo = FileReadString(m_File) + " " + FileReadString(m_File);
  m_Ticks.Info[m_Ticks.nTicks].time = StringToTime(StringSubstr(szInfo, class="num">0, class="num">19));
  m_Ticks.Info[m_Ticks.nTicks].time_msc = (m_Ticks.Info[m_Ticks.nTicks].time * class="num">1000) + (class="type">int)StringToInteger(StringSubstr(szInfo, class="num">20, class="num">3));
  m_Ticks.Info[m_Ticks.nTicks].bid = StringToDouble(FileReadString(m_File));
  m_Ticks.Info[m_Ticks.nTicks].ask = StringToDouble(FileReadString(m_File));
  m_Ticks.Info[m_Ticks.nTicks].last = StringToDouble(FileReadString(m_File));
  m_Ticks.Info[m_Ticks.nTicks].volume_real = StringToDouble(FileReadString(m_File));
  m_Ticks.Info[m_Ticks.nTicks].flags = (class="type">uchar)StringToInteger(FileReadString(m_File));
  m_Ticks.ModePlot = (m_Ticks.Info[m_Ticks.nTicks].volume_real > class="num">0.0 ? PRICE_EXCHANGE : m_Ticks.ModePlot);
  m_Ticks.nTicks++;
  }
 FileClose(m_File);
 if (m_Ticks.nTicks == (INT_MAX - class="num">2))
  {
  Print("Too much data in tick file.\nIt is not possible to class="kw">continue...");
  class="kw">return class="kw">false;
  }
 class="kw">return (!_StopFlag);

◍ 回放品种的精度与计价分支

在自建回放品种时,小数位和计价模式必须按数据源类型切分。若行情来自交易所(PRICE_EXCHANGE),小数位设为 4、计价走 SYMBOL_CALC_MODE_EXCH_STOCKS、图表取 LAST 价;否则按外汇处理,小数位 5、计价走 SYMBOL_CALC_MODE_FOREX、图表取 BID 价。 这个分支直接决定 MT5 回测引擎怎么算保证金和盈亏。外汇与贵金属杠杆高、点值敏感,小数位设错会让历史回测的滑点和库存费出现系统性偏移,实盘复制前建议在策略测试器里先跑一遍样本。 构造函数里把 m_Ticks.Rate 预扩到 def_BarsDiary,nRate 置 -1、nTicks 清 0,相当于给日内重采样留好空槽。BarsToTicks 从 CSV 读柱、按 def_MaxSizeArray 扩 local 缓冲,循环里用 _StopFlag 做中断保护,转换大文件时不会卡死终端。

MQL5 / C++
CustomSymbolSetInteger(def_SymbolReplay, SYMBOL_DIGITS, iRet = (m_Ticks.ModePlot == PRICE_EXCHANGE ? class="num">4 : class="num">5));
CustomSymbolSetInteger(def_SymbolReplay, SYMBOL_TRADE_CALC_MODE, m_Ticks.ModePlot == PRICE_EXCHANGE ? SYMBOL_CALC_MODE_EXCH_STOCKS : SYMBOL_CALC_MODE_FOREX);
CustomSymbolSetInteger(def_SymbolReplay, SYMBOL_CHART_MODE, m_Ticks.ModePlot == PRICE_EXCHANGE ? SYMBOL_CHART_MODE_LAST : SYMBOL_CHART_MODE_BID);

class="kw">return iRet;
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">public   :
class=class="str">"cmt">//+------------------------------------------------------------------+
   C_FileTicks()
   {
      ArrayResize(m_Ticks.Rate, def_BarsDiary);
      m_Ticks.nRate = -class="num">1;
      m_Ticks.nTicks = class="num">0;
      m_Ticks.Rate[class="num">0].time = class="num">0;
   }
class=class="str">"cmt">//+------------------------------------------------------------------+
   class="type">bool BarsToTicks(const class="type">class="kw">string szFileNameCSV, class="type">int MaxTickVolume)
   {
      C_FileBars     *pFileBars;
      C_Simulation   *pSimulator = NULL;
      class="type">int            iMem = m_Ticks.nTicks,
                     iRet = -class="num">1;
      class="type">MqlRates       rate[class="num">1];
      class="type">MqlTick        local[];
      class="type">bool           bInit = class="kw">false;

      pFileBars = new C_FileBars(szFileNameCSV);
      ArrayResize(local, def_MaxSizeArray);
      Print("Converting bars to ticks. Please wait...");
      class="kw">while ((*pFileBars).ReadBar(rate) && (!_StopFlag))
      {
         if (!bInit)
         {
把回放诊断交给小布
这些配置错配和定时漂移的初步诊断,小布盯盘的 AIGC 已内置,打开对应品种页即可看到负荷与窗口偏差提示,你只需聚焦策略本身。

常见问题

取决于开盘高低收是否一致等情形,单 tick 也可能够用;但真实数据混模拟时需在配置中显式给定上限,避免应用默认值拖垮定时。
真实数据带来不可控的到达节奏,模拟层若不能按配置限制最大报价数,定时机制会被动过载,从而让一分钟窗口构建偏离预期。
不必。把配置文件里的最大分时报价数调低,能以稍粗的精度换更平稳的运行,回放仍可用于可行性验证。
可以,小布的品种页会给出当前回放负荷与窗口偏差的参考,便于你判断该加还是减报价数,不必手动反复试错。
它绕过了应用内的实时调节逻辑,错误配置会被固化进数据源,本篇暂不展开,但用户侧误配纠正仍是当前重点。