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

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

第 1/3 篇

在 MT5 里重建外汇分笔成交的回放骨架

MetaTrader 5 的回放系统依赖历史分笔成交(tick)流来重演市场。要模拟外汇品种,核心是先拿到真实 tick 数据,再按时间序逐笔喂给测试引擎,而不是用 K 线级 OHLC 近似。 真实 tick 与聚合 K 线的最大差异在于点差跳动与报价刷新频率。以 EURUSD 为例,活跃时段每秒可能出现 3~10 次报价更新,伦敦-纽约重叠段峰值更高;若只用 1 分钟收盘重构,会丢失约 90% 以上的日内微观波动结构。 下面这段 MQL5 片段演示了如何打开历史 tick 序列并读取首条记录的时间戳,作为回放起点对齐的基准。 外汇与贵金属杠杆高、跳空频繁,用回放做策略验证时,需明确模拟点差与真实点差可能偏离,结论仅具概率意义。

MQL5 / C++
class="type">MqlTick tick_array[class="num">1];
class="type">int copied = CopyTicks(Symbol(), tick_array, COPY_TICKS_ALL, class="num">0, class="num">1);
if(copied > class="num">0)
   Print("First tick time: ", tick_array[class="num">0].time);

「回放系统已能兼容外汇与无成交空窗」

上一步我们把回放/模拟系统改到能读外汇行情,本小节继续把适配面摊开:现在系统不只能抓最新成交价,还接进了基于出价(bid)的显示逻辑,这类呈现方式在部分资产或细分市场里才是常态。 一个容易卡死的老问题是:某些品种在一段时间完全没有交易时,跳价数据会让回放系统锁住不动。我们加了定时释放机制——系统进定时模式,过了设定时长自动解锁,空窗期也能继续往下跑。 目前外汇实跑还没撞到这套机制失效,但新挑战已经在排队。贵金属与外汇杠杆高、跳空频繁,建议你开 MT5 用 EURUSD 或 XAUUSD 的稀疏时段数据手动拖一遍,验证定时解锁是否如预期。

◍ 配置文件乱序加载会清空图表,靠内存预读解决

回放或模拟服务启动时,若图表完全空白且无法访问控制指标,多半不是你用错了,而是自定义资产里的数据被整体清除——可能是配置改动,也可能是数据损坏。尤其在先加载 1 分钟柱线、再补跳价数据时,系统会切换显示价格方式,把已有柱线全删掉,失败率明显上升。 直接的修法是强制要求配置里先声明跳价再声明柱线,但这等于给用户加了无意义的顺序束缚。更合理的做法是:把整个配置文件读进内存,再按系统需要的顺序去取元素,而不是让用户迁就文件书写顺序。 我倾向一次性把文件全载到内存,用 FileSeek / FileTell 或纯内存访问都行,核心是「先吃跳价、后吃旧柱」这个序列不能反。MQL5 标准库的 CArrayString 已经测得很稳,用指针去访问它就行;但要记住,MQL5 的指针和 C/C++ 不同,初始化前乱读乱写会崩。下面这段包含文件与类骨架是起点: #include "C_FileBars.mqh" #include "C_FileTicks.mqh" //+------------------------------------------------------------------+ #include <Arrays\ArrayString.mqh> //+------------------------------------------------------------------+ class C_ConfigService : protected C_FileTicks { // ... Internal class code .... } 上面代码逐行看:前两句引入柱线与跳价文件处理类;中间引入标准库动态字符串数组;类 C_ConfigService 以保护继承方式拿到 C_FileTicks 的能力,内部再挂指针成员指向 CArrayString 实例。指针必须先 new 初始化,否则默认 NULL,调用方法前一定要确认非 NULL。 只读文件名不够排错。系统按内部顺序读数据,一旦出错很难定位到配置哪一行。我加了个轻量自研类 C_Array(原理同 CArrayInt 但能同时存文件名与行号),代价比硬套标准库小。修改后配置函数能直接报「第 X 行出错」,几乎零成本。 别把标准库当圣经 不是反对用标准库,而是当只需要其中两个方法时,自研反而省事。C_Array 几十行就解决了行号追踪,还顺带让系统能回放类外汇数据。 为了免重复编译,我用用户变量切换读取模型:1=跳价-跳价、2=跳价-柱线、3=柱线-跳价、4=柱线-柱线(表格 01)。默认走 1,越界就回退默认。同样数据库换模式,均值类指标会有细微差异。最终加载函数总是先载回放用数据、再载旧柱,外汇与贵金属数据回放高风险,参数乱填可能图表异常,开 MT5 把上面类骨架建好、改两行配置就能验证。

MQL5 / C++
class="macro">#include "C_FileBars.mqh"
class="macro">#include "C_FileTicks.mqh"
class=class="str">"cmt">//+------------------------------------------------------------------+
class="macro">#include <Arrays\ArrayString.mqh>
class=class="str">"cmt">//+------------------------------------------------------------------+
class C_ConfigService : class="kw">protected C_FileTicks
{
class=class="str">"cmt">// ... Internal class code ....
}

回放引擎的私有结构与配置载入

做 MT5 历史逐笔回放,第一步是把回放所需的私有数据和文件指针先架空。下面这段类内 private 区就定义了四个 CArrayString 指针,分别管 tick 重播、bar 转 tick、tick 转 bar、以及前序 bar 缓存,初始全置 NULL 避免野指针。 在 SetSymbolReplay 里,配置 csv 通过 FileOpen 打开,路径硬编码在 "Market Replay\\" 下;若返回 INVALID_HANDLE 直接打印失败并 return false,回放服务起不来。 读循环用 FileIsEnding + _StopFlag 双条件控制,逐行 GetDefinition 分流:Transcription_DEFINE 走参数解析,Transcription_INFO 按 cStage 阶段挂载文件。stage 1 时把 macroFileName 塞进 pBarsToPrev 数组,并 new 一个 C_FileBars 做 LoadPreView,返回 0 则 cError=3 报错,否则 bBarsPrev 置 true。 stage 2 才正式把 tick 文件加进 pTicksToReplay。可见回放不是直接读 tick,而是先校验前序 bar 可用性,再决定 tick 流是否装载——顺序错了就会卡在 cError=2(stage 0 收到 INFO 直接报错)。

MQL5 / C++
class="kw">private :
class=class="str">"cmt">//+------------------------------------------------------------------+
    enum eTranscriptionDefine {Transcription_INFO, Transcription_DEFINE};
    class="type">class="kw">string m_szPath;
    class="kw">struct st001
    {
        CArrayString *pTicksToReplay, *pBarsToTicks, *pTicksToBars, *pBarsToPrev;
    }m_GlPrivate;
class="type">bool SetSymbolReplay(const class="type">class="kw">string szFileConfig)
{
class=class="str">"cmt">// ... Internal code ...
m_GlPrivate.pTicksToReplay = m_GlPrivate.pTicksToBars = m_GlPrivate.pBarsToTicks = m_GlPrivate.pBarsToPrev = NULL;
class=class="str">"cmt">// ... The rest of the code ...
}
class="type">bool SetSymbolReplay(const class="type">class="kw">string szFileConfig)
{
class="macro">#define macroFileName((m_szPath != NULL ? m_szPath + "\\" : "") + szInfo)
    class="type">int    file,
           iLine;
    class="type">char   cError,
           cStage;
    class="type">class="kw">string szInfo;
    class="type">bool   bBarsPrev;
    if ((file = FileOpen("Market Replay\\" + szFileConfig, FILE_CSV | FILE_READ | FILE_ANSI)) == INVALID_HANDLE)
    {
        Print("Failed to open the configuration file [", szFileConfig, "]. Closing the service...");
        class="kw">return false;
    }
    Print("Loading ticks for replay. Please wait....");
    ArrayResize(m_Ticks.Rate, def_BarsDiary);
    m_Ticks.nRate = -class="num">1;
    m_Ticks.Rate[class="num">0].time = class="num">0;
    iLine = class="num">1;
    cError = cStage = class="num">0;
    bBarsPrev = false;
    m_GlPrivate.pTicksToReplay = m_GlPrivate.pTicksToBars = m_GlPrivate.pBarsToTicks = m_GlPrivate.pBarsToPrev = NULL;
    class="kw">while ((!FileIsEnding(file)) && (!_StopFlag) && (cError == class="num">0))
    {
        class="kw">switch (GetDefinition(FileReadString(file), szInfo))
        {
            case Transcription_DEFINE:
                cError = (WhatDefine(szInfo, cStage) ? class="num">0 : class="num">1);
                break;
            case Transcription_INFO:
                if (szInfo != "") class="kw">switch (cStage)
                {
                    case class="num">0:
                        cError = class="num">2;
                        break;
                    case class="num">1:
                        if (m_GlPrivate.pBarsToPrev == NULL) m_GlPrivate.pBarsToPrev = new CArrayString();
                        (*m_GlPrivate.pBarsToPrev).Add(macroFileName);
                        pFileBars = new C_FileBars(macroFileName);
                        if ((m_dtPrevLoading = (*pFileBars).LoadPreView()) == class="num">0) cError = class="num">3; else bBarsPrev = true;
                        class="kw">delete pFileBars;
                        break;
                    case class="num">2:
                        if (m_GlPrivate.pTicksToReplay == NULL) m_GlPrivate.pTicksToReplay = new CArrayString();
}

「回放指令解析里的错误码分支」

这段逻辑处在宏文件逐行解析的 switch 里,case 3~5 分别处理「Ticks 转 Bars 预载」「Bars 转 Ticks」「配置读取」三类任务,每个分支失败都会把 cError 置成 4~7 的具体编号。 case 3 中 LoadTicks(macroFileName, false) 返回 0 时 cError=5,否则把 bBarsPrev 标 true;case 4 调 BarsToTicks 失败则 cError=6。这些编号不是随便写的,后面统一在 switch(cError) 里分流——cError 为 0 且 m_Ticks.nTicks<=0 会打印「No ticks to use」并改成 -1,说明没有可回放数据就直接停服务。 值得在 MT5 里验证的是:解析完无论分支如何,都会顺序执行 Cmd_TicksToReplay、Cmd_BarsToTicks,并用 Cmd_TicksToBars / Cmd_BarsToPrev 的返回值去「或」更新 bBarsPrev。也就是说,前面 case 即使没走,只要这两个 Cmd 返回 true,预载 Bars 标志仍会被打开。

MQL5 / C++
(*m_GlPrivate.pTicksToReplay).Add(macroFileName);
if (LoadTicks(macroFileName) == class="num">0) cError = class="num">4;
break;
case class="num">3:
if (m_GlPrivate.pTicksToBars == NULL) m_GlPrivate.pTicksToBars = new CArrayString();
(*m_GlPrivate.pTicksToBars).Add(macroFileName);
if ((m_dtPrevLoading = LoadTicks(macroFileName, false)) == class="num">0) cError = class="num">5; else bBarsPrev = true;
break;
case class="num">4:
if (m_GlPrivate.pBarsToTicks == NULL) m_GlPrivate.pBarsToTicks = new CArrayString();
(*m_GlPrivate.pBarsToTicks).Add(macroFileName);
if (!BarsToTicks(macroFileName)) cError = class="num">6;
break;
case class="num">5:
if (!Configs(szInfo)) cError = class="num">7;
break;
}
break;
};
iLine += (cError > class="num">0 ? class="num">0 : class="num">1);
}
FileClose(file);
Cmd_TicksToReplay(cError);
Cmd_BarsToTicks(cError);
bBarsPrev = (Cmd_TicksToBars(cError) ? true : bBarsPrev);
bBarsPrev = (Cmd_BarsToPrev(cError) ? true : bBarsPrev);
class="kw">switch(cError)
{
case class="num">0:
if (m_Ticks.nTicks <= class="num">0)
{
Print("No ticks to use. Closing the service...");
cError = -class="num">1;
}else if (!bBarsPrev) FirstBarNULL();
break;

◍ 用类把报错行号和文本捆在一起

这段落底层的 switch 收尾很直白:case 1 打印“第 iLine 行命令无法被系统识别”,case 2 打印“第 iLine 行内容不符合预期”,default 兜底报文件访问错误。最终 return 看 cError,为 0 时取反 _StopFlag,否则直接 false,逻辑干净不拖泥。 真正值得抄的是后面的 C_Array 类。它用 m_Info[] 存文本、m_nLine[] 存行号、m_maxIndex 记当前数量,把“哪一行出了什么错”绑成一个结构,比散着用两个数组省心。 构造函数把 m_maxIndex 置 0;析构时若 m_maxIndex>0 就 ArrayResize 两个数组到 0 释放。Add 每次 m_maxIndex++ 后双数组同步扩容,再把 Info 和 nLine 写进末尾——注意下标用 m_maxIndex-1。 At 方法做越界保护:Index>=m_maxIndex 时 nLine 赋 -1 返回空串,否则通过引用把对应行号传出并返回文本。外汇与贵金属脚本跑文件解析时,这种封装能让你在日志里直接定位到具体行,排查概率更高,但市场高杠杆风险仍在,仅作工具思路。

MQL5 / C++
case class="num">1   : Print("Command in line ", iLine, " could not be recognized by the system...");   break;
case class="num">2   : Print("The contents of the line are unexpected for the system ", iLine);          break;
class="kw">default : Print("Error occurred class="kw">while accessing one of the specified files...");
      }
      class="kw">return (cError == class="num">0 ? !_StopFlag : false);
class="macro">#undef macroFileName
   }
class="macro">#class="kw">property copyright "Daniel Jose"
class=class="str">"cmt">//+------------------------------------------------------------------+
class C_Array
{
   class="kw">private :
class=class="str">"cmt">//+------------------------------------------------------------------+
      class="type">class="kw">string  m_Info[];
      class="type">int     m_nLine[];
      class="type">int     m_maxIndex;
class=class="str">"cmt">//+------------------------------------------------------------------+
   class="kw">public  :
      C_Array()
         :m_maxIndex(class="num">0)
      {};
class=class="str">"cmt">//+------------------------------------------------------------------+
      ~C_Array()
      {
         if (m_maxIndex > class="num">0)
         {
            ArrayResize(m_nLine, class="num">0);
            ArrayResize(m_Info, class="num">0);
         }
      };
class=class="str">"cmt">//+------------------------------------------------------------------+
      class="type">bool Add(const class="type">class="kw">string Info, const class="type">int nLine)
      {
         m_maxIndex++;
         ArrayResize(m_Info, m_maxIndex);
         ArrayResize(m_nLine, m_maxIndex);
         m_Info[m_maxIndex - class="num">1] = Info;
         m_nLine[m_maxIndex - class="num">1] = nLine;
         class="kw">return true;
      }
class=class="str">"cmt">//+------------------------------------------------------------------+
      class="type">class="kw">string At(const class="type">int Index, class="type">int &nLine) const
      {
         if (Index >= m_maxIndex)
         {
            nLine = -class="num">1;
            class="kw">return "";
         }
         nLine = m_nLine[Index];

常见问题

多是配置文件乱序加载触发了清空逻辑。用内存预读把配置整批读入再按顺序喂给回放引擎,可避免图表被清。
不会。回放骨架已兼容外汇与无成交空窗,引擎会按时间推进跳过空窗,不阻塞后续分笔重放。
可以。小布能按品种调出已内置的回放诊断,自动预读配置并标出报错行号,你只管看盘口结论。
用类把报错行号和错误文本绑在一起,引擎走错误码分支时直接返回绑定对象,一眼看到行号与原因。
把回放引擎的私有结构和配置载入拆成独立模块,新增品种只改配置映射,不动核心重放逻辑即可。