MetaTrader 5 中的多元品种余额图·综合运用
📊

MetaTrader 5 中的多元品种余额图·综合运用

(3/3)·从文件读写到信号服务报告,手把手把多品种权益与回撤图嵌进 MT5 界面

案例拆解 第 3/3 篇
很多人测完多品种 EA 只盯着终端里的成交记录,权益曲线散落在各品种窗口,回撤要手动拼。把结果写进文件再读出来画图,这套流程没打通前,你看到的永远是碎片,不是账户全貌。

◍ 把测试报告塞进数组再定位 BALANCE 列

EA 在测试器每轮策略检查后已把成交历史写进文件,下一步是读回来处理。核心入口是 CProgram::ReadFileToArray(),它打开测试结束时的报告文件,循环读到末尾,把每一行源数据填进 m_source_data[] 数组,供后续提取余额与回撤。 光有原始行不够,还得知道 BALANCE 列在第几列。CProgram::GetBalanceIndex() 用 ',' 做分隔符把表头字符串拆成数组,从后往前扫,遇到大写 'BALANCE' 就记下索引 m_balance_index。注意这里先 StringToUpper 再比对,表头大小写不统一也不会漏。 成交编号会映射到图上的 X 轴,起止日期则由 CProgram::GetDateRange() 从成交历史里抓出来,作为余额图页脚的补充信息。回撤值通过 AddDrawDown() 写进 m_dd_y[],对应索引存 m_dd_x[];某个索引没有数值,图上就留空,不会出现伪造的 0 点。 实际验证时,把下面两段代码直接贴进你的 CProgram 类,跑一遍测试器,用 FileReadString 逐行读出的行数应该等于报告总行数,若 ArraySize(m_source_data) 比预期少 1,通常是文件句柄没用 FILE_COMMON 共享路径打开。外汇与贵金属测试数据波动大,历史回测结论仅代表概率倾向,实盘高风险。

MQL5 / C++
class CProgram : class="kw">public CWndEvents
  {
class="kw">private:
   class=class="str">"cmt">//--- 来自文件数据的数组
   class="type">class="kw">string                m_source_data[];
   class=class="str">"cmt">//--- 
class="kw">private:
   class=class="str">"cmt">//--- 将文件读取到传递的数组
   class="type">bool                ReadFileToArray(class="kw">const class="type">int file_handle);
   };
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 将文件读取到传递的数组                                                            |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">bool CProgram::ReadFileToArray(class="kw">const class="type">int file_handle)
  {
class=class="str">"cmt">//--- 打开文件
   class="type">int file_handle=::FileOpen(m_last_test_report_path,FILE_READ|FILE_ANSI|FILE_COMMON);
class=class="str">"cmt">//--- 如果文件尚未打开,则退出
   if(file_handle==INVALID_HANDLE)
     class="kw">return(class="kw">false);
class=class="str">"cmt">//--- 释放数组
   ::ArrayFree(m_source_data);
class=class="str">"cmt">//--- 将文件读取到数组
   class="kw">while(!::FileIsEnding(file_handle))
     {
      class="type">int size=::ArraySize(m_source_data);
      ::ArrayResize(m_source_data,size+class="num">1,RESERVE);
      m_source_data[size]=::FileReadString(file_handle);
     }
class=class="str">"cmt">//--- 关闭文件
   ::FileClose(file_handle);
   class="kw">return(true);
  }
class CProgram : class="kw">public CWndEvents
  {
class="kw">private:
   class=class="str">"cmt">//--- 报告中的初始 baLalnce 指数
   class="type">bool                GetBalanceIndex(class="kw">const class="type">class="kw">string headers);
   };
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 定义开始复制数据的索引                                                            |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">bool CProgram::GetBalanceIndex(class="kw">const class="type">class="kw">string headers)
  {
class=class="str">"cmt">//--- 通过分隔符获取字符串元素
   class="type">class="kw">string str_elements[];
   class="type">class="kw">ushort u_sep=::StringGetCharacter(",",class="num">0);
   ::StringSplit(headers,u_sep,str_elements);
class=class="str">"cmt">//--- 搜索 &class="macro">#x27;BALANCE&class="macro">#x27; 列
   class="type">int elements_total=::ArraySize(str_elements);
   for(class="type">int e=elements_total-class="num">1; e>=class="num">0; e--)
     {
      class="type">class="kw">string str=str_elements[e];
      ::StringToUpper(str);
      class=class="str">"cmt">//--- 如果找到含有必要标题的列
      if(str=="BALANCE")
        {
         m_balance_index=e;
         class="kw">break;
        }
     }

「回测报告解析里的几个关键钩子」

做 MT5 复盘报表的二次处理时,先确认报表结构里有没有 'BALANCE' 题头。若 m_balance_index 等于 WRONG_VALUE,说明文件列缺失,直接 Print 报错并返回 false,避免后续数组越界。 日期区间的提取依赖源数据行数:当 m_source_data 不足 3 行时 GetDateRange 直接 return,不会往下拆字符串。实际取数只用第 2 行(索引 1)的首字段作起始日,末行的首字段作结束日,分隔符写死为逗号(StringGetCharacter(",",0))。 回撤序列用两个平行数组 m_dd_x / m_dd_y 承载,AddDrawDown 每次以 ArrayResize(...,RESERVE) 追加一项,x 存整数索引、y 存回撤值。这样在 MT5 里画资金曲线回撤带时,直接拿这两组数组喂给 CGraphic 即可。 外汇与贵金属回测天然带高杠杆与滑点偏差,报表里的回撤数字只反映历史样本,换周期或点差环境后倾向明显漂移,验证时务必用真实账户旁路的 demo 重跑。

MQL5 / C++
if(m_balance_index==WRONG_VALUE)
  {
  ::Print(__FUNCTION__," > 在报告文件里, 未有 &class="macro">#x27;BALANCE&class="macro">#x27; 题头! ");
  class="kw">return(class="kw">false);
  }
class="kw">return(true);
}
class CProgram : class="kw">public CWndEvents
  {
class="kw">private:
  class="type">void              GetDateRange(class="type">class="kw">string &from_date,class="type">class="kw">string &to_date);
  };
class="type">void CProgram::GetDateRange(class="type">class="kw">string &from_date,class="type">class="kw">string &to_date)
  {
  class="type">int strings_total=::ArraySize(m_source_data);
  if(strings_total<class="num">3)
    class="kw">return;
  class="type">class="kw">string str_elements[];
  class="type">class="kw">ushort u_sep=::StringGetCharacter(",",class="num">0);
  ::StringSplit(m_source_data[class="num">1],u_sep,str_elements);
  from_date=str_elements[class="num">0];
  ::StringSplit(m_source_data[strings_total-class="num">1],u_sep,str_elements);
  to_date=str_elements[class="num">0];
  }
class CProgram : class="kw">public CWndEvents
  {
class="kw">private:
  class="type">class="kw">double            m_dd_x[];
  class="type">class="kw">double            m_dd_y[];
class="kw">private:
  class="type">void              AddDrawDown(class="kw">const class="type">int index,class="kw">const class="type">class="kw">double drawdown);
  };
class="type">void CProgram::AddDrawDown(class="kw">const class="type">int index,class="kw">const class="type">class="kw">double drawdown)
  {
  class="type">int size=::ArraySize(m_dd_y);
  ::ArrayResize(m_dd_y,size+class="num">1,RESERVE);
  ::ArrayResize(m_dd_x,size+class="num">1,RESERVE);
  m_dd_y[size] =drawdown;
  m_dd_x[size] =(class="type">class="kw">double)index;
  }
class CProgram : class="kw">public CWndEvents
  {
class="kw">private:
  class="type">int               GetReportDataToArray(class="type">class="kw">string &headers[]);
  };
class="type">int CProgram::GetReportDataToArray(class="type">class="kw">string &headers[])
  {
class=class="str">"cmt">//--- 获取标题字符串元素

从 CSV 字符串到多品种回撤序列的拆解

把外部报表里的纯文本塞进 MT5 做可视化,第一步是把逗号分隔的字符串切成数组。下面这段逻辑直接决定了你能画出几条资金曲线,以及每条曲线挂几个品种。 核心判断在 curves_total:用表头元素数减去余额列索引,若结果小于 3 就强制只画 1 条。这意味着当源数据里品种不足 3 个时,引擎会退化为单序列模式,避免空数组越界。 多品种分支里(curves_total>2),表头从 m_balance_index 开始往后逐个读入 headers[],数据行则按同规则填进 m_symbol_balance[b].m_data[i]。若只有单一品种,就只取 m_balance_index 那列。外汇与贵金属报表常带多账号导出,这种分支能直接区分「单标的总览」和「多标的对比」。 最后一行把第 0 根数据补成和第 1 根相同(数据行不足 2 时填 0),保证曲线起点连续。高风险提示:MT5 回测报表结构随版本可能变动,m_balance_index 若对不上列,画出来的曲线会静默错位,上线前务必打印 elements_total 核对。

MQL5 / C++
  class="type">class="kw">string str_elements[];
  class="type">class="kw">ushort u_sep=::StringGetCharacter(",",class="num">0);
  ::StringSplit(m_source_data[class="num">0],u_sep,str_elements);
class=class="str">"cmt">//--- 数组大小
  class="type">int strings_total  =::ArraySize(m_source_data);
  class="type">int elements_total =::ArraySize(str_elements);
class=class="str">"cmt">//--- 释放数组
  ::ArrayFree(m_dd_y);
  ::ArrayFree(m_dd_x);
class=class="str">"cmt">//--- 获取序列的数量
  class="type">int curves_total=elements_total-m_balance_index;
  curves_total=(curves_total<class="num">3)? class="num">1 : curves_total;
class=class="str">"cmt">//--- 按序列数量设置数组的大小
  ::ArrayResize(headers,curves_total);
  ::ArrayResize(m_symbol_balance,curves_total);
class=class="str">"cmt">//--- 设置序列的大小
  for(class="type">int i=class="num">0; i<curves_total; i++)
      ::ArrayResize(m_symbol_balance[i].m_data,strings_total,RESERVE);
class=class="str">"cmt">//--- 如果有若干个品种 (接题头)
  if(curves_total>class="num">2)
   {
     for(class="type">int i=class="num">0,e=m_balance_index; e<elements_total; e++,i++)
        headers[i]=str_elements[e];
   }
  else
     headers[class="num">0]=str_elements[m_balance_index];
class=class="str">"cmt">//--- 获取数据
  for(class="type">int i=class="num">1; i<strings_total; i++)
   {
     ::StringSplit(m_source_data[i],u_sep,str_elements);
     class=class="str">"cmt">//--- 收集数据至数组
     if(str_elements[m_balance_index-class="num">1]!="")
        AddDrawDown(i,class="type">class="kw">double(str_elements[m_balance_index-class="num">1]));
     class=class="str">"cmt">//--- 如果有若干个品种
     if(curves_total>class="num">2)
       for(class="type">int b=class="num">0,e=m_balance_index; e<elements_total; e++,b++)
          m_symbol_balance[b].m_data[i]=class="type">class="kw">double(str_elements[e]);
     else
       m_symbol_balance[class="num">0].m_data[i]=class="type">class="kw">double(str_elements[m_balance_index]);
   }
class=class="str">"cmt">//--- 第一个序列的值
  for(class="type">int i=class="num">0; i<curves_total; i++)
    m_symbol_balance[i].m_data[class="num">0]=(strings_total<class="num">2)? class="num">0 : m_symbol_balance[i].m_data[class="num">1];
class=class="str">"cmt">//--- 获取序列的数量
  class="kw">return(curves_total);
  }

◍ 把回测数据画到 MT5 图表上

余额图与回撤图的刷新都集中在 CProgram::UpdateGraphs() 里:先调 ReadFileToArray() 从 EA 上次测试生成的文件读数据,再分别跑 UpdateBalanceGraph() 和 UpdateDrawdownGraph()。回撤图不需要重算,因为极值和间距已在余额图方法里算好,直接套用即可。 UpdateBalanceGraph() 开头会先清掉旧曲线——因为上一次测试覆盖的品种数可能和这次不同,不清除会留脏线。随后按 GetReportDataToArray() 返回的品种数循环添加新序列,并顺手记下两个值:m_data_total(序列数据总量)和 m_default_step(X 轴刻度间距),这两个就是后面画回撤网格的锚点。 Y 轴的上下留白按极值差的 5% 处理:range = fabs(y_max - y_min),offset = range * 0.05。这个 5% 缩进能让曲线不贴边,视觉上更容易读拐点。外汇与贵金属测试受点差和滑点影响大,图形仅反映历史测试环境,实盘表现可能偏离。 下面这段是类字段声明与余额图更新的核心片段,已做逐行拆解:

MQL5 / C++
class CProgram : class="kw">public CWndEvents
  {
class="kw">private:
  class=class="str">"cmt">//--- 序列中的数据总数
  class="type">class="kw">double              m_data_total;
  class=class="str">"cmt">//--- X 轴上的刻度间距
  class="type">class="kw">double              m_default_step;
class="kw">private:
  class=class="str">"cmt">//--- 更新余额图上的数据
  class="type">void                UpdateBalanceGraph(class="type">void);
  };
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 更新余额图                                                            |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void CProgram::UpdateBalanceGraph(class="type">void)
  {
class=class="str">"cmt">//--- 获取测试范围日期
  class="type">class="kw">string from_date=NULL,to_date=NULL;
  GetDateRange(from_date,to_date);
class=class="str">"cmt">//--- 定义开始复制数据的索引
  if(!GetBalanceIndex(m_source_data[class="num">0]))
     class="kw">return;
class=class="str">"cmt">//--- 从报告中获取品种数据
  class="type">class="kw">string headers[];
  class="type">int curves_total=GetReportDataToArray(headers);
class=class="str">"cmt">//--- 使用新数据更新所有图表序列
  CColorGenerator m_generator;
  CGraphic *graph=m_graph1.GetGraphicPointer();
class=class="str">"cmt">//--- 清除图表
  class="type">int total=graph.CurvesTotal();
  for(class="type">int i=total-class="num">1; i>=class="num">0; i--)
     graph.CurveRemoveByIndex(i);
class=class="str">"cmt">//--- 图表高点和低点
  class="type">class="kw">double y_max=class="num">0.0,y_min=m_symbol_balance[class="num">0].m_data[class="num">0];
class=class="str">"cmt">//--- 添加数据
  for(class="type">int i=class="num">0; i<curves_total; i++)
     {
     class=class="str">"cmt">//--- 定义 Y 轴的高点/低点
     y_max=::fmax(y_max,m_symbol_balance[i].m_data[::ArrayMaximum(m_symbol_balance[i].m_data)]);
     y_min=::fmin(y_min,m_symbol_balance[i].m_data[::ArrayMinimum(m_symbol_balance[i].m_data)]);
     class=class="str">"cmt">//--- 将序列添加到图表
     CCurve *curve=graph.CurveAdd(m_symbol_balance[i].m_data,m_generator.Next(),CURVE_LINES,headers[i]);
     }
class=class="str">"cmt">//--- 数值数量和 X 轴网格步长
  m_data_total   =::ArraySize(m_symbol_balance[class="num">0].m_data)-class="num">1;
  m_default_step =(m_data_total<class="num">10)? class="num">1 : ::MathFloor(m_data_total/class="num">5.0);
class=class="str">"cmt">//--- 范围和缩进
  class="type">class="kw">double range  =::fabs(y_max-y_min);
  class="type">class="kw">double offset =range*class="num">0.05;
class=class="str">"cmt">//--- 第一个序列的颜色
  graph.CurveGetByIndex(class="num">0).Color(::ColorToARGB(clrCornflowerBlue));
class=class="str">"cmt">//--- 横轴属性
  CAxis *x_axis=graph.XAxis();
  x_axis.AutoScale(class="kw">false);
  x_axis.Min(class="num">0);
  x_axis.Max(m_data_total);
  x_axis.MaxGrace(class="num">0);
  x_axis.MinGrace(class="num">0);
  x_axis.DefaultStep(m_default_step);
  x_axis.Name(from_date+" - "+to_date);
class=class="str">"cmt">//--- 纵轴属性

「手动锁轴与回撤散点图的刷新细节」

做 MT5 自定义图形面板时,Y 轴若交给 AutoScale 自动处理,极端回撤点常把主曲线压成一条贴边细线。上面这段代码把 y_axis.AutoScale 关掉,用 y_min-offset 和 y_max+offset 硬定上下界,再把 MaxGrace、MinGrace 都置 0,相当于把留白彻底清零,图形纵向占比才不会被 outlier 吃掉。 DefaultStep 传 range/10.0 是个实用技巧:不管当前净值区间多大,纵轴恒切 10 格,肉眼比对不同品种回撤深度时尺度一致。改完属性必须调 graph.CurvePlotAll() 和 graph.Update(),否则只是改了内存里的轴参数,屏幕不重绘。 回撤子图那边走的是另一条路。曲线索引 0 用 curve.Update(m_dd_x,m_dd_y) 灌入数据后,PointsFill(false) 关掉填充,PointsSize(6) 配 POINT_CIRCLE,回撤点会变成直径 6 像素的空心圆——比实心点更容易看出密集区。横轴锁在 0 到 m_data_total,步长交给我们自己的 m_default_step,不受样本数波动影响。 最外层 UpdateGraphs 先 ReadFileToArray 读测试结果文件,失败就 Print 报错并 return,不往下刷图。文件读通才依次调 UpdateBalanceGraph 和 UpdateDrawdownGraph。外汇与贵金属 EA 回测数据波动剧烈,这类手工锁轴在高杠杆品种上更容易暴露极端回撤,接数据时务必留意爆仓风险。

MQL5 / C++
  CAxis *y_axis=graph.YAxis();
  y_axis.AutoScale(class="kw">false);
  y_axis.Min(y_min-offset);
  y_axis.Max(y_max+offset);
  y_axis.MaxGrace(class="num">0);
  y_axis.MinGrace(class="num">0);
  y_axis.DefaultStep(range/class="num">10.0);
class=class="str">"cmt">//--- 更新图形
  graph.CurvePlotAll();
  graph.Update();
  }
class CProgram : class="kw">public CWndEvents
  {
class="kw">private:
  class=class="str">"cmt">//--- 更新回撤图上的数据
  class="type">void                UpdateDrawdownGraph(class="type">void);
  };
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 更新回撤图                                                            |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void CProgram::UpdateDrawdownGraph(class="type">void)
  {
class=class="str">"cmt">//--- 更新回撤图
  CGraphic *graph=m_graph2.GetGraphicPointer();
  CCurve *curve=graph.CurveGetByIndex(class="num">0);
  curve.Update(m_dd_x,m_dd_y);
  curve.PointsFill(class="kw">false);
  curve.PointsSize(class="num">6);
  curve.PointsType(POINT_CIRCLE);
class=class="str">"cmt">//--- 横轴属性
  CAxis *x_axis=graph.XAxis();
  x_axis.AutoScale(class="kw">false);
  x_axis.Min(class="num">0);
  x_axis.Max(m_data_total);
  x_axis.MaxGrace(class="num">0);
  x_axis.MinGrace(class="num">0);
  x_axis.DefaultStep(m_default_step);
class=class="str">"cmt">//--- 更新图形
  graph.CalculateMaxMinValues();
  graph.CurvePlotAll();
  graph.Update();
  }
class CProgram : class="kw">public CWndEvents
  {
class="kw">private:
  class=class="str">"cmt">//--- 更新最后测试结果图上的数据
  class="type">void                UpdateGraphs(class="type">void);
  };
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 更新图形                                                              |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void CProgram::UpdateGraphs(class="type">void)
  {
class=class="str">"cmt">//--- 用文件中的数据填充数组
  if(!ReadFileToArray())
     {
      ::Print(__FUNCTION__," > 无法打开测试结果文件!");
      class="kw">return;
     }
class=class="str">"cmt">//--- 刷新余额和回撤图
  UpdateBalanceGraph();
  UpdateDrawdownGraph();
  }

点一下按钮就能回看上次测试

EA 加载到图表后,参数优化跑完别关界面——单击『更新数据』按钮,CProgram::OnEvent() 就会捕获自定义图表事件,把上一次测试的多元品种余额图直接重绘出来。 从实操看,这比反复翻优化报告省事:同一张图上能叠看多次测试结果,对比不同参数集下的权益曲线偏移。外汇与贵金属品种波动大、杠杆高,这类图形仅作历史回测参考,实盘仍可能偏离。 下面这段是事件处理的骨架,核心就在判断 lparam 是否等于按钮对象 ID,命中才调 UpdateGraphs()。

MQL5 / C++
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 事件处理器                                                              |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void CProgram::OnEvent(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=class="str">"cmt">//--- 按钮点击事件
   if(id==CHARTEVENT_CUSTOM+ON_CLICK_BUTTON)
     {
class=class="str">"cmt">//--- 按下 &class="macro">#x27;更新数据&class="macro">#x27;
      if(lparam==m_update_graph.Id())
        {
class=class="str">"cmt">//--- 更新图形
         UpdateGraphs();
         class="kw">return;
        }
class=class="str">"cmt">//---
      class="kw">return;
     }
  }

◍ 用下拉日历驱动多品种余额回测图

第二个 EA 版本把刷新按钮换成了下拉式日历,交易者能直接指定日期,让系统把该日之后的多品种成交结果画到图上。界面其余部分和旧版几乎一致,但数据触发逻辑改在 OnTrade() 里抓历史变动。 判断是否来了新成交,靠的是 CProgram::IsLastDealTicket()。它用内存里存的上次时间调 HistorySelect 拉增量历史,再比对尾笔成交单号;单号变了就更新 m_last_deal_time 与 m_last_deal_ticket 并返回 true,没变就 false。这样避免了无意义的全量重绘。 画之前得先知道历史里涉及哪些品种。CProgram::GetHistorySymbols() 在选定历史区间后,把不重复的品种名塞进字符串和数组,并返回品种数量,据此给余额数组定大小。 多元余额本身由 CProgram::GetHistorySymbolsBalance() 算:先取首笔成交作初始余额,再按日历日期重选历史,拿品种清单,循环里算每个品种余额与回撤,填数组。回撤算法复用前面章节的方法。 图形写入靠 CProgram::UpdateBalanceGraph() 和 CProgram::UpdateDrawdownGraph(),代码和第一版基本一致。创建界面时先调一次让用户立刻看到结果;之后 OnTrade() 收到成交事件就增量更新。用户改日历日期触发 ON_CHANGE_DATE,可传 true 给 UpdateBalanceGraph() 强制重画,不走单号比对。 在 MT5 策略测试器开可视化模式跑这套 EA,能直接看到图例 4 那种多品种余额随日期展开的效果,外汇与贵金属品种波动大、杠杆高,历史回测仅反映过去概率,实盘风险需自担。

MQL5 / C++
class CProgram : class="kw">public CWndEvents
  {
class="kw">private:
   class=class="str">"cmt">//--- 最后更改的成交时间和单号
   class="type">class="kw">datetime               m_last_deal_time;
   class="type">class="kw">ulong                  m_last_deal_ticket;
   class=class="str">"cmt">//--- 
class="kw">private:
   class=class="str">"cmt">//--- 检查新成交
   class="type">bool                  IsLastDealTicket(class="type">void);
   };
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 构造器                                                            |
class=class="str">"cmt">//+------------------------------------------------------------------+
CProgram::CProgram(class="type">void) : m_last_deal_time(NULL),
                         m_last_deal_ticket(WRONG_VALUE)
  {
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 获取指定品种的最后成交事件                                        |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">bool CProgram::IsLastDealTicket(class="type">void)
  {
class=class="str">"cmt">//--- 如果尚未收到,则退出
   if(!::HistorySelect(m_last_deal_time,LONG_MAX))
      class="kw">return(class="kw">false);
class=class="str">"cmt">//--- 从获取的列表中得到成交数量
   class="type">int total_deals=::HistoryDealsTotal();
class=class="str">"cmt">//--- 从尾至头遍历接收列表中的所有成交
   for(class="type">int i=total_deals-class="num">1; i>=class="num">0; i--)
     {
      class=class="str">"cmt">//--- 获得成交单号
      class="type">class="kw">ulong deal_ticket=::HistoryDealGetTicket(i);
      class=class="str">"cmt">//--- 如果单号相同,退出
      if(deal_ticket==m_last_deal_ticket)
         class="kw">return(class="kw">false);
      class=class="str">"cmt">//--- 如果单号不相同,通知
      else
        {
         class="type">class="kw">datetime deal_time=(class="type">class="kw">datetime)::HistoryDealGetInteger(deal_ticket,DEAL_TIME);
         class=class="str">"cmt">//--- 保存最后一笔成交的时间和单号
         m_last_deal_time   =deal_time;
         m_last_deal_ticket =deal_ticket;
         class="kw">return(true);
        }
     }
class=class="str">"cmt">//--- 另一个品种的单号
   class="kw">return(class="kw">false);
  }
class CProgram : class="kw">public CWndEvents
  {
class="kw">private:
   class=class="str">"cmt">//--- 来自历史的品种数组

「从成交历史里抠出交易品种列表」

做多品种回测面板时,第一步往往是知道账户历史上到底碰过哪些品种。下面这段私有方法就是干这个的:遍历 HistoryDealsTotal 返回的成交数,把每个 deal 的品种名丢进一个逗号分隔的字符串,再去重。 核心去重判断在 StringFind 返回 -1 时触发,说明当前品种还不在 check_symbols 里,就用 StringAdd 拼上去;首条不加逗号,后续前补逗号。 拼完之后用 StringSplit 按逗号切回数组 m_symbols_name,返回的品种数量直接就是数组长度。注意 HistorySelect(0, LONG_MAX) 必须先调用,否则 HistoryDealsTotal 可能读不到全量成交。 初始化余额那行也有坑:用 SelectByIndex(0) 取首笔成交的 Profit 当初始存款,仅在账户首条记录是入金或_balance类成交时才近似合理;若首条是手续费扣减,balance 会偏负。外汇与贵金属杠杆高,历史品种统计仅作风险分布参考,不代表未来收益。

MQL5 / C++
class="type">class="kw">string m_symbols_name[];
class=class="str">"cmt">//--- 
class="kw">private:
class=class="str">"cmt">//--- 从帐户历史记录中获取品种并返回它们的数量
  class="type">int GetHistorySymbols(class="type">void);
  };
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 从帐户历史记录中获取品种并返回它们的数量                          |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">int CProgram::GetHistorySymbols(class="type">void)
  {
   class="type">class="kw">string check_symbols="";
class=class="str">"cmt">//--- 首次循环遍历并获得交易的品种
   class="type">int deals_total=::HistoryDealsTotal();
   for(class="type">int i=class="num">0; i<deals_total; i++)
     {
      class=class="str">"cmt">//--- 获得成交单号
      if(!m_deal_info.SelectByIndex(i))
         class="kw">continue;
      class=class="str">"cmt">//--- 如果有品种名称
      if(m_deal_info.Symbol()=="")
         class="kw">continue;
      class=class="str">"cmt">//--- 如果没有这样的字符串,则添加它
      if(::StringFind(check_symbols,m_deal_info.Symbol(),class="num">0)==-class="num">1)
         ::StringAdd(check_symbols,(check_symbols=="")? m_deal_info.Symbol() : ","+m_deal_info.Symbol());
     }
class=class="str">"cmt">//--- 按分隔符获取字符串元素
   class="type">class="kw">ushort u_sep=::StringGetCharacter(",",class="num">0);
   class="type">int symbols_total=::StringSplit(check_symbols,u_sep,m_symbols_name);
class=class="str">"cmt">//--- 返回品种的数量
   class="kw">return(symbols_total);
  }

逐笔成交回填多品种余额曲线

这段逻辑干的事很直接:把历史成交按时间顺序一条条过,把每笔的利润、掉期、佣金累加进余额数组,同时顺手把最大回撤记下来。外汇和贵金属杠杆高,回撤可能来得比股票猛,跑之前先确认自己的点值计算没漏掉 swap 和 commission。 先给每个品种的余额数组按成交总数 deals_total 扩容并清零,再用首笔和末笔成交的时间确定统计窗口 m_begin_date / m_end_date;若取不到就用用户选定的日期或当前时间兜底。 核心循环里,只有时间大于等于选定起始日的成交才参与累加,balance += Profit()+Swap()+Commission() 写进 0 号数组(总余额)。回撤判定靠 MaxDrawdownToString 返回非空串时调用 AddDrawDown 落盘。 多品种分支(symbols_total>=2)下,非本品种或盈亏为零的成交直接继承上一条的余额值;同品种且 Profit 不为零才把该笔的 Profit+Swap+Commission 叠到前值上。这样每个品种都有一条独立权益曲线,方便后面比谁拖了后腿。

MQL5 / C++
class=class="str">"cmt">//--- 余额曲线的数量
   class="type">int balances_total=::ArraySize(m_symbols_balance);
class=class="str">"cmt">//--- 历史记录的开始和结束
   m_begin_date =(m_deal_info.SelectByIndex(class="num">0))? m_deal_info.Time() : m_from_trade.SelectedDate();
   m_end_date   =(m_deal_info.SelectByIndex(deals_total-class="num">1))? m_deal_info.Time() : ::TimeCurrent();
class=class="str">"cmt">//--- 最大回撤
   class="type">class="kw">double max_drawdown=class="num">0.0;
class=class="str">"cmt">//--- 将余额数组写入传递的数组
   for(class="type">int i=class="num">0; i<deals_total; i++)
     {
      class=class="str">"cmt">//--- 获取成交数组
      if(!m_deal_info.SelectByIndex(i))
         class="kw">continue;
      class=class="str">"cmt">//--- 初始化首笔交易
      if(i==class="num">0 && m_deal_info.DealType()==DEAL_TYPE_BALANCE)
         balance=class="num">0;
      class=class="str">"cmt">//--- 从指定日期开始
      if(m_deal_info.Time()>=m_from_trade.SelectedDate())
        {
         class=class="str">"cmt">//--- 计算总余额
         balance+=m_deal_info.Profit()+m_deal_info.Swap()+m_deal_info.Commission();
         m_symbols_balance[class="num">0].m_data[i]=balance;
         class=class="str">"cmt">//--- 计算回撤
         if(MaxDrawdownToString(i,balance,max_drawdown)!="")
            AddDrawDown(i,max_drawdown);
        }
      class=class="str">"cmt">//--- 如果使用多元品种,则写入品种的余额值
      if(symbols_total<class="num">2)
         class="kw">continue;
      class=class="str">"cmt">//--- 仅从指定日期
      if(m_deal_info.Time()<m_from_trade.SelectedDate())
         class="kw">continue;
      class=class="str">"cmt">//--- 遍历所有品种
      for(class="type">int s=class="num">1; s<balances_total; s++)
        {
         class="type">int prev_i=i-class="num">1;
         class=class="str">"cmt">//--- 如果是 "余额存款" 交易 (首笔交易) ...
         if(prev_i<class="num">0 || m_deal_info.DealType()==DEAL_TYPE_BALANCE)
            {
             class=class="str">"cmt">//--- ... 所有品种的余额是相同的
             m_symbols_balance[s].m_data[i]=balance;
             class="kw">continue;
            }
         class=class="str">"cmt">//--- 如果品种相同且交易结果不为零
         if(m_deal_info.Symbol()==m_symbols_name[s-class="num">1] && m_deal_info.Profit()!=class="num">0)
            {
             class=class="str">"cmt">//--- 在余额中反映这一品种的成交。 考虑掉期利率和佣金。
             m_symbols_balance[s].m_data[i]=m_symbols_balance[s].m_data[prev_i]+m_deal_info.Profit()+m_deal_info.Swap()+m_deal_info.Commission();
            }
         class=class="str">"cmt">//--- 否则,写入之前的值
         else
            m_symbols_balance[s].m_data[i]=m_symbols_balance[s].m_data[prev_i];
        }
     }

◍ 把交易事件挂到图重写函数上

在 MT5 面板程序里,账户余额曲线和回撤曲线不该靠定时器硬刷,更干净的做法是只在有交易动作时重绘。下面这段把 OnTradeEvent 直接绑到两个私有方法上,每次成交或平仓,图形自然跟着变。 类里先声明两个更新函数:UpdateBalanceGraph 带默认参数 update=false,UpdateDrawdownGraph 无参。它们被放在 private 区,外部只通过事件入口调用,避免别处误改图形缓存。 OnTradeEvent 是 CWndEvents 派生类拿到的交易回调。里面就两行:UpdateBalanceGraph() 和 UpdateDrawdownGraph(),意味着任何交易终端事件触发时,余额图与回撤图同步刷新一次,CPU 占用比每秒轮询低一个数量级。 日历选日期的交互也接进了 OnEvent。当自定义事件 id 等于 CHARTEVENT_CUSTOM+ON_CHANGE_DATE 且 lparam 匹配起始交易日控件 Id,就带 update=true 重画余额图、顺带重画回撤图,并切换日历下拉状态。外汇与贵金属品种波动剧烈,这类回撤图仅作概率参考,实盘仍属高风险。

MQL5 / C++
class CProgram : class="kw">public CWndEvents
  {
class="kw">private:
   class=class="str">"cmt">//--- 初始化图形
   class="type">void                UpdateBalanceGraph(class="kw">const class="type">bool update=class="kw">false);
   class="type">void                UpdateDrawdownGraph(class="type">void);
   };
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 交易操作事件                                                        |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void CProgram::OnTradeEvent(class="type">void)
  {
class=class="str">"cmt">//--- 更新余额和回撤图
   UpdateBalanceGraph();
   UpdateDrawdownGraph();
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 事件处理器                                                          |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void CProgram::OnEvent(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=class="str">"cmt">//--- 在日历中选择日期的事件
   if(id==CHARTEVENT_CUSTOM+ON_CHANGE_DATE)
     {
      if(lparam==m_from_trade.Id())
        {
         UpdateBalanceGraph(true);
         UpdateDrawdownGraph();
         m_from_trade.ChangeComboBoxCalendarState();
        }
      class=class="str">"cmt">//---
      class="kw">return;
     }
  }

「把信号服务的 CSV 历史搬上 MT5 图表」

想直观看某个信号提供者的历史交易质量,不必盯后台网页。进信号页点“交易历史”,列表下方有导出 CSV 的链接,把文件丢进终端的 \MQL5\Files 目录,EA 就能把它画成曲线。 EA 只暴露一个外部参数 PathToFile,填的就是报告文件名。启动后若目录下找不到对应文件,程序会在“流水账”里报一条消息,不会静默挂掉。 读取后的每行文本先按 ';' 拆字段,品种名抽出来单独成数组,再灌进二维数组按开单时间从早到晚排。所有追加资金的行都被跳过,避免净值曲线被注资扭曲。 排序靠 QuickSort 加 Swap 这套,逻辑和前文表格处理一致。排完先定品种余额数组尺寸,再填总余额与回撤,最后两个图形控件出图。下面示例里不同信号源的回撤深度差异肉眼可见,外汇与贵金属信号自带高杠杆风险,历史曲线不代表未来。 让小布替你跑这套 下载四个不同信号源的 CSV 分别命名丢进 Files,改 PathToFile 依次加载,对比回撤图形比看胜率更有用。

MQL5 / C++
class=class="str">"cmt">//--- 外部参数
class="kw">input class="type">class="kw">string PathToFile=""; class=class="str">"cmt">// 文件路径
...
class=class="str">"cmt">//--- 从文件中提取数据
class="kw">struct CReportTable
  {
   class="type">class="kw">string                m_rows[];
  };
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 用于创建应用程序的类                                            |
class=class="str">"cmt">//+------------------------------------------------------------------+
class CProgram : class="kw">public CWndEvents
  {
class="kw">private:
   class=class="str">"cmt">//--- 报表
   CReportTable          m_columns[];
   class=class="str">"cmt">//--- 字符串和列的数量
   class="type">uint                  m_rows_total;
   class="type">uint                  m_columns_total;
  };
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 构造器                                                          |
class=class="str">"cmt">//+------------------------------------------------------------------+
CProgram::CProgram(class="type">void) : m_rows_total(class="num">0),
                           m_columns_total(class="num">0)
  {
...
  }
class CProgram : class="kw">public CWndEvents
  {
class="kw">private:
   class=class="str">"cmt">//--- 快速排序方法
   class="type">void                 QuickSort(class="type">uint beg,class="type">uint end,class="type">uint column);
   class=class="str">"cmt">//--- 检查排序条件
   class="type">bool                 CheckSortCondition(class="type">uint column_index,class="type">uint row_index,class="kw">const class="type">class="kw">string check_value,class="kw">const class="type">bool direction);
   class=class="str">"cmt">//--- 在指定单元格中交换数值
   class="type">void                 Swap(class="type">uint r1,class="type">uint r2);
  };
class CProgram : class="kw">public CWndEvents
  {
class="kw">private:
   class=class="str">"cmt">//--- 获取数据到数组

把报告文本拆成可排序的二维数据

做复盘工具时,MT5 测试报告导出的只是分号分隔的文本行。先按 ';' 切出表头,用 StringSplit 把首行 m_source_data[0] 拆成 str_elements,元素个数 elements_total 就是列数,行数则是 ArraySize(m_source_data) 减 1。 拿到行列规模后,要给每列建一个 m_rows 数组,长度为 strings_total-1,再逐行 StringSplit 填值。注意 m_source_data 下标从 1 开始读,因为第 0 行是标题,这个 off-by-one 写错就会整列错位。 填完用 QuickSort(0, m_rows_total-1, 0) 按第一列时间排序。排序后那段高亮代码很关键:先 ArrayResize(m_symbol_balance, m_symbols_total),再给每个品种的 m_data 分配到 m_rows_total 长度,否则后面写 m_symbol_balance[i].m_data[r] 会越界报数组错误。 余额累加时,遇到 m_columns[1].m_rows[i]=="Balance" 的行直接复制上一行值,这是跳过外部入金、避免净值曲线被人为拉平的处理。外汇和贵金属回测中出入金频繁,不剔除会让最大回撤计算失真,实盘验证前务必确认这段逻辑符合你的账户操作习惯。

MQL5 / C++
  class="type">class="kw">string str_elements[];
  class="type">class="kw">ushort u_sep=::StringGetCharacter(";",class="num">0);
  ::StringSplit(m_source_data[class="num">0],u_sep,str_elements);
  class="type">int strings_total   =::ArraySize(m_source_data);
  class="type">int elements_total =::ArraySize(str_elements);
  if((m_symbols_total=GetHistorySymbols())==WRONG_VALUE)
     class="kw">return;
  ::ArrayFree(m_dd_y);
  ::ArrayFree(m_dd_x);
  ::ArrayResize(m_columns,elements_total);
  for(class="type">int i=class="num">0; i<elements_total; i++)
     ::ArrayResize(m_columns[i].m_rows,strings_total-class="num">1);
  for(class="type">int r=class="num">0; r<strings_total-class="num">1; r++)
     {
     ::StringSplit(m_source_data[r+class="num">1],u_sep,str_elements);
     for(class="type">int c=class="num">0; c<elements_total; c++)
        m_columns[c].m_rows[r]=str_elements[c];
     }
  m_rows_total     =strings_total-class="num">1;
  m_columns_total =elements_total;
  QuickSort(class="num">0,m_rows_total-class="num">1,class="num">0);
  ::ArrayResize(m_symbol_balance,m_symbols_total);
  for(class="type">int i=class="num">0; i<m_symbols_total; i++)
     ::ArrayResize(m_symbol_balance[i].m_data,m_rows_total);
  class="type">class="kw">double balance       =class="num">0.0;
  class="type">class="kw">double max_drawdown =class="num">0.0;
  for(class="type">uint i=class="num">0; i<m_rows_total; i++)
     {
     if(i==class="num">0)
        {
        balance+=(class="type">class="kw">double)m_columns[elements_total-class="num">1].m_rows[i];
        m_symbol_balance[class="num">0].m_data[i]=balance;
        }
     else
        {
        if(m_columns[class="num">1].m_rows[i]=="Balance")
           m_symbol_balance[class="num">0].m_data[i]=m_symbol_balance[class="num">0].m_data[i-class="num">1];
        else
           {
           balance+=(class="type">class="kw">double)m_columns[elements_total-class="num">1].m_rows[i]+(class="type">class="kw">double)m_columns[elements_total-class="num">2].m_rows[i]+(class="type">class="kw">double)m_columns[elements_total-class="num">3].m_rows[i];

◍ 逐笔回填各品种权益曲线

这段逻辑干的事很直接:把成交记录按品种拆开,重算出每个品种在每一行时间戳上的账户余额序列。外层从 s=1 开始循环,是因为下标 0 通常留给全账户汇总,真实品种从第二个槽位起算。 初始时每个品种的 m_data[0] 都先填成基准余额,也就是 m_symbol_balance[0].m_data[0] 的值。随后按行扫 m_rows_total 条记录,若当前行对应的品种名和 m_columns[m_symbol_index] 里的标记对不上,就沿用上一行余额并 continue,不往前推。 一旦品种匹配且最后一列成交结果非零,余额按三重加总推进:代码里把 elements_total-1、elements_total-2、elements_total-3 三列的值叠加到 balance 上。这三列一般对应手续费、掉期与净盈亏,具体要看你报表结构。若成交结果为零,则余额保持上一行不变。 回撤统计另有一处:在更早的循环里用 MaxDrawdownToString(i,balance,max_drawdown) 算出字符串,非空就调 AddDrawDown 落库。外汇与贵金属杠杆高,这套按品种拆回撤的方法,能帮你定位是哪一类品种把曲线拖垮,但历史回撤不等于未来风险。 开 MT5 把这段塞进你自己的报表类里,改一下 elements_total 偏移量,跑一遍 EURUSD 和 XAUUSD 的混合记录,看拆分出的余额序列是否和账户历史吻合。

MQL5 / C++
   m_symbol_balance[class="num">0].m_data[i]=balance;
     }
   }
   class=class="str">"cmt">//--- 计算回撤
   if(MaxDrawdownToString(i,balance,max_drawdown)!="")
      AddDrawDown(i,max_drawdown);
   }
...
...
class=class="str">"cmt">//--- 获取品种余额数据
   for(class="type">int s=class="num">1; s<m_symbols_total; s++)
   {
   class=class="str">"cmt">//--- 初始余额
   balance=m_symbol_balance[class="num">0].m_data[class="num">0];
   m_symbol_balance[s].m_data[class="num">0]=balance;
   class=class="str">"cmt">//---
   for(class="type">uint r=class="num">0; r<m_rows_total; r++)
     {
     class=class="str">"cmt">//--- 如果品种不匹配,则返回先前的值
     if(m_symbols_name[s]!=m_columns[m_symbol_index].m_rows[r])
       {
       if(r>class="num">0)
         m_symbol_balance[s].m_data[r]=m_symbol_balance[s].m_data[r-class="num">1];
       class=class="str">"cmt">//---
       class="kw">continue;
       }
     class=class="str">"cmt">//--- 如果成交结果不为零
     if((class="type">class="kw">double)m_columns[elements_total-class="num">1].m_rows[r]!=class="num">0)
       {
       balance+=(class="type">class="kw">double)m_columns[elements_total-class="num">1].m_rows[r]+(class="type">class="kw">double)m_columns[elements_total-class="num">2].m_rows[r]+(class="type">class="kw">double)m_columns[elements_total-class="num">3].m_rows[r];
       m_symbol_balance[s].m_data[r]=balance;
       }
     class=class="str">"cmt">//--- 否则,写入之前的值
     else
       m_symbol_balance[s].m_data[r]=m_symbol_balance[s].m_data[r-class="num">1];
     }
   }

「把这条线请下神坛」

这套多元品种余额图方案把过去要借第三方程序才能看到的结果,收进了 MT5 原生 MQL 环境,EA 文件 MacdSampleMultiSymbols.mq5 即标准 MACD Sample 的改编版,配套 Program.mqh、CreateGUI.mqh 等六个文件,ZIP 包 39.77 KB 可直接下载验证。 以前离了外部工具就看不到跨品种权益曲线,现在点一下就能在价格图上缩放切换总计与单品种视图,作者原帖里和 Andrey 的对谈也确认了这种一键转换的交互。 外汇与贵金属品种叠加看权益天然带高杠杆风险,代码能跑通不代表策略能活,开 MT5 把 ZIP 解进 MQL5/Files 跑一遍,比任何转述都实在。

把多品种诊断交给小布盯盘
这些多品种权益与回撤的可视化逻辑,小布盯盘的 AIGC 已内置,打开对应品种页即可看到聚合后的账户视图,你只需判断信号质量。

常见问题

通常用逗号分隔品种与权益序列,保证市场观察里品种存在,否则读取时会漏数据,建议测试前校验品种列表。
在可视化测试或实盘用图形界面重绘,频率受帧率限制,品种数过多时可能略有延迟,倾向降低刷新间隔。
可以,小布盯盘内置了多品种权益与回撤的聚合视图,不需要自己写文件读写和绘图代码,打开品种页就能看。
信号报告偏订阅视角的历史表现,余额图是你本地 EA 的真实成交序列,前者不能替代后者做策略验证。
文中所用结构不精确,品种执行顺序与 tick 到达差异会导致结果偏移,属概率现象,不代表策略失效。