开发回放系统:用真实交易跳价重建市场模拟的底层逻辑(基础篇)
📈

开发回放系统:用真实交易跳价重建市场模拟的底层逻辑(基础篇)

(1/3)· 为什么 1 分钟柱线会骗你,以及怎样把无价的逐笔跳价存下来当模拟原料

偏理论进阶 第 1/3 篇

很多交易者以为下载几根 1 分钟 K 线就能还原行情,却在回测时发现价格行为和当时盘口完全对不上。遇到财报、拆合股、期货到期这类事件,柱线数值会被无声调整,而真正发生过的逐笔跳价才是不可篡改的事实。等你想回头研究某段行情,数据若没提前存,就永远丢了。

「用真实分笔数据喂回放引擎」

MT5 的回放系统若只勾选『仅用真实数据』, tick 序列就完全来自经纪商历史分笔,不会用插值补全缺失行情。这样跑出来的 K 线形态和真实盘口节奏一致,但遇到数据源断流的时间段,会出现整段空白而不是平滑曲线。 2023-11-27 发布的这套示例在社群里拿到 1591 次查看、5 条讨论,说明手动回放真实 tick 的需求一直有。打开 MT5 的『回放』面板,把『使用生成的分笔』取消勾选,就能强制走真实数据通道。 注意,外汇与贵金属属高杠杆品种,真实数据回放只还原历史概率,不代表未来重复出现,验证策略时仍要留足样本外窗口。

◍ 为什么必须每天存交易跳价

做价格行为研究,最容易被忽视的一步是保留原始交易跳价(tick)。如果你还没开始每天落盘自己关注的品种,现在就该动手——信息一旦丢了,任何后期手段都还原不回来。 随着时间推移,你会撞见一个具体现象:1分钟K线的数值,和当时真实成交的 tick 流对不上。这不是错觉。除常规波动外,财报发布、品种合并拆分、期货到期、债券发行等事件都会让资产价格做结构性调整,而 1 分钟柱线不会自动跟着重算。 有人觉得把差值累加就能对齐,问题在于市场是按「当时感知到的价格区间」反应的,不是按你事后修补的序列反应的。tick 流记录的是彼时真实成交,所以它是无价的。 我们回放系统从一开始就在写独立 tick 文件,但之前只用来回放。接下来要直接用这些文件生成预览柱线,替代现在只依赖 1 分钟预览文件的做法。外汇与贵金属波动受事件冲击大、杠杆风险高,建议先拿模拟盘验证这套落地方式。

把跳价文件伪装成预览柱线的代码改造

回放/模拟的配置文件原本有两套结构:一套列交易跳价,一套标预览柱线。若把跳价文件误塞进柱线结构,系统会直接报错——这本来是防拼写错误的保护机制,但也堵死了「用跳价文件当 1 分钟预览柱线」的路。 我们在 C_Replay.mqh 里加了三个宏定义:def_STR_FilesBar 对应 [BARS]、def_STR_FilesTicks 对应 [TICKS]、def_STR_TicksToBars 对应 [TICKS->BARS]。用户写 [ TICKS - > BARS ] 这种带空格的写法,预处理也会归一成 [TICKS->BARS],系统照认不误。 类内偷偷塞了一个私有枚举 eTranscriptionDefine,放在 C_Replay 的 private 区块,外部调不到。它只干一件事:标记某行配置是失败、普通信息、还是定义项。这样后续扩展配置复杂度时,不会动到对外接口。 GetDefinition 函数集中做配置预清洗:先 StringToUpper 全转大写,再 TrimLeft / TrimRight 削掉两边空白;首字符不是“[”就当普通信息返回,是“[”才进定义解析。哪怕你写成 [ B A R S ],小于空格的字符全忽略,照样认成 [BARS]。这套清洗让配置文件从此能写单行注释——行首特定字符打头,整行忽略。 真正的改造在读取跳价处:原始回放服务本就能把跳价转 1 分钟柱线,只是藏得深。我们加局部变量暂存系统状态,文件读完后若标记了 TICKS->BARS 且用户没中止,就进新分支——分配临时内存存柱线,插进资产并校正极限位置,最后恢复初始值。系统表现就像柱线直接从 BAR 文件来的,跳价源被完全隐藏。 这套做法改源代码量最小,但绝非唯一解。外汇与贵金属回测环境高风险,任何配置误写都可能让模拟脱离真实市况,动手前建议在 MT5 用样本跳价文件先跑通再上实盘数据。

MQL5 / C++
class="macro">#define def_STR_FilesBar        "[BARS]"
class="macro">#define def_STR_FilesTicks      "[TICKS]"
class="macro">#define def_STR_TicksToBars     "[TICKS->BARS]"
class=class="str">"cmt">// ... The rest of the code...
[ TICKS -> BARS ]
class C_Replay
{
    class="kw">private :
        enum eTranscriptionDefine {Transcription_FAILED, Transcription_INFO, Transcription_DEFINE};
        class="type">int     m_ReplayCount;
        class="type">class="kw">datetime m_dtPrevLoading;
class=class="str">"cmt">// ... The rest of the code...
class="kw">inline eTranscriptionDefine GetDefinition(class="kw">const class="type">class="kw">string &In, class="type">class="kw">string &Out)
{
    class="type">class="kw">string szInfo;
    
    szInfo = In;
    Out = "";
    StringToUpper(szInfo);
    StringTrimLeft(szInfo);
    StringTrimRight(szInfo);

「回放配置文件的读取与分支判定」

做 MT5 市场回放时,配置 CSV 决定了加载的是历史 K 线还是逐笔 tick。上面这段逻辑先判断每行首字符:不是 '[' 就当作普通信息直接返回,是 '[' 则剔除空白字符后归入定义类,靠这个区分配置段与数据段。 SetSymbolReplay 打开 Market Replay\\ 下的配置文件,失败就弹窗并返回 false;成功则把 m_Ticks.Rate 预扩到 def_BarsDiary 大小,nRate 置 -1、首元素 time 清 0,边读边按 GetDefinition 的返回值走 switch。 若是 Transcription_DEFINE,比对 szInfo 是不是 def_STR_FilesBar / def_STR_FilesTicks,据此切 isBars 布尔;若是 Transcription_INFO 且非空,就按 isBars 调 LoadPrevBars 或 LoadTicksReplay,任一步失败且非手动停止则弹窗并关文件返回 false。循环受 FileIsEnding 与 _StopFlag 双重控制,正常读完关文件、返回是否未被中止。 开 MT5 把这段塞进 EA,改 def_BarsDiary 看内存占用变化,或故意写错配置名验证弹窗路径,比读文档直观。外汇与贵金属回放属高风险验证环境,结论仅指向代码行为、不预示任何实盘结果。

MQL5 / C++
if (StringSubstr(szInfo, class="num">0, class="num">1) != "[")
{
	Out = szInfo;
	class="kw">return Transcription_INFO;
}
for (class="type">int c0 = class="num">0; c0 < StringLen(szInfo); c0++)
	if (StringGetCharacter(szInfo, c0) > &class="macro">#x27; &class="macro">#x27;)
		StringAdd(Out, StringSubstr(szInfo, c0, class="num">1));

class="kw">return Transcription_DEFINE;
}
class="type">bool SetSymbolReplay(class="kw">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 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;
	class="kw">while ((!FileIsEnding(file)) && (!_StopFlag)) class="kw">switch (GetDefinition(FileReadString(file), szInfo))
	{
		case Transcription_DEFINE:
			if (szInfo == def_STR_FilesBar) isBars = true; else
				if (szInfo == def_STR_FilesTicks) isBars = false;
			class="kw">break;
		case Transcription_INFO:
			if (szInfo != "") if (!(isBars ? LoadPrevBars(szInfo) : LoadTicksReplay(szInfo)))
			{
				if (!_StopFlag)
					MessageBox(StringFormat("File %s from %s\ncould not be loaded.", szInfo, (isBars ? def_STR_FilesBar : def_STR_FilesTicks), "Market Replay", MB_OK));
				FileClose(file);
				class="kw">return false;
			}
			class="kw">break;
	}
	FileClose(file);
	class="kw">return (!_StopFlag);
}
class="kw">inline eTranscriptionDefine GetDefinition(class="kw">const class="type">class="kw">string &In, class="type">class="kw">string &Out)
{
	class="type">class="kw">string szInfo;

◍ 回放配置里的字符串预处理与文件读取

做 Market Replay 的配置文件解析时,先得把每行文本规整干净。上面那段逻辑把入参先转大写、去左右空格,若首字符是 '#' 直接当注释返回 INFO,不是 '[' 开头的就原样吐出,是 '[' 才进定义解析并把所有非空格字符拼回 Out,返回 DEFINE。

实际落盘配置放在终端的 'Market Replay\\' 子目录下,用 FILE_CSVFILE_READFILE_ANSI 打开;句柄拿到 INVALID_HANDLE 就弹窗报“Failed to open configuration file.”并退出。

读文件前会 Print 一条“Loading data for replay. Please wait....”,同时给 m_Ticks.Rate 预分配 def_BarsDiary 长度、把 nRate 置 -1、首元素 time 清零——这套初始化在外汇与贵金属历史回放里是高风险的,Tick 序列错位可能导致重放价格脱节。 宏 macroERROR 把关每一处失败:FileClose 后弹 MessageBox 并返回 false,调用时传空串就不弹窗只静默退出。

MQL5 / C++
szInfo = In;
Out = "";
StringToUpper(szInfo);
StringTrimLeft(szInfo);
StringTrimRight(szInfo);
if (StringSubstr(szInfo, class="num">0, class="num">1) == "#") class="kw">return Transcription_INFO;
if (StringSubstr(szInfo, class="num">0, class="num">1) != "[")
{
   Out = szInfo;
   class="kw">return Transcription_INFO;
}
for (class="type">int c0 = class="num">0; c0 < StringLen(szInfo); c0++)
   if (StringGetCharacter(szInfo, c0) > &class="macro">#x27; &class="macro">#x27;)
      StringAdd(Out, StringSubstr(szInfo, c0, class="num">1));
class="kw">return Transcription_DEFINE;
}
class="type">bool SetSymbolReplay(class="kw">const class="type">class="kw">string szFileConfig)
   {
class="macro">#define macroERROR(MSG) { FileClose(file); if (MSG != "") MessageBox(MSG, "Market Replay", MB_OK); class="kw">return false; }
      class="type">int    file,
             iLine;
      class="type">class="kw">string szInfo;
      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=0、iLine=1 初始化,随后在 while 循环里逐行读取文件,直到文件结尾或用户触发 _StopFlag 才退出。 循环内每次调用 FileReadString 拿到一行,交给 GetDefinition 判定该行属于 DEFINE 还是 INFO 类型。DEFINE 分支里靠 szInfo 字符串比对把 iStage 切到 1/2/3,分别对应 Bars 文件、Ticks 文件、Ticks 转 Bars 三种模式;若字符串不匹配任何预设宏,就走 macroERROR 报出“某行无法识别”。 状态机靠 iStage 决定后续 INFO 行怎么处理:iStage 仍为 0 时说明前面没解析出合法命令,直接报错退出;进入 1 之后才允许把 INFO 内容按当前阶段落库。 在 MT5 里跑这类 EA 时,把配置文件第一行写成非 def_STR_FilesBar / FilesTicks / TicksToBars 的脏数据,就能在日志看到第 2 行(iLine 从 1 计)抛出的宏错误,以此验证解析顺序是否符合预期。

MQL5 / C++
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)
         {
            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:
把逐笔存档交给小布
小布盯盘的 AIGC 已内置品种级跳价采集视图,打开对应外汇或贵金属页就能看到历史逐笔密度,你只需设定要记录的品种,重复抓取与归档的脏活由它跑。

常见问题

因为财报、资产合并拆分、期货到期等事件会让价格被调整,而 1 分钟柱线无法反映这些跳价层面的真实变动,导致后来者难以理解当时市场反应。
可以,小布盯盘的品种页内置了逐笔跳价密度与归档入口,外汇贵金属这类高波动品种尤其适合长期开着自动记录,降低人工漏存风险。
回放系统会发出错误警告,这其实是刻意保留的保护机制,防止配置文件拼写错误导致模拟创建失控。
二者杠杆高、事件驱动跳空频繁,真实跳价虽珍贵但模拟结果仅代表历史概率,实盘仍可能因流动性断裂出现偏离,属高风险活动。