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

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

第 2/3 篇

「回放Tick里的价差扰动逻辑」

在 MT5 自绘回放品种时,若行情源不是真实 tick 且处于 PRICE_EXCHANGE 模式,点差不能写死,否则和实盘撮合对不上。上面这段代码用 rand() 给点差加了随机微扰,核心区间卡在 29080 到 32767 之间——大约占 rand() 全量程 32767 的 11%,意味着每 9 个左右的合成 tick 才可能触发一次额外跳动。 具体看 dSpread 的算法:基础点差恒为 PointsPerTick,仅当 iRand 落在 (29080, 32767) 内,才按 iRand 最低位奇偶再叠加一个 PointsPerTick 或归零。也就是说极端情况下点差会翻倍,但概率偏低,整体仍贴近常态点差。 last 与 ask/bid 的校正也值得注意:若合成 last 穿透了原 ask 或 bid,就以其为基准向外加减 dSpread 重算双边报价。这样回放出来的盘口不会出 last 夹在买卖价中间的荒谬形态。外汇与贵金属品种点差随流动性跳变,实盘高风险,这段代码只解决回放可视化自洽,不预示任何方向。 把这段直接塞进你的 Replay 引擎 tick 构造函数里,开 MT5 用 CustomTicksAdd 灌数据,切到回放品种看盘口跳动频率,就能验证那 11% 的扰动是否够自然。

MQL5 / C++
  class="type">int    iRand = rand();

  if (BuildBar1Min(m_ReplayCount, def_Rate, bNew))
  {
     m_Infos.tick[class="num">0] = m_Ticks.Info[m_ReplayCount];
     if ((!m_Ticks.bTickReal) && (m_Ticks.ModePlot == PRICE_EXCHANGE))
     {
        dSpread = m_Infos.PointsPerTick + ((iRand > class="num">29080) && (iRand < class="num">32767) ? ((iRand & class="num">1) == class="num">1 ? m_Infos.PointsPerTick : class="num">0 ) : class="num">0 );
        if (m_Infos.tick[class="num">0].last > m_Infos.tick[class="num">0].ask)
        {
           m_Infos.tick[class="num">0].ask = m_Infos.tick[class="num">0].last;
           m_Infos.tick[class="num">0].bid = m_Infos.tick[class="num">0].last - dSpread;
        }else   if (m_Infos.tick[class="num">0].last < m_Infos.tick[class="num">0].bid)
        {
           m_Infos.tick[class="num">0].ask = m_Infos.tick[class="num">0].last + dSpread;
           m_Infos.tick[class="num">0].bid = m_Infos.tick[class="num">0].last;
        }
     }
     if (bViewTicks) CustomTicksAdd(def_SymbolReplay, m_Infos.tick);
     CustomRatesUpdate(def_SymbolReplay, m_MountBar.Rate);
  }
  m_ReplayCount++;
class="macro">#undef def_Rate

出价模拟里的类重构与跳价生成逻辑

做 C_Simulation 类之前先补了一道失败通知机制:原先的函数能用但重复代码多,后来改成单一入口出口、用指针持有类实例,类干完活内存直接释放。顺带剔除了继承,资产数据初始化不再只绑真实跳价,图形里品种宽度也独立出来。 外汇模式下最麻烦的是:1 分钟柱线的高低价差范围,往往远大于实际能生成的跳价数。随机游走覆盖不了「开盘到极值」「极值到收盘」这两段,尤其低流动性(每分钟 <500 笔)时完全失效。所以旧随机游走扔掉,构造函数只做私密数据初始化,配置文件的绘图类型填错就会出怪错。 我们重载了一个双类型随机值过程:用字母 T 告诉编译器按调用类型(double 或 int)自动选函数,边界两边类型必须一致,不能 double 混 int。这样伪随机生成只写一遍,省得宏和重复代码。 时间模拟把 60000 毫秒除以跳价数当步长,循环里用整数重载取随机偏移,再拟合到分钟时间和毫秒时间——早期只为去可预测性,毫秒逻辑还没对齐 C_Replay。价格端先死压恒定点差,Mount_Bid 里做要价常规化,UNIQUE 防随机值覆盖高低极值。 点差随机化没放进 Mount_Bid,而是改 Simulation_Bid:用位运算把点差和 1~16 组合,值 1 是开=收、值 2 是开可异于收,≥3 才真随机;柱线文件报的最小点差因此不被破坏。最后成交价模式暂时抛错阻断,低流动性改造留后。 开 MT5 把下面代码里的 def_MaxSizeArray、def_BarsDiary 换成你实际常量,跑一根 1 分钟 EURUSD 柱,看 local[] 是否被撑爆就能验证数组预留是否合理。

MQL5 / C++
class="type">bool BarsToTicks(class="kw">const class="type">class="kw">string szFileNameCSV)
  {
    C_FileBars *pFileBars;
    class="type">int        iMem = m_Ticks.nTicks,
               iRet;
    class="type">MqlRates   rate[class="num">1];
    class="type">MqlTick    local[];
              
    pFileBars = new C_FileBars(szFileNameCSV);
    ArrayResize(local, def_MaxSizeArray);
    Print("Converting bars to ticks. Please wait...");
    class="kw">while ((*pFileBars).ReadBar(rate) && (!_StopFlag))
    {
       ArrayResize(m_Ticks.Rate, (m_Ticks.nRate > class="num">0 ? m_Ticks.nRate + class="num">3 : def_BarsDiary), def_BarsDiary);

◍ K线转逐笔报价的落地细节

把 MT5 的分钟级 K 线重建成逐笔成交(tick)流,核心在 Simulation() 的回放逻辑:每读一根 rate[0],先压进 m_Ticks.Rate 环形缓冲,再交给模拟器展开成若干 local tick,最后用 ArrayResize 把 m_Ticks.Info 撑大后逐条写入。 代码里有个容易踩的坑:Simulation 返回负值会直接释放 local 并 delete pFileBars 后 return false,意味着整段转换中断,不会补任何已读未写的 tick。实盘回测前建议先在脚本里把 iRet<0 的分支打印出具体错误码,否则只看到「转换失败」很难定位。 SetSymbolInfos() 则按品种模式切属性:PRICE_EXCHANGE 给 4 位小数、股票交易所计算模式、LAST 图模式;否则 5 位小数、SYMBOL_CALC_MODE_FOREX、BID 图模式。外汇与贵金属用 5 位小数时,点值计算务必走 SYMBOL_CALC_MODE_FOREX,否则自定义品种的下单手数校验可能偏掉。 BarsToTicks 的退出条件是 (!_StopFlag) && (iMem != m_Ticks.nTicks),即用户没点停止且确实新增了 tick 才算成功。你可以直接把这段挂到 EA 的初始化里,用自己导出的 CSV 跑一遍,观察终端「Converting bars to ticks. Please wait...」后自定义品种下生成的 tick 数量是否符合预期。

MQL5 / C++
m_Ticks.Rate[++m_Ticks.nRate] = rate[class="num">0];
if ((iRet = Simulation(rate[class="num">0], local)) < class="num">0)
{
   ArrayFree(local);
   class="kw">delete pFileBars;
   class="kw">return class="kw">false;
}
for (class="type">int c0 = class="num">0; c0 <= iRet; c0++)
{
   ArrayResize(m_Ticks.Info, (m_Ticks.nTicks + class="num">1), def_MaxSizeArray);
   m_Ticks.Info[m_Ticks.nTicks++] = local[c0];
}
}
ArrayFree(local);
class="kw">delete pFileBars;
m_Ticks.bTickReal = class="kw">false;

class="kw">return ((!_StopFlag) && (iMem != m_Ticks.nTicks));
}
class="type">int SetSymbolInfos(class="type">void)
{
   class="type">int iRet;
   CustomSymbolSetInteger(def_SymbolReplay, SYMBOL_DIGITS, iRet = (m_Ticks.ModePlot == PRICE_EXCHANGE ? class="num">4 : class="num">5));
   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);

   class="kw">return iRet;
}
class=class="str">"cmt">//+------------------------------------------------------------------+
   class="kw">public  :
class=class="str">"cmt">//+------------------------------------------------------------------+
   class="type">bool BarsToTicks(class="kw">const class="type">class="kw">string szFileNameCSV)
   {
      C_FileBars      *pFileBars;
      C_Simulation    *pSimulator = NULL;
      class="type">int             iMem = m_Ticks.nTicks,
                     iRet = -class="num">1;
      class="type">MqlRates        rate[class="num">1];
      class="type">MqlTick         local[];
      class="type">bool            bInit = class="kw">false;

      pFileBars = new C_FileBars(szFileNameCSV);
      ArrayResize(local, def_MaxSizeArray);
      Print("Converting bars to ticks. Please wait...");
      class="kw">while ((*pFileBars).ReadBar(rate) && (!_StopFlag))
      {
         if (!bInit)

「回放引擎里的tick合成与随机散布」

这段逻辑出现在历史回放类的 EA 初始化与逐根 K 线处理里:先按 real_volume 是否为 0 切换报价模式(PRICE_EXCHANGE 或 PRICE_FOREX),再 new 一个 C_Simulation 对象接管后续模拟。 [CODE] { m_Ticks.ModePlot = (rate[0].real_volume > 0 ? PRICE_EXCHANGE : PRICE_FOREX); pSimulator = new C_Simulation(SetSymbolInfos()); bInit = true; } ArrayResize(m_Ticks.Rate, (m_Ticks.nRate > 0 ? m_Ticks.nRate + 3 : def_BarsDiary), def_BarsDiary); m_Ticks.Rate[++m_Ticks.nRate] = rate[0]; if (pSimulator == NULL) iRet = -1; else iRet = (*pSimulator).Simulation(rate[0], local); if (iRet < 0) break; for (int c0 = 0; c0 <= iRet; c0++) { ArrayResize(m_Ticks.Info, (m_Ticks.nTicks + 1), def_MaxSizeArray); m_Ticks.Info[m_Ticks.nTicks++] = local[c0]; } } ArrayFree(local); delete pFileBars; delete pSimulator; m_Ticks.bTickReal = false; return ((!_StopFlag) && (iMem != m_Ticks.nTicks) && (iRet > 0)); } C_Simulation(const int nDigits) { m_NDigits = nDigits; m_IsPriceBID = (SymbolInfoInteger(def_SymbolReplay, SYMBOL_CHART_MODE) == SYMBOL_CHART_MODE_BID); m_TickSize = SymbolInfoDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_SIZE); } template < typename T > inline T RandomLimit(const T Limit01, const T Limit02) { T a = (Limit01 > Limit02 ? Limit01 - Limit02 : Limit02 - Limit01); return (Limit01 >= Limit02 ? Limit02 : Limit01) + ((T)(((rand() & 32767) / 32737.0) * a)); } inline void Simulation_Time(int imax, const MqlRates &rate, MqlTick &tick[]) { for (int c0 = 0, iPos, v0 = (int)(60000 / rate.tick_volume), v1 = 0, v2 = v0; c0 <= imax; c0++, v1 = v2, v2 += v0) { iPos = RandomLimit(v1, v2); tick[c0].time = rate.time + (iPos / 1000); tick[c0].time_msc = iPos % 1000; } } inline void Simulation_BID(int imax, const MqlRates &rate, MqlTick &tick[]) {

bool bHigh = (rate.open == rate.high)(rate.close == rate.high),
bLow = (rate.open == rate.low)(rate.close == rate.low);

[/CODE] 逐行看:花括号内先决定绘图用的价格类型,然后实例化模拟器并置 bInit;ArrayResize 给 Rate 数组留空间,nRate>0 时多留 3 根缓冲,否则用 def_BarsDiary。每根 rate[0] 入列后调 Simulation,返回值 iRet<0 直接 break。 RandomLimit 用 rand()&32767 再除以 32737.0 做归一化,把随机点落在两段区间之差 a 内——这是典型的伪随机散布,不依赖真实tick分布。Simulation_Time 里 v0=(int)(60000/rate.tick_volume),等于把这一分钟按 tick_volume 均分,再给每个合成 tick 打上 time 与 time_msc。 开 MT5 把这段塞进自己的回放脚本,改 32737.0 为更小分母会让散布更集中、滑点模拟偏激进;外汇与贵金属回测本身高风险,合成tick只能近似真实流,不能当成交保证。

MQL5 / C++
{
   m_Ticks.ModePlot = (rate[class="num">0].real_volume > class="num">0 ? PRICE_EXCHANGE : PRICE_FOREX);
   pSimulator = new C_Simulation(SetSymbolInfos());
   bInit = true;
}
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[class="num">0];
if (pSimulator == NULL) iRet = -class="num">1; else iRet = (*pSimulator).Simulation(rate[class="num">0], local);
if (iRet < class="num">0) class="kw">break;
for (class="type">int c0 = class="num">0; c0 <= iRet; c0++)
{
   ArrayResize(m_Ticks.Info, (m_Ticks.nTicks + class="num">1), def_MaxSizeArray);
   m_Ticks.Info[m_Ticks.nTicks++] = local[c0];
}
}
ArrayFree(local);
class="kw">delete pFileBars;
class="kw">delete pSimulator;
m_Ticks.bTickReal = class="kw">false;
									
class="kw">return ((!_StopFlag) && (iMem != m_Ticks.nTicks) && (iRet > class="num">0));
		}
C_Simulation(class="kw">const class="type">int nDigits)
{
   m_NDigits      = nDigits;
   m_IsPriceBID  = (SymbolInfoInteger(def_SymbolReplay, SYMBOL_CHART_MODE) == SYMBOL_CHART_MODE_BID);
   m_TickSize    = SymbolInfoDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_SIZE);
   }
class="kw">template < class="kw">typename T >
class="kw">inline T RandomLimit(class="kw">const T Limit01, class="kw">const T Limit02)
   {
      T a = (Limit01 > Limit02 ? Limit01 - Limit02 : Limit02 - Limit01);
      class="kw">return (Limit01 >= Limit02 ? Limit02 : Limit01) + ((T)(((rand() & class="num">32767) / class="num">32737.0) * a));
   }
class="kw">inline class="type">void Simulation_Time(class="type">int imax, class="kw">const class="type">MqlRates &rate, class="type">MqlTick &tick[])
   {
      for (class="type">int c0 = class="num">0, iPos, v0 = (class="type">int)(class="num">60000 / rate.tick_volume), v1 = class="num">0, v2 = v0; c0 <= imax; c0++, v1 = v2, v2 += v0)
      {
         iPos = RandomLimit(v1, v2);
         tick[c0].time = rate.time + (iPos / class="num">1000);
         tick[c0].time_msc = iPos % class="num">1000;
      }
   }
class="kw">inline class="type">void Simulation_BID(class="type">int imax, class="kw">const class="type">MqlRates &rate, class="type">MqlTick &tick[])
   {
      class="type">bool   bHigh  = (rate.open == rate.high) || (rate.close == rate.high),
            bLow = (rate.open == rate.low) || (rate.close == rate.low);

常见问题

价差扰动是模拟引擎按历史波动特征注入的,属于正常机制;可在回放设置里调低扰动强度来逼近平稳盘口。
重构时保留统一的报价入口方法,跳价生成改为从入口回调,避免直接写死在K线循环里,就能不漏tick。
可以,小布能加载你的回放配置做离线诊断,标出K线转逐笔报价中缺失或重复的tick,省去手动核对。
按成交量比例切分并加随机时间间隔,单根K线拆 20~200 笔不等,可避开规律间隔被识破。
建议散布在合成价的 0~1.5 个最小变动单位内,过大像滑点风暴,过小则失去盘口呼吸感。