开发回放系统  市场模拟(第 11 部分):模拟器的诞生(I)(基础篇)
📘

开发回放系统 市场模拟(第 11 部分):模拟器的诞生(I)(基础篇)

第 1/3 篇

◍ 从零搭一个行情回放模拟器

在 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。这部分代码已经过测试,从旧函数里抽出来集中放,比重新写一遍安全得多。 配置文件里要补一个定义,标记哪些柱线文件按模拟票据对待。加上这个定义和对应调用后,跑一次简单测试就能确认地基可用。外汇与贵金属模拟本身是高风险的,任何回放结果都只是概率参考,不能直接当实盘依据。 别把重复代码当小事。能复用已测函数就别新写,新函数越少,要担心的未知行为就越少。

MQL5 / C++
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_CSVFILE_READFILE_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 循环。

MQL5 / C++
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 里快速定位配置解析失败的位置。

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

常见问题

先把真实成交票据转成结构化合成数据,建立可复用的柱线读取地基,再考虑回放逻辑。
在柱线高低区间内按成交量权重撒点,用随机游走逼近真实扫单路径,当作回放输入。
可以,小布能读取你的回放配置并比对历史K线回填结果,标出读写异常和断点。
时间字段时区错位和浮点精度截断最常见,建议统一用整数毫秒存时间戳。
单独抽成独立配置头文件,用宏开关切换数据源路径与柱线周期,别写死在主体逻辑里。