优化结果的可视化评估(基础篇)
「用 MT5 测试报告看优化是否靠谱」
MT5 的策略测试器跑完一轮优化,别只盯着最终利润栏。报告里每一行参数组合都带着净值曲线、回撤和交易次数,这些才是判断过拟合的第一手材料。 以一篇 2022-03-23 发布的公开测试笔记为例,单次优化任务在社区里收获了 1841 次查看,说明这类可视化评估方法本身有稳定受众。把不同参数组的权益曲线叠在同一张图上,弯曲度差异大的组合,实盘失效概率往往更高。 外汇与贵金属品种波动受杠杆和消息面驱动,回测漂亮不等于实盘能复制,任何优化结论都只是「历史样本下倾向有效」,须以小额实盘验证。
◍ 一次跑完多准则优化的必要性
自定义优化准则让 EA 调参更顺手,但真要同时校验多个条件,传统做法是反复跑多次优化,耗时随准则数量线性放大,实盘前光回测就磨掉大半耐心。 更直接的做法是把多个自定义准则塞进同一次优化里并行评估,顺带把余额、净值曲线实时画出来。人脑接收的信息超 80% 来自视觉,能边跑边看图形,比盯着终值表格更容易抓到过拟合的尾巴。 本文落点是搭一套优化图形 + 最优准则挑选的流程,而且即便你 MQL5 只懂皮毛,也能借社区文章和论坛碎片拼出可用方案,不用从零啃文档。
把优化通测结果排成可比较的序列
做参数优化不能只盯 MT5 自带的 profit 和 recovery factor。每次优化通测跑完,先把这一轮的所有 pass 数据落盘,包括输入参数组合、成交次数、最大回撤,避免关掉优化器就丢现场。 给每次通测单独生成余额曲线和净值曲线图。MT5 优化结果页只给末态,自己导出 OnTester 里的 equity 数组画全周期曲线,才能看出某组参数是不是靠尾段一波拉伸撑起来的。 自定义准则建议至少算三个:sharpe 近似(均值/标准差)、回撤恢复比(总盈利/最大回撤时长)、参数敏感度(邻域 5% 扰动下收益衰减)。外汇与贵金属杠杆高,曲线断裂和跳空常导致这些准则失真,需人工复核。 按你定的加权准则对图形升序排,最优解不一定取第一,看前 5 名曲线的形态稳定性再定。小布知识库可接管这套排序,直接把最优结果和对应参数打印到日记,省去手动翻几百行。
「把改 EA 的代价压到最低」
给 EA 加回测截图与自定义筛选逻辑时,最忌讳直接动主策略文件。我们实际做法是把数据收集全部塞进独立的 SkrShotOpt.mqh 包含文件,自定义准则单独放 CustomCriterion.mqh 计算,ScreenShotOptimization.mq5 只负责画图与存盘。 这样主 EA 源码只需要补几行 include 和调用,后期升级 MT5 版本或换品种时,改动面控制在 10 行以内,回测工程不会越改越乱。 外汇与贵金属回测本身受点差和滑点影响大,属于高风险验证环境,任何截图优化结论都只能当作概率参考,不能直接当成实盘信号。
◍ 用成交事件驱动净值采样而不是每跳都写
做持仓快照最忌在 OnTick() 里无脑刷写。每来一个即时报价就检查一遍持仓和余额,CPU 和文件 IO 都扛不住,尤其黄金和美系直盘在数据行情里每秒十几跳,数组会爆得快。 实际做法是把净值极值跟踪放进 OnTick(),只维护 tempEquityMax / tempEquityMin 两个临时变量;真正的数组写入放到 OnTradeTransaction(),且只在成交数量变化(DEAL_ENTRY_IN / OUT / INOUT)时才动笔。这样一笔持仓从开到平,通常只落四个值:开仓时余额+净值、平仓时余额+期间最大或最小净值。 文件体积必须掐死。多笔交易连续跑,若每次都写全量帧数据,文件会膨胀到难以回放绘制。上面这套逻辑下,即便同根 K 线内先平后开,也只记一笔持仓——图形可视化不受损,数组长度却能砍掉一大截。 亏损了结写 tempEquityMin、盈利了结写 tempEquityMax(或按视图模式切),这个映射直接决定你后面看到的资金曲线是「回撤包络」还是「最低水位线」。外汇和贵金属杠杆高,净值摆动可能被放大数倍,采样漏掉极值会误判真实风险。
class="type">class="kw">double _Equity = AccountInfoDouble(ACCOUNT_EQUITY); if(tempEquityMax < _Equity) tempEquityMax = _Equity; if(tempEquityMin > _Equity) tempEquityMin = _Equity; class="type">void IsOnTradeTransaction(class="kw">const MqlTradeTransaction & trans, class="kw">const class="type">MqlTradeRequest & request, class="kw">const class="type">MqlTradeResult & result) { if(trans.type == TRADE_TRANSACTION_DEAL_ADD) if(HistoryDealSelect(trans.deal)) { if(_deal_entry != DEAL_ENTRY_OUT && _deal_entry != DEAL_ENTRY_OUT_BY) _deal_entry = HistoryDealGetInteger(trans.deal, DEAL_ENTRY); if(trans.deal_type == DEAL_TYPE_BUY || trans.deal_type == DEAL_TYPE_SELL) if(_deal_entry == DEAL_ENTRY_IN || _deal_entry == DEAL_ENTRY_OUT || _deal_entry == DEAL_ENTRY_INOUT || _deal_entry == DEAL_ENTRY_OUT_BY) allowed = true; } } if(allowed) class=class="str">"cmt">// if there was a trade { class="type">class="kw">double accBalance = AccountInfoDouble(ACCOUNT_BALANCE); class="type">class="kw">double accEquity = AccountInfoDouble(ACCOUNT_EQUITY); ArrayResize(balance, _size + class="num">1); ArrayResize(equity, _size + class="num">1); balance[_size] = accBalance; if(_deal_entry != DEAL_ENTRY_OUT && _deal_entry != DEAL_ENTRY_OUT_BY) class=class="str">"cmt">// if a new position appeared equity[_size] = accEquity; else class=class="str">"cmt">// if position closed { if(changesB < accBalance) equity[_size] = tempEquityMin; else class="kw">switch(s_view) { case min_max_E: equity[_size] = tempEquityMax; class="kw">break; class="kw">default: equity[_size] = tempEquityMin; class="kw">break; } tempEquityMax = accEquity; tempEquityMin = accEquity; } _size = _size + class="num">1; changesPos = PositionsTotal();
余额快照后的状态复位
在持仓回放逻辑里,把当前账户净值先存进 changesB,这一步是为了在后续比对中知道资金基线在哪。 紧接着将 _deal_entry 置为 -1,代表本次循环没有有效的开仓方向记录;同时把 allowed 设为 false,直接掐断这一轮继续撮合的可能。 这段复位代码通常出现在异常分支或单笔处理收尾处,若你不手动清状态,下一轮循环可能沿用上一次的 entry 或权限标记,回测结果会出现幽灵成交。
changesB = accBalance;
_deal_entry = -class="num">1;
allowed = class="kw">false;