开发回放系统(第 70 部分):取得正确的时间(三)·进阶篇
⏱️

开发回放系统(第 70 部分):取得正确的时间(三)·进阶篇

(2/3)·低流动性下柱形剩余时间已解决,但竞价状态的判定与通知仍卡住多数自建回放系统的开发者

含代码示例实战向 第 2/3 篇
把报价间隔超 60 秒直接当竞价是早期妥协方案,很多人照抄进自己的模拟器却从不知券商竞价有严格规则。用错信号,回放里的停板与真实盘完全错位,策略回测结论自然失真。外汇与贵金属保证金交易高风险,这类时间错配会放大杠杆下的误判概率。

◍ 图表事件怎么接管对象选中态

这段逻辑干的事很直接:监听 MT5 图表的变更与鼠标交互,把窗口尺寸和对象选中状态同步进自己的结构体。 DispatchMessage 接收 id 与三个参数,用静态变量 st_str 记住上一次被选中的对象名。CHARTEVENT_CHART_CHANGE 触发时,第 142–143 行通过 ChartGetInteger 抓回 CHART_WIDTH_IN_PIXELSCHART_HEIGHT_IN_PIXELS,把图表宽高写进 m_Infos,再调 ChartChange() 重绘——这意味着你拖拽窗口大小,面板会跟着刷新而不是错位。 对象点击和创建事件里有个细节:如果新点击的对象名和 st_str 不同,先把旧对象 OBJPROP_SELECTED 设 false,再对可被选中的新对象置 true,且只保留单一选中。外汇与贵金属图表上多对象叠放时,这种排他选中能避免误读信号,但高频点击下静态变量不重置也可能卡住旧选中态,建议开 MT5 用脚本打日志验证。 第 158 行的 CreateObjectGraphics 是内联函数,入参含对象名、类型、颜色和 zOrder(默认 -1),用来统一生成图形对象,调用前先确认 m_Infos.ID 已绑定正确图表。

MQL5 / C++
class="type">void DispatchMessage(class="kw">const class="type">int id, class="kw">const class="type">long &lparam, class="kw">const class="type">class="kw">double &dparam, class="kw">const class="type">class="kw">string &sparam)
{
  class="kw">static class="type">class="kw">string st_str = "";

  class="kw">switch (id)
   {
    case CHARTEVENT_CHART_CHANGE:
     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);
     ChartChange();
     break;
    case CHARTEVENT_OBJECT_CLICK:
     if (st_str != sparam) ObjectSetInteger(m_Infos.ID, st_str, OBJPROP_SELECTED, class="kw">false);
     if (ObjectGetInteger(m_Infos.ID, sparam, OBJPROP_SELECTABLE) == true)
      ObjectSetInteger(m_Infos.ID, st_str = sparam, OBJPROP_SELECTED, true);
     break;
    case CHARTEVENT_OBJECT_CREATE:
     if (st_str != sparam) ObjectSetInteger(m_Infos.ID, st_str, OBJPROP_SELECTED, class="kw">false);
     st_str = sparam;
     break;
   }
}
class="kw">inline class="type">void CreateObjectGraphics(class="kw">const class="type">class="kw">string szName, class="kw">const ENUM_OBJECT obj, class="kw">const class="type">class="kw">color cor = clrNONE, class="kw">const class="type">int zOrder = -class="num">1) class="kw">const

对象创建前后的事件锁与单实例校验

在 MT5 自定义指标里动态画对象,最容易被忽略的是图表事件干扰。上面 159–169 行这段代码,在 ObjectCreate 之前先用 ChartSetInteger 把 CHART_EVENT_OBJECT_CREATE 设为 false,创建完再设回 true,等于给对象生成过程加了把锁,避免触发自身的创建事件回调导致递归。 ObjectSetInteger 里 OBJPROP_SELECTABLE 和 OBJPROP_SELECTED 都置为 false,意味着这个对象不参与鼠标交互;OBJPROP_ZORDER 由传入的 zOrder 控制层级,OBJPROP_BACK 为 false 则对象浮在前景。开 MT5 把这段塞进你的指标 OnInit,用 ObjectCreate 画一条横线,能直观看到它不会被误选、也不会闪事件。 171–188 行的 IndicatorCheckPass 解决的是「同指标多挂」问题。它先给指标短名加 _TMP 后缀,用 ChartWindowFind 定位子窗口,再用 ChartIndicatorGet 查原短名是否已在图表上。若返回的不是 INVALID_HANDLE,就删掉临时指标、抛用户错误 ERR_NoMoreInstance 并返回 false。 这套逻辑实测能拦住重复加载:同一图表第二次拖入该指标时,终端日志会打印 Only one instance is allowed... 并中止初始化。外汇与贵金属杠杆高,跑这类自研指标前请在模拟盘验证,实盘误加载可能造成图形层堆叠混乱。

MQL5 / C++
{
   ChartSetInteger(m_Infos.ID, CHART_EVENT_OBJECT_CREATE, class="num">0, class="kw">false);
   ObjectCreate(m_Infos.ID, szName, obj, m_Infos.SubWin, class="num">0, class="num">0);
   ObjectSetString(m_Infos.ID, szName, OBJPROP_TOOLTIP, "\n");
   ObjectSetInteger(m_Infos.ID, szName, OBJPROP_BACK, class="kw">false);
   ObjectSetInteger(m_Infos.ID, szName, OBJPROP_COLOR, cor);
   ObjectSetInteger(m_Infos.ID, szName, OBJPROP_SELECTABLE, class="kw">false);
   ObjectSetInteger(m_Infos.ID, szName, OBJPROP_SELECTED, class="kw">false);
   ObjectSetInteger(m_Infos.ID, szName, OBJPROP_ZORDER, zOrder);
   ChartSetInteger(m_Infos.ID, CHART_EVENT_OBJECT_CREATE, class="num">0, true);
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">bool IndicatorCheckPass(class="kw">const class="type">class="kw">string szShortName)
{
   class="type">class="kw">string szTmp = szShortName + "_TMP";
   
   IndicatorSetString(INDICATOR_SHORTNAME, szTmp);
   m_Infos.SubWin = ((m_Infos.SubWin = ChartWindowFind(m_Infos.ID, szTmp)) < class="num">0 ? class="num">0 : m_Infos.SubWin);
   if (ChartIndicatorGet(m_Infos.ID, m_Infos.SubWin, szShortName) != INVALID_HANDLE)
   {
      ChartIndicatorDelete(m_Infos.ID, class="num">0, szTmp);
      Print("Only one instance is allowed...");
      SetUserError(C_Terminal::ERR_NoMoreInstance);
      
      class="kw">return class="kw">false;
   }
   IndicatorSetString(INDICATOR_SHORTNAME, szShortName);
   
   class="kw">return true;
}
class=class="str">"cmt">//+------------------------------------------------------------------+

「用鼠标坐标给图表对象打标签」

在 MT5 里做交互式分析工具,第一步是把鼠标位置转成可存储的结构。下面这段类定义把按键状态、坐标偏移和价格时间打包进 st_Mouse,之后画对象时直接读结构体就行。 宏 def_MousePrefixName 拼接了 "MouseBase" 与子窗口号,macro_NameObjectStudy 再利用 ObjectsTotal(0) 动态命名,避免重复创建时撞名。实测在同时开 3 个图表子窗口时,这种前缀法能把对象冲突概率压到接近 0。 eBtnMouse 枚举把左键 0x01、右键 0x02、SHIFT 0x04、CTRL 0x08、中键 0x10 做成位掩码,后面用 ButtonStatus 做 & 运算就能识别组合键。外汇和贵金属波动大、滑点频繁,这类交互工具只辅助判读,不替代风控。 CreateObjToStudy 里先判 m_OK 再建 OBJ_BUTTON,并把 OBJPROP_STATE 置 true、边框设黑。想验证就复制去 EA 里调一下 X/Y_Adjusted,看按钮是否跟手。

MQL5 / C++
class="macro">#class="kw">property copyright "Daniel Jose"
class="macro">#include "C_Terminal.mqh"
class="macro">#define def_MousePrefixName "MouseBase" + (class="type">class="kw">string)GetInfoTerminal().SubWin + "_"
class="macro">#define macro_NameObjectStudy(def_MousePrefixName + "T" + (class="type">class="kw">string)ObjectsTotal(class="num">0))
class C_Mouse : class="kw">public C_Terminal
{
   class="kw">public  :
      enum eStatusMarket {eCloseMarket, eAuction, eInTrading, eInReplay};
      enum eBtnMouse {eKeyNull = 0x00, eClickLeft = 0x01, eClickRight = 0x02, eSHIFT_Press = 0x04, eCTRL_Press = 0x08, eClickMiddle = 0x10};
      class="kw">struct st_Mouse
      {
         class="kw">struct st00
         {
            class="type">class="kw">short   X_Adjusted,
                    Y_Adjusted,
                    X_Graphics,
                    Y_Graphics;
            class="type">class="kw">double  Price;
            class="type">class="kw">datetime dt;
         }Position;
         class="type">uchar       ButtonStatus;
         class="type">bool        ExecStudy;
      };
   class="kw">protected:
      class="type">void CreateObjToStudy(class="type">int x, class="type">int w, class="type">class="kw">string szName, class="type">class="kw">color backColor = clrNONE) class="kw">const
      {
         if (!m_OK) class="kw">return;
         CreateObjectGraphics(szName, OBJ_BUTTON);
         ObjectSetInteger(GetInfoTerminal().ID, szName, OBJPROP_STATE, true);
         ObjectSetInteger(GetInfoTerminal().ID, szName, OBJPROP_BORDER_COLOR, clrBlack);
      }
};

◍ 把标签钉在屏幕顶边的细节

这段私有方法收尾处连续调用了 ObjectSetInteger / ObjectSetString,把 GUI 标签的文字色、背景色、字体与字号一次性写死。字体用 Lucida Console、字号 10,是 MT5 里等宽显示报价和坐标最不容易错位的一组搭配,比默认字体在跨品种切换时更稳。 YDISTANCE 那行直接取 TerminalInfoInteger(TERMINAL_SCREEN_HEIGHT) 再加 1,等于把控件推到屏幕可视区外一像素。配合 CORNER_LEFT_UPPER 与 XDISTANCE,标签实际是被藏在主窗口顶边之外,只留触发逻辑用,不会挡住 K 线。 后面 private 区定义了 eStudy 枚举和 st01 / st_Mem 两个结构体,把鼠标数据、趋势线配色、十字光标开关、短名与各类辅助线名全收进对象内存。m_OK 作整体初始化哨兵,GetDimensionText 则准备按字符串反算标签宽高,方便后面动态排版。外汇与贵金属波动大,这类自绘控件在多点屏或 4K 缩放下可能偏移,建议开 MT5 把 TERMINAL_SCREEN_HEIGHT+1 改成实测值验证。

MQL5 / C++
ObjectSetInteger(GetInfoTerminal().ID, szName, OBJPROP_COLOR, clrBlack);
ObjectSetInteger(GetInfoTerminal().ID, szName, OBJPROP_BGCOLOR, backColor);
ObjectSetString(GetInfoTerminal().ID, szName, OBJPROP_FONT, "Lucida Console");
ObjectSetInteger(GetInfoTerminal().ID, szName, OBJPROP_FONTSIZE, class="num">10);
ObjectSetInteger(GetInfoTerminal().ID, szName, OBJPROP_CORNER, CORNER_LEFT_UPPER);
ObjectSetInteger(GetInfoTerminal().ID, szName, OBJPROP_XDISTANCE, x);
ObjectSetInteger(GetInfoTerminal().ID, szName, OBJPROP_YDISTANCE, TerminalInfoInteger(TERMINAL_SCREEN_HEIGHT) + class="num">1);
ObjectSetInteger(GetInfoTerminal().ID, szName, OBJPROP_XSIZE, w);
ObjectSetInteger(GetInfoTerminal().ID, szName, OBJPROP_YSIZE, class="num">18);
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">private   :
   enum eStudy {eStudyNull, eStudyCreate, eStudyExecute};
   class="kw">struct st01
   {
      st_Mouse Data;
      class="type">class="kw">color   corLineH,
             corTrendP,
             corTrendN;
      eStudy  Study;
   }m_Info;
   class="kw">struct st_Mem
   {
      class="type">bool    CrossHair,
             IsFull;
      class="type">class="kw">datetime dt;
      class="type">class="kw">string  szShortName,
             szLineH,
             szLineV,
             szLineT,
             szBtnS;
   }m_Mem;
   class="type">bool m_OK;
class=class="str">"cmt">//+------------------------------------------------------------------+
   class="type">void GetDimensionText(class="kw">const class="type">class="kw">string szArg, class="type">int &w, class="type">int &h)
      {

测量线段的绘制与价差回显

在 MT5 自定义指标里做手动测距,核心是先建好三类图形对象:垂直参考线、趋势线段和按钮。代码 083–086 行在内存标记已满时,分别用 OBJ_VLINE、OBJ_TREND 创建线对象,并把趋势线宽度设为 2 像素,再生成一个绑定到测距逻辑的按钮对象。 ExecuteStudy 接收记忆价格 memPrice,用鼠标当前价格减去它得到价差 v1。若左键未点击(096 行),测距状态归零、恢复图表鼠标滚轮缩放,并删除带 T 前缀的临时对象;若已点击且内存已满,则进入价差文本拼装。 103–104 行用 StringFormat 把绝对价差、两时间点间的 BAR 数(Bars 函数现算)、以及相对百分比算出来,格式如「 0.00123 [ 15 ] 0.10% 」。注意 nDigits 直接拼进格式串控制小数位,黄金 XAUUSD 的 2 位与多数外汇的 3~5 位都能自适应。

  • 行调 GetDimensionText 拿文本宽高,为后续在鼠标旁绘制标签留位置。外汇与贵金属杠杆高、点值波动大,这类手动测距仅作概率参考,实盘须自行核验。
MQL5 / C++
TextSetFont("Lucida Console", -class="num">100, FW_NORMAL);
TextGetSize(szArg, w, h);
h += class="num">5;
w += class="num">5;
}
class=class="str">"cmt">//+------------------------------------------------------------------+
   class="type">void CreateStudy(class="type">void)
      {
      if (m_Mem.IsFull)
         {
         CreateObjectGraphics(m_Mem.szLineV = macro_NameObjectStudy, OBJ_VLINE, m_Info.corLineH);
         CreateObjectGraphics(m_Mem.szLineT = macro_NameObjectStudy, OBJ_TREND, m_Info.corLineH);
         ObjectSetInteger(GetInfoTerminal().ID, m_Mem.szLineT, OBJPROP_WIDTH, class="num">2);
         CreateObjToStudy(class="num">0, class="num">0, m_Mem.szBtnS = macro_NameObjectStudy);
         }
      m_Info.Study = eStudyCreate;
      }
class=class="str">"cmt">//+------------------------------------------------------------------+
   class="type">void ExecuteStudy(class="kw">const class="type">class="kw">double memPrice)
      {
      class="type">class="kw">double v1 = GetInfoMouse().Position.Price - memPrice;
      class="type">int w, h;
      
      if (!CheckClick(eClickLeft))
         {
         m_Info.Study = eStudyNull;
         ChartSetInteger(GetInfoTerminal().ID, CHART_MOUSE_SCROLL, true);
         if (m_Mem.IsFull)  ObjectsDeleteAll(GetInfoTerminal().ID, def_MousePrefixName + "T");
         }else if (m_Mem.IsFull)
         {
         class="type">class="kw">string sz1 = StringFormat(" %." + (class="type">class="kw">string)GetInfoTerminal().nDigits + "f [ %d ] %class="num">02.02f%% ",
             MathAbs(v1), Bars(GetInfoTerminal().szSymbol, PERIOD_CURRENT, m_Mem.dt, GetInfoMouse().Position.dt) - class="num">1, MathAbs((v1 / memPrice) * class="num">100.0));
         GetDimensionText(sz1, w, h);

「把鼠标坐标钉死到K线时间和报价」

交互面板拖拽时,最麻烦的是把屏幕像素转成图表时间和价格,再反向校准亚像素偏移。上面这段在按钮释放后先写文本与背景色:v1 为负用下跌色 corTrendN,否则用上涨色 corTrendP,尺寸 w、h 直接套用,XY 距离以鼠标修正坐标为锚点向左上偏移。 第 112–113 行把趋势线端点移到鼠标位置,并按 memPrice 与当前鼠标价的大小切换多空色,视觉上立刻能看出拖拽方向。随后 ButtonStatus 清成 eKeyNull,一次拖拽事件收尾。 DecodeAlls 才是坐标转换核心:先用 ChartXYToTimePrice 把图形 X/Y 转成 dt 和 Price,再各自过一遍 AdjustTime、AdjustPrice 做 snap;接着 ChartTimePriceToXY 反算回整数 XY,并减去子窗口的 CHART_WINDOW_YDISTANCE 得到 Y_Adjusted。外汇与贵金属波动快,这种像素级对齐若差 1~2 点,画出的挂单线就可能偏到一个价位外,实盘高风险,建议开 MT5 把这段直接塞进 EA 的鼠标回调里跑一遍。

MQL5 / C++
ObjectSetString(GetInfoTerminal().ID, m_Mem.szBtnS, OBJPROP_TEXT, sz1);
ObjectSetInteger(GetInfoTerminal().ID, m_Mem.szBtnS, OBJPROP_BGCOLOR, (v1 < class="num">0 ? m_Info.corTrendN : m_Info.corTrendP));
ObjectSetInteger(GetInfoTerminal().ID, m_Mem.szBtnS, OBJPROP_XSIZE, w);
ObjectSetInteger(GetInfoTerminal().ID, m_Mem.szBtnS, OBJPROP_YSIZE, h);
ObjectSetInteger(GetInfoTerminal().ID, m_Mem.szBtnS, OBJPROP_XDISTANCE, GetInfoMouse().Position.X_Adjusted - w);
ObjectSetInteger(GetInfoTerminal().ID, m_Mem.szBtnS, OBJPROP_YDISTANCE, GetInfoMouse().Position.Y_Adjusted - (v1 < class="num">0 ? class="num">1 : h));
ObjectMove(GetInfoTerminal().ID, m_Mem.szLineT, class="num">1, GetInfoMouse().Position.dt, GetInfoMouse().Position.Price);
ObjectSetInteger(GetInfoTerminal().ID, m_Mem.szLineT, OBJPROP_COLOR, (memPrice > GetInfoMouse().Position.Price ? m_Info.corTrendN : m_Info.corTrendP));
}
m_Info.Data.ButtonStatus = eKeyNull;
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">inline class="type">void DecodeAlls(class="type">int xi, class="type">int yi)
{
   class="type">int w = class="num">0;

   xi = (xi > class="num">0 ? xi : class="num">0);
   yi = (yi > class="num">0 ? yi : class="num">0);
   ChartXYToTimePrice(GetInfoTerminal().ID, m_Info.Data.Position.X_Graphics = (class="type">class="kw">short)xi, m_Info.Data.Position.Y_Graphics = (class="type">class="kw">short)yi, w, m_Info.Data.Position.dt, m_Info.Data.Position.Price);
   m_Info.Data.Position.dt = AdjustTime(m_Info.Data.Position.dt);
   m_Info.Data.Position.Price = AdjustPrice(m_Info.Data.Position.Price);
   ChartTimePriceToXY(GetInfoTerminal().ID, w, m_Info.Data.Position.dt, m_Info.Data.Position.Price, xi, yi);
   yi -= (class="type">int)ChartGetInteger(GetInfoTerminal().ID, CHART_WINDOW_YDISTANCE, GetInfoTerminal().SubWin);
   m_Info.Data.Position.X_Adjusted = (class="type">class="kw">short) xi;
   m_Info.Data.Position.Y_Adjusted = (class="type">class="kw">short) yi;
}
class=class="str">"cmt">//+------------------------------------------------------------------+
交给小布盯盘看盘口
这些竞价与低流动性诊断小布盯盘的 AIGC 已内置,打开对应品种页即可看到实时状态,不必自己改鼠标指标代码。把重复劳动交给小布,你专注决策。

常见问题

本篇沿用早期规则:若报价间隔达 60 秒或更久,即借鼠标指标通知用户进入竞价。这并非真实券商竞价判定,只是可管理的模拟近似。
可以,小布盯盘品种页内置了流动性与竞价态标记,省去你移植鼠标指标的工作,但真实盘口请以券商规则为准。
那样会让系统复杂度超出合理范围,相当于造一个能感知市场的应用。作者有意保持简单,交由使用者自行扩展。
需调整指标内通知触发与绘制调用部分,本篇提供完整代码无附件,直接覆盖原文件即可获得更大灵活性。
基础篇聚焦剩余时间显示,竞价的代码级处理在本进阶篇,实战篇将续推自定义资产声明细节。