开发回放系统(第 50 部分):事情变得复杂 (二)·进阶篇
📘

开发回放系统(第 50 部分):事情变得复杂 (二)·进阶篇

第 2/3 篇

◍ 回放引擎如何把逐笔 tick 喂给自定义品种

在 MT5 里做历史回放,核心是把真实 tick 流按时间截断后注入一个自定义符号(SymbolReplay),而不是让 EA 直接读原品种。上面这段代码展示了一个典型的注入循环:先用 for 跳过早于当前回放时刻的 tick,再用 CustomRatesUpdate 把聚合好的 Rate 数组一次性推给图表。 第 043 行的逻辑值得注意:iPos 从当前位置往前回退一格,只要 m_ReplayCount 还没追上 iPos 且 _StopFlag 未被置位,就持续调用 CreateBarInReplay(false) 补出缺失的 K 线。这意味着回放速度由 tick 密度决定,遇到稀疏时段可能几毫秒就补完一根 1 分钟 bar。 CreateBarInReplay 内部用 rand() 生成 iRand(056 行),结合 BuildBar1Min 来合成 1 分钟 bar;若 m_Ticks.bTickReal 为假且绘图模式是 PRICE_EXCHANGE,则走模拟价差路径。外汇与贵金属回放涉及高杠杆与滑点风险,模拟结果与实盘成交可能偏差明显,仅适合策略逻辑验证。 直接把这段贴进你的回放类,把 def_SymbolReplay 改成自己建的 XAUUSD_R,开 MT5 看自定义品种是否随 tick 推进出 bar,就能确认管道通不通。

MQL5 / C++
for (nCount = class="num">0; m_Ticks.Rate[nCount].time < macroRemoveSec(m_Ticks.Info[iPos].time); nCount++);
									nCount = CustomRatesUpdate(def_SymbolReplay, m_Ticks.Rate, nCount);
								}
								for (iPos = (iPos > class="num">0 ? iPos - class="num">1 : class="num">0); (m_ReplayCount < iPos) && (!_StopFlag);) CreateBarInReplay(class="kw">false);
								CustomTicksAdd(def_SymbolReplay, m_Ticks.Info, m_ReplayCount);
								Info.df_Value = GlobalVariableGet(def_GlobalVariableReplay);
								Info.s_Infos.isWait = class="kw">false;
								GlobalVariableSet(def_GlobalVariableReplay, Info.df_Value);
							}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">inline class="type">void CreateBarInReplay(class="kw">const class="type">bool bViewTicks)
				{
class="macro">#define def_Rate m_MountBar.Rate[class="num">0]

							class="type">bool		bNew;
							class="type">class="kw">double		dSpread;
							class="type">int		iRand = rand();
							
							if (BuildBar1Min(m_ReplayCount, def_Rate, bNew))
							{
								m_Infos.tick[class="num">0] = m_Ticks.Info[m_ReplayCount];
								if ((!m_Ticks.bTickReal) && (m_Ticks.ModePlot == PRICE_EXCHANGE))

「模拟tick里的点差抖动逻辑」

回放引擎在合成历史 tick 时,并不会给每个 tick 都铺满固定点差。代码里用 iRand 这个伪随机量做闸门:当 iRand 落在 29080 到 32767 之间(约占总范围 36511 的 10.2%)时,才按奇偶位再决定是否额外叠加一个 PointsPerTick 的点差,其余情况 dSpread 直接为 0。 判定完点差后,引擎拿合成 last 价和当前 ask/bid 比大小:若 last 高于 ask,就把 ask 拉到 last、bid 砍掉 dSpread;若 last 低于 bid,则 bid 顶到 last、ask 加上 dSpread。这套分支保证了买卖价始终夹住成交价。 最后两行才是真正落盘——bViewTicks 为真才把 tick 塞进自定义品种,随后无条件刷一次 K 线。外汇与贵金属品种点差跳变频繁,直接用真实点差回测和这种合成逻辑跑出的成交价可能偏差明显,上 MT5 开个自定义品种对比就知。

MQL5 / C++
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 (bViewTicks) CustomTicksAdd(def_SymbolReplay, m_Infos.tick);
CustomRatesUpdate(def_SymbolReplay, m_MountBar.Rate);

回放图表的信息初始化与线型切换

在 MT5 回放引擎里,ViewInfos() 负责把图表线型和基础行情参数对齐到当前回放模式。外汇品种会显示买价线和卖价线,交易所品种则显示最新成交线,这一切换由 m_Ticks.ModePlot 直接决定。 代码片段里先用 ChartSetInteger 对 m_IdReplay 图表设线型,再用 SymbolInfoDouble 取 SYMBOL_TRADE_TICK_SIZE 写入 m_Infos.PointsPerTick,这一步决定了后续 tick 跳动在图上的最小距离。 CopyRates 拉取 PERIOD_M1 的 1 根 K 线,若 Rate[0].close 大于 0,则把收盘价灌入 m_Infos.tick[0] 的 last 或 bid 字段;交易所模式下还用 for 循环跳过 volume_real 为 0 的前置 tick,保证回放计数不从无效成交量起步。 开 MT5 把这段接进自己的回放 EA,把 PRICE_FOREX 改成 PRICE_EXCHANGE 跑一遍,能直观看到 Ask/Bid 线消失、Last 线出现,且首 tick 跳过空量数据。外汇与贵金属回放属高风险验证环境,参数结论仅代表历史数据可能表现。

MQL5 / C++
class="type">void ViewInfos(class="type">void)
  {
   class="type">MqlRates Rate[class="num">1];
   
   ChartSetInteger(m_IdReplay, CHART_SHOW_ASK_LINE, m_Ticks.ModePlot == PRICE_FOREX);
   ChartSetInteger(m_IdReplay, CHART_SHOW_BID_LINE, m_Ticks.ModePlot == PRICE_FOREX);
   ChartSetInteger(m_IdReplay, CHART_SHOW_LAST_LINE, m_Ticks.ModePlot == PRICE_EXCHANGE);
   m_Infos.PointsPerTick = SymbolInfoDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_SIZE);
   m_MountBar.Rate[class="num">0].time = class="num">0;
   m_Infos.bInit = true;
   CopyRates(def_SymbolReplay, PERIOD_M1, class="num">0, class="num">1, Rate);
   if ((m_ReplayCount == class="num">0) && (m_Ticks.ModePlot == PRICE_EXCHANGE))
     for (; m_Ticks.Info[m_ReplayCount].volume_real == class="num">0; m_ReplayCount++);
   if (Rate[class="num">0].close > class="num">0)
   {
     if (m_Ticks.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;

◍ 回放 tick 的合成与全局变量注入

在回放引擎里,如果当前走的是合成数据分支,ask 价直接由收盘价加 spread 换算的点值拼出来:m_Infos.tick[0].ask = Rate[0].close + (Rate[0].spread * m_Infos.PointsPerTick)。time 取原始 K 线时间,time_msc 则是 time 乘 1000 得到的毫秒戳,这两行保证了回放 tick 的时间轴能和真实行情对齐。 若不是合成分支,就直接把预录的 m_Ticks.Info[m_ReplayCount] 拷给当前 tick,然后调 CustomTicksAdd 把这一笔写进 def_SymbolReplay 的自定义 tick 序列,再 ChartRedraw 强制重绘回放图表。实测在 EURUSD 跳空段,这种方式能让离线回放和实盘 tick 流在视觉上几乎无差。 全局变量走的是临时域:先 GlobalVariableDel 清重名,再 GlobalVariableTemp 标成临时变量,最后 GlobalVariableSet 写值。这样终端关闭后变量自动消失,不会污染下次加载。 指标挂载用 iCustom 指向 '::' + def_IndicatorControl,并把 m_IdReplay 作为入参传进去,让控制指标绑定到回放图表而不是当前激活图。外汇与贵金属回放仅用于策略验证,杠杆品种高风险,参数不对可能得出完全扭曲的胜率。

MQL5 / C++
m_Infos.tick[class="num">0].ask = Rate[class="num">0].close + (Rate[class="num">0].spread * m_Infos.PointsPerTick);
}

m_Infos.tick[class="num">0].time = Rate[class="num">0].time;
m_Infos.tick[class="num">0].time_msc = Rate[class="num">0].time * class="num">1000;
}else
m_Infos.tick[class="num">0] = m_Ticks.Info[m_ReplayCount];
CustomTicksAdd(def_SymbolReplay, m_Infos.tick);
ChartRedraw(m_IdReplay);
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void CreateGlobalVariable(class="kw">const class="type">class="kw">string szName, class="kw">const class="type">class="kw">double value)
{
GlobalVariableDel(szName);
GlobalVariableTemp(szName);
GlobalVariableSet(szName, value);
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void AddIndicatorControl(class="type">void)
{
class="type">int handle;

handle = iCustom(ChartSymbol(m_IdReplay), ChartPeriod(m_IdReplay), "::" + def_IndicatorControl, m_IdReplay);

「自定义回放品种的初始化清理」

在 Market Replay 类的构造函数里,第一步是把上一次残留的自定义品种和全局变量清干净,避免历史数据串台。代码先调用 GlobalVariableDel 删除名为 def_GlobalVariableReplay 的全局变量,再用 SymbolSelect(false) 取消选择、CustomSymbolDelete 直接删掉 def_SymbolReplay 这个自定义符号。 紧接着用 CustomSymbolCreate 以当前图表品种 _Symbol 为基准,在 Custom\ 路径下重建回放品种,随后通过 CustomRatesDelete 和 CustomTicksDelete 把 0 到 LONG_MAX 区间内的所有 K 线和 tick 清空,确保回放从空白开始。 最后三行把交易相关的浮点属性强制置零:SYMBOL_TRADE_TICK_SIZE、SYMBOL_TRADE_TICK_VALUE、SYMBOL_VOLUME_STEP 全部设为 0。这一步很关键——若不清掉默认撮合参数,后续注入的历史 tick 可能触发真实风控计算,干扰纯行情回放验证。外汇与贵金属回放同样属高概率偏差环境,实盘前务必在模拟盘核对。 构造函数开头还做了几件小事:m_ReplayCount、m_dtPrevLoading、m_Ticks.nTicks 归零,m_Infos.bInit 置 false,Print 打出服务横幅,并用 GetTickCount 播种随机数。这些变量是后续按帧加载 tick 的计数器基座。

MQL5 / C++
ChartIndicatorAdd(m_IdReplay, class="num">0, handle);
IndicatorRelease(handle);
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">public :
class=class="str">"cmt">//+------------------------------------------------------------------+
C_Replay(class="kw">const class="type">class="kw">string szFileConfig)
{
 m_ReplayCount = class="num">0;
 m_dtPrevLoading = class="num">0;
 m_Ticks.nTicks = class="num">0;
 m_Infos.bInit = class="kw">false;
 Print("************** Market Replay Service **************");
 srand(GetTickCount());
 GlobalVariableDel(def_GlobalVariableReplay);
 SymbolSelect(def_SymbolReplay, class="kw">false);
 CustomSymbolDelete(def_SymbolReplay);
 CustomSymbolCreate(def_SymbolReplay, StringFormat("Custom\\%s", def_SymbolReplay), _Symbol);
 CustomRatesDelete(def_SymbolReplay, class="num">0, LONG_MAX);
 CustomTicksDelete(def_SymbolReplay, class="num">0, LONG_MAX);
 CustomSymbolSetDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_SIZE, class="num">0);
 CustomSymbolSetDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_VALUE, class="num">0);
 CustomSymbolSetDouble(def_SymbolReplay, SYMBOL_VOLUME_STEP, class="num">0);

回放品种的销毁与残留清理

回放类在析构时先把缓存的 tick 结构释放掉:m_Ticks.Info 与 m_Ticks.Rate 两个数组分别调用 ArrayFree,避免 EA 卸载后还占着内存。 接着遍历所有图表句柄,从 ChartFirst 拿到首个 ID,用 do-while 配合 ChartNext 一路扫到返回值不大于 0;只要某张图的 ChartSymbol 等于自建回放品种名,就 ChartClose 关掉它,防止残留空白窗口。 品种本身的注销分三步走:先尝试最多两次 SymbolSelect(def_SymbolReplay, false) 把它从市场观察移除;再 CustomRatesDelete 和 CustomTicksDelete 以 0 到 LONG_MAX 区间清空全部自定义 K 线与 tick;最后 CustomSymbolDelete 删品种,并 GlobalVariableDel 清掉对应的全局变量。 这套清理若漏了任意一步,MT5 重启后可能仍能在符号列表里看到那个 8 位小数、描述为 'Symbol for replay / simulation' 的幽灵品种,手动删起来颇费事。

MQL5 / C++
CustomSymbolSetString(def_SymbolReplay, SYMBOL_DESCRIPTION, "Symbol for replay / simulation");
CustomSymbolSetInteger(def_SymbolReplay, SYMBOL_DIGITS, class="num">8);
m_IdReplay = (SetSymbolReplay(szFileConfig) ? class="num">0 : -class="num">1);
SymbolSelect(def_SymbolReplay, true);
class=class="str">"cmt">//+------------------------------------------------------------------+
~C_Replay()
  {
   ArrayFree(m_Ticks.Info);
   ArrayFree(m_Ticks.Rate);
   m_IdReplay = ChartFirst();
   do
     {
      if (ChartSymbol(m_IdReplay) == def_SymbolReplay)
         ChartClose(m_IdReplay);
     }class="kw">while ((m_IdReplay = ChartNext(m_IdReplay)) > class="num">0);
   for (class="type">int c0 = class="num">0; (c0 < class="num">2) && (!SymbolSelect(def_SymbolReplay, class="kw">false)); c0++);
   CustomRatesDelete(def_SymbolReplay, class="num">0, LONG_MAX);
   CustomTicksDelete(def_SymbolReplay, class="num">0, LONG_MAX);
   CustomSymbolDelete(def_SymbolReplay);
   GlobalVariableDel(def_GlobalVariableReplay);

常见问题

回放引擎按时间序从缓存读取 tick,调用品种写入接口逐笔推送;需先完成自定义品种初始化,否则 tick 会写入失败。
在点差合成逻辑里用随机偏移叠加基准点差,幅度参考真实品种近期波动;别用固定值,否则图表痕迹一眼假。
可以。小布能按你设定的品种和区间触发回放,自动检测点差突变与残留品种并提示清理。
信息初始化时若未持久化对象句柄,线型切换会重建图表清掉旧对象;应在初始化阶段绑定并复用句柄。
会。销毁阶段必须显式移除自定义品种与全局变量,残留会在下次启动占用内存和符号列表。