Hedging Momentum Crashes
Log in to collectAcademic paper
Constant Leverage Covering Strategy for Equity Momentum Portfolio with Transaction Costs
Mario Enrique Negrete
- Washington and Lee University
Strategy in a nutshell
U.S. momentum strategy with volatility-timing (CLvg): hold momentum portfolio in low-volatility months, switch to 1-month T-bills in high-volatility months. Form decile portfolios by past 12-month returns, skip one month, monthly rebalanced, value-weighted.
Economic rationale
Exploits the momentum anomaly while managing crash risk. Past returns and persistent loser volatility predict market turbulence, enabling risk-adjusted outperformance versus EMH expectations.
Backtest performance
Annualised return16.93%
Volatility20.33%
Beta0.118
Sharpe ratio0.83
Sortino ratio0.002
Win rate71%
Full Python code
from AlgorithmImports import *
import data_tools
from typing import List, Dict
from pandas.core.frame import DataFrame
# endregion
class HedgingMomentumCrashes(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2000, 1, 1)
self.SetCash(100000)
self.treasury:Symbol = self.AddEquity('BIL', Resolution.Daily).Symbol
self.weight:Dict[Symbol, float] = {}
self.data:Dict[Symbol, SymbolData] = {}
self.volatilities:List[float] = []
self.short:List[Fundamental] = []
self.volatility_period:int = 126
self.min_period:int = 12
self.annual_period:int = self.min_period * 21
self.quantile:int = 10
self.leverage:int = 5
self.fundamental_count:int = 500
self.fundamental_sorting_key = lambda x:x.DollarVolume
self.selection_flag:bool = False
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.Schedule.On(self.DateRules.MonthStart(self.treasury), self.TimeRules.AfterMarketOpen(self.treasury), self.Selection)
def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
for security in changes.AddedSecurities:
security.SetFeeModel(data_tools.CustomFeeModel())
security.SetLeverage(self.leverage)
def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
# store daily stock prices
for stock in fundamental:
symbol:Symbol = stock.Symbol
if symbol in self.data:
self.data[symbol].update_daily_return(stock.AdjustedPrice)
if self.selection_flag:
self.data[symbol].update_monthly_data(stock.AdjustedPrice)
if self.selection_flag:
short_count:int = len(self.short)
if short_count != 0:
short_portfolio_returns:np.ndarray = [self.data[stock.Symbol].get_daily_returns(self.volatility_period) for stock in self.short]
weights:np.ndarray = np.ones(short_count) / short_count
self.volatilities.append(self.annual_port_vol(weights, short_portfolio_returns))
# monthly selection
if not self.selection_flag:
return Universe.Unchanged
selected:List[Fundamental] = [x for x in fundamental if x.HasFundamentalData and x.MarketCap != 0 and x.Market == 'usa' and \
((x.SecurityReference.ExchangeId == "NYS") or (x.SecurityReference.ExchangeId == "NAS") or (x.SecurityReference.ExchangeId == "ASE"))]
if len(selected) > self.fundamental_count:
selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
perf:Dict[Symbol, float] = {}
# price warmup
for stock in selected:
symbol:Symbol = stock.Symbol
if symbol not in self.data:
self.data[symbol] = data_tools.SymbolData(self.annual_period, self.min_period)
history:DataFrame = self.History(symbol, self.annual_period, Resolution.Daily)
if history.empty:
self.Log(f"Not enough data for {symbol} yet.")
continue
data:pd.DataFrame = history.loc[symbol]
monthly_data = data.groupby(pd.Grouper(freq='MS')).last()
for time, row in data.iterrows():
self.data[symbol].update_daily_return(row.close)
for time, row in monthly_data.iterrows():
self.data[symbol].update_monthly_data(row.close)
# calculate momentum
if self.data[symbol].is_ready():
if self.data[symbol].get_momentum() == 0:
continue
perf[stock] = self.data[symbol].get_momentum()
if len(perf) >= self.quantile:
sorted_by_perf:List = sorted(perf.items(), key = lambda x:x[1], reverse=True)
quantile:int = int(len(sorted_by_perf) / self.quantile)
long:List[Fundamental] = [x[0] for x in sorted_by_perf[:quantile]]
self.short:List[Fundamental] = [x[0] for x in sorted_by_perf[-quantile:]]
if len(self.volatilities) >= self.min_period:
trade_direction:bool = True if self.volatilities[-1] < np.median(self.volatilities[:-1]) else False
if trade_direction:
for i, portfolio in enumerate([long, self.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
else:
# trade only treasury bills
self.weight[self.treasury] = 1
return list(self.weight.keys())
def OnData(self, data: Slice) -> None:
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)
self.weight.clear()
def Selection(self) -> None:
self.selection_flag = True
def annual_port_vol(self, weights: np.ndarray, daily_returns: np.ndarray) -> float:
# calculate the annual volatility of portfolio
returns_array:np.ndarray = np.column_stack(daily_returns)
covariance_matrix:np.ndarray = np.cov(returns_array, rowvar=False) * 252
result:float = np.sqrt(np.dot(weights.T, np.dot(covariance_matrix, weights)))
return result