开发回放系统  市场模拟(第 23 部分):外汇(IV)(基础篇)
📘

开发回放系统 市场模拟(第 23 部分):外汇(IV)(基础篇)

第 1/3 篇

「用历史报价重建外汇撮合环境」

在 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)时发生;若基于出价绘图则跳过模拟。这直接决定下一阶段设计的分支。

MQL5 / C++
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 重建仅用于验证逻辑,实盘风险自负。

MQL5 / C++
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;

常见问题

先导出对应品种的逐笔 tick 数据,按时间序重放报价并模拟点差与滑点,再接你的下单逻辑即可,不必重造轮子。
重复代码会让跳价转柱线、调用重构等改动要改好几处,极易漏改出 bug;把通用拼 K 与撮合抽成独立函数最稳妥。
小布可读取品种历史 tick 并自动拼出重放 K 线、标注模拟成交点,你直接看盘口回放不用自己写环境。
跳价时按时间戳归桶到对应分钟,缺失段用最后已知价补齐并标记跳空,别直接丢弃 tick。
挑一天波动率大的行情,对比重建的 1 分钟 K 线与实盘同周期 K 线的开高低收是否一致即可。