The strategy combines joint momentum (buy top quintile stocks and CDS) and contrarian signals (buy bottom stocks and top CDS). The portfolio is value-weighted, with positions held for one month.

I. 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.

II. 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.

III. SOURCE PAPER

 Related Securities and the Cross-Section of Stock Return Momentum: Evidence From Credit Default Swaps (CDS) [Click to Open PDF]

Lee, Seoul National University; Naranjo, University of Florida – Warrington College of Business Administration; Sirmans, Auburn University

<Abstract>

We document that stock return momentum strategies earn 20% more per year among firms with strong alignment in their past equity and credit returns than firms with diverging returns across these two markets. Using structural Q-theory, we show information in both equity and credit from the full liability side of a firm’s balance sheet reveals unobserved asset return momentum that explains cross-sectional variations in stock return momentum. We complement this rationale with limited arbitrage in equity and credit markets to further explain our findings during financial market dislocations. We also show that multi-market related securities signals hedge stock momentum crashes.

IV. BACKTEST PERFORMANCE

Annualised Return21.56%
Volatility24.09%
Beta0.082
Sharpe Ratio 0.81
Sortino Ratio0.109
Maximum DrawdownN/A
Win Rate49%

V. 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

Leave a Reply

Discover more from Quant Buffet

Subscribe now to keep reading and get access to the full archive.

Continue reading