Comomentum Strategy
Log in to collectAcademic paper
Co-Momentum: Inferring Arbitrage Capital from Return Correlations
Dong Lou; Christopher Polk
- London School of Economics and Political Science
- Centre for Economic Policy Research
- ?Centre for Economic Policy Research (CEPR)
- ?London School of Economics & Political Science (LSE)
- ?London School of Economics
Strategy in a nutshell
The strategy trades U.S. stocks from NYSE, Amex, and Nasdaq, excluding those priced under $5 or in the smallest NYSE size decile. Each month, stocks are ranked by their past 12-month return (skipping the last month) and sorted into deciles. Pairwise partial correlations of loser-decile returns are measured after adjusting for Fama-French factors. The classic momentum trade—long winners and short losers—is applied only when the loser-decile correlation (ComomL) is in its lowest historical quintile, signaling favorable conditions.
Economic rationale
Momentum works differently depending on capital flows. When arbitrage capital is low, underreaction drives short-term continuation. When capital is high, overreaction creates reversals and potential crashes from crowded unwinding. Monitoring these dynamics helps manage risks effectively.
Backtest performance
Full Python code
import itertools as it
import numpy as np
from AlgorithmImports import *
from pandas.core.frame import DataFrame
class ComomentumStrategy(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2010, 1, 1)
self.SetCash(100000)
self.long:List[Symbol] = []
self.short:List[Symbol] = []
market:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
# Weekly price data.
self.data:Dict[Symbol, SymbolData] = {}
self.period:int = 52
self.quantile:int = 10
self.leverage:int = 3
self.min_share_price:float = 5.
self.exchange_codes:List[str] = ['NYS', 'NAS', 'ASE']
self.fundamental_count:int = 500
self.fundamental_sorting_key = lambda x: x.DollarVolume
# Historical commonL values.
self.historical_CommonL:List[float] = []
self.min_historical_CommonL_period:int = 12
self.last_month:int = -1
self.selection_flag:bool = False
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.settings.daily_precise_end_time = False
self.settings.minimum_order_margin_portfolio_percentage = 0.
market: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
self.schedule.on(self.date_rules.month_start(market),
self.time_rules.after_market_open(market),
self.selection)
def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
for security in changes.AddedSecurities:
security.SetFeeModel(CustomFeeModel())
security.SetLeverage(self.leverage)
def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
for stock in fundamental:
symbol:Symbol = stock.Symbol
# Store monthly price.
if symbol in self.data:
self.data[symbol].update(stock.AdjustedPrice)
if not self.selection_flag:
return Universe.Unchanged
selected:List[Fundamental] = [x for x in fundamental if x.HasFundamentalData and x.Market == 'usa' and \
x.Price >= self.min_share_price and x.SecurityReference.ExchangeId in self.exchange_codes]
if len(selected) > self.fundamental_count:
selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
performance:Dict[Symbol, float] = {}
for stock in selected:
symbol:Symbol = stock.Symbol
if symbol not in self.data:
self.data[symbol] = SymbolData(self.period)
history:DataFrame = self.History(symbol, self.period * 5, Resolution.Daily)
if history.empty:
self.Log(f"Not warmed up yet: {symbol}")
continue
closes:pd.Series = history.loc[symbol].close
for index, close in enumerate(closes):
if index % 5 == 0:
self.data[symbol].update(close)
if self.data[symbol].is_ready():
performance[symbol] = self.data[symbol].performance()
if len(performance) < self.quantile:
return Universe.Unchanged
# Performance sorting.
sorted_by_perf:List = sorted(performance.items(), key = lambda x: x[1], reverse = True)
quantile:int = int(len(sorted_by_perf) / self.quantile)
high_by_perf:List[Symbol] = [x[0] for x in sorted_by_perf[:quantile]]
low_by_perf:List[Symbol] = [x[0] for x in sorted_by_perf[-quantile:]]
decile_corr:List[float] = []
symbol_pairs:List = list(it.combinations(low_by_perf, 2))
for pair in symbol_pairs:
symbol1_weekly_closes:List[float] = [x for x in self.data[pair[0]]._price][:-1]
symbol2_weekly_closes:List[float] = [x for x in self.data[pair[1]]._price][:-1]
decile_corr.append(np.corrcoef(symbol1_weekly_closes, symbol2_weekly_closes)[0, 1])
if len(decile_corr) != 0:
commonL:float = np.average(decile_corr)
self.historical_CommonL.append(commonL)
# At least year of monthly ComomL data is ready.
if len(self.historical_CommonL) >= self.min_historical_CommonL_period:
bottom_commonL_quintile:float = np.percentile(self.historical_CommonL, 20)
if commonL < bottom_commonL_quintile:
# Trade classical momentum strategy.
self.long = high_by_perf
self.short = low_by_perf
return self.long + self.short
def OnData(self, slice: Slice) -> None:
if not self.selection_flag:
return
self.selection_flag = False
# order execution
targets:List[PortfolioTarget] = []
for i, portfolio in enumerate([self.long, self.short]):
for symbol in portfolio:
if symbol in slice and slice[symbol]:
targets.append(PortfolioTarget(symbol, ((-1) ** i) / len(portfolio)))
self.SetHoldings(targets, True)
self.long.clear()
self.short.clear()
def selection(self) -> None:
self.selection_flag = True
class SymbolData():
def __init__(self, period: int):
self._price:RollingWindow = RollingWindow[float](period)
def is_ready(self) -> bool:
return self._price.IsReady
def update(self, close: float) -> None:
self._price.Add(close)
# Performance for previous 52 weeks, one month skipped.
def performance(self) -> float:
values = [x for x in self._price][4:]
return (values[3] / values[-1] - 1)
# Custom fee model.
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters):
fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))