Quant Quickstart III: Mean Reversion Strategy

Leo Smigel
April 13, 2020

New to the Quant Quickstart series? Read part one and part two.

If you've made it this far, congratulations! You've successfully imported the Dow 30 from Intrinio and graphed the relative strength index for multiple stocks.

In this post, we're going to create a simple mean reversion strategy that you'll be able to build on to develop your own profitable trading strategies. We'll continue using the Intrinio sandbox so everyone can follow along for free. We'll change our momentum and mean reversion periods to account for this.

If you're new to this series, my name is Leo Smigel, and I'm an algorithmic trader. I apply data science to traditional valuation and trading techniques to make money in the markets. If you're interested, you can learn more at Analyzing Alpha.

The Strategy

Before we dig into the code, let's discuss the strategy. Stocks tend to trend in the long-term and mean-revert in the short term. There's quite a bit of academic literature on this topic, which we won't cover here. If you're interested in the research, I highly recommend SSRN.

We'll create a short strategy. To implement our strategy, we'll rank the Dow 30's largest laggards using their simple moving average (SMA) over the last 50 days. The 50-period simple moving average (SMA) will be our trend indicator where we expect the stock to continue to the downside. We'll then take the top ten falling Dow stocks and rank them by the highest 10-period relative strength indicator (RSI). The 10-period RSI will show us shares that recently reached oversold conditions and moved higher. We'll sell our current positions and buy the two stocks with the highest RSI every Friday with an expectation that the stocks will continue lower.

Let's see what this looks like in code.

The Code

class MyStrategy(bt.Strategy):
    params = dict( 
        num_universe=10,
        num_positions=2,
        when=bt.timer.SESSION_START,
        weekdays=[5],
        weekcarry=True,
        rsi_period=10,
        sma_period=50
    ) 

    def __init__(self):
        self.inds = {}
        self.securities = self.datas[1:]
        for s in self.securities:
            self.inds[s] = {}
            self.inds[s]['sma'] = bt.ind.SMA(s, period=self.p.sma_period)
            self.inds[s]['rsi'] = bt.ind.RSI(s, period=self.p.rsi_period)

As we've done before, we create our class inheriting from bt.Strategy and then add our parameters. We initialize our strategy and use a dictionary to contain the SMA and RSI for each security.

add_timer is a callback to notify_timer allowing us to run code at a specified point in time. In our case, we rebalance every Friday, or the next business day if Friday isn't a trading day, based upon the params above.

notify_trade alerts us of open and closing of trades as shown below.

self.add_timer(
    when=self.p.when,
    weekdays=self.p.weekdays,
    weekcarry=self.p.weekcarry

def notify_timer(self, timer, when, *args, **kwargs):
    self.rebalance()
    
def notify_trade(self, trade):
    if trade.size == 0:  # Trade size zero is closed out.
                    print(f"Trade PNL: Date: {trade.data.datetime.date(ago=0)} Ticker: {trade.data.p.name} Profit: {round(trade.pnlcomm,2)}")

As often the case, rebalance is where the real magic is done.

def rebalance(self):
    rankings = list(self.securities)
    rankings.sort(
        key=lambda s: self.inds[s]['sma'][0],
        reverse=False
    )
    rankings = rankings[:self.p.num_universe]
    rankings.sort(
        key=lambda s: self.inds[s]['rsi'][0],
        reverse=True
    )

    pos_size = -1 / self.p.num_positions # Go short

    # Sell stocks no longer meeting ranking filter.
    for i, d in enumerate(rankings):
        if self.getposition(d).size:
            if i > self.p.num_positions:
                self.close(d)

    # Buy and rebalance stocks with remaining cash
    for i, d in enumerate(rankings[:self.p.num_positions]):
        self.order_target_percent(d, target=pos_size)

We start by creating a list of our Dow 30 securities. We then rank the securities by the SMA using a lambda function, which is just an anonymous function that allows us to skip the formalities of defining one. We then take the top ten lagging stocks and rank them by the highest RSI.

After we've done slicing our stocks, we sell any stocks that are no longer in the top 2 ranked positions and buy the stocks that are.

If you're new to Python, this may seem daunting. The good news is that after you understand the above, you can take your trading to a whole new level.

For good measure, we'll add our previous code below. We've seen it already. It loads the Intrinio data into the system and calculates the before and after profits.

if __name__ == '__main__':
    # Initialize a cerebro instance
    cerebro = bt.Cerebro()
    cerebro.broker.setcash(2500.0)
    cerebro.broker.setcommission(commission=0.001)

    # Read in the csv file we created and add the data.
    for stock in STOCKS:
        filename = stock + '.csv'
        data = bt.feeds.GenericCSVData(
            dataname=filename,
            dtformat=('%Y-%m-%d'),
            datetime=0,
            high=2,
            low=2,
            open=1,
            close=4,
            volume=5,
            openinterest=-1,
            name=stock
        )
        cerebro.adddata(data)

    # Add the strategy
    cerebro.addstrategy(MyStrategy)
    # Run the strategy printing the starting and ending values, and plot the results.

    print('Starting Portfolio Value: %.2f' % cerebro.broker.getvalue())
    cerebro.run()
    print('Ending Portfolio Value: %.2f' % cerebro.broker.getvalue())

So how did we do?

The Results

When we run the program, we get a list of our trades. Remember, a trade takes two orders: one to open the trade and one to close it.

Starting Portfolio Value: 2500.00
Trade PNL: Date: 2019-12-09 Ticker: BA Profit: 57.04
Trade PNL: Date: 2019-12-16 Ticker: GS Profit: -21.58
Trade PNL: Date: 2019-12-23 Ticker: CAT Profit: -2.11
Trade PNL: Date: 2020-01-06 Ticker: AXP Profit: -48.57
Trade PNL: Date: 2020-01-13 Ticker: CSCO Profit: -5.19
Trade PNL: Date: 2020-01-21 Ticker: GE Profit: 7.79
Trade PNL: Date: 2020-01-27 Ticker: GS Profit: -25.63
Trade PNL: Date: 2020-03-02 Ticker: INTC Profit: 135.69
Trade PNL: Date: 2020-03-02 Ticker: NKE Profit: 60.43
Trade PNL: Date: 2020-03-09 Ticker: KO Profit: 138.08
Trade PNL: Date: 2020-03-23 Ticker: PFE Profit: 223.84
Trade PNL: Date: 2020-03-23 Ticker: MRK Profit: 119.11
Trade PNL: Date: 2020-03-30 Ticker: VZ Profit: -21.8
Trade PNL: Date: 2020-04-06 Ticker: CSCO Profit: -286.29
Trade PNL: Date: 2020-04-06 Ticker: NKE Profit: 38.48
Ending Portfolio Value: 3124.94

It looks like we increased our portfolio value by 25% over the period. Not bad! Obviously, you would need to do more research to see if this strategy was effective over different time periods.

To Be Continued...

In the next post, we'll switch gears and add fundamentals to our strategy. If you're ambitious, create a fundamentals database before next month. As always, the code for this post is on GitHub.