开发回放系统 市场模拟(第 22 部分):外汇(III)·进阶篇
(2/3)·关掉模拟柱线再拖动图表,指标全乱?出价基准市场的定位坑在这篇填平
回放引擎里的一分钟K线拼接逻辑
这段内联函数负责把逐笔 tick 流按分钟重建成 MqlRates 结构,是离线回放和自定义品种渲染的底层环节。 MemNRates = (m_Ticks.nRate < 0 ? 0 : m_Ticks.nRate); MemNTicks = m_Ticks.nTicks; 这一组先把已有的 rate 与 tick 计数暂存到内存变量,避免后面重置指针时把已处理数据弄丢。 若 ToReplay 为 false,走实时补推分支:ArrayResize 按 (m_Ticks.nRate - MemNRates) 扩出本地 rate 数组,ArrayCopy 把增量拷进去,再 CustomRatesUpdate 推给 def_SymbolReplay。这里 m_Ticks.nRate 差值就是本次新增的 bar 数量,可直接作为数据完整性校验点。 BuiderBar1Min(MemNTicks) 从首根未处理 tick 起循环,PRICE_EXCHANGE 模式取 last 价(为 0 则跳过),PRICE_FOREX 模式取 bid(>0 才用)。macroRemoveSec 把秒以下截掉,时间一变就开新 bar,因此同一分钟的 tick 会被聚成一根 1 分钟 K。 外汇与贵金属自定义回放涉及高杠杆与滑点风险,实盘前应在 MT5 策略测试器用历史 tick 验证聚合结果是否与经纪商真实分笔一致。
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; BuiderBar1Min(MemNTicks); if (!ToReplay) { ArrayResize(RatesLocal, (m_Ticks.nRate - MemNRates)); ArrayCopy(RatesLocal, m_Ticks.Rate, class="num">0, class="num">0); 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); }else { CustomSymbolSetInteger(def_SymbolReplay, SYMBOL_TRADE_CALC_MODE, m_Ticks.ModePlot == PRICE_EXCHANGE ? SYMBOL_CALC_MODE_EXCH_STOCKS : SYMBOL_CALC_MODE_FOREX); CustomSymbolSetInteger(def_SymbolReplay, SYMBOL_CHART_MODE, m_Ticks.ModePlot == PRICE_EXCHANGE ? SYMBOL_CHART_MODE_LAST : SYMBOL_CHART_MODE_BID); } m_Ticks.bTickReal = true; class="kw">return dtRet; }; class="kw">inline class="type">void BuiderBar1Min(class="kw">const class="type">int iFirst) { class="type">MqlRates rate; class="type">class="kw">double dClose = class="num">0; rate.time = class="num">0; for (class="type">int c0 = iFirst; c0 < m_Ticks.nTicks; c0++) { class="kw">switch (m_Ticks.ModePlot) { case PRICE_EXCHANGE: if (m_Ticks.Info[c0].last == class="num">0.0) class="kw">continue; dClose = m_Ticks.Info[c0].last; break; case PRICE_FOREX: dClose = (m_Ticks.Info[c0].bid > class="num">0.0 ? m_Ticks.Info[c0].bid : dClose); if (dClose == class="num">0.0) class="kw">continue; break; } if (rate.time != macroRemoveSec(m_Ticks.Info[c0].time)) { ArrayResize(m_Ticks.Rate, (m_Ticks.nRate > class="num">0 ? m_Ticks.nRate + class="num">2 : def_BarsDiary), def_BarsDiary);
「用 tick 流拼出 1 分钟 bar 的两种价源分支」
这段构建逻辑直接吃 tick 数组,按秒级时间戳归桶成 1 分钟 K 线。外汇场景下走 PRICE_FOREX 分支,只用 bid 字段当收盘价,且当 bid 为 0 时直接 continue 跳过该 tick,避免脏点污染 bar。 新分钟开始时 bNew 置真,先 ArrayResize 把 Rate 容器扩出余量(已有 nRate 时加 2,否则用 def_BarsDiary 预设),再把 open/low/high/close 全钉在同一 dClose 上;外汇分支里 tick_volume 初始化为 1,而 exchange 分支给 0,这是两类市场成交量语义差异的直接体现。 同一分钟内后续 tick 进来走 else:close 刷新、high/low 做条件比较扩展,real_volume 累加 volume_real 强转 long,tick_volume 自增。最后一行 m_Ticks.Rate[(m_Ticks.nRate += (rate.tick_volume == 0 ? 1 : 0))] = rate 只在 tick_volume 为 0 时推进索引,说明空卷 bar 也会被记一笔——回测时若发现某些分钟 bar 成交量为 0,属代码刻意保留而非数据缺失。
rate.time = macroRemoveSec(m_Ticks.Info[c0].time); rate.real_volume = class="num">0; rate.tick_volume = class="num">0; rate.open = rate.low = rate.high = rate.close = dClose; }else { rate.close = dClose; 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) m_Ticks.Info[c0].volume_real; rate.tick_volume++; } m_Ticks.Rate[(m_Ticks.nRate += (rate.tick_volume == class="num">0 ? class="num">1 : class="num">0))] = rate; } } class="kw">inline class="type">void BuiderBar1Min(class="kw">const class="type">int iFirst) { class="type">MqlRates rate; class="type">class="kw">double dClose = class="num">0; class="type">bool bNew; rate.time = class="num">0; for (class="type">int c0 = iFirst; c0 < m_Ticks.nTicks; c0++) { class="kw">switch (m_Ticks.ModePlot) { case PRICE_EXCHANGE: if (m_Ticks.Info[c0].last == class="num">0.0) class="kw">continue; dClose = m_Ticks.Info[c0].last; break; case PRICE_FOREX: dClose = (m_Ticks.Info[c0].bid > class="num">0.0 ? m_Ticks.Info[c0].bid : dClose); if ((dClose == class="num">0.0) || (m_Ticks.Info[c0].bid == class="num">0.0)) class="kw">continue; break; } if (bNew = (rate.time != macroRemoveSec(m_Ticks.Info[c0].time))) { ArrayResize(m_Ticks.Rate, (m_Ticks.nRate > class="num">0 ? m_Ticks.nRate + class="num">2 : def_BarsDiary), def_BarsDiary); rate.time = macroRemoveSec(m_Ticks.Info[c0].time); rate.real_volume = class="num">0; rate.tick_volume = (m_Ticks.ModePlot == PRICE_FOREX ? class="num">1 : class="num">0); rate.open = rate.low = rate.high = rate.close = dClose; }else { rate.close = dClose;
◍ 逐笔刷新 K 线极值与成交量
这段逻辑出现在实时接收 tick 的回调里,核心是把每一笔成交归并进当前柱。高位与低位不是开盘就锁死,而是随 close 推移动态修正。 rate.high = (rate.close > rate.high ? rate.close : rate.high); 意思是若最新成交价高于已记录最高价,就把最高价更新为成交价,否则维持原值。rate.low 同理用小于号兜底最低价。 real_volume 用 (long) 强制转换 volume_real 后累加,tick_volume 则直接自增 1,两者分别反映真实手数与 tick 计数。若 bNew 为真,nRate 下标加 1 并写入新柱;否则原地覆盖当前柱。 在 MT5 里接一版 CTrade 的 OnTick 打印 rate.high / rate.low,你能看到亚盘一根 M15 的上下影线可能比收盘偏移 3~5 个点,外汇与贵金属杠杆高,这种重算对止损位有实际干扰。
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) m_Ticks.Info[c0].volume_real; rate.tick_volume++; } m_Ticks.Rate[(m_Ticks.nRate += (bNew ? class="num">1 : class="num">0))] = rate; } }
出价资产下跳价交易量归零的坑
做外汇回放或模拟时,若绘图取的是出价(bid)而非最后成交价(last),系统报告的跳价交易量会一直为 0。这不是代码逻辑错,而是外汇里根本不存在基于出价的那种成交量类型,跳价数据本身就不带交易量字段。 原计算在基于 last 的资产上没问题,但碰到 bid 显示就输出错值。修正方式是在函数里补一行赋值,并加条件判断,避免快速定位系统误用导致图表冒出怪异柱线。改完之后,无论 bid 类外汇品种还是 last 类金融产品,跳价计数方式统一。 一个反直觉点:修正后仍需在柱线收盘后向自定义资产再发一次兑换值,且仅 bid 类型必须这么做,原因暂未明。另外,原先的衡量系统已被删掉——因为把跳价加进市场观察窗口后,靠跳价就能估算每根柱线生成耗时,衡量代码纯属多余。 外汇与贵金属保证金交易杠杆高、滑点跳空频繁,回测交易量失真可能令策略评估偏乐观,实盘前务必在 MT5 用真实符号跑一遍校验。
class="kw">inline class="type">void CreateBarInReplay(class="kw">const class="type">bool bViewMetrics, class="kw">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]; class="kw">static class="type">class="kw">double PointsPerTick = class="num">0.0; if (bNew = (m_MountBar.memDT != macroRemoveSec(m_Ticks.Info[m_ReplayCount].time))) { PointsPerTick = (PointsPerTick == class="num">0.0 ? SymbolInfoDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_SIZE) : PointsPerTick); 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; def_Rate.tick_volume = class="num">0; } def_Rate.close = (m_Ticks.ModePlot == PRICE_EXCHANGE ? (m_Ticks.Info[m_ReplayCount].volume_real > class="num">0.0 ? m_Ticks.Info[m_ReplayCount].last : def_Rate.close) : (m_Ticks.Info[m_ReplayCount].bid > class="num">0.0 ? m_Ticks.Info[m_ReplayCount].bid : 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) { class="kw">static class="type">class="kw">double BID, ASK; class="type">class="kw">double dSpread; class="type">int iRand = rand(); dSpread = PointsPerTick + ((iRand > class="num">29080) && (iRand < class="num">32767) ? ((iRand & class="num">1) == class="num">1 ? PointsPerTick : class="num">0 ) : class="num">0 ); if (tick[class="num">0].last > ASK) {
「回放里重画 K 线的收口逻辑」
把逐笔 tick 塞进自定义品种后,还要把同一秒内的报价合并成一根 bar,否则 MT5 里看到的只是散点。代码里用 macroRemoveSec 把秒去掉,只要分钟级时间变了就判定为新 bar,m_MountBar.memDT 存的就是这个去秒后的时间戳。 新 bar 触发时先取一次 SYMBOL_TRADE_TICK_SIZE 作为 PointsPerTick,之后用 static 变量缓存不再反复查。外汇模式走 CustomRatesUpdate 推前一帧,交易所模式则直接拿 volume_real 大于 0 的 last 价当收盘价;外汇没 bid 就沿用上一根 close,这是回放里常见的脏数据兜底。 每来一笔 tick,high/low 只在创新高或新 bar 时刷新,open 仅新 bar 才赋值为首价。tick_volume 在外汇下只要 bid>0 就 +1,交易所下看 volume_real>0 才 +1,real_volume 则直接累加。最后再调一次 CustomRatesUpdate 把这根 bar 落进图表。 bViewTicks 为真时会把当前 tick 原样取出准备画明细,配合前面的 CustomTicksAdd 就能在回放品种上既看 K 线也看逐笔。外汇和贵金属回放涉及杠杆与滑点,实盘映射和回测结果可能有偏差,只建议用作策略验证而非收益依据。
ASK = tick[class="num">0].ask = tick[class="num">0].last; BID = tick[class="num">0].bid = tick[class="num">0].last - dSpread; if (tick[class="num">0].last < BID) { 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); m_ReplayCount++; class="macro">#undef def_Rate } class="kw">inline class="type">void CreateBarInReplay(class="kw">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]; class="kw">static class="type">class="kw">double PointsPerTick = class="num">0.0; if (bNew = (m_MountBar.memDT != macroRemoveSec(m_Ticks.Info[m_ReplayCount].time))) { PointsPerTick = (PointsPerTick == class="num">0.0 ? SymbolInfoDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_SIZE) : PointsPerTick); m_MountBar.memDT = (class="type">class="kw">datetime) macroRemoveSec(m_Ticks.Info[m_ReplayCount].time); if (m_Ticks.ModePlot == PRICE_FOREX) CustomRatesUpdate(def_SymbolReplay, m_MountBar.Rate, (def_Rate.time < m_MountBar.memDT ? class="num">1 : class="num">0)); def_Rate.real_volume = class="num">0; def_Rate.tick_volume = class="num">0; } def_Rate.close = (m_Ticks.ModePlot == PRICE_EXCHANGE ? (m_Ticks.Info[m_ReplayCount].volume_real > class="num">0.0 ? m_Ticks.Info[m_ReplayCount].last : def_Rate.close) : (m_Ticks.Info[m_ReplayCount].bid > class="num">0.0 ? m_Ticks.Info[m_ReplayCount].bid : 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.ModePlot == PRICE_FOREX) && (m_Ticks.Info[m_ReplayCount].bid > class="num">0.0) ? class="num">1 : (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];