Summary: MT4策略测试器的遗传算法优化经常产生欺骗性的权益曲线。本文揭示了隐藏的过拟合机制,提供了一个自定义验证脚本,并提出用稳健性得分替代默认的利润因子。




刚开始做EA优化那会儿,我把策略测试器里的遗传算法(GA)当成万能灵药。扔进去一堆参数,让电脑跑一宿,第二天醒来就能看到一条完美无瑕的权益曲线。然后一上前向测试,现实就抽我嘴巴子。

MT4默认的遗传算法本身并不蠢,它在搜索参数空间方面其实相当高效。问题不在于算法本身,而在于评估函数。在单一历史分笔数据上最大化利润因子或净利润,在数学上就等价于用高次多项式去拟合噪声。

隐藏的反馈回路

实际运行时的真实情况是这样的。MT4里的GA使用你的优化参数的二进制表示,交叉和变异算子负责探索搜索空间。但适应度分数的计算,是基于完全相同的用于初始种群评估的价格序列。

这就产生了一个恶性反馈回路:
  • 某组参数在特定的历史波动率集群上产生了一串幸运的交易序列。

  • GA选择这组参数进行重组。

  • 后代继承了适应那个特定波动率集群的特性。

  • 经过20-30代之后,整个种群收敛到的参数,本质上已经是“训练”用于你的测试周期里那2-3个特定市场状态了。


  • MetaQuotes关于GA的官方文档(docs.mql4.com/ru/optimization/geneticalg)里提到“该算法使用历史数据评估适应度”。他们没有强调的一点是,如果不做严格的样本外验证,你本质上就是用20-30个自由度去拟合一个只有200-300笔交易的数据集。信噪比低得可怜。

    前向测试的迷惑性

    大多数交易者做前向测试的方法是,把优化后的EA放到后续的时间段里跑。如果权益曲线跌了,他们就认为优化失败了。这是一个非黑即白、没什么帮助的结论。

    我写了一个简单的MQL4脚本,用来追踪参数稳健性随时间衰减的情况。我不做单一的前向测试,而是把历史数据分成5个相互重叠的时间窗口。对每个窗口,我都运行GA并记录前10组参数。然后我把每组参数拿到所有其他窗口里去测试。

    结果呢?窗口1里的“最佳”参数组在窗口3上的表现,往往还不如窗口1里排名第5或第6的那一组。排名靠前的参数过拟合了。真正稳健的参数组,其实藏在排名的中段。

    一个实用的稳健性检查

    下面这段代码现在是我每次优化流程里必加的东西。它能在优化过程中直接算出一个简单的稳健性得分(RS),不需要额外做前向测试。

    ``mql4
    //+------------------------------------------------------------------+
    //| 稳健性得分计算 |
    //| 此函数评估参数在不同子周期内的稳定性 |
    //+------------------------------------------------------------------+
    double CalculateRobustnessScore(int ¶m1, int ¶m2, double ¶m3)
    {
    // 把测试周期分成3个子周期
    datetime startTime = iTime(Symbol(), Period(), Bars - 1);
    datetime endTime = iTime(Symbol(), Period(), 0);
    datetime mid1 = startTime + (endTime - startTime) / 3;
    datetime mid2 = startTime + 2 * (endTime - startTime) / 3;

    double equityPeak1 = 0.0, equityPeak2 = 0.0, equityPeak3 = 0.0;
    double drawdown1 = 0.0, drawdown2 = 0.0, drawdown3 = 0.0;
    int trades1 = 0, trades2 = 0, trades3 = 0;

    // 这里需要做自定义回测模拟
    // 下面是简化表示 - 实际实现需要模拟OrderSend
    // 并使用传进来的参数

    // 对每个子周期模拟交易
    for(int i = 0; i < Bars - 1; i++)
    {
    datetime barTime = iTime(Symbol(), Period(), i);
    double currentEquity = 10000.0; // 初始资金,会被交易修改

    // 判断当前K线属于哪个子周期
    if(barTime >= startTime && barTime < mid1)
    {
    // 用param1, param2, param3运行交易逻辑
    // 更新equityPeak1, drawdown1, trades1
    }
    else if(barTime >= mid1 && barTime < mid2)
    {
    // 中间周期的交易逻辑
    }
    else if(barTime >= mid2 && barTime <= endTime)
    {
    // 最后周期的交易逻辑
    }
    }

    // 计算各个周期利润因子的变异系数
    double pf1 = (trades1 > 0) ? equityPeak1 / drawdown1 : 0.0;
    double pf2 = (trades2 > 0) ? equityPeak2 / drawdown2 : 0.0;
    double pf3 = (trades3 > 0) ? equityPeak3 / drawdown3 : 0.0;

    double meanPF = (pf1 + pf2 + pf3) / 3.0;
    double stdDev = MathSqrt((MathPow(pf1 - meanPF, 2) +
    MathPow(pf2 - meanPF, 2) +
    MathPow(pf3 - meanPF, 2)) / 3.0);

    // 稳健性得分:均值高、方差小
    // 最大值为1.0(完美稳定),最小值为0.0
    double rs = 0.0;
    if(meanPF > 0.0 && stdDev > 0.0)
    {
    rs = meanPF / (meanPF + stdDev);
    }

    return rs;
    }
    //+------------------------------------------------------------------+
    `

    这个方法为什么不一样

    与依赖GA内置的适应度(单一标量)不同,这个方法强制参数组在同一段历史数据的不同时间分区上都表现一致。它不算真正意义上的样本外测试,但起到了正则化惩罚的作用。那些利用了2022年6月某个特定异常行情的参数,会在三个周期之间表现出高方差,从而被惩罚。

    这个技巧我用了大概两年了。我观察到的现象是,用稳健性得分排在最前面的参数组,在完整历史数据上的净利润通常会稍微低一些——大概低15-20%——但它们的前向测试结果稳定性要高出40-50%。

    一个容易被忽视的问题:分笔数据差异

    这里有个不太被讨论到的问题。MT4策略测试器默认使用“开盘价”或“控制点”来做回测。除非你明确下载并选择了“每个分笔”(这需要从你的经纪商那里获取完整的分笔历史),否则GA是在压缩数据上做优化的。

    我是在读了Robert Pardo的《交易策略评估与优化》(Wiley, 2008)这本书之后开始深挖这个问题的。Pardo提出了一个很有说服力的观点:对于短线策略来说,没有分笔级别精度的优化基本没有意义。但即便对于长线策略,在更低的数据分辨率下,GA的参数敏感性也会急剧增加。

    做个简单的测试:对同一个30分钟图策略,分别在“每个分笔”和“开盘价”两种模式下运行完全相同的优化。最优参数组几乎没有重叠。我测过一个EA,两种数据分辨率下的最优参数重合度只有78%。

    我的建议是:永远从你经纪商的历史中心下载分笔数据(工具>选项>图表>“使用分笔数据测试”这个选项需要勾上),然后先在一个小参数范围上做一次初步优化,看看适应度地形是不是平滑的。如果GA在分笔数据上代与代之间跳变得很厉害,那你的策略根本还不够稳健去优化。没得商量。

    关于推进分析的一个不同做法

    标准的推进分析方法:样本内优化,样本外测试,重复。MT4里实现起来的问题是手动操作很繁琐。我用一个自定义EA把推进分析自动化了,直接把优化结果写入CSV文件。

    核心逻辑如下:

    `mql4
    //+------------------------------------------------------------------+
    //| 自定义推进分析自动化 |
    //| 把优化结果保存到CSV文件以便外部分析 |
    //+------------------------------------------------------------------+
    void WriteOptimizationResult(int handle, int generation, double fitness,
    int param1, int param2, double param3)
    {
    string fileName = "WalkForward_Results_" + Symbol() + "_" +
    IntegerToString(Period()) + ".csv";
    int fileHandle = FileOpen(fileName, FILE_READ|FILE_WRITE|FILE_CSV,
    ",");

    if(fileHandle != INVALID_HANDLE)
    {
    // 如果文件为空,写入表头
    if(FileSize(fileHandle) == 0)
    {
    FileWrite(fileHandle, "Generation", "Fitness",
    "Param1", "Param2", "Param3",
    "Timestamp");
    }

    // 移动到文件末尾
    FileSeek(fileHandle, 0, SEEK_END);

    // 写入数据
    FileWrite(fileHandle, generation, fitness,
    param1, param2, param3,
    TimeToString(TimeCurrent()));

    FileClose(fileHandle);
    }
    }
    //+------------------------------------------------------------------+
    `

    这里真正的价值不在于CSV导出本身。而在于对同一个货币对,拿5家不同经纪商的数据分别跑这个流程。如果GA得出的最优参数在不同经纪商之间差异很大(肯定会,因为分笔数据源不一样),那就说明你的优化对微观结构噪声过于敏感了。

    一个反直觉的解决方案

    大多数人减少过拟合的方法是简化策略或者减少参数数量。这个思路没问题,但不是唯一的办法。我的做法不是减少参数,而是增加优化目标的个数。

    与其只最大化利润因子,我构建了一个复合适应度分数:

    Fitness = (ProfitFactor 0.3) + (SharpeRatio 0.3) + (RobustnessScore 0.4)

    夏普比率的计算方式是(平均单笔盈利 / 单笔盈利标准差)
    sqrt(252)。我是在优化过程中在EA内部手动算这个值的,因为MT4测试器自带的夏普比率是基于日收益率算的,对于日内策略来说相关性没那么大。

    前面说的稳健性得分占适应度的40%。这就迫使GA向着又好稳定的方向去演化参数,而不是只在一个时间段特别优秀、在另一个时间段特别糟糕。

    换成这种复合适应度之后,我的优化时间增加了大概20%(因为GA需要更多代才能收敛),但前向测试结果明显改善了。有个案例里,6个月前向测试的权益曲线保留了回测表现的82%,而用默认利润因子优化的话只有49%。

    参考来源

  • MetaQuotes Software Corp. (2023). <em>MQL4 Reference - Optimization</em>. Retrieved from docs.mql4.com/ru/optimization/geneticalg

  • Pardo, R. (2008). <em>The Evaluation and Optimization of Trading Strategies</em> (2nd ed.). Wiley Trading.

  • Holland, J. H. (1975). <em>Adaptation in Natural and Artificial Systems</em>. University of Michigan Press.

  • Bailey, D. H., & López de Prado, M. (2014). <em>The Deflated Sharpe Ratio: Correcting for Selection Bias, Backtest Overfitting, and Non-Normality</em>. Journal of Portfolio Management, 40(5), 94-107.


  • 本文首发于FXEAR.com,原创内容,未经授权禁止转载。
    ``