One of the things I’ve noticed from staring at the screen all day for the last few months is that most of the large negative returns in US stock indexes have come overnight.
What do you mean by “overnight”?
The core stock trading session for US stocks is between 9:30 am and 4 pm Eastern Time.
That’s when most stock market transactions take place. When we look at daily OHLC (Open High Low Close) stock data, the open price is the first trade of the core 9:30 am session, and the close price is the price of the auction at the end of the 4 pm core trading session.
However, stocks also trade in the “pre-open” or “early trading session” which starts at 6:30 am and in the “late trading session” which goes until 8 pm. Futures on stock indexes also trade most of the day.
I’m interested to see how overnight returns (the jump from the close to the open) differ from intraday returns – and how that relationship may have changed recently.
Intuitively, we’d probably expect to see higher average returns overnight when the market is closed – because it’s much more difficult to hedge and manage our exposures when the cash market is closed, so we might expect to get paid a premium, on average, for taking that risk.
Let’s have a look…
First, we need some daily OHLC data. (Open, High, Low, Close).
Let’s use the SPY ETF, which is an exchange-traded fund which tracks the S&P 500 index.
If you want daily price data and don’t need to pull too much data at a time, then there are a number of free online sources for this, including:
We can use the
riingo packages to pull data from Alpha Vantage and Tiingo respectively.
Let’s use alphavantage. Go here and sign up for a free API key.
We’re going to use
alphavantager to pull daily adjusted time series data for SPY and hold it in an R data frame called SPY.
To make this work you’ll need to tell
alphavantager about your API key running the command:
av_api_key(MY_API_KEY). You’ll need to replace
AV_API_KEY with your actual API key.
av_api_key(MY_API_KEY) SPY <- av_get(symbol = 'SPY', av_fun = 'TIME_SERIES_DAILY_ADJUSTED', outputsize = 'full')
Here’s what our data looks like:
SPY %>% head(20) %>% kable() %>% kable_styling(full_width = FALSE, position = 'center') %>% scroll_box(width = '800px', height = '300px')
Calculate overnight and intraday returns
Now we calculate:
- overnight returns as the % difference between the close price and the previous open
- intraday returns as the % difference between the open and the close.
(I’ve also calculated close to close returns, which don’t get used in this analysis.)
SPY_returns <- SPY %>% mutate(adjfactor = adjusted_close / close) %>% mutate(open = adjfactor * (open - close) + adjusted_close, high = adjfactor * (high - close) + adjusted_close, low = adjfactor * (low - close) + adjusted_close, close = close * adjfactor) %>% mutate(c2c = close / lag(close) - 1) %>% mutate(intraday = close/open - 1) %>% mutate(overnight = lead(open)/close - 1) %>% mutate(overnightpremium = overnight - intraday) %>% na.omit()
Cumulative overnight and intraday returns
Now let’s plot the cumulative performance of two strategies:
- “intraday” goes long at the open, and holds until the end of the day, and is flat overnight
- “overnight” goes long at the close, holds overnight, and closes on the open the next day
SPY_returns %>% pivot_longer(c(intraday, overnight), names_to = 'period', values_to = 'returns') %>% group_by(period) %>% mutate(cumreturns = cumprod(1+returns)) %>% ggplot(aes(x=timestamp, y=cumreturns, color=period)) + geom_line() + ggtitle('Intraday and Overnight Cumulative SPY Returns')
What do we see?
We see that most of our returns over the full cycle come from holding stock exposure overnight (the green line). In fact, the total return of SPY intraday since 2000 has actually been negative. This quite remarkable.
We also see that most of the recent losses in the last two weeks have come overnight.
And that most of the recent gains in the last few weeks have been intraday.
Rolling average difference in intraday and overnight returns
Let’s plot the rolling difference between overnight and intraday returns.
We use the
slide_dbl function from the
slider package to achieve this.
SPY_returns %>% mutate(diff = intraday - overnight) %>% mutate(diff20 = slide_dbl(diff, mean, .before = 20, .complete = TRUE)) %>% na.omit() %>% ggplot(aes(x=timestamp, y=diff20)) + geom_line() + ggtitle('20 day moving average of difference between SPX intraday and overnight returns')
You can see how historically anomalous the recent behaviour has been.
Would we bet on that continuing for a while? I probably wouldn’t. I’d just expect this behaviour to revert to normal.
Maybe we can get a little more insight by looking at the 5-day average since 2019:
SPY_returns %>% mutate(diff = intraday - overnight) %>% mutate(diff5 = slide_dbl(diff, mean, .before = 5, .complete = TRUE)) %>% filter(timestamp >= '2019-01-01') %>% na.omit() %>% ggplot(aes(x=timestamp, y=diff5)) + geom_line() + ggtitle('5 day moving average of difference between SPX intraday and overnight returns')
And that’s exactly what we seem to see. When viewed over a shorter window length, the average difference in overnight and intraday returns does seem to be reverting to its mean.
The overnight drift
This paper looks at the returns from equity index futures and suggests that nearly 100% of those returns have come in one hour between 2 am and 3 am.
This is an insane result, and something well worth looking into…