开发回放系统 市场模拟(第 21 部分):外汇(II)(基础篇)
在 MT5 里重建外汇分笔成交的回放骨架
MetaTrader 5 的回放系统依赖历史分笔成交(tick)流来重演市场。要模拟外汇品种,核心是先拿到真实 tick 数据,再按时间序逐笔喂给测试引擎,而不是用 K 线级 OHLC 近似。 真实 tick 与聚合 K 线的最大差异在于点差跳动与报价刷新频率。以 EURUSD 为例,活跃时段每秒可能出现 3~10 次报价更新,伦敦-纽约重叠段峰值更高;若只用 1 分钟收盘重构,会丢失约 90% 以上的日内微观波动结构。 下面这段 MQL5 片段演示了如何打开历史 tick 序列并读取首条记录的时间戳,作为回放起点对齐的基准。 外汇与贵金属杠杆高、跳空频繁,用回放做策略验证时,需明确模拟点差与真实点差可能偏离,结论仅具概率意义。
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 把上面类骨架建好、改两行配置就能验证。
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 直接报错)。
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 标志仍会被打开。
(*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 返回空串,否则通过引用把对应行号传出并返回文本。外汇与贵金属脚本跑文件解析时,这种封装能让你在日志里直接定位到具体行,排查概率更高,但市场高杠杆风险仍在,仅作工具思路。
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];