该策略涵盖CRSP数据库中AMEX、NYSE和NASDAQ的股票,排除价格低于2美元或高于1000美元、以及市值低于500万美元的股票。基于气候政策不确定性(CPU)指数进行60个月滚动回归分析,计算每只股票的CPU贝塔值。股票按CPU贝塔值排序后,构建零投资组合,做多低CPU贝塔股票(前10%),做空高CPU贝塔股票(后10%)。组合按市值加权,并每月调整。

策略概述

投资范围包括从CRSP数据库中提取的所有AMEX、NYSE和NASDAQ交易所的股票,股票代码为10和11。价格低于每股2美元或高于1000美元以及市值低于500万美元的股票会被剔除。此外,月回报率超过100%的股票也被排除。气候政策不确定性(CPU)指数来自www.policyuncertainty.com网站,使用Gavriilidis (2021) 提出的基于新闻的每月CPU指数。样本期为2000年1月至2020年12月。财务数据如账面价值来自CRSP-Compustat合并数据库,Fama-French因子回报率则来自Kenneth French的在线数据资料库。 简单的交易策略如下构建。为了获得每只股票(i)的CPU贝塔值,进行60个月的滚动回归分析。每只股票(i)的月度超额回报率以CPU指数、超额市场回报率、三因子回报率(SMB、HML和UMD)以及流动性因子为自变量进行回归分析(均为月度数据,见第8页,公式1)。随后,根据估算的CPU贝塔值对股票进行排序,并将其划分为十等分。低CPU贝塔股票位于前10%,高CPU贝塔股票位于后10%。构建零投资组合,通过做多前10%(低CPU贝塔股票)和做空后10%(高CPU贝塔股票)来实现。所有投资组合按市值加权,并每月重新调整。

策略合理性

该策略的基本逻辑基于ICAPM模型框架(Merton, 1973)。在Merton的世界中,厌恶不确定性的投资者要求持有低CPU贝塔股票的溢价,从而使这些股票在未来具有更高的股票回报。同时,这些投资者愿意支付更高的价格购买被视为对冲资产的高CPU贝塔股票,导致这些股票的未来回报率较低。此外,即使在控制公司特征(如规模、市账比、价格崩盘风险和市场波动)后,逆CPU溢价也无法得到解释。该策略的异常回报具有统计显著性,且不能被任何Fama-French因子模型所涵盖。

论文来源

Climate Policy Uncertainty and the Cross-Section of Stock Returns [点击浏览原文]

<摘要>

我们研究了气候政策不确定性(CPU)是否会在个别股票中被横截面定价,并发现存在显著的负相关关系。平均而言,低CPU暴露股票的风险调整后年化未来回报比高CPU暴露股票高出6.5%至7.7%。低CPU贝塔公司是价值型、绿色股票,风险低,并且倾向于民主党;而高CPU贝塔公司是成长型、棕色股票,崩盘风险高,且倾向于共和党。与Merton(1973)的跨期资本资产定价模型一致,我们的研究表明,投资者为对冲气候政策不确定性,愿意支付更高的价格并接受较低的未来回报。

回测表现

年化收益率7.04%
波动率9.3%
Beta0.065
夏普比率1.1
索提诺比率N/A
最大回撤N/A
胜率51%

完整python代码

from AlgorithmImports import *
import statsmodels.api as sm
import data_tools
# endregion
class ClimatePolicyUncertaintyAndTheCrossSectionOfStockReturns(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100000)
        
        self.leverage:int = 5
        self.period:int = 21
        self.regression_period:int = 60
        self.max_missing_months:int = 2
        self.quantile:int = 10  
        self.weights:dict[Symbol, float] = {}    
        self.prices:dict[Symbol, RollingWindow] = {}
        self.exchanges:list[str] = ['NYS', 'NAS', 'ASE']
        self.cpu_index:Symbol = self.AddData(data_tools.CPUIndex, 'CPU_INDEX', Resolution.Daily).Symbol        
        self.fama_french:Symbol = self.AddData(data_tools.QuantpediaFamaFrench, 'fama_french_3_factor_monthly', Resolution.Daily).Symbol
        
        self.market_symbol:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        self.prev_market_price:float = None
        self.regression_data:RegressionData = data_tools.RegressionData(self.regression_period)        
        self.coarse_count:int = 1000
        self.recent_month:int = -1
        self.selection_flag:bool = False
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.CoarseSelectionFunction, self.FineSelectionFunction)
    
    def OnSecuritiesChanged(self, changes):
        for security in changes.AddedSecurities:
            security.SetFeeModel(data_tools.CustomFeeModel())
            security.SetLeverage(self.leverage)
    def CoarseSelectionFunction(self, coarse):
        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.Price >= 2 and x.Price <= 1000],
                    key=lambda x: x.DollarVolume, reverse=True)[:self.coarse_count]
        else:
            selected:list = [x for x in coarse if x.HasFundamentalData and x.Price >= 2 and x.Price <= 1000]
        selected_symbols:list[Symbol] = []
        for stock in selected:
            symbol:Symbol = stock.Symbol
            if symbol not in self.prices:
                # init rolling window for newly selected stock
                self.prices[symbol] = RollingWindow[float](self.regression_period + 1)
            selected_symbols.append(symbol)
        for equity in coarse:
            symbol:Symbol = equity.Symbol
            if symbol in self.prices:
                # update monthly prices
                self.prices[symbol].Add(equity.AdjustedPrice)
        return [x for x in selected_symbols if self.prices[x].IsReady]
    def FineSelectionFunction(self, fine):
        # make sure regression data are ready
        if not self.regression_data.is_ready():
            return Universe.Unchanged
        # make sure regression data are up to date, otherwise reset them
        elif not self.regression_data.data_still_coming(self.max_missing_months, self.Time.date()):
            self.regression_data.reset()
            return Universe.Unchanged
        # filter fine
        fine:list = [x for x in fine if x.MarketCap != 0 and x.MarketCap >= 5000000 and x.SecurityReference.ExchangeId in self.exchanges]
        if len(fine) > self.coarse_count:
            sorted_by_market_cap:list = sorted(fine, key = lambda x: x.MarketCap, reverse=True)
            fine:list = sorted_by_market_cap[:self.coarse_count]
        cpu_beta:dict = {}
        regression_x:list = self.regression_data.regression_x()
        for stock in fine:
            symbol:Symbol = stock.Symbol
            prices:np.array = np.array([x for x in self.prices[symbol]])
            regression_y:np.array = (prices[:-1] / prices[1:]) - 1
            regression_model = self.MultipleLinearRegression(regression_x, regression_y)
            cpu_beta_value:float = regression_model.params[1]
            cpu_beta[stock] = cpu_beta_value
        
        # make sure there are enough stocks for selection
        if len(cpu_beta) < self.quantile:
            return Universe.Unchanged
        quantile:int = int(len(cpu_beta) / self.quantile)
        # low CPU beta stocks are in the top decile whereas high CPU beta stocks are in the bottom decile
        sorted_by_beta:list = [x[0] for x in sorted(cpu_beta.items(), key=lambda item: item[1], reverse=True)]
        # the zero-investment portfolio is constructed by long the top decile and short the bottom decile
        long_part:list = sorted_by_beta[-quantile:]
        short_part:list = sorted_by_beta[:quantile]
        total_long_cap:float = sum([stock.MarketCap for stock in long_part])
        for stock in long_part:
            self.weights[stock.Symbol] = stock.MarketCap / total_long_cap
        total_short_cap:float = sum([stock.MarketCap for stock in short_part])
        for stock in short_part:
            self.weights[stock.Symbol] = -stock.MarketCap / total_short_cap
        return list(self.weights.keys())
        
    def OnData(self, data):
        rebalance_flag = False
        # update regression data when fama french and cpu data come and market prices are ready
        if self.fama_french in data and data[self.fama_french] and self.cpu_index in data and data[self.cpu_index]:
            if self.recent_month != self.Time.month:
                rebalance_flag = True
                self.recent_month = self.Time.month
            if self.Securities.ContainsKey(self.market_symbol) and self.Securities[self.market_symbol].Price != 0:
                # get latest market price - market may not be opened at the CPU and FF arrival date
                # market_price:float = data[self.market_symbol].Value
                market_price:float = self.Securities[self.market_symbol].Price
                if self.prev_market_price != None:
                    market_return:float = (market_price - self.prev_market_price) / self.prev_market_price
                    self.regression_data.update(
                        data[self.cpu_index].Value,
                        market_return,
                        data[self.fama_french].Market,
                        data[self.fama_french].Size,
                        data[self.fama_french].Value,
                        self.Time.date()
                    )
                
                self.prev_market_price = market_price            
        
        # rebalance monthly
        if rebalance_flag:
            self.selection_flag = True
            return
        if not self.selection_flag:
            return
        self.selection_flag = False
        
        # trade execution
        invested = [x.Key for x in self.Portfolio if x.Value.Invested]
        for symbol in invested:
            if symbol not in self.weights:
                self.Liquidate(symbol)
        for symbol, w in self.weights.items():
            if symbol in data and data[symbol]:
                self.SetHoldings(symbol, w)
                
        self.weights.clear()
    def MultipleLinearRegression(self, x:list, y:list):
        x:np.array = np.array(x).T
        x = sm.add_constant(x)
        result = sm.OLS(endog=y, exog=x).fit()
        return result

Leave a Reply

Discover more from Quant Buffet

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

Continue reading