开发回放系统  市场模拟(第 17 部分):跳价和更多跳价(I)·进阶篇
📈

开发回放系统 市场模拟(第 17 部分):跳价和更多跳价(I)·进阶篇

(2/3)· 为什么真实跳价远比你以为的复杂,又该如何塞进回放类而不崩

含代码示例实战向 第 2/3 篇
很多人以为跳价图表只是 Ask/Bid 数字闪一下,模拟时随便改个值就行。真动手接进回放系统才会发现,市场观察、深度、订单三处的跳价来源和时序并不统一。本篇接上篇类拆分,继续深挖这块硬骨头。

◍ 回放引擎怎么把tick塞进内存

做历史 tick 回放,第一步是把文件里的逐笔数据读进自定义结构数组。下面这段逻辑用宏把上限和当前元素访问封装起来,避免循环里反复写长下标。 #define def_LIMIT (INT_MAX - 2) 把单文件 tick 数上限卡在 2147483645,防止数组下标溢出;#define def_Ticks m_Ticks.Info[m_Ticks.nTicks] 则是把「当前正在填的那一笔」做成短别名。 循环条件是 !FileIsEnding(m_File) 且 nTicks < def_LIMIT 且没收到 _StopFlag。每读一笔先用 ArrayResize 把 Info 数组扩 1,再用 FileReadString 连读两串拼成 szInfo,前 19 字符转 time、第 20–22 字符转毫秒偏移填进 def_Ticks。 若 nTicks 触到 def_LIMIT,会 Print 报错并 return false,说明该 tick 文件体量已超出回放器设计容量,此时只能换分段时间或降采样,外汇与贵金属 tick 重放本身属高风险验证,结论仅代表程序中止可能。

MQL5 / C++
if (m_Ticks.nTicks == def_LIMIT)
            {
              Print("Too much data in the tick file.\nCannot class="kw">continue...");
              class="kw">return class="kw">false;
            }
            class="kw">return (!_StopFlag);
class="macro">#undef def_LIMIT
          }
class="kw">inline class="type">bool ReadAllsTicks(class="kw">const class="type">bool ToReplay)
          {
class="macro">#define def_LIMIT(INT_MAX - class="num">2)
class="macro">#define def_Ticks m_Ticks.Info[m_Ticks.nTicks]
            class="type">class="kw">string  szInfo;
            class="type">MqlRates rate;
            
            Print("Loading replay ticks. Please wait...");
            ArrayResize(m_Ticks.Info, def_MaxSizeArray, def_MaxSizeArray);
            class="kw">while ((!FileIsEnding(m_File)) && (m_Ticks.nTicks < def_LIMIT) && (!_StopFlag))
            {
              ArrayResize(m_Ticks.Info, m_Ticks.nTicks + class="num">1, def_MaxSizeArray);
              szInfo = FileReadString(m_File) + " " + FileReadString(m_File);
              def_Ticks.time = StringToTime(StringSubstr(szInfo, class="num">0, class="num">19));
              def_Ticks.time_msc = (class="type">int)StringToInteger(StringSubstr(szInfo, class="num">20, class="num">3));

从 tick 文件重建分钟 K 的读取循环

把历史 tick 数据从文件读回内存时,逐行解析是最容易踩坑的一段。下面这段代码演示了如何按字段顺序读取 bid、ask、last、volume_real 与 flags,并在成交量大于 0 时才参与聚合。

MQL5 / C++
def_Ticks.bid = StringToDouble(FileReadString(m_File));      class=class="str">"cmt">// 读一行字符串并转为 class="type">class="kw">double,赋给 bid
def_Ticks.ask = StringToDouble(FileReadString(m_File));      class=class="str">"cmt">// 同上,读 ask 报价
def_Ticks.last = StringToDouble(FileReadString(m_File));     class=class="str">"cmt">// 读 last 成交价
def_Ticks.volume_real = StringToDouble(FileReadString(m_File)); class=class="str">"cmt">// 读真实成交量
def_Ticks.flags = (class="type">uchar)StringToInteger(FileReadString(m_File)); class=class="str">"cmt">// 读整数字符串并强转为 class="type">uchar 标记
if (def_Ticks.volume_real > class="num">0.0)                             class=class="str">"cmt">// 仅当成交量大于 class="num">0 才处理
{
  ArrayResize(m_Ticks.Rate, (m_Ticks.nRate > class="num">0 ? m_Ticks.nRate + class="num">2 : def_BarsDiary), def_BarsDiary); class=class="str">"cmt">// 动态扩容量价数组,步长 class="num">2 或初始 def_BarsDiary
  m_Ticks.nRate += (BuiderBar1Min(rate, def_Ticks) ? class="num">1 : class="num">0); class=class="str">"cmt">// 调分钟柱构建函数,成功则计数 +class="num">1
  m_Ticks.Rate[m_Ticks.nRate] = rate;                        class=class="str">"cmt">// 存入构建好的 rate 结构
}
m_Ticks.nTicks++;                                            class=class="str">"cmt">// 无论成交量如何,tick 总数 +class="num">1
}
FileClose(m_File);                                           class=class="str">"cmt">// 关闭文件句柄
if (m_Ticks.nTicks == def_LIMIT)                             class=class="str">"cmt">// 达到预设 tick 上限
{
  Print("Too much data in the tick file.\nCannot class="kw">continue..."); class=class="str">"cmt">// 打印溢出提示
  class="kw">return class="kw">false;                                             class=class="str">"cmt">// 终止读取返回失败
}
注意 ArrayResize 的第三个参数 def_BarsDiary 是预留增量,若你的 tick 文件单日量很大,把这个值调小会频繁触发重分配,MT5 回测时可能拖慢 10%–30% 的装载速度。 def_LIMIT 是防呆上限,建议你在 EA 输入参数里暴露出来;EURUSD 这类品种一天 tick 数可能破百万,不限制直接读容易让脚本卡死在 FileReadString 循环里。

MQL5 / C++
def_Ticks.bid = StringToDouble(FileReadString(m_File));
def_Ticks.ask = StringToDouble(FileReadString(m_File));
def_Ticks.last = StringToDouble(FileReadString(m_File));
def_Ticks.volume_real = StringToDouble(FileReadString(m_File));
def_Ticks.flags = (class="type">uchar)StringToInteger(FileReadString(m_File));
if (def_Ticks.volume_real > class="num">0.0)
{
  ArrayResize(m_Ticks.Rate, (m_Ticks.nRate > class="num">0 ? m_Ticks.nRate + class="num">2 : def_BarsDiary), def_BarsDiary);
  m_Ticks.nRate += (BuiderBar1Min(rate, def_Ticks) ? class="num">1 : class="num">0);
  m_Ticks.Rate[m_Ticks.nRate] = rate;
}
m_Ticks.nTicks++;
}
FileClose(m_File);
if (m_Ticks.nTicks == def_LIMIT)
{
  Print("Too much data in the tick file.\nCannot class="kw">continue...");
  class="kw">return class="kw">false;
}

「给重放行情补一根零量开盘棒」

在自建回放引擎里,首根历史 tick 之前往往没有可绘制的 K 线,直接推送后续 rate 会让指标在图表最左端出现断点。上面这段从 C_ConfigService 里摘出的 FirstBarNULL,就是专门用来补一根‘空棒’的。 它先声明 MqlRates rate[1],然后顺着 m_Ticks.Info 往后扫,只要某 tick 的 volume_real 等于 0,就把该 tick 的 last 价持续赋给 rate[0].close。循环结束时,open/high/low/close 全部压成同一个价,tick_volume 与 real_volume 都置 0,time 设为首 tick 时间减 60 秒。 最后用 CustomRatesUpdate(def_SymbolReplay, rate) 把这根零量棒写进自定义品种,并把 m_ReplayCount 归零。你在 MT5 里跑重放脚本时,若发现副图指标开头少一根或错位,优先检查这里有没有被调用——外汇与贵金属重放属高仿真但非实盘环境,信号仅作概率参考。

MQL5 / C++
            class="kw">return (!_StopFlag);
class="macro">#undef def_Ticks
class="macro">#undef def_LIMIT
            }
class C_ConfigService : class="kw">protected C_FileTicks
{
      class="kw">protected:
class=class="str">"cmt">//+------------------------------------------------------------------+
            class="type">class="kw">datetime m_dtPrevLoading;
            class="type">int      m_ReplayCount;
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">inline class="type">void FirstBarNULL(class="type">void)
              {
                  class="type">MqlRates rate[class="num">1];

                  for(class="type">int c0 = class="num">0; m_Ticks.Info[c0].volume_real == class="num">0; c0++)
                      rate[class="num">0].close = m_Ticks.Info[c0].last;
                  rate[class="num">0].open = rate[class="num">0].high = rate[class="num">0].low = rate[class="num">0].close;
                  rate[class="num">0].tick_volume = class="num">0;
                  rate[class="num">0].real_volume = class="num">0;
                  rate[class="num">0].time = m_Ticks.Info[class="num">0].time - class="num">60;
                  CustomRatesUpdate(def_SymbolReplay, rate);
                  m_ReplayCount = class="num">0;
              }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//... The rest of the class...
}

◍ 改 C_Replay 时碰到的报价注入坑

改 C_Replay 类最开始只是小修小补,后来却绕进了报价注入的逻辑里。为了不让回放刚开始时资产在画出第一根 1 分钟柱前就骤变,我们被迫加了一段临时代码——禁用它,图表开头就会出现一次明显跳空,这跳空源于后面才会讲到的另一处事实。 关键改动在原始建柱函数里。表面看新函数和旧函数差不多,但系统找收盘价的方式变了:BID/ASK 会干扰数据链,不能像没报价影响时直接取数。新办法是看一次跳价是否带交易量,带量才把它的值当收盘价用。 第二个新调用最怪,核心是 CustomTicksAdd。文档写明:该函数只对接市场观察里已打开的自定义品种;一次传超过 256 个跳价时,前大半直接写库,最后 128 个发往市场观察再由终端存库。MqlTick 里 time 与 time_msc 的处理规则是——time_msc 非零就反推 time,time_msc 为零且 time 非零就乘 1000 补毫秒,都零则用服务器当前毫秒时间填。 很多人建模失败,是因为从文件读真实跳价直接塞进 MqlTick,却没满足「time_msc 必须自含 time 推导值」的隐含条件。上面那段代码就是强制造出第一个条件:填好自零时毫秒值,同时让毫秒字段里带上 time 应有的数值,MT5 才算得对时间。 这么一来 CustomTicksAdd 不光把跳价送进市场观察,顺带让出价、要价、最后价三条线出现在正在拼的图表上——以前没这功能。但系统还没完工,得拿真实跳价数据跑一遍回放来验证这阶段。 [CODE] 里的 AdjustPositionToReplay 干的就是定位回放位置:先读全局变量拿偏移,m_ReplayCount 从 0 起跳过 volume_real 为 0 的跳价;若滑块位置换算后没动就 return;否则按滑块比例从总跳价里算出 iPos,再把对应跳价时间去掉秒级误差写进 Rate[0].time,作为当前柱起点。

MQL5 / C++
                class="type">void AdjustPositionToReplay(class="kw">const class="type">bool bViewBuider)
                    {
                        u_Interprocess Info;
                        class="type">MqlRates      Rate[def_BarsDiary];
                        class="type">int           iPos,   nCount;
                        
                        Info.u_Value.df_Value = GlobalVariableGet(def_GlobalVariableReplay);
                        if (m_ReplayCount == class="num">0)
                            for (; m_Ticks.Info[m_ReplayCount].volume_real == class="num">0; m_ReplayCount++);
                        if (Info.s_Infos.iPosShift == (class="type">int)((m_ReplayCount * def_MaxPosSlider * class="num">1.0) / m_Ticks.nTicks)) class="kw">return;
                        iPos = (class="type">int)(m_Ticks.nTicks * ((Info.s_Infos.iPosShift * class="num">1.0) / (def_MaxPosSlider + class="num">1)));
                        Rate[class="num">0].time = macroRemoveSec(m_Ticks.Info[iPos].time);

回放游标越界时的数据截断与等待

当回放游标 iPos 小于已重放计数 m_ReplayCount,说明发生了倒退或重置。此时先调用 CustomRatesDelete 把重放品种从当前 Rate[0].time 到 LONG_MAX 的自定义行情整段清掉,避免旧柱干扰后续重建。 若首帧且之前无加载记录,直接 FirstBarNULL() 把首根置空;否则把 Rate[0].time 减 60 秒,反向递减 m_ReplayCount,直到时间追上 tick 信息里对应的秒级边界,再补回 1 次计数。 反向越界(iPos 大于 m_ReplayCount)时逻辑相反。若处于图形构建态 bViewBuider,就置 isWait 为真并通过 GlobalVariableSet 写全局变量通知外部;否则向前推 m_ReplayCount 直到时间对齐,再统计 nCount 定位到 iPos 对应的秒级行情下标。外汇与贵金属回放测试属高风险验证,结果仅代表历史数据重演概率,不预示实盘表现。

MQL5 / C++
if (iPos < m_ReplayCount)
            {
               CustomRatesDelete(def_SymbolReplay, Rate[class="num">0].time, LONG_MAX);
               if ((m_dtPrevLoading == class="num">0) && (iPos == class="num">0)) FirstBarNULL(); else
               {
                  for(Rate[class="num">0].time -= class="num">60; (m_ReplayCount > class="num">0) && (Rate[class="num">0].time <= macroRemoveSec(m_Ticks.Info[m_ReplayCount].time)); m_ReplayCount--);
                  m_ReplayCount++;
               }
            }else if (iPos > m_ReplayCount)
            {
               if (bViewBuider)
               {
                  Info.s_Infos.isWait = true;
                  GlobalVariableSet(def_GlobalVariableReplay, Info.u_Value.df_Value);
               }else
               {
                  for(; Rate[class="num">0].time > m_Ticks.Info[m_ReplayCount].time; m_ReplayCount++);
                  for (nCount = class="num">0; m_Ticks.Rate[nCount].time < macroRemoveSec(m_Ticks.Info[iPos].time); nCount++);
交给小布盯盘看盘口
这些跳价时序与盘口一致性诊断,小布盯盘的 AIGC 已内置,打开对应品种页即可看到实时跳价偏离提示,把重复劳动交给小布,你专注决策。

常见问题

真实跳价来自交易所逐笔推送,模拟需从已有 tick 流重演,时序与丢包处理不同,可能导致盘口视觉不一致。
可以,小布盯盘品种页支持接入自定义回放流,自动比对模拟跳价与历史实盘跳价分布的偏离概率。
两者取数频率和渲染触发逻辑不同,MT5 标准组件中并非同一数据源直驱,模拟层要分别补偿。
拆出的轻量子类负责跳价事件派发,C_Replay 只留调度,这样后续七篇的改进不会互相污染。