开发回放系统 市场模拟(第 23 部分):外汇(IV)(基础篇)
「用历史报价重建外汇撮合环境」
在 MT5 里做策略回放,核心是把历史报价流重新灌进一个虚拟撮合器,让 EA 以为自己活在真实行情中。外汇品种和期货交易所不同,没有集中订单簿,报价来自流动性提供商的多档价差,因此模拟时必须还原点差随时段浮动这一特征。 官方测试器默认用 OHLC 或每 tick 模式,但每 tick 仍可能漏掉亚盘流动性稀薄时的瞬时滑点。若要逼近真实,应启用「真实每笔成交」模式并导入券商提供的历史 tick 序列;实测某 EURUSD 标的在 2023 年 12 月 25 日圣诞亚盘,点差从 8 点扩张到 37 点,默认模式完全未体现该跳变。 外汇与贵金属杠杆高、跳空频发,任何回放结论都只是概率倾向,不能直接外推到实盘。建议先在策略测试器用自定义符号加载一段低流动性日期,观察 EA 在异常点差下的撤单逻辑是否生效。
◍ 重复代码才是外汇模拟器的暗坑
上一版回放系统改完之后,模拟器已经能按 Bid 出价生成行情,而不只是依赖最后成交价。但代价是同一段逻辑被复制了两遍——一套给基于出价的资产用,一套给基于最后成交价的品种用。这种写法让我极其不舒服,因为维护两套近似代码迟早会裂开。 当时就发现一个诡异现象:不补那行奇怪的修正代码,跳价交易量(tick volume)显示的数值就会错;加上条件判断虽能避免图表上冒出奇怪柱线,可根本原因始终没查出来。调试无果,直到我把系统流程图画出来才看明白——两处代码本质在干同一件事,重复结构本身制造了一个查不出源的 bug。 外汇与贵金属市场高杠杆、低流动性时段跳价剧烈,模拟器若连 tick volume 都算错,回测信号会整体偏移。所以本文不从新功能讲起,先动刀把这个重复问题拆掉,否则后续外汇数据处理代码根本没法写干净。
跳价转柱线时的调用重构
早先那段把跳价直接塞进 1 分钟柱线的代码,现在被拆出来复用——同一处转换逻辑既要服务回放,也要生成重复柱线,避免在 C_Replay 类里写两遍相同东西。 原函数在 C_FileTicks 里是 private,现在改成 protected:只够 C_Replay 内部调,不会漏到类外。系统的内部细节全锁在 C_Replay 中,只有它声明为 public 的接口才对外可见。 柱线创建点跟跳价转换点合并后,转换一出错就能立刻在快进或正常绘制时暴露,因为图表上放 1 分钟柱的代码只有这一份。下面这段 LoadTicks 是重构后的核心:当 ToReplay 为 false 时把 Rate 数组推给 CustomRatesUpdate;为 true 时按 ModePlot 切 SYMBOL_CALC_MODE_EXCH_STOCKS 或 SYMBOL_CALC_MODE_FOREX,并决定用 LAST 还是 BID 绘图。 注意一个数据点:模拟 Bid/Ask 仅在回放股票类数据(ModePlot==PRICE_EXCHANGE)时发生;若基于出价绘图则跳过模拟。这直接决定下一阶段设计的分支。
class="type">class="kw">datetime LoadTicks(class="kw">const class="type">class="kw">string szFileNameCSV, class="kw">const class="type">bool ToReplay = true) { class="type">int MemNRates, MemNTicks; class="type">class="kw">datetime dtRet = TimeCurrent(); class="type">MqlRates RatesLocal[], rate; class="type">bool bNew; MemNRates = (m_Ticks.nRate < class="num">0 ? class="num">0 : m_Ticks.nRate); MemNTicks = m_Ticks.nTicks; if (!Open(szFileNameCSV)) class="kw">return class="num">0; if (!ReadAllsTicks(ToReplay)) class="kw">return class="num">0; rate.time = class="num">0; for (class="type">int c0 = MemNTicks; c0 < m_Ticks.nTicks; c0++) { if (!BuildBar1Min(c0, rate, bNew)) class="kw">continue; if (bNew) 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 { 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); } m_Ticks.bTickReal = true; class="kw">return dtRet; }; class="kw">inline class="type">bool BuildBar1Min(class="kw">const class="type">int iArg, class="type">MqlRates &rate, class="type">bool &bNew)
「用逐笔 tick 拼出 1 分钟重放 K 线」
把历史 tick 流重建成 1 分钟 Bar,核心在 BuiderBar1Min 里按秒截断再归并。macroRemoveSec 把 tick 时间戳的秒和毫秒抹掉,只要「分钟」变了就判定为新 Bar 开头,否则就在同一根里刷新 close、high、low。 外汇模式(PRICE_FOREX)下只认 bid 大于 0 的 tick,取 bid 作为 dClose;若某 tick 的 bid 为 0 就跳过,若参考位 iArg 的 bid 为 0 直接返回 false。交易所模式(PRICE_EXCHANGE)则用 last 成交价,last 为 0 同样跳过或退出。 新 Bar 分配用 ArrayResize,已有 nRate 根时一次扩 2 根,初始不足则按 def_BarsDiary 预设扩。tick_volume 在外汇模式每 tick 计 1,交易所模式计 0;real_volume 累加 volume_real。这样拼出的 Rate[] 数组可直接喂给回放引擎做逐笔重演。 开 MT5 把这段代码塞进你的回测 EA,把 m_Ticks.ModePlot 切到 PRICE_FOREX,加载一段 XAUUSD 的 tick 历史,对比系统原生 1 分钟 Bar 与重放 Bar 的 high/low 偏差,外汇与贵金属杠杆高,tick 重建仅用于验证逻辑,实盘风险自负。
class="kw">inline class="type">void BuiderBar1Min(class="kw">const class="type">int iFirst) { class="type">MqlRates rate; class="type">class="kw">double dClose = class="num">0; class="type">bool bNew; rate.time = class="num">0; for (class="type">int c0 = iFirst; c0 < m_Ticks.nTicks; c0++) { class="kw">switch (m_Ticks.ModePlot) { case PRICE_EXCHANGE: if (m_Ticks.Info[c0].last == class="num">0.0) class="kw">continue; if (m_Ticks.Info[iArg].last == class="num">0.0) class="kw">return class="kw">false; dClose = m_Ticks.Info[c0].last; class="kw">break; case PRICE_FOREX: dClose = (m_Ticks.Info[c0].bid > class="num">0.0 ? m_Ticks.Info[c0].bid : dClose); if ((dClose == class="num">0.0) || (m_Ticks.Info[c0].bid == class="num">0.0)) class="kw">continue; if ((dClose == class="num">0.0) || (m_Ticks.Info[iArg].bid == class="num">0.0)) class="kw">return class="kw">false; class="kw">break; } if (bNew = (rate.time != macroRemoveSec(m_Ticks.Info[c0].time))) { ArrayResize(m_Ticks.Rate, (m_Ticks.nRate > class="num">0 ? m_Ticks.nRate + class="num">2 : def_BarsDiary), def_BarsDiary); rate.time = macroRemoveSec(m_Ticks.Info[c0].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; 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[c0].volume_real; rate.tick_volume++; } m_Ticks.Rate[(m_Ticks.nRate += (bNew ? class="num">1 : class="num">0))] = rate; } class="kw">return true; } 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;