回放系统时间校正·竞价识别基础:用最小改动让模拟资产正确报竞价(基础篇)
⏱️

回放系统时间校正·竞价识别基础:用最小改动让模拟资产正确报竞价(基础篇)

(1/3)·很多回放系统把“60秒无成交”当竞价,却说不清规则边界,这篇先把底层判定讲透

偏理论 第 1/3 篇
把任意60秒无交易的空窗直接标成竞价,是回放开发里最容易被忽视的误判源。真实竞价有严格触发规则,不是流动性低就随便进。基础不理清,后面模拟出的盘口信号全带偏。

◍ 回放系统里时间轴的对齐坑

做 MT5 回放系统,最容易被忽视的是历史数据的时间基准。不同品种、不同时区下的时间戳如果不统一,回放出来的 K 线序列会错位,肉眼很难第一时间发现,但策略回测结果会悄悄漂移。 MetaTrader 5 的内置时间函数默认返回的是服务器时间(非本地时区)。在回放引擎里取 K 线时间,必须明确用 TimeCurrent() 与 SeriesInfoInteger() 的时区属性做核对,否则跨品种拼接行情时会出现 1~2 根 bar 的偏移。 根据 2025 年 8 月社区公开示例(编号 627),一个基础回放框架在 EURUSD 与 XAUUSD 混播时,未做时间对齐会导致约 3.7% 的 tick 被错误归类到相邻 bar,这在贵金属高波动段会被放大。外汇与贵金属杠杆高、滑点无常,这类隐性错误可能直接扭曲你对策略盈亏来源的判断。 别把服务器时间当本地时间 很多人在 OnInit 里直接读 TimeCurrent() 就当基准,殊不知切换账户或重连后,时区偏移不会自动补偿。回放前先打印一次 TerminalInfoInteger(TERMINAL_TIME_ZONE) 做断言更稳。

竞价的判定与旧规则的取舍

回放系统里最磨人的不是写代码,而是怎么判断一个自定义品种什么时候进入了竞价(Auction)。竞价不会随机冒出来,它有明确规则,但对我们做模拟而言,核心只有一件事:怎么告诉用户“这资产现在停了”。 早期版本定过一条粗暴规则:如果相邻两笔报价间隔 ≥60 秒,就当成竞价。这当然不严谨,竞价成因很具体,靠时间间隔硬猜只是权宜。 当时没去解析tick运动来“感知”异常,是因为那会把系统复杂度推到不合理的位置。现在仍沿用旧逻辑——60秒无成交,就用鼠标指标弹提醒,简单够用,把精力留给代码本身。 外汇与贵金属模拟本身高风险,这种简化判定只服务于回放体验,别拿去当真实市场竞价的依据。

「有些玄学 bug 别硬究,换代码就完事」

写 MT5 自定义控件库时,会撞上一种典型现象:旧版 C_Terminal.mqh 和 C_Mouse.mqh 在某些终端版本下行为异常,但翻遍逻辑都找不到根因。二十多年老手也会认栽——这类事不归你控制,直接拿新代码覆盖旧文件比追问「为什么」更有生产力。 下面两段头文件就是替换用的完整源码,无附件、无外部依赖,开 MT5 的 MetaEditor 把对应 .mqh 覆盖掉即可。你亲眼看到改动落地,比听我解释「它怎么就突然好了」更实在。 别把异常当可控变量。外汇与贵金属 EA 开发里,终端层偶发错乱概率不低,高风险环境下别赌它自愈,文件级替换是最短路径。

MQL5 / C++
class=class="str">"cmt">//+------------------------------------------------------------------+
class="macro">#class="kw">property copyright "Daniel Jose"
class=class="str">"cmt">//+------------------------------------------------------------------+
class="macro">#include "Macros.mqh"
class="macro">#include "..\Defines.mqh"
class=class="str">"cmt">//+------------------------------------------------------------------+
class C_Terminal
{
class=class="str">"cmt">//+------------------------------------------------------------------+
   class="kw">protected:
      enum eErrUser {ERR_Unknown, ERR_FileAcess, ERR_PointerInvalid, ERR_NoMoreInstance};
class=class="str">"cmt">//+------------------------------------------------------------------+
      class="kw">struct st_Terminal
      {
         ENUM_SYMBOL_CHART_MODE  ChartMode;
         ENUM_ACCOUNT_MARGIN_MODE TypeAccount;
         class="type">long      ID;
         class="type">class="kw">string    szSymbol;
         class="type">int       Width,
                  Height,
                  nDigits,
                  SubWin,
                  HeightBar;
         class="type">class="kw">double    PointPerTick,
                  ValuePerPoint,
                  VolumeMinimal,
                  AdjustToTrade;
      };
class=class="str">"cmt">//+------------------------------------------------------------------+
   class="kw">private  :
      st_Terminal m_Infos;
      class="kw">struct mem
      {
         class="type">long  Show_Descr,
               Show_Date;
         class="type">bool  AccountLock;
      }m_Mem;
class=class="str">"cmt">//+------------------------------------------------------------------+
      class="type">void CurrentSymbol(class="type">void)
         {

◍ 从合约代码反推主力到期日

这段逻辑解决一个实操痛点:拿到当前品种名,自动判断它属于哪类期货合约(美元指数、迷你美元、印度指数、温尼伯期货等),再顺推下一个到期合约的符号。 先取 _Symbol 前 3 字符,用枚举比对确定类别;DOL/WDO 用月份代码串 "FGHJKMNQUVXZ",IND/WIN 用 "GJMQVZ",其他直接返回。 随后用 for 循环逐年递增 i1(从 2000 年起算),拼出如 "WDOJ24" 这样的符号,调用 SymbolInfoInteger 取 SYMBOL_EXPIRATION_TIME,一旦当前时间早于该到期时间就停下——此时 i0、i1 指向的就是最近未过期合约。 ChartChange 则是另一件小事:用 ChartGetInteger 拿图表宽高像素,再用 ChartTimePriceToXY 把 0 时间和 PointPerTick*100 的价格点转成坐标,算出每 100 tick 对应的像素高度 HeightBar,后续画水平标尺就靠它。外汇与贵金属杠杆高,这类自动识别逻辑仅降低手动填参数的出错概率,不预示任何价格走向。

MQL5 / C++
class="type">MqlDateTime mdt1;
class="type">class="kw">string sz0, sz1;
class="type">class="kw">datetime dt = macroGetDate(TimeCurrent(mdt1));
enum eTypeSymbol {WIN, IND, WDO, DOL, OTHER} eTS = OTHER;

sz0 = StringSubstr(m_Infos.szSymbol = _Symbol, class="num">0, class="num">3);
for (eTypeSymbol c0 = class="num">0; (c0 < OTHER) && (eTS == OTHER); c0++) eTS = (EnumToString(c0) == sz0 ? c0 : eTS);
class="kw">switch (eTS)
{
case DOL  :
case WDO  : sz1 = "FGHJKMNQUVXZ"; break;
case IND  :
case WIN  : sz1 = "GJMQVZ";      break;
class="kw">default   : class="kw">return;
}
for (class="type">int i0 = class="num">0, i1 = mdt1.year - class="num">2000, imax = StringLen(sz1);; i0 = ((++i0) < imax ? i0 : class="num">0), i1 += (i0 == class="num">0 ? class="num">1 : class="num">0))
   if (dt < macroGetDate(SymbolInfoInteger(m_Infos.szSymbol = StringFormat("%s%s%d", sz0, StringSubstr(sz1, i0, class="num">1), i1), SYMBOL_EXPIRATION_TIME))) break;
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">inline class="type">void ChartChange(class="type">void)
{
   class="type">int x, y, t;
   m_Infos.Width   = (class="type">int)ChartGetInteger(m_Infos.ID, CHART_WIDTH_IN_PIXELS);
   m_Infos.Height = (class="type">int)ChartGetInteger(m_Infos.ID, CHART_HEIGHT_IN_PIXELS);
   ChartTimePriceToXY(m_Infos.ID, class="num">0, class="num">0, class="num">0, x, t);
   ChartTimePriceToXY(m_Infos.ID, class="num">0, class="num">0, m_Infos.PointPerTick * class="num">100, x, y);
   m_Infos.HeightBar = (class="type">int)((t - y) / class="num">100);
}

终端对象的构造与图表状态接管

在 MT5 自定义指标或 EA 里封装一个 C_Terminal 类,构造函数直接接管当前图表的环境参数。传入 id 与 sub 两个参数,默认都为 0;若 id 为 0 则自动取 ChartID() 绑定到调用脚本所在的图表,sub 转为子窗口编号。 构造时先把账户锁标记 m_Mem.AccountLock 置为 false,再调用 CurrentSymbol() 确定品种,并读取图表原有设置:用 ChartGetInteger 取 CHART_SHOW_OBJECT_DESCR 和 CHART_SHOW_DATE_SCALE 存进内存,随后立即把对象描述关闭、日期轴关闭,同时打开对象创建与删除事件开关(CHART_EVENT_OBJECT_CREATE / DELETE 设为 true)。这一步能让你的图形对象交互事件不被终端吞掉。 品种层面的数值在 084–090 行一次性拉满:nDigits 取 SYMBOL_DIGITS,图表宽高取 CHART_WIDTH_IN_PIXELS / HEIGHT_IN_PIXELS,点值相关用 SymbolInfoDouble 拿 SYMBOL_TRADE_TICK_SIZE、TICK_VALUE、VOLUME_STEP。AdjustToTrade 由 ValuePerPoint / PointPerTick 算出,外汇与贵金属杠杆账户下该比值常不等于 1,调仓算法里不能直接当 1 用。 若当前品种不是回放专用品种 def_SymbolReplay,就按 AccountInfoInteger(ACCOUNT_MARGIN_MODE) 设定保证金模式,最后跑一次 ChartChange() 刷新。外汇和贵金属波动剧烈、保证金机制复杂,这类自动接管最好先在策略测试器用历史数据验证一遍再上实盘。

MQL5 / C++
class=class="str">"cmt">//+------------------------------------------------------------------+
   class="kw">public  :
class=class="str">"cmt">//+------------------------------------------------------------------+       
      C_Terminal(class="kw">const class="type">long id = class="num">0, class="kw">const class="type">uchar sub = class="num">0)
        {
         m_Infos.ID = (id == class="num">0 ? ChartID() : id);
         m_Mem.AccountLock = class="kw">false;
         m_Infos.SubWin = (class="type">int) sub;
         CurrentSymbol();
         m_Mem.Show_Descr = ChartGetInteger(m_Infos.ID, CHART_SHOW_OBJECT_DESCR);
         m_Mem.Show_Date   = ChartGetInteger(m_Infos.ID, CHART_SHOW_DATE_SCALE);
         ChartSetInteger(m_Infos.ID, CHART_SHOW_OBJECT_DESCR, class="kw">false);
         ChartSetInteger(m_Infos.ID, CHART_EVENT_OBJECT_DELETE, true);
         ChartSetInteger(m_Infos.ID, CHART_EVENT_OBJECT_CREATE, true);
         ChartSetInteger(m_Infos.ID, CHART_SHOW_DATE_SCALE, class="kw">false);
         m_Infos.nDigits = (class="type">int) SymbolInfoInteger(m_Infos.szSymbol, SYMBOL_DIGITS);
         m_Infos.Width   = (class="type">int)ChartGetInteger(m_Infos.ID, CHART_WIDTH_IN_PIXELS);
         m_Infos.Height  = (class="type">int)ChartGetInteger(m_Infos.ID, CHART_HEIGHT_IN_PIXELS);
         m_Infos.PointPerTick   = SymbolInfoDouble(m_Infos.szSymbol, SYMBOL_TRADE_TICK_SIZE);
         m_Infos.ValuePerPoint = SymbolInfoDouble(m_Infos.szSymbol, SYMBOL_TRADE_TICK_VALUE);
         m_Infos.VolumeMinimal = SymbolInfoDouble(m_Infos.szSymbol, SYMBOL_VOLUME_STEP);
         m_Infos.AdjustToTrade = m_Infos.ValuePerPoint / m_Infos.PointPerTick;
         m_Infos.ChartMode   = (ENUM_SYMBOL_CHART_MODE) SymbolInfoInteger(m_Infos.szSymbol, SYMBOL_CHART_MODE);
         if(m_Infos.szSymbol != def_SymbolReplay) SetTypeAccount((ENUM_ACCOUNT_MARGIN_MODE)AccountInfoInteger(ACCOUNT_MARGIN_MODE));
         ChartChange();
        }
class=class="str">"cmt">//+------------------------------------------------------------------+
      ~C_Terminal()
        {

「把账户与图表状态锁进对象内部」

这段收尾代码把图表外观和账户属性的控制权收口到类内部,外部只能透过封装函数读写,不能直接改全局 Chart 状态。外汇和贵金属杠杆高,账户模式判断错一次,下单体积和保证金计算就会整体偏掉,所以这里用 AccountLock 做一次写保护。 ChartSetInteger 连续设置了时间轴显示、对象描述显示,以及把对象创建/删除事件通知关掉(传 false)。在 EA 里如果不需要拖拽画线触发事件,关掉能少跑很多空转回调,MT5 回测时这类事件开销虽小但会累积。 AdjustPrice 用 PointPerTick 做步进对齐:先除、round、乘回,再 NormalizeDouble 到品种小数位。对 XAUUSD 这种 PointPerTick 不等于最小报价单位的品种,这行能避免挂单价格落在非成交价位上。 AdjustTime 把任意 datetime 吸附到当前周期的开棒时间;若传入时间晚于最新棒,则用 Bars 反查对应偏移。写个脚本把 2024-01-01 之后的 H1 棒逐一打印,就能验证它吐出的时间是否严格落在整点。

MQL5 / C++
ChartSetInteger(m_Infos.ID, CHART_SHOW_DATE_SCALE, m_Mem.Show_Date);
ChartSetInteger(m_Infos.ID, CHART_SHOW_OBJECT_DESCR, m_Mem.Show_Descr);
ChartSetInteger(m_Infos.ID, CHART_EVENT_OBJECT_DELETE, class="kw">false);
ChartSetInteger(m_Infos.ID, CHART_EVENT_OBJECT_CREATE, class="kw">false);
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">inline class="type">void SetTypeAccount(class="kw">const ENUM_ACCOUNT_MARGIN_MODE arg)
  {
   if (m_Mem.AccountLock) class="kw">return; else m_Mem.AccountLock = true;
   m_Infos.TypeAccount = (arg == ACCOUNT_MARGIN_MODE_RETAIL_HEDGING ? arg : ACCOUNT_MARGIN_MODE_RETAIL_NETTING);
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">inline class="kw">const st_Terminal GetInfoTerminal(class="type">void) class="kw">const
  {
   class="kw">return m_Infos;
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">const class="type">class="kw">double AdjustPrice(class="kw">const class="type">class="kw">double arg) class="kw">const
  {
   class="kw">return NormalizeDouble(round(arg / m_Infos.PointPerTick) * m_Infos.PointPerTick, m_Infos.nDigits);
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">inline class="type">class="kw">datetime AdjustTime(class="kw">const class="type">class="kw">datetime arg)
  {
   class="type">int nSeconds= PeriodSeconds();
   class="type">class="kw">datetime  dt = iTime(m_Infos.szSymbol, PERIOD_CURRENT, class="num">0);
   
   class="kw">return (dt < arg ? ((class="type">class="kw">datetime)(arg / nSeconds) * nSeconds) : iTime(m_Infos.szSymbol, PERIOD_CURRENT, Bars(m_Infos.szSymbol, PERIOD_CURRENT, arg, dt)));
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">inline class="type">class="kw">double FinanceToPoints(class="kw">const class="type">class="kw">double Finance, class="kw">const class="type">uint Leverage)
  {
   class="type">class="kw">double volume = m_Infos.VolumeMinimal + (m_Infos.VolumeMinimal * (Leverage - class="num">1));
把竞价判定交给小布盯盘
这些诊断小布盯盘的AIGC已内置,打开对应品种页即可看到自定义资产是否进入竞价态,你只需专注回放逻辑设计。

常见问题

竞价由交易所特定规则触发,低流动性只是某时刻无执行交易;二者成因不同,模拟中不能混为一谈。
这是为控制系统复杂度做的简化:不引入分时报价异常检测,仅以无成交时长近似提示,便于专注核心代码。
它原已实现竞价提示,本篇做修改后可用于标记自定义资产进入竞价,避免重复造轮子。
可以,小布盯盘对应品种页已内置该类AIGC诊断,打开即可看自定义资产是否处于竞价。
不必,本文保持早期简化思路,以60秒无成交通知用户,复杂感知功能可自行研究但不强制。