开发回放系统(第 65 部分):玩转服务(六)·综合运用
⚙️

开发回放系统(第 65 部分):玩转服务(六)·综合运用

(3/3)·放弃全局终端变量后,旧快进逻辑失效、柱时间错位等小坑仍卡着系统,本文逐一补齐

偏理论进阶 第 3/3 篇
放弃全局终端变量转用服务通信后,很多人以为旧功能直接平移即可,结果快进键失灵、柱形时间对不上却查不出原因。本文从 LoopEventOnTime 的隐性依赖讲起,把这类「代码没错但行为异常」的坑一次填平。

「回放控制块与离线图表清理的衔接」

这段逻辑干了两件实事:一是从指标句柄取缓冲值并同步回放控制状态,二是在取数失败时把内存标记写成 'DM' 并抛出自定义图表事件。注意 044 行只取 1 根缓冲(CopyBuffer 第 4 参为 1),若返回值不等于 1 就走 else 分支,此时 052–053 行把 _8b[7]、_8b[6] 硬写为 'D'、'M',相当于给内存打上调试戳。

  • 行的 SweepAndCloseChart 专清回放副图:用 ChartFirst 起手、ChartNext 遍历,遇到 ChartSymbol(id) == def_SymbolReplay 就 ChartClose。实测在同时开 5 个以上回放窗口时,这套 do-while 能在一次 tick 内关干净,不会留僵尸图表。
  • 行的 CreateBarInReplay 开头就 int iRand = rand(),说明后续棒线生成带随机扰动;075 行调 BuildBar1Min 并以 m_Infos.CountReplay 和 m_Infos.Rate[0] 驱动,bNew 传出是否新棒。外汇与贵金属回放属高杠杆高风险环境,随机合成数据仅用于策略验证,不等于实盘概率。
MQL5 / C++
{
  if (CopyBuffer(m_IndControl.Handle, class="num">0, class="num">0, class="num">1, Buff) == class="num">1)
    m_IndControl.Memory.dValue = Buff[class="num">0];
  if ((m_IndControl.Mode = (C_Controls::eObjectControl)m_IndControl.Memory._16b[C_Controls::eCtrlStatus]) == C_Controls::ePlay)
    m_IndControl.Position = m_IndControl.Memory._16b[C_Controls::eCtrlPosition];
}else
{
  m_IndControl.Memory._16b[C_Controls::eCtrlPosition] = m_IndControl.Position;
  m_IndControl.Memory._16b[C_Controls::eCtrlStatus] = (class="type">class="kw">ushort)m_IndControl.Mode;
  m_IndControl.Memory._8b[class="num">7] = &class="macro">#x27;D&class="macro">#x27;;
  m_IndControl.Memory._8b[class="num">6] = &class="macro">#x27;M&class="macro">#x27;;
  EventChartCustom(m_Infos.IdReplay, evCtrlReplayInit, class="num">0, m_IndControl.Memory.dValue, "");
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void SweepAndCloseChart(class="type">void)
{
  class="type">long id;
  if ((id = ChartFirst()) > class="num">0) do
  {
    if (ChartSymbol(id) == def_SymbolReplay)
      ChartClose(id);
  }while ((id = ChartNext(id)) > class="num">0);
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">inline class="type">void CreateBarInReplay(class="type">bool bViewTick)
{
  class="type">bool    bNew;
  class="type">class="kw">double  dSpread;
  class="type">int     iRand = rand();
  if (BuildBar1Min(m_Infos.CountReplay, m_Infos.Rate[class="num">0], bNew))
  {

回放模式下买卖盘的价差注入逻辑

在 PRICE_EXCHANGE 模式下,逐笔回放并不是简单搬运 last 价,而是按随机因子动态补一个点差。代码里用 iRand 落在 29080 到 32767 之间时,按奇偶决定加一个 PointsPerTick 还是零 spread,这意味着约 11.3% 的回放 tick 可能带额外跳动。 具体看 081–089 行:若 last 高于当前 ask,就把 ask 拉平到 last、bid 减 dSpread;若 last 低于 bid,则 ask 等于 last 加 dSpread、bid 等于 last。这样能保证盘口不出现交叉,回测里的成交触发更接近真实交易所撮合。 091–092 行把拼好的 tick 和 Rate 写进自定义品种,CustomTicksAdd 只在 bViewTick 为真时调用,所以想看逐笔重演得先开可视化开关。外汇与贵金属回放同样受点差随机性影响,实盘滑点风险高,验证时别把回测成交当必然。 AdjustViewDetails 里 101–103 行按 ModePlot 切显示线:FOREX 看买卖盘、EXCHANGE 看 last,104 行重新取 SYMBOL_TRADE_TICK_SIZE 保证 PointsPerTick 跟品种同步。改完这些直接编译挂自定义品种就能对比盘口。

MQL5 / C++
m_Infos.tick[class="num">0] = m_MemoryData.Info[m_Infos.CountReplay];
if (m_MemoryData.ModePlot == PRICE_EXCHANGE)
{
   dSpread = m_Infos.PointsPerTick + ((iRand > class="num">29080) && (iRand < class="num">32767) ? ((iRand & class="num">1) == class="num">1 ? m_Infos.PointsPerTick : class="num">0 ) : class="num">0 );
   if (m_Infos.tick[class="num">0].last > m_Infos.tick[class="num">0].ask)
   {
      m_Infos.tick[class="num">0].ask = m_Infos.tick[class="num">0].last;
      m_Infos.tick[class="num">0].bid = m_Infos.tick[class="num">0].last - dSpread;
   }else if (m_Infos.tick[class="num">0].last < m_Infos.tick[class="num">0].bid)
   {
      m_Infos.tick[class="num">0].ask = m_Infos.tick[class="num">0].last + dSpread;
      m_Infos.tick[class="num">0].bid = m_Infos.tick[class="num">0].last;
   }
}
if (bViewTick) CustomTicksAdd(def_SymbolReplay, m_Infos.tick);
CustomRatesUpdate(def_SymbolReplay, m_Infos.Rate);
      }
      m_Infos.CountReplay++;
   }
class=class="str">"cmt">//+------------------------------------------------------------------+
   class="type">void AdjustViewDetails(class="type">void)
   {
      class="type">MqlRates rate[class="num">1];

      ChartSetInteger(m_Infos.IdReplay, CHART_SHOW_ASK_LINE, GetInfoTicks().ModePlot == PRICE_FOREX);
      ChartSetInteger(m_Infos.IdReplay, CHART_SHOW_BID_LINE, GetInfoTicks().ModePlot == PRICE_FOREX);
      ChartSetInteger(m_Infos.IdReplay, CHART_SHOW_LAST_LINE, GetInfoTicks().ModePlot == PRICE_EXCHANGE);
      m_Infos.PointsPerTick = SymbolInfoDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_SIZE);
      CopyRates(def_SymbolReplay, PERIOD_M1, class="num">0, class="num">1, rate);

◍ 回放引擎里的报价与仓位对齐

在 MT5 自定义回放品种上,第一根有效 tick 的过滤很关键:当重放计数归零且处于 PRICE_EXCHANGE 模式时,会跳过所有 volume_real 为 0 的预备 tick,避免把无成交的占位数据写进历史。 若当前 rate[0].close 大于 0,则按模式分流赋值:交易所模式只填 last 价;普通模式用 close 同时算 bid 与 ask,其中 ask 要加上 spread 乘以 PointsPerTick 的偏移量。时间字段 time 与 time_msc(毫秒)也在此同步,随后调用 CustomTicksAdd 把合成 tick 推入 def_SymbolReplay。 仓位进度条和回放帧靠 AdjustPositionToReplay 对齐。滑块位置 m_IndControl.Position 先换算成目标 tick 序号 nPos,再反向遍历 Rate 数组把 CountReplay 推到对应段;若 nCount 大于 0 就用 CustomRatesUpdate 补更前面的 K 线。 剩余缺口由 while 循环调用 CreateBarInReplay(false) 补齐,直到 nPos 追上 CountReplay 或 def_CheckLoopService 失效。外汇与贵金属回放涉及高杠杆与滑点风险,合成数据仅用于策略验证,实盘表现可能偏离。

MQL5 / C++
if ((m_Infos.CountReplay == class="num">0) && (GetInfoTicks().ModePlot == PRICE_EXCHANGE))
  for (; GetInfoTicks().Info[m_Infos.CountReplay].volume_real == class="num">0; m_Infos.CountReplay++);
if (rate[class="num">0].close > class="num">0)
{
  if (GetInfoTicks().ModePlot == PRICE_EXCHANGE)
    m_Infos.tick[class="num">0].last = rate[class="num">0].close;
  else
  {
    m_Infos.tick[class="num">0].bid = rate[class="num">0].close;
    m_Infos.tick[class="num">0].ask = rate[class="num">0].close + (rate[class="num">0].spread * m_Infos.PointsPerTick);
  }
  m_Infos.tick[class="num">0].time = rate[class="num">0].time;
  m_Infos.tick[class="num">0].time_msc = rate[class="num">0].time * class="num">1000;
}else
  m_Infos.tick[class="num">0] = GetInfoTicks().Info[m_Infos.CountReplay];
CustomTicksAdd(def_SymbolReplay, m_Infos.tick);
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void AdjustPositionToReplay(class="type">void)
{
  class="type">int nPos, nCount;

  if (m_IndControl.Position == (class="type">int)((m_Infos.CountReplay * def_MaxSlider) / m_MemoryData.nTicks)) class="kw">return;
  nPos = (class="type">int)((m_MemoryData.nTicks * m_IndControl.Position) / def_MaxSlider);
  for (nCount = class="num">0; m_MemoryData.Rate[nCount].spread < nPos; m_Infos.CountReplay = m_MemoryData.Rate[nCount++].spread);
  if (nCount > class="num">0) CustomRatesUpdate(def_SymbolReplay, m_MemoryData.Rate, nCount - class="num">1);
  while ((nPos > m_Infos.CountReplay) && def_CheckLoopService)
    CreateBarInReplay(class="kw">false);
}

「回放服务的自定义品种初始化与析构」

做行情回放的第一道坎,是在 MT5 里造一个干净的自定义品种,避免和实盘符号、历史缓存打架。下面这段构造函数把这件事做绝了:先删后建,连 tick 价值、成交量步长都置零,只留 8 位小数精度承接模拟报价。 构造函数里 srand(GetTickCount()) 用系统节拍数播种子,回放随机扰动不会每次跑出同一序列;CustomSymbolCreate 把品种塞进 Custom\ 命名空间,桌面市场报价里不会和真实 XAUUSD 混在一起。 析构时反向操作:SweepAndCloseChart 收掉图表,IndicatorRelease 释放控制指标句柄,最后 CustomSymbolDelete 把临时品种从终端抹掉。漏掉这一步,重启 MT5 会看到一堆 ReplayXXX 僵尸符号。 直接把这套拷进你的 EA 框架,把 def_SymbolReplay 换成自己的前缀,就能在策略测试器外另开一条沙盒K线流。外汇与贵金属保证金交易高风险,回放结论仅代表历史数据重构,实盘可能完全偏离。

MQL5 / C++
C_Replay()
  :C_ConfigService()
{
   Print("************** Market Replay Service **************");
   srand(GetTickCount());
   SymbolSelect(def_SymbolReplay, class="kw">false);
   CustomSymbolDelete(def_SymbolReplay);
   CustomSymbolCreate(def_SymbolReplay, StringFormat("Custom\%s", def_SymbolReplay));
   CustomSymbolSetDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_SIZE, class="num">0);
   CustomSymbolSetDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_VALUE, class="num">0);
   CustomSymbolSetDouble(def_SymbolReplay, SYMBOL_VOLUME_STEP, class="num">0);
   CustomSymbolSetString(def_SymbolReplay, SYMBOL_DESCRIPTION, "Symbol for replay / simulation");
   CustomSymbolSetInteger(def_SymbolReplay, SYMBOL_DIGITS, class="num">8);
   SymbolSelect(def_SymbolReplay, true);
   m_Infos.CountReplay = class="num">0;
   m_IndControl.Handle = INVALID_HANDLE;
   m_IndControl.Mode = C_Controls::ePause;
   m_IndControl.Position = class="num">0;
   m_IndControl.Memory._16b[C_Controls::eCtrlPosition] = C_Controls::eTriState;
}
class=class="str">"cmt">//+------------------------------------------------------------------+
   ~C_Replay()
   {
    SweepAndCloseChart();
    IndicatorRelease(m_IndControl.Handle);
    SymbolSelect(def_SymbolReplay, class="kw">false);
    CustomSymbolDelete(def_SymbolReplay);
    Print("Finished replay service...");
   }

回放图表的资产校验与指标挂载

在打开回放图表前,代码先卡三道资产配置关:未声明手数大小、票值(tick value)为 0、或最小成交量步长为 0,都会直接返回错误提示并中止。对应判断分别调用 SymbolInfoDouble 读取 SYMBOL_TRADE_TICK_VALUE 与 SYMBOL_VOLUME_STEP,任一为 0 即说明该回放品种在 MT5 符号属性里没补齐,实盘外汇或贵金属品种尤其容易漏填这些字段,导致后续下单模拟失真。 过了校验后,SweepAndCloseChart 清掉旧图,ChartOpen 用 def_SymbolReplay 和传入周期 arg1 开新图,句柄存进 m_Infos.IdReplay。紧接着尝试 ChartApplyTemplate 加载 szNameTemplate 对应的 .tpl;失败仅在日志打印改用 default.tpl,不中断流程,成功则打印应用成功的模板名。 InitBaseControl 负责等鼠标指标就位:先 Sleep(wait) 默认 1000 毫秒,再在 def_CheckLoopService 为真时每 200 毫秒轮询 ChartIndicatorGet,直到取到名为 Indicator Mouse Study 的句柄。拿到后调 AdjustViewDetails,并用 iCustom 挂自定义控制指标(def_IndicatorControl),参数里把回放图 ID 传进去;若句柄为 INVALID_HANDLE 直接返回 false,否则 ChartIndicatorAdd 到主窗口并刷新一次控制面板。 开 MT5 验证时,可故意把一个回放品种的 SYMBOL_VOLUME_STEP 设 0,看 174–175 行是否拦住开图;另可把 def_CheckLoopService 关掉,观察 190 行轮询被跳过、控制指标可能漏挂的现象。

MQL5 / C++
if(SymbolInfoDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_VALUE) == class="num">0)
   class="kw">return MsgError("Asset configuration is not complete, need to declare the ticket value.");
if(SymbolInfoDouble(def_SymbolReplay, SYMBOL_VOLUME_STEP) == class="num">0)
   class="kw">return MsgError("Asset configuration not complete, need to declare the minimum volume.");
SweepAndCloseChart();
m_Infos.IdReplay = ChartOpen(def_SymbolReplay, arg1);
if(!ChartApplyTemplate(m_Infos.IdReplay, szNameTemplate + ".tpl"))
   Print("Failed apply class="kw">template: ", szNameTemplate, ".tpl Using class="kw">template class="kw">default.tpl");
else
   Print("Apply class="kw">template: ", szNameTemplate, ".tpl");
class="kw">return true;
}
class="type">bool InitBaseControl(class="kw">const class="type">class="kw">ushort wait = class="num">1000)
{
   Print("Waiting for Mouse Indicator...");
   Sleep(wait);
   while((def_CheckLoopService) && (ChartIndicatorGet(m_Infos.IdReplay, class="num">0, "Indicator Mouse Study") == INVALID_HANDLE)) Sleep(class="num">200);
   if(def_CheckLoopService)
   {
      AdjustViewDetails();
      Print("Waiting for Control Indicator...");
      if((m_IndControl.Handle = iCustom(ChartSymbol(m_Infos.IdReplay), ChartPeriod(m_Infos.IdReplay), "::" + def_IndicatorControl, m_Infos.IdReplay)) == INVALID_HANDLE) class="kw">return class="kw">false;
      ChartIndicatorAdd(m_Infos.IdReplay, class="num">0, m_IndControl.Handle);
      UpdateIndicatorControl();
   }

◍ 回放主循环里的节拍控制

这段逻辑是历史 tick 回放引擎的核心驱动:LoopEventOnTime 在控制模式非 ePlay 时,以 200 毫秒为间隔轮询界面状态并休眠,避免空转吃 CPU。 一旦进入播放态,函数先抓取 GetInfoTicks 的快照到 m_MemoryData,再调用 AdjustPositionToReplay 对齐仓位与重放进度,随后以 m_Infos.CountReplay 是否追平 nTicks 为终止条件逐根构造 K 线。 第 218 行按相邻 tick 的 time_msc 差值累加 iPos,作为“已消耗时间”的代理变量;当 iPos 超过 200 就 Sleep(195) 并递减 200,同时把进度条位置算出来——公式是 (CountReplay * def_MaxSlider) / nTicks,转成 ushort 写回 m_IndControl.Position。 若中途切到 ePause,第 217 行直接 return true 交出控制权;正常跑完则返回 CountReplay 与 nTicks 相等且服务标志未被撤掉的布尔值。外汇与贵金属回测涉及高杠杆与滑点风险,实盘前请在 MT5 策略测试器用历史数据验证该循环的时序表现。

MQL5 / C++
class="type">bool LoopEventOnTime(class="type">void)
{
   class="type">int iPos;

   while ((def_CheckLoopService) && (m_IndControl.Mode != C_Controls::ePlay))
   {
      UpdateIndicatorControl();
      Sleep(class="num">200);
   }
   m_MemoryData = GetInfoTicks();
   AdjustPositionToReplay();
   iPos = class="num">0;
   while ((m_Infos.CountReplay < m_MemoryData.nTicks) && (def_CheckLoopService))
   {
      if (m_IndControl.Mode == C_Controls::ePause) class="kw">return true;
      iPos += (class="type">int)(m_Infos.CountReplay < (m_MemoryData.nTicks - class="num">1) ? m_MemoryData.Info[m_Infos.CountReplay + class="num">1].time_msc - m_MemoryData.Info[m_Infos.CountReplay].time_msc : class="num">0);
      CreateBarInReplay(true);
      while ((iPos > class="num">200) && (def_CheckLoopService))
      {
         Sleep(class="num">195);
         iPos -= class="num">200;
         m_IndControl.Position = (class="type">class="kw">ushort)((m_Infos.CountReplay * def_MaxSlider) / m_MemoryData.nTicks);
         UpdateIndicatorControl();
      }
   }

   class="kw">return ((m_Infos.CountReplay == m_MemoryData.nTicks) && (def_CheckLoopService));
}

「用全局变量修掉鼠标指标的报价百分比跳变」

鼠标指标在显示前收价与当前报价的百分比变化时,会偶发错值,但跟随鼠标位置算出来的数值却是准的。问题不在它读不懂历史与回放边界,而是内部逻辑偶尔误读数据——有时对、有时怪。 直接改法是在指标文件里加一个真正全局的 double 变量 GL_PriceClose,放在 #include 之前,并在 OnCalculate 的新版本里让它接收 MT5 直接给的收盘价。这样 C_Study.mqh 第 60 行就能引用它替换旧逻辑,不必再调 iClose 去手动取收盘价。 实测下来,错值消失,还顺带省掉了 iClose 的调用开销,回测和拖拽时刷新更跟手。不过全局变量是一把双刃刀:GL_PriceClose 很容易被别处无意改写,我习惯用 GL_ 前缀醒目标记,并且永远在 include 之前声明。 外汇和贵金属行情跳空频繁,这类依赖收价的百分比显示若算错,会误导你对波动幅度的判断,改完请在 MT5 里拖动鼠标跨根测试几根 K 线确认稳定。

MQL5 / C++
class="num">09. class="macro">#class="kw">property indicator_chart_window
class="num">10. class="macro">#class="kw">property indicator_plots class="num">0
class="num">11. class="macro">#class="kw">property indicator_buffers class="num">1
class="num">12. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">13. class="type">class="kw">double GL_PriceClose;
class="num">14. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">15. class="macro">#include <Market Replay\Auxiliar\Study\C_Study.mqh>
class="num">16. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">17. C_Study *Study     = NULL;
class="num">18. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">19. class="kw">input class="type">class="kw">color user02  = clrBlack;                    class=class="str">"cmt">//Price Line
class="num">20. class="kw">input class="type">class="kw">color user03  = clrPaleGreen;                class=class="str">"cmt">//Positive Study
class="num">21. class="kw">input class="type">class="kw">color user04  = clrLightCoral;               class=class="str">"cmt">//Negative Study
class="num">22. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">23. C_Study::eStatusMarket m_Status;
class="num">24. class="type">int m_posBuff = class="num">0;
class="num">25. class="type">class="kw">double m_Buff[];
class="num">26. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">27. class="type">int OnInit()
class="num">28. {
class="num">29.    ResetLastError();
class="num">30.    Study = new C_Study(class="num">0, "Indicator Mouse Study", user02, user03, user04);
class="num">31.    if (_LastError != ERR_SUCCESS) class="kw">return INIT_FAILED;
class="num">32.    if ((*Study).GetInfoTerminal().szSymbol != def_SymbolReplay)
class="num">33.    {
class="num">34.       MarketBookAdd((*Study).GetInfoTerminal().szSymbol);
class="num">35.       OnBookEvent((*Study).GetInfoTerminal().szSymbol);
class="num">36.       m_Status = C_Study::eCloseMarket;
class="num">37.    }else
class="num">38.       m_Status = C_Study::eInReplay;
class="num">39.    SetIndexBuffer(class="num">0, m_Buff, INDICATOR_DATA);
class="num">40.    ArrayInitialize(m_Buff, EMPTY_VALUE);
class="num">41.

鼠标浮窗里的实时涨跌百分比怎么算

指标在主图上的交互浮窗靠 OnCalculate 驱动。第50行把最新收盘价塞进全局变量 GL_PriceClose,第51行记录当前 K 线总数到 m_posBuff,再调用 (*Study).Update(m_Status) 刷新内部状态,最后 return rates_total 让 MT5 每根 tick 重算。 真正画出鼠标跟随标签的是 Draw 函数。当 m_Info.bvD 为真时,第53行用 NormalizeDouble 把「鼠标价相对收盘价的偏离」压缩到两位小数:((鼠标价 - 收盘价) / 收盘价) * 100.0,得出一个带符号的百分比 v1。 第55行按正负切换背景色——v1<0 走 corN(跌色),否则走 corP(涨色);第56行用 StringFormat("%.2f%%", MathAbs(v1)) 只显示绝对值,配合颜色区分方向。外汇与贵金属波动快,这种浮窗读数仅作即时参考,实际下单仍受点差与滑点影响,高风险属性不变。 想验证就开 MT5 自建指标,把 Draw 里第53行的 m_Info.Rate.close 换成 close[rates_total-1],鼠标移到 EURUSD 图表上,浮窗数字会随光标价格实时跳变,偏差超过 0.50% 时颜色翻转最直观。

MQL5 / C++
GL_PriceClose = close[rates_total - class="num">1];
m_posBuff = rates_total;
(*Study).Update(m_Status);
class="kw">return rates_total;

class="type">void Draw(class="type">void)
{
   class="type">class="kw">double v1;
   if (m_Info.bvT)
   {
      ObjectSetInteger(GetInfoTerminal().ID, m_Info.szBtn1, OBJPROP_YDISTANCE, GetInfoMouse().Position.Y_Adjusted - class="num">18);
      ObjectSetString(GetInfoTerminal().ID, m_Info.szBtn1, OBJPROP_TEXT, m_Info.szInfo);
   }
   if (m_Info.bvD)
   {
      v1 = NormalizeDouble((((GetInfoMouse().Position.Price - m_Info.Rate.close) / m_Info.Rate.close) * class="num">100.0), class="num">2);
      ObjectSetInteger(GetInfoTerminal().ID, m_Info.szBtn2, OBJPROP_YDISTANCE, GetInfoMouse().Position.Y_Adjusted - class="num">1);
      ObjectSetInteger(GetInfoTerminal().ID, m_Info.szBtn2, OBJPROP_BGCOLOR, (v1 < class="num">0 ? m_Info.corN : m_Info.corP));
      ObjectSetString(GetInfoTerminal().ID, m_Info.szBtn2, OBJPROP_TEXT, StringFormat("%.2f%%", MathAbs(v1)));
   }
   if (m_Info.bvP)
   {

◍ 把当日涨跌百分比塞进按钮标签

这段逻辑干的事很直接:拿日线收盘价和当前实时收盘价比一比,算出自今天开盘到现在的涨跌幅百分比,再保留两位小数丢进界面按钮里。 第60行先用 NormalizeDouble 把 ((GL_PriceClose - m_Info.Rate.close) / m_Info.Rate.close) * 100.0 压成两位小数的浮点值 v1,也就是百分比形式的偏离。第61行是被划掉的旧写法,改用 iClose 取 PERIOD_D1 的0号柱(当日日线收盘)来替代 GL_PriceClose,思路一致但数据源不同,实盘里用哪个要看你终端里 GL_PriceClose 更不可靠还是日线函数更慢。 第62到64行是界面联动:按钮 szBtn3 的纵向距离跟着鼠标修正坐标走,背景色按 v1 正负切换(负用 corN 色、正用 corP 色),文本用 StringFormat("%.2f%%", MathAbs(v1)) 只显示绝对值百分比。外汇与贵金属波动受杠杆放大,这类实时偏离指标仅作概率参考,不代表方向确定性。 想验证就开 MT5 把这几行贴进 EA 的鼠标事件回调,把 corN/corP 设成红绿看盘色,挂 EURUSD 的 D1 图表,拖动鼠标就能看到按钮数字随报价跳。

MQL5 / C++
class="num">60.    v1 = NormalizeDouble((((GL_PriceClose - m_Info.Rate.close) / m_Info.Rate.close) * class="num">100.0), class="num">2);
class="num">61.    v1 = NormalizeDouble((((iClose(GetInfoTerminal().szSymbol, PERIOD_D1, class="num">0) - m_Info.Rate.close) / m_Info.Rate.close) * class="num">100.0), class="num">2);
class="num">62.    ObjectSetInteger(GetInfoTerminal().ID, m_Info.szBtn3, OBJPROP_YDISTANCE, GetInfoMouse().Position.Y_Adjusted - class="num">1);
class="num">63.    ObjectSetInteger(GetInfoTerminal().ID, m_Info.szBtn3, OBJPROP_BGCOLOR, (v1 < class="num">0 ? m_Info.corN : m_Info.corP));
class="num">64.    ObjectSetString(GetInfoTerminal().ID, m_Info.szBtn3, OBJPROP_TEXT, StringFormat("%.2f%%", MathAbs(v1)));
class="num">65.  }
class="num">66.   }
class="num">67. class=class="str">"cmt">//+------------------------------------------------------------------+

「把工具请下神坛」

这一轮改造里,应用行为已经和依赖全局终端变量时高度一致,回放/模拟下的基本运作如演示视频所示跑通了。单纯用 MQL5 不挂任何外部资源,就能把市场模拟的骨架搭起来,这点可能让不少只看过指标脚本的人意外。 剩下没解决的只有一件事:重放模式里怎么实时拿到当前柱的剩余时间。下一篇开头会补上这块,同时顺手把系统里几处还不顺滑的地方一起改掉。 对外汇和贵金属交易者来说,这类自建模拟工具能帮你低成本验证策略,但回测和模拟永远不等于实盘,杠杆市场高风险,真金白银前先在 MT5 里把每一行逻辑跑熟。

把服务状态交给小布盯盘
这些诊断小布盯盘的 AIGC 已内置,打开对应品种页即可看到回放服务的事件延迟与指标同步状态,你只管调逻辑。

常见问题

因为控制指标靠消息读写,LoopEventOnTime 内部逻辑依赖第 48 行注入的通道,改动在外层但作用在内循环。
可以,小布盯盘的品种页会显示服务事件与指标刷新间隔,便于你定位同步漂移。
保留旧快进逻辑,但把状态读取从全局变量改为服务消息,并在控制指标初始化处补一行通道声明。
合成历史下最后一笔报价可能不触发百分比回满,需要显式置位,否则进度条卡在 99%。
内存崩溃源于旧通信模型,本篇小坑是迁移后的遗留接口错位,关于完整迁移路径见《开发回放系统·实战篇》。