Credit-Informed Tactical Asset Allocation
Log in to collectAcademic paper
Credit-Informed Tactical Asset Allocation - 10 Years On
David Klein
- University of California System
- ?University of California
Strategy in a nutshell
The strategy trades the SPY ETF and S&P 500 e-mini futures, leveraging the relationship between credit spreads and equity valuations. First, convert the option-adjusted spread (OAS) of the ICE BofA Single-B US High Yield Index (HY/B) into default probabilities using a hazard rate calculation, assuming a five-year maturity. Next, apply an equity premium adjustment to the S&P 500 index to account for expected returns. Daily, regress the log of the adjusted S&P 500 index on the default probability over the past three months. The trading rule is as follows: if the S&P 500 is below the regression line, add 20% exposure via e-mini futures (120% long); if above, take a 120% short position in e-mini futures (20% net short). Leverage is recalculated daily, and the portfolio is continuously adjusted based on the regression signals.
Economic rationale
The strategy is grounded in the principle that credit markets often anticipate equity trends, while equities confirm them. By comparing default probability signals from the high-yield bond market with equity valuations, investors can identify over- or undervaluation in the S&P 500. This debt-equity relationship allows for tactical adjustments to equity exposure, capturing potential mispricings and enhancing risk-adjusted returns.
Backtest performance
Full Python code
from AlgorithmImports import *
from math import exp
import statsmodels.api as sm
from typing import List, Dict
import data_tools
# endregion
class CreditInformedTacticalAssetAllocation(QCAlgorithm):
def Initialize(self) -> None:
self.SetStartDate(2000, 1, 1)
self.SetCash(100000)
self.T: float = 5. # maturity assumption
self.RR: float = .4 # recovery rate assumption
self.r: float = .123 # annual premium rate; source: Source paper
self.traded_weight: float = 1.2
self.leverage: int = 5
self.market_index: Symbol = self.AddEquity("SPY", Resolution.Daily).Symbol
data: Security = self.AddData(data_tools.QuantpediaFutures, 'CME_ES1', Resolution.Daily)
data.SetLeverage(self.leverage)
data.SetFeeModel(data_tools.CustomFeeModel())
self.market_futures: Symbol = data.Symbol
self.HYB: Symbol = self.AddData(data_tools.QuantpediaDailyData, 'BAMLH0A2HYB', Resolution.Daily).Symbol
# regression data
self.regression_period: int = 3*21
self.default_probability_values: RollingWindow = RollingWindow[float](self.regression_period)
self.index_values: RollingWindow = RollingWindow[float](self.regression_period)
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
def OnData(self, data: Slice) -> None:
custom_data_last_update_date: Dict[Symbol, datetime.date] = data_tools.LastDateHandler.get_last_update_date()
if (self.Securities[self.market_futures].GetLastData() and self.Time.date() > custom_data_last_update_date[self.market_futures]) or \
(self.Securities[self.HYB].GetLastData() and self.Time.date() > custom_data_last_update_date[self.HYB]):
self.Liquidate()
return
# all needed data are present in the algorithm
if data.ContainsKey(self.market_index) and data.ContainsKey(self.market_futures) and data.ContainsKey(self.HYB):
oas: float = data[self.HYB].Value / 10000
hazard_rate: float = oas * (1 / (1-self.RR))
default_probability: float = 1 - exp(-self.T * hazard_rate)
self.default_probability_values.Add(default_probability)
# apply the equity premium rate
I: float = data[self.market_index].Value
I_adjusted: float = I * exp(self.r * self.T)
self.index_values.Add(I_adjusted)
# data for regression are ready
if self.default_probability_values.IsReady and self.index_values.IsReady:
model: RegressionResultsWrapper = self.MultipleLinearRegression(list(self.default_probability_values), list(self.index_values))
if model.resid[0] < 0:
# if the current S&P 500 index value is below the estimated OLS regression line, the S&P 500 appears to be undervalued
self.SetHoldings(self.market_futures, self.traded_weight)
else:
# if the current S&P 500 index value is above the estimated OLS regression line, the S&P 500 appears to be overvalued
self.SetHoldings(self.market_futures, -self.traded_weight)
else:
if self.Portfolio.Invested:
self.Liquidate()
def MultipleLinearRegression(self, x: List[float], y: List[float]):
x: np.ndarray = np.array(x).T
x = sm.add_constant(x)
result: RegressionResultsWrapper = sm.OLS(endog=y, exog=x).fit()
return result