智能系统健壮性测试·进阶篇
「MT5优化里防过度拟合的四道闸」
在 MT5 里给 EA 跑参数优化,本质是在特定时段挑出绩效最优值。但前面章节已经演示过:只调少量参数、小幅扰动,很容易在随机数据上凑出漂亮曲线——这就是过度拟合。想要回测结果有点信誉,得从样本、参数、成本、步长四个角度同时设防。 样本量不够,中值就靠不住。大数定律下,策略回报若服从某种分布,样本越多,样本回报中值才越接近真实中值。除高频剥头皮外,建议至少拉 10 年、数千笔交易的数据;若策略对宏观制度偏转敏感,用近期数据也行,但底线是能证明历史上有可重复的形态。跑在较高时间帧的 EA,还可以跨多种资产测试,把样本面摊开,顺带验证策略能否适配不同市场行为。 参数越多,优化器筛出“假赢家”的路径就越杂。David Aronson 在《基于证据的技术分析》里提到,单参数策略虽少见,但过度拟合概率低得多。实操中可调参数压到 5 个以内,魔幻数字、固化了的指标回望周期这类不影响逻辑的项,别算进优化变量。 成本审计最容易被新手忽略。MT5 策略测试器里点资金($)标志,把佣金、点差、掉期利率调成和你实盘环境一致。散户做的是负和游戏:券商笔笔抽成。以 EURUSD 为例,做市商无佣金但点差 1 点起,ECN 收每手 7 美元且点差 0.1 点起;若做 10 点止盈剥头皮,每笔实际被抽约 10% 成本。一个 55% 胜率、1:1 风险回报的健壮策略,无成本时净值曲线很美,加上这 10% 摩擦就劳而无获。初学交易者碰剥头皮,统计劣势太大,不建议碰。 步长别抠太细。MT5 优化时把步长相对常用值放大,避免盯着 RSI 回望 11 还是 12 这种小事,错过更广的参数绩效形态。个人偏好用盈利因子(毛盈利/毛亏损)而非总回报做量值。参数敏感性上,理想情况是最优值周边呈凹形:偏离最优后性能平稳下滑,证明策略在参数扰动下仍有优势。 下面这段 Python 模拟了 1000 笔、55% 胜率、每笔 10% 成本下的净值侵蚀,可本地跑一遍直观感受摩擦的杀伤:
<span class="keyword">class="kw">import</span> numpy <span class="keyword">as</span> np <span class="keyword">class="kw">import</span> matplotlib.pyplot <span class="keyword">as</span> plt <span class="keyword">class="kw">import</span> pandas <span class="keyword">as</span> pd <span class="comment"># Parameters</span> n_trades = <span class="number">class="num">1000</span> <span class="comment"># Number of trades</span> win_rate = <span class="number">class="num">0.55</span> <span class="comment"># class="num">55% win rate</span> commission_rate = <span class="number">class="num">0.1</span> <span class="comment"># class="num">10% commission per trade</span> initial_balance = <span class="number">class="num">10000</span> <span class="comment"># Starting balance</span> trade_amount = <span class="number">class="num">100</span> <span class="comment"># Amount per trade</span> <span class="comment"># Simulate the trades</span> np.random.seed(<span class="number">class="num">42</span>) <span class="comment"># For reproducibility</span> <span class="comment"># Generate random outcomes(class="num">1 for win, class="num">0 for loss)</span> outcomes = np.random.choice([<span class="number">class="num">1</span>, <span class="number">class="num">0</span>], size=n_trades, p=[win_rate, <span class="number">class="num">1</span> - win_rate]) <span class="comment"># Initialize balance and equity curve</span> balance = initial_balance equity_curve = [balance] <span class="comment"># Simulate each trade</span> <span class="keyword">for</span> outcome <span class="keyword">in</span> outcomes: <span class="comment"># Calculate the result of the trade</span> <span class="keyword">if</span> outcome == <span class="number">class="num">1</span>: <span class="comment"># Win: add profit(trade_amount) and subtract commission</span> balance += trade_amount - (trade_amount * commission_rate) <span class="keyword">else</span>: <span class="comment"># Loss: subtract loss(trade_amount) and subtract commission</span> balance -= trade_amount + (trade_amount * commission_rate) <span class="comment"># Append the updated balance to the equity curve</span> equity_curve.append(balance) <span class="comment"># Plot the equity curve</span> plt.figure(figsize=(<span class="number">class="num">7</span>, <span class="number">class="num">4</span>)) plt.plot(equity_curve) plt.title(<span class="class="type">class="kw">string">&class="macro">#x27;Equity Curve with class="num">10% Commission on Each Trade&class="macro">#x27;</span>) plt.xlabel(<span class="class="type">class="kw">string">&class="macro">#x27;Number of Trades&class="macro">#x27;</span>) plt.ylabel(<span class="class="type">class="kw">string">&class="macro">#x27;Balance&class="macro">#x27;</span>) plt.grid(<span class="literal">True</span>) plt.show()
◍ 别把近期行情算进优化里
改 EA 或换周期品种做测试前,先把最近一段行情剔出认知范围。这部分数据只用来事后验真,不参与任何参数观察和规则修改,这就是样本内/外切分。核心目的是防前瞻偏差——你基于刚发生的形态去调逻辑,等于偷看了答案;同时也能压住过度拟合的概率。 切分比例看样本总量定,常用 7:3、8:2、9:1。样本内做全部推断和修改,样本外只跑回测验成色。若样本内漂亮、样本外亏或白忙,多半是近期形态偏移吃掉了优势,或优化期被拟合了。外汇贵金属波动大,这种侵蚀在极端行情里会更猛。 评估一致性时,先做一个关掉复利效应的版本,否则尾部净值会被放大、扭曲判断。重点盯三个量:盈利因子落在 1.2–1.5 较合理,低于 1.2 盈利存疑,高于 1.5 可能样本太小或没算交易成本;最大净值回撤要比你容忍上限再低至少 10%,用净值回撤而非绝对回撤,它反映的是潜在风险;LR 相关性超 0.9 说明净值曲线稳定。多空双向的 EA,两边胜率和交易量要相近,差太多是失衡信号。 传统派爱用前向游走,一段段往当前推。参数少、没拟合特定值,一次样本外就够——优势在信号规则不在参数。但机器学习类 EA 不同,模型参数随训练周期变,前向游走是必要的,得审计不同数据集下的衰减。终判定夺是:近期仍维持一定盈利,才说明生存力还在。
揪出回测里的异常值水分
一套 EA 的盈利若主要靠少数几笔毛刺单撑着,拿大样本去近似期望收益就失去意义。审计时先扫一眼净值曲线:健康的曲线应是稳定抬升,而非靠零星巨阳线把水位拉起来;同时比对报告里最大单笔收益与平均增益,差距过大就值得警惕。 时间维度上也要防“短期暴利”。在 MT5 回测报告右键保存为表格,本例里 Deals 字段行数达到 9342,说明成交笔数够厚,但还得看月度回报分布是否均匀。把文件喂进下面的 Python 脚本,能直接吐出按月份着色的百分比回报表,红绿一眼辨冷暖。 跨资产 EA 另有坑:某一品种可能包揽绝大部分利润,其余品种凑数。脚本末尾的饼图就是干这个的——哪块扇区独大,哪块策略就偏科。外汇与贵金属波动剧烈、杠杆风险高,这类集中暴露可能在实盘放大回撤概率。 别把正态当圣经 回撤月几乎无法在长回测中避免,重点不是零回撤,而是单月回撤幅度别显著超出全样本平均。超出越多,策略对特定市况的脆弱性越高。
class="kw">import pandas as pd class="kw">import matplotlib.pyplot as plt # Replace &class="macro">#x27;your_file.xlsx&class="macro">#x27; with the path to your file input_file = &class="macro">#x27;your_backtest.xlsx&class="macro">#x27; # Load the Excel file and skip the first {skiprows} rows, skiprows = the row of "DEAL" data = pd.read_excel(input_file, skiprows=class="num">9342) # Select the &class="macro">#x27;profit&class="macro">#x27; column(assumed to be &class="macro">#x27;Unnamed: class="num">10&class="macro">#x27;) and filter rows as per your instructions profit_data = data[[&class="macro">#x27;Time&class="macro">#x27;,&class="macro">#x27;Symbol&class="macro">#x27;,&class="macro">#x27;Profit&class="macro">#x27;,&class="macro">#x27;Balance&class="macro">#x27;]][class="num">1:-class="num">1] profit_data = profit_data[profit_data.index % class="num">2 == class="num">0] # Filter for rows with odd indices profit_data = profit_data.reset_index(drop=True) # Reset index # Convert to class="type">class="kw">float, then apply the condition to set values to class="num">1 if > class="num">0, otherwise to class="num">0 profit_data[[&class="macro">#x27;Profit&class="macro">#x27;,&class="macro">#x27;Balance&class="macro">#x27;]] = profit_data[[&class="macro">#x27;Profit&class="macro">#x27;,&class="macro">#x27;Balance&class="macro">#x27;]].apply(pd.to_numeric, errors=&class="macro">#x27;coerce&class="macro">#x27;).fillna(class="num">0) # Convert to class="type">class="kw">float, replacing NaN with class="num">0 # Load the data data = profit_data # Calculate percentage gain compared to the previous balance data[&class="macro">#x27;percentage_gain&class="macro">#x27;] = data[&class="macro">#x27;Profit&class="macro">#x27;] / data[&class="macro">#x27;Balance&class="macro">#x27;].shift(class="num">1) * class="num">100 # Drop the first row because it doesn&class="macro">#x27;t have a previous balance to compare data = data.dropna() # Ensure &class="macro">#x27;time&class="macro">#x27; is in class="type">class="kw">datetime format data[&class="macro">#x27;Time&class="macro">#x27;] = pd.to_datetime(data[&class="macro">#x27;Time&class="macro">#x27;]) # Extract the year and month from the &class="macro">#x27;time&class="macro">#x27; column data[&class="macro">#x27;year&class="macro">#x27;] = data[&class="macro">#x27;Time&class="macro">#x27;].dt.year data[&class="macro">#x27;month&class="macro">#x27;] = data[&class="macro">#x27;Time&class="macro">#x27;].dt.month_name() # Ensure months are ordered correctly(January to December) month_order = [&class="macro">#x27;January&class="macro">#x27;, &class="macro">#x27;February&class="macro">#x27;, &class="macro">#x27;March&class="macro">#x27;, &class="macro">#x27;April&class="macro">#x27;, &class="macro">#x27;May&class="macro">#x27;, &class="macro">#x27;June&class="macro">#x27;, &class="macro">#x27;July&class="macro">#x27;, &class="macro">#x27;August&class="macro">#x27;, &class="macro">#x27;September&class="macro">#x27;, &class="macro">#x27;October&class="macro">#x27;, &class="macro">#x27;November&class="macro">#x27;, &class="macro">#x27;December&class="macro">#x27;] data[&class="macro">#x27;month&class="macro">#x27;] = pd.Categorical(data[&class="macro">#x27;month&class="macro">#x27;], categories=month_order, ordered=True) # Calculate the total class="kw">return for each year-month combination monthly_return = data.groupby([&class="macro">#x27;year&class="macro">#x27;, &class="macro">#x27;month&class="macro">#x27;])[&class="macro">#x27;percentage_gain&class="macro">#x27;].sum().unstack(fill_value=class="num">0) # Function to apply class="type">class="kw">color formatting based on class="kw">return value def colorize(val): class="type">class="kw">color = &class="macro">#x27;green&class="macro">#x27; if val > class="num">0 else &class="macro">#x27;red&class="macro">#x27; class="kw">return f&class="macro">#x27;background-class="type">class="kw">color: {class="type">class="kw">color}&class="macro">#x27; # Display the table with class="type">class="kw">color coding styled_table = monthly_return.style.applymap(colorize, subset=pd.IndexSlice[:, :]) # Show the table styled_table class="kw">import seaborn as sns # Group by symbol and calculate the total profit/loss for each symbol symbol_return = data.groupby(&class="macro">#x27;Symbol&class="macro">#x27;)[&class="macro">#x27;percentage_gain&class="macro">#x27;].sum() # Plot the pie chart plt.figure(figsize=(class="num">7, class="num">3)) plt.pie(symbol_return, labels=symbol_return.index, autopct=&class="macro">#x27;%class="num">1.1f%%&class="macro">#x27;, startangle=class="num">90, colors=sns.color_palette("Set2", len(symbol_return))) # Title and display plt.title(&class="macro">#x27;Total Return by Symbol&class="macro">#x27;) plt.show()
「四种超出回测的健壮性压力测试」
回测漂亮不等于能活。下面四种测试更贴近真实战场,但需要你多花专业时间和算力。 实盘或纸面小仓位跑策略,是检验滑点、点差、执行延迟的唯一真实路径。它还能暴露你在真实盈亏波动下的执行变形——很多策略回测犀利,一上实盘就偏离预期。 蒙特卡洛模拟把已有交易的盈亏顺序随机打散,生成成千上万条净值曲线,甚至对入场、止损做随机扰动。它能给出潜在最糟场景,逼你确认策略不是靠历史样本过拟合出来的。 最大回撤看的是净值峰顶到谷底的最大亏损,再算破产风险——按当前风险回报配置,账户归零的概率。外汇和贵金属杠杆高,这俩数字直接决定你能否长期留在牌桌上。 MT5 策略测试器的压力测试可模拟进出场点与实际成交的随机偏差,按波动、单量、流动性给滑点建模。新闻事件类策略对滑点极敏感,必须单独压。但多数散户不用死磕这块:滑点双向发生,正负常相互抵消,相对别的交易成本影响偏小。
◍ 画得少,看得清
一套 EA 的健壮性,不靠美化回测曲线证明。前面几节把样本外测试、三类异常值(交易 / 时间 / 品种)和 Python 验证流程拆开后,核心就一句:能经住少而硬的检验,比堆满参数的漂亮报告值钱。 作者在原讨论里点过一个数:若月回报与标的实际行情相关性超过 0.2,回测利润大概率来自趋势暴露而非策略逻辑,这种系统上实盘要谨慎。外汇和贵金属杠杆高,这类隐性偏差会放大爆仓概率。 打开 MT5 把优化结果丢进样本外周期跑一遍,只留那些买卖规则对称、胜率与交易量双边接近的设定,其余删掉就行。