English Русский Deutsch 日本語
preview
解密开盘区间突破(ORB)日内交易策略

解密开盘区间突破(ORB)日内交易策略

MetaTrader 5交易 |
1 300 15
Zhuo Kai Chen
Zhuo Kai Chen

概述

开盘区间突破(ORB)策略基于这样一种理念:市场开盘后不久确立的初始交易区间,反映了买卖双方就价格价值达成共识的重要水平。通过识别突破某一特定区间上方或下方的走势,交易者可以把握随之而来的市场契机——当市场方向愈发明朗时,这种契机往往会进一步显现。 

在本文中,我们将探讨三种改编自康克瑞图姆集团论文的ORB策略。首先,我们将介绍研究背景,包括关键概念和所采用的研究方法。接着,针对每种策略,我们将解释其运作方式,列出信号规则,并从统计角度分析其表现。最后,我们将从投资组合的角度审视这些策略,重点关注分散化这一主题。  

本文不会深入探讨编程细节,而是聚焦于研究过程,包括重现、分析和测试这三篇论文中的策略。这将适合那些寻找潜在交易优势或对如何研究并复制这些策略感兴趣的读者。尽管如此,我们仍会公布所有智能交易系统(EA)的MQL5代码。欢迎读者们基于这些框架自行进行拓展开发。


研究背景

本节将介绍我们用于分析策略的研究方法,以及文章后续将提及的关键概念。  

康克瑞图姆集团是少数开发日内交易策略的学术研究团队之一。在我们所借鉴的研究中,其策略专注于在市场开盘至收盘期间(美国东部时间上午9:30至下午4:00)进行交易。由于我们的经纪商使用UTC+2/3时区,这对应于服务器时间18:30-24:00——在测试时请确保根据您经纪商的时区进行调整。  

原始研究以QQQ(追踪纳斯达克-100指数的交易型开放式指数基金)为交易标的。值得注意的是,纳斯达克-100指数反映了在纳斯达克交易所上市的100家大型科技公司的整体表现。该指数本身实际上不可直接交易,而只有其衍生品可以交易。QQQ让投资者通过单一股票即可获得对这些公司的投资敞口。在我们的测试中,我们将交易USTEC(纳斯达克-100指数的差价合约),其允许在不持有标的资产的情况下对价格走势进行投机,通常使用杠杆来放大收益或亏损。

本文将引入两个关键指标:α(alpha)和β(beta)。在交易中,α代表投资相对于市场指数等基准产生的超额收益。其表明投资是否超越预期,本质上反映了优势。β衡量投资对市场波动的敏感度。β值为1意味着其波动与市场一致。值高于1表示波动性更大,而值低于1则表明波动性较小。这些指标对于理解您的策略在多大程度上依赖市场趋势与其独特优势至关重要。这一知识有助于您评估趋势性资产(如指数或加密货币)中可能存在的方向性偏差。

α和β的计算方法如下:

αβ

Ri是投资收益率,Rf是无风险利率(通常基于国债收益率,有时也可忽略不计),Rm是市场收益率。协方差和方差通常使用日收益率进行计算。

本文后续将用到的一个关键指标是成交量加权平均价格(VWAP)。其计算公式为:

vwap

VWAP背后的逻辑在于,通过成交量加权来衡量证券在一段时间内的平均交易价格,从而反映该时段内的“真实”的交易成本。与简单平均值不同,它赋予高交易活跃度价格更大的权重,因此是一个更为公平的基准。

在算法交易中的常见用途包括:

  • 将其用作趋势过滤器。
  • 将其用作跟踪止损。
  • 将其用作信号生成器(例如,当价格穿越VWAP时入场)。

我们通常从市场开盘后的第一根K线开始计算VWAP。在上述公式中,Pi代表第i根K线的价格,通常采用收盘价,而Vi则是第i根K线的成交量。由于不同差价合约(CFD)经纪商的流动性提供商不同,成交量可能存在差异,但各经纪商之间的相对权重通常应保持一致。

本文采用杠杆空间风险模型进行交易风险管理。该模型规定,每笔交易在触发止损时,仅承担账户余额的固定比例风险。止损范围将设定为标的资产价格的固定百分比,以适应其价格波动和波动率变化。为简化操作,每笔交易的风险将设定为整数金额,目标是将最大回撤控制在约20%以内。我们将对每种策略进行为期五年的测试,时间跨度从2020年1月1日至2025年1月1日,以收集足够的近期数据来评估其当前盈利能力。全面的统计分析将包括与买入持有策略的对比(基于累计百分比收益)以及各项独立表现指标的评估。


策略一:开盘K线方向

我们将探讨的第一种策略是康克瑞图姆集团在论文 《日内交易真的能盈利吗?》 中提出的经典开盘突破策略。该策略信号规则背后的动机,旨在捕捉短期价格动能,同时兼顾日内交易者的实际可行性和风险管理。作者选择ORB方法,旨在利用市场开盘时经常出现的剧烈波动和方向性契机。这一时段被视为关键窗口,期间机构交易活动频繁曝光,而散户交易者可利用这一时段的价格方向作为判断全天趋势的依据。

在研读该论文后,我们发现了多种改进原始策略的方法。原始方法以首个五分钟K线的高点或低点作为止损点,并设定10倍风险(10R)的止盈目标。尽管该方法能够盈利,但在实际交易中对散户交易者而言并不实用。首个五分钟K线设置的紧止损增加了相对交易成本。此外,由于我们在每日收盘前会全部平仓,因此10倍风险的止盈目标并无必要,且极少能达到。最后,原始策略缺乏市场状态过滤器,因此添加移动平均线作为市场状态过滤器可对其进行改进。

我们修改后的信号规则如下:

  • 当市场开盘五分钟后,如果开盘五分钟K线为阳线且收盘价高于350周期移动平均线,则买入。
  • 当市场开盘五分钟后,如果开盘五分钟K线为阴线且收盘价低于350周期移动平均线,则卖出。
  • 市场收盘前五分钟,将当前持仓平仓。
  • 止损点设定为入场价格水平的1%。
  • 每笔交易风险设定为2%。

该EA的完整MQL5代码如下:

//USTEC-M5
#include <Trade/Trade.mqh>
CTrade trade;

input int startHour = 18;
input int startMinute = 35;
input int endHour = 23;
input int endMinute = 55;
input double risk = 2.0;
input double slp = 0.01;
input int MaPeriods = 350;
input int Magic = 0;

int barsTotal = 0;
int handleMa;
double lastClose=0;
double lastOpen = 0;
double lot = 0.1;

//+------------------------------------------------------------------+
//|Initialization function                                           |
//+------------------------------------------------------------------+ 
int OnInit()
  {
   trade.SetExpertMagicNumber(Magic);
   handleMa = iMA(_Symbol,PERIOD_CURRENT,MaPeriods,0,MODE_SMA,PRICE_CLOSE);
   return(INIT_SUCCEEDED);
  }

//+------------------------------------------------------------------+
//|Deinitialization function                                         |
//+------------------------------------------------------------------+ 
void OnDeinit(const int reason)
  {
  }

//+------------------------------------------------------------------+
//|On tick function                                                  |
//+------------------------------------------------------------------+ 
void OnTick()
  {
   int bars = iBars(_Symbol, PERIOD_CURRENT);
   if(barsTotal != bars){
      barsTotal=bars;
      double ma[];
      CopyBuffer(handleMa,0,1,1,ma);
      
      if(MarketOpen()){
         lastClose = iClose(_Symbol,PERIOD_CURRENT,1);
         lastOpen = iOpen(_Symbol,PERIOD_CURRENT,1);
         if(lastClose<lastOpen&&lastClose<ma[0])executeSell();
         if (lastClose>lastOpen&&lastClose>ma[0]) executeBuy();
      }
      
      if(MarketClose()){
         for(int i = PositionsTotal()-1; i>=0; i--){
            ulong pos = PositionGetTicket(i);
            string symboll = PositionGetSymbol(i);
            if(PositionGetInteger(POSITION_MAGIC) == Magic&&symboll== _Symbol)trade.PositionClose(pos);
            }
       }  
   }
}

//+------------------------------------------------------------------+
//| Detect if market is opened                                       |
//+------------------------------------------------------------------+
bool MarketOpen()
{
    datetime currentTime = TimeTradeServer(); 
    MqlDateTime timeStruct;
    TimeToStruct(currentTime, timeStruct);
    int currentHour = timeStruct.hour;
    int currentMinute = timeStruct.min;
    if (currentHour == startHour &&currentMinute==startMinute)return true;
    else return false;
}

//+------------------------------------------------------------------+
//| Detect if market is closed                                       |
//+------------------------------------------------------------------+
bool MarketClose()
{
    datetime currentTime = TimeTradeServer(); 
    MqlDateTime timeStruct;
    TimeToStruct(currentTime, timeStruct);
    int currentHour = timeStruct.hour;
    int currentMinute = timeStruct.min;

    if (currentHour == endHour && currentMinute == endMinute)return true;
    else return false;
}

//+------------------------------------------------------------------+
//| Sell execution function                                          |
//+------------------------------------------------------------------+        
void executeSell() {      
       double bid = SymbolInfoDouble(_Symbol, SYMBOL_BID);
       bid = NormalizeDouble(bid,_Digits);
       double sl = bid*(1+slp);
       sl = NormalizeDouble(sl, _Digits);
       lot = calclots(bid*slp);
       trade.Sell(lot,_Symbol,bid,sl);    
}
  
//+------------------------------------------------------------------+
//| Buy execution function                                           |
//+------------------------------------------------------------------+   
void executeBuy() {
       double ask = SymbolInfoDouble(_Symbol, SYMBOL_ASK);
       ask = NormalizeDouble(ask,_Digits);
       double sl = ask*(1-slp);
       sl = NormalizeDouble(sl, _Digits);
       lot = calclots(ask*slp);
       trade.Buy(lot,_Symbol,ask,sl);
}

//+------------------------------------------------------------------+
//| Calculate lot size based on risk and stop loss range             |
//+------------------------------------------------------------------+
double calclots(double slpoints) {
    double riskAmount = AccountInfoDouble(ACCOUNT_BALANCE) * risk / 100;
    double ticksize = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_SIZE);
    double tickvalue = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_VALUE);
    double lotstep = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP);
    double moneyperlotstep = slpoints / ticksize * tickvalue * lotstep;
    double lots = MathFloor(riskAmount / moneyperlotstep) * lotstep;
    lots = MathMin(lots, SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MAX));
    lots = MathMax(lots, SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN));
    return lots;
}

一次典型交易示例如下:

orb1示例

回测结果:

orb1设置

orb1参数

orb1净值曲线

orb1结果

如果不采用移动平均线过滤器,原始策略规则会在每个交易日生成一笔交易。而引入该过滤器后,交易数量减少了一半。由于平均持仓周期贯穿整个交易时段,其结果在一定程度上反映了宏观趋势,其中多头交易发生更为频繁且胜率更高。总体而言,该策略实现了1.23的盈亏比和2.81的夏普比率,表现强劲。这些简单规则不易出现过拟合问题,表明稳固的回测结果很可能在实盘交易中得以延续。

orb1对比

orb1回撤

在该五年期间,该EA在USTEC上的表现显著优于买入并持有策略,同时将最大回撤控制在18%,仅为基准指标的一半。净值曲线保持平稳,仅在2022年末至2023年初出现短暂停滞,而这一时期USTEC正经历着较大幅度的回撤。

orb1策略月度收益

orb1策略月度回撤

α:1.6017
β:0.0090

相关性为0.9%,表明该策略的日收益率与标的资产的日收益率仅存在0.9%的相关性,这意味着该策略的优势主要源自其交易规则,而非市场趋势。回撤和收益情况保持稳定,这表明该策略在面对极端市场环境(如2020年新冠疫情引发的市场暴跌)时具有较强的韧性。绝大部分月份都能实现盈利,且出现回撤的月份情况较为温和,最严重的回撤幅度为10.2%。总体而言,这是一个可行且能盈利的交易策略。


策略二:VWAP趋势跟踪

我们将要探讨的第二种策略,更偏向于一种开盘时段趋势跟踪策略,该策略在论文 《VWAP:日内交易系统的“圣杯”》中有所介绍。该策略信号规则背后的契机,是利用VWAP这一清晰、以成交量为权重的基准,来识别日内趋势。当价格收盘高于VWAP时,触发做多仓位;当价格收盘低于VWAP时,触发做空仓位。该策略旨在捕捉已确认的动能,同时过滤掉市场噪音。这种简单性确保了日内交易者能够获得可操作、可复制的信号。这种经典的趋势跟踪方法在市场高波动性条件下表现最佳,能够捕捉长期趋势,并产生高风险回报比的利润。在股票市场开放的五个小时内,指数会出现大幅波动,为该策略的成功提供了绝佳的基于时间的流动性。

原始论文是在一分钟时间框架下进行交易的,并声称这是在各种时间框架中最有效的一种。然而,我个人的测试表明,15分钟时间框架对该策略而言效果更优,很可能是因为与交易型开放式指数基金(ETF)相比,差价合约(CFD)的交易成本更高,这样使得频繁交易不太可行。此外,原始论文中未设定止损。而在我们的方法中,由于使用的是更高的时间框架,所以将加入止损。这一添加项起到了意外保护的作用,并为计算风险提供了参考范围。最后,我们像之前一样添加了一个移动平均线趋势过滤器。

我们修改后的信号规则如下:

  • 当市场开盘后,如果当前无持仓,且最近一个15分钟K线收盘价高于VWAP及300周期移动平均线,则买入。
  • 市场开盘后,若当前无持仓,且最近一个15分钟K线收盘价低于VWAP及300周期移动平均线,则卖出。
  • 止损点设定为入场价格水平的0.8%。
  • 每笔交易风险设定为2%。

该EA的完整MQL5代码如下:

//USTEC-M15
#include <Trade/Trade.mqh>
CTrade trade;

input int startHour = 18;
input int startMinute = 35;
input int endHour = 23;
input int endMinute = 45;

input double risk = 2.0;
input double slp = 0.008;
input int MaPeriods = 300;
input int Magic = 0;

int barsTotal = 0;
int handleMa;
double lastClose=0;
double lot = 0.1;

//+------------------------------------------------------------------+
//|Initialization function                                           |
//+------------------------------------------------------------------+ 
int OnInit()
  {
   trade.SetExpertMagicNumber(Magic);
   handleMa = iMA(_Symbol,PERIOD_CURRENT,MaPeriods,0,MODE_SMA,PRICE_CLOSE);
   return(INIT_SUCCEEDED);
  }

//+------------------------------------------------------------------+
//|Deinitialization function                                         |
//+------------------------------------------------------------------+ 
void OnDeinit(const int reason)
  { 
  }

//+------------------------------------------------------------------+
//|On tick function                                                  |
//+------------------------------------------------------------------+ 
void OnTick()
  {
   int bars = iBars(_Symbol, PERIOD_CURRENT);
   if(barsTotal != bars){
      barsTotal=bars;
      bool NotInPosition = true;
      double ma[];
      CopyBuffer(handleMa,0,1,1,ma);
      if(MarketOpened()&&!MarketClosed()){
         lastClose = iClose(_Symbol,PERIOD_CURRENT,1);
         int startIndex = getSessionStartIndex();
         double vwap = getVWAP(startIndex);
         for(int i = PositionsTotal()-1; i>=0; i--){
            ulong pos = PositionGetTicket(i);
            string symboll = PositionGetSymbol(i);
            if(PositionGetInteger(POSITION_MAGIC) == Magic&&symboll== _Symbol){
               if((PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY&&lastClose<vwap)||(PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_SELL&&lastClose>vwap))trade.PositionClose(pos);
               else NotInPosition=false;
            }
         }
         if(lastClose<vwap&&NotInPosition&&lastClose<ma[0])executeSell();
         if(lastClose>vwap&&NotInPosition&&lastClose>ma[0]) executeBuy();
       } 
       if(MarketClosed()){
          for(int i = PositionsTotal()-1; i>=0; i--){
            ulong pos = PositionGetTicket(i);
            string symboll = PositionGetSymbol(i);
            if(PositionGetInteger(POSITION_MAGIC) == Magic&&symboll== _Symbol)trade.PositionClose(pos);
          }
       }
        
   }
}

//+------------------------------------------------------------------+
//| Detect if market is opened                                       |
//+------------------------------------------------------------------+ 
bool MarketOpened()
{
    datetime currentTime = TimeTradeServer(); 
    MqlDateTime timeStruct;
    TimeToStruct(currentTime, timeStruct);
    int currentHour = timeStruct.hour;
    int currentMinute = timeStruct.min;
    if (currentHour >= startHour &&currentMinute>=startMinute)return true;
    else return false;
}

//+------------------------------------------------------------------+
//| Detect if market is closed                                       |
//+------------------------------------------------------------------+ 
bool MarketClosed()
{
    datetime currentTime = TimeTradeServer(); 
    MqlDateTime timeStruct;
    TimeToStruct(currentTime, timeStruct);
    int currentHour = timeStruct.hour;
    int currentMinute = timeStruct.min;

    if (currentHour >= endHour && currentMinute >= endMinute)return true;
    else return false;
}

//+------------------------------------------------------------------+
//| Sell execution function                                          |
//+------------------------------------------------------------------+        
void executeSell() {      
       double bid = SymbolInfoDouble(_Symbol, SYMBOL_BID);
       bid = NormalizeDouble(bid,_Digits);
       double sl = bid*(1+slp);
       sl = NormalizeDouble(sl, _Digits);
       lot = calclots(bid*slp);
       trade.Sell(lot,_Symbol,bid,sl);    
}

//+------------------------------------------------------------------+
//| Buy execution function                                           |
//+------------------------------------------------------------------+    
void executeBuy() {
       double ask = SymbolInfoDouble(_Symbol, SYMBOL_ASK);
       ask = NormalizeDouble(ask,_Digits);
       double sl = ask*(1-slp);
       sl = NormalizeDouble(sl, _Digits);
       lot = calclots(ask*slp);
       trade.Buy(lot,_Symbol,ask,sl);
}

//+------------------------------------------------------------------+
//| Get VWAP function                                                |
//+------------------------------------------------------------------+
double getVWAP(int startCandle)
{
   double sumPV = 0.0;  // Sum of (price * volume)
   long sumV = 0.0;    // Sum of volume

   // Loop from the starting candle index down to 1 (excluding current candle)
   for(int i = startCandle; i >= 1; i--)
   {
      // Calculate typical price: (High + Low + Close) / 3
      double high = iHigh(_Symbol, PERIOD_CURRENT, i);
      double low = iLow(_Symbol, PERIOD_CURRENT, i);
      double close = iClose(_Symbol, PERIOD_CURRENT, i);
      double typicalPrice = (high + low + close) / 3.0;

      // Get volume and update sums
      long volume = iVolume(_Symbol, PERIOD_CURRENT, i);
      sumPV += typicalPrice * volume;
      sumV += volume;
   }

   // Calculate VWAP or return 0 if no volume
   if(sumV == 0)
      return 0.0;
   
   double vwap = sumPV / sumV;

   // Plot the dot
   datetime currentBarTime = iTime(_Symbol, PERIOD_CURRENT, 0);
   string objName = "VWAP" + TimeToString(currentBarTime, TIME_MINUTES);
   ObjectCreate(0, objName, OBJ_ARROW, 0, currentBarTime, vwap);
   ObjectSetInteger(0, objName, OBJPROP_COLOR, clrGreen);    // Green dot
   ObjectSetInteger(0, objName, OBJPROP_STYLE, STYLE_DOT);   // Dot style
   ObjectSetInteger(0, objName, OBJPROP_WIDTH, 1);           // Size of the dot
   
   return vwap;
}

//+------------------------------------------------------------------+
//| Find the index of the candle corresponding to the session open   |
//+------------------------------------------------------------------+
int getSessionStartIndex()
{
   int sessionIndex = 1;
   // Loop over bars until we find the session open
   for(int i = 1; i <=1000; i++)
   {
      datetime barTime = iTime(_Symbol, PERIOD_CURRENT, i);
      MqlDateTime dt;
      TimeToStruct(barTime, dt);
      
      if(dt.hour == startHour && dt.min == startMinute-5)
      {
         sessionIndex = i;
         break;
      }
   }
      
   return sessionIndex;
}

//+------------------------------------------------------------------+
//| Calculate lot size based on risk and stop loss range             |
//+------------------------------------------------------------------+
double calclots(double slpoints) {
    double riskAmount = AccountInfoDouble(ACCOUNT_BALANCE) * risk / 100;
    double ticksize = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_SIZE);
    double tickvalue = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_VALUE);
    double lotstep = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP);
    double moneyperlotstep = slpoints / ticksize * tickvalue * lotstep;
    double lots = MathFloor(riskAmount / moneyperlotstep) * lotstep;
    lots = MathMin(lots, SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MAX));
    lots = MathMax(lots, SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN));
    return lots;
}

一次典型交易示例如下:

orb2示例

回测结果:

orb2设置

orb2参数

orb2净值曲线

orb2测试结果

与首个开盘突破策略相比,该策略交易频率更高,平均每日交易超过一笔。这一增加源于在市场交易时段内,只要价格再次穿越VWAP,就允许重新入场。该策略的胜率为42%,低于50%,这对于采用动态追踪止损的趋势跟踪策略而言属于正常情况。这种设置更有利于实现高风险回报比的交易,但同时也增加了被止损出局的概率。该策略的夏普比率和盈亏比分别高达3.57和1.26,表现极为出色。

orb2对比

orb2回撤

该策略的表现远超买入并持有方式,五年内实现了501%的收益率。其最大回撤为16%,最糟糕的时期出现在2021年末,与USTEC表现最差的阶段不同,这暗示着该策略的收益表现与市场相关性较低。

orb2策略月度收益

orb2策略月度回撤

α:4.8714
β:0.0985

该策略的β值与首个策略相当,同样表明其与标的资产的相关性较低。值得注意的是,该策略的α值是首个策略的三倍,同时最大回撤幅度与之相近。这一优势可能源自更频繁的交易、更短的持仓周期,以及在同一天内通过做多和做空机会实现的更充分的内部分散化。月度数据表证实了其稳健的表现,回撤和收益在各月份间分布均匀且保持一致。


策略三:康克瑞图姆通道突破

第三种策略是一种在市场开盘时段进行交易的噪音区间突破策略。该策略最初在《战胜市场:标普500交易型开放式指数基金(SPY)的有效日内动量策略》一文中被提出,随后在X/推特上广泛传播。康克瑞图姆通道突破策略的信号规则背后的动机,源于识别日内交易中由供需失衡引发的显著价格变动的目标。该策略使用基于波动率的通道,这些通道根据前一日收盘价或当日开盘价计算,并通过波动率乘数进行调整,以界定一个“噪音区域”,即价格随机波动的范围。这些规则旨在过滤掉市场噪音,利用高概率的契机转变,并适应不同的波动率情况,确保交易与真正的趋势起点相契合,而非短暂的波动。

以下是通道的计算方法:

通道公式

由于原始论文中的信号规则设计得十分精妙,因此本文不会对其做大幅度改动。为简化操作,我们将沿用相同的交易标的(USTEC)和相同的风险管理方法,这可能会产生与论文方法不同的结果。信号规则如下:

  • 市场开盘后,当1分钟K线向上突破上轨时买入。
  • 市场开盘后,当1分钟K线向下突破下轨时卖出。
  • 当市场收盘时全部平仓。
  • 止损设置为入场价格水平的1%,同时以VWAP作为追踪止损。
  • 每笔交易风险设定为4%。

该EA的完整MQL5代码如下:

//USTEC-M1
#include <Trade/Trade.mqh>
CTrade trade;

input int startHour = 18;
input int startMinute = 35;
input int endHour = 23;
input int endMinute = 55;

input double risk = 4.0;
input double slp = 0.01;
input int Magic = 0;
input int maPeriod = 400;

int barsTotal = 0;
int handleMa;
double lastClose=0;
double lastOpen = 0;
double lot = 0.1;

//+------------------------------------------------------------------+
//|Initialization function                                           |
//+------------------------------------------------------------------+ 
int OnInit()
  {
   trade.SetExpertMagicNumber(Magic);
   handleMa = iMA(_Symbol,PERIOD_CURRENT,maPeriod,0,MODE_SMA,PRICE_CLOSE);
   return(INIT_SUCCEEDED);
  }

//+------------------------------------------------------------------+
//|Deinitialization function                                         |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {  
  }

//+------------------------------------------------------------------+
//|On tick function                                                  |
//+------------------------------------------------------------------+ 
void OnTick()
  {
   int bars = iBars(_Symbol, PERIOD_CURRENT);
   if(barsTotal != bars){
      barsTotal=bars;
      bool NotInPosition = true;
      double ma[];
      CopyBuffer(handleMa,0,1,1,ma);
      if(MarketOpened()&&!MarketClosed()){
         lastClose = iClose(_Symbol,PERIOD_CURRENT,1);
         lastOpen = iOpen(_Symbol,PERIOD_CURRENT,1);
         int startIndex = getSessionStartIndex();
         double vwap = getVWAP(startIndex);
         for(int i = PositionsTotal()-1; i>=0; i--){
            ulong pos = PositionGetTicket(i);
            string symboll = PositionGetSymbol(i);
            if(PositionGetInteger(POSITION_MAGIC) == Magic&&symboll== _Symbol){
               if((PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY&&lastClose<vwap)||(PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_SELL&&lastClose>vwap))trade.PositionClose(pos);
               else NotInPosition=false;
            }
         }
         double lower = getLowerBand();
         double upper = getUpperBand();
         if(NotInPosition&&lastOpen>lower&&lastClose<lower&&lastClose<ma[0])executeSell();
         if(NotInPosition&&lastOpen<upper&&lastClose>upper&&lastClose>ma[0]) executeBuy();
       } 
       if(MarketClosed()){
          for(int i = PositionsTotal()-1; i>=0; i--){
            ulong pos = PositionGetTicket(i);
            string symboll = PositionGetSymbol(i);
            if(PositionGetInteger(POSITION_MAGIC) == Magic&&symboll== _Symbol)trade.PositionClose(pos);
          }
       }
        
   }
}

//+------------------------------------------------------------------+
//| Detect if market is opened                                       |
//+------------------------------------------------------------------+ 
bool MarketOpened()
{
    datetime currentTime = TimeTradeServer(); 
    MqlDateTime timeStruct;
    TimeToStruct(currentTime, timeStruct);
    int currentHour = timeStruct.hour;
    int currentMinute = timeStruct.min;
    if (currentHour >= startHour &&currentMinute>=startMinute)return true;
    else return false;
}

//+------------------------------------------------------------------+
//| Detect if market is closed                                       |
//+------------------------------------------------------------------+ 
bool MarketClosed()
{
    datetime currentTime = TimeTradeServer(); 
    MqlDateTime timeStruct;
    TimeToStruct(currentTime, timeStruct);
    int currentHour = timeStruct.hour;
    int currentMinute = timeStruct.min;
    if (currentHour >= endHour && currentMinute >= endMinute)return true;
    else return false;
}

//+------------------------------------------------------------------+
//| Sell execution function                                          |
//+------------------------------------------------------------------+        
void executeSell() {      
       double bid = SymbolInfoDouble(_Symbol, SYMBOL_BID);
       bid = NormalizeDouble(bid,_Digits);
       double sl = bid*(1+slp);
       sl = NormalizeDouble(sl, _Digits);
       lot = calclots(bid*slp);
       trade.Sell(lot,_Symbol,bid,sl);    
}

//+------------------------------------------------------------------+
//| Buy execution function                                           |
//+------------------------------------------------------------------+     
void executeBuy() {
       double ask = SymbolInfoDouble(_Symbol, SYMBOL_ASK);
       ask = NormalizeDouble(ask,_Digits);
       double sl = ask*(1-slp);
       sl = NormalizeDouble(sl, _Digits);
       lot = calclots(ask*slp);
       trade.Buy(lot,_Symbol,ask,sl);
}

//+------------------------------------------------------------------+
//| Get VWAP function                                                |
//+------------------------------------------------------------------+
double getVWAP(int startCandle)
{
   double sumPV = 0.0;  // Sum of (price * volume)
   long sumV = 0.0;    // Sum of volume

   // Loop from the starting candle index down to 1 (excluding current candle)
   for(int i = startCandle; i >= 1; i--)
   {
      // Calculate typical price: (High + Low + Close) / 3
      double high = iHigh(_Symbol, PERIOD_CURRENT, i);
      double low = iLow(_Symbol, PERIOD_CURRENT, i);
      double close = iClose(_Symbol, PERIOD_CURRENT, i);
      double typicalPrice = (high + low + close) / 3.0;

      // Get volume and update sums
      long volume = iVolume(_Symbol, PERIOD_CURRENT, i);
      sumPV += typicalPrice * volume;
      sumV += volume;
   }

   // Calculate VWAP or return 0 if no volume
   if(sumV == 0)
      return 0.0;
   
   double vwap = sumPV / sumV;

   // Plot the dot
   datetime currentBarTime = iTime(_Symbol, PERIOD_CURRENT, 0);
   string objName = "VWAP" + TimeToString(currentBarTime, TIME_MINUTES);
   ObjectCreate(0, objName, OBJ_ARROW, 0, currentBarTime, vwap);
   ObjectSetInteger(0, objName, OBJPROP_COLOR, clrGreen);    // Green dot
   ObjectSetInteger(0, objName, OBJPROP_STYLE, STYLE_DOT);   // Dot style
   ObjectSetInteger(0, objName, OBJPROP_WIDTH, 1);           // Size of the dot
   
   return vwap;
}

//+------------------------------------------------------------------+
//| Find the index of the candle corresponding to the session open   |
//+------------------------------------------------------------------+
int getSessionStartIndex()
{
   int sessionIndex = 1;
   // Loop over bars until we find the session open
   for(int i = 1; i <=1000; i++)
   {
      datetime barTime = iTime(_Symbol, PERIOD_CURRENT, i);
      MqlDateTime dt;
      TimeToStruct(barTime, dt);
      
      if(dt.hour == startHour && dt.min == 30)
      {
         sessionIndex = i;
         break;
      }
   }
      
   return sessionIndex;
}

//+------------------------------------------------------------------+
//| Get the number of bars from now to market open                   |
//+------------------------------------------------------------------+
int getBarShiftForTime(datetime day_start, int hour, int minute) {
    MqlDateTime dt;
    TimeToStruct(day_start, dt);
    dt.hour = hour;
    dt.min = minute;
    dt.sec = 0;
    datetime target_time = StructToTime(dt);
    int shift = iBarShift(_Symbol, PERIOD_M1, target_time, true);
    return shift;
}

//+------------------------------------------------------------------+
//| Get the upper Concretum band value                               |
//+------------------------------------------------------------------+
double getUpperBand() {
    // Get the time of the current bar
    datetime current_time = iTime(_Symbol, PERIOD_CURRENT, 0);
    MqlDateTime current_dt;
    TimeToStruct(current_time, current_dt);
    int current_hour = current_dt.hour;
    int current_min = current_dt.min;
    
    // Find today's opening price at 9:30 AM
    datetime today_start = iTime(_Symbol, PERIOD_D1, 0);
    int bar_at_930_today = getBarShiftForTime(today_start, 9, 30);
    if (bar_at_930_today < 0) return 0; // Return 0 if no 9:30 bar exists
    double open_930_today = iOpen(_Symbol, PERIOD_M1, bar_at_930_today);
    if (open_930_today == 0) return 0; // No valid price
    
    // Calculate sigma based on the past 14 days
    double sum_moves = 0;
    int valid_days = 0;
    for (int i = 1; i <= 14; i++) {
        datetime day_start = iTime(_Symbol, PERIOD_D1, i);
        int bar_at_930 = getBarShiftForTime(day_start, 9, 30);
        int bar_at_HHMM = getBarShiftForTime(day_start, current_hour, current_min);
        if (bar_at_930 < 0 || bar_at_HHMM < 0) continue; // Skip if bars don't exist
        double open_930 = iOpen(_Symbol, PERIOD_M1, bar_at_930);
        double close_HHMM = iClose(_Symbol, PERIOD_M1, bar_at_HHMM);
        if (open_930 == 0) continue; // Skip if no valid opening price
        double move = MathAbs(close_HHMM / open_930 - 1);
        sum_moves += move;
        valid_days++;
    }
    if (valid_days == 0) return 0; // Return 0 if no valid data
    double sigma = sum_moves / valid_days;
    
    // Calculate the upper band
    double upper_band = open_930_today * (1 + sigma);
    
    // Plot a blue dot at the upper band level
    string obj_name = "UpperBand_" + TimeToString(current_time, TIME_DATE|TIME_MINUTES|TIME_SECONDS);
    ObjectCreate(0, obj_name, OBJ_ARROW, 0, current_time, upper_band);
    ObjectSetInteger(0, obj_name, OBJPROP_ARROWCODE, 159); // Dot symbol
    ObjectSetInteger(0, obj_name, OBJPROP_COLOR, clrBlue);
    ObjectSetInteger(0, obj_name, OBJPROP_WIDTH, 2);
    
    return upper_band;
}

//+------------------------------------------------------------------+
//| Get the lower Concretum band value                               |
//+------------------------------------------------------------------+
double getLowerBand() {
    // Get the time of the current bar
    datetime current_time = iTime(_Symbol, PERIOD_CURRENT, 0);
    MqlDateTime current_dt;
    TimeToStruct(current_time, current_dt);
    int current_hour = current_dt.hour;
    int current_min = current_dt.min;
    
    // Find today's opening price at 9:30 AM
    datetime today_start = iTime(_Symbol, PERIOD_D1, 0);
    int bar_at_930_today = getBarShiftForTime(today_start, 9, 30);
    if (bar_at_930_today < 0) return 0; // Return 0 if no 9:30 bar exists
    double open_930_today = iOpen(_Symbol, PERIOD_M1, bar_at_930_today);
    if (open_930_today == 0) return 0; // No valid price
    
    // Calculate sigma based on the past 14 days
    double sum_moves = 0;
    int valid_days = 0;
    for (int i = 1; i <= 14; i++) {
        datetime day_start = iTime(_Symbol, PERIOD_D1, i);
        int bar_at_930 = getBarShiftForTime(day_start, 9, 30);
        int bar_at_HHMM = getBarShiftForTime(day_start, current_hour, current_min);
        if (bar_at_930 < 0 || bar_at_HHMM < 0) continue; // Skip if bars don't exist
        double open_930 = iOpen(_Symbol, PERIOD_M1, bar_at_930);
        double close_HHMM = iClose(_Symbol, PERIOD_M1, bar_at_HHMM);
        if (open_930 == 0) continue; // Skip if no valid opening price
        double move = MathAbs(close_HHMM / open_930 - 1);
        sum_moves += move;
        valid_days++;
    }
    if (valid_days == 0) return 0; // Return 0 if no valid data
    double sigma = sum_moves / valid_days;
    
    // Calculate the lower band
    double lower_band = open_930_today * (1 - sigma);
    
    // Plot a red dot at the lower band level
    string obj_name = "LowerBand_" + TimeToString(current_time, TIME_DATE|TIME_MINUTES|TIME_SECONDS);
    ObjectCreate(0, obj_name, OBJ_ARROW, 0, current_time, lower_band);
    ObjectSetInteger(0, obj_name, OBJPROP_ARROWCODE, 159); // Dot symbol
    ObjectSetInteger(0, obj_name, OBJPROP_COLOR, clrRed);
    ObjectSetInteger(0, obj_name, OBJPROP_WIDTH, 2);
    
    return lower_band;
}

//+------------------------------------------------------------------+
//| Calculate lot size based on risk and stop loss range             |
//+------------------------------------------------------------------+
double calclots(double slpoints) {
    double riskAmount = AccountInfoDouble(ACCOUNT_BALANCE) * risk / 100;
    double ticksize = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_SIZE);
    double tickvalue = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_VALUE);
    double lotstep = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP);
    double moneyperlotstep = slpoints / ticksize * tickvalue * lotstep;
    double lots = MathFloor(riskAmount / moneyperlotstep) * lotstep;
    lots = MathMin(lots, SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MAX));
    lots = MathMax(lots, SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN));
    return lots;
}

一次典型交易示例如下:

orb3示例

回测结果:

orb3设置

orb3参数

orb3净值曲线

orb3结果

该策略的交易频率与首个ORB策略相近,平均每两个交易日进行约一笔交易。其并非每日交易,因为有时价格波动会维持在噪音区间内,未能突破通道。由于采用VWAP作为动态追踪止损,其胜率低于50%。盈亏比为1.3,夏普比率为5.9,表明相对于回撤而言,该策略的收益表现强劲。

orb3对比

orb3回撤

该策略表现略优于买入并持有策略,同时最大回撤幅度仅为后者的一半。然而,与前一个策略相比,它更频繁地经历大幅回撤期。这就表明,尽管该策略表现更优,但在创下新的净值高点之前,往往会经历较长时间的回撤。

orb3策略月度收益

orb3策略月度回撤

α:1.6562
β: -0.1183

该策略的相关性为-11%,表明其与标的资产呈轻微负相关性。对于寻求与市场趋势反向操作以获取优势的交易者而言,这是一个有利的结果。与其他两种策略相比,该策略出现回撤的月份更多,约占50%,但在盈利月份的收益更高。这一模式表明,交易者在实盘操作中应做好应对长期回撤期的准备,并耐心等待收益更高的阶段。基于在较长且稳定的时间范围内积累的大量样本数据,该策略仍具备可交易性。


反思

之前的文章中,我们探讨了构建模型系统而非单一策略的方法。在本文中,我们同样采用了这一理念。这三种策略均源自股市开盘时段的区间突破,且经过验证,其变体策略均能实现盈利。我们还分享了如何结合自身知识与直观感受,借鉴学术论文来发掘策略优势的心得。这一方法堪称发掘稳健交易理念、拓宽认知视野的绝佳途径。

既然我们手握三种盈利策略,现在便应从投资组合的视角加以考量。在同时交易这些策略之前,我们需要审视它们的综合表现、相关性以及整体最大回撤。在算法交易领域,分散化才是真正的“圣杯”。其有助于在不同时间段内,通过不同策略的回撤相互抵消,降低整体风险。在某种程度上,您的最大收益受限于您愿意承受的回撤幅度。通过组合多样化的策略,您可以在保持相似回撤水平的同时增加风险敞口,从而提升收益。然而,这种方式无法无限放大风险,因为最小风险始终会高于单笔交易的风险。

实现分散化的一些常见方法包括:

  • 交易同一策略模型,并将其应用于不同无相关性的资产上。
  • 在单一资产上交易不同的策略模型。
  • 将资金分配至不同的交易方式,如期权、套利和选股等。

必须明确的是,并非分散化程度越高越好;无相关性的分散化才是关键。例如,在所有加密货币市场应用同一策略并不理想,因为从更广泛的层面来看,大多数加密资产之间高度相关。此外,仅依赖回测中的分散化也可能产生误导,因为相关性取决于时间段,如日收益或月收益。此外,在市场环境发生重大转变时,策略间的相关性可能会发生扭曲和偏斜,出乎意料。因此,一些交易者更倾向于使用实盘交易结果相关性相对于回测结果相关性的变化,来评估其策略优势是否已衰退。

基于上述认知,以下是三种策略组合表现的回测统计数据。

组合净值曲线

组合回撤

净值曲线和回撤曲线直观地展示了不同策略如何在不同时间段内相互抵消回撤。当前组合的最大回撤约为10%,显著低于各单一策略超过15%的最大回撤水平。

组合月度收益

组合阅读回撤

回撤与收益在各月份间分布均匀,表明没有极端市场环境对回测表现产生不成比例的影响。这在拥有3000多个样本且每笔交易风险分配一致的情况下是合理的。

相关性矩阵

相关性用于衡量各策略回测净值曲线之间的相似程度,其取值范围从-1(表示行为相反)到1(表示行为完全一致),通常用于对两个对象进行比较。我们以净值曲线的时间轴为x轴、收益轴为y轴来进行计算。

相关性

相关性矩阵有助于直观呈现三种或更多策略之间的表现关联性。以月度为周期分析发现,各策略的月度收益存在轻微相关性,平均约为0.3。相关性低于0.5尚可接受,不过负相关性会更理想。尽管这些策略同时包含多头和空头交易,但均呈现正相关性,这很可能是因为它们均基于同一标的资产进行操作。这一深入洞察表明,虽然组合后的最大回撤低于单一策略,但由于我们在同一资产上交易了相似策略,月度收益仍较为相近。这提示我们,应将这些策略与其他不同策略搭配,而非将它们归入同一投资组合。


结论

本文回顾了康克瑞图姆集团学术论文中的三种日内开盘区间突破策略。我们首先概述了研究背景,阐释了全文采用的关键概念与方法。接着探讨了这三种策略的研发动机,指出了改进方向,提供了明确的信号规则、MQL5代码及回测统计分析。最后,我们反思了整个过程,引入了分散化理念,并对组合结果进行了分析。

本文为策略开发真实稳健性的探索提供了见解。深入的统计分析让我们能更全面地审视策略表现及其在投资组合中的作用。所有努力均旨在投入实盘交易前加深理解、建立信心。鼓励读者复现本文研究过程,并利用所提供的框架开发自己的EA。


文件表

文件名 文件使用
ORB1.mq5                                      第一种策略的MQL5 EA脚本
ORB2.mq5 第二种策略的MQL5 EA脚本
ORB3.mq5 第三种策略的MQL5 EA脚本

本文由MetaQuotes Ltd译自英文
原文地址: https://www.mql5.com/en/articles/17745

附加的文件 |
ORB.zip (7.11 KB)
最近评论 | 前往讨论 (15)
Muhammad Syamil Bin Abdullah
Muhammad Syamil Bin Abdullah | 20 6月 2025 在 15:19
感谢您的分享。
1149190
1149190 | 12 10月 2025 在 09:58
好文章

可惜的是,在默认设置下,我根本无法重现回溯测试的结果- 所有 3 个策略都是如此。

使用[redacted] 模拟账户 的数据。

有什么建议可以解释为什么我无法重现结果?

Serge Rosenberg
Serge Rosenberg | 12 10月 2025 在 17:43
1149190 # 这篇文章太棒了我只希望我能在默认设置下重现测试结果--这适用于所有 3 个策略。我使用的是[redacted] 模拟账户 的数据。对于为什么我无法重现结果,有什么建议吗?

这只适用于期货,如 NQ。

1149190
1149190 | 12 10月 2025 在 18:58
Serge Rosenberg #:

这只适用于期货,如 NQ。

感谢您的回复。我会看看的。我对策略 2 进行了反向测试,结果还不错,但策略 1 和策略 3 却不行。

您目前是在真实账户上运行该策略吗?
Wu簡單
Wu簡單 | 17 1月 2026 在 10:24
大家好 我把 orb1 修改一下 把指定時間 開倉後 持有多少個bar後自動平倉 效果不錯
orb2 不行 沒有預期效果

交易中的神经网络:具有层化记忆的智代 交易中的神经网络:具有层化记忆的智代
模仿人类认知过程的层化记忆方式令复杂金融数据的处理、以及适配新信号成为可能,因此在动态市场中提升投资决策的有效性。
创建动态多货币对EA(第二部分):投资组合多元化与优化 创建动态多货币对EA(第二部分):投资组合多元化与优化
投资组合多元化与优化旨在将投资有策略地分散配置于多种资产之上,在最小化风险的同时,依据风险调整后的绩效指标挑选出最理想的资产组合,从而实现回报最大化。
交易中的神经网络:具有层化记忆的智代(终篇) 交易中的神经网络:具有层化记忆的智代(终篇)
我们继续致力于创建 FinMem 框架,其采用层化记忆方式,即模拟人类认知过程。这令该模型不仅能有效处理复杂的财务数据,还能适应新信号,显著提升了在动态变化市场中投资决策的准确性和有效性。
从基础到中级:模板和类型名称 (五) 从基础到中级:模板和类型名称 (五)
在本文中,我们将探讨模板的最后一个简单用例,并讨论在代码中使用 typename 的好处和必要性。虽然这篇文章乍一看可能有点复杂,但为了以后使用模板和 typename,正确理解它很重要。