“通过PEAR贝塔交易纳斯达克、纽约证券交易所和美国证券交易所的股票,做多贝塔最低的十分位,做空最高的十分位,使用价值加权、每月重新平衡的投资组合。”

I. 策略概要

投资范围包括纳斯达克(NASDAQ)、纽约证券交易所(NYSE)和美国证券交易所(AMEX)上市股票,但不包括金融和公用事业行业的股票,以及股价低于1美元的股票。总统经济支持率(PEAR)指数按月计算,方法是取Roper iPoll数据库中以经济为主题的全国性民调的支持率的简单平均值,剔除发布较晚的调查数据。

对于每只股票,使用滚动的60个月回归模型来估算其对PEAR指数的贝塔值(PEAR beta),其中回归自变量是当月和前一个月PEAR指数的变动,回归系数之和即为该股票的PEAR beta。每月底,按PEAR beta将股票分为十个分位组。该策略对PEAR beta最低的十分位组股票做多,对最高的做空,构建按市值加权的投资组合,并每月进行再平衡。

II. 策略合理性

研究人员将PEAR贝塔异常归因于与现任总统政策相关的情绪驱动的错误定价。例如,像石油公司这样的股票在支持清洁能源的总统任期内可能被低估。当一位具有不同优先事项的新总统上任时,这种错误定价得到纠正,为低PEAR贝塔股票创造了溢价。低-高PEAR贝塔价差的异常回报主要由多头部分驱动,表明低PEAR贝塔股票被低估,而高PEAR贝塔股票定价合理。这些回报在控制各种风险因素后仍然显著,在子样本期间表现稳健,并且在大型和流动性强的股票中更为明显。

III. 来源论文

Another Presidential Puzzle? Presidential Economic Approval Rating and the Cross-Section of Stock Returns [点击查看论文]

<摘要>

我们构建了1981年至2019年期间的月度总统经济支持率(PEAR)指数,通过平均各项全国民意调查中对总统经济处理的支持率。在横截面上,对PEAR指数变化具有高贝塔值的股票在未来风险调整后每月显著跑输低贝塔值的股票1.00%。低PEAR贝塔溢价持续长达一年,并且存在于各种子样本中,甚至在其他G7国家也存在。PEAR贝塔动态地揭示了公司对现任总统经济政策的感知一致性,而投资者似乎错误地评估了这种一致性。

IV. 回测表现

年化回报14.16%
波动率19.33%
β值1.64
夏普比率0.73
索提诺比率-0.318
最大回撤N/A
胜率49%

V. 完整的 Python 代码

from AlgorithmImports import *
import numpy as np
import statsmodels.api as sm
#endregion
class PresidentialEconomicApprovalRatingAndTheCrossSectionOfStockReturns(QCAlgorithm):
    def Initialize(self) -> None:
        self.SetStartDate(2001, 1, 1) # First presidential economic approval ratings are in 2001
        self.SetCash(100_000)
        self.exchange_codes: List[str] = ['NYS', 'NAS', 'ASE']    
        self.period: int = 60 # Need n values for regression
        self.wait_days: int = 3 # When data comes wait n + 1 days before trade
        self.quantile: int = 10
        self.leverage: int = 10
        self.min_share_price: int = 1
        self.max_missing_days = 6 * 31
        
        self.data: Dict[Symbol, RollingWindow] = {} # Storing stock prices for regression
        self.weight: Dict[Symbol, float] = {} # Storing weights of selected stocks
        
        self.economic_approval_ratings: Symbol = self.AddData(QuantpediaEconomicApprovalRatings, 'ECONOMIC_APPROVAL_RATINGS', Resolution.Daily).Symbol
        
        # Need n + 2 data in RollingWindow, beacause after CreateRegressionX both variables will have n length
        self.economic_approval_ratings_values: RollingWindow = RollingWindow[float](self.period + 2)
        
        self.fundamental_count: int = 3_000
        self.fundamental_sorting_key = lambda x: x.MarketCap
        self.count_days: int = 0
        self.trade_flag: bool = False
        self.selection_flag: bool = False
        self.UniverseSettings.Leverage = self.leverage
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.FundamentalSelectionFunction)
        self.settings.daily_precise_end_time = False
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
    def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
        for security in changes.AddedSecurities:
            security.SetFeeModel(CustomFeeModel())
    
    def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
        # Select stock on monthly basis
        if not self.selection_flag:
            return Universe.Unchanged
            
        # Store prices only on selection
        for stock in fundamental:
            symbol = stock.Symbol
            
            if symbol in self.data:
                self.data[symbol].Add(stock.AdjustedPrice)
        
        selected: List[Fundamental] = [
            x for x in fundamental 
            if x.HasFundamentalData 
            and x.MarketCap != 0
            and x.Market == 'usa' 
            and x.Price > self.min_share_price 
            and x.SecurityReference.ExchangeId in self.exchange_codes
            and x.AssetClassification.MorningstarSectorCode != MorningstarSectorCode.Utilities
            and x.AssetClassification.MorningstarSectorCode != MorningstarSectorCode.FinancialServices
        ]
        if len(selected) > self.fundamental_count:
            selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
        
        # Store last price of currently selected stocks
        for stock in selected:
            symbol: Symbol = stock.Symbol
            
            if symbol in self.data:
                continue
            
            # Need n + 1 values, because after performance calculation there will be n results
            self.data[symbol] = RollingWindow[float](self.period + 1)
            
            # Get last day prices
            history: dataframe = self.History(symbol, 1, Resolution.Daily)
            
            if history.empty:
                self.Log(f"Not enough data for {symbol} yet")
                continue
            
            closes: Series = history.loc[symbol].close
            for _, close in closes.items():
                # Store yesterday's close for current stock
                self.data[symbol].Add(close)
        # Change selection_flag to prevent next day selection
        self.selection_flag = False
        
        # Give signal for trading 
        # NOTE: There will be only liquidation, if stocks weren't selected
        self.trade_flag = True
        
        # Create regression x only if economic approval ratings values have enough data
        if not self.economic_approval_ratings_values.IsReady:
            return Universe.Unchanged
            
        # Create regression x based on economic approval ratings values
        regression_x: List[List[float]] = self.CreateRegressionX(self.economic_approval_ratings_values)
        
        # Storing value of stock's beta for each stock, which has ready data
        stocks_beta: Dict[Symbol, float] = {}
        market_cap = {}
        
        for stock in selected:
            symbol: Symbol = stock.Symbol
            
            # Continue only if stock's prices are ready for regression
            if not self.data[symbol].IsReady:
                continue
            
            # Update market capitalization for stock
            market_cap[symbol] = stock.MarketCap
            
            # Create regression y based on stock's close prices
            regression_y: List[float] = self.StockPerformances(self.data[symbol])
            
            # Calculate regression
            regression_model = self.MultipleLinearRegression(regression_x, regression_y)
            
            # Calculate stocks beta
            stocks_beta[stock] = sum(regression_model.params)
        
        # Make sure we have enough stocks for decile selection
        if len(stocks_beta) < self.quantile:
            return
        
        # Decile selection based on stocks_beta values
        quantile: int = int(len(stocks_beta) / self.quantile)
        sorted_by_stocks_beta: List[fundamental] = [x[0] for x in sorted(stocks_beta.items(), key=lambda item: item[1])]
        
        # Go long (short) on the low (high) decile stocks with the lowest (highest) beta to PEAR index
        long: List[fundamental] = sorted_by_stocks_beta[:quantile]
        short: List[fundamental] = sorted_by_stocks_beta[-quantile:]
        
        # Create weights for stocks
        for i, portfolio in enumerate([long, short]):
            mc_sum: float = sum(list(map(lambda stock: stock.MarketCap, portfolio)))
            for stock in portfolio:
                self.weight[stock.Symbol] = ((-1)**i) * stock.MarketCap / mc_sum
        return list(self.weight.keys())
    def OnData(self, data: Slice) -> None:
        ear_last_date_update: datetime.date = QuantpediaEconomicApprovalRatings.get_last_update_date()
        if self.economic_approval_ratings in data and data[self.economic_approval_ratings]:
            # Firstly add new value of economic approval rating
            value: float = data[self.economic_approval_ratings].Value
            self.economic_approval_ratings_values.Add(value)
            
            # Start selection, because data comes
            self.selection_flag = True
        else:
            if self.Securities[self.economic_approval_ratings].GetLastData() and self.Time.date() > ear_last_date_update:
                self.Liquidate()
        
        # Trade after data comes        
        if self.trade_flag:
            # Wait n days before trade
            if self.count_days != self.wait_days:
                self.count_days += 1
                
            else:
                # Trade execution
                portfolio: List[PortfolioTarget] = [PortfolioTarget(symbol, w) for symbol, w in self.weight.items() if symbol in data and data[symbol]]
                self.SetHoldings(portfolio, True)
                # Clear old rebalance
                self.weight.clear()
                
                # Hold up trades
                self.trade_flag = False
                self.count_days = 0
    
    def CreateRegressionX(self, rolling_window: RollingWindow) -> List[List[np.ndarray]]:
        values: np.ndarray = np.array([x for x in rolling_window])
        performances: np.ndarray = (values[:-1] - values[1:]) / values[1:]
        # For first variable in regression x get all performances except the last one
        first_variable: np.ndarray = performances[:-1]
        # For second variable in regression x get all performances except the first one
        second_variable: np.ndarray = performances[1:]
        # Return regression x
        return [first_variable, second_variable]
    def StockPerformances(self, rolling_window: RollingWindow) -> np.ndarray:
        closes: np.ndarray = np.array([x for x in rolling_window])
        return (closes[:-1] - closes[1:]) / closes[1:]
    def MultipleLinearRegression(self, x: np.ndarray, y: np.ndarray):
        x: np.ndarray = np.array(x).T
        # NOTE: Need to change regression_model.params to regression_model.params[1:] after adding constant
        # x = sm.add_constant(x)
        result: ReggresionResultWrapper = sm.OLS(endog=y, exog=x).fit()
        return result
    
# Quantpedia data.
# NOTE: IMPORTANT: Data order must be ascending (datewise)
class QuantpediaEconomicApprovalRatings(PythonData):
    _last_update_date: datetime.date = datetime(1,1,1).date()
    @staticmethod
    def get_last_update_date() -> Dict[Symbol, datetime.date]:
       return QuantpediaEconomicApprovalRatings._last_update_date
    def GetSource(self, config: SubscriptionDataConfig, date: datetime, isLiveMode: bool) -> SubscriptionDataSource:
        return SubscriptionDataSource(
            "data.quantpedia.com/backtesting_data/economic/{0}.csv".format(config.Symbol.Value),
            SubscriptionTransportMedium.RemoteFile,
            FileFormat.Csv
        )
    # Header of csv file: date;approved;disapproved;no_opinion
    def Reader(self, config: SubscriptionDataConfig, line: str, date: datetime, isLiveMode: bool) -> BaseData:
        data = QuantpediaEconomicApprovalRatings()
        data.Symbol = config.Symbol
        
        if not line[0].isdigit(): return None
        split = line.split(';')
        
        data.Time = datetime.strptime(split[0], "%d.%m.%Y") + timedelta(days=1)
        data['approved'] = float(split[1])
        data.Value = float(split[1])
        if data.Time.date() > QuantpediaEconomicApprovalRatings._last_update_date:
            QuantpediaEconomicApprovalRatings._last_update_date = data.Time.date()
        return data
    
# Custom fee model.
class CustomFeeModel(FeeModel):
    def GetOrderFee(self, parameters: OrderFeeParameters) -> OrderFee:
        fee: float = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
        return OrderFee(CashAmount(fee, "USD"))

发表评论

了解 Quant Buffet 的更多信息

立即订阅以继续阅读并访问完整档案。

继续阅读