
The strategy involves sorting stocks based on cumulative returns from t-12 to t-2, buying absolute winners (top 10%) and selling absolute losers (bottom 10%), with monthly rebalancing of value-weighted portfolios.
ASSET CLASS: stocks | REGION: United States | FREQUENCY:
Monthly | MARKET: equities | KEYWORD: Absolute Momentum, Effect, Stocks
I. STRATEGY IN A NUTSHELL
This strategy buys absolute winners and sells absolute losers from NASDAQ, AMEX, and NYSE stocks, based on 11-month cumulative returns. Portfolios are value-weighted and rebalanced monthly.
II. ECONOMIC RATIONALE
Investors tend to overreact to past performance, creating momentum. Absolute strength momentum reduces distortions from relative ranking, capturing true performance trends.
III. SOURCE PAPER
Absolute strength: Exploring momentum in stock returns [Click to Open PDF]
Huseyin Gulen and Ralitsa Petkova.Mitchell E. Daniels, Jr School of Business, Purdue University; Purdue University – Krannert School of ManagementCase Western Reserve University – Department of Banking & Finance.
<Abstract>
We document a new pattern in stock returns that we call absolute strength momentum. Stocks that have signifi cantly increased in value in the recent past (absolute strength winners) continue to gain, and stocks that have signifi cantly decreased in value (absolute strength losers) continue to lose in the near future. Absolute strength winner and loser portfolio breakpoints are recursively determined by the historical distribution of realized cumulative returns across time and across stocks. The historical distribution yields stable breakpoints that are always positive (negative) for the winner (loser) portfolios. As a result, winners are those that have experienced a signifi cant upward trend, while losers are those that have experienced a signifi cant downward trend, and stocks with no momentum have cumulative returns that are not signi ficantly different from zero. Absolute strength momentum generates large and signi ficant risk-adjusted returns, outperforms the relative strength momentum strategy of Jegadeesh and Titman (1993) and other prominent momentum strategies, and its profi tability is consistent across sample periods, international markets, asset classes, and holding periods.


IV. BACKTEST PERFORMANCE
| Annualised Return | 22.28% |
| Volatility | 29.2% |
| Beta | -0.199 |
| Sharpe Ratio | 0.76 |
| Sortino Ratio | -0.026 |
| Maximum Drawdown | N/A |
| Win Rate | 52% |
V. FULL PYTHON CODE
from AlgorithmImports import *
from scipy import stats
from pandas.core.frame import dataframe
class AbsoluteMomentumEffectStocks(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2000, 1, 1)
self.SetCash(100000)
market:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
self.period:int = 13
self.quantile:int = 5
self.leverage:int = 5
self.exchange_codes:List[str] = ['NYS', 'NAS', 'ASE']
self.required_yearly_return_period:int = 10 # Minimum of years to calculate distribution from.
self.data:Dict[Symbol, SymbolData] = {} # Monthly price data.
self.weight:Dict[Symbol, float] = {}
self.fundamental_count:int = 500
self.fundamental_sorting_key = lambda x: x.DollarVolume
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.
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]:
if not self.selection_flag:
return Universe.Unchanged
# Update the rolling window every month.
for stock in fundamental:
symbol:Symbol = stock.Symbol
# Store monthly price.
if symbol in self.data:
self.data[symbol].update(stock.AdjustedPrice)
# Add yearly performance.
if self.data[symbol].is_ready():
self.data[symbol].add_yearly_return(self.data[symbol].performance())
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]
if len(selected) > self.fundamental_count:
selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
long:List[Fundamental] = []
short:List[Fundamental] = []
# Warmup price rolling windows.
for stock in selected:
symbol:Symbol = stock.Symbol
if symbol not in self.data:
self.data[symbol] = SymbolData(self.period, self.required_yearly_return_period)
history:dataframe = self.History(symbol, self.period*30, Resolution.Daily)
if history.empty:
self.Log(f"Not enough data for {symbol} yet.")
continue
closes:pd.Series = history.loc[symbol].close
closes_len:int = len(closes.keys())
# Find monthly closes.
for index, time_close in enumerate(closes.items()):
# index out of bounds check.
if index + 1 < closes_len:
date_month:int = time_close[0].date().month
next_date_month:int = closes.keys()[index + 1].month
# Found last day of month.
if date_month != next_date_month:
self.data[symbol].update(time_close[1])
if self.data[symbol].yearly_returns_ready():
# Calculate distribution.
yearly_returns:List[float] = [x for x in self.data[symbol]._yearly_returns]
prev_yearly_returns:List[float] = yearly_returns[:-1]
yearly_ret:float = yearly_returns[-1]
percentile:float = stats.percentileofscore(prev_yearly_returns, yearly_ret) / 100
if percentile >= 0.9:
long.append(stock)
elif percentile <= 0.1:
short.append(stock)
# Market cap weighting.
for i, portfolio in enumerate([long, short]):
mc_sum:float = sum(map(lambda x: x.MarketCap, portfolio))
for stock in portfolio:
self.weight[stock.Symbol] = ((-1) ** i) * stock.MarketCap / mc_sum
return list(self.weight.keys())
def OnData(self, data: Slice) -> None:
if not self.selection_flag:
return
self.selection_flag = False
# Trade execution.
portfolio:List[PortfolioTarget] = [
PortfolioTarget(symbol, w) for symbol, w in self.weight.items() if symbol in data and data[symbol]
]
self.SetHoldings(portfolio, True)
self.weight.clear()
def selection(self) -> None:
self.selection_flag = True
class SymbolData():
def __init__(self, period: int, required_yearly_return_period: int):
self._prices:RollingWindow = RollingWindow[float](period)
self._yearly_returns:List[float] = []
self._required_yearly_return_period:int = required_yearly_return_period
def update(self, price: float) -> None:
self._prices.Add(price)
def add_yearly_return(self, value: float) -> None:
self._yearly_returns.append(value)
def is_ready(self) -> bool:
return self._prices.IsReady
def yearly_returns_ready(self) -> bool:
return len(self._yearly_returns) >= self._required_yearly_return_period
# Yearly performance, one month skipped.
def performance(self) -> float:
return (self._prices[1] / self._prices[self._prices.Count - 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"))