Quant BuffetRelax, Not Over Thinking

Consistent Momentum Long-Short Portfolio with Six-Month Hold

Log in to collect

Academic paper

Persistency of the Momentum Effect: The Role of Consistent Winners and Losers

AuthorsHong‐Yi Chen; Pin‐Huang Chou; Chia‐Hsun Hsieh

Institute
  • TWNational Chengchi University
  • ?National Chengchi University - Department of Finance
  • TWNational Central University

Strategy in a nutshell

The investment universe consists of stocks listed at NYSE, AMEX, and NASDAQ, whose price data (at least for the past seven months) are available at the CRSP database. The investor creates a zero-investment portfolio at the end of the month t, longing stocks that are in the top decile in terms of returns both in the period from t-7 to t-1 and from t-6 to t, while shorting stocks in the bottom decile in both periods (i.e. longing consistent winners and shorting consistent losers). The stocks in the portfolio are weighted equally. The holding period is six months, with no rebalancing during the period. There is a one-month skip between the formation and holding period.

Economic rationale

Momentum anomaly is, in general, related to investors’ irrationality – they underreact to new information as they do not incorporate news in their transaction prices sufficiently. Under the information asymmetry and the heterogeneous beliefs hypotheses, the persistence of momentum effect depends on size, idiosyncratic volatility, % of institutional ownership, and trading volume. According to the former, investors tend to be conservative in the case of stocks with higher idiosyncratic volatilities and a lower percentage of outstanding stocks owned by institutional investors (they become consistent winners/losers due to slow price adjustment). The latter suggests that higher trading volume on stock (a proxy for disagreement among investors) should produce a stronger momentum effect.

Backtest performance

Annualised return16.08%
Volatility25.33%
Beta0.001
Sharpe ratio0.269
Sortino ratio0.279
Maximum drawdown63.1%
Win rate52%

Full Python code

from AlgoLib import *

class ConsistentMomentumStrategy(XXX):

def Initialize(self):
self.SetStartDate(2010, 1, 1)
self.SetCash(100000)

market:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol

self.long:List[Symbol] = []
self.short:List[Symbol] = []

self.data:Dict[Symbol, SymbolData] = {}

self.fundamental_count:int = 1000
self.fundamental_sorting_key = lambda x: x.DollarVolume

self.period:int = 7 * 21
self.quantile:int = 10
self.leverage:int = 5
self.exchange_codes:List[str] = ['NYS', 'NAS', 'ASE']

self.selection_flag:bool = False
self.UniverseSettings.Resolution = Resolution.Daily
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.AddUniverse(self.FundamentalSelectionFunction)
self.Schedule.On(self.DateRules.MonthStart(market), self.TimeRules.AfterMarketOpen(market), self.Rebalance)

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]:
# Update the rolling window every day.
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.SecurityReference.ExchangeId in self.exchange_codes and \
    x.MarketCap != 0 and x.CompanyReference.IsREIT != 1]
if len(selected) > self.fundamental_count:
    selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]

momentum_t71_t60:Dict[Symbol, float] = {}

# Warmup price rolling windows.
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, Resolution.Daily)
        if history.empty:
            self.Log(f"Not enough data for {symbol} yet")
            continue
        closes:pd.Series = history.loc[symbol].close
        for time, close in closes.items():
            self.data[symbol].update(close)
        
    if self.data[symbol].is_ready():
        momentum_t71_t60[symbol] = (self.data[symbol].performance_t7t1(), self.data[symbol].performance_t6t0())

if len(momentum_t71_t60) >= self.quantile:
    # Momentum t-7 to t-1 sorting
    sorted_by_perf_t71:List = sorted(momentum_t71_t60.items(), key = lambda x: x[1][0], reverse = True)
    quantile:int = int(len(sorted_by_perf_t71) / self.quantile)
    high_by_perf_t71:List[Symbol] = [x[0] for x in sorted_by_perf_t71[:quantile]]
    low_by_perf_t71:List[Symbol] = [x[0] for x in sorted_by_perf_t71[-quantile:]]

    # Momentum t-6 to t sorting
    sorted_by_perf_t60:List = sorted(momentum_t71_t60.items(), key = lambda x: x[1][1], reverse = True)
    quantile = int(len(sorted_by_perf_t60) / self.quantile)
    high_by_perf_t60:List[Symbol] = [x[0] for x in sorted_by_perf_t60[:quantile]]
    low_by_perf_t60:List[Symbol] = [x[0] for x in sorted_by_perf_t60[-quantile:]]
    
    self.long = [x for x in high_by_perf_t71 if x in high_by_perf_t60]
    self.short = [x for x in low_by_perf_t71 if x in low_by_perf_t60]

return self.long + self.short

def OnData(self, data: 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 data and data[symbol]:
            targets.append(PortfolioTarget(symbol, ((-1) ** i) / len(portfolio)))
		
self.SetHoldings(targets, True)

self.long.clear()
self.short.clear()

def Rebalance(self) -> None:
if self.Time.month % 6 == 0:
    self.selection_flag = True

class SymbolData():
def __init__(self, period: int):
self._price:RollingWindow = RollingWindow[float](period)

def update(self, price: float) -> None:
self._price.Add(price)

def is_ready(self) -> bool:
return self._price.IsReady

def performance_t7t1(self) -> float:
closes:List[float] = [x for x in self._price][21:]
return (closes[0] / closes[-1] - 1)

def performance_t6t0(self) -> float:
closes:List[float] = [x for x in self._price][:-21]
return (closes[0] / closes[-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"))