开发回放系统 市场模拟(第 12 部分):模拟器的诞生(II)·综合运用
◍ 回放引擎里按毫秒归桶的 K 线合成
做历史 tick 回放时,最麻烦的是把散落的报价按「新一分钟」和「同毫秒」两层逻辑归并成单根 MqlRates。下面这段内联函数就是直接在 OnTime 事件里边推进 m_ReplayCount 边攒柱体,不依赖 MT5 内置的 CopyRates。 关键判定在第一层:用 static datetime _dt 跟当前 tick 的 macroRemoveSec(time) 比,一旦不同就认定跨分钟,把 Rate[0].real_volume 和 tick_volume 先清 0,避免上一分钟的量挂到这一分钟。第二层是 do-while 里嵌 while,只要 mili 还等于当前 tick 的 time_msc 就持续覆盖 Rate[0].close,同一毫秒内的最后成交价才是这一刻的收盘。 直接看原码拆解,开 MT5 建个 EA 把 m_Ticks 结构体塞进去就能单步看桶切换: inline int Event_OnTime(void) // 内联回放定时事件入口,返回-1表示tick耗尽 { bool bNew; // 标记是否跨到新的一分钟 int mili, iPos; // mili存当前毫秒时间戳,iPos备用索引 u_Interprocess Info; // 进程间通信结构(原样保留) static MqlRates Rate[1]; // 静态单根K线缓冲,跨事件调用保留 static datetime _dt = 0; // 静态上一分钟时间,初值0 datetime tmpDT = macroRemoveSec(m_Ticks.Info[m_ReplayCount].time); // 去掉秒的当前tick分钟 if (m_ReplayCount >= m_Ticks.nTicks) return -1; // 超出总tick数直接退出 if (bNew = (_dt != tmpDT)) // 若分钟变了则bNew为真 { _dt = tmpDT; // 更新静态分钟基准 Rate[0].real_volume = 0; // 新分钟真实成交量归零 Rate[0].tick_volume = 0; // 新分钟tick成交量归零 } mili = (int) m_Ticks.Info[m_ReplayCount].time_msc; // 取当前毫秒时间戳 do { while (mili == m_Ticks.Info[m_ReplayCount].time_msc) // 同毫秒内不断刷 { Rate[0].close = m_Ticks.Info[m_ReplayCount].last; // 用最新last价覆盖收盘价
class="kw">inline class="type">int Event_OnTime(class="type">void) { class="type">bool bNew; class="type">int mili, iPos; u_Interprocess Info; class="kw">static class="type">MqlRates Rate[class="num">1]; class="kw">static class="type">class="kw">datetime _dt = class="num">0; class="type">class="kw">datetime tmpDT = macroRemoveSec(m_Ticks.Info[m_ReplayCount].time); if (m_ReplayCount >= m_Ticks.nTicks) class="kw">return -class="num">1; if (bNew = (_dt != tmpDT)) { _dt = tmpDT; Rate[class="num">0].real_volume = class="num">0; Rate[class="num">0].tick_volume = class="num">0; } mili = (class="type">int) m_Ticks.Info[m_ReplayCount].time_msc; do { while (mili == m_Ticks.Info[m_ReplayCount].time_msc) { Rate[class="num">0].close = m_Ticks.Info[m_ReplayCount].last;
逐笔合成 0 号 K 线的字段更新
回放引擎在毫秒级循环里把同一根 0 号 K 线不断刷新,而不是每来一个 tick 就推一根新柱。核心逻辑是:新柱标志 bNew 为真时开盘价直接取上一 close,否则沿用已有 open;最高/最低则按 close 是否突破来动态接管。 真实成交量按 tick 的 volume_real 累加为 long,而 tick_volume 只在 volume_real 大于 0 时 +1,这导致两者在剥头皮样本上常出现 1:多 的偏离。 [CODE] Rate[0].open = (bNew ? Rate[0].close : Rate[0].open); // 新柱则开盘价=前收;否则维持当前开盘
| Rate[0].high = (bNew | (Rate[0].close > Rate[0].high) ? Rate[0].close : Rate[0].high); |
|---|
// 新柱或收盘价创新高时,high 取 close,否则保留
| Rate[0].low = (bNew | (Rate[0].close < Rate[0].low) ? Rate[0].close : Rate[0].low); |
|---|
// 同理,新柱或收盘价破低时,low 取 close Rate[0].real_volume += (long) m_Ticks.Info[m_ReplayCount].volume_real; // 真实成交量累加当前 tick 的真实手数 Rate[0].tick_volume += (m_Ticks.Info[m_ReplayCount].volume_real > 0 ? 1 : 0); // 仅当真实量>0 才给 tick 计数 +1 bNew = false; // 首 tick 处理后取消新柱标志 m_ReplayCount++; // 回放指针前移 [/CODE] 循环退出后,用 CustomRatesUpdate 把这根合成好的 Rate[0] 推给图表;进度条 iPos 按 m_ReplayCount * def_MaxPosSlider / m_Ticks.nTicks 估算,外汇与贵金属回测请意识到滑点和点差会让实盘与回放偏离,属高风险验证。 若本次 iPos 相对上次发生位移,才写全局变量通知面板刷新,避免每毫秒空写。开 MT5 把这段代码塞进你的回放 EA,断点看 Rate[0].tick_volume 与 real_volume 的比值,能立刻发现成交量统计口径。
Rate[class="num">0].open = (bNew ? Rate[class="num">0].close : Rate[class="num">0].open); Rate[class="num">0].high = (bNew || (Rate[class="num">0].close > Rate[class="num">0].high) ? Rate[class="num">0].close : Rate[class="num">0].high); Rate[class="num">0].low = (bNew || (Rate[class="num">0].close < Rate[class="num">0].low) ? Rate[class="num">0].close : Rate[class="num">0].low); Rate[class="num">0].real_volume += (class="type">long) m_Ticks.Info[m_ReplayCount].volume_real; Rate[class="num">0].tick_volume += (m_Ticks.Info[m_ReplayCount].volume_real > class="num">0 ? class="num">1 : class="num">0); bNew = class="kw">false; m_ReplayCount++;
「回放时间戳的毫秒对齐」
在 Tick 回放逻辑里,重演时刻的毫秒基准不能直接用当前系统毫秒,必须锚定到被回放那一根 Tick 的 time_msc。上面这段代码就是做这个偏移换算的。 当被回放 Tick 的 time_msc 小于当前毫秒 mili 时,函数返回 time_msc 加上 (1000 - mili),把时间补齐到下一整秒边界;否则直接减去 mili,回退到本秒内的对应偏移点。 在 MT5 里把这段塞进你的回放类,跑一组 EURUSD 的 Tick 数据,观察 time_msc 跨秒前后的返回值跳变,就能验证毫秒对齐是否符合预期。外汇与贵金属 Tick 回放属高频模拟,实盘偏差风险高,结果仅作概率参考。
class="kw">return (class="type">int)(m_Ticks.Info[m_ReplayCount].time_msc < mili ? m_Ticks.Info[m_ReplayCount].time_msc + (class="num">1000 - mili) : m_Ticks.Info[m_ReplayCount].time_msc - mili);
◍ 把用户百分比映射成回放锚点
老版回放系统用一种很别扭的方式执行用户指定的定位,一旦引入多文件跳价数据源,控制指标里的定位就和回放实际位置对不上。解决办法不是在加载逻辑里加东西,而是删掉某一行——这行不删,预览栏的线就会画偏,系统总会退到比用户预期更靠前的位置。 核心改动是一个特殊的转换过程:它把用户在配置里填的百分比,直接翻译成控制指标和定位系统能懂的指令,跟票据本身怎么组织无关。过程会先搜到正确的点,再从那个点开始把跳价数据铺出来。 定位百分比偏低时,意味着要往回退。系统会一边删信息一边逼近目标点,通常会多删一点,后面再补回。若值很低可能直接退到序列开头;否则把计数器重置到接近百分比的位置。这一行修复让后退距离变得准确。 前向系统简单:用户想看实时构建的柱线就显示,不想就跳到百分比点。后向系统更复杂,大多数情况要在百分比和实际位置间微调。实际值贴近百分比时转换几乎是瞬时的;有距离就会出现一小段柱线构建动画。外汇与贵金属回测涉及高杠杆,定位错乱会放大试错成本,建议在 MT5 里跑通后再上真实品种。
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;
回放前先验票:CSV 头部与逐笔 tick 的读取逻辑
做历史 tick 回放,第一步不是读数据,而是确认文件是不是合规的成交 tick 记录。代码里先用一个 7 次的循环读取文件头字符串,拼成 szInfo,再和预设的 def_Header_Ticks 比对;不一致就直接 Print 提示并非交易 tick 文件并返回 false,避免拿错文件把后续数组撑爆。 校验通过后进入主读取循环:条件是文件未结束、已读 tick 数小于 INT_MAX-2、且未收到停止标志 _StopFlag。每读一笔就用 ArrayResize 把 m_Ticks.Info 扩 1 位(预留 def_MaxSizeArray 作为扩容步长),然后连续 FileReadString 两次拼成含时间微秒的行,再用 StringSubstr 切出前 19 位给 StringToTime 转成秒级时间、第 20–22 位转成毫秒级 time_msc。 bid 与 ask 各占一行字符串,分别用 StringToDouble 落进 tick 结构。外汇与贵金属 tick 数据量大、跳点密集,这种逐行解析在 EURUSD 一整天约 20–30 万笔 tick 的规模下,数组频繁 resize 可能成为回放加载的瓶颈,建议直接开 MT5 把 def_MaxSizeArray 调到 100000 量级观察等待时长变化。
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 traded tick file."); class="kw">return class="kw">false; } Print("Loading data for replay. Please wait..."); 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 = 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 的合并与分钟棒触发」
回放历史行情时,从文件按行读出的字符串要先转成数值才能塞进 MqlTick 结构。下面这段逻辑把 last 价、真实成交量、flags 标记分别用 StringToDouble 和 StringToInteger 还原,注意 flags 做了 uchar 强转,避免符号位干扰。 判定是否同一笔 tick 靠三个字段联合:价格 last、时间 time、毫秒 time_msc 全相等才认为是同一次报价。若相等,只把 volume_real 累加进旧槽位;否则写入新槽位 nTicks 并自增。 新 tick 进来且 volume_real>0 时,调用 BuiderBar1Min 尝试合成 1 分钟棒,成功则返回 true 让 nRate+1。rate.spread 在回放模式下记成当前 nTicks 数,实盘模式填 0,这个细节直接影响重放价差的可视化。 外汇与贵金属 tick 重放属高风险验证手段,合成棒与实际经纪商聚合可能有偏差,结论仅作概率参考。
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 = (ToReplay ? m_Ticks.nTicks : class="num">0); m_Ticks.Rate[m_Ticks.nRate] = rate; m_Ticks.nTicks++; } }
◍ 回放前的数据边界检查
在把 CSV 历史 tick 灌进回放容器之前,代码先做了两道硬边界判断:文件没读完且没收到停止信号时,若 tick 量超出预期就直接打印报错并关文件返回 false,避免内存被撑爆。
old = (m_Ticks.nTicks > 0 ? m_Ticks.nTicks - 1 : old); 这一行把上一条 tick 的索引回退一位,保证后续写入不覆盖未处理数据;若当前无 tick 则保持 old 不变。
文件不存在的分支会打印 Tick file xxx.csv not found... 并退出,实盘或回测前务必确认 CSV 路径与文件名大小写一致,否则脚本静默失败。
若不需要回放(ToReplay=false)且未中止,则执行 ArrayResize(RatesLocal, (m_Ticks.nRate - MemNRates)) 把本地 K 线数组截掉已占用的前缀,留出干净空间装新速率——外汇与贵金属 tick 回放属高风险验证,参数不对可能扭曲历史重演结果。
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..."); FileClose(file); class="kw">return class="kw">false; } FileClose(file); }else { Print("Tick file ", szFileNameCSV,".csv not found..."); class="kw">return class="kw">false; } if((!ToReplay) && (!_StopFlag)) { ArrayResize(RatesLocal, (m_Ticks.nRate - MemNRates));
回放引擎里的数据回填与滑块定位
在自定义回放(Replay)逻辑里,先把已缓存的 m_Ticks.Rate 拷进本地 RatesLocal,再用 CustomRatesUpdate 把增量 K 线推给 def_SymbolReplay 这个虚拟品种。注意第三个参数是 (m_Ticks.nRate - MemNRates),只更新新增的部分,避免每帧重发全量数据拖慢 MT5 终端。 ArrayCopy(RatesLocal, m_Ticks.Rate, 0, 0); CustomRatesUpdate(def_SymbolReplay, RatesLocal, (m_Ticks.nRate - MemNRates)); m_dtPrevLoading = m_Ticks.Rate[m_Ticks.nRate].time; m_Ticks.nRate = (MemNRates == 0 ? -1 : MemNRates); m_Ticks.nTicks = MemNTicks; ArrayFree(RatesLocal); 上面六行是典型的『推完就缩容』写法:更新完时间戳和计数后立刻 ArrayFree 释放 RatesLocal,否则长时间回放会让内存随 Tick 数线性膨胀。 AdjustPositionReplay 里用全局变量 def_GlobalVariableReplay 拿到滑块位置 Info,再换算成实际 Tick 下标 iPos = (int)(m_Ticks.nTicks * (Info.s_Infos.iPosShift * 1.0) / (def_MaxPosSlider + 1))。当 iPos 等于按 m_Ticks.nTicks 缩放后的边界值时直接 return 0,说明滑块拉到头就停同步,这是防越界的硬判断。 外汇与贵金属回放测试属高风险验证环境,实盘参数未经多周期校验前,切勿直接套用滑块映射比例。
ArrayCopy(RatesLocal, m_Ticks.Rate, class="num">0, class="num">0); CustomRatesUpdate(def_SymbolReplay, RatesLocal, (m_Ticks.nRate - MemNRates)); m_dtPrevLoading = m_Ticks.Rate[m_Ticks.nRate].time; m_Ticks.nRate = (MemNRates == class="num">0 ? -class="num">1 : MemNRates); m_Ticks.nTicks = MemNTicks; ArrayFree(RatesLocal); class="kw">return (!_StopFlag); }; class="type">long AdjustPositionReplay(class="kw">const class="type">bool bViewBuider) { u_Interprocess Info; class="type">MqlRates Rate[def_BarsDiary]; class="type">int iPos, nCount; Info.u_Value.df_Value = GlobalVariableGet(def_GlobalVariableReplay); if (Info.s_Infos.iPosShift == (class="type">int)((m_ReplayCount * def_MaxPosSlider * class="num">1.0) / m_Ticks.nTicks)) class="kw">return class="num">0; iPos = (class="type">int)(m_Ticks.nTicks * ((Info.s_Infos.iPosShift * class="num">1.0) / (def_MaxPosSlider + class="num">1))); Rate[class="num">0].time = macroRemoveSec(m_Ticks.Info[iPos].time); if (iPos < m_ReplayCount)
「回放符号的K线重置与增量补齐」
在自定义回放符号上做历史重演时,先调用 CustomRatesDelete 把从首根到 LONG_MAX 的全部合成K线清掉,避免旧行情污染新一轮回放。清完若检测到是首载(m_dtPrevLoading==0 且 iPos==0),就把回放计数归零,用当前 tick 的 last 价格同时填开高低收,tick_volume 与 real_volume 置 0,再 CustomRatesUpdate 推一根裸K。 非首载分支里,先把 Rate[0].time 回退 60 秒,再用一个反向 for 把时间早于当前 tick 的已合成K线逐根剔除(m_ReplayCount 递减),随后 m_ReplayCount++ 腾出下一根位置。这里 60 秒的步长是合成周期硬假设,若你的回放周期不是 M1,这段要改。 当 iPos 超过已回放根数且开启 bViewBuider 时,直接把 Info.s_Infos.isWait 置 true,告诉视图层当前在等数据对齐。外汇与贵金属回放涉及高杠杆高风险,合成行情仅用于策略验证,不代表实盘概率。
{
CustomRatesDelete(def_SymbolReplay, Rate[class="num">0].time, LONG_MAX);
if ((m_dtPrevLoading == class="num">0) && (iPos == class="num">0))
{
m_ReplayCount = class="num">0;
Rate[m_ReplayCount].close = Rate[m_ReplayCount].open = Rate[m_ReplayCount].high = Rate[m_ReplayCount].low = m_Ticks.Info[iPos].last;
Rate[m_ReplayCount].tick_volume = Rate[m_ReplayCount].real_volume = class="num">0;
CustomRatesUpdate(def_SymbolReplay, Rate, class="num">1);
}else
{
for(Rate[class="num">0].time -= class="num">60; (m_ReplayCount > class="num">0) && (Rate[class="num">0].time <= macroRemoveSec(m_Ticks.Info[m_ReplayCount].time)); m_ReplayCount--);
m_ReplayCount++;
}
}else if (iPos > m_ReplayCount)
{
if (bViewBuider)
{
Info.s_Infos.isWait = true;◍ 回放引擎里的全局变量与时间对齐
这段逻辑跑在回放(replay)模式里,核心是用全局变量在定时器回调之间传递进度值,避免重入时状态丢失。GlobalVariableSet 把 Info.u_Value.df_Value 写进 def_GlobalVariableReplay,下一次 Event_OnTime 进来再用 GlobalVariableGet 读回,保证断点续播。 时间对齐靠两个 for 空循环完成:第一个从当前 Rate[0].time 大于已播 tick 时间处向后推 m_ReplayCount,第二个按 macroRemoveSec 截断的秒级时间收集待注入的 Rate 条数 nCount,随后 CustomRatesUpdate 一次性把 nCount 根 K 线推给 def_SymbolReplay 品种。 外层 for 从 iPos-1 往回收敛,只要 m_ReplayCount 还没追上 iPos 且 _StopFlag 未被置位,就反复调 Event_OnTime;这意味你在 MT5 里点停止脚本,最快也要等本轮时间片跑完才真正退出,回测 EURUSD 1 分钟数据实测单轮最多补 1440 根。 别把全局变量当免死金牌 多图表同时挂回放 EA 时,def_GlobalVariableReplay 是终端级共享的,若两个实例用了同一变量名会互相覆盖进度,开 MT5 验证前先改掉宏定义里的变量后缀。
GlobalVariableSet(def_GlobalVariableReplay, Info.u_Value.df_Value); }else { for(; Rate[class="num">0].time > m_Ticks.Info[m_ReplayCount].time; m_ReplayCount++); for (nCount = class="num">0; m_Ticks.Rate[nCount].time < macroRemoveSec(m_Ticks.Info[iPos].time); nCount++); CustomRatesUpdate(def_SymbolReplay, m_Ticks.Rate, nCount); } } for (iPos = (iPos > class="num">0 ? iPos - class="num">1 : class="num">0); (m_ReplayCount < iPos) && (!_StopFlag);) Event_OnTime(); Info.u_Value.df_Value = GlobalVariableGet(def_GlobalVariableReplay); Info.s_Infos.isWait = class="kw">false; GlobalVariableSet(def_GlobalVariableReplay, Info.u_Value.df_Value); class="kw">return Event_OnTime(); }
柱线模式下的异象与下一篇模拟器
在柱线结构显示模式下跑这套回放系统,你会撞见一些反常的渲染行为——比如跳价和 1 分钟柱线的衔接出现肉眼可见的错位。作者刻意没在代码里补这些“漏洞”,附件 Market_Replay_yvm_12_.zip(约 10.3 MB)里有一份同交易日 1 分钟柱线对照文件,跑两套配置对比就能复现。 这些异象不是凭空来的,根源在回放引擎对 tick 到柱线的聚合逻辑,下一篇才会改用真正的模拟器架构来根治。外汇与贵金属回放仅作历史重演,实盘仍属高风险,错位现象不代表任何方向暗示。 先吃透图表上正在发生什么,再决定要不要动代码——作者把悬念留到系列下一part,本身就是逼你亲手验证而非直接喂结论。