Quant BuffetRelax, Not Over Thinking

Combined Stock and CDS Momentum

Log in to collect

Academic paper

Related Securities and the Cross-Section of Stock Return Momentum: Evidence From Credit Default Swaps (CDS)

AuthorsJongsub Lee; Stace Sirmans

Institute
  • KRSeoul National University
  • University of Florida
  • ?University of Florida - Warrington College of Business Administration
  • Auburn University

Strategy in a nutshell

The strategy integrates joint momentum and disjoint contrarian signals using both stocks and CDS contracts from firms listed on NYSE, AMEX, and NASDAQ. Stocks and CDS are each sorted into quintiles based on past 12-month and 4-month returns. The joint momentum strategy goes long on firms in the top quintile for both stocks and CDS and shorts those in the bottom quintile. The disjoint contrarian strategy buys firms in the bottom quintile for stocks and shorts those in the top quintile for CDS, and vice versa. Portfolios are value-weighted and rebalanced monthly.

Economic rationale

The strategy exploits the information linkage between equity and credit markets. Stock returns and CDS spreads often move inversely, reflecting differing investor sentiment across markets. The joint momentum effect captures firms where both markets align in optimism or pessimism, indicating consistent information flow. In contrast, the disjoint contrarian effect profits from temporary mispricing when equity and CDS signals diverge. Combining these two effects helps capture both cross-market momentum and reversal opportunities, improving overall portfolio efficiency.

Backtest performance

Annualised return21.56%
Volatility24.09%
Beta0.082
Sharpe ratio0.81
Sortino ratio0.109
Win rate49%

Full Python code

from AlgorithmImports import *
import data_tools
from typing import List, Dict
#endregion
class CombinedStockandCDSMomentum(QCAlgorithm):
def Initialize(self) -> None:
self.SetStartDate(2005, 1, 1)
self.SetCash(100_000)
self.stock_period: int = 12 * 21
self.cds_period: int = 4 * 21
self.quantile: int = 5
self.leverage: int = 5

market: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
self.cds: Symbol = self.AddData(data_tools.EquityCDS5Y, 'CDS', Resolution.Daily).Symbol

# data yet to be initialized
self.tickers: List[str] = []       # CDS universe tickers
self.data: Dict[str, data_tools.SymbolData] = {}         # equity symbol data

self.quantity: Dict[Symbol, float] = {}      # traded monthly quantity
self.weight: Dict[Symbol, float] = {}

self.selection_flag: bool = False
self.UniverseSettings.Leverage = self.leverage
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.settings.daily_precise_end_time = False
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.Schedule.On(self.DateRules.MonthStart(market), self.TimeRules.AfterMarketOpen(market), self.Selection)
def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
for security in changes.AddedSecurities:
    security.SetFeeModel(data_tools.CustomFeeModel())
def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
# Update the rolling window every day.
if self.Securities.ContainsKey(self.cds):
    cds_data = self.Securities[self.cds].GetLastData()        
    if cds_data:
        # data has not been initialized yet
        if len(self.data) == 0:
            self.tickers = list([x.upper() for x in cds_data.GetStorageDictionary().Keys])
            self.data = { x : data_tools.SymbolData(self.stock_period, self.cds_period) for x in self.tickers }
        
        for stock in fundamental:
            ticker: str = stock.Symbol.Value

            # Store daily price and cds.
            if ticker in self.data:
                cds_price: float = cds_data[ticker]
                self.data[ticker].update(stock.AdjustedPrice, cds_price)

if not self.selection_flag:
    return Universe.Unchanged

# cds data probably ended
custom_data_last_update_date: datetime.date = data_tools.EquityCDS5Y.get_last_update_date()
if self.Securities[self.cds].GetLastData() and self.Time.date() > custom_data_last_update_date:
    return Universe.UNCHANGED
selected: List[Fundamental] = [x for x in fundamental if x.HasFundamentalData and x.Symbol.Value in self.tickers]
market_cap: Dict[Symbol, float] = {}
price_momentum: Dict[Symbol, float] = {}
cds_momentum: Dict[Symbol, float] = {}

for stock in selected:
    symbol: Symbol = stock.Symbol
    ticker: str = symbol.Value
    if not self.data[ticker].is_ready():
        continue
    if stock.MarketCap == 0:
        continue
    market_cap[symbol] = stock.MarketCap
    price_momentum[symbol] = self.data[ticker].price_momentum()
    cds_momentum[symbol] = self.data[ticker].cds_momentum()

if len(price_momentum) > self.quantile:
    sorted_by_price_momentum: List[Symbol] = [x[0] for x in sorted(price_momentum.items(), key=lambda item:item[1], reverse=True)]
    quantile: int = int(len(sorted_by_price_momentum) / self.quantile)
    top_by_momentum: List[Symbol] = sorted_by_price_momentum[:quantile]
    bottom_by_momentum: List[Symbol] = sorted_by_price_momentum[-quantile:]
    sorted_by_cds_momentum: List[Symbol] = [x[0] for x in sorted(cds_momentum.items(), key=lambda item:item[1], reverse=True)]
    quantile: int = int(len(sorted_by_cds_momentum) / self.quantile)
    top_by_cds_momentum: List[Symbol] = sorted_by_cds_momentum[:quantile]
    bottom_by_cds_momentum: List[Symbol] = sorted_by_cds_momentum[-quantile:]
    
    # Joint momentum
    joint_long: List[Symbol] = [x for x in top_by_momentum if x in top_by_cds_momentum]
    joint_short: List[Symbol] = [x for x in bottom_by_momentum if x in bottom_by_cds_momentum]
    
    # Contrarian strategy
    contrarian_long: List[Symbol] = [x for x in bottom_by_momentum if x in top_by_cds_momentum]
    contrarian_short: List[Symbol] = [x for x in top_by_momentum if x in bottom_by_cds_momentum]
    
    # Strategy weighting
    portfolio_weight: float = 0.5 # two-strategy portfolio adjustment
    for i, portfolio in enumerate([[joint_long, contrarian_long], [joint_short, contrarian_short]]):
        for subportfolio in portfolio:
            mc_sum: float = sum(list(map(lambda x: market_cap[x], subportfolio)))
            for symbol in subportfolio:
                w: float = ((-1)**i) * (market_cap[symbol] / mc_sum) * portfolio_weight
                q: float = (self.Portfolio.TotalPortfolioValue * w) / self.data[symbol.Value].price[0]
                self.quantity[symbol] = q
    
return list(self.quantity.keys())

def OnData(self, slice: Slice) -> None:
if not self.selection_flag: 
    return
self.selection_flag = False
self.Liquidate()
        
for symbol, q in self.quantity.items():
    if slice.contains_key(symbol) and slice[symbol]:
        self.MarketOrder(symbol, q)

self.quantity.clear()

def Selection(self) -> None:
self.selection_flag = True