投资范围包括AMEX、NYSE和NASDAQ的所有股票,排除市值低于NYSE第20个百分位的股票,数据来自CRSP和Compustat。每月通过60个月滚动回归估算失业率beta,做多失业率beta最低的十分位,做空最高的十分位。策略按市值加权,每月重新平衡。

策略概述

投资范围包括在 AMEX、NYSE 和 NASDAQ 交易的所有股票。市值低于 NYSE 第 20 个百分位的股票被排除在外。数据来源于 CRSP 和 Compustat 数据库。失业率预测来自费城联邦储备银行的经济数据。

首先,每个月 t,估算每只股票的失业率 beta。对每只股票进行 60 个月固定窗口滚动回归:R_t = αt + β(FUNEMP)∆FUNEMP_t + β(MKT)MKT_t + ε_t(方程 (1) 第 9 页),其中 R_t 为股票在 t 月的超额收益,∆FUNEMP_t 为未来一季度的失业率预测(由专业预测人员计算)四个季度的平均变动,MKT_t 代表市场超额收益,ε_t 为残差。估计系数 β(FUNEMP) 代表失业率 beta,即股票对失业率预期变化的暴露。

其次,根据前一个月的失业率 beta 将股票分为十分位。最后,做多失业率 beta 最低的十分位,做空失业率 beta 最高的十分位。该策略按市值加权,并每月重新平衡。

策略合理性

该策略的核心在于失业率预测显著预测工业生产增长。它与 GDP 增长和工业生产增长负相关,因此影响投资机会。其次,失业率预测上升表明许多人将失业。根据作者(Ince)的观点,这将降低整体人力资本的预期回报(即预期劳动收入增长),促使投资者持有对冲组合以保护他们的人力资本回报免受创新影响。最后,在经济状况不佳、高隐含波动率和高经济不确定性期间,失业溢价更高。

论文来源

Forecasted Unemployment and the Cross-Section of Stock Returns [点击浏览原文]

<摘要>

我们引入失业率预测作为预测未来宏观经济活动的状态变量。因此,失业率预测被认为会反映在股票收益的横截面上。我们量化了股票对失业率预测的暴露,并记录了失业率 beta 在个股定价中的重要性。失业率 beta 最低的十分位股票的年化风险调整回报比失业率 beta 最高的十分位股票高出 7%。失业溢价是由失业率 beta 为负的股票跑赢(失业率 beta 为正的股票表现不佳)所驱动的。该溢价对公司特定特征、风险因素、宏观经济和金融变量的控制具有稳健性。

回测表现

年化收益率5.54%
波动率16.1%
Beta0.049
夏普比率0.34
索提诺比率N/A
最大回撤N/A
胜率51%

完整python代码

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