开发回放系统(第 50 部分):事情变得复杂 (二)(基础篇)
📘

开发回放系统(第 50 部分):事情变得复杂 (二)(基础篇)

第 1/3 篇

◍ 回放系统迭代里的复杂拐点

这一版回放系统进入第 50 次迭代的第二阶段,工程复杂度明显抬升。原文作者 Daniel Jose 在 2025 年 1 月 9 日 12:12 发布的示例里提到,截至该时点文章交互量为 573 次查看、1 条评论,说明这类底层回放框架的受众面偏窄但仍在推进。 复杂点主要来自历史 tick 重建与事件时序对齐:当回放速度可调、且要兼容多品种同步时,单纯按时间戳重排会丢失挂单触发逻辑。MT5 内置的 HistorySelect 若只按起止时间拉取,遇到非交易时段的数据空洞会让回放出现跳变。 对做价格行为复盘的 trader 来说,这意味着你自己在 MT5 里写回放脚本时,不能只依赖常规历史获取接口,要额外处理会话边界。外汇与贵金属品种在高杠杆下波动剧烈,回放验证策略时须认清这是高风险环境,历史重构的偏差可能放大实盘误判。

「回放系统的模块化保护逻辑」

上一阶段把回放/模拟器往完全模块化方向推,本意不是堆复杂度,而是把只有回放服务才用到的图表部件隔离出来。这样能挡住经验不足的用户在服务运行时误删或错配关键图表段,避免意外停服。 之前文末暴露过控制指示器抖动的问题:不致命、不会让 MT5 崩,但偶发会让回放服务掉线。正因没稳住,那篇没挂可用文件,否则你拿到的是半成品。 这一节先盯住这类故障里至少一条做修复尝试,具体改动你在 MT5 里加载后对比指示器日志就能验。外汇与贵金属回放测试本身属高风险验证,参数错配可能引出假信号。

回放图表ID错位的根因与传参修复

回放/模拟器早期用模板承载图表与服务的全部配置,用户一旦套用就再也切不回自己磨了几个月的分析模板。老手通常攒了一套按品种、按时段预设的模板,手动重设成本极高,所以让服务独立于模板去挂必要图形对象才是正路。 麻烦出在图表 ID 上。服务里用 C_Replay 类第 183 行调 ChartOpen 开回放图,返回的是另一个 ID;而控制指标里直接调 ChartID() 拿到的是主图 ID。两者不一致时,对象可能被塞进错误甚至不存在的窗口。 之前我们把 ChartOpen 生成的 ID 通过全局终端变量传给指标,后来在系列里删掉了这块,改由 C_Terminal 类自己用 ChartID() 取——结果在服务拉起指标的场景下,取到的值必然是错的。 解法不是回头用全局变量,而是把 ID 作为指标调用参数直接塞进 C_Terminal 构造函数。代码第 62 行做了判断:传了非 0 的 id 就忽略 ChartID(),用传入值;不传才 fallback 到 ChartID()。这样服务对象归属就不会乱。 外汇与贵金属回测环境本身高风险,ID 错位不会爆仓但会让图形控制完全失效,开 MT5 跑一遍回放服务即可验证对象是否落对窗口。

MQL5 / C++
class="num">59. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">60.                 C_Terminal(class="kw">const class="type">long id = class="num">0)
class="num">61.                   {
class="num">62.                     m_Infos.ID = (id == class="num">0 ? ChartID() : id);
class="num">63.                     m_Mem.AccountLock = class="kw">false;
class="num">64.                     CurrentSymbol();
class="num">65.                     m_Mem.Show_Descr = ChartGetInteger(m_Infos.ID, CHART_SHOW_OBJECT_DESCR);
class="num">66.                     m_Mem.Show_Date  = ChartGetInteger(m_Infos.ID, CHART_SHOW_DATE_SCALE);
class="num">67.                     ChartSetInteger(m_Infos.ID, CHART_SHOW_OBJECT_DESCR, class="kw">false);
class="num">68.                     ChartSetInteger(m_Infos.ID, CHART_EVENT_OBJECT_DELETE, class="num">0, true);
class="num">69.                     ChartSetInteger(m_Infos.ID, CHART_EVENT_OBJECT_CREATE, class="num">0, true);
class="num">70.                     ChartSetInteger(m_Infos.ID, CHART_SHOW_DATE_SCALE, class="kw">false);
class="num">71.                     m_Infos.nDigits = (class="type">int) SymbolInfoInteger(m_Infos.szSymbol, SYMBOL_DIGITS);
class="num">72.                     m_Infos.Width   = (class="type">int)ChartGetInteger(m_Infos.ID, CHART_WIDTH_IN_PIXELS);

◍ 从图表与品种接口抓取交易基准参数

写自定义面板或回放引擎时,第一件事是把当前图表的物理尺寸和交易品种的合约细节一次性读进结构体,否则后面算坐标和手数都会偏。下面这段初始化直接从 ChartGetInteger 拿图表像素高度,再用 SymbolInfoDouble 取 tick 尺寸、tick 价值和最小交易量步长。 注意第 77 行 AdjustToTrade 用 ValuePerPoint 除以 PointPerTick,得到每点金额对 tick 的换算系数;外汇与贵金属杠杆高、点值随品种变动,这个系数错了,面板显示的盈亏可能差出数倍。 第 79 行判断如果当前品种不是回放专用符号,才去读账户保证金模式——回放环境没有真实账户上下文,强行调 AccountInfoInteger 会返回 0 并写错误码,所以先用 ResetLastError 清掉前文可能的残留。开 MT5 把这段塞进你的 CStruct 初始化函数,切 EURUSD 和 XAUUSD 各跑一次,对比 AdjustToTrade 数值就能验证抓取逻辑。

MQL5 / C++
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));
ResetLastError();

「把控制指标钉死在回放图表上」

早先视频里能看到一个怪现象:指标列在导航器里,却不在图表上显示,终端还弹错误。根因在 ChartID 调用拿不到正确图表 ID,指标代码第 25 行做了错误检查并拦截。把服务创建的图表 ID 作为参数传给指标后,它才肯正常绘制——但别以为这就彻底修好了。 修改落在 C_Replay.mqh 而不是服务主源码。第 116 行新增一个过程,专门把控制指标挂到图表;该过程在第 193 行、图表被服务打开且 MT5 显示后立刻调用。第 120 行先用 HandleCreate 建一个句柄引用内嵌指标资源,第 121 行把 m_IdReplay(ChartOpen 返回的图表 ID)强制绑过去,保证你手开别的回放品种图表也不会误带这个指标;第 122 行释放句柄。 控制指标侧也要配合:第 16 行加了一个 input 参数接收图表 ID,默认 NULL,不填就第 27 行报错。第 23 行告诉 C_Terminal 类别用自动生成的 ID,改用服务报上来的那个。重新编译跑起来后交互却变得很诡异,那是下一个要啃的骨头——鼠标跟图表对象失去了正常响应。 先顺手做用户自定义模板。服务代码第 18 行让用户指定模板名,第 25 行传给类;C_Replay.mqh 第 176 行接参数,第 200 行应用,失败则第 201 行提示并回退默认模板。若你跑多品种回测,把模板名写进建模配置文件能省去每次启动手填。 模板一切换,MT5 会发 DeInit 事件把控制指标冲掉,用户又没法手动加回。指标第 69 行检查删除原因,若是模板变更,第 70 行打印消息并让服务终止——比允许乱改更干净。第 71 行防用户擅自改服务传进来的参数,若被改,第 76 行直接关掉回放图表,服务检测图表不在就自行退出。外汇与贵金属仿真回放虽无真实资金风险,但配置错乱会让历史重放结论完全失真,验证前请确认模板与参数未被手动动过。

MQL5 / C++
<span class="number">class="num">001</span>. <span class="comment">class=class="str">"cmt">//+------------------------------------------------------------------+</span>
<span class="number">class="num">002</span>. <span class="preprocessor">class="macro">#class="kw">property </span><span class="macro">copyright</span> <span class="class="type">class="kw">string">"Daniel Jose"</span>
<span class="number">class="num">003</span>. <span class="comment">class=class="str">"cmt">//+------------------------------------------------------------------+</span>
<span class="number">class="num">004</span>. <span class="preprocessor">class="macro">#include </span><span class="class="type">class="kw">string">"C_ConfigService.mqh"</span>
<span class="number">class="num">005</span>. <span class="comment">class=class="str">"cmt">//+------------------------------------------------------------------+</span>
<span class="number">class="num">006</span>. <span class="keyword">class</span> C_Replay : <span class="keyword">class="kw">private</span> C_ConfigService
<span class="number">class="num">007</span>. {
<span class="number">class="num">008</span>.&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="kw">private</span> :
<span class="number">class="num">009</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="type">long</span>&nbsp;&nbsp;&nbsp;&nbsp;m_IdReplay;
<span class="number">class="num">010</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="kw">struct</span> st01
<span class="number">class="num">011</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{
<span class="number">class="num">012</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="predefines">class="type">MqlRates</span> Rate[<span class="number">class="num">1</span>];
<span class="number">class="num">013</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="type">class="kw">datetime</span> memDT;
<span class="number">class="num">014</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;}m_MountBar;
<span class="number">class="num">015</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="keyword">class="kw">struct</span> st02
<span class="number">class="num">016</span>.&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{

回放游标与K线重建的联动逻辑

这段结构把回放进度和图表上的 K 线生成绑在了一起。核心是一个滑块位置 iPosShift,它经比例换算成 tick 数组里的绝对索引 iPos,再去驱动 CreateBarInReplay 补出当前帧的柱。 第 028 行先读全局变量 def_GlobalVariableReplay 拿到进程间传来的游标值;第 029 行做越界保护,若滑块位置换算后等于总 tick 数对应的末端就直接 return,避免溢出。 第 030 行把滑块值域 (0~def_MaxPosSlider) 映射到实际 tick 下标:iPos = (int)(m_Ticks.nTicks * iPosShift / (def_MaxPosSlider + 1)),这是典型的「UI 值域 → 数据索引」归一化。第 031 行用 macroRemoveSec 把 tick 时间截到分钟级赋给 Rate[0].time,作为回放 K 线的起点时间戳。 若 bViewBuider 为 true(第 033 行),只置 isWait 标志并回写全局变量,相当于暂停等待外部构建;否则第 039 行用空循环把 m_ReplayCount 推到比当前帧时间更晚的位置,实现自动追帧。外汇与贵金属回测属高风险场景,该映射比例若 def_MaxPosSlider 设错,回放可能卡在中间段不再前进。

MQL5 / C++
class="num">017.    class="type">bool   bInit;
class="num">018.    class="type">class="kw">double PointsPerTick;
class="num">019.    class="type">MqlTick tick[class="num">1];
class="num">020.    }m_Infos;
class="num">021. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">022.    class="type">void AdjustPositionToReplay(class="kw">const class="type">bool bViewBuider)
class="num">023.        {
class="num">024.            u_Interprocess Info;
class="num">025.            class="type">MqlRates        Rate[def_BarsDiary];
class="num">026.            class="type">int             iPos, nCount;
class="num">027. 
class="num">028.            Info.df_Value = GlobalVariableGet(def_GlobalVariableReplay);
class="num">029.            if (Info.s_Infos.iPosShift == (class="type">int)((m_ReplayCount * def_MaxPosSlider * class="num">1.0) / m_Ticks.nTicks)) class="kw">return;
class="num">030.            iPos = (class="type">int)(m_Ticks.nTicks * ((Info.s_Infos.iPosShift * class="num">1.0) / (def_MaxPosSlider + class="num">1)));
class="num">031.            Rate[class="num">0].time = macroRemoveSec(m_Ticks.Info[iPos].time);
class="num">032.            CreateBarInReplay(true);
class="num">033.            if (bViewBuider)
class="num">034.            {
class="num">035.                Info.s_Infos.isWait = true;
class="num">036.                GlobalVariableSet(def_GlobalVariableReplay, Info.df_Value);
class="num">037.            }else
class="num">038.            {
class="num">039.            for(; Rate[class="num">0].time > (m_Ticks.Info[m_ReplayCount].time); m_ReplayCount++);

常见问题

多是模块化保护逻辑没补齐,回放状态在切换时被外部消息打断。先给核心模块加互斥锁,再逐步放开接口调用。
根因是图表ID在传参时丢失或被复用。把创建回放图表时拿到的ID显式透传到底层绘制函数,别用全局变量隐式取。
可以。把回放日志贴给小布,它能指出ID传参断点和品种接口取数遗漏,并给出修复顺序建议。
游标变动先置脏标记,下一帧统一重建而非即时重绘。限制单次重建根数,避免主线程阻塞。
创建指标时指定回放图表ID与回放品种名,并在回放复位时重绑。别依赖当前激活图表上下文。