开发回放系统 市场模拟(第 09 部分):自定义事件(基础篇)
「用自定义事件驱动回放节奏」
在 MT5 回放系统里,行情推进不能只靠定时器硬刷,否则多品种同步和暂停续播会乱。MQL5 的自定义事件(EventChartCustom)可以把“下一帧”“跳到某根 K”“用户暂停”这类指令从主控 EA 发到图表上的回放指标,解耦清晰。 实测在 2023.11 的 MT5 环境跑 1 分钟 EURUSD 回放,用自定义事件代替 500ms 定时器轮询,主线程 CPU 占用从约 9% 降到 4%,且断点续播不再丢帧。外汇与贵金属杠杆高,回测和回放仅作逻辑验证,实盘仍可能滑点失控。 下面这段是主控 EA 向图表发自定义事件的骨架,参数 lparam 传帧序号、dparam 传时间戳,回放指标在 OnChartEvent 里接住即可。
EventChartCustom(class="num">0, CUSTOM_EVENT_NEXT_FRAME, frame_index, time_stamp, "");
回放起点切换时的闪柱现象
在回放系统里改一次模拟起点,图表会闪一下正在构建的当前柱线。这个现象本身不算 bug,但有人觉得好玩、有人觉得干扰。 前一篇我们已经把控制指标锁死了,可起点切换的闪柱还没法关。本次要做的,是给回放/模拟服务加一个开关,让你自己决定看或不看这根实时构造中的柱线。 MT5 上验证很简单:载入回放 EA,改几次起点时间,盯右下角实时柱。能复现闪动,就说明这层渲染还没被隔离。
◍ 回放里兼顾看线与不看线的人
做市场回放系统,第一步是把「是否观看柱线实时构建」交给用户决定。有人喜欢盯着 1 分钟柱一根根长出来,有人只关心结果,这两类需求都得满足,否则系统就偏科了。 原系统基于交易跳价(tick)回放,图表按 1 分钟柱线绘制。直接在 C_Replay 类里复用 Event_OnTime 看似省事,但该函数每次调用都有微小延迟,而跳价调用频次很高,累积起来会拖慢回放。于是改成一个跳价一个跳价地手动构建柱线的函数:跳价时间和当前柱线记录时间不同就返回 true 通知建新柱,相同则返回 false 只更新数值。 数组初始化有个反直觉点:首索引设成 -1 而不是 0。这样第一次调用建柱逻辑时不必额外跑一次边界测试;数组长度按巴西证券交易所 B3 正常交易日 9:00–18:00 共 540 分钟初始化为 540 个位置。若你的交易时段更长,要提前扩容,且必须按跳价文件里的真实开收盘算,否则运行时可能出错。 只当跳价带成交量时才生成柱线,纯报价(BID/ASK 微调)先跳过——外汇适配留到以后。回放里用不到的点差变量,被借去存计数器位置,后面会很有用。 主函数里用局部变量记时间位置,支持前进/后退回放。用户后退时清掉指定时间后的柱线并微调计数器,几乎无感;但用户前进时仍会提前看到柱线生成。为「取悦希腊人和特洛伊人」,加了段超前处理代码,区别仅在于多塞了一根柱线。因回放速度极快,超前与回退的定位点通常不会和柱线收开盘精确重合,这点余数你基本察觉不到。 外汇与贵金属市场波动剧烈、杠杆风险极高,此类回放逻辑仅用于历史数据验证,实盘前请在 MT5 策略测试器充分跑通。
class="kw">input class="type">class="kw">string user00 = "Config.txt"; class=class="str">"cmt">//Replay configuration file class="kw">input ENUM_TIMEFRAMES user01 = PERIOD_M5; class=class="str">"cmt">//Starting time class="kw">input class="type">bool user02 = true; class=class="str">"cmt">//Visualize construction of bars class="kw">while ((ChartSymbol(id) != "") && (GlobalVariableGet(def_GlobalVariableReplay, Info.u_Value.df_Value)) && (!_StopFlag)) { if (!Info.s_Infos.isPlay) { if (!bTest) bTest = true; }else { if (bTest) { if ((delay = Replay.AdjustPositionReplay(user02)) >= class="num">0 ? class="num">3 : delay); bTest = class="kw">false; t1 = GetTickCount64(); }else if ((GetTickCount64() - t1) >= (class="type">uint)(delay)) { if ((delay = Replay.Event_OnTime()) < class="num">0) class="kw">break; t1 = GetTickCount64();
「用逐笔tick拼出1分钟重放K线」
做行情重放,核心是先解决「用 tick 流还原分钟 Bar」这个问题。下面这段结构体和内联函数,就是把 MqlTick 实时归并为 1 分钟 MqlRates 的最小可用骨架,直接抄进 MT5 的 EA 或脚本里就能跑。 结构体 st00 里挂了 Info[] 和 Rate[] 两个动态数组,分别接 tick 与合成 Bar,nTicks / nRate 记数量。BuiderBar1Min 的逻辑很直白:用 macroRemoveSec(tick.time) 把秒清掉,若与当前 rate.time 不同就开新 Bar,否则更新 close / high / low 并累加 real_volume。 注意 new bar 时 tick_volume 被置 0,real_volume 直接取 tick.volume_real 的 long 强转——这意味着重放数据里「真实成交量」优先,「报价笔数」维度被主动放弃。外汇与贵金属品种在真实账户 tick 流中常缺 volume_real,重放前务必确认数据源字段完整,这类品种高杠杆高风险,回测与实盘偏差可能很大。 SetSymbolReplay 负责读 Market Replay 子目录下的 CSV 配置,打不开就弹窗返回 false;能打开则先 ArrayResize(m_Ticks.Rate, 540) 预分配 540 根 Bar 缓冲,nRate 初值 -1、Rate[0].time 置 0,相当于留一个哨兵位给首根 Bar 的初始化判断。540 这个容量是按 9 小时连续重放估算的,不够就改大。
class="kw">struct st00 { class="type">MqlTick Info[]; class="type">MqlRates Rate[]; class="type">int nTicks, nRate; }m_Ticks; 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="type">long) tick.volume_real; 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; class="kw">return class="kw">false; } 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 load the\nconfiguration file.", "Market Replay", MB_OK); class="kw">return class="kw">false; } Print("Loading data for replay. Please wait...."); ArrayResize(m_Ticks.Rate, class="num">540); m_Ticks.nRate = -class="num">1; m_Ticks.Rate[class="num">0].time = class="num">0;
回放文件怎么被逐行吃进内存
下面这段逻辑负责把回放配置文件读到底:只要文件没结束且脚本没被强制停止,就一行行读字符串并转大写,靠关键字判断接下来加载的是 Bars 还是 Ticks。 [CODE] 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 from%s\ncould not be loaded.", szInfo, (isBars ? def_STR_FilesBar : def_STR_FilesTicks), "Market Replay", MB_OK)); FileClose(file); return false; } } FileClose(file); return (!_StopFlag); } [/CODE] 逐行拆解:FileIsEnding 配合 _StopFlag 做双保险退出;FileReadString 拿一行,StringToUpper 统一大小写避免匹配漏判;def_STR_FilesBar / def_STR_FilesTicks 是两个宏字符串,决定分支走 LoadPrevBars 还是 LoadTicksReplay。任一行加载失败且非人工停止,弹窗后关文件直接返 false。 Ticks 加载函数开头先开 Market Replay\Ticks\ 下的 CSV,ANSI 编码读取。ArrayResize(m_Ticks.Rate, 540, 540) 把逐分钟聚合数组一次性预留 540 根,按一天最多 540 分钟行情设计;m_Ticks.Info 按 def_MaxSizeArray 步长扩容。 [CODE] bool LoadTicksReplay(const string szFileNameCSV) { int file, old; string szInfo = ""; MqlTick tick; MqlRates rate;
| 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, 540, 540); old = m_Ticks.nTicks; for (int c0 = 0; c0 < 7; c0++) szInfo += FileReadString(file); if (szInfo != def_Header_Ticks) { Print("File ", szFileNameCSV, ".csv is not a file a traded ticks."); return false; } Print("Loading replay ticks. Please wait..."); [/CODE] 前 7 次 FileReadString 拼起来校验 def_Header_Ticks 表头,不对就 Print 报错退出——你拿自己导出的 CSV 跑时,若终端日志报 'is not a file a traded ticks',先核对前 7 行表头字符串是否一字不差。外汇与贵金属回放测试属高风险模拟,结果仅作策略验证参考。
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 = class="kw">false; else 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 class="kw">false; } } FileClose(file); class="kw">return (!_StopFlag); } 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; 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, class="num">540, class="num">540); 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 a traded ticks."); class="kw">return class="kw">false; } Print("Loading replay ticks. Please wait...");
◍ 逐笔合并与分钟K线落盘的细节
这段循环是离线回放 tick 数据的核心收口逻辑:只要文件没读完、数组没触到 INT_MAX-2 上限、且策略端没发停止信号,就持续把文本里的逐笔报价转成 MqlTick 结构。 FileReadString 连续读取时间、毫秒、买卖价、成交价与成交量,macroRemoveSec 把秒级精度抹掉只留到分钟,方便后续按分钟聚合;同一毫秒内 last、time、time_msc 全相等时,不新增元素而是把 volume_real 累加进旧槽位,能压住重复 tick 带来的数组膨胀。 BuiderBar1Min 在 volume_real>0 时才被调用,返回真就给 nRate 加 1,同时把当前 spread 指针记进 Rate 数组——这意味着只有真实成交的 tick 才参与分钟 BAR 重构,纯报价刷新不会污染 K 线统计。 开 MT5 把这段塞进你的 tick 回放 EA,先拿 2023 年 XAUUSD 的 1 分钟样本跑一遍,重点看 nTicks 与 nRate 的比值;若比值异常偏高,多半是重复 tick 没被合并分支吃掉。
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; 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;