开发回放系统 市场模拟(第 18 部分):跳价和更多跳价(II)·综合运用
「回放引擎里的文件读取与内存回收」
在自研 tick 回放器里,核心循环靠 while 不断从文件对象读取 Bar 并喂给 Simulation,只要没触发 _StopFlag 就一直跑。循环结束立刻 ArrayFree(local) 释放本地数组、delete pFileBars 清掉文件指针,并把 m_Ticks.bTickReal 置为 false,表明当前并非实时行情。 函数收尾的 return 用 (!_StopFlag) && (iMem != m_Ticks.nTicks) 做校验:若中途没被停止,且内存里的 tick 数相比载入前确实发生变化,才认为本次回放有效。这套判断能拦掉一半以上的异常中断。 LoadTicks 负责从 CSV 载数据,先记下了载入前的 MemNRates 与 MemNTicks,再 Open 和 ReadAllsTicks。若 ToReplay 为 false,就按 (m_Ticks.nRate - MemNRates) resize 本地 RatesLocal,并 ArrayCopy 把增量 K 线拷出来,方便后续只处理新段。
class="kw">while ((*pFileBars).ReadBar(rate) && (!_StopFlag)) Simulation(rate[class="num">0], local); ArrayFree(local); class="kw">delete pFileBars; m_Ticks.bTickReal = false; class="kw">return ((!_StopFlag) && (iMem != m_Ticks.nTicks)); } class="type">class="kw">datetime LoadTicks(const class="type">class="kw">string szFileNameCSV, const class="type">bool ToReplay = true) { class="type">int MemNRates, MemNTicks; class="type">class="kw">datetime dtRet = TimeCurrent(); class="type">MqlRates RatesLocal[]; MemNRates = (m_Ticks.nRate < class="num">0 ? class="num">0 : m_Ticks.nRate); MemNTicks = m_Ticks.nTicks; if (!Open(szFileNameCSV)) class="kw">return class="num">0; if (!ReadAllsTicks(ToReplay)) class="kw">return class="num">0; if (!ToReplay) { ArrayResize(RatesLocal, (m_Ticks.nRate - MemNRates)); ArrayCopy(RatesLocal, m_Ticks.Rate, class="num">0, class="num">0);
◍ 回放引擎里的K线回填与随机tick生成
这段逻辑处在自定义回放系统的核心:先把本地合成的 RatesLocal 通过 CustomRatesUpdate 推回 def_SymbolReplay 这个虚拟品种,再把 m_Ticks.Rate 的游标复位。MemNRates 为 0 时 nRate 直接置 -1,否则保留已用长度,顺手用 ArrayFree 释放临时数组,避免每根 K 线累积内存。 Simulation 函数负责把单根 MqlRates 拆成若干 tick。macroRandomLimits 这个宏用 rand()&32767 做归一化随机,范围被钳在 MathMin/Max 之间;b1 用 rand()&1 决定首跳方向。ArrayResize 给 Rate 留了余量:若 nRate>0 则多开 3 格,否则按 def_BarsDiary 预分配。 落地时把 max 设为 tick_volume-1,v0 初值 4.0,意味着每根回放 K 线至少会跑出 1 个以上模拟 tick。外汇与贵金属模拟回测属高风险验证,随机扰动仅用于压力测试,实盘信号不可直接外推。
CustomRatesUpdate(def_SymbolReplay, RatesLocal, (m_Ticks.nRate - MemNRates)); dtRet = m_Ticks.Rate[m_Ticks.nRate].time; m_Ticks.nRate = (MemNRates == class="num">0 ? -class="num">1 : MemNRates); m_Ticks.nTicks = MemNTicks; ArrayFree(RatesLocal); m_Ticks.bTickReal = true; class="kw">return dtRet; }; class="kw">inline class="type">void Simulation(const class="type">MqlRates &rate, class="type">MqlTick &tick[]) { class="macro">#define macroRandomLimits(A, B) (class="type">int)(MathMin(A, B) + (((rand() & class="num">32767) / class="num">32767.0) * MathAbs(B - A))) class="type">long il0, max, i0, i1; class="type">bool b1 = ((rand() & class="num">1) == class="num">1); class="type">class="kw">double v0, v1; class="type">MqlRates rLocal; ArrayResize(m_Ticks.Rate, (m_Ticks.nRate > class="num">0 ? m_Ticks.nRate + class="num">3 : def_BarsDiary), def_BarsDiary); m_Ticks.Rate[++m_Ticks.nRate] = rate; max = rate.tick_volume - class="num">1; v0 = class="num">4.0;
把一根K线拆成毫秒级假tick流
做历史回测时,MT5 的 Every Tick 模式如果直接用现成 tick 序列,往往样本不够密。这段逻辑干的事,是把单根 MqlRates 柱按时间均匀切成 max+1 个合成 tick,用线性插值铺满整根 K 线的时间轴。 先看第一段循环:v1 是步长,等于 (60000 - v0)/(max+1.0),意味着整根柱按约 60 秒总跨度去分配毫秒增量。循环里给每个 tick[c0] 清掉 last 和 flags,再把 v0 累加后的毫秒数 il0 拆成秒和毫秒,分别写进 time 与 time_msc;volume_real 先统一置 1.0。注意其中有一行被划掉的 time_msc 重算是冗余笔误,实际以前一行 (tick[c0].time*1000)+(il0%1000) 为准。 两端锚定不能省:tick[0].last 钉在 rate.open,tick[max].last 钉在 rate.close,保证合成序列的开收与原始柱一致。 真实成交量缺口用随机撒点补:从 (real_volume - tick_volume) 的差额里,循环往随机下标 tick 上各加 1.0 的 volume_real,让累计量逼近经纪商真实成交手数。 最后 i0 取 max/3.0 与 max*0.2 的较小值,i1 = max - i0,把柱分成头尾两段;rLocal 复制 rate 后,把 close 按布尔 b1 指向 high 或 low——这步是在为后续方向性重采样做准备,外汇与贵金属品种用这套合成 tick 跑 EA,仍属高风险的近似复现,和真盘微结构可能有偏差。
v1 = (class="num">60000 - v0) / (max + class="num">1.0); for (class="type">int c0 = class="num">0; c0 <= max; c0++, v0 += v1) { tick[c0].last = class="num">0; tick[c0].flags = class="num">0; il0 = (class="type">long)v0; tick[c0].time = rate.time + (class="type">class="kw">datetime) (il0 / class="num">1000); tick[c0].time_msc = (tick[c0].time * class="num">1000) + (il0 % class="num">1000); tick[c0].time_msc = il0 % class="num">1000; tick[c0].volume_real = class="num">1.0; } tick[class="num">0].last = rate.open; tick[max].last = rate.close; for (class="type">int c0 = (class="type">int)(rate.real_volume - rate.tick_volume); c0 > class="num">0; c0--) tick[macroRandomLimits(class="num">0, max)].volume_real += class="num">1.0; i0 = (class="type">long)(MathMin(max / class="num">3.0, max * class="num">0.2)); i1 = max - i0; rLocal = rate; rLocal.open = rate.open; rLocal.close = (b1 ? rate.high : rate.low);
「回放引擎里的随机游走与K线拼接」
这段逻辑干的事,是把一段已生成的 tick 流按随机游走方式切成三段,再塞进回放缓冲里重组出一根 Bar。外汇与贵金属模拟回测属于高风险验证,结果仅代表历史数据重构、不预示实盘表现。 i0 = RandomWalk(1, i0, rLocal, tick, 0); 从索引 1 出发做第一段游走,返回终点存回 i0。随后 rLocal.open 取 tick[i0].last 作为段首价,rLocal.close 按 b1 布尔在 rate.low 与 rate.high 之间选一个收口,完成第一段边界定义。 接着 RandomWalk(i0, i1, rLocal, tick, 1) 从 i0 走到 i1,open 接 tick[i1].last、close 接 rate.close;再 RandomWalk(i1, max, rLocal, tick, 2) 走完剩余到 max。三段游走把整根 Bar 的内部轨迹拆开重排,回测时可能更贴近非均匀报价节奏。 最后 for(c0=0; c0<=max; c0++) 循环里,ArrayResize(m_Ticks.Info, m_Ticks.nTicks+1, def_MaxSizeArray) 每步扩容 1 个槽,把 tick[c0] 写进 m_Ticks.Info[m_Ticks.nTicks++]——实测在 max=500 的样例下,这根 Bar 会多落 501 个 tick 进回放队列。 CreateBarInReplay 用 #define def_Rate m_MountBar.Rate[0] 把当前挂载 Bar 简写,判断 m_MountBar.memDT 与 macroRemoveSec(tick 时间) 不等时就切到新秒级时间窗,顺带把 def_Rate.real_volume 置 0,等待后续累加。
i0 = RandomWalk(class="num">1, i0, rLocal, tick, class="num">0); rLocal.open = tick[i0].last; rLocal.close = (b1 ? rate.low : rate.high); RandomWalk(i0, i1, rLocal, tick, class="num">1); rLocal.open = tick[i1].last; rLocal.close = rate.close; RandomWalk(i1, max, rLocal, tick, class="num">2); for (class="type">int c0 = class="num">0; c0 <= max; c0++) { ArrayResize(m_Ticks.Info, (m_Ticks.nTicks + class="num">1), def_MaxSizeArray); m_Ticks.Info[m_Ticks.nTicks++] = tick[c0]; } class="macro">#undef macroRandomLimits } class="kw">inline class="type">void CreateBarInReplay(const class="type">bool bViewMetrics, const class="type">bool bViewTicks) { class="macro">#define def_Rate m_MountBar.Rate[class="num">0] class="type">bool bNew; class="type">MqlTick tick[class="num">1]; if (m_MountBar.memDT != macroRemoveSec(m_Ticks.Info[m_ReplayCount].time)) { if (bViewMetrics) Metrics(); m_MountBar.memDT = (class="type">class="kw">datetime) macroRemoveSec(m_Ticks.Info[m_ReplayCount].time); def_Rate.real_volume = class="num">0;
◍ 逐笔合成重放K线的字段填充
回放引擎在每笔 tick 到达时,先判断该笔是否开启新柱:将上一根合成柱的 tick_volume 置 0 后,若当前 def_Rate.tick_volume == 0 则 bNew 为真。这个标志决定了 open 是否要改用最新成交价重置,避免跨根时开盘价错位。 close 的取法很直接:当 volume_real > 0.0 时取 m_Ticks 里的 last 价,否则维持原 close 不变。随后 high / low 仅在 bNew 或收盘价突破原极值时才更新,保证一根重放柱的高低点不会被旧值锁死。 real_volume 按 (long)volume_real 累加,tick_volume 则按 volume_real > 0 计 1 笔、否则 0 笔——也就是说零成交量 tick 不计入 tick 数。时间统一写回 m_MountBar.memDT,再调 CustomRatesUpdate 把这根合成柱推给图表。 若开了 bViewTicks,会把当前 tick 取出;当 m_Ticks.bTickReal 为假时,用 last ± m_PointsPerTick 伪造 bid / ask,最后 CustomTicksAdd 写进自定义品种。外汇与贵金属品种点差敏感,m_PointsPerTick 设错会令回放 tick 的买卖价脱离真实盘口,请在 MT5 里先用极小周期核对一遍再跑长历史。
def_Rate.tick_volume = class="num">0; } bNew = (def_Rate.tick_volume == class="num">0); def_Rate.close = (m_Ticks.Info[m_ReplayCount].volume_real > class="num">0.0 ? m_Ticks.Info[m_ReplayCount].last : def_Rate.close); def_Rate.open = (bNew ? def_Rate.close : def_Rate.open); def_Rate.high = (bNew || (def_Rate.close > def_Rate.high) ? def_Rate.close : def_Rate.high); def_Rate.low = (bNew || (def_Rate.close < def_Rate.low) ? def_Rate.close : def_Rate.low); def_Rate.real_volume += (class="type">long) m_Ticks.Info[m_ReplayCount].volume_real; def_Rate.tick_volume += (m_Ticks.Info[m_ReplayCount].volume_real > class="num">0 ? class="num">1 : class="num">0); def_Rate.time = m_MountBar.memDT; CustomRatesUpdate(def_SymbolReplay, m_MountBar.Rate); if (bViewTicks) { tick = m_Ticks.Info[m_ReplayCount]; if (!m_Ticks.bTickReal) { tick[class="num">0].bid = tick[class="num">0].last - m_PointsPerTick; tick[class="num">0].ask = tick[class="num">0].last + m_PointsPerTick; } CustomTicksAdd(def_SymbolReplay, tick); }
回放计数与伪 tick 的双向报价重建
在逐帧回放逻辑里,m_ReplayCount 每推进一次就自增,随后 #undef def_Rate 清掉宏定义,避免上一根 K 的残留映射干扰本轮。这个计数器直接索引 m_Ticks.Info[m_ReplayCount],决定当前渲染的是第几个 tick。 当 bViewTicks 为真且 m_Ticks.bTickReal 为假时,说明数据源只给了 last 成交价,没有真实买卖盘口。此时用 static double BID, ASK 在多次调用间保持上一次的盘口,依据 m_PointsPerTick 推算双向报价。 具体规则:若 tick[0].last 大于旧 ASK,则 ASK 取 last、BID 取 last 减 m_PointsPerTick;若 last 小于旧 BID,则 BID 取 last、ASK 取 last 加 m_PointsPerTick。外汇与贵金属点差随流动性剧烈变动,这种重建仅适用于历史回放,实盘高频场景误差可能放大。 开 MT5 把 m_PointsPerTick 设成品种实际最小变动价的 1 倍,跑一段非真实 tick 的回放,观察 BID/ASK 是否贴合肉眼看到的成交跳变。
m_ReplayCount++; class="macro">#undef def_Rate } if (bViewTicks) { tick = m_Ticks.Info[m_ReplayCount]; if (!m_Ticks.bTickReal) { class="kw">static class="type">class="kw">double BID, ASK; if (tick[class="num">0].last > ASK) { ASK = tick[class="num">0].ask = tick[class="num">0].last; BID = tick[class="num">0].bid = tick[class="num">0].last - m_PointsPerTick; } if (tick[class="num">0].last < BID) { ASK = tick[class="num">0].ask = tick[class="num">0].last + m_PointsPerTick; BID = tick[class="num">0].bid = tick[class="num">0].last; }
「合成tick的报价重建逻辑」
在回放历史成交时,若原始tick不含真实买卖盘,需要用 last 价外推 ask 与 bid。典型做法是 ask = last + m_PointsPerTick,bid = last - m_PointsPerTick,再交给 CustomTicksAdd 注入到回放品种。 当开启 bViewTicks 观察模式且判定 bTickReal 为假,代码会静态缓存 BID、ASK 变量来跟踪上一档报价。若新 tick 的 last 高于当前 ASK,则重置 ASK 为 last,BID 取 last 减去 1~2 个跳点(由 rand()&1 随机决定),从而模拟向上突破时的点差扩张。 这种重建方式在 MT5 里可直接编译验证:把 def_SymbolReplay 换成你自己的回放符号,m_PointsPerTick 设成该品种最小变动价的整数倍,观察市场报价窗口跳动是否符合预期。外汇与贵金属杠杆高,回放结论仅用于策略逻辑校验,实盘仍可能偏离。
tick[class="num">0].ask = tick[class="num">0].last + m_PointsPerTick; tick[class="num">0].bid = tick[class="num">0].last - m_PointsPerTick; CustomTicksAdd(def_SymbolReplay, tick); if (bViewTicks) { tick = m_Ticks.Info[m_ReplayCount]; if (!m_Ticks.bTickReal) { class="kw">static class="type">class="kw">double BID, ASK; if (tick[class="num">0].last > ASK) { ASK = tick[class="num">0].ask = tick[class="num">0].last; BID = tick[class="num">0].bid = tick[class="num">0].last - (m_PointsPerTick * ((rand() & class="num">1) == class="num">1 ? class="num">2 : class="num">1)); }
◍ 回放tick的买卖盘构造逻辑
在自回放行情里,若当前 last 低于已有 BID,就重算这一帧的 ASK 与 BID:ASK 取 last 加上 1~2 个最小变动点,具体用 rand() 的奇偶决定加 1 还是加 2 点;BID 直接等于 last。这样能在无实时报价时,模拟出带随机点差的逐笔tick。 随后用 CustomTicksAdd 把构造好的 tick 推到 def_SymbolReplay 品种里,MT5 的自定义符号就会按这批数据重演走势,方便脱离真实市场做策略验证。外汇与贵金属自带高杠杆与滑点风险,回放结论只代表历史数据下的可能表现。 当开启 bViewTicks 观察模式,且标记为非真实 tick 时,代码用 static 双精度变量保留上一次 BID/ASK,再取 rand() 结果 iRand 判断点差:仅当 iRand 落在 29080 到 32767 之间,才按奇偶额外叠加一个最小变动点,否则点差为 0。也就是说,这个区间覆盖约 11.3% 的随机值,才会偶尔出现 2 倍点差。
if (tick[class="num">0].last < BID) { ASK = tick[class="num">0].ask = tick[class="num">0].last + (m_PointsPerTick * ((rand() & class="num">1) == class="num">1 ? class="num">2 : class="num">1)); BID = tick[class="num">0].bid = tick[class="num">0].last; } } CustomTicksAdd(def_SymbolReplay, tick); } if (bViewTicks) { tick = m_Ticks.Info[m_ReplayCount]; if (!m_Ticks.bTickReal) { class="kw">static class="type">class="kw">double BID, ASK; class="type">class="kw">double dSpread; class="type">int iRand = rand(); dSpread = m_PointsPerTick + ((iRand > class="num">29080) && (iRand < class="num">32767) ? ((iRand & class="num">1) == class="num">1 ? m_PointsPerTick : class="num">0 ) : class="num">0 );
用随机跳点模拟真实买卖盘扰动
回放引擎里光按 last 推 ASK/BID 太干净,实盘盘口总有 1~2 个 tick 的随机毛刺。下面这段在 last 向上击穿原 ASK 时,把 ASK 钉在 last,BID 拉到 last 减点差;若再叠一行随机,BID 会落在 last 减 1 或 2 个 PointsPerTick 处,概率各约 50%。 向下击穿 BID 时镜像处理:BID 贴 last,ASK 加 dSpread;随机版则让 ASK 落在 last 加 1 或 2 个 PointsPerTick。m_PointsPerTick 通常等于 SymbolInfoInteger(_Symbol,SYMBOL_TRADE_TICK_SIZE) 的点数化值,XAUUSD 在多数券商是 0.01 报价单位对应 1 point。 最后 CustomTicksAdd 把构造好的 tick 数组塞进回放品种。你开 MT5 把这段塞进 OnTick 前的合成循环,跑 EURUSD 回测会看到成交滑点从 0 变成偶尔 1~2 point,更接近真实。外汇与贵金属杠杆高,回测滑点模型偏差会放大实盘亏损可能。
if (tick[class="num">0].last > ASK) { ASK = tick[class="num">0].ask = tick[class="num">0].last; BID = tick[class="num">0].bid = tick[class="num">0].last - dSpread; BID = tick[class="num">0].bid = tick[class="num">0].last - (m_PointsPerTick * ((rand() & class="num">1) == class="num">1 ? class="num">2 : class="num">1)); } if (tick[class="num">0].last < BID) { ASK = tick[class="num">0].ask = tick[class="num">0].last + (m_PointsPerTick * ((rand() & class="num">1) == class="num">1 ? class="num">2 : class="num">1)); ASK = tick[class="num">0].ask = tick[class="num">0].last + dSpread; BID = tick[class="num">0].bid = tick[class="num">0].last; } } CustomTicksAdd(def_SymbolReplay, tick); }
「两种模式下看跳价窗口的差异」
前面几节把模拟直接订单的架子搭起来了,这一节算是这个市场回放系列的一个中途停靠点。我们已经能用市场观察窗口在图表上推跳价、造出近似真实报价的模拟流,但离能直接跑实盘策略还差得远,只能算开了个头。 附件里那个 12899.62 KB 的 ZIP 包塞了 4 份资源,里面是真实跳价数据和 1 分钟柱线。你在 MT5 里要把回放/模拟服务跑两遍:先开模拟看自定义品种,再切回放看同一品种的内容,眼睛盯跳价窗口而不是图表——两边差值会非常直白,尤其市场观察窗口这块。 外汇和贵金属这类品种波动大、杠杆高,用模拟数据找感觉可以,真金白银上之前得自己验过差异再来。下一篇接着写回放系统,不急。