Quant BuffetRelax, Not Over Thinking

Forecasted Unemployment Beta Predicts the Cross-Section of Stock Returns

Log in to collect

Academic paper

Forecasted Unemployment and the Cross-Section of Stock Returns

AuthorsBarıs Ince

Institute
  • IEUniversity College Dublin
  • Goethe Institute
  • DEGoethe University Frankfurt
  • ?Goethe University Frankfurt - House of Finance

Strategy in a nutshell

The strategy invests in US stocks (excluding the smallest 20% by market cap), estimating each stock’s sensitivity to forecasted unemployment changes (unemployment beta) via a 60-month rolling regression. Each month, the portfolio goes long the lowest unemployment-beta decile and short the highest, value-weighted and rebalanced monthly.

Economic rationale

Stocks with high sensitivity to forecasted unemployment underperform during economic slowdowns, as rising unemployment predicts weaker GDP and industrial production. The strategy exploits this unemployment risk premium to enhance returns and hedge against labor-income-related risks.

Backtest performance

Annualised return5.54%
Volatility16.1%
Beta0.049
Sharpe ratio0.34
Win rate51%

Full Python code

from AlgorithmImports import *
from pandas.core.frame import DataFrame
from typing import List, Dict
import statsmodels.api as sm
from dateutil.relativedelta import relativedelta
# endregion

class ForecastedUnemploymentBetaPredictstheCrossSectionofStockReturns(QCAlgorithm):

def Initialize(self):
 self.SetStartDate(2005, 1, 1) 
 self.SetCash(100000)
 
 self.market:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
 self.ue:Symbol = self.AddData(UnemploymentRate, 'UE', Resolution.Daily).Symbol

 self.period:int = 60
 self.ue_period:int = 72
 self.ue_len_threshold:int = 24
 self.unemployment_beta:Dict[Symbol, float] = {}

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

 self.leverage:int = 10
 self.coarse_count:int = 500
 self.selection_flag:bool = False
 self.rebalance_flag:bool = False
 self.UniverseSettings.Resolution = Resolution.Daily
 self.AddUniverse(self.CoarseSelectionFunction, self.FineSelectionFunction)
 self.Schedule.On(self.DateRules.MonthStart(self.market), self.TimeRules.AfterMarketOpen(self.market), self.Selection)

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

def CoarseSelectionFunction(self, coarse:List[CoarseFundamental]) -> List[Symbol]:
 # monthly selection
 if not self.selection_flag:
     return Universe.Unchanged
 self.selection_flag = False

 selected:List[Symbol] = [x.Symbol
     for x in sorted([x for x in coarse if x.HasFundamentalData and x.Market == 'usa' and x.AdjustedPrice > 5], 
     key = lambda x: x.DollarVolume, reverse = True)][:self.coarse_count]
 # selected:List[Symbol] = [x.Symbol for x in coarse if x.HasFundamentalData and x.Market == 'usa' and x.AdjustedPrice > 5]

 return selected

def FineSelectionFunction(self, fine:List[FineFundamental]) -> List[Symbol]:
 fine:Dict[Symbol, FineFundamental] = {x.Symbol : x for x in fine if x.MarketCap != 0 and \
                     (x.SecurityReference.ExchangeId == 'NYS') or (x.SecurityReference.ExchangeId == 'NAS') or (x.SecurityReference.ExchangeId == 'ASE')}

 # fine:List[FineFundamental] = [x for x in fine if x.MarketCap != 0 and \
 #                     (x.SecurityReference.ExchangeId == 'NYS') or (x.SecurityReference.ExchangeId == 'NAS') or (x.SecurityReference.ExchangeId == 'ASE')]
 # if len(fine) > self.coarse_count:
 #     fine:Dict[Symbol, FineFundamental] = {x.Symbol : x for x in sorted(fine, key=lambda x:x.MarketCap, reverse=True)[:self.coarse_count]}
 # else:
 #     fine = {x.Symbol : x for x in fine}

 # call history on assets and unemployment rates
 history:DataFrame = self.History(list(fine.keys()) + [self.market], start=self.Time.date() - relativedelta(months=self.period), end=self.Time.date())['close'].unstack(level=0)
 history = history.groupby(pd.Grouper(freq='M')).last()
 history = history.iloc[:-1]

 ue_last_update_date:Dict[Symbol, datetime.date] = UnemploymentRate.get_last_update_date()
 
 # check if uemployment data are still arriving
 if self.Securities[self.ue].GetLastData() and self.ue in ue_last_update_date and self.Time.date() <= ue_last_update_date[self.ue]:

     # call history on unemployment rates
     history_ue:DataFrame = self.History([self.ue], start=self.Time.date() - relativedelta(months=self.ue_period), end=self.Time.date())
     beta_by_symbol:Dict[FineFundamental, float] = {}
 
     if len(history) >= self.period and len(history_ue) >= self.ue_len_threshold:
         history = history.iloc[-self.period:]
         asset_returns:DataFrame = history.pct_change().iloc[1:]
         history_ue = history_ue.reset_index()

         history_ue.set_index('time', inplace=True)
         
         rolling_mean_ue:DataFrame = history_ue.rolling(window=4).mean()
         rolling_mean_ue = rolling_mean_ue.resample('M').last().ffill()
         x_df:DataFrame = pd.concat((asset_returns[self.market], rolling_mean_ue['value']), axis=1).ffill().dropna()
         stock_returns:DataFrame = asset_returns.loc[:, asset_returns.columns != self.market]
         stock_returns = stock_returns.loc[stock_returns.index.isin(x_df.index)]

         # run regression
         x:np.ndarray = x_df.values
         y:np.ndarray = stock_returns.values
         model = self.multiple_linear_regression(x, y)
         beta_values:np.ndarray = model.params[2]

         for i, asset in enumerate(list(stock_returns.columns)):
             asset_s:Symbol = self.Symbol(asset)

             if asset_s not in beta_by_symbol:
                 if beta_values[i] != 0 and beta_values[i] is not None:
                     beta_by_symbol[fine[asset_s]] = beta_values[i]

     # sort by beta and divide to upper decile and lower decile
     if len(beta_by_symbol) >= self.quantile:
         sorted_by_beta:List[FineFundamental] = sorted(beta_by_symbol, key=beta_by_symbol.get)
         quantile:int = int(len(sorted_by_beta) / self.quantile)
         long:List[FineFundamental] = sorted_by_beta[:quantile]
         short:List[FineFundamental] = sorted_by_beta[-quantile:]

         # calculate weights based on values
         sum_long:float = sum([x.MarketCap for x in long])
         for stock in long:
             self.weight[stock.Symbol] = stock.MarketCap / sum_long

         sum_short:float = sum([x.MarketCap for x in short])
         for stock in short:
             self.weight[stock.Symbol] = -stock.MarketCap / sum_short

 return list(self.weight.keys())

def OnData(self, data: Slice):
 # monthly rebalance
 if not self.rebalance_flag:
     return
 self.rebalance_flag = False

 invested:List[Symbol] = [x.Key for x in self.Portfolio if x.Value.Invested]
 for price_symbol in invested:
     if price_symbol not in self.weight:
         self.Liquidate(price_symbol)
 
 for price_symbol, weight in self.weight.items():
     if price_symbol in data and data[price_symbol]:
         self.SetHoldings(price_symbol, weight)

 self.weight.clear()

def Selection(self):
 self.selection_flag = True
 self.rebalance_flag = True

def multiple_linear_regression(self, x:np.ndarray, y:np.ndarray):
 # x:np.ndarray = np.array(x).T
 x = sm.add_constant(x, prepend=True)
 result = sm.OLS(endog=y, exog=x).fit()
 return result

# Source: https://www.philadelphiafed.org/surveys-and-data/real-time-data-research/survey-of-professional-forecasters
class UnemploymentRate(PythonData):
def GetSource(self, config, date, isLiveMode):
 return SubscriptionDataSource('data.quantpedia.com/backtesting_data/economic/next_q_unemployment_forecast.csv', SubscriptionTransportMedium.RemoteFile, FileFormat.Csv)

_last_update_date:Dict[Symbol, datetime.date] = {}

@staticmethod
def get_last_update_date() -> Dict[Symbol, datetime.date]:
return UnemploymentRate._last_update_date

def Reader(self, config, line, date, isLiveMode):
 data = UnemploymentRate()
 data.Symbol = config.Symbol

 if not line[0].isdigit(): return None
 split = line.split(';')
 
 # Parse the CSV file's columns into the custom data class
 data.Time = datetime.strptime(split[0], "%Y-%m-%d") + timedelta(days=1)
 data.Value = float(split[1])

 if config.Symbol not in UnemploymentRate._last_update_date:
     UnemploymentRate._last_update_date[config.Symbol] = datetime(1,1,1).date()
 if data.Time.date() > UnemploymentRate._last_update_date[config.Symbol]:
     UnemploymentRate._last_update_date[config.Symbol] = data.Time.date()
 
 return data

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