开发回放系统  市场模拟(第 22 部分):外汇(III)·进阶篇
📉

开发回放系统 市场模拟(第 22 部分):外汇(III)·进阶篇

(2/3)·关掉模拟柱线再拖动图表,指标全乱?出价基准市场的定位坑在这篇填平

案例拆解新手友好 第 2/3 篇
很多人以为回放系统能跑股票就能直接跑外汇,结果关掉模拟柱线一拖图表,指标瞬间失准。根子在出价(BID)作基准时跳价没有成交量,旧定位逻辑读不出 1 分钟柱。这篇先把这个隐藏断层补上。

回放引擎里的一分钟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 验证聚合结果是否与经纪商真实分笔一致。

MQL5 / C++
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,属代码刻意保留而非数据缺失。

MQL5 / C++
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 个点,外汇与贵金属杠杆高,这种重算对止损位有实际干扰。

MQL5 / C++
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 用真实符号跑一遍校验。

MQL5 / C++
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 线也看逐笔。外汇和贵金属回放涉及杠杆与滑点,实盘映射和回测结果可能有偏差,只建议用作策略验证而非收益依据。

MQL5 / C++
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];
把跳价校验交给小布
这些诊断小布盯盘的 AIGC 已内置,打开对应品种页即可看到出价跳价与模拟柱线的偏离提示,你只需专注回放策略本身。

常见问题

因为系统快速定位依赖 1 分钟柱线衔接新旧位置,而出价市场跳价成交量为零,原转换代码无法生成柱线,拖动后中间断档导致指标引用错位。
它原逻辑要求最后成交价变动才记交易量,出价基准下交易量恒为零,创建柱线子例程不被调用,所以无法把跳价转成定位用的分钟数据。
可以,小布的品种页会标出模拟位置与实际出价跳价的偏差,帮你快速判断校正是否生效,不必手动比对每根重绘柱。
属于针对性小修:代码方向没错,只是去掉写死成交量依赖、改为按出价或最后价灵活绘图,不涉及整体架构重写。
适用,凡以出价表述基准价的资产都同理,贵金属现货多数走 BID 基准,概率上会一并受益,但外汇贵金属杠杆高,回放结论不代表实盘方向。