Quant BuffetRelax, Not Over Thinking

Betting Against Correlation Effect

Log in to collect

Academic paper

Betting Against Correlation: Testing Theories of the Low-Risk Effect

AuthorsClifford S. Asness; Andrea Frazzini; Niels Joachim Gormsen; Lasse Heje Pedersen

Institute
  • Capital University
  • ?AQR Capital Management, LLC
  • University of Chicago
  • ?University of Chicago - Booth School of Business
  • Centre for Economic Policy Research
  • DKCopenhagen Business School
  • New York University
  • ?Centre for Economic Policy Research (CEPR)
  • ?Copenhagen Business School - Department of Finance
  • ?New York University (NYU)

Strategy in a nutshell

The strategy targets US stocks ranked by their previous month’s estimated volatility, dividing them into five quintiles. Each quintile is further split into low- and high-correlation portfolios. Low-correlation stocks receive higher weights in the long positions, while high-correlation stocks are shorted within each quintile. Quintiles are equally weighted, and the portfolio is rebalanced monthly to systematically capture both volatility and correlation effects.

Economic rationale

The strategy leverages the low-risk effect, primarily explained by leverage constraints. By neutralizing volatility using the BAC (betting against correlation) factor, it consistently performs well domestically and internationally. Evidence shows higher returns when margin debt is low, indicating investors prefer low-risk stocks under leverage constraints. Behavioral explanations, tested via LMAX and SMAX factors, appear less significant, highlighting leverage constraints as the dominant driver of these low-risk returns.

Backtest performance

Annualised return12.28%
Volatility13.2%
Beta1.616
Sharpe ratio0.93
Sortino ratio0.297
Win rate53%

Full Python code

import numpy as np
from AlgorithmImports import *
from typing import Dict, List
class BettingAgainstCorrelationEffect(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2000, 1, 1)
self.SetCash(100000)
self.leverage:int = 5
self.quantile:int = 5
self.min_share_price:int = 5
self.fundamental_sorting_key = lambda x: x.DollarVolume
self.fundamental_count:int = 500
self.quintile_internal_count:int = 10   # Pick n low corraleted stocks and n high corraleted stocks in each quintile.
self.data:Dict[Symbol, RollingWindow] = {} # Storing daily prices
self.long:List[Symbol] = []
self.short:List[Symbol] = []

self.symbol:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
self.period:int = 21
self.SetWarmUp(self.period)

self.market_prices:RollingWindow = RollingWindow[float](self.period)

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

self.settings.daily_precise_end_time = False

def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
for security in changes.AddedSecurities:
    security.SetFeeModel(CustomFeeModel())
    security.SetLeverage(self.leverage)
def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
# Update Prices in RollingWindow
for stock in fundamental:
    symbol:Symbol = stock.Symbol
    
    if symbol in self.data:
        self.data[symbol].Add(stock.AdjustedPrice)

if not self.selection_flag:
    return Universe.Unchanged

selected:List[Fundamental] = [
    x for x in fundamental if x.HasFundamentalData and x.Price > self.min_share_price and x.Market == 'usa'
]

if len(selected) > self.fundamental_count:
    selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]

# Warmup price rolling windows.
for stock in selected:
    symbol:Symbol = stock.Symbol
    if symbol in self.data:
        continue
    self.data[symbol] = RollingWindow[float](self.period)
    history:DataFrame = self.History(symbol, self.period, Resolution.Daily)
    if history.empty:
        self.Log(f"Not enough data for {symbol} yet")
        continue
    closes:Series = history.loc[symbol].close
    for time, close in closes.items():
        self.data[symbol].Add(close)
        
if not self.market_prices.IsReady:
    return Universe.Unchanged

market_prices:List[float] = [x for x in self.market_prices]

correlation:Dict[Symbol, float] = {}
volatility:Dict[Symbol, float] = {}

for stock in selected:
    symbol:Symbol = stock.Symbol
    if not self.data[symbol].IsReady:
        continue
    closes:List[float] = [x for x in self.data[symbol]]
    volatility[symbol] = self.Volatility(closes)
    correlation[symbol] = np.correlate(closes, market_prices)
# Volatility sorting
sorted_by_vol:List[Symbol] = [x[0] for x in sorted(volatility.items(), key=lambda item: item[1], reverse=True)]
quantile:int = int(len(sorted_by_vol)/self.quantile)

# Group splitting
volatility_groups:List[List[Symbol]] = [sorted_by_vol[x:x+quantile] for x in range(0, len(sorted_by_vol),quantile)]
group_count:int = len(volatility_groups)

# Long = array of arrays = groups by 10 stocks to long
# Short = array of arrays = groups by 10 stocks to short
for vol_group in volatility_groups:
    symbols:List[Symbol] = [x for x in vol_group]
    sorted_by_correlation:List[Symbol] = sorted(symbols, key = lambda x: correlation[x], reverse = True)
    
    # Go long stocks with highest correlations and short stocks with lowest correlation
    self.long.append([x for x in sorted_by_correlation][:self.quintile_internal_count])
    self.short.append([x for x in sorted_by_correlation][-self.quintile_internal_count:])
    
long_short:List[Symbol] = []
for group in self.long + self.short:
    for symbol in group:
        long_short.append(symbol)
        
return long_short

def OnData(self, data: Slice) -> None:
# store daily market price
if self.symbol in data and data[self.symbol]:
    price:float = data[self.symbol].Value
    self.market_prices.Add(price)
    
if not self.selection_flag:
    return
self.selection_flag = False
# Trade execution
self.Liquidate()

if len(self.long) == 0 or len(self.short) == 0:
    self.Liquidate()
    return
long_length:int = len(self.long)
short_length:int = len(self.short)

equity_per_long_group:float = float(1 / long_length)
equity_per_short_group:float = float(1 / short_length)

for group in self.long:
    count:int = int(len(group))
    weight_index:float = 1 / sum([x for x in range(count+1)])
    for symbol in group:
        if symbol in data and data[symbol]:
            self.SetHoldings(symbol, equity_per_long_group * (count*weight_index))
            count -= 1
for group in self.short:
    count = int(len(group))
    weight_index = 1 / sum([x for x in range(count+1)])
    count = 1
    for symbol in group:
        if symbol in data and data[symbol]:
            self.SetHoldings(symbol, -equity_per_short_group * (count*weight_index))
            count -= 1
    
self.long.clear()
self.short.clear()
def Selection(self) -> None:
# Monthly rebalance
self.selection_flag = True

def Volatility(self, values) -> float:
values = np.array(values)
returns = (values[:-1] - values[1:]) / values[1:]
return np.std(returns)  

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