Quant BuffetRelax, Not Over Thinking

Afternoon Reversal Trading Strategy

Log in to collect

Academic paper

AuthorsXu, Haoyu

Strategy in a nutshell

The strategy targets all AMEX, NYSE, and NASDAQ stocks with share codes 10 and 11, excluding closed-end funds, REITs, ADRs, foreign stocks, and stocks priced below $5. At the end of each month, the strategy calculates monthly cumulative afternoon returns for each stock by summing daily 2:00–4:00 p.m. returns. Stocks are then sorted into deciles, and the bottom decile (lowest cumulative afternoon returns) is bought, while the top decile (highest cumulative afternoon returns) is shorted. The portfolio is equally weighted and rebalanced monthly.

Economic rationale

The strategy exploits short-term reversal patterns caused by investor overreaction. Afternoon trading hours are optimal due to lower trading costs and information asymmetry compared to the morning, allowing liquidity providers to capture returns efficiently. Empirical research suggests that such reversals may serve as a proxy for profits from liquidity provision.

Backtest performance

Annualised return9.85%
Volatility8.44%
Beta0.029
Sharpe ratio1.17
Sortino ratio0.106
Win rate50%

Full Python code

from AlgorithmImports import *# endregionclass AfternoonReversalTradingStrategy(QCAlgorithm):    def Initialize(self):        self.SetStartDate(2000, 1, 1)        self.SetCash(100000)                self.SetTimeZone(TimeZones.NewYork)        self.market:Symbol = self.AddEquity("SPY", Resolution.Daily).Symbol                self.selected_universe:List[FineFundamental] = []       # selected stock universe        self.intraday_returns:Dict[Symbol, List[float]] = {}    # intraday returns for the most recent month        self.recent_open_price:Dict[Symbol, float] = {}         # most recent intraday candle open price        self.value_weighted_portfolio:bool = False              # False - EW; True - VW        self.leverage:int = 3        self.open_hour:int = 15                                 # taking open of this hourly candle        self.close_hour:int = 16                                # taking close of this hourly candle        self.min_intraday_return_period:int = 15                # minimum of intraday returns store for them most recent month        self.quantile:int = 10        self.fundamental_count:int = 500        self.fundamental_sorting_key = lambda x: x.DollarVolume        self.min_share_price:float = 5.        self.required_exchanges:List[str] = ['NYS', 'NAS', 'ASE']        self.tickers_to_ignore:List[str] = ['GME', 'NE']        self.weight:Dict[Symbol, float] = {}            # traded weights by symbol        self.selection_flag:bool = False        self.rebalance_flag:bool = False        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.        self.UniverseSettings.Resolution = Resolution.Hour        self.AddUniverse(self.FundamentalSelectionFunction)        self.Schedule.On(self.DateRules.MonthEnd(self.market, 1), self.TimeRules.AfterMarketOpen(self.market), self.Selection)    def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:        for security in changes.AddedSecurities:            symbol:Symbol = security.Symbol            security.SetFeeModel(CustomFeeModel())            security.SetLeverage(self.leverage)            self.intraday_returns[symbol] = []            self.recent_open_price[symbol] = 0                for security in changes.RemovedSecurities:            symbol:Symbol = security.Symbol                        if symbol in self.intraday_returns:                del self.intraday_returns[symbol]            if symbol in self.recent_open_price:                del self.recent_open_price[symbol]    def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:        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.AdjustedPrice >= self.min_share_price and \            x.Symbol.Value not in self.tickers_to_ignore and not x.CompanyReference.IsREIT and x.MarketCap != 0 and x.SecurityReference.ExchangeId in self.required_exchanges]                if len(selected) > self.fundamental_count:            selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]        cumulative_perf:Dict[Symbol, float] = {}        for stock in selected:            symbol:Symbol = stock.Symbol            if symbol in self.intraday_returns and len(self.intraday_returns[symbol]) >= self.min_intraday_return_period:                cumulative_eq:np.ndarray = (1 + np.array(self.intraday_returns[symbol])).cumprod()                cumulative_perf[stock] = cumulative_eq[-1] / cumulative_eq[0] - 1                                # reset intraday return monthly series                self.intraday_returns[symbol] = []        long:List[FineFundamental] = []        short:List[FineFundamental] = []        if len(cumulative_perf) >= self.quantile:            sorted_by_returns:List = sorted(cumulative_perf.items(), key=lambda x: x[1], reverse=True)            quantile:int = int(len(sorted_by_returns) / self.quantile)            long = [x[0] for x in sorted_by_returns[-quantile:]]            short = [x[0] for x in sorted_by_returns[:quantile]]        if self.value_weighted_portfolio:            for i, portfolio in enumerate([long, short]):                for stock in portfolio:                    mc_sum:float = sum([x.MarketCap for x in portfolio])                    self.weight[stock.Symbol] = ((-1) ** i) * stock.MarketCap / mc_sum        else:            for i, portfolio in enumerate([long, short]):                for stock in portfolio:                    self.weight[stock.Symbol] = ((-1) ** i) / len(portfolio)        # assign symbols to currently selected universe        self.selected_universe = list(map(lambda x: x.Symbol, selected))        self.rebalance_flag = True        return self.selected_universe    def OnData(self, data: Slice) -> None:        for symbol in self.selected_universe:            if data.ContainsKey(symbol):                # intraday period open candle                if self.Time.hour == self.open_hour:                    self.recent_open_price[symbol] = data[symbol].Open                if self.Time.hour == self.close_hour:                    # calculate intraday return                    if symbol in self.recent_open_price and self.recent_open_price[symbol] != 0:                        open_price:float = self.recent_open_price[symbol]                        intraday_return:float = data[symbol].Close / open_price - 1                        self.recent_open_price[symbol] = 0                        # append intraday return to monthly series                        if symbol in self.intraday_returns:                            self.intraday_returns[symbol].append(intraday_return)                    if self.Time.hour != 10:            return        # monthly rebalance        if not self.rebalance_flag:            return        self.selection_flag = False        self.rebalance_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:        # monthly rebalance        self.selection_flag = True# Custom fee model.class CustomFeeModel(FeeModel):    def GetOrderFee(self, parameters):        fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005        return OrderFee(CashAmount(fee, "USD"))