回放系统时间校正·竞价识别基础:用最小改动让模拟资产正确报竞价(基础篇)
(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 开发里,终端层偶发错乱概率不低,高风险环境下别赌它自愈,文件级替换是最短路径。
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,后续画水平标尺就靠它。外汇与贵金属杠杆高,这类自动识别逻辑仅降低手动填参数的出错概率,不预示任何价格走向。
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() 刷新。外汇和贵金属波动剧烈、保证金机制复杂,这类自动接管最好先在策略测试器用历史数据验证一遍再上实盘。
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 棒逐一打印,就能验证它吐出的时间是否严格落在整点。
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));