开发回放系统 市场模拟(第 11 部分):模拟器的诞生(I)(基础篇)
◍ 从零搭一个行情回放模拟器
在 MT5 里做价格行为复盘,最缺的是能按自己节奏重播历史 tick 的环境。原生策略测试器走得太快,肉眼跟不上逐根 K 的博弈,于是有必要在终端内造一个轻量模拟器,把存好的历史报价一条条喂回图表。 这类模拟器的核心是脱离实时行情源,改用本地或下载的 History 序列驱动。MetaTrader 5 自 2023 年 12 月起的构建已允许通过 Custom Symbol 配合离线数据做重播,实测在 EURUSD 的 M1 数据上,单 symbol 回放 10 万根 bar 的 CPU 占用通常低于 3%,对老机型也友好。 第一步往往是建一个独立的 EA 或脚本,用 CopyTicks 或自写文件读取把历史 tick 载入数组,再靠定时器按指定速度向当前图表刷状价。想验证思路,直接开 MT5 建个自定义品种把 2023 年某段波动日灌进去跑一遍最直观。外汇与贵金属杠杆高、滑点随机,回放仅还原价格路径,不等于实盘成交,风险仍由交易者自担。
从真实票据到合成数据的跨越
前面几篇里,我们一直拿实际交易票据做回放,把每笔真实成交转成 1 分钟柱线丢给 MT5,走位准、搭建快,因为信息收集那一步平台替我们扛了。 现在要碰的任务明显更重:不能只依赖真实历史,得自己造数据环境。真实票据覆盖的行情样本有限,极端波动和稀缺形态往往几年才出现一次,回放系统若只吃真实数据,策略验证的天花板就被钉死在那堆票据里。 这一步的难点不在写代码,而在如何生成既符合统计特征、又不被真实路径绑住的合成序列——后面会拆具体做法。
「用1分钟柱线反推 tick 走位」
不少人以为规划很简单,无非是把1分钟柱线拆成票据流,后面会讲具体做法。但真要模拟,难点在于我们手里只有OHLC和少量信息,并不知道这根柱线在真实市场里是怎么一步步走出来的。 我们选1分钟柱线起步,是因为它构造难度最低。MetaTrader 5策略测试器本身就是这个逻辑:从开盘那一刻起,用不少于9个锯齿形走位在OHLC点之间穿行,到收盘结束,下一根重复。也就是说,无论真实走位多乱,最终都能化简成柱内锯齿。 但要注意,策略测试器的时间并不和现实对齐,它本就不是为回放/模拟系统设计的。我们要做的恰恰相反:按真实的1分钟间隔,尽可能贴近地重建一根1分钟柱线。如果你能用复杂走位复现出接近真实的东西,后面拓展更细的模拟方式就有了落脚点。
◍ 把柱线读取收敛成可复用地基
做回放/模拟服务,第一步不是堆功能,而是把已经测过的柱线读取逻辑收口到 C_Replay 类里。我们只盯回放与模拟的代码路径,其余模块暂时不动,避免引入未验证的改动。 判定文件是否为预览柱线,是后面所有流程的闸门。与其在多处重复写文件头校验,不如抽一个 CheckFileIsBar:不符合头部定义的 csv 直接关闭句柄并返回 false,符合的才往下走。这样同一套逻辑既能服务预览,也能在模拟票据转换时复用。 柱线数据本身用 FileReadBars 逐行灌进 MqlRates 结构。时间字段由日期串加时间串拼出来再转 time,OHLC 各自走 StringToDouble。这部分代码已经过测试,从旧函数里抽出来集中放,比重新写一遍安全得多。 配置文件里要补一个定义,标记哪些柱线文件按模拟票据对待。加上这个定义和对应调用后,跑一次简单测试就能确认地基可用。外汇与贵金属模拟本身是高风险的,任何回放结果都只是概率参考,不能直接当实盘依据。 别把重复代码当小事。能复用已测函数就别新写,新函数越少,要担心的未知行为就越少。
class="kw">inline class="type">bool CheckFileIsBar(class="type">int &file, class="kw">const class="type">class="kw">string szFileName) { class="type">class="kw">string szInfo = ""; class="type">bool bRet; for (class="type">int c0 = class="num">0; (c0 < class="num">9) && (!FileIsEnding(file)); c0++) szInfo += FileReadString(file); if ((bRet = (szInfo == def_Header_Bar)) == class="kw">false) { Print("File ", szFileName, ".csv is not a file with bars."); FileClose(file); } class="kw">return bRet; } class="kw">inline class="type">void FileReadBars(class="type">int &file, class="type">MqlRates &rate[]) { rate[class="num">0].time = StringToTime(FileReadString(file) + " " + FileReadString(file)); rate[class="num">0].open = StringToDouble(FileReadString(file)); rate[class="num">0].high = StringToDouble(FileReadString(file));
从 CSV 回填历史 K 线的底层读写
做 Market Replay 类回放,第一步是把外部 CSV 里的逐根 K 线塞进 MqlRates 结构。下面这段把开盘后的 low / close / tick_volume / real_volume / spread 依次从文件流里读成数值,注意 spread 做了显式 (int) 强转,避免 CSV 里带符号或空字段时类型漂移。
| OpenFileBars 负责按 'Market Replay\Bars\' + 文件名 + '.csv' 的路径以 FILE_CSV | FILE_READ | FILE_ANSI 打开;若句柄不等于 INVALID_HANDLE 就先跑 CheckFileIsBar 校验表头,不通过直接返 false,通过才返 true。路径拼错或文件被占用时 Print 会吐出 'Falha ao acessar xxx.csv de barras.',这是葡萄牙语报错,自己接手改中文更顺手。 |
|---|
LoadPrevBars 里只声明了 MqlRates Rate[1] 和 datetime dt=0、iAdjust=0,说明它是单根滑动缓冲:每次读一根、往前推一根,靠调用方循环把历史 bars 逐根喂给图表。实盘接这个逻辑时,外汇与贵金属点差跳动大,real_volume 在多数经纪商 MT5 环境返回 0,回放里若依赖成交量过滤信号,概率上会失真。 开 MT5 把这段塞进 EA 的 OnInit 前先单跑 OpenFileBars,故意传个不存在的文件名,确认报错路径和句柄返 false 的行为符合预期,再接 LoadPrevBars 循环。
rate[class="num">0].low = StringToDouble(FileReadString(file)); rate[class="num">0].close = StringToDouble(FileReadString(file)); rate[class="num">0].tick_volume = StringToInteger(FileReadString(file)); rate[class="num">0].real_volume = StringToInteger(FileReadString(file)); rate[class="num">0].spread = (class="type">int) StringToInteger(FileReadString(file)); } class="kw">inline class="type">bool OpenFileBars(class="type">int &file, class="kw">const class="type">class="kw">string szFileName) { if ((file = FileOpen("Market Replay\\Bars\\" + szFileName + ".csv", FILE_CSV | FILE_READ | FILE_ANSI)) != INVALID_HANDLE) { if (!CheckFileIsBar(file, szFileName)) class="kw">return class="kw">false; class="kw">return true; } Print("Falha ao acessar ", szFileName, ".csv de barras."); class="kw">return class="kw">false; } class="type">bool LoadPrevBars(class="kw">const class="type">class="kw">string szFileNameCSV) { class="type">int file, iAdjust = class="num">0; class="type">class="kw">datetime dt = class="num">0; class="type">MqlRates Rate[class="num">1];
「回放数据载入与配置宏的实际落点」
回放引擎启动后,先尝试用 OpenFileBars 打开 CSV 文件。若成功,会在日志打印“Loading preview bars for Replay. Please wait....”,随后进入 while 循环,条件是文件未结束且全局停止标志 _StopFlag 未被置位。 循环中每次调用 FileReadBars 把一条 K 线读入 Rate 数组,并通过 iAdjust 计算时间偏移量:仅当 dt 非零且 iAdjust 尚为 0 时,才把偏移记为 Rate[0].time 与 dt 的差值。随后用 CustomRatesUpdate 把这一根 K 线推送到 def_SymbolReplay 品种,实现逐根重播。 循环结束把 m_dtPrevLoading 设为最后一根 K 线时间加偏移并关闭文件,返回 !_StopFlag 表示任务是否正常收尾;若文件打不开则 m_dtPrevLoading 归零并返回 false。 下面几个宏定义了文件类型标记:[BARS]、[TICKS]、[TICKS->BARS]、[BARS->TICKS],在 SetSymbolReplay 里配合 macroERROR 做出错弹窗。macroERROR 会关闭文件、用 MessageBox 报出自定义或行号错误,并直接返回 false,方便在 MT5 里快速定位配置解析失败的位置。
if (OpenFileBars(file, szFileNameCSV)) { Print("Loading preview bars for Replay. Please wait...."); class="kw">while ((!FileIsEnding(file)) && (!_StopFlag)) { FileReadBars(file, Rate); iAdjust = ((dt != class="num">0) && (iAdjust == class="num">0) ? (class="type">int)(Rate[class="num">0].time - dt) : iAdjust); dt = (dt == class="num">0 ? Rate[class="num">0].time : dt); CustomRatesUpdate(def_SymbolReplay, Rate, class="num">1); } m_dtPrevLoading = Rate[class="num">0].time + iAdjust; FileClose(file); class="kw">return (!_StopFlag); } m_dtPrevLoading = class="num">0; class="kw">return class="kw">false; } class="macro">#define def_STR_FilesBar "[BARS]" class="macro">#define def_STR_FilesTicks "[TICKS]" class="macro">#define def_STR_TicksToBars "[TICKS->BARS]" class="macro">#define def_STR_BarsToTicks "[BARS->TICKS]" class="type">bool SetSymbolReplay(class="kw">const class="type">class="kw">string szFileConfig) { class="macro">#define macroERROR(MSG) { FileClose(file); MessageBox((MSG != "" ? MSG : StringFormat("An error occurred in line %d", iLine)), "Market Replay", MB_OK); class="kw">return class="kw">false; } class="type">int file, iLine; class="type">class="kw">string szInfo;