Abstract

In this tutorial, we implement a version of the short-term reversal strategy published by De Groot, Huij, & Zhou (2012). The strategy works by observing the returns of each security in the universe over the previous month. Every week, the algorithm longs the worst performers and shorts the top performers. The original strategy outlined in the literature considers the entire universe of stocks when trading. To reduce trading costs, we limit our universe to the most liquid large cap stocks. Our analysis shows the strategy underperforms the S&P 500 index during all our backtest periods except the 2020 market crash.

Method

The strategy code mainly consists of four parts: Initialization, Universe Selection, OnData, and OnSecuritiesChanged.

Algorithm Initialization

When initializing the algorithm, we add a coarse universe selection method and specify several parameters for selecting securities.

def initialize(self):
    # ...

    self.universe_settings.resolution = Resolution.DAILY
    self.add_universe(self.select_coarse)

    self.dollar_volume_selection_size = 100
    self.roc_selection_size = int(0.1 * self.dollar_volume_selection_size)

    self.lookback = 22
    self.roc_by_symbol = {}
    self.week = 0

Universe Selection

The coarse universe selection method creates a RateOfChange indicator for each of the top 100 most liquid securities in the market. Upon creation, the indicator is manually warmed-up with historical closing prices. After the indicators are ready, the universe selects the securities with the 10 best and 10 worst RateOfChange values.

def select_coarse(self, coarse):

    # We should keep a dictionary for all securities that have been selected
    for cf in coarse:
        symbol = cf.symbol
        if symbol in self.roc_by_symbol:
            self.roc_by_symbol[symbol].update(cf.end_time, cf.adjusted_price)

    # Refresh universe each week
    week_number = self.time.date().isocalendar()[1]
    if week_number == self.week:
        return Universe.UNCHANGED
    self.week = week_number

    # sort and select by dollar volume
    sorted_by_dollar_volume = sorted(coarse, key=lambda x: x.dollar_volume, reverse=True)
    selected = {cf.symbol: cf for cf in sorted_by_dollar_volume[:self.dollar_volume_selection_size]} 

    # New selections need a history request to warm up the indicator
    symbols = [k for k in selected.keys()
        if k not in self.roc_by_symbol or not self.roc_by_symbol[k].is_ready]

    if symbols:
        history = self.history(symbols, self.lookback+1, Resolution.DAILY)
        if history.empty:
            self.log(f'No history for {", ".join([x.value for x in symbols])}')
        history = history.close.unstack(0)

        for symbol in symbols:
            symbol_id = symbol.id.to_string()
            if symbol_id not in history:
                continue

            # Create and warm-up the RateOfChange indicator
            roc = RateOfChange(self.lookback)
            for time, price in history[symbol_id].dropna().iteritems():
                roc.update(time, price)

            if roc.is_ready:
                self.roc_by_symbol[symbol] = roc

    # Sort the symbols by their ROC values
    selected_rate_of_change = {}
    for symbol in selected.keys():
        if symbol in self.roc_by_symbol:
            selected_rate_of_change[symbol] = self.roc_by_symbol[symbol]
    sorted_by_rate_of_change = sorted(selected_rate_of_change.items(), key=lambda kv: kv[1], reverse=True)

    # Define the top and the bottom to buy and sell
    self.roc_top = [x[0] for x in sorted_by_rate_of_change[:self.roc_selection_size]]
    self.roc_bottom = [x[0] for x in sorted_by_rate_of_change[-self.roc_selection_size:]]

    return self.roc_top + self.roc_bottom

The OnData Method

As new data is passed to the OnData method, we issue orders to form a long-short portfolio. We long the securities with the lowest RateOfChange values and short those with the largest values. After rebalancing, we clear the rocTop and rocBottom lists to ensure we don’t trade again until the universe is refreshed.

def on_data(self, data):
    # Rebalance
    for symbol in self.roc_top:
        self.set_holdings(symbol, -0.5/len(self.roc_top))
    for symbol in self.roc_bottom:
        self.set_holdings(symbol, 0.5/len(self.roc_bottom))

    # Clear the list of securities we have placed orders for
    # to avoid new trades before the next universe selection
    self.roc_top.clear() 
    self.roc_bottom.clear()

The OnSecuritiesChanged Method

We are rebalancing the portfolio weekly, but securities can leave our defined universe between rebalance days. To accommodate this, we liquidate any securities removed from the universe in the OnSecuritiesChanged method.

def on_securities_changed(self, changes):
    for security in changes.removed_securities:
        self.liquidate(security.symbol, 'Removed from Universe')

Relative Performance

Period NameStart DateEnd DateStrategySharpeVariance
5 Year Backtest1/1/20161/1/2021Strategy0.2870.047
Benchmark0.7540.024
2020 Crash2/19/20203/23/2020Strategy-1.0750.798
Benchmark-1.4670.416
2020 Recovery3/23/20206/8/2020Strategy1.9870.132
Benchmark7.9420.101

Reference

  1. Quantpedia - Short Term Reversal in Stocks