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 ManualUniverseSelectionModel 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.set_universe_selection( ManualUniverseSelectionModel(symbols) )
self.universe_settings.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 on_securities_changed(self, algorithm, changes):
    for security in changes.added_securities:
        self.intraday_momentum_by_symbol[security.symbol] = IntradayMomentum(security, algorithm)

    for security in changes.removed_securities:
        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.contains_key(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.get_next_market_close(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.date_time_is_open(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.targets_collection = PortfolioTargetCollection()
        self.invested_symbols = []

    def execute(self, algorithm, targets):
        # for performance we check count value, OrderByMarginImpact and ClearFulfilled are expensive to call
        self.targets_collection.add_range(targets)
        if self.targets_collection.count > 0:
            for target in self.targets_collection.order_by_margin_impact(algorithm):
                # calculate remaining quantity to be ordered
                quantity = OrderSizing.get_unordered_quantity(algorithm, target)
                if quantity == 0:
                    continue

                algorithm.market_order(target.symbol, quantity)
                algorithm.market_on_close_order(target.symbol, -quantity)

            self.targets_collection.clear_fulfilled(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 1.452 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 Name Start Date End Date Strategy Sharpe ASD
Backtest 1/1/2015 8/16/2020 Strategy -0.628 0.002
Benchmark 0.582 0.023
Fall 2015 8/10/2015 10/10/2015 Strategy -0.417 0.002
Benchmark -0.642 0.044
2020 Crash 2/19/2020 3/23/2020 Strategy 1.452 0.045
Benchmark -1.466 0.416
2020 Recovery 3/23/2020 6/8/2020 Strategy 0.305 0.007
Benchmark 7.925 0.101