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.

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 Return22.28%
Volatility29.2%
Beta-0.199
Sharpe Ratio0.76
Sortino Ratio-0.026
Maximum DrawdownN/A
Win Rate52%

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"))

Leave a Reply

Discover more from Quant Buffet

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

Continue reading