Quant BuffetRelax, Not Over Thinking

Climate Policy Uncertainty and the Cross-Section of Stock Returns

Log in to collect

Academic paper

Climate policy uncertainty and the cross-section of stock returns

AuthorsKam Fong Chan; Ihtisham Malik

Institute
  • University of Western Australia
  • Research Network (United States)
  • ?Financial Research Network (FIRN)
  • ?The University of Western Australia
  • University of Queensland
  • ?University of Queensland - Business School

Strategy in a nutshell

The strategy forms a zero-investment portfolio based on firms’ exposure to Climate Policy Uncertainty (CPU). Using U.S. stocks from AMEX, NYSE, and NASDAQ (2000–2020), each stock’s CPU beta is estimated via a 60-month rolling regression on the CPU index, market, Fama-French factors, and a liquidity factor. Stocks are then sorted into deciles by CPU beta. The portfolio goes long low-CPU-beta stocks (top decile) and short high-CPU-beta stocks (bottom decile), value-weighted and rebalanced monthly.

Economic rationale

Grounded in the ICAPM framework (Merton, 1973), the strategy exploits investor aversion to climate policy uncertainty. Low-CPU-beta stocks demand higher risk premia and thus yield higher future returns, while high-CPU-beta stocks act as hedges and underperform. The resulting return spread remains significant even after controlling for standard risk factors and firm characteristics.

Backtest performance

Annualised return7.04%
Volatility9.3%
Beta0.065
Sharpe ratio1.1
Win rate51%

Full Python code

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