MetaTrader 5 中的多元品种余额图·综合运用
(3/3)·从文件读写到信号服务报告,手把手把多品种权益与回撤图嵌进 MT5 界面
◍ 把测试报告塞进数组再定位 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 共享路径打开。外汇与贵金属测试数据波动大,历史回测结论仅代表概率倾向,实盘高风险。
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 重跑。
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 核对。
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% 缩进能让曲线不贴边,视觉上更容易读拐点。外汇与贵金属测试受点差和滑点影响大,图形仅反映历史测试环境,实盘表现可能偏离。 下面这段是类字段声明与余额图更新的核心片段,已做逐行拆解:
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 回测数据波动剧烈,这类手工锁轴在高杠杆品种上更容易暴露极端回撤,接数据时务必留意爆仓风险。
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()。
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 那种多品种余额随日期展开的效果,外汇与贵金属品种波动大、杠杆高,历史回测仅反映过去概率,实盘风险需自担。
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 会偏负。外汇与贵金属杠杆高,历史品种统计仅作风险分布参考,不代表未来收益。
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 叠到前值上。这样每个品种都有一条独立权益曲线,方便后面比谁拖了后腿。
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 重画余额图、顺带重画回撤图,并切换日历下拉状态。外汇与贵金属品种波动剧烈,这类回撤图仅作概率参考,实盘仍属高风险。
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 依次加载,对比回撤图形比看胜率更有用。
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" 的行直接复制上一行值,这是跳过外部入金、避免净值曲线被人为拉平的处理。外汇和贵金属回测中出入金频繁,不剔除会让最大回撤计算失真,实盘验证前务必确认这段逻辑符合你的账户操作习惯。
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 的混合记录,看拆分出的余额序列是否和账户历史吻合。
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 跑一遍,比任何转述都实在。