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

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

(2/3)· 一分钟柱里报价爆量时,MetaTrader 5 连实盘都卡,重放更会崩,这篇给解法

含代码示例实战向 第 2/3 篇
很多人以为平台处理不过来海量报价是电脑太旧或系统坏了,其实是 MetaTrader 5 本身的数据吞吐上限在作祟。连真实服务器都扛不住的脉冲,回放时只会把计时精度拖进泥潭。先给模拟和外部数据都加上限流,比事后调参数更省心。

◍ 用随机游走把K线拆成逐笔tick

这段内联函数负责把一根 MqlRates 柱面还原成若干笔 MqlTick,核心在 Random_Price 与 DistributeVolumeReal 两个例程。Random_Price 从 1 循环到 m_Marks.iMax-1,对 high/low 之间做 NormalizeDouble(RandomLimit(...), m_NDigits) 取随机价,再交给 MountPrice 挂到 tick 数组;同时用三元判断 m_IsPriceBID 来比对 bid 或 last 是否触到当日高低,写回 m_Marks.bHigh / bLow 标志。 成交量分配走的是“先均摊、后随机补”的路子:DistributeVolumeReal 先把 0 到 iMax 每笔 volume 和 volume_real 都置 1,若 (iMax+1) 小于 tick_volume,就随机挑位置把整数字段累加;real_volume 超出部分同理以 1.0 步长散到随机下标。这样造出的 tick 流在成交量分布上更接近真实撮合而非均匀假数据。 RandomWalk 是总调度入口,入参带 Open/Close/High/Low/Spread 与 iMode、iDesloc,内部先声明 vStep、vNext、price 及局部高低 vH、vL,char i0 作步进计数器。外汇与贵金属品种用这类合成 tick 做回测,需清醒认识:历史重放和蒙特卡洛式随机重建都只是概率近似,实盘滑点与流动性断裂风险远高于离线环境。

MQL5 / C++
class="num">061.         tick[iPos].last = NormalizeDouble(price, m_NDigits);
class="num">062.         }
class="num">063. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">064. class="kw">inline class="type">void Random_Price(const class="type">MqlRates &rate, class="type">MqlTick &tick[])
class="num">065.         {
class="num">066.           for (class="type">int c0 = class="num">1; c0 < m_Marks.iMax; c0++)
class="num">067.           {
class="num">068.             MountPrice(c0, NormalizeDouble(RandomLimit(rate.high, rate.low), m_NDigits), (rate.spread + RandomLimit((class="type">int)(rate.spread | (m_Marks.iMax & 0xF)), class="num">0)), tick);
class="num">069.             m_Marks.bHigh = (rate.high == (m_IsPriceBID ? tick[c0].bid : tick[c0].last)) || m_Marks.bHigh;
class="num">070.             m_Marks.bLow = (rate.low == (m_IsPriceBID ? tick[c0].bid : tick[c0].last)) || m_Marks.bLow;
class="num">071.           }
class="num">072.         }
class="num">073. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">074. class="kw">inline class="type">void DistributeVolumeReal(const class="type">MqlRates &rate, class="type">MqlTick &tick[])
class="num">075.         {
class="num">076.           for (class="type">int c0 = class="num">0; c0 <= m_Marks.iMax; c0++)
class="num">077.           {
class="num">078.             tick[c0].volume_real = class="num">1.0;
class="num">079.             tick[c0].volume = class="num">1;
class="num">080.           }
class="num">081.           if ((m_Marks.iMax + class="num">1) < rate.tick_volume) for (class="type">int c0 = (class="type">int)(rate.tick_volume - m_Marks.iMax); c0 > class="num">0; c0--)
class="num">082.             tick[RandomLimit(class="num">0, m_Marks.iMax - class="num">1)].volume += class="num">1;
class="num">083.           for (class="type">int c0 = (class="type">int)(rate.real_volume - m_Marks.iMax); c0 > class="num">0; c0--)
class="num">084.             tick[RandomLimit(class="num">0, m_Marks.iMax)].volume_real += class="num">1.0;
class="num">085.         }
class="num">086. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">087. class="kw">inline class="type">int RandomWalk(class="type">int In, class="type">int Out, const class="type">class="kw">double Open, const class="type">class="kw">double Close, class="type">class="kw">double High, class="type">class="kw">double Low, const class="type">int Spread, class="type">MqlTick &tick[], class="type">int iMode, class="type">int iDesloc)
class="num">088.         {
class="num">089.           class="type">class="kw">double vStep, vNext, price, vH = High, vL = Low;
class="num">090.           class="type">char i0 = class="num">0;

「逐tick重排时边界与步进的收口逻辑」

这段循环负责在 In 到 Out 的 tick 区间内,按 m_TickSize 粒度重排价格并同步更新高低边界。vStep 由 (Out - In) / ((High - Low) / m_TickSize) 算得,意味着每推进约 vStep 个 tick,理论价格才跨过一个 tick 价位,这是控制重排密度的核心参数。 第95行先取前一 tick 的 bid 或 last(由 m_IsPriceBID 决定),再叠加减 iDesloc 到 +iDesloc 的随机偏移;第96行把价格夹在 vH 与 vL 之间,防止越界。第97行调用 MountPrice 写入重排后的报价与伪 spread。 iMode 分支里,mode 1 用 i0 的 0x01/0x02 位标记是否触到 High/Low,当 i0==3 时把 vH、vL 钉死为原 High/Low;mode 0 则在 price==Close 时直接返回当前索引 c0,提前结束重排。 第111行是节流阀:若 floor(vNext) 仍大于等于已循环计数 c1 就 continue,否则 c2 自增,最多容忍连续 3 次不推进价位。越过阈值后 vNext += vStep,并按 iMode 与 c2 奇偶性微调 vL、vH(第113–114行),使边界随 Close 位置缓慢收缩或扩张。外汇与贵金属tick重排属高风险模拟,实盘前务必在 MT5 策略测试器用历史数据校验边界行为。

MQL5 / C++
vNext = vStep = (Out - In) / ((High - Low) / m_TickSize);
for (class="type">int c0 = In, c1 = class="num">0, c2 = class="num">0; c0 <= Out; c0++, c1++)
{
   price = (m_IsPriceBID ? tick[c0 - class="num">1].bid : tick[c0 - class="num">1].last) + (m_TickSize * ((rand() & class="num">1) == class="num">1 ? -iDesloc : iDesloc));
   price = (price > vH ? vH : (price < vL ? vL : price));
   MountPrice(c0, price, (Spread + RandomLimit((class="type">int)(Spread | (m_Marks.iMax & 0xF)), class="num">0)), tick);
   class="kw">switch (iMode)
   {
      case class="num">1:
         i0 |= (price == High ? 0x01 : class="num">0);
         i0 |= (price == Low ? 0x02 : class="num">0);
         vH = (i0 == class="num">3 ? High : vH);
         vL = (i0 == class="num">3 ? Low : vL);
         break;
      case class="num">0:
         if (price == Close) class="kw">return c0;
      class="kw">default:
         break;
   }
   if (((class="type">int)floor(vNext)) >= c1) class="kw">continue; else if ((++c2) <= class="num">3) class="kw">continue;
   vNext += vStep;
   vL = (iMode != class="num">2 ? (Close > vL ? (i0 == class="num">3 ? vL : vL + m_TickSize) : vL) : (((c2 & class="num">1) == class="num">1) ? (Close > vL ? vL + m_TickSize : vL) : (Close < vH ? vL : vL + m_TickSize)));
   vH = (iMode != class="num">2 ? (Close > vL ? vH : (i0 == class="num">3 ? vH : vH - m_TickSize)) : (((c2 & class="num">1) == class="num">1) ? (Close > vL ? vH : vH - m_TickSize) : (Close < vH ? vH - m_TickSize : vH)));
}

class="kw">return Out;

回放引擎里的逐笔 tick 合成逻辑

这段 C_Simulation 构造器只做三件事:记下报价精度 nDigits、判断回放品种用的是 BID 还是 LAST 图(m_IsPriceBID)、抓一次 SYMBOL_TRADE_TICK_SIZE 存进 m_TickSize。后面 Simulation() 每次吃一根 MqlRates 蜡烛,吐出一组 MqlTick 数组,本质是把 1 根 K 线拆成若干假 tick 供 EA 回测。 iMax 先被夹在 1 到 def_MaxTicksVolume 之间,再用当前 tick_volume-1 封顶——也就是说一根成交量只有 5 的柱,最多拆 4 个 tick。bHigh / bLow 标记开盘或收盘是否触到极值,决定后续游走方向偏好。 当 iMax 超过 10 时走细分分支:前段占 i0 = min(iMax/3, iMax*0.2) 个 tick,中段补到 i1,步长 i2 由 (high-low)/TickSize 除以 i0 得到,最小为 1。若 iMax>=1000 用 rand() 抛硬币选先冲高还是先探低,否则比较 (high-open) 与 (open-low) 谁大就反向先走。RandomWalk 两次调用把 open→极值→另一极值→close 的路径填进 tick 数组。 外汇与贵金属 tick 回放纯属历史重采样,合成路径不代表真实流动性;MT5 里把 def_MaxTicksVolume 调小可加速回测但会丢失微观结构,调大更拟真但耗时陡增,按品种成交量分布试两次就有体感。

MQL5 / C++
class="num">118.    }
class="num">119. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">120.   class="kw">public  :
class="num">121. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">122.    C_Simulation(const class="type">int nDigits)
class="num">123.     {
class="num">124.       m_NDigits       = nDigits;
class="num">125.       m_IsPriceBID    = (SymbolInfoInteger(def_SymbolReplay, SYMBOL_CHART_MODE) == SYMBOL_CHART_MODE_BID);
class="num">126.       m_TickSize      = SymbolInfoDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_SIZE);
class="num">127.     }
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 = def_MaxTicksVolume)
class="num">130.     {
class="num">131.       class="type">int    i0, i1, i2;
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.       m_Marks.iMax   = ((class="type">int)rate.tick_volume > m_Marks.iMax ? m_Marks.iMax : (class="type">int)rate.tick_volume - class="num">1);
class="num">136.       m_Marks.bHigh  = (rate.open == rate.high) || (rate.close == rate.high);
class="num">137.       m_Marks.bLow   = (rate.open == rate.low) || (rate.close == rate.low);
class="num">138.       Simulation_Time(rate, tick);
class="num">139.       MountPrice(class="num">0, rate.open, rate.spread, tick);
class="num">140.       if (m_Marks.iMax > class="num">10)
class="num">141.        {
class="num">142.          i0 = (class="type">int)(MathMin(m_Marks.iMax / class="num">3.0, m_Marks.iMax * class="num">0.2));
class="num">143.          i1 = m_Marks.iMax - i0;
class="num">144.          i2 = (class="type">int)(((rate.high - rate.low) / m_TickSize) / i0);
class="num">145.          i2 = (i2 == class="num">0 ? class="num">1 : i2);
class="num">146.          b0 = (m_Marks.iMax >= class="num">1000 ? ((rand() & class="num">1) == class="num">1) : (rate.high - rate.open) < (rate.open - rate.low));
class="num">147.          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">148.          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);

◍ 极值标记与价格挂载的执行顺序

这段片段处在 tick 合成函数的尾部,核心动作是把当前 K 线的高低点标记复位,并按条件把价格挂进 tick 序列。第 150 行在 RandomWalk 调用后把 bHigh、bLow 同时置 true,意味着随机游走路径已覆盖极值,后续不再重复挂载。 若走 else 分支,则调用 Random_Price 生成价格;非 BID 模式下还会用 DistributeVolumeReal 分配真实成交量,这是复刻真实报价微观结构的关键一步。 154–156 行用 bLow / bHigh 的布尔状态做守卫:未标记低点时把 Unique(rate.high, tick) 作为低价挂载,未标记高点时反向处理,最后无论怎样都把 iMax 位置的 close 价挂上。

  • 行 CorretTime 修正时间戳,159 行返回 iMax 作为本函数写入的末位索引。外汇与贵金属 tick 仿真涉及点差与跳空,实盘验证前务必清楚这类随机合成仅用于回测,真实风险极高。
MQL5 / C++
class="num">149.             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">150.             m_Marks.bHigh = m_Marks.bLow = true;
class="num">151.
class="num">152.          }else Random_Price(rate, tick);
class="num">153.          if (!m_IsPriceBID) DistributeVolumeReal(rate, tick);
class="num">154.          if (!m_Marks.bLow) MountPrice(Unique(rate.high, tick), rate.low, rate.spread, tick);
class="num">155.          if (!m_Marks.bHigh) MountPrice(Unique(rate.low, tick), rate.high, rate.spread, tick);
class="num">156.          MountPrice(m_Marks.iMax, rate.close, rate.spread, tick);
class="num">157.          CorretTime(tick);
class="num">158.
class="num">159.          class="kw">return m_Marks.iMax;
class="num">160.       }
class="num">161. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">162. };
class="num">163. class=class="str">"cmt">//+------------------------------------------------------------------+

「压住回放里的报价洪峰」

真实分时报价灌进回放器时,单位时间内的 tick 数可能冲破系统内部上限。和纯模拟不同,这里卡在一点:加载阶段你根本不知道图表用的是 Bid 还是 Last 报价,也就没法提前按单一市场模型去裁剪。 C_FileTicks 里的 LoadTicks 是切入点。原函数第 10、11 行先暂存已加载分时报价的柱形位置;第 12 行开文件,第 13 行把文件里绝对所有的 tick 一次性读进来。读完后才确定图表类型,所以超限判断只能往后放。 改造思路是在 BuildBar1Min 的循环里动手:一分钟柱形闭合前,若记录的 tick 数超了预设值,就让模拟类用随机游走补运动,并覆盖掉多余的真实 tick。难点是执行顺序——第 21 行若留在原位置,BuildBar1Min 在第 24 行就建不出标准,必须按新片段重排。 第 27 行用双重检查:先确认 ToReplay 为真(避免负索引),再比 tick 数上限;第 29~34 行把位移指针设到待替位置、跑模拟、传数据、销毁模拟器。当前版本第 31 行模拟失败就不释放内存,属测试期小缺陷,下篇再修。 实际在 MT5 里跑这段代码时,把预设上限从默认调小到 200 附近,能明显看到一分钟柱内的报价抖动被熨平,但极端行情下随机游走可能偏离真实微观结构——外汇与贵金属杠杆高,回测结论仅代表历史数据倾向,不构成实盘指引。

MQL5 / C++
class="num">01.     class="type">class="kw">datetime LoadTicks(const class="type">class="kw">string szFileNameCSV, const class="type">bool ToReplay = true)
class="num">02.       {
class="num">03.           class="type">int     MemNRates,
class="num">04.                 MemNTicks;
class="num">05.           class="type">class="kw">datetime dtRet = TimeCurrent();
class="num">06.           class="type">MqlRates RatesLocal[],
class="num">07.                   rate;
class="num">08.           class="type">bool     bNew;
class="num">09. 
class="num">10.           MemNRates = (m_Ticks.nRate < class="num">0 ? class="num">0 : m_Ticks.nRate);
class="num">11.           MemNTicks = m_Ticks.nTicks;
class="num">12.           if (!Open(szFileNameCSV)) class="kw">return class="num">0;
class="num">13.           if (!ReadAllsTicks()) class="kw">return class="num">0;           
class="num">14.           rate.time = class="num">0;
把限流诊断交给小布
这些关于报价密度与平台响应瓶颈的判断,小布盯盘的 AIGC 已内置,打开对应品种页即可看到实时吞吐预警,你只管决定要不要降频重放。

常见问题

这通常与硬件无关,而是平台自身处理高频报价或成交的吞吐限制;期货合约在特定时段易出现此类脉冲,属已知瓶颈。
它主要在模拟类与后续限流判断中被调用,用于统一约束生成和重放的分时报价数量上限。
需在模拟类里做轻微修改,使生成节奏仍对齐原有时间轴,避免计数下降导致整体时序漂移。
可以,小布盯盘的品种页会标注当前报价密度是否逼近平台处理边界,并提示可能需要的重放降频操作。
外部数据来自真实录单或第三方,脉冲更不可控;内部模拟可调参数,但两者越界都会暴露平台瓶颈,故分别约束更稳。