开发回放系统  市场模拟(第 12 部分):模拟器的诞生(II)·进阶篇
🎲

开发回放系统 市场模拟(第 12 部分):模拟器的诞生(II)·进阶篇

(2/3)· 为什么固定序列练不出盘感?本篇用随机数引擎让模拟器吐出更像真市的走势

案例拆解新手友好 第 2/3 篇
很多人以为用固定一分钟柱回放就能练交易,结果练出的是对固定序列的条件反射。一旦换到真实行情,那种「似曾相识」的错觉会让你误判随机冲击。把随机数请进模拟器,才是接近真市的起点。

◍ 预览柱线缺失时服务空转的坑

做回放系统的人大概率都撞过这个暗病:不加载预览柱线直接启动,控制指标根本访问不到。这个 bug 在代码里潜伏了好一阵,只因为过去预览柱线总默认存在,就被一直搁着没修。 根因很直白——系统初始化时假设「前一根柱线已在内存」,一旦用户跳过预览直接跑,这个假设断裂,指标句柄就废了。修复思路是在 SetSymbolReplay 里加两个局部变量,初值 false 表示「还没加载预览柱线」;任意时刻补载了旧柱线,就把标记翻 true,系统由此知道上下文齐了。 另一半逻辑是跳价校验:如果文件里压根没有跳价数据,启动服务没有意义,直接停掉。若有跳价但没载过任何类型的前柱,就手动初始化一根空 MqlRates 柱线。少了这步,即便服务活着,指标也调不动。 下面这段是修正后的函数头部与初始化片段,注意 bBarPrev 和 rate[1] 的声明位置,以及 file 打开失败时的宏处理。外汇与贵金属回测属高风险场景,任何初始化遗漏都可能让回放结果失真,请在 MT5 里单步跟一遍确认。

MQL5 / C++
                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 class="kw">false; }
                        class="type">int     file,
                                iLine;
                        class="type">class="kw">string  szInfo;
                        class="type">char    iStage;
                        class="type">bool    bBarPrev;
                        class="type">MqlRates rate[class="num">1];
                        
                        if ((file = FileOpen("Market Replay\\" + szFileConfig, FILE_CSV | FILE_READ | FILE_ANSI)) == INVALID_HANDLE)
                        {
                            MessageBox("Failed to open the\nconfiguration file.", "Market Replay", MB_OK);
                            class="kw">return class="kw">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 变量标记当前要落地的数据种类。初始把 iStage 置 0、iLine 置 1、bBarPrev 置 false,随后在文件未结束且未触发停止标志时循环读行。 while 循环里每读一行字符串就丢给 GetDefinition 做解析,返回的枚举决定走哪个 case。Transcription_DEFINE 分支专门识别头定义:比对 szInfo 与若干宏字符串,命中就把 iStage 切成 1~5(分别对应 Bars 文件、Ticks 文件、Ticks 转 Bars、Bars 转 Ticks、品种配置),全不命中就抛错并打印出错的行号 iLine。 这种用整型 stage 做状态机的写法,能让后续 Transcription_INFO 等分支按当前 iStage 决定把数据写进哪块缓冲区。你在 MT5 里复刻时,重点确认 def_STR_FilesBar 等宏字符串必须和文件里的实际标记完全一致,否则会直接走 macroERROR 中断解析。外汇与贵金属数据回放属高风险操作,stage 错乱可能导致回测样本失真。

MQL5 / C++
iStage = class="num">0;
iLine = class="num">1;
bBarPrev = class="kw">false;
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
          if (szInfo == def_STR_BarsToTicks) iStage = class="num">4; else
          if (szInfo == def_STR_ConfigSymbol) iStage = class="num">5; else
            macroERROR(StringFormat("%s is not recognized in the system\nin line %d.", szInfo, iLine));
          break;
        case Transcription_INFO:

配置文件解析失败时的分支处理

EA 在读取外部配置文件时,会把每行内容先存进 szInfo,再按当前解析阶段 iStage 走 switch 分流。若 szInfo 非空却无法识别成合法指令,iStage 为 0 就直接抛错并指出具体出错行号,这一步能让你在 MT5 日志里精确定位到第几行配置写崩了。 当 iStage 等于 1,程序尝试调用 LoadPrevBars(szInfo) 载入历史棒线;该函数返回 false 同样触发 macroERROR 中断,成功才把 bBarPrev 置为 true,意味着前一根棒线数据已就位。 iStage 为 2 时走 LoadTicksReplay(szInfo),负责把历史tick流按配置回放,载入失败即停。实盘或回测前建议先单独跑一遍配置解析,确认三个阶段都不报 macroERROR,否则后续回放可能在第 3 步才悄悄断掉。

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

◍ 回放模式的分支加载逻辑

在 tick 级回放引擎的初始化流程里,case 3 到 case 5 分别对应三种不同的数据装载路径,彼此互斥、靠 break 隔离。 case 3 调用 LoadTicksReplay(szInfo, false) 直接载入已缓存的 tick 流;若返回失败则 macroERROR("") 中断,成功后将 bBarPrev 置为 true,意味着上一根 bar 的状态需要被保留用于后续重建。 case 4 走 LoadBarsToTicksReplay(szInfo),把现成 bar 反推成 tick 序列来跑回放,适合没有原始 tick 只有聚合数据的场景。case 5 则是 Configs(szInfo) 读取策略与品种配置,任一步失败都靠宏报错退出。 这三个分支在 MT5 回测器里实际对应的是不同的历史数据粒度选择,外汇与贵金属品种因点差跳变频繁,用 case 4 反推 tick 时可能丢失真实滑点,验证前应先比对真实 tick 与反推序列的成交量偏差。

MQL5 / C++
case class="num">3:
   if (!LoadTicksReplay(szInfo, class="kw">false)) macroERROR("");
   bBarPrev = true;
   break;
case class="num">4:
   if (!LoadBarsToTicksReplay(szInfo)) macroERROR("");
   break;
case class="num">5:
   if (!Configs(szInfo)) macroERROR("");
   break;
}

「回放引擎的收尾与首根 K 线构造」

下面这段是回放服务读完 tick 文件后的收口逻辑,重点在关闭文件、空数据拦截,以及首根自定义 K 线的初始化。 iLine++ 只是循环计数自增,随后 FileClose(file) 必须执行,否则句柄泄漏会让下次回放打开同一文件失败。 若 m_Ticks.nTicks <= 0,说明整段历史里没抓到任何 tick,直接弹窗“No ticks to be used”并 return false,回放服务就此中止,不会进到报价推送阶段。 当 bBarPrev 为 false(即尚未有前置 K 线)时,代码把 rate[0] 的 open/high/low/close 全部设为 m_Ticks.Info[0].last,tick_volume 与 real_volume 置 0,time 设为首 tick 时间减 60 秒,再调 CustomRatesUpdate 写一根“预备棒”。这能避免首 tick 到达前图表无基准价。 最后 return (!_StopFlag),只要终端没点停止,回放循环就继续;macroERROR 宏随后 undef 清理。 在 MT5 里跑这段时,若你改了 def_SymbolReplay 对应的自定义品种名,要确认 CustomRatesUpdate 的品种已用 CustomSymbolCreate 建好,否则首根 K 线写不进去。外汇与贵金属回放涉及杠杆与滑点,属高风险验证环境,结论仅代表代码逻辑可能成立。

MQL5 / C++
iLine++;
}
FileClose(file);
if (m_Ticks.nTicks <= class="num">0)
{
MessageBox("No ticks to be used.\nClose the service...", "Market Replay", MB_OK);
class="kw">return class="kw">false;
}
if (!bBarPrev)
{
rate[class="num">0].close = rate[class="num">0].open = rate[class="num">0].high = rate[class="num">0].low = m_Ticks.Info[class="num">0].last;
rate[class="num">0].tick_volume = class="num">0;
rate[class="num">0].real_volume = class="num">0;
rate[class="num">0].time = m_Ticks.Info[class="num">0].time - class="num">60;
CustomRatesUpdate(def_SymbolReplay, rate, class="num">1);
}

class="kw">return (!_StopFlag);
class="macro">#undef macroERROR
}

柱线里藏着的跳价计数

MetaTrader 5 直接给的 VOLUME 字段是 REAL VOLUME,也就是该时间点成交的合约数,可为分数值;把 REAL VOLUME 乘以每合约最小份数能得到 FINANCIAL VOLUME,平台不报,得自己算。 TICK VOLUME 完全是另一回事:它只在查柱线时出现,表示这根柱线内实际发生交易的跳价次数。实测里按分钟柱线建模时,我们造出的真实跳价交易量平均约 150 次,而交易所报的 1 分钟柱平均跳价量常在 12,890 次左右——差距来自建模并未覆盖全部报价跳价。 计算逻辑很直白:只统计带 BUY 或 SELL 标志的跳价,BID/ASK 激活的不算;因为这些标志只在价格或真实成交量大于零时才置位,所以代码里不必显式查标志。外汇品种以后会改这套规则,但那是另一篇的事。 下面这段在合成 1 分钟柱时顺手累加跳价计数,注意 tick_volume 的 +1 条件只看 last 价格是否大于 0,并不区分买卖方向,真正的买卖筛选在另一处按标志位做。 别把柱线文件里的交易量当真值 目前回放系统用柱线模拟跳价时,算出的 tick_volume 和柱线文件自带值对不上,下一篇会单独修,这里先留个坑。

MQL5 / C++
class="kw">inline class="type">bool BuiderBar1Min(class="type">MqlRates &rate, class="kw">const class="type">MqlTick &tick)
{
	if (rate.time != macroRemoveSec(tick.time))
	{
		rate.real_volume = class="num">0;
		rate.tick_volume = class="num">0;
		rate.time = macroRemoveSec(tick.time);
		rate.open = rate.low = rate.high = rate.close = tick.last;
		class="kw">return true;
	}
	rate.close = tick.last;
	rate.high = (rate.close > rate.high ? rate.close : rate.high);
	rate.low = (rate.close < rate.low ? rate.close : rate.low);
	rate.real_volume += (class="type">long) tick.volume_real;
	rate.tick_volume += (tick.last > class="num">0 ? class="num">1 : class="num">0);
	class="kw">return class="kw">false;
}
把重复劳动交给小布
这些诊断小布盯盘的 AIGC 已内置,打开对应品种页即可看到随机性分布与回放偏差提示,你专注决策就好。

常见问题

会。固定值令伪随机序列每次相同,系统变成可背记的脚本,无法训练对未知波动的反应,概率上偏离真实市场。
这等于提前知道轴点方向,属于作弊。策略测试器能识别此类取巧,令任何模拟结果失效,训练价值归零。
可以。小布盯盘的 AIGC 模块支持导入单一随机源文件,自动标注跳价与轴点偏移,省去手动比对代码的时间。
它隐藏了柱线起步方向,令训练者无法预谋。但实现不当可能放大滑点假设,外汇贵金属属高风险,需回测验证。
应在随机引擎完成后接入成交层,按真实点差与流动性概率成交,避免人为指定成交价,否则模拟可信度低。