Strategy Library

Intraday ETF Momentum

Abstract

In this tutorial, we implement an intraday momentum strategy that trades some of the most actively traded ETFs. Specifically, we observe the return generated from the first half-hour of the trading day to predict the sign of the trading day's last half-hour return. Researchers have shown that this momentum pattern is statistically and economically significant, even after accounting for trading fees. The algorithm we design here is a recreation of the research completed by Gao, Han, Li, and Zhou (2017).

Background

News items are usually released before the opening bell. As it takes time for traders to digest and interpret the news, the first half-hour of trading typically has relatively higher levels of volume and volatility. Additionally, as traders attempt to mitigate overnight risk by unloading positions near the close, the last half-hour of trading also sees these higher levels of volume and volatility. These characteristics can be observed from the image below, which is reproducible in the attached research notebook.

Tutorial1026-intraday-etf-momentum-1

Bogousslavsky (2016) points out that some investors are late-informed or simply prefer to delay their trading until the market close. As a result, a positive correlation exists between the direction of the opening and closing periods. Gao et al (2017) find that when trading this momentum strategy, the average annual return over their sample period was 6.67% for SPY, 11.72% for IWM, and 24.22% for IYR. Equal-weighting these returns leads to a combined average annual return of 14.2%.

Method

Universe Selection

We implement a manual universe selection model that supplies a subset of the proposed ETFs in the attached research paper. Gao et al (2017) select the following tickers: DIA, QQQ, IWM, EEM, FXI, EFA, VWO, XLF, IYR, and TLT. In an effort to increase the backtest performance, we narrow our universe to SPY, IWM, and IYR.

tickers = ['SPY',  # S&P 500
           'IWM',  # Russell 2000
           'IYR'   # Real Estate ETF
]
symbols = [ Symbol.Create(ticker, SecurityType.Equity, Market.USA) for ticker in tickers ]
self.SetUniverseSelection( ManualUniverseSelectionModel(symbols) )
self.UniverseSettings.Resolution = Resolution.Minute

Alpha Construction

The IntradayMomentumAlphaModel emits insights to take positions for the last `return_bar_count` minutes of the day in the direction of the return for the first `return_bar_count` minutes of the day. During construction, we create a dictionary to store IntradayMomentum data for each symbol, define a method to determine the sign of returns, and specify the value of `return_bar_count`. In this tutorial, we follow Gao et al (2017) in setting `return_bar_count` to 30 by default.

class IntradayMomentumAlphaModel(AlphaModel):
    intraday_momentum_by_symbol = {}
    sign = lambda _, x: int(x and (1, -1)[x < 0])

    def __init__(self, algorithm, return_bar_count = 30):
        self.return_bar_count = return_bar_count

Alpha Securities Management

When a new security is added to the universe, we create an IntradayMomentum object for it to store information needed to calculate morning returns. The management of the IntradayMomentum objects occurs in the alpha model's OnSecuritiesChanged method.

def OnSecuritiesChanged(self, algorithm, changes):
    for security in changes.AddedSecurities:
        self.intraday_momentum_by_symbol[security.Symbol] = IntradayMomentum(security, algorithm)

    for security in changes.RemovedSecurities:
        self.intraday_momentum_by_symbol.pop(security.Symbol, None)

The definition of the IntradayMomentum class is shown below. We save a reference to the security's exchange so we can access the market hours of the exchange when generating insights.

class IntradayMomentum:
    def __init__(self, security, algorithm):
        self.symbol = security.Symbol
        self.exchange = security.Exchange

        self.bars_seen_today = 0
        self.yesterdays_close = algorithm.History(self.symbol, 1, Resolution.Daily).loc[self.symbol].close[0]
        self.morning_return = 0

Alpha Update

With each call to the alpha model's Update method, we count the number of bars the algorithm has received for each symbol. If we've reached the end of the morning window, we calculate the morning return. If we are at the beginning of the close window, we emit an insight in the direction of the morning window's return. If we are at the end of the day, we save the closing price and reset the counter for the number of bars seen today.

def Update(self, algorithm, slice):
    insights = []

    for symbol, intraday_momentum in self.intraday_momentum_by_symbol.items():
        if slice.ContainsKey(symbol) and slice[symbol] is not None:
            intraday_momentum.bars_seen_today += 1
            
            # End of the morning return
            if intraday_momentum.bars_seen_today == self.return_bar_count:
                intraday_momentum.morning_return = (slice[symbol].Close - intraday_momentum.yesterdays_close) / intraday_momentum.yesterdays_close
            
            ## Beginning of the close
            next_close_time = intraday_momentum.exchange.Hours.GetNextMarketClose(slice.Time, False)
            mins_to_close = int((next_close_time - slice.Time).total_seconds() / 60)
            
            if mins_to_close == self.return_bar_count + 1:
                insight = Insight.Price(intraday_momentum.symbol, 
                                        next_close_time, 
                                        self.sign(intraday_momentum.morning_return))
                insights.append(insight)
                continue
            
            # End of the day
            if not intraday_momentum.exchange.DateTimeIsOpen(slice.Time):
                intraday_momentum.yesterdays_close = slice[symbol].Close
                intraday_momentum.bars_seen_today = 0
            
    return insights

Trade Execution

The attached research paper holds positions for the last 30 minutes of the trading day, exiting at the market close. In order to accomplish this, we create a custom execution model. The model defined below submits a market order for the entry while also submitting a market on close order in the same time step.

class CloseOnCloseExecutionModel(ExecutionModel):
    def __init__(self):
        self.targetsCollection = PortfolioTargetCollection()
        self.invested_symbols = []

    def Execute(self, algorithm, targets):
        # for performance we check count value, OrderByMarginImpact and ClearFulfilled are expensive to call
        self.targetsCollection.AddRange(targets)
        if self.targetsCollection.Count > 0:
            for target in self.targetsCollection.OrderByMarginImpact(algorithm):
                # calculate remaining quantity to be ordered
                quantity = OrderSizing.GetUnorderedQuantity(algorithm, target)
                if quantity == 0:
                    continue

                algorithm.MarketOrder(target.Symbol, quantity)
                algorithm.MarketOnCloseOrder(target.Symbol, -quantity)

            self.targetsCollection.ClearFulfilled(algorithm)

Algorithm

Conclusion

We conclude that the momentum pattern documented by Gao et al (2017) produces lower returns over our testing period. Comparing the strategy to the S&P 500 benchmark, the strategy has a lower Sharpe ratio during the backtesting period and during the recovery from the 2020 stock market crash. However, the strategy greatly outperforms the benchmark during the downfall of the 2020 crash, achieving a 4.8 Sharpe ratio. Throughout all of the time periods we tested, the strategy had a lower annual standard deviation than the benchmark, meaning more consistent returns. A breakdown of the results from all of the testing periods can be seen in the table below.

Period NameStart DateEnd DateStrategySharpeASD
Backtest 1/1/2015 8/16/2020Strategy-0.764 0.05
Benchmark 0.7090.185
Fall 2015 8/10/2015 10/10/2015Strategy -0.696 0.058
Benchmark-1.2430.793
2020 Crash 2/19/2020 3/23/2020Strategy 4.818 0.266
Benchmark-1.2430.793
2020 Recovery 3/23/2020 6/8/2020Strategy0.602 0.103
Benchmark 13.7610.386

We find the lack of performance for this strategy is not largely attributed to the inclusion of transaction costs in our analysis while Gao et al (2017) decide to ignore them. Even with ignoring the transaction fees, spread costs, and slippage, the strategy still has a lower Sharpe ratio than the S&P 500 and doesn't match the results found in the original research paper. Refer to the backtest results.

Throughout their research paper, Gao et al (2017) provide several suggestions to increase the return generated by this momentum pattern. These areas of future research include:

  • Trading only on days with economic news events by utilizing the TradingEconomics data set. Gae et al (2017) suggest using the Michigan Consumer Sentiment Index, and news released on gross domestic product or the consumer price index.
  • Restricting trading to times of greater volatility or during financial crises.
  • Incorporating a volume threshold the morning session must pass to signal a trade for the close.
  • Increasing diversification by extending the universe to include ETFs from other sectors.
  • Considering the return from multiple n-minute periods throughout the day to predict the return of the closing period.

References

  1. Gao, Lei and Han, Yufeng and Li, Sophia Zhengzi and Zhou, Guofu, Market Intraday Momentum (June 19, 2017). Online copy

You can also see our Documentation and Videos. You can also get in touch with us via Chat.

Did you find this page helpful?

Contribute to the tutorials: