Timing Betting Against Beta with Small Stocks
Log in to collectAcademic paper
Adam Zaremba
- Poznań University of Economics and Business
- Montpellier Business School
- ?Poznan University of Economics and Business
Strategy in a nutshell
Ranks 24 developed-market countries by past SMB returns and implements BAB (betting-against-beta) strategies: going long in high-SMB countries and short in low-SMB countries. Portfolios are equally weighted and rebalanced monthly to capture cross-country SMB differences.
Economic rationale
Performance is driven by funding and asset liquidity: rising small-cap prices improve collateral for leveraged low-beta stocks, increasing demand and temporarily boosting returns. BAB profits are enhanced after positive SMB payoffs, while periods without SMB signals generate minimal alpha.
Backtest performance
Annualised return22.42%
Volatility19.87%
Beta-0.033
Sharpe ratio1.13
Win rate55%
Full Python code
from AlgorithmImports import *
import numpy as np
#endregion
class TimingBettingAgainstBetawithSmallStocks(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2000, 1, 1)
self.SetCash(100000)
self.countries = [
"AUS", "AUT", "BEL", "CAN", "DNK", "FIN", "FRA", "DEU",
"GRC", "HKG", "IRL", "ISR","ITA","JPN","NLD","NZL","NOR",
"PRT","SGP","ESP","SWE","CHE","GBR","USA"
]
self.symbol:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
self.smb_history:dict = {}
self.quantile:int = 5
self.max_missing_days:int = 5
self.period:int = 3 * 21 # performance period.
self.SetWarmUp(self.period, Resolution.Daily)
self.smb_symbol:Symbol = self.AddData(SMB, 'SMB_percentage', Resolution.Daily).Symbol
for country in self.countries:
# BAB and SMB data.
data = self.AddData(BAB, country + '_BAB', Resolution.Daily)
data.SetLeverage(10)
data.SetFeeModel(CustomFeeModel())
self.smb_history[country] = RollingWindow[float](self.period)
self.recent_month:int = -1
def OnData(self, data:Slice) -> None:
if self.smb_symbol in data and data[self.smb_symbol]:
for country in self.countries:
smb_value:float = data[self.smb_symbol].GetProperty(country)
self.smb_history[country].Add(smb_value)
# rebalance monthly
if self.Time.month == self.recent_month:
return
self.recent_month = self.Time.month
# SMB factor data is still comming in
if self.Securities[self.smb_symbol].GetLastData() and (self.Time.date() - self.Securities[self.smb_symbol].GetLastData().Time.date()).days > self.max_missing_days:
self.Liquidate()
return
# calculate average performance
avg_perf:dict[str, float] = {}
for country in self.countries:
if self.smb_history[country].IsReady:
# BAB factor data is still comming in
if self.Securities[country + '_BAB'].GetLastData() and (self.Time.date() - self.Securities[country + '_BAB'].GetLastData().Time.date()).days > self.max_missing_days:
continue
avg_perf[country] = np.average([x for x in self.smb_history[country]])
if len(avg_perf) < self.quantile:
self.Liquidate()
return
sorted_by_avg_perf:List = [x[0] for x in sorted(avg_perf.items(), key = lambda x: x[1], reverse = True)]
quantile:int = int(len(sorted_by_avg_perf) / self.quantile)
long_countries:List[str] = sorted_by_avg_perf[:quantile]
short_countries:List[str] = sorted_by_avg_perf[-quantile:]
invested:List[str] = [x.Key.Value for x in self.Portfolio if x.Value.Invested]
for symbol in invested:
if symbol not in long_countries + short_countries:
self.Liquidate(self.traded_symbol(symbol))
long_count:int = len(long_countries)
short_count:int = len(short_countries)
for country in long_countries:
if self.traded_symbol(country) in data and data[self.traded_symbol(country)]:
self.SetHoldings(self.traded_symbol(country), 1 / long_count)
for country in short_countries:
if self.traded_symbol(country) in data and data[self.traded_symbol(country)]:
self.SetHoldings(self.traded_symbol(country), -1 / short_count)
def traded_symbol(self, symbol:str) -> str:
return symbol + '_BAB'
# Custom fee model
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters):
fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))
# SMB factor.
# NOTE: IMPORTANT: Data order must be ascending (datewise).
# Data source: https://www.aqr.com/Insights/Datasets/Betting-Against-Beta-Equity-Factors-Daily
class SMB(PythonData):
def GetSource(self, config, date, isLiveMode):
return SubscriptionDataSource("data.quantpedia.com/backtesting_data/equity/smb_factor_percentage.csv", SubscriptionTransportMedium.RemoteFile, FileFormat.Csv)
# File example.
# DATE;AUS;AUT;BEL;CAN;CHE;DEU;DNK;ESP;FIN;FRA;GBR;GRC;HKG;IRL;ISR;ITA;JPN;NLD;NOR;NZL;PRT;SGP;SWE;USA
# 09/30/2020;1.40;1.19;0.72;-0.22;0.84;1.05;0.31;1.26;0.67;0.83;1.12;-0.16;-0.47;0.16;-0.20;1.21;-0.07;0.26;0.23;-0.37;0.68;-0.84;0.43;-0.61
def Reader(self, config, line, date, isLiveMode):
data = SMB()
data.Symbol = config.Symbol
if not line[0].isdigit(): return None
split = line.split(';')
# Prevent look-ahead bias.
data.Time = datetime.strptime(split[0], "%m/%d/%Y") + timedelta(days=1)
data['AUS'] = float(split[1])
data['AUT'] = float(split[2])
data['BEL'] = float(split[3])
data['CAN'] = float(split[4])
data['CHE'] = float(split[5])
data['DEU'] = float(split[6])
data['DNK'] = float(split[7])
data['ESP'] = float(split[8])
data['FIN'] = float(split[9])
data['FRA'] = float(split[10])
data['GBR'] = float(split[11])
data['GRC'] = float(split[12])
data['HKG'] = float(split[13])
data['IRL'] = float(split[14])
data['ISR'] = float(split[15])
data['ITA'] = float(split[16])
data['JPN'] = float(split[17])
data['NLD'] = float(split[18])
data['NOR'] = float(split[19])
data['NZL'] = float(split[20])
data['PRT'] = float(split[21])
data['SGP'] = float(split[22])
data['SWE'] = float(split[23])
data['USA'] = float(split[24])
data.Value = float(split[1])
return data
# BAB factor.
# NOTE: IMPORTANT: Data order must be ascending (datewise).
# Data source: https://www.aqr.com/Insights/Datasets/Betting-Against-Beta-Equity-Factors-Daily
class BAB(PythonData):
def GetSource(self, config, date, isLiveMode):
return SubscriptionDataSource(f"data.quantpedia.com/backtesting_data/equity/{config.Symbol.Value}.csv", SubscriptionTransportMedium.RemoteFile, FileFormat.Csv)
# File example.
# date;AUS
# 09/30/2020;24.05738634
def Reader(self, config, line, date, isLiveMode):
data = BAB()
data.Symbol = config.Symbol
if not line[0].isdigit(): return None
split = line.split(';')
# Prevent look-ahead bias.
data.Time = datetime.strptime(split[0], "%m/%d/%Y") + timedelta(days=1)
data.Value = float(split[1])
return data