Quant BuffetRelax, Not Over Thinking

Credit-Informed Tactical Asset Allocation

Log in to collect

Academic paper

Credit-Informed Tactical Asset Allocation - 10 Years On

AuthorsDavid Klein

Institute
  • 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

Annualised return18.1%
Volatility22.5%
Beta0.021
Sharpe ratio0.81
Sortino ratio0.086
Maximum drawdown39.3%
Win rate27%

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