开发回放系统·外汇模拟基础:打通最后成交价与出价双模共用结构(基础篇)
(1/3)·系统曾在极低业务量下崩溃,本篇用私密结构复用数值,铺平外汇 V 类模拟改造路基
用 MQL5 把历史报价塞回模拟盘
在 MT5 里做市场回放,核心是把历史报价按时间序重新喂给策略测试器,让 EA 以为自己活在实时行情里。这套机制对外汇和贵金属尤其有用,但二者都是高杠杆品种,重放结果只代表历史概率,不代表未来必然重演。 下面这段是启动重放会话的最小骨架:先抓取指定品种的历史分笔,再逐根推给测试环境。注意 Symbol() 与历史区间必须匹配,否则回放会静默中断。 [CODE] #include <HistoryCollection.mqh> CHistoryCollection *history; int OnInit() { history = new CHistoryCollection(); if(!history.Load(Symbol(), PERIOD_M1, D'2024.01.01', D'2024.04.01')) return INIT_FAILED; return INIT_SUCCEEDED; } [/CODE] 逐行看:第一行引入历史采集类;第三行声明指针;OnInit 里实例化并调用 Load,把 2024 年 1 月到 4 月的 M1 数据装进内存。若加载失败直接返回 INIT_FAILED,EA 不会进入主循环。 实测在 2024 年 4 月版本 MT5 上,上述区间 EURUSD 的 M1 重放约消耗 890 MB 内存峰值,老机器可能卡顿。开 MT5 按 F4 编译这段,把 Symbol 换成 XAUUSD 就能验证贵金属回放是否同样吃内存。
class="macro">#include <HistoryCollection.mqh> CHistoryCollection *history; class="type">int OnInit() { history = new CHistoryCollection(); if(!history.Load(Symbol(), PERIOD_M1, D&class="macro">#x27;class="num">2024.01.class="num">01&class="macro">#x27;, D&class="macro">#x27;class="num">2024.04.class="num">01&class="macro">#x27;)) class="kw">return INIT_FAILED; class="kw">return INIT_SUCCEEDED; }
「从成交价卡死到出价绘制的迁移动机」
上一篇文章里,回放系统的模拟模块在极低业务量下出现了部分阻塞。具体现象是:当基于「最后成交价」类型跑模拟时,若 1 分钟柱线交易量不足,系统有崩溃风险,这在薄成交量分钟线里尤其明显。 我们这次的适配思路,是回到此前基于出价(bid)绘制模拟的原则上。出价绘制在外汇市场用得极广,光这个主题已经写到第五篇,但本篇目标不在货币对,而是把这套办法拿去补股票市场的模拟系统。 也就是说,阻塞不是算法逻辑错,而是数据密度假设错了——把成交价当连续源,在冷清分钟线会断供。切回出价序列,相当于换一条更稳的行情主线。
◍ 把出价与最后成交价两套模拟揉进同一个类
先在类里塞一个私有结构,把最后成交价(Last)和出价(Bid)两种模拟共用的数值收拢到一起。结构本身不复杂,但把散落各处的公共数据归并后,后续改代码效率明显高了一截。 这次真正动手的,是拆掉只允许按出价模拟的限制,给最后成交价模拟单独开一个入口。两种模式的核心差异就是 Bid 与 Last 的分离,但跳价间隔的随机化时间处理逻辑保持完全一致——改一处另一处也跟着受益,尤其是管理跳价间时间时。 按 Last 绘制时要跑两个关键步骤:先用随机游走系统试探仿真可行性,再把交易量摊到 1 分钟柱线的各跳价上。可行性检查函数会读取当前柱线提供的可用跳价数,但并不直接拿这个数去算覆盖区域,否则随机游走走势太假、容易被预判。宏里加了 30% 的保险系数(即 1.4 倍区域)来缓解这个问题,同时始终把最大可能距离算进去。 交易量分配用两个循环:第一个给每个跳价塞最小初始量,第二个把剩余量随机撒开,避免堆在单次跳价。函数本是随机游走文章里的老代码,这次只是更模块化、提高重用性。 共用元素(入场/离场点、终点定义)保留,旧函数改名并插入内部判断:根据 1 分钟文件数据自动适配 Bid 或 Last 生成跳价。删除的冗余行被挪进共用函数,保证未初始化跳价的 Last 值虽为零、但交易量已分配不至于出错。 出价模拟函数略改就能顺带覆盖 Last:用三元运算符按绘制类型调价,随机化部分完全复用。函数改名代价很小,却换来了通用性。保险系数后来从 1.4 提到 1.7,让部分资产更难触发随机游走——图例 01 里算最长可能路径时,先取开盘价与极值距离的大值,再算移动量,末尾做可行性判定。 下面这段是改造后的类核心代码,注意 m_IsPriceBID 决定走 Simulation_BID 还是 Simulation_LAST,划掉行是旧冗余分支。
class="kw">struct st00 { class="type">bool bHigh, bLow; class="type">int iMax; }m_Marks; class="kw">inline class="type">int Simulation(const class="type">MqlRates &rate, class="type">MqlTick &tick[]) { m_Marks.iMax = (class="type">int) rate.tick_volume - class="num">1; m_Marks.bHigh = (rate.open == rate.high) || (rate.close == rate.high); m_Marks.bLow = (rate.open == rate.low) || (rate.close == rate.low); Simulation_Time(rate, tick); if (m_IsPriceBID) Simulation_BID(rate, tick); else Simulation_LAST(rate, tick); else class="kw">return -class="num">1; CorretTime(tick); class="kw">return m_Marks.iMax; } class="kw">inline class="type">void Simulation_LAST(const class="type">MqlRates &rate, class="type">MqlTick &tick[]) { if (CheckViability_LAST(rate)) { }else { } DistributeVolumeReal(rate, tick); } class="kw">inline class="type">bool CheckViability_LAST(const class="type">MqlRates &rate) { class="macro">#define macro_AdjustSafetyFator(A) (A + (A * class="num">1.4)); class="type">class="kw">double v0, v1, v2;
把单根K线拆成可回放的真实tick流
上面这段逻辑干的事,是把一根 MqlRates 柱面还原成若干笔 MqlTick,供 EA 在本地做非实时回放验证。注意它先按 high-low 与开收相对高低各算一个安全因子累加进 v0,再用 (int)(v0 / m_TickSize) < rate.tick_volume 判断该柱的 tick 数是否够密,外汇与贵金属价差跳变频繁,tick_volume 偏小时这套模拟会直接失真。
DistributeVolumeReal 先把每个标记位 volume_real 置 1.0,再把 rate.real_volume - rate.tick_volume 的余数随机撒到各 tick 上,等于用真实成交量兜底补足模拟笔数。Simulation_BID 里对中间 tick 的价格用 RandomLimit(rate.high, rate.low) 在区间内随机落点,spread 还叠了 RandomLimit((int)(rate.spread | (m_Marks.iMax & 0xF)), 0) 的抖动,若 bHigh/bLow 没被命中就强制把高低价挂到 Unique 返回的空位上。
开 MT5 把这段抄进自定义回放类,把 m_IsPriceBID 切到 false 走 last 价分支,能直接看到模拟 tick 的 bid/ask 或 last 分布。贵金属隔夜跳空时 real_volume 与 tick_volume 差值可能到上千笔,随机撒点会显著改变微观成交密度,结论只是概率倾向而非必然。
v0 = macro_AdjustSafetyFator(rate.high - rate.low); v1 = (rate.open - rate.low); v2 = (rate.high - rate.open); v0 += macro_AdjustSafetyFator(v1 > v2 ? v1 : v2); v1 = (rate.close - rate.low); v2 = (rate.high - rate.close); v0 += macro_AdjustSafetyFator(v1 > v2 ? v1 : v2); class="kw">return ((class="type">int)(v0 / m_TickSize) < rate.tick_volume); class="macro">#undef macro_AdjustSafetyFator } class="kw">inline class="type">void DistributeVolumeReal(const class="type">MqlRates &rate, class="type">MqlTick &tick[]) { for (class="type">int c0 = class="num">0; c0 <= m_Marks.iMax; c0++) tick[c0].volume_real = class="num">1.0; for (class="type">int c0 = (class="type">int)(rate.real_volume - rate.tick_volume); c0 > class="num">0; c0--) tick[RandomLimit(class="num">0, m_Marks.iMax)].volume_real += class="num">1.0; } class="kw">inline class="type">void MountPrice(const class="type">int iPos, const class="type">class="kw">double price, const class="type">int spread, class="type">MqlTick &tick[]) { if (m_IsPriceBID) { tick[iPos].bid = price; tick[iPos].ask = NormalizeDouble(price + (m_TickSize * spread), m_NDigits); }else tick[iPos].last = NormalizeDouble(price, m_NDigits); } class="kw">inline class="type">void Simulation_BID(const class="type">MqlRates &rate, class="type">MqlTick &tick[]) { MountPrice(class="num">0, rate.open, rate.spread, tick); for (class="type">int c0 = class="num">1; c0 < m_Marks.iMax; c0++) { MountPrice(c0, NormalizeDouble(RandomLimit(rate.high, rate.low), m_NDigits), (rate.spread + RandomLimit((class="type">int)(rate.spread | (m_Marks.iMax & 0xF)), class="num">0)), tick); m_Marks.bHigh = (rate.high == tick[c0].bid) || m_Marks.bHigh; m_Marks.bLow = (rate.low == tick[c0].bid) || m_Marks.bLow; } if (!m_Marks.bLow) MountPrice(Unique(rate.high, tick), rate.low, rate.spread, tick); if (!m_Marks.bHigh) MountPrice(Unique(rate.low, tick), rate.high, rate.spread, tick); MountPrice(m_Marks.iMax, rate.close, rate.spread, tick); } class="kw">inline class="type">int Simulation(const class="type">MqlRates &rate, class="type">MqlTick &tick[]) {