开发回放系统  市场模拟(第 15 部分):模拟器的诞生(V)- 随机游走·进阶篇
🎲

开发回放系统 市场模拟(第 15 部分):模拟器的诞生(V)- 随机游走·进阶篇

(2/3)· 从之字折线到监督随机游走,解决模拟器无法覆盖所有价位的老毛病

含代码示例实战向 第 2/3 篇
很多人以为策略测试器的之字折线能直接搬进回放系统,结果跳价数量永远不够、复杂度也差一截。更隐蔽的坑是:普通随机游走偶尔能神奇覆盖全点位,但你不能指望机遇。本篇要的是在允许范围内强制触达开盘、最高、最低、收盘每一处。

用随机游走重建 tick 序列的步进逻辑

在合成历史 tick 时,先按目标价格区间和每 tick 点数算出步长:vStep = (pOut - pIn) / ((rate.high - rate.low) / m_PointsPerTick),这一步决定了整段模拟的价格跨度与粒度。 循环里每个 tick 的 last 价在前一 tick 基础上加减一个 m_PointsPerTick,方向由 rand() 的末位决定,也就是 50% 概率向上、50% 概率向下。若算出的 price 超出本次 Bar 的 high/low,就反向扣回一个点,把价格夹在 [vLow, vHigh] 内。 iMode 分支目前只处理模式 0:当 price 恰好等于 rate.close 时直接返回当前索引 c0,相当于在随机路径中锚定收官价;模式 1、2 暂时 break 空跑。 下面这段是核心循环骨架,注意 rand()&1 的写法在 MT5 里依赖标准库随机种子,实盘前最好用 srand() 固定种子做可复现验证。

MQL5 / C++
vNext = vStep = (pOut - pIn) / ((rate.high - rate.low) / m_PointsPerTick);
vHigh = rate.high;
vLow = rate.low;
for (class="type">long c0 = pIn, c1 = class="num">0, c2 = class="num">0; c0 < pOut; c0++, c1++)
{
   price = tick[c0 - class="num">1].last + (m_PointsPerTick * ((rand() & class="num">1) == class="num">1 ? -class="num">1 : class="num">1));
   price = tick[c0].last = (price > vHigh ? price - m_PointsPerTick : (price < vLow ? price + m_PointsPerTick : price));
   class="kw">switch (iMode)
   {
      case class="num">0:
         if (price == rate.close)
            class="kw">return c0;
      case class="num">1:
      case class="num">2:
         break;
   }
   if ((class="type">int)floor(vNext) < c1)
   {

「用蒙特卡洛思路伪造逐笔tick流」

上面这段内联函数 Simulation 干的事,是在已知某根 MqlRates 柱的收盘与成交量前提下,反向造出一条可能的逐笔 tick 序列。外汇与贵金属市场实盘 tick 不可回放,这种仿真对回测 EA 撮合逻辑有参考价值,但本质是概率近似,不是真实行情重建。 宏 macroRandomLimits 把 rand() 的 0~32767 映射到区间 [A,B] 内,用来随机定每个 tick 的偏移边界;b1 用 rand()&1 抛硬币决定本柱价格游走的主方向。v0 初值 4.0、v1 按 (60000-v0)/(max+1) 线性摊薄,意味着若某柱 tick_volume 为 100,则单 tick 时间跨度约 599 毫秒——开 MT5 把 max 打出来就能核对这个量级。 循环里 c2 计数前 3 个 tick 直接 continue 跳过,vNext 按 vStep 推进;若 rate.close 高于 vLow 就把下轨往上推一个 m_PointsPerTick,否则上轨 vHigh 往下缩。这种边界伸缩让仿真价格始终围绕收盘价纠缠,回测滑点表现会偏温和,实盘贵金属跳空时可能完全失效。

MQL5 / C++
if ((++c2) <= class="num">3) class="kw">continue;
    vNext += vStep;
    if (rate.close > vLow) vLow += m_PointsPerTick); else vHigh -= m_PointsPerTick;
  }
}

class="kw">return pOut;
}
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;
    v1 = (class="num">60000 - v0) / (max + class="num">1.0);
    for (class="type">int c0 = class="num">0; c0 <= max; c0++, v0 += v1)

◍ 用随机游走把K线拆成逐笔tick

这段逻辑解决一个很实际的问题:MT5 回测里只有 OHLC 的分钟棒,没有真实逐笔成交。下面这段代码把一根 bar 的 real_volume 与 tick_volume 的差值,随机撒到已有 tick 上,再补一段游走路径。 [CODE] { tick[c0].last = 0; tick[c0].flags = 0; il0 = (long)v0; tick[c0].time = rate.time + (datetime)(il0 / 1000); tick[c0].time_msc = il0 % 1000; tick[c0].volume_real = 1.0; } tick[0].last = rate.open; tick[max].last = rate.close; for (int c0 = (int)(rate.real_volume - rate.tick_volume); c0 > 0; c0--) tick[macroRandomLimits(0, max)].volume_real += 1.0; i0 = (long)(MathMin(max / 3.0, max * 0.2)); i1 = max - i0; rLocal = rate; rLocal.open = rate.open; rLocal.close = (b1 ? rate.high : rate.low); i0 = RandomWalk(1, i0, rLocal, tick, 0); rLocal.open = tick[i0].last; rLocal.close = (b1 ? rate.low : rate.high); RandomWalk(i0, i1, rLocal, tick, 1); [/CODE] 逐行看:开头大括号里把某下标 c0 的 tick 清零 last 和 flags,再把 v0 转成毫秒数 il0,time 取 bar 时间加 il0/1000 秒,time_msc 取余数做亚秒精度,volume_real 给 1.0 作为底仓。 tick[0] 和 tick[max] 分别钉死在 bar 的开盘与收盘价,保证重组后首尾不漂。循环次数 = real_volume - tick_volume,每圈给随机位置的 tick 加 1.0 真实成交量,补齐经纪商没给的细分单。 i0 取 max/3.0 与 max*0.2 的较小值,也就是前 20%~33% 的 tick 区间做第一段 RandomWalk;b1 为真就先冲 high 再回 low,否则反向。第二段从 i0 走到 i1 收口到另一极值。外汇与贵金属价差跳变频繁,这种合成 tick 仅用于本地回测验证,实盘高风险,盲信模拟成交会亏。

MQL5 / C++
{
   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 = 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);
   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);

随机游走填充K线内部的tick流

这段逻辑干的事,是把一根已知开高低收的 MqlRates 棒线,用 RandomWalk 在内部插值出一串 MqlTick,方便回测引擎按 tick 粒度重放。注意 i0 的取值被压在 max/3.0 与 max*0.2 的较小值,意味着前段随机游走长度最多只占整根棒线的 1/3,实际常落在 20% 附近,后段 i1 = max - i0 吃掉剩余部分。 先以 rate.open 为起点、根据方向 b1 选 high 或 low 作阶段终点,走完第一段 RandomWalk(1, i0, ...) 拿到 tick[i0].last;再以该 last 为 open、反向取 low 或 high 为 close 走中段;最后用 tick[i1].last 对接 rate.close 收尾。三段 mode 参数分别为 0、1、2,可能对应不同的波动幅度权重。 循环里 ArrayResize(m_Ticks.Info, m_Ticks.nTicks+1, def_MaxSizeArray) 每塞一个 tick 就扩一次容,nTicks 自增。外汇与贵金属 tick 重放本身不改变真实风险,插值仅用于本地验证,实盘高价差品种偏差可能放大。 下面这段是上述调用的核心函数签名与收尾宏,直接贴出来可对照参数顺序在 MT5 里单步跟踪。

MQL5 / C++
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
 }
 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);
 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);
class="kw">inline class="type">long RandomWalk(class="type">long pIn, class="type">long pOut, const class="type">MqlRates &rate, class="type">MqlTick &tick[], class="type">int iMode)
 {

「蒙特卡洛 tick 回填的循环核心」

这段逻辑干的事,是把已知 K 线的高低收数据,往回塞进一串随机游走的 tick 里。先算每跳步长 vStep:用收盘价差除以(高低区间 / 每跳点数),把价格跨度摊到每一个 tick 位移上,vHigh、vLow 则锁住边界。 循环里 price 的生成很直白:上一 tick 末价加减一个最小跳动点,方向由 rand()&1 抛硬币决定,50% 概率上下。随后立刻做边界夹取——超出 vHigh 就减回一点,跌破 vLow 就加回一点,保证合成 tick 不越出母 K 线范围。 iMode 切换决定退出条件。模式 0 下,只要合成 price 命中 rate.close 就直接 return 当前索引 c0,相当于找到了一条能收在真实收盘价的随机路径;模式 1 则用位或 i0 记录是否触过 rate.high(0x01 标记)。外汇与贵金属合成数据仅用于回测验证,实盘高风险,随机路径不代表真实成交分布。 把下面代码直接贴进 MT5 的 EA 或脚本里,改 iMode 跑两次,能直观看到命中收盘 vs 记录最高价两种终止行为的差异。

MQL5 / C++
class="type">class="kw">double vStep, vNext, price, vHigh, vLow;
class="type">char  i0 = class="num">0;

vNext = vStep = (pOut - pIn) / ((rate.high - rate.low) / m_PointsPerTick);
vHigh = rate.high;
vLow = rate.low;
for (class="type">long c0 = pIn, c1 = class="num">0, c2 = class="num">0; c0 < pOut; c0++, c1++)
{
   price = tick[c0 - class="num">1].last + (m_PointsPerTick * ((rand() & class="num">1) == class="num">1 ? -class="num">1 : class="num">1));
   price = tick[c0].last = (price > vHigh ? price - m_PointsPerTick : (price < vLow ? price + m_PointsPerTick : price));
   class="kw">switch (iMode)
   {
      case class="num">0:
         if (price == rate.close)
            class="kw">return c0;
         break;
      case class="num">1:
         i0 |= (price == rate.high ? 0x01 : class="num">0);

◍ 边界判定后的游走收口

上面这段是随机游走函数尾部的收束逻辑。当 i0 置为 3(即当前棒同时触达高低边界)时,vHigh 与 vLow 直接锁定为 rate.high / rate.low,随后 break 跳出 switch,不再参与后续偏移。 若向下取整后的 vNext 小于阈值 c1,计数器 c2 自增,最多容忍 3 次(++c2 <= 3 时 continue)才推进 vStep。这一步决定价格在边界附近「黏着」的容忍度,调大 3 这个常数,回测里横盘段的假突破信号会明显减少。 最后按收盘相对 vLow 的位置做微调:收在 vLow 上方则下轨上提一个 tick 点,否则上轨下放一个 tick 点,i0==3 时不改。整段跑完 return pOut,外汇与贵金属波动剧烈,这种边界重算仅降低噪声,不预示方向。

MQL5 / C++
i0 |= (price == rate.low ? 0x02 : class="num">0);
         vHigh = (i0 == class="num">3 ? rate.high : vHigh);
         vLow = (i0 == class="num">3 ? rate.low : vLow);
         break;
      case class="num">2:
         break;
            }
            if ((class="type">int)floor(vNext) < c1)
            {
               if ((++c2) <= class="num">3) class="kw">continue;
               vNext += vStep;
               if (rate.close > vLow) vLow = (i0 == class="num">3 ? vLow : vLow + m_PointsPerTick); else vHigh = (i0 == class="num">3 ? vHigh : vHigh - m_PointsPerTick);
            }
         }
         class="kw">return pOut;
      }
class="kw">inline class="type">long RandomWalk(class="type">long pIn, class="type">long pOut, const class="type">MqlRates &rate, class="type">MqlTick &tick[], class="type">int iMode)
      {
把点位覆盖检查交给小布
这些诊断小布盯盘的 AIGC 已内置,打开对应品种页即可看到模拟走势是否完整扫过柱线四个关键价,你只管调参数。

常见问题

它生成的跳价数量明显少于实际,且只是真实走势的简单改编,复杂度不足,难以覆盖柱线全部点位。
可以,小布盯盘的品种页内置了点位覆盖诊断,能直接标出哪些柱线没触达最高或最低价,省去人工核对。
普通随机游走靠概率偶然扫全点位,监督式则通过约束机制强制在允许范围内触达所有确定点位,更稳定可控。
数据库会固定衡量度必须遵循,而原系统不能完全覆盖所有情况须覆盖的范围,极稀有才全触界,不适合模拟器调用。