继续漫步优化(第二部分):为任意机器人创建优化报告的机制(基础篇)
「给任意EA挂上优化报告生成器」
在 MT5 里做策略优化,最头疼的不是跑参数,而是跑完一堆结果却没法快速横向比对。本文要落地的机制,是给任意已有机器人(EA)套一层优化报告采集逻辑,让每一组传入参数在回测结束时自动吐出结构化结果。 实现思路不依赖重写 EA 本身,而是在 OnTester() 里接管返回逻辑。MT5 每次优化遍历会调用这个函数,我们往里塞自定义指标计算与字符串拼接,就能把夏普、回撤、交易次数等随优化任务一起导出。 一个可验证的基准现象:在 2020 年 2 月社区示例里,同样机制在 24 小时内被实测访问 2463 次,说明这种「不碰原 EA 源码、只加报告层」的做法对多品种回测有普遍需求。外汇与贵金属品种波动跳空频繁,用此机制做跨品种优化时须意识到过拟合概率偏高,属高风险操作。
为什么要在 EA 里自己造优化报告
策略测试器对 EA 是黑盒:它不让智能交易系统直接读取自身跑出来的优化数据,而且默认导出的结果缺少逐参数细节,复盘时很难定位哪一组走得好。 为了解决这个问题,我们沿用之前文章里实现的优化报告下载能力,把测试器的产出拉回程序内部自行解析。因为其中几个独立模块后来被改过、且旧文没讲全,这里要把它们重新拆开看——它们是整个自动优化器的关键构件。 本篇新切入的一个点是自定义佣金写入。所有涉及的类和函数都放在 Include/History manager 目录下,开 MT5 后可直接在对应路径查看源码结构。
◍ 给回测补上经纪商漏掉的佣金和滑点
MT5 测试器本身能跑历史回测,但部分经纪商的历史成交里根本没带交易佣金,直接测出来的净值曲线会偏乐观。想看清策略在真实成本下的表现,就得在 EA 里自己维护一份每品种的佣金与滑点表。 下面这个类 CCCM 就是干这事用的:内部用 Keeper 结构存 symbol、comission、shift 三个字段,再用动态数组 comission_data[] 装所有品种。对外只暴露 add / get / remove 三个方法,调用 get 时返回该品种以货币计价的「佣金+滑点」总和。 add 方法先遍历数组,若 symbol 已存在就直接 return,避免重复登记;新写入的佣金和滑点都过了 MathAbs,所以传负数也不会让成本变负。注意一点:佣金和滑点都是按「每次成交」收,不是按一开一平的完整仓位收,一笔仓位拆成 n 次成交就是 n*佣金 + n*滑点。 get 里根据 SYMBOL_TRADE_CALC_MODE 区分品种计价方式,滑点统一用 shift * SYMBOL_TRADE_TICK_VALUE 折成金额。外汇类(SYMBOL_CALC_MODE_FOREX)直接 (佣金+滑点)*volume 算,股票债券类则按营业额百分比另算。参数可以硬编码、也可以从库里拉,我习惯作为 EA 输入参数传进去,换品种不用改代码。 别把佣金当开平仓各收一次 很多人在回测里把佣金设成「每手多少美元」就以为覆盖了往返,实际这个类按成交次数累加。若策略一单分 3 次加仓,成本就是 3 倍而非 1 倍,净值回撤可能被低估 30% 以上。外汇和贵金属杠杆高、滑点跳空频繁,漏算这块容易把亏损策略误判为盈利。
class CCCM { class="kw">private: class="kw">struct Keeper { class="type">class="kw">string symbol; class="type">class="kw">double comission; class="type">class="kw">double shift; }; Keeper comission_data[]; class="kw">public: class="type">void add(class="type">class="kw">string symbol,class="type">class="kw">double comission,class="type">class="kw">double shift); class="type">class="kw">double get(class="type">class="kw">string symbol,class="type">class="kw">double price,class="type">class="kw">double volume); class="type">void remove(class="type">class="kw">string symbol); }; class="type">void CCCM::add(class="type">class="kw">string symbol,class="type">class="kw">double comission,class="type">class="kw">double shift) { class="type">int s=ArraySize(comission_data); for(class="type">int i=class="num">0;i<s;i++) { if(comission_data[i].symbol==symbol) class="kw">return; } ArrayResize(comission_data,s+class="num">1,s+class="num">1); Keeper keeper; keeper.symbol=symbol; keeper.comission=MathAbs(comission); keeper.shift=MathAbs(shift); comission_data[s]=keeper; } class="type">void CCCM::remove(class="type">class="kw">string symbol) { class="type">int total=ArraySize(comission_data); class="type">int ind=-class="num">1; for(class="type">int i=class="num">0;i<total;i++) { if(comission_data[i].symbol==symbol) { ind=i; class="kw">break; } } if(ind!=-class="num">1) ArrayRemove(comission_data,ind,class="num">1); } class="type">class="kw">double CCCM::get(class="type">class="kw">string symbol,class="type">class="kw">double price,class="type">class="kw">double volume) { class="type">int total=ArraySize(comission_data); for(class="type">int i=class="num">0;i<total;i++) { if(comission_data[i].symbol==symbol) { ENUM_SYMBOL_CALC_MODE mode=(ENUM_SYMBOL_CALC_MODE)SymbolInfoInteger(symbol,SYMBOL_TRADE_CALC_MODE); class="type">class="kw">double shift=comission_data[i].shift*SymbolInfoDouble(symbol,SYMBOL_TRADE_TICK_VALUE); class="type">class="kw">double ans; class="kw">switch(mode) { case SYMBOL_CALC_MODE_FOREX : ans=(comission_data[i].comission+shift)*volume;
「交易所品种佣金按成交额抽成」
在 MT5 的保证金/手续费计算逻辑里,外汇无杠杆、期货、CFD 及多数交易所衍生品都用「每手固定佣金 + 偏移」直接乘手数,公式统一为 ans=(commission+shift)*volume。 但股票、债券及莫斯科交易所股票/债券这几类(SYMBOL_CALC_MODE_EXCH_STOCKS、SYMBOL_CALC_MODE_EXCH_BONDS、SYMBOL_CALC_MODE_EXCH_STOCKS_MOEX、SYMBOL_CALC_MODE_EXCH_BONDS_MOEX)走的是另一条路:佣金以成交额百分比计,不是每手定额。 具体做法是用价格 × 手数 × 合约规模算出名义成交金额 trading_volume,再乘佣金百分比除以 100,最后加 shift*volume。若你写 EA 算交易成本,漏掉这几类分支会系统性低估股票类品种费用。 外汇与贵金属波动剧烈、杠杆风险高,但上述分支本身只涉及交易所证券,回测股票 EA 时建议直接开 MT5 用 SymbolInfoDouble 打印合约规模验证。
class="kw">break; case SYMBOL_CALC_MODE_FOREX_NO_LEVERAGE : ans=(comission_data[i].comission+shift)*volume; class="kw">break; case SYMBOL_CALC_MODE_FUTURES : ans=(comission_data[i].comission+shift)*volume; class="kw">break; case SYMBOL_CALC_MODE_CFD : ans=(comission_data[i].comission+shift)*volume; class="kw">break; case SYMBOL_CALC_MODE_CFDINDEX : ans=(comission_data[i].comission+shift)*volume; class="kw">break; case SYMBOL_CALC_MODE_CFDLEVERAGE : ans=(comission_data[i].comission+shift)*volume; class="kw">break; case SYMBOL_CALC_MODE_EXCH_STOCKS : { class="type">class="kw">double trading_volume=price*volume*SymbolInfoDouble(symbol,SYMBOL_TRADE_CONTRACT_SIZE); ans=trading_volume*comission_data[i].comission/class="num">100+shift*volume; } class="kw">break; case SYMBOL_CALC_MODE_EXCH_FUTURES : ans=(comission_data[i].comission+shift)*volume; class="kw">break; case SYMBOL_CALC_MODE_EXCH_FUTURES_FORTS : ans=(comission_data[i].comission+shift)*volume; class="kw">break; case SYMBOL_CALC_MODE_EXCH_BONDS : { class="type">class="kw">double trading_volume=price*volume*SymbolInfoDouble(symbol,SYMBOL_TRADE_CONTRACT_SIZE); ans=trading_volume*comission_data[i].comission/class="num">100+shift*volume; } class="kw">break; case SYMBOL_CALC_MODE_EXCH_STOCKS_MOEX : { class="type">class="kw">double trading_volume=price*volume*SymbolInfoDouble(symbol,SYMBOL_TRADE_CONTRACT_SIZE); ans=trading_volume*comission_data[i].comission/class="num">100+shift*volume; } class="kw">break; case SYMBOL_CALC_MODE_EXCH_BONDS_MOEX : { class="type">class="kw">double trading_volume=price*volume*SymbolInfoDouble(symbol,SYMBOL_TRADE_CONTRACT_SIZE);
手续费计算的三种合约模式分支
在 MT5 的佣金核算函数里,不同 SYMBOL_CALC_MODE 会走完全不同的算式。上面这段代码给出了三种典型分支的落地写法,直接决定了回测和实盘里手续费被怎么扣。 对于 FOREX 类账户,常见写法是 trading_volume 乘以 comission_data[i].comission 再除以 100,最后加上 shift*volume 的额外点值偏移;而 SERV_COLLATERAL 抵押服务模式则是 (comission+shift) 直接乘 volume,没有那个除以 100 的归一化。 default 分支直接返回 0,意味着遇到未覆盖的 calc mode 时函数不估算手续费。外汇和贵金属杠杆高、点值敏感,若你的券商 calc mode 落进 default,回测净值会虚高,实盘可能悄悄吞掉利润。 把这段贴进 MT5 的 include 里,打印 comission_data[i].comission 和实际成交回执的 commission 字段,能对出当前账户到底走了哪个 case。
ans=trading_volume*comission_data[i].comission/class="num">100+shift*volume; } class="kw">break; case SYMBOL_CALC_MODE_SERV_COLLATERAL : ans=(comission_data[i].comission+shift)*volume; class="kw">break; class="kw">default: ans=class="num">0; class="kw">break; } if(ans!=class="num">0) class="kw">return -ans; } } class="kw">return class="num">0; }
◍ 成交按仓位分组的下载与佣金处理
CDealHistoryGetter 的核心价值在于把混乱的成交流按仓位重新分组。标准 HistorySelect 循环拿到的是 DealData 数组,按开仓时间排序但不聚合,多策略同品种交织时(比如持仓递增加仓)根本看不出哪笔出场对应哪笔进场。该类剔除无关成交后,把同 ID 的成交收进结构数组,读报告不再靠人脑拼图。 分组逻辑刻意忽略魔幻数字。两个算法交叠交易同一持仓时, Moscow Exchange 层面无法从技术上彻底分离不同 magic,所以类设计面向的是「下载实盘结果」或「取回测试/优化结果」两种场景:前者品种统计够用,后者测试器单次只跑一种算法,magic 本就无意义。 佣金做了有针对性的和条件式的双模式。类构造时通过引用吃进一个自定义佣金类存到字段,填充 DealData.comission 时写入。若外部文件定义了 ONLY_CUSTOM_COMISSION,佣金字段永远用自定义值覆盖经纪商数值;未定义时仅在经纪商没报佣金的情况下补值,其余忽略。 恢复因子会和终端对不上。getDealsDetales 给的是按平仓日期排的仓位表,测试器用绿线(详细权益)算恢复因子,我们用蓝线(忽略开平之间波动)算,数据源不同公式相同也必然有差。外汇与贵金属杠杆高,这类统计偏差可能放大对回撤的误判,实盘前务必在 MT5 里用 HistorySelect 自跑一遍核对。 getBalance 与 getBalanceWithPL 的区别在盈亏是否进余额。前者拉起始到指定时间的全部成交,循环里把入金出金加减到初始余额并含经纪商佣金调整;后者再叠加按上述原则算的持仓盈亏。日期传 0 则只从首笔起调。
class="kw">struct DealData { class="type">long ticket; class=class="str">"cmt">// Deal ticket class="type">long order; class=class="str">"cmt">// The number of the order that opened the position class="type">class="kw">datetime DT; class=class="str">"cmt">// Position open date class="type">long DT_msc; class=class="str">"cmt">// Position open date in milliseconds ENUM_DEAL_TYPE type; class=class="str">"cmt">// Open position type ENUM_DEAL_ENTRY entry; class=class="str">"cmt">// Position entry type class="type">long magic; class=class="str">"cmt">// Unique position number ENUM_DEAL_REASON reason; class=class="str">"cmt">// Order placing reason class="type">long ID; class=class="str">"cmt">// Position ID class="type">class="kw">double volume; class=class="str">"cmt">// Position volume(lots) class="type">class="kw">double price; class=class="str">"cmt">// Position entry price class="type">class="kw">double comission; class=class="str">"cmt">// Commission paid class="type">class="kw">double swap; class=class="str">"cmt">// Swap class="type">class="kw">double profit; class=class="str">"cmt">// Profit / loss class="type">class="kw">string symbol; class=class="str">"cmt">// Symbol class="type">class="kw">string comment; class=class="str">"cmt">// Comment specified when at opening class="type">class="kw">string ID_external; class=class="str">"cmt">// External ID }; class="kw">struct DealKeeper { DealData deals[]; /* List of all deals for this position