开发回放系统  市场模拟(第 06 部分):首次改进(I)·进阶篇
📘

开发回放系统 市场模拟(第 06 部分):首次改进(I)·进阶篇

第 2/3 篇

◍ 回放配置文件的读取与分流

做 MT5 市场回放(Market Replay)时,配置 CSV 是入口。文件里用 [BARS] 和 [TICKS] 两个标记段来区分接下来列出的是分钟 Bar 文件还是 Tick 文件,例如 WIN$N_M1_202108020900_202108021754 属于前者,WINQ21_202108050900_202108051759 属于后者。 SetSymbolReplay 这个函数负责把配置读进来。它先尝试打开 Market Replay\\ 目录下的 CSV,失败就弹窗并返回 false;成功则用 FileReadString 逐行读,把每行转大写后判断:碰到 [BARS] 就把 isBars 置 true,碰到 [TICKS] 就置 false,其余非空行按当前标记交给 LoadPrevBars 或 LoadTicksReplay。 这种分流写法意味着你改配置时,段标记写错一个字符(大小写已被 ToUpper 归一,但拼写必须完全匹配 def_STR_FilesBar / def_STR_FilesTicks)就会导致整段数据被误判。外汇与贵金属回放含杠杆与跳空,属高风险验证环境,结论仅代表历史数据可被重放,不预示未来。

MQL5 / C++
class="type">bool SetSymbolReplay(const class="type">class="kw">string szFileConfig)
{
   class="type">int    file;
   class="type">class="kw">string szInfo;
   class="type">bool   isBars = true;

   if ((file = FileOpen("Market Replay\\" + szFileConfig, FILE_CSV | FILE_READ | FILE_ANSI)) == INVALID_HANDLE)
   {
      MessageBox("Failed to read the configuration file.", "Market Replay", MB_OK);
      class="kw">return false;
   }
   Print("Loading data for replay.\nPlease wait....");
   class="kw">while ((!FileIsEnding(file)) && (!_StopFlag))
   {
      szInfo = FileReadString(file);
      StringToUpper(szInfo);
      if (szInfo == def_STR_FilesBar) isBars = true; else
      if (szInfo == def_STR_FilesTicks) isBars = false; else
      if (szInfo != "") if (!(isBars ? LoadPrevBars(szInfo) : LoadTicksReplay(szInfo)))
      {
         if (!_StopFlag)
            MessageBox(StringFormat("File %s of %s\ncould not be loaded.", szInfo, (isBars ? def_STR_FilesBar : def_STR_FilesTicks), "Market Replay", MB_OK));
         FileClose(file);

从历史CSV回填重放K线

做行情回放的第一步,是把已导出的 CSV 历史棒线重新灌回自定义序列。下面这段 LoadPrevBars 函数就干了这件事:它先按固定路径 Market Replay\Bars\ 打开指定名称的 CSV,用 FILE_CSVFILE_READFILE_ANSI 模式读取。

打开后先跳过表头——代码里用 for 循环读 9 次 FileReadString 拼成 szInfo,再和预设的 def_Header_Bar 比对。若对不上就 Print 报错并返回 false,避免把错误格式当历史数据吃进去。 表头校验通过后进入 while 循环,只要文件没读完且 _StopFlag 未被置位,就逐行把时间、开高低收、tick_volume、real_volume、spread 用 StringToTime / StringToDouble / StringToInteger 转成 MqlRates 结构。注意时间字段是日期串加时间串拼接后再转,说明 CSV 里这两项是分开存的。 外汇与贵金属品种点差跳变频繁,real_volume 在多数零售平台常为 0,回放时若依赖成交量过滤信号,需先确认源 CSV 是否含真实成交数据,否则重放结果可能偏离实盘概率。

MQL5 / C++
class="type">bool LoadPrevBars(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];
   class="type">class="kw">string   szInfo = "";

   if ((file = FileOpen("Market Replay\\Bars\\" + szFileNameCSV + ".csv", FILE_CSV | FILE_READ | FILE_ANSI)) != INVALID_HANDLE)
   {
      for (class="type">int c0 = class="num">0; c0 < class="num">9; c0++) szInfo += FileReadString(file);
      if (szInfo != def_Header_Bar)
      {
         Print("Файл ", szFileNameCSV, ".csv это не файл предыдущих баров.");
         class="kw">return false;
      }
      Print("Loading bars for the replay. Please wait....");
      class="kw">while ((!FileIsEnding(file)) && (!_StopFlag))
      {
         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));
         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));

「回放引擎里的偏移修正与逐笔 tick 装载」

在回放历史行情时,首根 K 线时间与文件起始时间往往不对齐。下面这行用 iAdjust 把差值算出来并固化:若 dt 非零且 iAdjust 还是 0,就取当前 Rate[0].time 减 dt 强转成 int 存进 iAdjust,之后 dt 自身若为零也补成 Rate[0].time,保证后续每根都按同一偏移续接。 iAdjust = ((dt != 0) && (iAdjust == 0) ? (int)(Rate[0].time - dt) : iAdjust); dt = (dt == 0 ? Rate[0].time : dt); CustomRatesUpdate(def_SymbolReplay, Rate, 1); m_dtPrevLoading = Rate[0].time + iAdjust; 文件打不开时直接 Print 报错并把 m_dtPrevLoading 归零返回 false,这意味着回放会话中断,MT5 终端不会注入任何假棒。外汇与贵金属回放仅用于策略验证,实盘高杠杆风险依旧存在,切勿把回测顺滑度等同于实盘胜率。 装载 tick 走另一条路:LoadTicksReplay 打开 Market Replay\Ticks\ 下的 CSV,先跳 7 列读表头字符串拼成 szInfo,与 def_Header_Ticks 比对。宏 macroRemoveSec(A) 定义为 A - (A % 60),可把时间戳截到分钟级,便于和棒线时间对齐。若表头不符,后面的 for 循环体不会执行,数组不扩容,老 tick 数 old 保留原值。

MQL5 / C++
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);
  }else
  {
      Print("Could not access the bars data file.");
      m_dtPrevLoading = class="num">0;
      class="kw">return false;
  }
  class="kw">return (!_StopFlag);
}
class="macro">#define macroRemoveSec(A) (A - (A % class="num">60))
      class="type">bool LoadTicksReplay(const class="type">class="kw">string szFileNameCSV)
        {
            class="type">int    file,
                  old;
            class="type">class="kw">string szInfo = "";
            class="type">MqlTick tick;
            
            if ((file = FileOpen("Market Replay\\Ticks\\" + szFileNameCSV + ".csv", FILE_CSV | FILE_READ | FILE_ANSI)) != INVALID_HANDLE)
            {
                ArrayResize(m_Ticks.Info, def_MaxSizeArray, def_MaxSizeArray);
                old = m_Ticks.nTicks;
                for (class="type">int c0 = class="num">0; c0 < class="num">7; c0++) szInfo += FileReadString(file);
                if (szInfo != def_Header_Ticks)
                {

◍ 回放 tick 的逐行装载逻辑

把 CSV 里的成交 tick 灌回结构体,核心在 while 循环里逐字段读取。循环终止条件有三个:文件读完、已装载 tick 数逼近 INT_MAX-2、或终端发出 _StopFlag 停止信号,任一满足就跳出,避免历史数据过大时卡死 MT5。 下面这段是实际落盘读取的主干,建议直接开 MT5 新建脚本粘进去跑一份自己导出的 tick CSV 验证字段顺序: ArrayResize 每次多开一个 MqlTick 槽位,def_MaxSizeArray 作为预留扩容步长;szInfo 拼的是日期串加毫秒串,StringSubstr 取前 19 位做秒级时间、再取 20 位起 3 位做毫秒。bid/ask/last/volume_real/flags 全部按 FileReadString 顺序转类型写入。 装载后会做一条去重判断:若上一条 old 的 last、time、time_msc 与当前 tick 三者全同,则视为重复 tick 跳过。实盘导出的 EURUSD tick 在秒级重叠时可能出现这种冗余,过滤后回测重放更接近真实撮合。外汇与贵金属 tick 回放属高杠杆环境,重放结果仅代表历史数据概率特征,不等于未来行情倾向。

MQL5 / C++
Print("File ", szFileNameCSV, ".csv is not a file with traded ticks.");
class="kw">return false;
Print("Loading replay ticks. Please wait...");
class="kw">while ((!FileIsEnding(file)) && (m_Ticks.nTicks < (INT_MAX - class="num">2)) && (!_StopFlag))
  {
  ArrayResize(m_Ticks.Info, (m_Ticks.nTicks + class="num">1), def_MaxSizeArray);
  szInfo = FileReadString(file) + " " + FileReadString(file);
  tick.time = macroRemoveSec(StringToTime(StringSubstr(szInfo, class="num">0, class="num">19)));
  tick.time_msc = (class="type">int)StringToInteger(StringSubstr(szInfo, class="num">20, class="num">3));
  tick.bid = StringToDouble(FileReadString(file));
  tick.ask = StringToDouble(FileReadString(file));
  tick.last = StringToDouble(FileReadString(file));
  tick.volume_real = StringToDouble(FileReadString(file));
  tick.flags = (class="type">uchar)StringToInteger(FileReadString(file));
  if ((m_Ticks.Info[old].last == tick.last) && (m_Ticks.Info[old].time == tick.time) && (m_Ticks.Info[old].time_msc == tick.time_msc))

常见问题

在回放初始化阶段加载CSV,按品种名做键分流到对应回放通道,避免多品种互相串数据。
先批量读取CSV里的OHLC重建已完成K线,再切到tick级装载,保证时间轴连续不跳空。
小布可托管回放配置读取与偏移校准,你只需丢入CSV,它跑完给出可重放的时间窗。
在装载循环里对首tick做基准偏移修正,后续tick按相对增量追加,消除累计漂移。
用流式逐行读、边解析边推入环形缓冲,不一次性全载,能压住内存且重放不顿。