该策略投资于NYSE、NASDAQ或AMEX的普通股,基于跨行业离散(CID_t)指标。首先计算49个行业投资组合回报的平均绝对偏差(CID_t),然后通过时间序列回归估算每只股票的β_CID值。根据β_CID值将股票分为五组,买入β_CID最低的股票组合(Q1),卖出β_CID最高的股票组合(Q5)。所有投资组合按价值加权,每月重新平衡。

策略概述

投资范围包括在纽约证券交易所(NYSE)、纳斯达克(NASDAQ)或美国证券交易所(AMEX)交易的普通股。 (宏观经济数据的来源可以从圣路易斯联邦储备银行和劳工统计局(BLS)获取。肯尼斯·弗伦奇的网站可用于获取有关行业投资组合的数据。)

<策略执行>

所需变量的计算: a. 跨行业离散(CID_t)的计算,即在给定月度期间,49个行业投资组合回报的平均绝对偏差(公式(1)), b. 通过时间序列回归(公式(3))估计未知参数β_CID,其中月度股票回报R_it是因变量,独立变量是CID_t(t为当前月份)。

投资者根据β_CID的值对股票进行五分位排序,形成五个投资组合。Q1为β_CID最低的投资组合,Q5为β_CID最高的股票组合。

最终的多空组合通过买入Q1并卖出Q5来形成。

所有投资组合按价值加权(使用上个月的市值作为权重构建),并定期每月再平衡。

策略合理性

本文的新颖之处在于探索CID作为行业变化带来的劳动力收入风险的衡量指标,并探讨其资产定价影响。简单结论是,CID在显著程度上能够预测失业率。CID溢价是由行业特定的人力资本风险驱动的,该风险源于行业间的结构性转变。行业投资组合之间的因子负载异质性、宏观经济和金融不确定性或常见的个体波动性并不能解释这些结果。现有的关于不确定性和劳动力收入风险的已知指标(如波动性和VIX)也未能解释这些结果。可能的解释之一是,这些股票具有低动量和投资,以及高账面市值比,因而容易受到行业变动的影响。

论文来源

Labor Income Risk and the Cross-Section of Expected Returns [点击浏览原文]

<摘要>

本文探讨了行业结构性转变所带来的失业风险对资产定价的影响。我使用跨行业离散(CID)作为这一风险的代理变量,CID定义为49个行业投资组合回报的平均绝对偏差。在加速的行业再分配和高度不确定性时期,CID达到峰值。我发现股票的预期回报与其对CID变化的敏感性呈横截面关系。对CID高度敏感的股票年化回报比对CID低敏感的股票低5.9%。相对于最佳因子模型的异常回报为3.5%,表明常见因子无法解释这一回报差异。对CID高度敏感的股票往往是那些受益于行业变化的股票。CID通过其长期成分正向预测失业率,支持了CID作为行业结构变化导致失业风险的代理的假设。

回测表现

年化收益率6.04%
波动率13.67%
Beta0.031
夏普比率0.44
索提诺比率N/A
最大回撤N/A
胜率49%

完整python代码

from AlgorithmImports import *
from typing import List, Dict
from collections import deque
from pandas.core.frame import DataFrame
import statsmodels.api as sm
from dateutil.relativedelta import relativedelta
# endregion
class CrossIndustryDispersionFactor(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100000)
        self.stock_price_data:Dict[Symbol, deque] = {}
        self.ff_price_data:Dict[Symbol, deque] = {}
        
        self.leverage:int = 3
        self.month_period:int = 2 * 12
        self.period:int = self.month_period * 30
        self.quantile:int = 5  
        self.SetWarmup(self.period, Resolution.Daily)
        
        self.market:Symbol = self.AddEquity("SPY", Resolution.Daily).Symbol
        self.ff_industry:Symbol = self.AddData(QuantpediaFamaFrench, "fama_french_49_industry_VW", Resolution.Daily).Symbol
        self.weight:Dict[Symbol, float] = {}
        self.coarse_count:int = 1000
        self.selection_flag:bool = False
        self.exchanges:List[str] = ['NYS', 'NAS', 'ASE']
        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 CoarseSelectionFunction(self, coarse:List[CoarseFundamental]) -> List[Symbol]:
        # update price every day
        for stock in coarse:
            symbol = stock.Symbol
            if symbol in self.stock_price_data:
                self.stock_price_data[symbol].append((self.Time, stock.AdjustedPrice))
        if not self.selection_flag:
            return Universe.Unchanged
        if self.coarse_count < 3000:
            selected:list = sorted([x for x in coarse if x.HasFundamentalData and x.AdjustedPrice >= 1],
                    key=lambda x: x.DollarVolume, reverse=True)[:self.coarse_count]
        else:
            selected:list = [x for x in coarse if x.HasFundamentalData and x.AdjustedPrice >= 1]
        
        # warmup prices
        for stock in selected:
            symbol:Symbol = stock.Symbol
            if symbol in self.stock_price_data:
                continue
            
            self.stock_price_data[symbol] = deque(maxlen=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.iteritems():
                self.stock_price_data[symbol].append((time, close))
            
        return [x.Symbol for x in selected if len(self.stock_price_data[x.Symbol]) == self.stock_price_data[x.Symbol].maxlen]
    
    def FineSelectionFunction(self, fine:List[FineFundamental]) -> List[Symbol]:
        # filter fine
        fine:Dict[Symbol, FineFundamental] = {x.Symbol : x for x in fine if x.MarketCap != 0 and x.SecurityReference.ExchangeId in self.exchanges }
        if len(fine) >= self.coarse_count:
            fine = {x[0] : x[1] for x in sorted(fine.items(), key = lambda x: x[1].MarketCap, reverse=True)[:self.coarse_count]}
        # create FF df out of deques
        assets:Dict = {symbol : [i[1] for i in deq] for symbol, deq in self.ff_price_data.items()}
        ff_df:DataFrame = pd.DataFrame(assets, index=[i[0] for i in list(self.ff_price_data.values())[0]])
        
        # daily FF returns to daily equity
        ff_df[QuantpediaFamaFrench._columns] = (1 + ff_df[QuantpediaFamaFrench._columns]).cumprod()
        
        # calculate monthly return
        ff_df = ff_df.groupby(pd.Grouper(freq='M')).last()
        ff_df = ff_df.pct_change().iloc[-self.month_period-1:-1]
        CID:DataFrame = abs(ff_df[QuantpediaFamaFrench._columns].sub(ff_df[self.market], axis=0)).mean(axis=1)
        # create stock df out of deques
        assets:Dict = {symbol : [i[1] for i in self.stock_price_data[symbol]] for symbol, _  in fine.items()}
        stock_df:DataFrame = pd.DataFrame(assets, index=[i[0] for i in list(self.stock_price_data.values())[0]])
        
        # calculate monthly stock returns
        stock_df = stock_df.groupby(pd.Grouper(freq='M')).last()
        stock_df = stock_df.pct_change().iloc[-self.month_period-1:-1]
        # run regression
        x:np.ndarray = CID.values
        y:np.ndarray = stock_df.values
        model = self.multiple_linear_regression(x, y)
        beta_values:np.ndarray = model.params[1]
        asset_cols:List[str] = list(stock_df.columns)
        # store beta by symbol
        beta_by_symbol:Dict[Symbol, float] = {}
        for i, beta in enumerate(beta_values):
            beta_by_symbol[fine[asset_cols[i]]] = beta
        if len(beta_by_symbol) >= self.quantile:
            # sort by beta
            sorted_beta:List[Tuple] = sorted(beta_by_symbol, key=beta_by_symbol.get, reverse=True)
            quantile:int = len(sorted_beta) // self.quantile
            long:List[FineFundamental] = sorted_beta[-quantile:]
            short:List[FineFundamental] = sorted_beta[:quantile]
            total_market_cap_long:float = sum([x.MarketCap for x in long])
            total_market_cap_short:float = sum([x.MarketCap for x in short])
            
            for stock in long:
                self.weight[stock.Symbol] = stock.MarketCap / total_market_cap_long
            for stock in short:
                self.weight[stock.Symbol] = -stock.MarketCap / total_market_cap_short
        return list(self.weight.keys())
    def OnSecuritiesChanged(self, changes:SecurityChanges) -> None:
        for security in changes.AddedSecurities:
            security.SetFeeModel(CustomFeeModel())
            security.SetLeverage(self.leverage)
    def OnData(self, data: Slice) -> None:
        # store FF industry data and market data
        if data.ContainsKey(self.ff_industry) and data[self.ff_industry]:
            if data.ContainsKey(self.market) and data[self.market]:
                # init deques
                for symbol in [self.market] + QuantpediaFamaFrench._columns:
                    if symbol not in self.ff_price_data:
                        self.ff_price_data[symbol] = deque(maxlen=self.period)
                # FF daily returns
                for col in QuantpediaFamaFrench._columns:
                    self.ff_price_data[col].append((self.Time, data[self.ff_industry].GetProperty(col) / 100.))
                
                # market daily prices
                self.ff_price_data[self.market].append((self.Time, data[self.market].Value))
        if not self.selection_flag:
            return
        self.selection_flag = False
        # liquidate
        stocks_invested:List[Symbol] = [x.Key for x in self.Portfolio if x.Value.Invested]
        for symbol in stocks_invested:
            if symbol not in self.weight:
                self.Liquidate(symbol)
        # trade execution
        for symbol, w in self.weight.items():
            if symbol in data and data[symbol]:
                self.SetHoldings(symbol, w)
        self.weight.clear()
    def Selection(self) -> None:
        if self.ff_price_data:
            if all(len(self.ff_price_data[col]) == self.period for col in QuantpediaFamaFrench._columns) and \
                len(self.ff_price_data[self.market]) == self.period:
                self.selection_flag = True
    def multiple_linear_regression(self, x:np.ndarray, y:np.ndarray):
        # x = np.array(x).T
        x = sm.add_constant(x)
        result = sm.OLS(endog=y, exog=x).fit()
        return result
        
# Quantpedia data
# NOTE: IMPORTANT: Data order must be ascending (datewise)
class QuantpediaFamaFrench(PythonData):
    def GetSource(self, config, date, isLiveMode):
        return SubscriptionDataSource("data.quantpedia.com/backtesting_data/equity/fama_french/fama_french_49_industry_VW.csv", SubscriptionTransportMedium.RemoteFile, FileFormat.Csv)
    
    _last_update_date:datetime.date = datetime(1,1,1).date()
    _columns:List[str] = list()
    @staticmethod
    def get_last_update_date() -> Dict[Symbol, datetime.date]:
       return QuantpediaFamaFrench._last_update_date
    def Reader(self, config, line, date, isLiveMode):
        data = QuantpediaFamaFrench()
        data.Symbol = config.Symbol
        
        if not line[0].isdigit():
            QuantpediaFamaFrench._columns = line.split(',')[1:] # skip 'Date' columns
            return None
        
        split = line.split(',')
        
        data.Time = datetime.strptime(split[0], "%Y-%m-%d") + relativedelta(months=1)
        if data.Time.date() > QuantpediaFamaFrench._last_update_date:
            QuantpediaFamaFrench._last_update_date = data.Time.date()
        for i, col_name in enumerate(QuantpediaFamaFrench._columns):
            data[col_name] = float(split[i+1]) 
        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