逆转形态:测试头肩形态

Dmitriy Gizlyk | 31 十二月, 2018

内容

概述

逆转形态:测试双顶/双底形态 本文继续讨论逆转形态主题,这次考查分析另一种名为头肩的图形。


1. 形态形成的理论观点

头部和肩部以及倒置的头部和肩部形态是最著名和最广泛使用的图形形态。 形态的名称清楚地描述了其图形结构。 头肩形态是在看涨趋势结束时形成的,并提供卖出信号。 该形态本身由三个连续的价格图表顶部组成。 中间的顶部高于两个相邻的顶部,就像肩膀上方的头部。 中间的顶部称为头部,而相邻的部分称为肩部。 连接形态顶部与底部之间的连线为颈线。 其颈部向左倾斜的形态信号被认为更强。 倒置的头肩形态是头肩的镜像版本,表明看涨走势。

头肩形态

大多数情况下,该形态是由价格支撑/阻力位假突破的情况下形成的。 趋势跟踪交易者认为小幅修正(左肩上方)是增加持仓的好机会。 结果,价格回到当前趋势并突破左肩价位。 在突破目前支撑位/阻力位之后,逆势交易者增加其持仓会阻止疲软趋势,从而导致新的调整。 形成趋势逆转,价格再次跌破价位(头部形成)。 另一种恢复趋势的尝试表明其弱点,形成一个小的走势(右肩)。 此时,市场参与者注意到趋势逆转。 顺势交易者大规模离场,而逆势交易者增加其持仓。 这导致了强劲走势和新趋势的形成。


2. 形态交易策略

与双顶/双底形态一样,头肩交易有多种策略。 在许多方面,它们类似于先前形态的交易策略。

2.1. 案例 1

第一个策略是基于突破颈线。 在这种情况下,在所分析的时间帧收盘价格突破颈线后开单。 在这种情况下,在形态头部极值价位或略微缩进之处设置止损。 还应考虑品种点差。

策略 1

这种方法的缺点在于,在价格走势凌厉的情况下,柱线可能会远离颈线。 这可能导致利润转为损失,并根据特定形态增加交易亏损的风险。

这种情况的变体是在价格跨越颈线并自新启趋势运行到某个固定距离之后再执行交易。

2.2. 案例 2

第二种形态选项 — 在价格突破之后回滚到颈线时开仓。 在这种情况下,止损设置再最后一个极值处,令其更贴近,并允许交易者按照相同的风险开立更大交易量的交易。 这增加了潜在的盈/亏比。

案例 2

与双顶/双底一样,价格在突破后并不会总是回归颈线。 结果,跳过了相当多的形态。 因此,在这种情况下,我们跳过一些形态,并离场观望。

当使用这两种情况时,建议的最小止盈距颈线距离等于价格自走势从头部到颈线的距离。

止盈


3. 为进行策略测试而创建智能交易系统

您可能已经注意到双顶/双底与头肩形态的交易方法的相似性。 我们将使用前一篇文章中的算法来开发新的 EA。

我们已经在文章 [1] 中完成了大部分准备工作。 我们创建 CHS_Pattern 类来搜索形态。 它应该从 CPattern 类派生,以便我们能够应用上一篇文章中的进度。 因此,我们将可以访问父类的所有方法。 在此我们只添加缺少的元素并重写类初始化,形态搜索和入场点方法。

class CHS_Pattern : public CPattern
  {
protected:
   s_Extremum     s_HeadExtremum;         //头部极值点
   s_Extremum     s_StartRShoulder;       //右侧肩部起始点

public:
                     CHS_Pattern();
                    ~CHS_Pattern();
//--- 初始化类
   virtual bool      Create(CTrends *trends, double min_correction, double max_correction);
//--- 形态和入场点搜索方法
   virtual bool      Search(datetime start_time);
   virtual bool      CheckSignal(int &signal, double &sl, double &tp1, double &tp2);
//---
   s_Extremum        HeadExtremum(void)      const {  return s_HeadExtremum;     }
   s_Extremum        StartRShoulder(void)    const {  return s_StartRShoulder;   }
  };

在类初始化方法中,首先初始化父类及准备所添加的结构。

bool CHS_Pattern::Create(CTrends *trends,double min_correction,double max_correction)
  {
   if(!CPattern::Create(trends,min_correction,max_correction))
      return false;
//---
   s_HeadExtremum.Clear();
   s_StartRShoulder.Clear();
//---
   return true;
  }

在形态搜索方法中,检查足够数量的极值以便判断形态。

bool CHS_Pattern::Search(datetime start_time)
  {
   if(CheckPointer(C_Trends)==POINTER_INVALID || C_Trends.Total()<6)
      return false;

然后定义指定日期与相应的极值索引为形态搜索开始。 如果在指定日期之后没有形成单个极值,则使用 false 结果退出该方法。

   int start=C_Trends.ExtremumByTime(start_time);
   if(start<0)
      return false;

接下来,下载最后六个极值的数据,并检查价格走势是否符合必要的形态。 如果找到形态,则使用 true 结果退出方法。 如果未找到形态,则自当前走势向前顺移一个形态并重复循环。 如果在遍历所有极值后未找到形态,则使用 false 结果退出该方法。

   b_found=false; 
   for(int i=start;i>=0;i--)
     {
      if((i+5)>=C_Trends.Total())
         continue;
      if(!C_Trends.Extremum(s_StartTrend,i+5) || !C_Trends.Extremum(s_StartCorrection,i+4) ||
         !C_Trends.Extremum(s_EndCorrection,i+3)|| !C_Trends.Extremum(s_HeadExtremum,i+2) ||
         !C_Trends.Extremum(s_StartRShoulder,i+1) || !C_Trends.Extremum(s_EndTrend,i))
         continue;
//---
      double trend=MathAbs(s_StartCorrection.Price-s_StartTrend.Price);
      double correction=MathAbs(s_StartCorrection.Price-s_EndCorrection.Price);
      double header=MathAbs(s_HeadExtremum.Price-s_EndCorrection.Price);
      double revers=MathAbs(s_HeadExtremum.Price-s_StartRShoulder.Price);
      double r_shoulder=MathAbs(s_EndTrend.Price-s_StartRShoulder.Price);
      if((correction/trend)<d_MinCorrection || header>(trend-correction)   ||
         (1-fmin(header,revers)/fmax(header,revers))>=d_MaxCorrection      ||
         (1-r_shoulder/revers)<d_MinCorrection || (1-correction/header)<d_MinCorrection)
         continue;
      b_found= true; 
//---
      break;
     }
//---
   return b_found;
  }

在下一阶段,重新编写入场点搜索方法。 在方法开始时,检查先前是否在分析的类实例中找到了形态。 如果未找形态式,则使用 false 结果退出该方法。

bool CHS_Pattern::CheckSignal(int &signal, double &sl, double &tp1, double &tp2)
  {
   if(!b_found)
      return false;

然后,检查形态出现后形成的柱线数。 如果形态刚刚形成,则使用 false 结果退出方法。

   string symbol=C_Trends.Symbol();
   if(symbol=="Not Initilized")
      return false;
   datetime start_time=s_EndTrend.TimeStartBar+PeriodSeconds(C_Trends.Timeframe());
   int shift=iBarShift(symbol,e_ConfirmationTF,start_time);
   if(shift<0)
      return false;

之后,下载必要的报价历史记录,并准备辅助变量。

   MqlRates rates[];
   int total=CopyRates(symbol,e_ConfirmationTF,0,shift+1,rates);
   if(total<=0)
      return false;
//---
   signal=0;
   sl=tp1=tp2=-1;
   bool up_trend=C_Trends.IsHigh(s_EndTrend);
   int shift1=iBarShift(symbol,e_ConfirmationTF,s_EndCorrection.TimeStartBar,true);
   int shift2=iBarShift(symbol,e_ConfirmationTF,s_StartRShoulder.TimeStartBar,true);
   if(shift1<=0 || shift2<=0)
      return false;
   double koef=(s_StartRShoulder.Price-s_EndCorrection.Price)/(shift1-shift2);
   bool break_neck=false;

进而在循环中,搜索带有后续价格调的颈线突破。 在颈线突破期间定义潜在的止盈价位。 正当搜索价格回滚到颈线时,检查价格是否达到潜在的止盈价位。 如果价格在回滚到颈线之前触及潜在的止盈,则该形态被视为无效,且我们用 false 结果退出该方法。 当检测到入场点时,定义止损价位,并用 true 结果退出该方法。 请注意,如果入场点的形成时间不早于所分析时间帧的最后两根柱线,则该入场点被视为有效。 否则,用 false 作为结果退出方法。

   for(int i=0;i<total;i++)
     {
      if(up_trend)
        {
         if((tp1>0 && rates[i].low<=tp1) || rates[i].high>s_HeadExtremum.Price)
            return false;
         double neck=koef*(shift2-shift-i)+s_StartRShoulder.Price;
         if(!break_neck)
           {
            if(rates[i].close>neck)
               continue;
            break_neck=true;
            tp1=neck-(s_HeadExtremum.Price-neck)*0.9;
            tp2=neck-(neck-s_StartTrend.Price)*0.9;
            tp1=fmax(tp1,tp2);
            continue;
           }
         if(rates[i].high>neck)
           {
            if(sl==-1)
               sl=rates[i].high;
            else
               sl=fmax(sl,rates[i].high);
           }
         if(rates[i].close>neck || sl==-1)
            continue;
         if((total-i)>2)
            return false;
//---
         signal=-1;
         break;
        }
      else
        {
         if((tp1>0 && rates[i].high>=tp1) || rates[i].low<s_HeadExtremum.Price)
            return false;
         double neck=koef*(shift2-shift-i)+s_StartRShoulder.Price;
         if(!break_neck)
           {
            if(rates[i].close<neck)
               continue;
            break_neck=true;
            tp1=neck+(neck-s_HeadExtremum.Price)*0.9;
            tp2=neck+(s_StartTrend.Price-neck)*0.9;
            tp1=fmin(tp1,tp2);
            continue;
           }
         if(rates[i].low<neck)
           {
            if(sl==-1)
               sl=rates[i].low;
            else
               sl=fmin(sl,rates[i].low);
           }
         if(rates[i].close<neck || sl==-1)
            continue;
         if((total-i)>2)
            return false;
//---
         signal=1;
         break;
        }
     }   
//---
   return true;
  }

附件中提供了所有方法和函数的完整代码。

用于测试策略的 EA 代码取自文章 [1],几乎没有变化。 唯一变化就是要替换类,以便处理新的形态。 可在附件中找到完整的 EA 代码。

在 2018 年的 10 个月期间 EA 进行了测试。 测试参数在下面的屏幕截图中提供。

测试参数 测试参数

测试证明了 EA 在所分析的时间帧内产生了利润能力。 超过 57% 的交易以盈利了结,盈利因子为 2.17。 然而,交易量并不令人十分鼓舞 – 在 10 个月内只有 14 笔交易。

测试结果 测试结果


4. 将两种形态合并为单一 EA

作为这两篇文章的工作结果,我们得到了两个针对逆转图形形态可盈利的 EA。 很自然,为了提高交易的整体效率,将两种策略合并成一个交易程序是合理的。 正如我已经提到的,我们开发的新形态搜索类始自前一个。 这极大地简化了我们将策略合并到单一 EA 中的工作量。

首先,我们标识类。 基础 CObject 类包含 Type 虚方法。 我们将它添加到我们的类描述中。 在 CPattern 类中,此方法将返回值 101,而在 CHS_Pattern类中,它返回 102。 目前,我们只使用两种形态,因此我们可以将自己局限于数值常数。 当增加形态数量时,我建议使用枚举来提高代码可读性。

之后,通过比较类的类型来提供类比较方法。 结果,方法代码如下所示。

int CPattern::Compare(const CPattern *node,const int mode=0) const
  {
   if(Type()>node.Type())
      return -1;
   else
      if(Type()<node.Type())
         return 1;
//---
   if(s_StartTrend.TimeStartBar>node.StartTrend().TimeStartBar)
      return -1;
   else
      if(s_StartTrend.TimeStartBar<node.StartTrend().TimeStartBar)
         return 1;
//---
   if(s_StartCorrection.TimeStartBar>node.StartCorrection().TimeStartBar)
      return -1;
   else
      if(s_StartCorrection.TimeStartBar<node.StartCorrection().TimeStartBar)
         return 1;
//---
   if(s_EndCorrection.TimeStartBar>node.EndCorrection().TimeStartBar)
      return -1;
   else
      if(s_EndCorrection.TimeStartBar<node.EndCorrection().TimeStartBar)
         return 1;
//---
   return 0;
  }

这样就完成了类代码更改。 现在,我们来终结 EA 代码。 这里我们还会有一些补充。 首先,我们开发一个函数,根据获得的参数创建相应类的新实例。 该函数的代码非常简单,仅包含用 switch 调用所需类的创建方法。

CPattern *NewClass(int type)
  {
   switch(type)
     {
      case 0:
        return new CPattern();
        break;
      case 1:
        return new CHS_Pattern();
        break;
     }
//---
   return NULL;
  }

将针对 OnTick 函数进行以下更改。 它们只需要使用新形态搜索模块。 在已发现的形态中搜索入场点的模块不需要因类继承而进行更改。

在此,我们安排循环持续搜索一种类型的形态,然后依据历史数据搜索另一种类型的形态。 为此,启动调用刚添加到新类实例创建点中的函数。 循环当前经手索引在参数中发送给它。 EA 操作算法保持不变。

void OnTick()
  {
//---
.........................
.........................
.........................
//---
   for(int pat=0;pat<2;pat++)
     {
      Pattern=NewClass(pat);
      if(CheckPointer(Pattern)==POINTER_INVALID)
         return;
      if(!Pattern.Create(ar_Objects.At(1),d_MinCorrection,d_MaxCorrection))
        {
         delete Pattern;
         continue;
        }
//---
      datetime ss=start_search;
      while(!IsStopped() && Pattern.Search(ss))
        {
         ss=fmax(ss,Pattern.EndTrendTime()+PeriodSeconds(e_TimeFrame));
         bool found=false;
         for(int i=2;i<ar_Objects.Total();i++)
           {
            CPattern *temp=ar_Objects.At(i);
            if(Pattern.Compare(temp,0)==0)
              {
               found=true;
               break;
              }
           }
         if(found)
            continue;
         if(!CheckPattern(Pattern))
            continue;
         if(!ar_Objects.Add(Pattern))
            continue;
         Pattern=NewClass(pat);
         if(CheckPointer(Pattern)==POINTER_INVALID)
            break;
         if(!Pattern.Create(ar_Objects.At(1),d_MinCorrection,d_MaxCorrection))
           {
            delete Pattern;
            break;
           }
        }
      if(CheckPointer(Pattern)!=POINTER_INVALID)
         delete Pattern;
     }
//---
   return;
  }

这样就完成了 EA 代码更改。 在附件的 TwoPatterns.mq5 文件中可找到完整的 EA 代码。

在进行必要的更改后,我们将使用相同的参数进行测试。

测试参数 测试参数

测试结果显示了这些策略如何相互补充。 EA 在测试期内进行了 128 笔交易(其中 60% 是盈利的)。 结果,EA 盈利因子为 1.94,而恢复因子为 3.85。 完整的测试结果显示在下面的屏幕截图中。

测试结果测试结果

5. 测试交易系统

为了测试交易系统的操作稳定性,我们在不改变测试参数的情况下对 6 个主要品种进行了测试。

品名 盈利 盈利因子
EURUSD 743.57 1.94
EURJPY 125.13 1.47
GBPJPY
33.93 1.04
EURGBP -191.7
0.82
GBPUSD -371.05 0.60
USDJPY
-657.38 0.31

从上面的结果表格可以看出,EA 在一半的测试品种上是盈利的,而其它的则出现亏损。 这一事实再次证实,每个品种的价格图表需要一个独立的方法,在使用 EA 之前,有必要针对特定条件对其进行优化。 例如,优化止损回退能够增加四个品种的利润并减少两个品种的损失。 这增加了品种篮的整体盈利能力。 结果显示在下表中。

品名 盈利 盈利因子 止损回退
EURUSD 1020.28 1.78 350
EURJPY 532.54 1.52 400
GBPJPY 208.69 1.17 300
EURGBP 91.45 1.05 450
GBPUSD -315.87 0.55 100
USDJPY -453.08 0.33 100


6. 在亏损品种上分配交易信号

根据上一节中的 EA 测试结果,有两个品种显示负面结果。 它们是 GBPUSD 和 USDJPY。 这可能表明所考查的形态对这些品种的趋势逆转的强度不足。 稳定走势朝着减少余额的方向导致在信号输入期间产生逆向交易的想法。

那么反向交易的目标价位是什么?我们应该在何处设置止损? 若要回答这些问题,我们来看看我们之前测试的结果。 测试表明,持仓主要由止损平仓。 价格很少首先触及止盈。 因此,通过逆向交易,我们可以对调止损和止盈 1。

对亏损交易图表的详细研究表明,通道通常在假信号的价格范围内形成,而在标准图形分析中,通道是趋势延续形态。 因此,我们可以预期价格走势与前一个相当。 在参考 CheckSignal 方法(形态搜索类)的代码时,我们可以很容易地看到前一走势的规模由止盈 2 了结。 我们需要做的全部就是将止盈 2 设置在自当前价格相同的距离,但是在另一个方向上。

这可令我们仅通过更改 EA 代码来逆转交易,而不是更改形态搜索类的代码。

若要实现这种功能,请将 ReverseTrade 参数添加到 EA,用作反向交易功能的开/关标志。

input bool  ReverseTrade   =  true; //反向交易

然而,单一参数不能改变成交开立逻辑。 我们来更改 CheckPattern 函数。 将 temp 变量添加到局部变量声明模块。 在对调止损和止盈 1 价位时,该变量将用于临时存储。

bool CheckPattern(CPattern *pattern)
  {
   int signal=0;
   double sl=-1, tp1=-1, tp2=-1;
   if(!pattern.CheckSignal(signal,sl,tp1,tp2))
      return false;
//---
   double price=0;
   double to_close=100;
   double temp=0;
//---

接下来,将 ReverseTrade 标志状态检查添加到 switch 主体内。 如果该标志设置为 false,则使用旧逻辑。 当使用反向交易时,更改止损和止盈 1 数值。 接着,重新遵照品种点数计算止盈数值,并将获得的数值传递给 CLimitTakeProfit 类。 成功通过所有迭代后,开立与所接收信号相反的交易。

//---
   switch(signal)
     {
      case 1:
        CLimitTakeProfit::Clear();
        if(!ReverseTrade)
          {
           price=SymbolInfoDouble(_Symbol,SYMBOL_ASK);
           if((tp1-price)>SymbolInfoInteger(_Symbol,SYMBOL_TRADE_STOPS_LEVEL)*_Point)
              if(CLimitTakeProfit::AddTakeProfit((uint)((tp1-price)/_Point),(fabs(tp1-tp2)>=_Point ? 50 : 100)))
                 to_close-=(fabs(tp1-tp2)>=_Point ? 50 : 100);
           if(to_close>0 && (tp2-price)>SymbolInfoInteger(_Symbol,SYMBOL_TRADE_STOPS_LEVEL)*_Point)
              if(!CLimitTakeProfit::AddTakeProfit((uint)((tp2-price)/_Point),to_close))
                 return false;
           if(Trade.Buy(d_Lot,_Symbol,price,sl-i_SL*_Point,0,NULL))
              return false;
          }
        else
          {
           price=SymbolInfoDouble(_Symbol,SYMBOL_BID);
           temp=tp1;
           tp1=sl-i_SL*_Point;
           sl=temp;
           tp1=(price-tp1)/_Point;
           tp2=(tp2-price)/_Point;
           if(tp1>SymbolInfoInteger(_Symbol,SYMBOL_TRADE_STOPS_LEVEL))
              if(CLimitTakeProfit::AddTakeProfit((uint)(tp1),((tp2-tp1)>=1? 50 : 100)))
                 to_close-=((tp2-tp1)>=1 ? 50 : 100);
           if(to_close>0 && tp2>SymbolInfoInteger(_Symbol,SYMBOL_TRADE_STOPS_LEVEL))
              if(!CLimitTakeProfit::AddTakeProfit((uint)(tp2),to_close))
                 return false;
           if(Trade.Sell(d_Lot,_Symbol,price,sl,0,NULL))
              return false;
          }
        break;
      case -1:
        CLimitTakeProfit::Clear();
        if(!ReverseTrade)
          {
           price=SymbolInfoDouble(_Symbol,SYMBOL_BID);
           if((price-tp1)>SymbolInfoInteger(_Symbol,SYMBOL_TRADE_STOPS_LEVEL)*_Point)
              if(CLimitTakeProfit::AddTakeProfit((uint)((price-tp1)/_Point),(fabs(tp1-tp2)>=_Point ? 50 : 100)))
                 to_close-=(fabs(tp1-tp2)>=_Point ? 50 : 100);
           if(to_close>0 && (price-tp2)>SymbolInfoInteger(_Symbol,SYMBOL_TRADE_STOPS_LEVEL)*_Point)
              if(!CLimitTakeProfit::AddTakeProfit((uint)((price-tp2)/_Point),to_close))
                 return false;
           if(Trade.Sell(d_Lot,_Symbol,price,sl+i_SL*_Point,0,NULL))
              return false;
          }
        else
          {
           price=SymbolInfoDouble(_Symbol,SYMBOL_ASK);
           temp=tp1;
           tp1=sl+i_SL*_Point;
           sl=temp;
           tp1=(tp1-price)/_Point;
           tp2=(price-tp2)/_Point;
           if(tp1>SymbolInfoInteger(_Symbol,SYMBOL_TRADE_STOPS_LEVEL))
              if(CLimitTakeProfit::AddTakeProfit((uint)(tp1),((tp2-tp1)>=1 ? 50 : 100)))
                 to_close-=((tp2-tp1)>=1 ? 50 : 100);
           if(to_close>0 && tp2>SymbolInfoInteger(_Symbol,SYMBOL_TRADE_STOPS_LEVEL))
              if(!CLimitTakeProfit::AddTakeProfit((uint)(tp2),to_close))
                 return false;
           if(Trade.Buy(d_Lot,_Symbol,price,sl,0,NULL))
              return false;
          }
        break;
     }
//---
   return true;
  }

这样就完成了 EA 代码的改善。 附件中提供了所有类和函数的完整代码。

我们来测试反向交易功能。 第一轮测试将使用相同的时间间隔和时间帧,针对 GBPUSD 进行。 EA 参数在下面的屏幕截图中提供。

针对 GBPUSD 的反向测试针对 GBPUSD 的反向测试

测试结果显示解决方案的效能。 在测试的时间间隔内,EA(启用反向交易功能)显示盈利。 在 127 笔成交中,95 笔(75.59%)以盈利了结。 盈利因子大约 2.35。 完整的测试结果显示在下面的屏幕截图中。

针对 GBPUSD 的反向测试结果针对 GBPUSD 的反向测试结果

若要修正所获结果,请在 USDJPY 上执行类似的测试。 屏幕截图中提供了测试参数。

针对 USDJPY 的反向测试针对 USDJPY 的反向测试

反向功能的第二轮测试也证明是成功的。 在 108 笔成交中,66 笔(61.11%)以盈利了结,盈利因子为 2.45。 完整的测试结果显示在屏幕截图中。

针对 USDJPY 的反向测试结果针对 USDJPY 的反向测试结果

测试表明,由于持仓逆转,亏损策略可能会变成盈利策略。 但请记住,在一个品种上获利的策略可能并不总会在另一个品种上盈利。


结束语

本文展示了基于标准图形形态运用策略的可行性。 我们已经运营很长一段时间来开发 EA,并在各品种上产生利润。 不过,在使用交易策略时,我们应该始终考虑每个品种的具体情况,因为策略在某些情况下可能会盈利,但在其它情况下可能会亏本。 尽管应考虑交易条件,但逆转交易有时可能会改善情况。

当然,这些只是建立可盈利交易系统的第一步。 EA 可基于其它参数来优化。 此外,它的功能可以补充(将止损移动到“盈亏平衡”,尾随停止,等等)。 当然,我不建议在已知为亏损的品种上使用 EA。

我希望,我的经验将有助您制定自己的交易策略。

文章中所提供的 EA 仅用作该策略的演示。 它应该加以改进后再用于实盘市场。

参考

  1. 逆转形态:测试双顶/双底形态
  2. 使用限价订单替代止盈且无需更改 EA 的原始代码

本文中使用的程序

# 名称 类型 描述
1 ZigZag.mqh 类库 之字折线指标类
2 Trends.mqh 类库 趋势搜索类
3 Pattern.mqh 类库 处理双顶/双底形态的类
4 HS_Pattern 类库 处理头肩形态的类
5 LimitTakeProfit.mqh 类库 用限价订单替换止盈的类
6 Header.mqh 函数库 EA 头文件
7 Head-Shoulders.mq5 智能交易系统 基于头肩策略的 EA
8 TwoPatterns.mq5 智能交易系统 结合了两种形态的 EA