Using an Internal Bar Strength Indicator (IBSI) , the aim of this post is to show the mean reverting properties of overnight returns. The indicator measures the relative position of the daily closing price in relation to the daily price range. When the Close price is near the bottom of this range the following day’s Open price tends to be higher than average and visa versa.
We will explore this effect over three scenarios:
Scenario 1: The Goldilocks scenario where we are able to both observe end of day price for the purposes of generating the signal AND invest at that price (close to close)
Scenario 2: We observe the end of day price for the purposes of generating a signal but do not invest until the following morning (open to close return)
Scenario 3: We observe the end of day price for the purposes of generating a signal but do not invest until the close of the following day (t+1 close to close)
The formula for IBSI used in this report is: \[IBSI = \frac{Close_t - Low_t}{High_t - Low_t}\]
The inspiration for this post is based on this article
To test each scenario we will need to do the following:
Create a list of Equity ETFs to test
For each ETF create the IBS factor
For each ETF calculate the corresponding factor return in each scenario
Combine ETFs into one global Factor
To run our strategy we will be using the following etfs
To process our data we will use the following function
get.etf.ibs = function(etf.name , scenario = 1 , volume_filter = 1000000){
# Get data and Clean
etf = get.etf.symbol(etf.name)
etf = na.locf.default(etf)
etf = as.data.table(etf)
colnames(etf) = c( "date" , "Open" , "High" , "Low" , "Close" , "Volume" , "Adjusted")
# Volume Filter
etf[ , dollar_volume := Volume*Close]
etf[ , dollar_volume_average := rollapplyr(dollar_volume , 7 , mean , fill = NA)]
etf[ , dollar_volume_average_shift := shift(dollar_volume_average , 1)]
etf[ dollar_volume_average_shift > volume_filter , investable := T]
etf[ is.na(investable) , investable := F]
# Create factor and return series
etf = etf[ , IBS := (Close - Low) / (High - Low) , ]
etf = etf[ , daily.return.cc := Adjusted / shift(Adjusted) - 1 , ]
etf = etf[ , daily.return.oc := Close / Open - 1 , ]
etf = etf[ , daily.return.cc.lead := shift(daily.return.cc , type = "lead" , 1) , ]
etf = etf[ , daily.return.cc.lead2 := shift(daily.return.cc , type = "lead" , 2 ) , ]
etf = etf[ , daily.return.oc.lead := shift(daily.return.oc , type = "lead") , ]
# Set return to correct scenario
if(scenario == 1){
etf$scenario_returns = etf$daily.return.cc.lead
} else {
if(scenario == 2){
etf$scenario_returns = etf$daily.return.oc.lead
} else {
etf$scenario_returns = etf$daily.return.cc.lead2
}
}
etf = etf[year(date) > 1999, ]
# plot returns against raw signal
p1 = ggplot(etf[investable == T , ] , aes(x = IBS , y = scenario_returns)) + geom_point() +
geom_smooth(method='lm',formula=y~x) + ggtitle(label = etf.name$Name , subtitle = etf.name$Type)
# generate lm
regression = summary(lm(scenario_returns ~ IBS , etf[investable == T , ]))
# Create signal factor bins
etf$portfolio = ifelse(etf$IBS <= 0.2 , 1 ,
ifelse(etf$IBS <= 0.4 , 2 ,
ifelse(etf$IBS <= 0.6 , 3,
ifelse(etf$IBS <= 0.8 , 4, 5))))
etf$portfolio = na.locf0(etf$portfolio)
etf$portfolio = as.factor(etf$portfolio)
src = calcluate_information_coefficient(etf[investable == T , ] , holding_period = 1)
# Generate box-plox
p2 = ggplot(etf[investable == T , ] , aes(x = portfolio , y = scenario_returns)) +
geom_boxplot() + ggtitle(label = etf.name$Name , subtitle = etf.name$Type)
# Calculate cumulative returns by factor bin
etf.cumulative.return = etf[investable == T , ][order(portfolio , date)][ , .(date , returns = scenario_returns) ,portfolio ]
# Calculate buy and hold returns
overall.cumuative.return = etf[order(date)][ , .(date , returns = daily.return.cc.lead) ,]
overall.cumuative.return$portfolio = "overall.etf"
etf.cumulative.return = rbind(etf.cumulative.return , overall.cumuative.return)
# Calculate ls portfolio return
ls.portfolio = etf[investable == T , ][ portfolio %in% c(1,5)][order(date)]
ls.portfolio$scenario_returns = ifelse(ls.portfolio$portfolio == 1,ls.portfolio$scenario_returns , -ls.portfolio$scenario_returns)
ls.portfolio = data.table(portfolio = "ls.portfolio" , date = ls.portfolio$date , returns = ls.portfolio$scenario_returns)
etf.cumulative.return = rbind(etf.cumulative.return , ls.portfolio)
etf.cumulative.return$portfolio = as.factor(etf.cumulative.return$portfolio)
# Get Performance stats
etf.cumulative.return = dcast(etf.cumulative.return , date ~ portfolio , value.var = "returns" )
etf.cumulative.return[is.na(etf.cumulative.return)] = 0
stats = get_annualized_stats(etf.cumulative.return)
# Plot out all return series
etf.cumulative.return = melt(etf.cumulative.return, id.vars = "date")
etf.cumulative.return = etf.cumulative.return[ , cum.returns := cumprod(value + 1) , variable]
etf.cumulative.return = etf.cumulative.return[ , portfolio := variable]
p3 = ggplot(etf.cumulative.return , aes(x = date , y = cum.returns , color = portfolio)) + geom_line() +
ggtitle(label = etf.name$Name , subtitle = etf.name$Type)
# Return all data
etf$etf.name = etf.name$Name
etf$etf.ticker = etf.name$Symbol
output = list(p1 = p1 , regression = regression , src = src , summary_table = stats, p2 = p2 , p3 = p3 , etf.data = etf)
return(output)
}
We can then run our tickers through this function for scenario 1, 2 and then 3 and output the factor analysis and performance for each ticker under each scenario.
There are a lot of tickers in our data set so let’s start by summarizing by Information Coefficient and Performance Data for Scenario 1
In general, for factors, information coefficients are fairly low, in-fact finding ICs with values greater than +/-0.1 is fairly uncommon.
Let’s take a look at a histogram of our ICs:
You can see that almost all of our information coefficients (IC) are negative and centered around -0.06. In fact the mean and median are -0.062 and -0.058 respectively.
Next we can review the overall universe IBSI scores vs following day returns in a linear model.
| scenario_returns | |||
|---|---|---|---|
| Predictors | Estimates | CI | p |
| (Intercept) | 0.00158 | 0.00149 – 0.00168 | <0.001 |
| IBS | -0.00243 | -0.00258 – -0.00228 | <0.001 |
| Observations | 372679 | ||
| R2 / R2 adjusted | 0.003 / 0.003 | ||
We don’t have the strongest R squared value here but it is encouraging that our coefficients are statistically significant. Furthermore a spearman rank of our entire data set of -0.05 helps confirm a pattern likely exists.
Let’s take a closer look at some interesting ETFs:
We will start with the one with the largest IC, the SHV (The Short Treasury Bond ETF).
| scenario_returns | |||
|---|---|---|---|
| Predictors | Estimates | CI | p |
| (Intercept) | 0.00011 | 0.00010 – 0.00012 | <0.001 |
| IBS | -0.00013 | -0.00014 – -0.00011 | <0.001 |
| Observations | 4313 | ||
| R2 / R2 adjusted | 0.056 / 0.055 | ||
Next let’s take a look at the ETF with the largest return, the PIN (India ETF)
| scenario_returns | |||
|---|---|---|---|
| Predictors | Estimates | CI | p |
| (Intercept) | 0.00407 | 0.00280 – 0.00535 | <0.001 |
| IBS | -0.00760 | -0.00963 – -0.00557 | <0.001 |
| Observations | 3087 | ||
| R2 / R2 adjusted | 0.017 / 0.017 | ||
And finally the one with the worst IC the ARGT (Argentina ETF)
| scenario_returns | |||
|---|---|---|---|
| Predictors | Estimates | CI | p |
| (Intercept) | -0.00161 | -0.00358 – 0.00036 | 0.110 |
| IBS | 0.00420 | 0.00090 – 0.00751 | 0.013 |
| Observations | 1046 | ||
| R2 / R2 adjusted | 0.006 / 0.005 | ||
A couple of things I want to make note of at this point:
These results look really great, but it’s important to distinguish between market anomalies and what is actually tradable. For instance, Scenario 1 is very unrealistic; being able to observe the close price for signal purposes and then execute at that price is technically impossible. You might be able to get close if you are a high frequency trader but even then anyone who has actually traded knows that open and close are where most price action occurs.
A lot of these ETFs are low volume, not all of them, but a lot of them. For instance the India Country ETF with the highest returning portfolio. Its average trading volume over the period was barely $8M a day. For Reference the SPY averages $19 Billion in volume daily. So far we haven’t accounted for slippage or fees. To try and account for the low volume, the function above does have a volume filter which will only count the signal and invest in the portfolio if the rolling 7 day dollar volume of the ETF is more than $1MM. This helps eliminate a lot of data outliers, but even this volume assumption is fairly low. (Periods of no trading due to this constraint are most evident in the ARGT equity curves above).
Technically our hypothesis is that ETFs mean revert based on the IBS signal. But we’ve seen that some, like the Argentina ETF, exhibit more trend following behavior. In an actual trading model, technically we don’t need to make our model so strict. We could easily relax this assumption and instead look for strong mean-reverting OR trend following signals. You can easily see in the plots above that if we flip our long/short (ls) portfolios we would actually have a decent ls return for the ARGT.
For now though we will continue with the analysis.
Now that we know we might have a signal we can test out a hypothetical portfolio as if we were able to trade under Scenario 1.
For this step we will keep the portfolio construction fairly simple.
It’s important to note that we are cheating here. How?
Technically we are using an IC that has been trained over the entire period to filter our data. This is called look ahead bias. Basically we are using information to make a decision historically with data we would not have had at the time.
A more realistic implementation would be to create a rolling IC and then use the prior day value to filter our etfs. (Similar to what we did for the volume constraint).
Acknowledging that let’s proceed.
Amazing, we created a long short portfolio that returns 30% a year, has a Sharpe ratio over 3 and only drew down about 17% at its worst. And all we need was a bit of look ahead bias, a highly unrealistic entry assumptions, zero slippage and no transaction fees. (Note the sarcasm)
In all seriousness though, we might not be able to trade this portfolio but we did prove that there does seem to be a mean reverting effect between intraday ETF movement and overnight price action.
Next let’s see if we can make it a bit more realistic from a trading perspective.
You can already see that both IC and returns are down when we change to scenario 2. This is further exemplified by our histogram.
In this case our mean and median have moved basically to 0, at -0.004 and -0.01 respectively.
You can see very quickly that buying the following morning of the signal and holding to close, does not have the same effect of buying the close the same day.
Our annualized returns now fall to 4.5%, the Sharpe ratio to .58 and Drawdown to 21.5%.
You can also see from the plot that you really haven’t made any money since 2010.
Here is an interesting market dynamic, semi-related to our current exploration.
In general for the S&P 500 (as proxied by the SPY). An overwhelming majority of returns have come by holding overnight (Close to Open) and not during the trading session (Open to Close). Note that this exercise uses un-adjusted prices so will not account for dividend reinvestment.
This could be one reason why returns in general are so low in our current scenario 2. But more analysis would need to be conducted for the other ETFs.
Scenario 3 is another practical implementation from a trading perspective. The main question here is whether the signal is strong enough to still be relevant investing at the following day’s close.
Let’s take a look.
On an individual basis our returns are even lower in this scenario and our IC also seem to have dropped.
The histogram of ICs also shows the same story as scenario 2 as the distribution has moved a lot closer to 0 from scenario 1.
Our returns are slightly better than scenario 2, but also much worse than our original scenario. Also the benefits in scenario 3’s returns over scenario 2 is likely due to the market anomaly we discussed above. Here we are holding close to close, not just during the session.
The signal under scenario 3 also did not produce any real returns from 2005 until 2020.
Now, you might also look at this return profile and think. “Well our long portfolio outperforms the SPY, so why not just hold that?”
Here is the thing… To be honest, SPY isn’t really the best benchmark for this exercise (and definately a topic for another post). To start, if we want to benchmark our long short portfolio we certainly shouldn’t be using a long only benchmark. Secondly, even the long only “Portfolio 1” is an amalgamation of sector, country and precious metal ETFs all with their own unique factor profiles, most of which completely unrelated to the SPY.
Therefore, in order to determine that the out performance was driven by the actual IBSI factor a more extensive analysis would be required (like determining what type of factor risk Portfolio 1 is actually exposed to and which ETFs it’s most commonly holding on a look through basis).
In this post we demonstrated that likely most Equity ETFs exhibit mean reverting properties based on intraday returns as measured with the IBSI factor.
We also demonstrated the importance of distinguishing between a market anomaly and a tradable signal. Scenario 1 seemed very promising from a risk reward perspective but actually implementing that strategy is theoretically impossible.
We then saw how quickly our signal strength dissipates as we moved to more realistic implementations.
And finally we discussed how just because a portfolio may seem like it “outperforms”, understanding how it might differ from the chosen benchmark is imperative before any conclusions can be reached.