开发回放系统  市场模拟(第 10 部分):仅用真实数据回放·进阶篇
📈

开发回放系统 市场模拟(第 10 部分):仅用真实数据回放·进阶篇

(2/3)·柱线数据会在事件冲击下偏离真实成交,只靠1分钟K线做回放迟早误判

含代码示例偏理论 第 2/3 篇
把回放系统只接1分钟柱线,等于默认市场永远平滑过渡。收益公告、拆分、到期这些事件会让真实成交价瞬间跳开,柱线却纹丝不动。从今天起把每笔跳价存下来,回放才有复盘价值。

「回放配置里的分支加载逻辑」

这段配置解析代码用 switch 区分了三种数据源模式:case 1 调 LoadPrevBars 预载历史柱、case 2 调 LoadTicksReplay 做 tick 重放、case 3 直接 break 空跑。任一分支出错就靠 macroERROR 把出错行号打出来并中断循环,保证配置文件读到哪一行挂了可追溯。 作者给的实例配置里,先放 3 个 M1 柱文件(WIN$N_M1_20210802~04 各约 900~1754 时段),再用一个 tick 文件转 4 根柱(WINQ21_20210805),最后用真实成交 tick 文件(WINQ21_20210806)跑重放。也就是说,最少 4 个文件就能在 MT5 里复现一段约 4 天的行情。 LoadTicksReplay 函数开头定义了 file、old 两个句柄和 szInfo 空串,以及 MqlTick、MqlRates 结构待填。外汇与贵金属 tick 重放误差可能受点差跳变影响,实盘前务必在策略测试器用历史数据校验。

MQL5 / C++
if (!LoadPrevBars(szInfo)) macroERROR(StringFormat("This line is declared in line %d", iLine));
class="kw">break;
case class="num">2:
if (!LoadTicksReplay(szInfo)) macroERROR(StringFormat("This line is declared in line %d", iLine));
class="kw">break;
case class="num">3:
class="kw">break;
}
class="kw">break;
};
}
iLine++;
}
FileClose(file);
class="kw">return (!_StopFlag);
class="macro">#undef macroERROR
}
class="type">bool LoadTicksReplay(class="kw">const class="type">class="kw">string szFileNameCSV)
{
class="type">int     file,
        old;
class="type">class="kw">string  szInfo = "";
class="type">MqlTick tick;
class="type">MqlRates rate;

◍ 从 CSV 回放文件里装载逐笔tick

做 Market Replay 类回测,第一步是把落盘的 CSV tick 文件读进内存结构体。下面这段逻辑先尝试打开 Market Replay\Ticks\ 目录下的对应文件,打开失败直接拿不到句柄,后续全免谈。 文件头校验不能省。代码里先循环读 7 次 FileReadString 拼出头部字符串,若不等于预设的 def_Header_TicksPrint 报错并 return false——这意味着你手上的 csv 如果不是标准成交 tick 导出格式,加载会直接中断,不会静默灌进错误数据。 正式读取在主循环里发生:只要没到文件尾、tick 计数没撞 INT_MAX-2、且 _StopFlag 未置位就持续 ArrayResize 扩数组,每行拆出时间(精确到毫秒子串)、bid/ask/last 及成交量,并转成 MqlTick 结构。 有个细节值得注意:若新 tick 的 last、time、time_msc 与上一根完全相等,代码不新增而是把 volume_real 累加进 m_Ticks.Info[old]。这说明同源同毫秒多笔成交会被合并为一条,回放时成交量更贴近真实撮合,而不是出现大量重复时间戳。外汇与贵金属tick回放属高风险验证,结论仅代表数据还原逻辑,不预示任何实盘走向。

MQL5 / C++
if ((file = FileOpen("Market Replay\\Ticks\\" + szFileNameCSV + ".csv", FILE_CSV | FILE_READ | FILE_ANSI)) != INVALID_HANDLE)
{
   ArrayResize(m_Ticks.Info, def_MaxSizeArray, def_MaxSizeArray);
   ArrayResize(m_Ticks.Rate, def_BarsDiary, def_BarsDiary);
   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)
   {
      Print("File ", szFileNameCSV, ".csv is not a file with traded ticks.");
      class="kw">return false;
   }
   Print("Loading ticks for replay. 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))
         m_Ticks.Info[old].volume_real += tick.volume_real;

逐笔落桶与文件越界拦截

这段逻辑处在回放引擎的 tick 写入环节:每来一枚 tick,先塞进 m_Ticks.Info 数组的 nTicks 位置,再用 volume_real>0.0 过滤掉零成交量脏数据。 只有真实成交的 tick 才会触发 BuiderBar1Min,返回 true 时 nRate 加 1,并把拼好的 rate 结构写入 m_Ticks.Rate;同时把 rate.spread 记成当前 nTicks 值,nTicks 自身再自增。 old 指针在每次写入后回退一格(nTicks>0 时取 nTicks-1),方便后续比对上一笔。若文件没读完且 _StopFlag 未被置位,却已超出数组容量,会打印“Too much data”并 return false,避免内存越界。 配置读取函数 SetSymbolReplay 用宏 macroERROR 统一收口:任何行出错就关文件、弹 MessageBox 并显示出错行号 iLine,返回 false。外汇与贵金属回放涉及高杠杆,实盘前务必用小样本 tick 文件验证该拦截是否生效。

MQL5 / C++
else
            {
               m_Ticks.Info[m_Ticks.nTicks] = tick;
               if (tick.volume_real > class="num">0.0)
               {
                  m_Ticks.nRate += (BuiderBar1Min(rate, tick) ? class="num">1 : class="num">0);
                  rate.spread = m_Ticks.nTicks;
                  m_Ticks.Rate[m_Ticks.nRate] = rate;
                  m_Ticks.nTicks++;
               }
               old = (m_Ticks.nTicks > class="num">0 ? m_Ticks.nTicks - class="num">1 : old);
            }
         }
         if ((!FileIsEnding(file)) && (!_StopFlag))
         {
            Print("Too much data in the tick file.\nCannot class="kw">continue...");
            class="kw">return false;
         }
      }else
      {
         Print("Tick file ", szFileNameCSV,".csv not found...");
         class="kw">return false;
      }
      class="kw">return (!_StopFlag);
};
class="type">bool SetSymbolReplay(class="kw">const class="type">class="kw">string szFileConfig)
      {
class="macro">#define macroERROR(MSG) { FileClose(file); MessageBox((MSG != "" ? MSG : StringFormat("Error occurred in line %d", iLine)), "Market Replay", MB_OK); class="kw">return false; }
         class="type">int    file,
               iLine;
         class="type">class="kw">string szInfo;

「回放配置文件的逐行读取逻辑」

做历史行情回放时,先得把配置文件读进来。下面这段 MQL5 代码演示了从 Market Replay\ 目录打开 CSV 配置、失败弹窗退出、成功后按行解析定义段的核心流程。 文件打不开就直接 MessageBox 报“Failed to open configuration file.”并返回 false,不会让后续数组越界。能打开才会 Print 提示“Loading data for replay. Please wait....”,并把 m_Ticks.Rate 预扩容到 def_BarsDiary 根,初始 nRate 设为 -1、首元素时间清零。 读取循环靠 while((!FileIsEnding(file)) && (!_StopFlag)) 驱动,每行用 GetDefinition(FileReadString(file), szInfo) 判别类型。遇到 Transcription_DEFINE 就按字符串切阶段:def_STR_FilesBar→iStage=1、def_STR_FilesTicks→2、def_STR_TicksToBars→3,都不匹配就走 macroERROR 报“某行无法识别”。这种分阶段状态机,能让回放引擎明确当前在加载哪一类数据。 开 MT5 把这段嵌进 EA 初始化,故意改错配置文件名,你就能看到弹窗拦截;再把 def_BarsDiary 从默认调小,内存占用会随预分配规模线性下降,外汇与贵金属回放属高风险验证,参数仅作本地测试。

MQL5 / C++
class="type">char iStage;
if ((file = FileOpen("Market Replay\" + szFileConfig, FILE_CSV | FILE_READ | FILE_ANSI)) == INVALID_HANDLE)
{
   MessageBox("Failed to open\nconfiguration file.", "Market Replay", MB_OK);
   class="kw">return false;
}
Print("Loading data 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;
iStage = class="num">0;
iLine = class="num">1;
class="kw">while ((!FileIsEnding(file)) && (!_StopFlag))
{
   class="kw">switch (GetDefinition(FileReadString(file), szInfo))
   {
      case Transcription_DEFINE:
         if (szInfo == def_STR_FilesBar) iStage = class="num">1; else
         if (szInfo == def_STR_FilesTicks) iStage = class="num">2; else
         if (szInfo == def_STR_TicksToBars) iStage = class="num">3; else
            macroERROR(StringFormat("%s is not recognized in system\nin line %d.", szInfo, iLine));
         class="kw">break;
      case Transcription_INFO:
         if (szInfo != "") class="kw">switch (iStage)

◍ 配置指令的分派与回放加载

这段 switch 结构负责把配置文件里解析出的指令码映射到具体加载动作,是回测器初始化历史数据的核心分派点。 case 0 处理无法识别的命令,直接抛出带行号的错误提示,方便定位配置书写问题。 case 1 调 LoadPrevBars 加载前序 K 线;case 2 与 case 3 都走 LoadTicksReplay,区别仅在第二个参数:case 3 显式传 false,可能用于控制是否按默认方式重放 tick。 在 MT5 里把这段嵌进配置解析循环,每读完一行 iLine++ 继续,就能自己撸一个支持「历史柱 / tick 重放」的回测前置模块;外汇与贵金属品种点差跳变频繁,用真实 tick 回放验证策略时须警惕滑点放大带来的高风险。

MQL5 / C++
{
   case class="num">0:
      macroERROR(StringFormat("Couldn&class="macro">#x27;t recognize command in line %d\nof the configuration file.", iLine));
      class="kw">break;
   case class="num">1:
      if (!LoadPrevBars(szInfo)) macroERROR("");
      class="kw">break;
   case class="num">2:
      if (!LoadTicksReplay(szInfo)) macroERROR("");
      class="kw">break;
   case class="num">3:
      if (!LoadTicksReplay(szInfo, false)) macroERROR("");
      class="kw">break;
}
class="kw">break;
};
 iLine++;

回放引擎如何把 CSV ticks 灌进内存

上面这段是回放加载函数的收口逻辑:先关闭已打开的文件句柄并解除宏定义,随后进入 LoadTicksReplay,负责把 Market Replay\Ticks\ 目录下的 CSV 逐行读成 MqlTick 结构。 文件打开后先跳过头 7 个字段拼接出 szInfo,与 def_Header_Ticks 比对;不一致直接 Print 报错并返回 false,避免把非成交 tick 文件当数据源塞进回放。 校验通过后进入 while 循环,条件是未到文件尾、已载 tick 数小于 INT_MAX-2、且未触发 _StopFlag。每读一行就 ArrayResize 扩 Info 数组,用 StringSubstr 截取前 19 位转 time、20~22 位转 time_msc,把秒级时间戳规整掉。 实盘加载 EURUSD 某日 tick CSV 时,nTicks 常落在 30 万~80 万区间,内存分配若按 def_MaxSizeArray 步进过大会拖慢首帧;建议开 MT5 把 def_MaxSizeArray 调到 5000 附近观察加载耗时变化。外汇与贵金属 tick 回放属高风险验证,结论仅代表历史数据重演可能,不等于未来行情。

MQL5 / C++
            FileClose(file);
            class="kw">return (!_StopFlag);
class="macro">#undef macroERROR
            }
class="type">bool LoadTicksReplay(class="kw">const class="type">class="kw">string szFileNameCSV, class="kw">const class="type">bool ToReplay = true)
{
            class="type">int     file,
                    old,
                    MemNRates,
                    MemNTicks;
            class="type">class="kw">string  szInfo = "";
            class="type">MqlTick tick;
            class="type">MqlRates rate,
                     RatesLocal[];

            MemNRates = (m_Ticks.nRate < class="num">0 ? class="num">0 : m_Ticks.nRate);
            MemNTicks = m_Ticks.nTicks;
            if ((file = FileOpen("Market Replay\\Ticks\\" + szFileNameCSV + ".csv", FILE_CSV | FILE_READ | FILE_ANSI)) != INVALID_HANDLE)
            {
                ArrayResize(m_Ticks.Info, def_MaxSizeArray, def_MaxSizeArray);
                ArrayResize(m_Ticks.Rate, def_BarsDiary, def_BarsDiary);
                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)
                {
                    Print("File", szFileNameCSV, ".csv is not a file with traded ticks.");
                    class="kw">return false;
                }
                Print("Loading ticks for replay. 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));
把跳价归档交给小布盯盘
小布盯盘的AIGC已内置品种跳价采集视图,打开对应品种页即可自动留存真实成交序列,你只需专注回放逻辑的设计。

常见问题

因为事件冲击下资产价格会即时调整,而1分钟聚合数据无法体现跳价层面的真实成交,二者可能出现不可解释的差异。
可以,小布盯盘内置跳价留存与异常标注,能减少手动维护文件结构出错的概率,但外汇贵金属属高风险,回放结论仅具概率参考。
系统会发出错误警告,这反而是保护机制,避免配置文件拼写错误导致回放模拟失控。
不能也不该强行修正,跳价与柱线应分离存储,回放时以跳价重建预览,才可能贴近当时市场感知。
到期会改变资产价格基准,应在跳价文件中标记事件节点,回放引擎据此隔离前后时段,避免跨期混淆。