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

I. 策略概要

投资范围包括纽约证券交易所、纳斯达克和美国证券交易所的股票,不包括小盘股(按市值计算的后20%)。气候变化敞口使用美国国家海洋和大气管理局(NOAA)国家环境信息中心(NCEI)的帕尔默干旱严重指数(PDSI)进行衡量。每月对每只股票进行时间序列回归,因变量为超额回报(股票回报减去无风险利率),解释变量包括常数和PDSI。PDSI系数(beta)的绝对值代表气候变化敞口。股票每月根据此敞口分为五分位,策略做多敞口最低的五分位,做空最高的五分位。价值加权投资组合每月重新平衡。

II. 策略合理性

该策略的有效性源于投资者延迟将气候变化敞口纳入股价。气候变化敞口较高的公司未来ROA(资产回报率)出现统计学和经济学上显著的下降,但投资者关注有限,导致价格调整滞后和回报可预测性。主要变量,即公开可用的帕尔默干旱严重指数,确保了实际适用性。该策略在各种控制下都表现稳健,包括公司和行业特征、替代气候变化变量以及排除小盘股。在与CAPM、Fama-French三因子模型和Carhart四因子模型等资产定价模型进行测试时,它也保持显著性。

III. 来源论文

Climate Change Exposure and Stock Return Predictability [点击查看论文]

<摘要>

本文发现证据表明,股票回报率以可预测的方式随公司实体气候变化敞口而变化。我们表明,投资者未能有效纳入有关公司实体气候变化敞口的显著信息,而敞口较高的公司随后股票回报率较低。基于敞口排名构建的多空交易策略每月产生约0.5%的显著因子调整阿尔法。我们还表明,我们的结果不能由基于对冲气候变化风险、碳风险定价或对ESG风险的担忧的替代解释所驱动。此外,我们记录到,近期公众对气候变化的意识提高降低了可预测性关系的强度。

IV. 回测表现

年化回报5.13%
波动率15.89%
β值-0.079
夏普比率0.32
索提诺比率0.244
最大回撤N/A
胜率49%

V. 完整的 Python 代码

from AlgorithmImports import *
import numpy as np
import statsmodels.api as sm
from typing import List, Dict
from pandas.core.frame import dataframe
from pandas.core.series import Series
#endregion
class ClimateChangeExposureAndTheCrossSectionOfStockReturns(QCAlgorithm):
    def Initialize(self) -> None:
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100_000)
        
        self.exchange_codes: List[str] = ['NYS', 'NAS', 'ASE']    
        self.period: int = 60 # We need 60 months data of pdsi index
        self.month_period: int = 21
        self.quantile: int = 5
        self.leverage: int = 5
        self.min_share_price: int = 5
        self.percentile: float = 0.2
        
        self.data: Dict[Symbol, RollingWindow] = {}
        self.weight: Dict[Symbol, float] = {}
        
        market: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        self.pdsi_symbol: Symbol = self.AddData(QuantpediaPDSI, 'PDSI', Resolution.Daily).Symbol
        self.data[self.pdsi_symbol] = RollingWindow[float](self.period)
        self.fundamental_count: int = 500
        self.fundamental_sorting_key = lambda x: x.DollarVolume
        
        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.
        self.Schedule.On(self.DateRules.MonthStart(market), self.TimeRules.AfterMarketOpen(market), self.Selection)  
    def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
        for security in changes.AddedSecurities:
            security.SetFeeModel(CustomFeeModel())
            
    def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
        # If PDSI data aren't ready, don't update rolling windows or use history
        if not self.data[self.pdsi_symbol].IsReady:
            return Universe.Unchanged
            
        # Update rolling windows each day
        for stock in fundamental:
            symbol: Symbol = stock.Symbol
            
            if symbol in self.data:
                self.data[symbol].Add(stock.AdjustedPrice)
        
        # Rebalance monthly
        if not self.selection_flag:
            return Universe.Unchanged
        
        selected = [
            x for x in fundamental 
            if x.HasFundamentalData 
            and x.Market == 'usa' 
            and x.Price > self.min_share_price 
            and x.MarketCap != 0 
            and x.SecurityReference.ExchangeId in self.exchange_codes
        ]
        if len(selected) > self.fundamental_count:
            selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
        
        # Warmup price rolling windows.
        for stock in selected:
            symbol: Symbol = stock.Symbol
            if symbol in self.data:
                continue
            # Need self.period months of daily returns to calculate self.period months momentums
            self.data[symbol] = RollingWindow[float](self.period * self.month_period)
            history: dataframe = self.History(symbol, self.period * self.month_period, Resolution.Daily)
            if history.empty:
                self.Log(f"Not enough data for {symbol} yet")
                continue
            closes: Series = history.loc[symbol].close
            for time, close in closes.items():
                self.data[symbol].Add(close)
        selected_ready: List[Fundamental] = [x for x in selected if self.data[x.Symbol].IsReady]
        pdsi_last_update_date: Dict[Symbol, datetime.date] = QuantpediaPDSI.get_last_update_date()
        # Check if data is still coming
        if not self.data[self.pdsi_symbol].IsReady or \
            (self.Securities[self.pdsi_symbol].GetLastData() and (self.Time.date() > pdsi_last_update_date[self.pdsi_symbol])):
            return Universe.Unchanged
        
        # Omit small caps
        sorted_by_cap: List[Fundamental] = [x for x in sorted(selected_ready, key=lambda stock: stock.MarketCap)]
        selected_ready = sorted_by_cap[int(len(sorted_by_cap) * 0.2):] # In the bottom 20% percentile are dropped
        
        # Create independent variable in regression
        x_regression: List[float] = [x for x in self.data[self.pdsi_symbol]]
        x_regression: np.ndarray = np.array(x_regression).T
        x_regression = sm.add_constant(x_regression) # Add constant to independent variable
        
        regression_betas: Dict[Fundamental, float] = {}
        
        for stock in selected_ready:
            symbol = stock.Symbol
            
            # Separate monthly prices
            daily_prices: List[float] = [x for x in self.data[symbol]]
            separate_months: List[float] = [daily_prices[x:x+self.month_period] for x in range(0, len(daily_prices),self.month_period)]
            
            # Create monthly returns as dependent variable
            y_regression: List[float] = []
            for month in separate_months:
                monthly_return: float = (month[0] - month[-1]) / month[-1] # Calculate monthly return
                y_regression.append(monthly_return) # Add monthly return to list
                
            # Get and store beta about each stock in universe from regression model
            regression_model: RegressionResultWrapper = sm.OLS(endog=y_regression, exog=x_regression).fit()
            
            # Store beta of this regression
            regression_betas[stock] = regression_model.params[1]
            
        # Check if enough stocks are picked
        if len(regression_betas) < self.quantile:
            return Universe.Unchanged
            
        # Long the lowest quintile and short the highest quintile
        quantile: int = int(len(regression_betas) / self.quantile)
        sorted_by_beta: List[Fundamental] = [x[0] for x in sorted(regression_betas.items(), key=lambda item: item[1])]
        
        # Select long and short
        long: List[Fundamental] = sorted_by_beta[:quantile]
        short: List[Fundamental] = sorted_by_beta[-quantile:]
        
        # Value weight long and short portfolio
        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:
        # Check if there are pdsi data
        if self.pdsi_symbol in data and data[self.pdsi_symbol]:
            # Calculate pdsi index from pdsi values of all regions
            pdsi_values: List[float] = [x for x in data[self.pdsi_symbol].Pdsi_Values]
            pdsi_index: float = np.mean(pdsi_values)
            # Update rolling window for pdsi index
            self.data[self.pdsi_symbol].Add(pdsi_index)
        
        # Rebalance monthly
        if not self.selection_flag:
            return
        self.selection_flag = False
        
        # 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 dictiorany for new portfolio selection
        self.weight.clear()
        
    def Selection(self) -> None:
        self.selection_flag = True
        
# Commitments of Traders data.
# Data description: https://commitmentsoftraders.org/wp-content/uploads/Static/CoTData/file_key.html
class QuantpediaPDSI(PythonData):
    _last_update_date:Dict[Symbol, datetime.date] = {}
    @staticmethod
    def get_last_update_date() -> Dict[Symbol, datetime.date]:
       return QuantpediaPDSI._last_update_date
    def GetSource(self, config: SubscriptionDataConfig, date: datetime, isLiveMode: bool) -> SubscriptionDataSource:
        return SubscriptionDataSource(f"data.quantpedia.com//backtesting_data/index/{config.Symbol.Value}.csv", SubscriptionTransportMedium.RemoteFile, FileFormat.Csv)
    def Reader(self, config: SubscriptionDataConfig, line: str, date: datetime, isLiveMode: bool) -> BaseData:
        data = QuantpediaPDSI()
        data.Symbol = config.Symbol
        
        if not line[0].isdigit(): return None
        split = line.split(';')
        
        # Prevent lookahead bias.
        data.Time = datetime.strptime(split[0], "%d.%m.%Y") + timedelta(days=1)
        data['PDSI_VALUES'] = [float(x) for x in split[1:]]
        data.Value = float(split[1])
        if config.Symbol not in QuantpediaPDSI._last_update_date:
            QuantpediaPDSI._last_update_date[config.Symbol] = datetime(1,1,1).date()
        if data.Time.date() > QuantpediaPDSI._last_update_date[config.Symbol]:
            QuantpediaPDSI._last_update_date[config.Symbol] = 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 的更多信息

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

继续阅读