Quant BuffetRelax, Not Over Thinking

Moving Averages Distance Strategy in Equities

Log in to collect

Academic paper

The Predictability of Equity Returns from Past Returns: A New Moving Average-Based Perspective

AuthorsDoron Avramov; Guy Kaplanski; Avanidhar Subrahmanyam

Institute
  • ILReichman University
  • ?Interdisciplinary Center (IDC) Herzliyah
  • ILBar-Ilan University
  • ?Bar-Ilan University - Graduate School of Business Administration
  • University of California, Los Angeles
  • Research Network (United States)
  • ?Financial Research Network (FIRN)
  • ?University of California, Los Angeles (UCLA) - Finance Area

Strategy in a nutshell

Trades U.S. stocks using the Moving Average Deviation (MAD) ratio (MA21 ÷ MA200). Long when MAD ≥ 1.2, short when MAD ≤ 0.8. Equally weighted, rebalanced monthly, capturing short-term deviations from long-term trends.

Economic rationale

Profits arise from investor anchoring and underreaction to new information. The MAD strategy exploits this behavioral bias, showing persistent profitability even after controlling for momentum, earnings revisions, and trading costs.

Backtest performance

Annualised return8.35%
Volatility18.31%
Beta-0.452
Sharpe ratio0.46
Sortino ratio0.36
Win rate60%

Full Python code

import numpy as npfrom AlgorithmImports import *from typing import List, Dictclass MovingAveragesDistance(QCAlgorithm):    def Initialize(self):        self.SetStartDate(2000, 1, 1)        self.SetCash(100000)        self.exchange_codes:List[str] = ['NYS', 'NAS', 'ASE']	        self.fundamental_sorting_key = lambda x: x.DollarVolume        self.fundamental_count:int = 500                self.min_share_price:int = 5        self.leverage:int = 10        self.period:int = 200        self.month_period:int = 21                self.data:Dict[Symbol, SymbolData] = {}                self.long:List[Symbol] = []        self.short:List[Symbol] = []                self.symbol:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol                self.selection_flag:bool = False        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.        self.UniverseSettings.Resolution = Resolution.Daily        self.AddUniverse(self.FundamentalSelectionFunction)        self.Schedule.On(self.DateRules.MonthStart(self.symbol), self.TimeRules.AfterMarketOpen(self.symbol), self.Selection)        self.settings.daily_precise_end_time = False    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 = stock.Symbol            # Store daily 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]]        MAD: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(symbol, 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:Series = history.loc[symbol].close                for time, close in closes.items():                    self.data[symbol].update(close)            if not self.data[symbol].is_ready():                continue                        prices:List[float] = self.data[symbol].return_prices()            ma21:float = np.average(prices[:self.month_period])            ma200:float = np.average(prices)                        MAD[symbol] = ma21 / ma200            self.long = [x[0] for x in MAD.items() if x[1] >= 1.2]        self.short = [x[0] for x in MAD.items() if x[1] <= 0.8]                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 Selection(self) -> None:        self.selection_flag = True    class SymbolData():    def __init__(self, symbol:Symbol, period:int):        self.Symbol:Symbol = symbol        self.Prices:RollingWindow = RollingWindow[float](period)            def update(self, price:float):        self.Prices.Add(price)            def is_ready(self) -> bool:        return self.Prices.IsReady            def return_prices(self) -> List[float]:        return [x for x in self.Prices]        class CustomFeeModel(FeeModel):    def GetOrderFee(self, parameters):        fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005        return OrderFee(CashAmount(fee, "USD"))