Quant BuffetRelax, Not Over Thinking

Betting Against Correlation in S&P500 Stocks

Log in to collect

Academic paper

The Low-Risk Effect, from Betting Against Beta to Betting Against Correlation

AuthorsTommaso Pasetti; Dennis Marco Montagna

Institute
  • ?Intermonte SIM
  • ITUniversity of Pavia
  • ?University of Pavia - Department of Economics and Management

Strategy in a nutshell

This strategy invests in AMEX, NYSE, and NASDAQ stocks (CRSP share codes 10 and 11) using the quartile Betting Against Correlation (qBAC) factor. Stocks are first sorted into four quartiles by volatility and then within each quartile into low- and high-correlation portfolios relative to the S&P 500. Long positions are taken in low-correlation stocks and short positions in high-correlation stocks, with correlation-weighted allocations and monthly rebalancing. Risk-adjusted returns are computed per quartile, incorporating inverse correlation weights and beta adjustments, and the qBAC factor is defined as the average of these returns across quartiles.

Economic rationale

CAPM predicts a positive risk-return relationship, but leverage constraints and behavioral biases prevent investors from fully exploiting it. Many overpay for high-beta stocks, despite historical evidence showing low-beta and low-correlated stocks often outperform. The qBAC factor captures this anomaly by going long low-correlated, defensive sectors (Utilities, Healthcare, Consumer Staples) and shorting high-correlated cyclical sectors (Industrials, Tech, Financials). This approach historically outperformed the S&P 500 until 2016, though central bank interventions and tech rallies during the pandemic reduced its relative performance.

Backtest performance

Annualised return7.32%
Volatility27.48%
Beta0.23
Sharpe ratio0.27
Sortino ratio0.246
Maximum drawdown-83.16%
Win rate53%

Full Python code

from AlgorithmImports import *
from pandas.core.frame import DataFrame
# endregion

class BettingAgainstCorrelationInSP500Stocks(QCAlgorithm):

def Initialize(self):
self.SetStartDate(2000, 1, 1)
self.SetCash(100000)

self.volatility_period:int = 12*21
self.correlation_period:int = 5*12*21
self.quantile:int = 4
self.portfolio_percentage:float = 1.

self.prices:Dict[Symbol, RollingWindow] = {}
self.weight:Dict[Symbol, float] = {}

self.exchanges:List[str] = ['NYS', 'NAS', 'ASE']

self.market_symbol:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
self.prices[self.market_symbol] = RollingWindow[float](self.correlation_period)

self.max_cap_weight:float = .1
self.long_leg_corr_treshold:float = 0.
self.long_leg_corr_substitute:float = .001

self.fundamental_count:int = 1000
self.fundamental_sorting_key = lambda x: x.DollarVolume

self.selection_flag:bool = False
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.Schedule.On(self.DateRules.MonthStart(self.market_symbol), self.TimeRules.BeforeMarketClose(self.market_symbol, 0), self.Selection)

def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
for security in changes.AddedSecurities:
    security.SetFeeModel(CustomFeeModel())
    security.SetLeverage(10)

def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
# update daily prices
for equity in fundamental:
    symbol:Symbol = equity.Symbol

    if symbol in self.prices:
        self.prices[symbol].Add(equity.AdjustedPrice)

if not self.selection_flag:
    return Universe.Unchanged

selected:List[Fundamental] = [x for x in fundamental if x.HasFundamentalData and x.Market == 'usa' and x.SecurityReference.ExchangeId in self.exchanges]
if len(selected) > self.fundamental_count:
    selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]

volatility:Dict[Symbol, list[float, list]] = {}
stocks_returns:Dict[Symbol, np.ndarray] = {}

# warm up stock prices
for stock in selected:
    symbol:Symbol = stock.Symbol
    
    if symbol not in self.prices:
        self.prices[symbol] = RollingWindow[float](self.correlation_period)
        history:DataFrame = self.History(symbol, self.volatility_period, Resolution.Daily)
        if history.empty:
            continue

        closes:pd.Series = history.loc[symbol].close

        for time, close in closes.items():
            self.prices[symbol].Add(close)
    
    # make sure SPY prices are ready
    if not self.prices[self.market_symbol].IsReady:
        continue
    
    # calculate volatility and store daily returns
    if self.prices[symbol].IsReady:
        prices:np.ndarray = np.array([x for x in self.prices[symbol]])
        returns:np.ndarray = prices[:-1] / prices[1:] - 1
        vol_value:float = np.std(returns[:self.volatility_period])

        volatility[symbol] = vol_value
        stocks_returns[symbol] = returns

# make sure enough stocks has volatility value
if len(volatility) < self.quantile:
    return Universe.Unchanged

quantile:int = int(len(volatility) / self.quantile)
sorted_by_vol:List[Symbol] = [x[0] for x in sorted(volatility.items(), key=lambda item: item[1])]

market_prices:np.ndarray = np.array([x for x in self.prices[self.market_symbol]])
market_returns:np.ndarray = market_prices[:-1] / market_prices[1:] - 1

# create long and short portfolio part
for i in range(self.quantile):
    long_leg:List[tuple[Symbol, float]] = []
    short_leg:List[tuple[Symbol, float]] = []

    total_long_corr:float = 0
    total_short_corr:float = 0

    correlation:Dict[Symbol, float] = {}
    curr_quantile_stocks:List[Symbol] = sorted_by_vol[i * quantile : (i + 1) * quantile]

    for symbol in curr_quantile_stocks:
        stock_returns:np.ndarray = stocks_returns[symbol]
        correlation_matrix:np.ndarray = np.corrcoef(stock_returns, market_returns)
        corr_value:float = correlation_matrix[0][1]
        correlation[symbol] = corr_value

    corr_median:float = np.median(list(correlation.values()))

    for symbol, corr_value in correlation.items():
        # within each quartile we go long (short) low (high) correlation stocks using the median as a threshold
        if corr_value >= corr_median:
            short_leg.append((symbol, corr_value))
            total_short_corr += abs(corr_value)
        else:
            if corr_value < self.long_leg_corr_treshold:
                corr_value = self.long_leg_corr_substitute

            long_leg.append((symbol, corr_value))
            total_long_corr += 1 / abs(corr_value)

    # weights calculations
    for i, portfolio in enumerate([long_leg, short_leg]):
        for symbol, corr_value in portfolio:
            w:float = ((1 / corr_value) / total_long_corr) * (1 / self.quantile) * self.portfolio_percentage
            w = min(self.max_cap_weight, w) # weight cap
            self.weight[symbol] = ((-1) ** i) * w
            
return list(self.weight.keys())

def OnData(self, data: Slice) -> None:
# rebalance monthly
if not self.selection_flag:
    return
self.selection_flag = False

# trade execution
portfolio:List[PortfolioTarget] = [PortfolioTarget(symbol, w) for symbol, w in self.weight.items() if symbol in data and data[symbol]]
self.SetHoldings(portfolio, True)

self.weight.clear()
        
def Selection(self) -> None:
self.selection_flag = True

# Custom fee model
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters):
fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))