Quant BuffetRelax, Not Over Thinking

Timing Betting-Against-Beta (BAB) Anomaly

Log in to collect

Academic paper

Strategy in a nutshell

The Betting Against Beta (BAB) strategy targets the risk-return anomaly across stocks with different market betas. Low-beta stocks are bought, and high-beta stocks are shorted, forming a self-financing, zero-beta portfolio. Stocks are weighted by their betas, and portfolio returns are scaled using realized volatility from the past 21 days to maintain a target volatility of 12%. The portfolio is rebalanced monthly, with positions adjusted based on the prior month’s volatility, exploiting the lower risk-adjusted returns of high-beta stocks relative to low-beta stocks.

Economic rationale

Research shows that the predictable gains from BAB primarily arise from specific (idiosyncratic) risk rather than market risk. Scaling by specific risk captures most of the strategy’s performance, suggesting that risk management and exploiting mispricing in low-beta versus high-beta stocks drive the returns, rather than broad market exposure.

Backtest performance

Annualised return22.35%
Volatility16.99%
Beta0.782
Sharpe ratio1.32
Sortino ratio0.187
Maximum drawdown-66.14%
Win rate49%

Full Python code

from AlgorithmImports import *
import numpy as np
from math import sqrt
import pandas as pd
from scipy import stats
from typing import Dict, List

class TimingBettingAgainstBetaAnomaly(QCAlgorithm):

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

self.exchange_codes:List[str] = ['NYS']	

# Daily price data.
self.data:Dict[Symbol, RollingWindow] = {}
self.period:int = 21

self.leverage:int = 10
self.min_share_price:int = 5

# Warmup market daily data.
self.symbol:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
self.data[self.symbol] = RollingWindow[float](self.period)
history:DataFrame = self.History(self.symbol, self.period, Resolution.Daily)
if history.empty:
    self.Log(f"Not enough data for {self.symbol} yet")
else:
    closes:Series = history.loc[self.symbol].close
    for time, close in closes.items():
        self.data[self.symbol].Add(close)

self.target_volatility:float = 0.12

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

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

self.selection_flag = False
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.Schedule.On(self.DateRules.MonthEnd(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 the rolling window every day.
for stock in fundamental:
    symbol:Symbol = stock.Symbol

    if symbol in self.data:
        # Store daily price.
        self.data[symbol].Add(stock.AdjustedPrice)

# Selection once a month.
if not self.selection_flag:
    return Universe.Unchanged

# selected = [x.Symbol for x in fundamental if x.HasFundamentalData and x.Market == 'usa']
selected:List[Fundamental] = [
    x for x in fundamental if x.HasFundamentalData and x.Price > self.min_share_price and x.Market == 'usa' \
    and x.MarketCap != 0 and x.SecurityReference.ExchangeId in self.exchange_codes
]
    
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 not in self.data:
        self.data[symbol] = RollingWindow[float](self.period)
        history = self.History(symbol, self.period, Resolution.Daily)
        if history.empty:
            self.Log(f"Not enough data for {symbol} yet")
            continue
        closes = history.loc[symbol].close
        for time, close in closes.items():
            self.data[symbol].Add(close)

if not self.data[self.symbol].IsReady:
    return Universe.Unchanged

market_returns:List[float] = []
market_closes:np.ndarray = np.array([x for x in self.data[self.symbol]])
market_returns = (market_closes[:-1] - market_closes[1:]) / market_closes[1:]

beta:Dict[Symbol, float] = {}
for stock in selected:
    symbol:Symbol = stock.Symbol
    
    # Data is ready.
    if self.data[symbol].IsReady and len(market_returns) != 0:
        stock_closes:np.ndarray = np.array([x for x in self.data[symbol]])
        stock_returns:np.ndarray = (stock_closes[:-1] - stock_closes[1:]) / stock_closes[1:]
        
        # cov = np.cov(market_returns, stock_returns)[0][1]
        # market_variance = np.std(market_returns) ** 2
        # beta[symbol] = cov / market_variance
        
        slope, intercept, r_value, p_value, std_err = stats.linregress(market_returns, stock_returns)
        beta[symbol] = slope

# Beta diff calc.
beta_median:float = np.median([x[1] for x in beta.items()])
long_diff:List[Tuple[Symbol, float]] = [(x[0], x[1] - beta_median) for x in beta.items() if x[1] >= beta_median]
short_diff:List[Tuple[Symbol, float]] = [(x[0], beta_median - x[1]) for x in beta.items() if x[1] < beta_median]

# Beta diff weighting.
for i, portfolio in enumerate([long_diff, short_diff]):
    total_diff:float = sum(list(map(lambda x: x[1], portfolio)))
    for symbol, diff in portfolio:
        self.weight[symbol] = ((-1)**i) * diff / total_diff

return [x[0] for x in self.weight.items()]

def OnData(self, data: Slice) -> None:
# Market daily data is stored in fundamental.
if not self.selection_flag:
    return
self.selection_flag = False

# Portfolio volatility calc.
df:DataFrame = pd.DataFrame()
weights:List[float] = []
for symbol, w in self.weight.items():
    df[str(symbol)] = [x for x in self.data[symbol]]
    weights.append(w)

weights = np.array(weights)

daily_returns:DataFrame = df.pct_change()
portfolio_vol:flaot = np.sqrt(np.dot(weights.T, np.dot(daily_returns.cov() * 21, weights.T)))

leverage:float = self.target_volatility / portfolio_vol

# trade execution
portfolio:List[PortfolioTarget] = [PortfolioTarget(symbol, w * leverage) 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"))