MetaTrader 5 中的多元品种余额图·进阶篇
(2/3)· 还在切图表看各品种盈亏?这篇把测试与实盘的多品种余额图做进同一界面
「把逗号分隔的品种名塞进交易数组」
做多品种扫描面板时,第一道关是把用户传进来的字符串(例如 "EURUSD, XAUUSD, GBPUSD")拆成真实可交易的品种数组。下面这段逻辑先按逗号切分,再逐个去空格,最后交给品种校验函数过滤掉平台不存在的标的。 核心切分只用了三个调用:StringGetCharacter 取逗号字符码,StringSplit 按该分隔符拆成 string 数组,随后 for 循环里 StringTrimLeft / StringTrimRight 把每个元素两侧空白清掉。若输入空串,函数直接跳过打印并返回 0 个品种。 校验环节调用 CheckTradeSymbols,它内部会遍历 SymbolName 并和来源数组比对,命中就用 SymbolSelect 加入市场观察,同时压入 checked_array;一个都没命中时退回当前图表品种 _Symbol。外汇与贵金属杠杆高、点差跳变频繁,这种自动收纳逻辑能减少手动添加遗漏,但实际成交仍受流动性影响,滑点可能偏大。 下面是原文里负责拆解与兜底的那段实现,可直接复制到 MT5 的 mqh 里验证:
class="type">int CProgram::CheckSymbols(class="kw">const class="type">class="kw">string symbols_enum) { if(symbols_enum!="") ::Print(__FUNCTION__," > 输入交易品种: ",symbols_enum); class=class="str">"cmt">//--- 从字符串中获取品种 class="type">class="kw">string symbols[]; class="type">class="kw">ushort u_sep=::StringGetCharacter(",",class="num">0); ::StringSplit(symbols_enum,u_sep,symbols); class=class="str">"cmt">//--- 从两侧剔除空白 class="type">int elements_total=::ArraySize(symbols); for(class="type">int e=class="num">0; e<elements_total; e++) { ::StringTrimLeft(symbols[e]); ::StringTrimRight(symbols[e]); } class=class="str">"cmt">//--- 检查品种 ::ArrayFree(m_symbols); CheckTradeSymbols(symbols,m_symbols); class=class="str">"cmt">//--- 获取交易品种的数量 class="kw">return(::ArraySize(m_symbols)); }
多品种初始化的数组与回传陷阱
在 MT5 里做跨品种策略,初始化阶段最容易翻车的地方不是指标计算,而是符号数组和策略实例的绑定。上面这段把 CheckSymbols 得到的品种数直接喂给 ArrayResize,意味着 m_strategy 的下标和 m_symbols 必须一一对应,少一个就整个 OnInitEvent 返回 false,EA 直接起不来。 注意那行被高亮的判断:m_strategy[i].OnInitEvent(m_symbols[i]) 只要任意一个品种初始化失败,函数立刻 return(false)。这在实盘里很要命——比如某个贵金属品种当时无报价或权限不足,整个机器人就废了,而不是跳过该品种。外汇和贵金属交易高风险,这种全有或全无的设计需要你按自己的品种清单改成分支跳过。 验证方法很简单:开 MT5 随便挂 5 个以上交叉盘,故意把其中一个设成无效名称,编译跑一遍看 EA 是否直接离线。改参数的落点是把 return(false) 换成 Print()+continue,就能隔离单品种故障。
class="type">bool CProgram::OnInitEvent(class="type">void) { class=class="str">"cmt">//--- 获取交易品种 m_symbols_total=CheckSymbols(Symbols); class=class="str">"cmt">//--- 调整数组大小 ::ArrayResize(m_strategy,m_symbols_total); class=class="str">"cmt">//--- 初始化 for(class="type">int i=class="num">0; i<m_symbols_total; i++) { class=class="str">"cmt">//--- 初始化 if(!m_strategy[i].OnInitEvent(m_symbols[i])) class="kw">return(class="kw">false); } class=class="str">"cmt">//--- 初始化成功 class="kw">return(true); }
◍ 把测试报告落盘到公共目录
想让任意 MT5 终端都能读到回测结果,文件得写在终端公共数据文件夹,而不是某个品种或账号的沙盒里。构造函数里直接拼出路径:用 MQLInfoString(MQL_PROGRAM_NAME) 拿程序名,再接上 "\\LastTest.csv",这样每次跑完测试都能覆盖同一份最新报告。 写入逻辑封装在 CProgram::CreateSymbolBalanceReport() 里,核心依赖一个品种余额数组 m_symbol_balance[],结构体 CReportBalance 里挂了动态 double 数组 m_data[],多品种时每个元素对应一个品种的累计余额。文件打开标志必须带 FILE_COMMON,否则别的终端实例读不到。 表头先写死 10 列:成交时间、品种、成交类型、开平方向、交易量、价格、掉期、盈亏、回撤百分比、总余额。若跑的是多品种,标题行要往后追加各品种名,再一次性写进文件。主循环遍历整段成交历史,算利润时把掉期和佣金都算进去;多品种就在二层循环里逐个填余额。 回撤列调用 MaxDrawdownToString() 生成,首次调用回撤为 0,当前余额存为局部极值,后续依前值推算,余额刷新极值就更新,否则回吐部分返回空串。最终 CSV 可直接进 Excel 看结构,测试结束自动调这个方法出报告。外汇与贵金属回测含高杠杆风险,落盘数据仅作策略验证,不代表实盘表现。
class CProgram : class="kw">public CWndEvents { class="kw">private: class=class="str">"cmt">//--- 最后的测试结果的保存文件路径 class="type">class="kw">string m_last_test_report_path; }; class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| 构造器 | class=class="str">"cmt">//+------------------------------------------------------------------+ CProgram::CProgram(class="type">void) : m_symbols_total(class="num">0) { class=class="str">"cmt">//--- 最后的测试结果的保存文件路径 m_last_test_report_path=::MQLInfoString(MQL_PROGRAM_NAME)+"\\LastTest.csv"; } class=class="str">"cmt">//--- 所有品种的余额数组 class="kw">struct CReportBalance { class="type">class="kw">double m_data[]; }; 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">//--- 所有品种的余额数组 CReportBalance m_symbol_balance[]; class=class="str">"cmt">//--- class="kw">private: class=class="str">"cmt">//--- 创建 CSV 格式的交易测试报告 class="type">void CreateSymbolBalanceReport(class="type">void); }; class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| 创建 CSV 格式的交易测试报告 | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void CProgram::CreateSymbolBalanceReport(class="type">void) { ... } ... class=class="str">"cmt">//--- 在终端公共文件夹中创建一个用于写入数据的文件 class="type">int file_handle=::FileOpen(m_last_test_report_path,FILE_CSV|FILE_WRITE|FILE_ANSI|FILE_COMMON); class=class="str">"cmt">//--- 如果句柄有效 (文件已创建/已打开) if(file_handle==INVALID_HANDLE) { ::Print(__FUNCTION__," > 创建文件错误: ",::GetLastError()); class="kw">return; } ... ... class="type">class="kw">double max_drawdown =class="num">0.0; class=class="str">"cmt">// 最大回撤 class="type">class="kw">double balance =class="num">0.0; class=class="str">"cmt">// 余额 class="type">class="kw">string delimeter =","; class=class="str">"cmt">// 分隔符 class="type">class="kw">string string_to_write =""; class=class="str">"cmt">// 为了形成一个入场行 class=class="str">"cmt">//--- 形成标题行 class="type">class="kw">string headers="TIME,SYMBOL,DEAL TYPE,ENTRY TYPE,VOLUME,PRICE,SWAP($),PROFIT($),DRAWDOWN(%),BALANCE"; ... ... class=class="str">"cmt">//--- 如果涉及多元品种,则补充标题行
「把多品种成交摊开成 CSV 行」
导出交易报告时,先拿 ArraySize 量出监控品种数 symbols_total,大于 1 就循环用 StringAdd 把每个品种名拼进表头,再用 FileWrite 落地第一行标题。这一步决定了后面每列对应哪个品种,少一个符号后面余额数组就会错位。 历史抓取直接用 HistorySelect(0, LONG_MAX) 拉全量,HistoryDealsTotal 返回成交笔数 deals_total。接着 ArrayResize 给 m_symbol_balance 按品种数开槽,再内层循环给每个品种的 m_data 按 deals_total 定长——外汇与贵金属持仓跨周期时,笔数常到几千,提前 resize 比动态追加更稳。 逐笔写文件的主循环里,SelectByIndex(i) 取单号失败就 continue 跳过。digits 用 SymbolInfoInteger(SYMBOL_DIGITS) 动态拿,金价若跳到 3 位小数也不会把 1900.123 截成 1900.12。balance 累加 Profit+Swap+Commission,这是含库存费与佣金的真实净值,外汇贵金属隔夜 swap 可能吃掉短线利润,必须并入。 StringConcatenate 把时间、品种、类型、开平、成交量(2位)、价格(digits位)、swap、profit、回撤字符串、累计余额拼成一行。MaxDrawdownToString 算到第 i 笔的峰谷回撤,最后一列 balance 留 2 位小数。若 symbols_total>1,再走内层循环把各品种分项余额补进同行的对应 delimeter 后,否则多品种报告会缺列。
class="type">int symbols_total=::ArraySize(m_symbols); if(symbols_total>class="num">1) { for(class="type">int s=class="num">0; s<symbols_total; s++) ::StringAdd(headers,delimeter+m_symbols[s]); } class=class="str">"cmt">//--- 写报告标题 ::FileWrite(file_handle,headers); class=class="str">"cmt">//--- 获取整个历史 ::HistorySelect(class="num">0,LONG_MAX); class=class="str">"cmt">//--- 找出成交数量 class="type">int deals_total=::HistoryDealsTotal(); class=class="str">"cmt">//--- 通过品种数量设置余额数组的数量 ::ArrayResize(m_symbol_balance,symbols_total); class=class="str">"cmt">//--- 为每个品种设置成交数组的大小 for(class="type">int s=class="num">0; s<symbols_total; s++) ::ArrayResize(m_symbol_balance[s].m_data,deals_total); 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">//--- 找出价格中的小数位数 class="type">int digits=(class="type">int)::SymbolInfoInteger(m_deal_info.Symbol(),SYMBOL_DIGITS); class=class="str">"cmt">//--- 计算总余额 balance+=m_deal_info.Profit()+m_deal_info.Swap()+m_deal_info.Commission(); class=class="str">"cmt">//--- 形成连接写入的行 ::StringConcatenate(string_to_write, ::TimeToString(m_deal_info.Time(),TIME_DATE|TIME_MINUTES),delimeter, m_deal_info.Symbol(),delimeter, m_deal_info.TypeDescription(),delimeter, m_deal_info.EntryDescription(),delimeter, ::DoubleToString(m_deal_info.Volume(),class="num">2),delimeter, ::DoubleToString(m_deal_info.Price(),digits),delimeter, ::DoubleToString(m_deal_info.Swap(),class="num">2),delimeter, ::DoubleToString(m_deal_info.Profit(),class="num">2),delimeter, MaxDrawdownToString(i,balance,max_drawdown),delimeter, ::DoubleToString(balance,class="num">2)); class=class="str">"cmt">//--- 如果有多元品种,请写入其余额值 if(symbols_total>class="num">1) { class=class="str">"cmt">//--- 沿着所有品种移动 for(class="type">int s=class="num">0; s<symbols_total; s++) {
逐笔成交回填与回撤追踪的实现细节
把每笔成交按品种拆账,核心逻辑是先判断成交归属与盈利是否非零。若品种匹配且 Profit() 不为 0,就把该品种当前索引的余额写成上一笔余额加上利润、掉期与佣金三项之和;否则沿用前值,仅当成交类型为 DEAL_TYPE_BALANCE 时才把所有品种首笔余额统一设为账户余额。 下面这段是品种余额写入与文件落盘的节选,注意 StringAdd 用两位小数精度拼接待写行,FileWrite 每轮结束后清空字符串变量,避免跨行串数据。 回撤统计另由 MaxDrawdownToString 处理,用两个 static 变量 max 与 min 维护局部极值。首笔成交时 max_drawdown 置 0、max 与 min 都等于初始 balance;后续若 balance 超过 max,则按 100-((min/max)*100) 算出上一段的回撤百分比,再刷新局部最大值。外汇与贵金属杠杆高,回撤数值仅反映历史样本,实盘可能偏离。 开 MT5 把这段逻辑接进自己的成交遍历循环,改掉 m_symbols 的索引方式就能按品种导出权益曲线 CSV,重点验证 min 是否在 max 刷新前被正确记录。
class=class="str">"cmt">//--- 如果品种匹配并且成交结果不为零 if(m_deal_info.Symbol()==m_symbols[s] && m_deal_info.Profit()!=class="num">0) class=class="str">"cmt">//--- 显示此品种一笔交易的余额。 考虑掉期利率和佣金 m_symbol_balance[s].m_data[i]=m_symbol_balance[s].m_data[i-class="num">1]+m_deal_info.Profit()+m_deal_info.Swap()+m_deal_info.Commission(); class=class="str">"cmt">//--- 否则,写入之前的值 else { class=class="str">"cmt">//--- 如果是 "资金余额" 交易 (第一笔交易),所有品种的余额是相同的 if(m_deal_info.DealType()==DEAL_TYPE_BALANCE) m_symbol_balance[s].m_data[i]=balance; class=class="str">"cmt">//--- 否则,将以前的值写入当前索引 else m_symbol_balance[s].m_data[i]=m_symbol_balance[s].m_data[i-class="num">1]; } class=class="str">"cmt">//--- 将品种余额添加到字符串 ::StringAdd(string_to_write,delimeter+::DoubleToString(m_symbol_balance[s].m_data[i],class="num">2)); } } class=class="str">"cmt">//--- 写入形成的字符串 ::FileWrite(file_handle,string_to_write); class=class="str">"cmt">//--- 强制将下一个字符串的变量设置为零 string_to_write=""; } class=class="str">"cmt">//--- 关闭文件 ::FileClose(file_handle); class="type">class="kw">string CProgram::MaxDrawdownToString(class="kw">const class="type">int deal_number,class="kw">const class="type">class="kw">double balance,class="type">class="kw">double &max_drawdown) { class=class="str">"cmt">//--- 用于在报告中显示的字符串 class="type">class="kw">string str=""; class=class="str">"cmt">//--- 用于局部最大和回撤计算 class="kw">static class="type">class="kw">double max=class="num">0.0; class="kw">static class="type">class="kw">double min=class="num">0.0; class=class="str">"cmt">//--- 如果是首笔交易 if(deal_number==class="num">0) { class=class="str">"cmt">//--- 尚无回撤 max_drawdown=class="num">0.0; class=class="str">"cmt">//--- 将初始点设置为局部最大值 max=balance; min=balance; } else { class=class="str">"cmt">//--- 如果当前余额超过保存的余额 if(balance>max) { class=class="str">"cmt">//--- 按先前的值计算回撤 max_drawdown=class="num">100-((min/max)*class="num">100); class=class="str">"cmt">//--- 更新本地最大值
◍ 回撤归零与测试后报告触发
当账户权益回到历史高点,代码把 max_drawdown 直接置为 0.0,并用 fmin 把 min 刷新为当前余额与历史最小值的较小者。这意味着策略在创新高那一刻,最大回撤统计被清零,后续只记录新的下行段,不会把旧回撤一直背在报告里。 报告字符串的生成很克制:max_drawdown 等于 0 时返回空串,否则用 DoubleToString 保留两位小数。你在 MT5 回测报告里看到的回撤字段,就是这段逻辑吐出来的文本,改精度只需动那个 2。 OnTesterEvent 只在正式测试(非优化、非可视化、非逐帧)结束后才调用 CreateSymbolBalanceReport。想验证触发条件,把 MQL_VISUAL_MODE 改成 true 跑一遍,文件就不会写——外汇与贵金属回测本身高风险,这种边界条件最好手动踩一遍。
max=balance; min=balance; } else { class=class="str">"cmt">//--- 获得零回撤并更新最小值 max_drawdown=class="num">0.0; min=fmin(min,balance); } } class=class="str">"cmt">//--- 为报告定义字符串 str=(max_drawdown==class="num">0)? "" : ::DoubleToString(max_drawdown,class="num">2); class="kw">return(str); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| 测试完成事件 | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">class="kw">double CProgram::OnTesterEvent(class="type">void) { class=class="str">"cmt">//--- 只有在测试后才写入报告 if(::MQLInfoInteger(MQL_TESTER) && !::MQLInfoInteger(MQL_OPTIMIZATION) && !::MQLInfoInteger(MQL_VISUAL_MODE) && !::MQLInfoInteger(MQL_FRAME_MODE)) { class=class="str">"cmt">//--- 形成报告并写入文件 CreateSymbolBalanceReport(); } class=class="str">"cmt">//--- class="kw">return(class="num">0.0); }