“The investment universe consists of all stocks traded on AMEX, NYSE, and NASDAQ. Stocks below the 20th NYSE market capitalization percentile are excluded. The data come from CRPS and Compustat databases. The unemployment rate forecasts come from the Economic Data of Federal Reserve Bank of Philadelphia.”

I. 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.

II. 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.

III. SOURCE PAPER

Forecasted Unemployment and the Cross-Section of Stock Returns [Click to Open PDF]

Baris Ince, University College Dublin

<Abstract>

We introduce forecasted unemployment as a state variable that forecasts future macroeconomic activity. Hence, forecasted unemployment is expected to be priced in the cross-section of stock returns. Consistently, we quantify stock exposure to forecasted unemployment and document the importance of unemployment beta in the pricing of individual stocks. Stocks in the lowest unemployment beta decile generate 7% more annualized risk-adjusted return than stocks in the highest unemployment beta decile. The unemployment premium is driven by the outperformance (underperformance) by stocks with negative (positive) unemployment beta. The premium is robust to controls for firm-specific characteristics, risk factors, macroeconomic and financial variables.

IV. BACKTEST PERFORMANCE

Annualised Return5.54%
Volatility16.1%
Beta0.049
Sharpe Ratio0.34
Sortino RatioN/A
Maximum DrawdownN/A
Win Rate51%

V. 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"))

Leave a Reply

Discover more from Quant Buffet

Subscribe now to keep reading and get access to the full archive.

Continue reading