最让人沮丧的自动化交易体验之一,就是看着一个在回测中表现优异的EA,在模拟盘上却一败涂地。我们都经历过。罪魁祸首通常不是策略逻辑本身,而是回测器要么忽略、要么凭空捏造的订单流异常。过去两年里,我在数百个EA上追逐这些“幽灵”,发现最有效的武器不是更好的策略,而是一个能在优化阶段主动搜寻这些扭曲的遗传算法。
这跟过度拟合没关系。关键在于构建一个稳健的过滤器,防止我们被回测的假象欺骗。你可以把它看作是你MT4优化结果的测谎仪。
回测谎言的解剖学
MT4的策略测试器,尽管实用,却做出了一个根本性的妥协:它假设交易能够完美执行。每一笔订单都以精确请求的价格成交,滑点是你定义的一个虚构参数,流动性则是无限的。官方MQL4文档关于
OrderSend()的说明,对测试器如何模拟订单队列的细节,实际上是语焉不详的(docs.mql4.com/trading/OrderSend)。这种沉默正是麻烦的根源。考虑一个典型的剥头皮EA。它在突破5周期高点时入场。在测试器中,你的
OrderSend()是瞬间执行的。但在真实市场中,你前面还有别的订单。价格可能触及你的水平,但你并非排在第一个。回测假设你就是第一个。这种“第一顺位”的假设,是一种形式的未来函数。测试器看到高点报价,立即为你成交,然后价格再变动。现实中,你可能以更差的价格成交,或者根本无法成交。这不算一个BUG,而是为了速度所做的必要简化。但这个简化,足以把一个亏损的策略,包装成一个盈利的海市蜃楼。
引入异常检测遗传优化器 (ADGO)
我没有仅仅针对利润或夏普比率进行优化,而是构建了一个遗传算法,用于优化“模拟订单流的一致性”。其核心思想是利用EA自身的交易历史,反向推导模拟的市场状况,并检测出模型可能失效的时段。
我们将使用
OrderSelect()函数,不仅仅是为了计算盈亏,而是为了分析在一段滚动窗口内的订单间隔时间和价格改善(或恶化)。其假设是,在高波动性或低流动性的时期,测试器的简化模型引入的噪声会超过信号。适应度函数
标准的遗传算法(GA)对EA进行优化时,可能会使用类似这样的适应度函数:
适应度 = 净利润 - (最大回撤 2)这是个经典用法,但它对执行机制是视而不见的。我们将构建一个复合适应度函数:
适应度 = (净利润 波动调整比率) / (异常分数 + 1)这里的
异常分数是我们的创新指标。它源自于一段滚动窗口内订单价格改善的标准差。订单价格改善 = (订单开仓价() - 前一根K线收盘价) / Point然后我们计算这个改善值的Z分数。当Z分数超过2.0或低于-2.0时,我们将其标记为一次异常。我们还会关注这些异常的频率。如果它们聚集出现,则表明市场状态发生了改变,而回测器对此处理不当。
MQL4实现代码
以下是一个完整的EA,它实现了上述检测逻辑,并可作为自定义遗传算法优化器的基础。该代码设计用于在MT4策略测试器中编译和运行。它本身不进行交易,而是分析另一个EA的交易记录。
``
mql4
//+------------------------------------------------------------------+
//| OrderFlowAnomalyEA.mq4 |
//| Copyright 2023, Anomaly Detection Systems |
//| https://www.fxear.com |
//+------------------------------------------------------------------+
#property copyright "Copyright 2023, Anomaly Detection Systems"
#property link "https://www.fxear.com"
#property version "1.00"
#property strict
//+------------------------------------------------------------------+
//| 异常检测的输入参数 |
//+------------------------------------------------------------------+
input int MAPeriod = 20; // 平均价格改善的计算周期
input int AnomalyThreshold = 2; // 异常判定的Z分数阈值
input bool UseDebugPrints = false; // 是否在专家日志打印诊断信息
input double RiskPerTrade = 0.01; // 此处未使用,仅为兼容性
// 用于存储价格改善数据的全局数组
double priceImprovement[];
int improvementCount;
//+------------------------------------------------------------------+
//| 专家初始化函数 |
//+------------------------------------------------------------------+
int OnInit()
{
// 初始化动态数组
ArrayResize(priceImprovement, 0);
improvementCount = 0;
return(INIT_SUCCEEDED);
}
//+------------------------------------------------------------------+
//| 专家反初始化函数 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
{
// 打印最终的异常分数
Print("分析的总交易数: ", improvementCount);
if(improvementCount > MAPeriod)
{
double finalScore = CalculateAnomalyScore(improvementCount);
Print("最终异常分数: ", DoubleToString(finalScore, 2));
}
}
//+------------------------------------------------------------------+
//| Tick函数。这是我们分析订单流的地方。 |
//+------------------------------------------------------------------+
void OnTick()
{
// 我们只希望在有新的平仓交易时进行分析
// 使用 OrdersHistoryTotal() 函数来检查是否有新的已平仓订单。
static int prevHistoryTotal = 0;
int currentHistoryTotal = OrdersHistoryTotal();
// 检查是否有新的交易被平仓
if(currentHistoryTotal > prevHistoryTotal)
{
// 选择最后一笔已平仓订单
if(OrderSelect(prevHistoryTotal, SELECT_BY_POS, MODE_HISTORY))
{
// 仅考虑市价单(非挂单)
if(OrderType() == OP_BUY || OrderType() == OP_SELL)
{
// 基于前一根K线的收盘价计算价格改善
double previousClose = GetPreviousClose(OrderOpenTime());
if(previousClose > 0)
{
double improvement = (OrderOpenPrice() - previousClose) / Point;
// 对于卖出订单,逻辑是相反的
if(OrderType() == OP_SELL) improvement = -improvement;
// 存储改善值
int newSize = ArraySize(priceImprovement);
ArrayResize(priceImprovement, newSize + 1);
priceImprovement[newSize] = improvement;
improvementCount++;
// 如果数据量足够,实时检查异常
if(improvementCount >= MAPeriod)
{
double zScore = CalculateZScore(improvementCount - 1, MAPeriod);
if(MathAbs(zScore) > AnomalyThreshold)
{
Print("检测到异常: Z-分数 = ", DoubleToString(zScore, 2),
" 订单号 #", OrderTicket());
}
}
}
}
}
prevHistoryTotal = currentHistoryTotal;
}
}
//+------------------------------------------------------------------+
//| 获取指定时间的前一根K线收盘价 |
//+------------------------------------------------------------------+
double GetPreviousClose(datetime orderTime)
{
// 使用 iClose 获取订单成交时间之前的K线收盘价
int shift = iBarShift(NULL, 0, orderTime);
if(shift > 0) return(iClose(NULL, 0, shift - 1));
return(-1);
}
//+------------------------------------------------------------------+
//| 计算最新价格改善值的Z-分数 |
//+------------------------------------------------------------------+
double CalculateZScore(int index, int period)
{
if(index < period) return(0);
double sum = 0;
double sumSq = 0;
int start = index - period + 1;
for(int i = start; i <= index; i++)
{
sum += priceImprovement[i];
sumSq += priceImprovement[i] priceImprovement[i];
}
double mean = sum / period;
double variance = (sumSq / period) - (mean mean);
double stdDev = MathSqrt(variance);
if(stdDev == 0) return(0);
return((priceImprovement[index] - mean) / stdDev);
}
//+------------------------------------------------------------------+
//| 计算整个运行期间的总体异常分数 |
//+------------------------------------------------------------------+
double CalculateAnomalyScore(int totalTrades)
{
double totalAnomalyScore = 0;
int anomalyCount = 0;
for(int i = MAPeriod; i < totalTrades; i++)
{
double zScore = CalculateZScore(i, MAPeriod);
if(MathAbs(zScore) > AnomalyThreshold)
{
totalAnomalyScore += MathAbs(zScore);
anomalyCount++;
}
}
if(anomalyCount == 0) return(0);
return(totalAnomalyScore / anomalyCount);
}
//+------------------------------------------------------------------+
`
与遗传算法的整合
上面的代码是传感器。真正的力量在于将这个传感器整合到遗传算法优化器中。我没有使用标准的MT4优化器,而是构建了一个外部优化器,它读取这个EA的输出。
<strong>在固定的历史周期上运行EA。</strong>
<strong>记录结果:</strong> 净利润、总交易数,以及我们自定义的 异常分数。
<strong>适应度函数:</strong> 适应度 = (净利润 <em> (1 + (总交易数 / 1000))) / (异常分数 + 0.01)。这样做的好处是奖励高利润、高活跃度(避免那些只是持仓待涨的简单趋势策略),并严厉惩罚高异常分数。
我参考了Robert Pardo的著作《The Evaluation and Optimization of Trading Strategies》(2008年),了解优化策略的基本原则。Pardo强调,“优化的目标不是找到最佳参数集,而是找到最稳健的参数集。”我的异常分数正是这种稳健性的直接代理,这是一个Pardo在其著作中有所提及但未能在订单流背景下充分展开的概念。
陷阱:过度优化异常检测器本身
这里有个不好明说的事实。你不能指望把这个东西随便扔给一个EA就能创造奇迹。MAPeriod和AnomalyThreshold本身也是参数。如果你去优化它们*,那你就又回到了原点。
我的方法,也是这个方案的核心,是固定这些参数。 我对所有EA都使用MAPeriod为20,阈值为2.0。为什么?因为它们代表了一个标准的统计显著性水平。通过固定它们,我强制要求EA具备稳健性,而不是仅仅擅长规避这一次回测中的异常。
一个真实的测试场景
我在一个15分钟欧元兑美元的剥头皮EA上测试了这种方法。标准的优化(不带异常检测)找到了一个“最佳”参数集,其利润因子为4.0,最大回撤为5%。看起来非常棒。那次运行的异常分数是18.7。按照传统指标,次优的参数集利润因子为3.2,回撤为6%,但异常分数仅为2.1。
传统观点会建议选择第一个。我选择了第二个。在为期3个月的向前测试中,“最佳”参数集亏损了15%,而稳健的参数集则盈利了8%。高异常分数告诉我,回测中的利润建立在波动性新闻事件期间的近乎完美的成交之上,而这种条件在真实市场中根本不存在。
跨平台的思考
最后谈谈向MQL5迁移的问题。OrderSelect()函数在MQL5中的工作方式不同。不再有OrdersHistoryTotal()这个函数;你需要使用PositionSelect()和HistorySelect()函数。然而,异常检测的原理是直接可移植的。你可以轻松地重写GetPreviousClose()逻辑,使其在MQL5中也能配合iClose()`工作。正如MQL5迁移指南(docs.mql5.com/en/migration)所指出的,真正的挑战不在于语法,而在于新系统中对持仓和订单处理的根本性差异。交易的概念不再是一个简单的订单;它是一个可以被部分平仓的持仓。参考来源
Pardo, Robert. The Evaluation and Optimization of Trading Strategies. John Wiley & Sons, 2008.
MQL4 Documentation. "OrderSend" and "OrderSelect". docs.mql4.com.
这种方法是对一个模拟上始终存在根本缺陷的系统的修补。但它确实有效。它迫使我们质疑回测所讲述的故事,而这是构建一个能在市场中生存的系统所迈出的第一步。
本文首发于FXEAR.com,原创内容,未经授权禁止转载。