开发回放系统  市场模拟(第 21 部分):外汇(II)·进阶篇
⚙️

开发回放系统 市场模拟(第 21 部分):外汇(II)·进阶篇

(2/3)· 从配置文件崩溃到资产跳价锁定,手把手拆掉回放模拟里的隐藏坑

案例拆解 第 2/3 篇
不少人加载完 1 分钟柱线再塞跳价数据,回放服务直接空白报错还找不到原因。更隐蔽的是,某些资产长时间无交易时系统会卡死在锁定态,错过后续行情重放。这些问题不修,外汇回放基本没法稳定用。

「回放引擎里的零成交量首棒处理」

在 MT5 做 tick 级回放时,真实行情文件开头往往有一串 volume_real 为 0 的 tick,直接灌进自定义品种会留下一根无开盘价的怪棒。C_ConfigService 里用 FirstBarNULL 专门兜底:从 m_Ticks.Info 头部扫到第一个有真实成交量的 tick 之前,把所有 last 价都塞进同一根 rate[0].close。 它把 open/high/low 全部等同 close,tick_volume 与 real_volume 置 0,time 设为首 tick 时间减 60 秒,再调 CustomRatesUpdate(def_SymbolReplay, rate) 强行写一根「空白首棒」。随后 m_ReplayCount 归零,回放计数从这根假棒之后重新开始。 外汇与贵金属回放属于高杠杆高风险操作,这种首棒修正只解决数据衔接,不预示任何方向。开 MT5 把这段逻辑挂到你的回放类里,能避免自定义品种图表最左端出现无法绘制的空洞。

MQL5 / C++
class="kw">inline class="type">void FirstBarNULL(class="type">void)
  {
    class="type">MqlRates rate[class="num">1];
    for(class="type">int c0 = class="num">0; m_Ticks.Info[c0].volume_real == class="num">0; c0++)
      rate[class="num">0].close = m_Ticks.Info[c0].last;
    rate[class="num">0].open = rate[class="num">0].high = rate[class="num">0].low = rate[class="num">0].close;
    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);
    m_ReplayCount = class="num">0;
  }

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

做行情回放服务时,第一步是把配置文件读进内存并判断当前处于哪个解析阶段。下面这段逻辑直接打开 Market Replay 目录下的 CSV 配置,读不到句柄就立刻退出,避免后续空跑。 文件句柄拿到后,代码把Tick数组预扩容到 def_BarsDiary 大小,并把 nRate 置为 -1、首元素时间清零,相当于给回放缓冲做了冷启动。cError 与 cStage 双零初始化,说明此时既无错误也未进入任何定义段。 主循环依靠 FileIsEnding 与全局 _StopFlag 双条件退出,只要 cError 不为 0 也会中断。每一行先过 GetDefinition 判定是 DEFINE 还是 INFO:DEFINE 走 WhatDefine 解析并回写 cError;INFO 则按 cStage 分流,stage 0 遇到非空信息直接标错 2,stage 1 往 pBarsToPrev 挂文件名与行号,stage 2 才建 pTicksToReplay 容器。 在 MT5 里跑这段,重点看 FileOpen 的路径拼接和 cStage 的递增时机——若配置里 INFO 出现在 DEFINE 之前,cStage 仍为 0,会稳定触发 cError=2 并停服。外汇与贵金属回测涉及高杠杆与滑点风险,该逻辑仅保证文件解析次序正确,不预示任何收益。

MQL5 / C++
class="type">int    file;
class="type">char   cError,
       cStage;
class="type">class="kw">string szInfo;
class="type">bool   bBarsPrev;
if ((file = FileOpen("Market Replay\" + szFileConfig, FILE_CSV | FILE_READ | FILE_ANSI)) == INVALID_HANDLE)
{
   Print("Failed to open configuration file [", szFileConfig, "]. Closing the service...");
   class="kw">return false;
}
Print("Loading ticks 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;
cError = cStage = class="num">0;
bBarsPrev = false;
m_GlPrivate.Line = class="num">1;
m_GlPrivate.pTicksToReplay = m_GlPrivate.pTicksToBars = m_GlPrivate.pBarsToTicks = m_GlPrivate.pBarsToPrev = NULL;
class="kw">while ((!FileIsEnding(file)) && (!_StopFlag) && (cError == class="num">0))
{
   class="kw">switch (GetDefinition(FileReadString(file), szInfo))
   {
      case Transcription_DEFINE:
         cError = (WhatDefine(szInfo, cStage) ? class="num">0 : class="num">1);
         break;
      case Transcription_INFO:
         if (szInfo != "") class="kw">switch (cStage)
         {
            case class="num">0:
               cError = class="num">2;
               break;
            case class="num">1:
               if (m_GlPrivate.pBarsToPrev == NULL) m_GlPrivate.pBarsToPrev = new C_Array();
               (*m_GlPrivate.pBarsToPrev).Add(macroFileName, m_GlPrivate.Line);
               break;
            case class="num">2:
               if (m_GlPrivate.pTicksToReplay == NULL) m_GlPrivate.pTicksToReplay = new C_Array();

◍ 回放指令的分流与报错出口

宏文件按行读取后,系统用 switch 把指令归到 5 类容器:case 2 把重放 tick 序列压进 pTicksToReplay,case 3 把 tick 转 bar 的任务挂到 pTicksToBars,case 4 则进 pBarsToTicks;三者都先做 NULL 判断再 new C_Array(),避免重复分配。 case 5 调用 Configs(szInfo) 做配置解析,失败就把 cError 置为 7,这条路径决定了后续服务能否起得来。 文件读完先 FileClose(file),接着依次跑 Cmd_TicksToReplay、Cmd_BarsToTicks,并用返回值更新 bBarsPrev——只要任一条返回 true,bBarsPrev 就被改写。 收尾的 switch(cError) 是硬出口:cError=0 时若 m_Ticks.nTicks<=0 直接打印 No ticks to use 并置 -1;等于 1 或 2 则打印具体行号的错误描述,方便你开 MT5 看日志定位是哪一行宏写崩了。

MQL5 / C++
(*m_GlPrivate.pTicksToReplay).Add(macroFileName, m_GlPrivate.Line);
break;
case class="num">3:
if (m_GlPrivate.pTicksToBars == NULL) m_GlPrivate.pTicksToBars = new C_Array();
(*m_GlPrivate.pTicksToBars).Add(macroFileName, m_GlPrivate.Line);
break;
case class="num">4:
if (m_GlPrivate.pBarsToTicks == NULL) m_GlPrivate.pBarsToTicks = new C_Array();
(*m_GlPrivate.pBarsToTicks).Add(macroFileName, m_GlPrivate.Line);
break;
case class="num">5:
if (!Configs(szInfo)) cError = class="num">7;
break;
}
break;
};
m_GlPrivate.Line += (cError > class="num">0 ? class="num">0 : class="num">1);
}
FileClose(file);
Cmd_TicksToReplay(cError);
Cmd_BarsToTicks(cError);
bBarsPrev = (Cmd_TicksToBars(cError) ? true : bBarsPrev);
bBarsPrev = (Cmd_BarsToPrev(cError) ? true : bBarsPrev);
class="kw">switch(cError)
{
case class="num">0:
if (m_Ticks.nTicks <= class="num">0)
{
Print("No ticks to use. Closing the service...");
cError = -class="num">1;
}else if (!bBarsPrev) FirstBarNULL();
break;
case class="num">1  : Print("Command in line ", m_GlPrivate.Line, " could not be recognized by the system..."); break;
case class="num">2  : Print("The contents of the line is unexpected for the system: ", m_GlPrivate.Line);      break;

「回放指令里的逐文件加载与错误码分支」

这段内联函数群负责把外部指定的 tick 或 bar 数据源逐个喂进回放引擎。Cmd_TicksToReplay 遍历 pTicksToReplay 指针容器,只要 At() 返回空串就 break,否则调用 LoadTicks;若返回 0 条记录则把 cError 置为 4,说明该文件可能为空或路径失效。 Cmd_BarsToTicks 逻辑类似,但失败分支的 cError 是 6,对应 BarsToTicks() 返回 false 的情形——通常是品种周期不匹配。两个函数都在循环结束后 delete 掉容器指针,避免重复释放。 Cmd_TicksToBars 多了一个 bBarsPrev 标记,LoadTicks 的第二个参数传 false 表示不立即建 bar,成功才把 m_dtPrevLoading 记为非零时间戳并翻转标记。错误码 5 即代表该 tick 文件加载条数为 0。外汇与贵金属回测中这类静默错误常导致样本量虚高,建议在 MT5 策略测试器里单步跑一遍看 cError 实际落点。

MQL5 / C++
class="kw">default : Print("Access error occurred in the files indicated in line: ", m_GlPrivate.Line);
		}
		
		class="kw">return (cError == class="num">0 ? !_StopFlag : false);
class="macro">#undef macroFileName
	}
class="kw">inline class="type">void Cmd_TicksToReplay(class="type">char &cError)
	{
		class="type">class="kw">string szInfo;
		if (m_GlPrivate.pTicksToReplay != NULL)
		{
			for (class="type">int c0 = class="num">0; (c0 < INT_MAX) && (cError == class="num">0); c0++)
			{
				if ((szInfo = (*m_GlPrivate.pTicksToReplay).At(c0, m_GlPrivate.Line)) == "") break;
				if (LoadTicks(szInfo) == class="num">0) cError = class="num">4;
			}
			class="kw">delete m_GlPrivate.pTicksToReplay;
		}
	}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">inline class="type">void Cmd_BarsToTicks(class="type">char &cError)
	{
		class="type">class="kw">string szInfo;
		if (m_GlPrivate.pBarsToTicks != NULL)
		{
			for (class="type">int c0 = class="num">0; (c0 < INT_MAX) && (cError == class="num">0); c0++)
			{
				if ((szInfo = (*m_GlPrivate.pBarsToTicks).At(c0, m_GlPrivate.Line)) == "") break;
				if (!BarsToTicks(szInfo)) cError = class="num">6;
			}
			class="kw">delete m_GlPrivate.pBarsToTicks;
		}
	}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">inline class="type">bool Cmd_TicksToBars(class="type">char &cError)
	{
		class="type">bool bBarsPrev = false;
		class="type">class="kw">string szInfo;
		if (m_GlPrivate.pTicksToBars != NULL)
		{
			for (class="type">int c0 = class="num">0; (c0 < INT_MAX) && (cError == class="num">0); c0++)
			{
				if ((szInfo = (*m_GlPrivate.pTicksToBars).At(c0, m_GlPrivate.Line)) == "") break;
				if ((m_dtPrevLoading = LoadTicks(szInfo, false)) == class="num">0) cError = class="num">5; else bBarsPrev = true;
			}
			class="kw">delete m_GlPrivate.pTicksToBars;
		}

历史数据回放的内存回收与分支调度

这段内联函数负责把离线准备好的 tick 与 bar 序列灌进回测环境,同时把临时文件对象及时 delete 掉,避免 MT5 策略测试器里内存悄悄爬升。 Cmd_BarsToPrev 先判断 m_GlPrivate.pBarsToPrev 是否为空;非空就按行读取预览文件描述,用 C_FileBars 加载,LoadPreView 返回 0 则把 cError 置为 3,否则标记 bBarsPrev 为 true。循环上限写的是 INT_MAX,实际靠空字符串跳出,属于防御式写法。 CMD_Array 用 switch(e1) 把四种回放类型映射到不同指针:eTickReplay、eTickToBar、eBarToTick、eBarPrev。若指针非空,同样逐行取 szInfo,按类型分流——例如 eTickReplay 走 LoadTicks,返回 0 则 cError = 4。 开 MT5 把这段贴进 EA 的私有方法区,故意让 pBarsToPrev 指向一个损坏路径,能复现 cError=3 的静默中断;外汇与贵金属回测本身高风险,错误码不抛异常容易让策略在残缺数据上跑出失真结果。

MQL5 / C++
class="kw">inline class="type">bool Cmd_BarsToPrev(class="type">char &cError)
  {
    class="type">bool bBarsPrev = false;
    class="type">class="kw">string szInfo;
    C_FileBars      *pFileBars;
    if (m_GlPrivate.pBarsToPrev != NULL)
    {
      for (class="type">int c0 = class="num">0; (c0 < INT_MAX) && (cError == class="num">0); c0++)
      {
        if ((szInfo = (*m_GlPrivate.pBarsToPrev).At(c0, m_GlPrivate.Line)) == "") break;
        pFileBars = new C_FileBars(szInfo);
        if ((m_dtPrevLoading = (*pFileBars).LoadPreView()) == class="num">0) cError = class="num">3; else bBarsPrev = true;
        class="kw">delete pFileBars;
      }
      class="kw">delete m_GlPrivate.pBarsToPrev;
    }
    class="kw">return bBarsPrev;
  }
class="kw">inline class="type">bool CMD_Array(class="type">char &cError, eWhatExec e1)
  {
    class="type">bool        bBarsPrev = false;
    class="type">class="kw">string      szInfo;
    C_FileBars  *pFileBars;
    C_Array    *ptr = NULL;
    class="kw">switch (e1)
    {
      case eTickReplay: ptr = m_GlPrivate.pTicksToReplay; break;
      case eTickToBar : ptr = m_GlPrivate.pTicksToBars;   break;
      case eBarToTick : ptr = m_GlPrivate.pBarsToTicks;   break;
      case eBarPrev   : ptr = m_GlPrivate.pBarsToPrev;    break;
    }
    if (ptr != NULL)
    {
      for (class="type">int c0 = class="num">0; (c0 < INT_MAX) && (cError == class="num">0); c0++)
      {
        if ((szInfo = ptr.At(c0, m_GlPrivate.Line)) == "") break;
        class="kw">switch (e1)
        {
          case eTickReplay:
            if (LoadTicks(szInfo) == class="num">0) cError = class="num">4;

◍ 配置解析与回放加载的收口逻辑

上面这段是历史数据回放服务的尾部:switch 分支处理四种加载模式,Tick 转 Bar、Bar 转 Tick、读取预览 Bar 各有错误码(5、6、3),任一失败即打断 bBarsPrev 的置位。 C_ConfigService 在构造时把 m_ModelLoading 默认设为 1,这意味着若配置文件不显式写 LOADMODEL,引擎会走第一种加载模型。 配置解析靠 StringSplit(szInfo, '=', szRet) == 2 保证键值成对,再拿 trim 后的左值去比对 PATH、POINTSPERTICK 等五个固定字段;字段不匹配就落到 switch 默认分支,参数被静默忽略。 开 MT5 把这段嵌进 EA 时,建议先打印 szRet[0] 确认配置文件没多空格——很多加载失败其实只是等号左边带了不可见字符。

MQL5 / C++
break;
				case eTickToBar :
					if ((m_dtPrevLoading = LoadTicks(szInfo, false)) == class="num">0) cError = class="num">5; else bBarsPrev = true;
					break;
				case eBarToTick :
					if (!BarsToTicks(szInfo)) cError = class="num">6;
					break;
				case eBarPrev  :
					pFileBars = new C_FileBars(szInfo);
					if ((m_dtPrevLoading = (*pFileBars).LoadPreView()) == class="num">0) cError = class="num">3; else bBarsPrev = true;
					class="kw">delete pFileBars;
					break;
				}
			}
			class="kw">delete ptr;
		}
		class="kw">return bBarsPrev;
	}
class C_ConfigService : class="kw">protected C_FileTicks
{
class="kw">protected:
class=class="str">"cmt">//+------------------------------------------------------------------+

class="type">class="kw">datetime m_dtPrevLoading;

class="type">int      m_ReplayCount,
         m_ModelLoading;
C_ConfigService()
:m_szPath(NULL), m_ModelLoading(class="num">1)
{
}
class="kw">inline class="type">bool Configs(const class="type">class="kw">string szInfo)
	{
		const class="type">class="kw">string szList[] =
		{
			"PATH",
			"POINTSPERTICK",
			"VALUEPERPOINTS",
			"VOLUMEMINIMAL",
			"LOADMODEL"
		};
		class="type">class="kw">string  szRet[];
		class="type">char    cWho;
		if (StringSplit(szInfo, &class="macro">#x27;=&class="macro">#x27;, szRet) == class="num">2)
		{
			StringTrimRight(szRet[class="num">0]);
			StringTrimLeft(szRet[class="num">1]);
			for (cWho = class="num">0; cWho < ArraySize(szList); cWho++) if (szList[cWho] == szRet[class="num">0]) break;
			class="kw">switch (cWho)
			{
把配置解析交给小布盯盘
这些诊断小布盯盘的 AIGC 已内置,打开对应品种页即可看到跳价加载顺序与锁定态提示,你专注调参而不是翻日志。

常见问题

系统在加载柱线后切换显示价模式会清空已有柱线,若跳价在后声明则结构被破坏,需先声明跳价再读柱线,或改由程序重排读取顺序。
可引入定时模式,在设定时间后自动解除锁定,避免无成交时段导致模拟停滞,具体参数需结合品种活跃度调整。
它们能定位文件偏移,实现不按顺序的物理读取,从而在内存中按依赖关系重排配置元素,降低用户书写约束。
可以,小布盯盘的品种页会标记跳价与柱线声明次序异常,并给出重排建议,省去手动查日志。
常见于外汇和部分场内价差资产,这类市场最后一笔成交价未必代表可交易价,出价驱动显示更贴近实盘盘口。