
Trade mutual funds by climate beta, going long on the lowest-beta quintile and short on the highest, using equally-weighted portfolios rebalanced monthly, excluding funds under $15 million in assets.
ASSET CLASS: funds | REGION: United States | FREQUENCY:
Monthly | MARKET: equities | KEYWORD: Climate, Beta, Mutual Fund
I. STRATEGY IN A NUTSHELL
The strategy sorts mutual funds by their climate beta—sensitivity to climate news innovations from Crimson Hexagon over 24 months—and constructs equally-weighted quintile portfolios. It goes long the lowest climate beta funds and short the highest, rebalancing monthly.
II. ECONOMIC RATIONALE
High climate beta funds contain stocks that benefit from rising climate awareness. These stocks earn higher returns due to better fundamentals and investor demand for climate-hedging exposure. The premium remains significant even after controlling for fund characteristics and style, showing that climate beta captures a unique return driver linked to climate-risk sensitivity.
III. SOURCE PAPER
Climate sensitivity and mutual fund performance [Click to Open PDF]
Thang Ho, University of Bradford School of Management
<Abstract>
In the presence of rising concern about climate change that potentially affects risk and return of investors’ portfolio companies, active investors might have dispersed climate risk exposures. We compute mutual fund covariance with market-wide climate change news index and find that high (positive) climate beta funds outperform low (negative) climate beta funds by 0.24% per month on a risk-adjusted basis. High climate beta funds tilt their holdings toward stocks with high potential to hedge against climate change. In the cross section, such stocks yield higher excess returns, which are driven by greater pricing pressure and superior financial performance over our sample period.


IV. BACKTEST PERFORMANCE
| Annualised Return | 2.55% |
| Volatility | 2.27% |
| Beta | -0.028 |
| Sharpe Ratio | 1.12 |
| Sortino Ratio | N/A |
| Maximum Drawdown | N/A |
| Win Rate | 49% |
V. FULL PYTHON CODE
import statsmodels.api as sm
from AlgorithmImports import *
from data_tools import CustomFeeModel, MutualFund, SymbolData, ClimateChangeData, ClimateChange
from typing import Dict, List
# endregion
class ClimateBetaAndMutualFunds(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2004, 1, 1)
self.SetCash(100000)
self.leverage:int = 5
self.period:int = 24
self.quantile:int = 5
self.max_missing_days:int = 5
self.max_missing_days_climate_change:int = 40
self.min_prices:int = 15
self.long_only_flag:bool = False
self.recent_month:int = -1
self.data:Dict[Symbol, SymbolData] = {}
self.climate_change:Symbol = self.AddData(ClimateChange, 'CLIMATE_CHANGE', Resolution.Daily).Symbol
self.climate_change_data:ClimateChangeData = ClimateChangeData(self.period)
self.symbol_count:int = 500
ticker_file_str:str = self.Download('data.quantpedia.com/backtesting_data/equity/mutual_funds/500_mutual_funds_tickers.csv')
ticker_file_str = ticker_file_str.replace('\r', '')
self.tickers:List[str] = ticker_file_str.split('\n')[:self.symbol_count]
for t in self.tickers:
data = self.AddData(MutualFund, t, Resolution.Daily)
data.SetFeeModel(CustomFeeModel())
data.SetLeverage(self.leverage)
self.data[data.Symbol] = SymbolData(self.period)
def OnData(self, data: Slice) -> None:
curr_date:datetime.date = self.Time.date()
# store data
for symbol, symbol_data in self.data.items():
if symbol in data and data[symbol] and data[symbol].Value != 0:
symbol_data.update_daily_prices(curr_date, data[symbol].Value)
if self.climate_change in data and data[self.climate_change]:
search_value:float = data[self.climate_change].Value
self.climate_change_data.update(curr_date, search_value)
# rebalance
if self.recent_month == self.Time.month:
return
self.recent_month = self.Time.month
# data is still comming in
if not self.climate_change_data.data_still_coming(curr_date, self.max_missing_days_climate_change):
self.climate_change_data.reset()
x:List[float]|None = self.climate_change_data.get_monthly_changes() \
if self.climate_change_data.is_ready() else None
beta_values:Dict[Symbol, float] = {}
# run regression
for symbol, symbol_data in self.data.items():
if not symbol_data.prices_still_coming(curr_date, self.max_missing_days):
symbol_data.reset()
continue
if symbol_data.daily_prices_ready(self.min_prices):
symbol_data.update_monthly_returns()
if x != None and symbol_data.monthly_returns_ready() and symbol in data \
and data[symbol] and data[symbol].Value != 0:
monthly_returns:List[float] = symbol_data.get_monthly_returns()
regression_model = self.MultipleLinearRegression(x, monthly_returns)
beta:float = regression_model.params[1]
beta_values[symbol] = beta
symbol_data.reset_daily_prices()
if len(beta_values) < self.quantile:
self.Liquidate()
return
# sort by beta
quantile:int = int(len(beta_values) / self.quantile)
sorted_by_beta:List[Symbol] = [x[0] for x in sorted(beta_values.items(), key=lambda item: item[1])]
short_leg:List[Symbol] = [] if self.long_only_flag else sorted_by_beta[-quantile:]
long_leg:List[Symbol] = sorted_by_beta[:quantile]
# trade execution
invested:List[Symbol] = [x.Key for x in self.Portfolio if x.Value.Invested]
for symbol in invested:
if symbol not in long_leg+ short_leg:
self.Liquidate(symbol)
for symbol in long_leg:
self.SetHoldings(symbol, 1 / quantile)
for symbol in short_leg:
self.SetHoldings(symbol, -1 / quantile)
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