The strategy invests in the top decile of ETFs with the highest past 36-month returns and shorts the bottom decile, using a value-weighted portfolio that is rebalanced monthly.

I. STRATEGY IN A NUTSHELL

The strategy invests in U.S. ETFs (≥$20M market cap, price >$1) by ranking them on 36-month cumulative returns. Each month, it goes long the top decile and short the bottom decile, using a value-weighted portfolio.

II. ECONOMIC RATIONALE

ETF momentum is distinct from individual stock momentum and persists independently of benchmark co-movements, macro risk, liquidity, or stock characteristics. Momentum is strongest in ETFs holding large-cap stocks, reflecting unique market dynamics rather than traditional stock-level drivers.

III. SOURCE PAPER

ETF Momentum [Click to Open PDF]

Weikai Li, Singapore Management University – Lee Kong Chian School of Business; Melvyn Teo, Singapore Management University – Lee Kong Chian School of Business; Chloe Yang, Fudan University – Fanhai International School of Finance (FISF)

<Abstract>

We document economically large momentum profits when sorting ETFs on returns over the past two to four years. A value-weighted, long-short strategy based on ETF momentum delivers Carhart (1997) four-factor alphas of up to 1.20% per month. Neither cross-sectional stock momentum nor co-variation with macroeconomic and liquidity risks can explain ETF momentum. Instead, the post-holding period returns are most consonant with the behavioral story of delayed overreaction. While ETF momentum survives multiple adjustments for transaction costs, it may be difficult to arbitrage as the profits are volatile and concentrated in ETFs with high idiosyncratic volatility or that hold low-analyst-coverage stocks.

IV. BACKTEST PERFORMANCE

Annualised Return16.08%
Volatility25.78%
Beta0.39
Sharpe Ratio0.62
Sortino Ratio0.325
Maximum DrawdownN/A
Win Rate53%

V. FULL PYTHON CODE

from AlgorithmImports import *
class ETFMomentum(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2008, 1, 1)
        self.SetCash(100000)
        self.market = self.AddEquity('SPY', Resolution.Daily).Symbol
        
        # Daily ROC data with market cap.
        self.data = {}
        self.period = 36 * 21
        self.SetWarmUp(self.period)
        
        # Data source: https://etfdb.com/screener/#page=4&tab=overview&sort_by=assets&sort_direction=desc&asset_class=equity&regions=U.S.&inception_on_start=2005-01-01&inception_on_end=2020-09-01
        csv_string_file = self.Download('data.quantpedia.com/backtesting_data/economic/us_equities.csv')
        
        # header: symbol;etf_name;total_assets_mm;previous_closing_price
        lines = csv_string_file.split('\r\n')
        for line in lines[1:]:
            line_split = line.split(';')
            
            etf_symbols = line_split[0]
            market_cap = float(line_split[2])  # $MM
            last_price = float(line_split[3])
            
            if market_cap > 20 and last_price > 1:
                data = self.AddEquity(etf_symbols, Resolution.Daily)
                data.SetLeverage(5)
                
                self.data[etf_symbols] = (self.ROC(etf_symbols, self.period, Resolution.Daily), market_cap)
                
        self.Schedule.On(self.DateRules.MonthStart(self.market), self.TimeRules.AfterMarketOpen(self.market), self.Rebalance)
        
    def Rebalance(self):
        performance = {x : self.data[x][0].Current.Value for x in self.data if self.data[x][0].IsReady}
        sorted_by_performance = sorted(performance.items(), key = lambda x: x[1], reverse = True)
        decile = int(len(sorted_by_performance) / 10)
        
        # Symbol, market cap tuples.
        long = [(x[0], self.data[x[0]][1]) for x in sorted_by_performance[:decile]]
        short = [(x[0], self.data[x[0]][1]) for x in sorted_by_performance[-decile:]]
        
        # Market cap weighting.
        weight = {}
        total_market_cap_long = sum([x[1] for x in long])
        for symbol, market_cap in long:
            weight[symbol] = market_cap / total_market_cap_long
        
        total_market_cap_short = sum([x[1] for x in short])
        for symbol, market_cap in short:
            weight[symbol] = -market_cap / total_market_cap_short   
        
        # Trade execution.
        invested = [x.Key for x in self.Portfolio if x.Value.Invested]
        for symbol in invested:
            if symbol not in weight:
                self.Liquidate(symbol)
        
        for symbol, w in weight.items():
            if self.Securities[symbol].Price != 0 and self.Securities[symbol].IsTradable:
                self.SetHoldings(symbol, w)

VI. Backtest Performance

Leave a Reply

Discover more from Quant Buffet

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

Continue reading