MQL5自优化智能交易系统(第九部分):双移动平均线交叉
在本系列文章中,我们从多个角度探讨了如何降低传统移动平均线交叉策略的信号滞后性。
我们最初的尝试是使用统计建模工具来提前预测均线交叉。在这一方向上取得了一定的进展:我们发现,在合适的市场环境下,预测均线交叉比直接预测价格更准确。之后,我们发现了另一种进一步降低滞后性的方法:将两条均线的计算周期固定为相同的数值,一条应用于开盘价,另一条应用于收盘价,以此产生交叉信号。事实证明该方法十分有效,无需依赖高级的建模工具,仅通过相同周期加上不同价格的组合,就能进一步降低信号滞后。
在本文中,我们将探索一种全新的、此前未涉及过的独特方案。正如生活和数学中的大多数问题一样,一个问题往往有多种解法,但每种方案都有其优缺点。通过对比这些方案,我们希望搞清楚,究竟能在多大程度上控制系统的信号滞后。
这里,我们将实现一种名为“双移动平均线交叉”的策略。如图1所示,传统均线交叉策略通常仅在单一时间周期上使用两条不同周期的均线。与上一篇中两条均线使用固定相同周期的方案不同,这次我们略微回归传统思路,允许两个指标使用不同的周期。
这种原始做法的缺陷在于:入场信号往往确认得太迟 —— 行情已经启动后才出现信号,导致入场滞后甚至错失机会。

图 1:在日线时间框架上可视化我们的移动平均线交叉策略
我们这里提出的方法并非全新的概念。事实上,全自动化交易员或人工交易员长期以来一直使用类似的逻辑。其核心思路是:首先在较高的时间框架(如图1所示的日线图)上观察均线交叉形态。然而,我们不会立刻根据这些信号执行交易。相反,一旦在较高的时间框架上发现交叉信号,我们就切换到较低的时间框架(如图2所示的30分钟图),寻找与较高的时间框架信号方向一致的交叉形态。
人工交易员常说:“要顺着大周期的方向交易”。而在我们此前提及的大部分算法交易内容中,策略都是在同一个时间框架上应用并执行的。但今天,我们将策略分两次应用:一次在较高的时间框架上,一次在较低的时间框架上。较高的时间框架用于确定当日的方向偏向,较低的时间框架则用于寻找符合该偏向的入场信号。这正是我们双交叉策略的核心精髓。我们的期望是:先在较高的时间框架确立方向偏向,再在较低的时间框架寻找顺势交易机会,以此来消除较高的时间框架信号所产生的滞后性。
既然已经理清了思路,我们就可以开始编写代码实现该策略,并验证其是否具备实战价值。在正式动笔之前,很显然这种方案包含多个需要仔细考量的动态模块。一个关键问题是,如何定义入场条件?假设在较高的时间框架上出现了看涨交叉,那么在较低的时间框架上我们有两种选择:
- 逆势入场:等待较低时间框架上出现看跌交叉后反向入场 —— 认为最终会在当日回归看涨方向。
- 顺势入场:直接等待较低的时间框架形成与较高的时间框架方向一致的看涨交叉后入场。
这两种选择代表了该策略下两种截然不同的交易理念。相比之下,出场逻辑会带来更多的复杂度与变体,每种方式都有其各自的利弊权衡。例如,当较低的时间框架不再符合较高时间框架方向的偏向时,我们选择平仓。或者,仅在较高的时间框架本身方向的偏向发生改变时出场。也就是说,如果当日开盘日线图给出看涨信号,我们就一直持仓,直到较高的时间框架翻转成看跌。
正如您所见,这套方法拥有非常多入场与出场的组合方式。我们不能仅靠推理来确定最优组合,还必须借助遗传优化器。它有助于我们从市场数据中识别出:在众多方案中,究竟哪种组合能带来最大的利润。

图 2:在较低的时间框架30分钟图(M30)上可视化我们的移动平均线交叉策略
我们要做的第一步,是定义几个系统常量 —— 在整个应用程序开发阶段,这些常量值都必须固定不变。为简化流程,我们先固定时间框架常量:日线图(D1)作为较高的时间框架,15分钟图(M15)作为较低的时间框架,4小时图(H4)用于计算止损。
后续我们可以考虑把这些系统常量更改为可优化参数,让遗传优化器自动调整,以确保获得最佳的入场效果。然而,在起步阶段,先保持这些值固定不变。//+------------------------------------------------------------------+ //| Double Crossover.mq5 | //| Copyright 2024, MetaQuotes Ltd. | //| https://www.mql5.com | //+------------------------------------------------------------------+ #property copyright "Copyright 2024, MetaQuotes Ltd." #property link "https://www.mql5.com" #property version "1.00" //+------------------------------------------------------------------+ //| System constants | //+------------------------------------------------------------------+ //--- System time frames #define TF_1 PERIOD_D1 #define TF_2 PERIOD_M15 #define TF_3 PERIOD_H4 #define LOT_MULTILPLE 1
我们还需要定义一组自定义枚举,表示策略可运行的不同模式。例如,应用程序可以运行在趋势跟踪模式或均值回归模式下,而专用的枚举类型能让用户在两者之间自由切换。同样地,我们会定义另一个枚举,用于指定平仓条件是在较低的时间框架上还是在较高的时间框架上评估。
//+------------------------------------------------------------------+ //| Custom enumerations | //+------------------------------------------------------------------+ //--- What trading style should we follow when opening our positions, trend following or mean reverting? enum STRATEGY_MODES { TREND = 0, //Trend Following Mode MEAN_REVERTING = 1 //Mean Reverting Mode }; //--- Which time frame should we consult, when determining if we should close our position? enum CLOSING_TIME_FRAME { HIGHER_TIME_CLOSE = 0, //Close on higher time frames LOWER_TIME_CLOSE = 1 //Close on lower time frames };
我们的输入参数非常简洁直观。我们会先在较高时间框架上指定一个周期值,然后在第一条与第二条移动平均线周期之间定义一个间隔值。这种设计能确保遗传优化器选择的间隔至少为1—— 从结构上强制让两条均线的周期保持非0差值。
//+------------------------------------------------------------------+ //| Inputs | //+------------------------------------------------------------------+ input group "Technical Indicators" input int ma_1_period = 10; //Higher Time Frame Period input int ma_1_gap = 20; //Higher Time Frame Period Gap input int ma_2_period = 10; //Lower Time Frame Period input int ma_2_gap = 20; //Lower Time Frame Period Gap input group "Strategy Settings" input STRATEGY_MODES strategy_mode = 0; //Strategy Operation Mode input CLOSING_TIME_FRAME closing_tf = 0; //Strategy Closing Timeframe
该应用程序只需要适量的全局变量,例如技术指标对象,以及用于监控当前市场价格的实例。
//+------------------------------------------------------------------+ //| Global variables | //+------------------------------------------------------------------+ int ma_c_1_handle,ma_c_2_handle,ma_c_3_handle,ma_c_4_handle; double ma_c_1[],ma_c_2[],ma_c_3[],ma_c_4[]; double volume_min; double bid,ask; int state;
我们唯一需要的外部依赖库只有Trade库,用于按需执行开仓与平仓操作。
//+------------------------------------------------------------------+ //| Libraries | //+------------------------------------------------------------------+ #include <Trade\Trade.mqh> CTrade Trade;
初始化时,我们将根据用户传入的设置来配置技术指标。同时会把系统状态重置为-1,表示当前无持仓,并记录市场允许的最小交易手数。
//+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { //--- volume_min = SymbolInfoDouble(Symbol(),SYMBOL_VOLUME_MIN); ma_c_2_handle = iMA(Symbol(),TF_1,ma_1_period,0,MODE_SMA,PRICE_CLOSE); ma_c_1_handle = iMA(Symbol(),TF_1,(ma_1_period + ma_1_gap),0,MODE_SMA,PRICE_CLOSE); ma_c_4_handle = iMA(Symbol(),TF_2,ma_2_period,0,MODE_SMA,PRICE_CLOSE); ma_c_3_handle = iMA(Symbol(),TF_2,(ma_2_period + ma_2_gap),0,MODE_SMA,PRICE_CLOSE); state = -1; //--- return(INIT_SUCCEEDED); }
当应用程序卸载(或退出)时,我们将释放技术指标所占用的内存。
//+------------------------------------------------------------------+ //| Expert deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { //--- IndicatorRelease(ma_c_1_handle); IndicatorRelease(ma_c_2_handle); IndicatorRelease(ma_c_3_handle); IndicatorRelease(ma_c_4_handle); }
当接收到新的价格数据时,我们会检查在较低时间框架(本例为15分钟图M15)上是否形成了新的K线。如果检测到新的K线,我们就立即更新系统的内部状态。
//+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick() { //--- datetime current_time = iTime(Symbol(),TF_2,0); static datetime time_stamp; if(time_stamp != current_time) { time_stamp = current_time; update(); } } //+------------------------------------------------------------------+
其中最重要的模块之一,是用于识别交易形态的函数。该函数接收一个名为padding的参数,代表我们持仓的止损幅度。Padding由上层函数通过历史价格区间自动计算得出。作为一项安全机制,交易形态检测函数会首先检查总持仓数是否为0,以避免过度交易。
如果在较高的时间框架上出现看涨交叉,且策略处于趋势跟踪模式,该函数就会在较低的时间框架上寻找同步的看涨交叉。如果策略处于均值回归模式,则会寻找反向交叉(如看跌交叉)并进行反向交易。做空入场的逻辑则完全相反:如果在较高的时间框架上出现看跌交叉,我们会根据所选模式,在较低的时间框架上寻找对应的信号。
//+------------------------------------------------------------------+ //| Find a trading signal | //+------------------------------------------------------------------+ void find_setup(double padding) { if(PositionsTotal() == 0) { //--- Reset the system state state = -1; //--- Bullish on the higher time frame if(ma_c_1[0] > ma_c_2[0]) { //--- Trend following mode if((ma_c_3[0] > ma_c_4[0]) && (strategy_mode == 0)) { Trade.Buy(volume_min,Symbol(),ask,(bid - padding),0,""); state = 1; } //--- Mean reverting mode if((ma_c_3[0] < ma_c_4[0]) && (strategy_mode == 1)) { Trade.Buy(volume_min,Symbol(),ask,(bid - padding),0,""); state = 1; } } //--- Bearish on the higher time frame if(ma_c_1[0] < ma_c_2[0]) { //--- Trend following mode if((ma_c_3[0] < ma_c_4[0]) && (strategy_mode == 0)) { Trade.Sell(volume_min,Symbol(),bid,(ask + padding),0,""); state = 0; } //--- Mean reverting mode if((ma_c_3[0] > ma_c_4[0]) && (strategy_mode == 1)) { Trade.Sell(volume_min,Symbol(),bid,(ask + padding),0,""); state = 0; } } } }
开仓后,我们会调用一个独立的持仓管理函数。该函数也会根据我们希望基于较低的时间框架还是较高的时间框架平仓,在两种不同的模式下运行。系统状态会在开仓时更新,并决定交易方向。例如,状态为0表示我们开了空头订单。如果第一条均线已上穿第二条均线(表明日线出现看涨势头),并且我们设置基于较高的时间框架来管理出场,就会立即平仓。另外,目前尚无法确定哪种平仓条件效率更高,因此必须对两种模式都进行测试。
//+------------------------------------------------------------------+ //| Manage our open positions | //+------------------------------------------------------------------+ void manage_setup(void) { if(closing_tf == 0) { if((state ==0) && (ma_c_1[0] > ma_c_2[0])) Trade.PositionClose(Symbol()); if((state ==1) && (ma_c_1[0] < ma_c_2[0])) Trade.PositionClose(Symbol()); } else if(closing_tf == 1) { if((state ==0) && (ma_c_3[0] > ma_c_4[0])) Trade.PositionClose(Symbol()); if((state ==1) && (ma_c_3[0] < ma_c_4[0])) Trade.PositionClose(Symbol()); } }
最后,我们还需要一个函数来更新关键的系统变量,例如当前的买入价与卖出价。我们同时会记录第三个时间框架(即风险时间框架)上最近10期的最高价和最低价。在本例中,风险时间框架为4小时图(H4),其恰好介于15分钟周期与日线周期之间。非常适合用来衡量市场风险,并据此设置既不过于贴近、也不过度远离入场点的止损位。
//+------------------------------------------------------------------+ //| Update our technical indicators and positions | //+------------------------------------------------------------------+ void update(void) { //Update technical indicators and market readings CopyBuffer(ma_c_2_handle,0,0,1,ma_c_2); CopyBuffer(ma_c_1_handle,0,0,1,ma_c_1); CopyBuffer(ma_c_4_handle,0,0,1,ma_c_4); CopyBuffer(ma_c_3_handle,0,0,1,ma_c_3); bid = SymbolInfoDouble(Symbol(),SYMBOL_BID); ask = SymbolInfoDouble(Symbol(),SYMBOL_ASK); vector high = vector::Zeros(10); vector low = vector::Zeros(10); low.CopyRates(Symbol(),TF_3,COPY_RATES_LOW,0,10); high.CopyRates(Symbol(),TF_3,COPY_RATES_HIGH,0,10); vector var = high - low; double padding = var.Mean(); //Find an open position if(PositionsTotal() == 0) find_setup(padding); //Manage our open positions else if(PositionsTotal() > 0) manage_setup(); } //+------------------------------------------------------------------+
在开始回测之前,我们首先要选择刚刚编写好的EA —— 双均线交叉EX5。接下来,我们选择交易品种欧元兑美元(EURUSD),使用1分钟时间框架,设定回测时间段为2020年1月至今,即五年回测周期。我们会用现有数据的一半进行前瞻测试,以尽可能保证结果贴近真实行情。

图 3:选择我们的交易应用程序与训练时间段
为模拟真实的市场环境,我们将设置随机延迟,并采用逐笔价格(tick)数据作为回测模式。如前所述,我们的优化过程将使用快速遗传算法。

图 4:选择市场模拟条件
接下来,如前所述,我们选择策略中需要调整的输入参数。这些参数包括较高的时间框架与较低的时间框架上的移动平均线周期和间隔。此外,我们将启用策略的两种运行模式:趋势跟踪与均值回归。最后,该策略还可以配置为基于较高的时间框架或较低的时间框架进行平仓。遗传优化器将会遍历所有这些设置,帮我们筛选出最优的参数组合。

图 5:选择由遗传优化器进行调优的策略参数
回测结果看起来相当不错。从优化结果来看,我们的大部分策略都实现了盈利,尤其是在均值回归模式下。然而,前瞻测试的结果却显示,实现盈利的策略大多运行在趋势跟踪模式下,而非回测中表现较好的均值回归模式。

图 6:初次测试的回测结果
更令人担忧的是,仔细观察前瞻测试结果就会发现:回测中表现好的策略,没有一个能在前瞻测试中复刻成功。也就是说,几乎没有哪个策略能同时在回测和前瞻测试中都保持盈利。

图 7:初次测试的前瞻结果表明,我们的大多数策略在两次测试中的表现都不稳定
我不得不手动筛选前瞻测试结果,找出在回测和前瞻测试中均能盈利的策略参数。这正是我们判断策略稳定性的依据 —— 一个策略应当在回测和前瞻测试中都能保持盈利。在发现只有极少数参数组合符合这一标准后,我决定对该策略做进一步的优化改进。

图 8:由遗传优化器生成的策略中,仅有极少数能在回测与前瞻测试中同时实现盈利这并不是一个好迹象。
进一步优化
改进该策略的一个重要方向,是让遗传优化器自主决定用于计算止损和风险参数的时间框架。此外,我们还会让优化器自行选择计算止损所需的历史K线数量。在最初的版本中,我们默认使用10根4小时K线就已足够。但现在,我们将放开这一设置,交由优化器调整,并验证这一改动能否提升策略表现。 //+------------------------------------------------------------------+ //| Inputs | //+------------------------------------------------------------------+ input group "Money Management Settings" input ENUM_TIMEFRAMES TF_3 = PERIOD_H4; //Risk Time Frame input int HISTORICAL_BARS = 10; //Historical bars for risk calculation
我们还必须对代码做出相应的修改。在之前的版本中,update方法负责计算每个仓位的止损缓冲(padding)。在该新版本中,我们将改用独立函数来处理止损缓冲,因为现在我们希望止损能跟随并追踪盈利仓位。
//+------------------------------------------------------------------+ //| Get the stop loss size to use | //+------------------------------------------------------------------+ double get_padding(void) { vector high = vector::Zeros(10); vector low = vector::Zeros(10); low.CopyRates(Symbol(),TF_3,COPY_RATES_LOW,0,HISTORICAL_BARS); high.CopyRates(Symbol(),TF_3,COPY_RATES_HIGH,0,HISTORICAL_BARS); vector var = high - low; double padding = var.Mean(); return(padding); }
update方法也会做出相应地修改。现在,止损缓冲将通过get_padding方法来计算。原先在update方法中负责识别仓位的逻辑,现在会拆分为两个函数:一个用于寻找交易机会,另一个用于管理当前持仓。
//+------------------------------------------------------------------+ //| Update our technical indicators and positions | //+------------------------------------------------------------------+ void update(void) { //Update technical indicators and market readings CopyBuffer(ma_c_2_handle,0,0,1,ma_c_2); CopyBuffer(ma_c_1_handle,0,0,1,ma_c_1); CopyBuffer(ma_c_4_handle,0,0,1,ma_c_4); CopyBuffer(ma_c_3_handle,0,0,1,ma_c_3); bid = SymbolInfoDouble(Symbol(),SYMBOL_BID); ask = SymbolInfoDouble(Symbol(),SYMBOL_ASK); double padding = get_padding(); //Find an open position if(PositionsTotal() == 0) find_setup(padding); //Manage our open positions else if(PositionsTotal() > 0) manage_setup(); } //+------------------------------------------------------------------+
持仓管理方法会持续检查新计算出的建议止损值是否比当前止损值更优。如果是,就更新止损位;否则,保持当前的止损值不变。
//+------------------------------------------------------------------+ //| Manage our open positions | //+------------------------------------------------------------------+ void manage_setup(void) { //Does the position exist? if(PositionSelect(Symbol())) { //Get the current stop loss double current_sl = PositionGetDouble(POSITION_SL); double padding = get_padding(); double new_sl; //Sell position if((state == 0)) { new_sl = (ask + padding); if(new_sl < current_sl) Trade.PositionModify(Symbol(),new_sl,0); } //Buy position if((state == 1)) { new_sl = (bid - padding); if(new_sl > current_sl) Trade.PositionModify(Symbol(),new_sl,0); } if(closing_tf == 0) { if((state ==0) && (ma_c_1[0] > ma_c_2[0])) Trade.PositionClose(Symbol()); if((state ==1) && (ma_c_1[0] < ma_c_2[0])) Trade.PositionClose(Symbol()); } else if(closing_tf == 1) { if((state ==0) && (ma_c_3[0] > ma_c_4[0])) Trade.PositionClose(Symbol()); if((state ==1) && (ma_c_3[0] < ma_c_4[0])) Trade.PositionClose(Symbol()); } } }
为进行测试,我选取了一套在回测和前瞻测试中均实现盈利的参数组合,在其他设置固定不变的前提下,让遗传优化器去寻找更优的风险参数配置。

图 9:尝试优化初始测试结果尽管我们允许遗传优化器自主调整风险参数,但新结果依然无法做到在回测与前瞻测试中同时实现盈利。
遗憾的是,我们再次遇到了完全相同的问题。在优化风险参数的过程中,程序仅在前瞻测试中实现盈利,而在回测中未能实现盈利。

图 10:新结果仍然无法在回测与前瞻测试中同时实现盈利
结论
我们从这次实践中学到了很多。通过双均线交叉策略,我们证实了可以对策略中的滞后性进行控制,尽管这类调整带来的效果并不总是一目了然。我们或许应该重新考虑:放开所有参数,同时进行完整的全局优化。只调整单个参数而固定其他参数,可能并非最优方案。同时对所有参数进行搜索,或许能得到更稳定的结果。
在后续的探讨中,完整的全参数优化完成后,我们将基于表现最优的参数构建统计模型,这样可能有助于进一步降低策略的滞后性。不过就目前而言,我们已经有了一个不错的开端。请记住:优化并不能提供任何保证,AI也无法替代勤奋严谨的开发者。我们必须反复执行优化流程,直到遗传优化器能够批量输出在回测与前瞻测试中均稳定盈利的策略为止;否则,说明优化方面做得还远远不够。
本文由MetaQuotes Ltd译自英文
原文地址: https://www.mql5.com/en/articles/18793
注意: MetaQuotes Ltd.将保留所有关于这些材料的权利。全部或部分复制或者转载这些材料将被禁止。
本文由网站的一位用户撰写,反映了他们的个人观点。MetaQuotes Ltd 不对所提供信息的准确性负责,也不对因使用所述解决方案、策略或建议而产生的任何后果负责。
从基础到中级:结构(四)
新手在交易中的10个基本错误
市场模拟(第 16 部分):套接字(十)