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.
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.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)
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 |
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. 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 -minute periods throughout the day to predict the return of the closing period.
Reference
- Gao, Lei and Han, Yufeng and Li, Sophia Zhengzi and Zhou, Guofu, Market Intraday Momentum (June 19, 2017). Online copy