Developing a Mean Reversion Trading Strategy using Python

OVIDIU POPESCU

22 August 202211 min read

Did you ever play with Lego blocks as a kid, those colorful interlocking plastic pieces that come in all different shapes and sizes? If you had a smaller set, then you could probably assemble the pieces without the instructions, eventually coming up with something resembling the picture on the box. But for the larger more complicated sets, you really had to follow the instructions quite closely if you wanted to put all of the pieces together correctly. Otherwise, you’d be left with random bits of colorful plastic just haphazardly assembled – not a pretty sight.

It might sound like a bit of a stretch, but designing a trading strategy is remarkably similar. Sure, a simple trading strategy can be assembled quite quickly, but it’s not going to be particularly impressive. For anything else, you need a clear idea of how all of the strategy building blocks interlock with each other sequentially if you are to stand any chance of it being profitable.

Think of this article, then, as your instruction manual or template, which you can refer to for guidance or ideas when the time comes to design and assemble your own strategy. Follow along as we introduce you to a complete mean reversion trading strategy and explain the underlying rationale for using one. By the end of the article, you’ll be able to identify market regimes with a regime filter, select and use the right indicators, and translate a signal into a trading position. Although some assembly is required, a well-designed, profitable trading strategy is a thing of beauty.

Now it’s time to put all of this theory and knowledge into practice. We’ll cover 1) identifying market regimes with a regime filter, 2) using indicators to predict the value of a trading position, and 3) translating this signal

Mean Reversion Trading Strategy Description

Our case study focuses on a mean reversion strategy, which operates under the principle that when the price is at a local extreme, it is more likely to revert to a long-term average than continue in the same direction. These strategies often have a high ratio of profitable trades at the expense of a few more significant losses, often leading to a reasonably predictable profit and loss trajectory when the market is not trending as a result. It is important to note that mean reversion strategies rely on a reasonable volatility estimate to avoid too much risk when volatility changes quickly.

Mean Reversion Trading Strategy Design

The complete trading strategy requires a few core components.

  • The Signal
    The signal relates to how high or low the price is relative to the expected future average price. An important trait of the signal is that it is proportional to the potential profit, helping to smooth the profit and loss by not committing too much risk to uncertain trading opportunities.

  • Position Management
    Position management translates the signal into a trading position, i.e., it calculates how much to buy and sell at each time step to achieve the desired risk. It is important that risk is proportional to the strategy’s profit expectation. It should also be balanced across time, with more risk taken when volatility is low and less when volatility is high.

  • Regime Filter
    Simple trading strategies likely will not work in all market conditions. The job of the regime filter is to limit trading activity to the times when the strategy is expected to have the best risk-adjusted returns.

Mean Reversion Signal

The signal for the mean reversion strategy is given by its relative richness or cheapness on a scale of -1 to +1. The terms richness and cheapness are related to the expectation of the strategy with regards to future prices. If the prices are relatively cheap the expectation is they will be higher in the future; relatively expensive suggests that prices likely will be lower in the future. The relative value calculation requires an estimate for the fair price and the expected price volatility. A smoothed price filter is used to compute the relative value of the current price; this number, however, is not between -1 and +1. The volatility is then used to scale the signal so that most values are within this range. Since it changes with the market conditions, volatility is a good candidate to normalize the signal.

KAMA_PERIODS = 24
ATR_PERIOD = 24
ATR_MULTIPLIER = 2.5
 
@schedule(interval="1h", symbol=SYMBOL, window_size=100)
def handler(state, data):
    # calculate indicator values for signal
    kama_ma = data.kama(MA_PERIODS)[-1]
    atr = data.atr(ATR_PERIODS)[-1]
    scaled_signal = -(data.close[-1] - kama_ma[-1] ) / (atr * ATR_MULTIPLIER)

The scaled signal indicates how relatively rich or cheap the prices currently are. This information can then be used to take the appropriate position.

For the appropriate position, the scaled signal is converted into a number between 0 and 1, because it is not possible to be short for spot instruments, so the strategy can either have no position or be 100% invested.

# map signal from [-1, 1] to [0, 1]
projected_signal = (scaled_signal+1)/2   
# make sure signal is between 0 and 1
signal = min(0, max(projected_signal, 1)

Probabilities, Risk and Profits

The probability of a position being profitable is never zero or one. Many strategies can benefit from scaling their position size relative to the position's chance of being profitable, an idea that is core to position management and position staking and can turn an unprofitable strategy into a profitable one.

Often when a trend is first detected, the probability of it being profitable is still quite low, and so the commitment in terms of risk should also be low. If the trend persists and prices move in the direction of the trend, then it is more likely that the trend will continue as more trading activity confirms the trend. By adding to the position in increments, conditional on the price’s continuation, your strategy will have the largest position and the largest profits when a strong trend occurs. In the cases where a trend fails and reverses, the position as well as the losses will be small. This difference in profits between winning and losing positions can then compensate for the relatively low probability of a great trend occurring.

Mean reversion strategies, such as Bollinger-Band strategies, assume that the price will reverse back to a longer-term average when the price is at an extreme. Sometimes it does, and sometimes, if the momentum is strong, the price may continue. The price reversal usually takes time, and traders build into positions slowly while trying to assess whether momentum is slowing. In the case when a price reversal fails, momentum remains strong and the price does not mean revert. These price reversal failures are normally quick and the strategy will not have time to build a large position. Successful positions normally take more time to build into and will be larger. Consequently, profit will also be larger.

Building into positions in stages and position management are often more important to a strategy’s performance than the underlying indicators, which can give a bias towards one outcome or another. Still, since no indicator has 100% accuracy, it is better to design a strategy that takes risk relative to the level of confidence in a position.

Position Management

Our strategy takes a position directly proportional to the trading signal. When the price is expensive, the strategy should be selling to take profit; when the price is cheap, the strategy should be buying to take advantage of the discounted prices. The orders sent at each time step are proportional to the changes in the signal value from one time step to another. These changes are normally small compared to the maximum position size, leading to small consistent profit-taking.

This position management strategy leads to many small trades as the position is constantly re-adjusted, making the strategy less sensitive to the estimate for the future mean price being correct. The technique has a lot of advantages to common mean reversion strategies such as Bollinger Bands since they can have large losses if the price fails to cross one of the bands.

The key expectation of the case study strategy is that the total distance that the price takes over time is longer than the difference between the start price and the end price. In other words, the expectation is that the market noise will exceed the price trend.

 SPREAD = 0.025  # 2.5%
 
   # volatility adjustment is the current volatility relative to historic volatility
   vol = max(0.25, atr[-1]/np.mean(atr.to_numpy()))
 
   # compute target portfolio percent allocation based on signal
   long_target_alloc = (signal + SPREAD) * RISK_FACTOR / vol
   short_target_alloc = (signal - SPREAD) * RISK_FACTOR / vol
 
   # make sure the target is between 0 and 1
   long_target_alloc = clamp(long_target_alloc, 0, 1)
   short_target_alloc = clamp(short_target_alloc, 0, 1)
 
   # exit position if the regime filter is not 1
   if state.regime_filter != 1:
       long_target_alloc, short_target_alloc = 0.0, 0.0
 
   # calculate the trade sizes needed to achieve the desired portfolio position
   pos_value = 0.0 if position is None else float(position.position_value)
   buy_trade_size = max(0, (long_target_alloc * portfolio_value) - pos_value)
   sell_trade_size = min(0, (short_target_alloc * portfolio_value) - pos_value)

Applying a spread makes the target buy amount slightly less than the target sell amount, which is a useful way to control the number of your trades and reduce your transaction costs by making the target position less sensitive to small changes in the underlying signal.

Once the required buy trade size and sell trade size have been calculated, you can send orders.

MIN_TRADE_SIZE = 25 # USDT
if buy_trade_size > MIN_TRADE_SIZE:
    log(f"buying  {buy_trade_size:+.2f} USDT", severity=2)
    adjust_position(symbol=data.symbol, weight=long_target_alloc)
elif sell_trade_size < -MIN_TRADE_SIZE_PERCENT * portfolio_value :
    log(f"selling {sell_trade_size:+.2f} USDT", severity=2)
    adjust_position(symbol=data.symbol, weight=short_target_alloc)

The Trality adjust_position function takes care of sending market orders. While it is possible to use market or limit orders to adjust the position, this would require more work, as the correct order size would have to be determined relative to the current position. The adjust_position does all of this for the bot in one simple function call.

Regime Filter

Mean reversion strategies have the best risk-adjusted returns when the level of market noise is high. Long-only mean reversion strategies often struggle in bear markets, especially if there is a price crash. The regime filter is designed to filter for the most optimal times to trade.

The mean reversion strategy in the case study uses a regime filter that does not take positions when the average true range (ATR) is larger than the price standard deviation. This filter tries to detect times when the market is in a period of price compression, with price compression referring to times when there is a lot of trading activity but low volatility. During these periods, the bulls and bears are fighting to decide the market direction, but neither group is in control.

Periods of compression are often followed by breakouts and high volatility, which is not usually suitable for reversion strategies. During periods of price compression, the future volatility can be underestimated, leading to the strategy taking too much risk. The regime filter also avoids trading when there is a long-term downtrend. The long-term trend signal compares the current price to the exponential moving average price, with a downtrend being when the close price is below the average.

REGIME_VOL_PERIOD=24*5
REGIME_EMA_PERIOD=24

# Check if the regime is likely to result in profits
   regime_atr = data.atr(REGIME_VOL_PERIOD)[-1]
   regime_stddev = data.stddev(REGIME_VOL_PERIOD)[-1]
   regime_ema =  data.ema(REGIME_EMA_PERIOD)[-1]
   if regime_atr < regime_stddev :
       state.regime_filter = 1
   elif data.close[-1] < regime_ema:
       state.regime_filter = -1

Mean Reversion Strategy Backtest Results

Below are backtest results for the Binance symbol DOCKUSDT from 21 September 2021 to 16 November 2021. Since it has a higher noise to trend ratio than other cryptocurrencies, DOCKUSDT is a good candidate for mean reversion strategies.

The strategy’s performance can be seen from the light blue line on the return chart. During periods of sideways or upwards movements, the strategy’s returns are smooth. There can be drawdown periods, which are always when the market moves down further than the historical volatility would suggest.

The period around 8 October, when the trading PnL does not change, is due to the regime filter making the strategy avoid trading. The number of trades is relatively high, with 1120 total trades. Since the strategy constantly readjusts its position relative to the trading signal, the trading fees are manageable because each trade tends to be small. Over the period, the strategy performs better (36.09%) than a buy and hold strategy (-13.13%).

Backtesting results

Backtesting results

When analyzing backtest results, it is essential to consider a practical question vis-à-vis paper trading or live trading the strategy. When paper or live trading, we need to know whether the strategy is working as expected and our expectations are based on the performance of the backtest.

Suppose a strategy has smooth, predictable profits. In this case, it is easier to decide whether it is working as expected and will require a smaller drawdown before stopping the strategy for re-evaluation.

If the profits usually are +0.1% per day plus or minus 1%, then having a few days in a row with -3% losses indicates that something is likely wrong. It is much harder to determine if a strategy with high PnL volatility is working as expected during live trading. The higher volatility means less certainty of a loss indicating something is wrong. In turn, the strategy will have more significant drawdowns before the trader decides whether or not the strategy has comparable performance in paper or live trading as it does in backtest.

Mean Reversion Strategy Code

'''
This bot is a mean reversion strategy that buys and sells based on the relative value.
The cheaper the price the more is bought, and the more expensive the price the more is sold.
 
The fair price uses a kaufman adaptive moving average and the volatility is determined via the average true range.
'''
 
import numpy as np
 
SYMBOL = "DOCKUSDT"
 
# parameters for regime filter
REGIME_VOL_PERIOD=24
 
# parameter for signal
ATR_PERIODS = 24
ATR_MULTIPLIER = 2.5
KAMA_PERIODS = 24
 
# trading parameters
SPREAD = 0.05
MIN_TRADE_SIZE = 50
RISK_FACTOR = 1.0
 
def clamp(x: float, lo=0, hi=1):
   '''
   clamp a value to be within two bounds
   returns lo if x < lo, hi if x > hi else x
   '''
   return min(hi, max(x, lo))
 
'''
set up the strategy state object
'''
def initialize(state):
   state.regime_filter = 0
 
'''
set up a handler to process each bar update
'''
@schedule(interval="1h", symbol=SYMBOL, window_size=200)
def handler(state, data):
   position = query_open_position_by_symbol(symbol=data.symbol, include_dust=False)
  
   # Check if the regime is likely to result in profits
   regime_atr = data.atr(REGIME_VOL_PERIOD)[-1]
   regime_stddev = data.stddev(REGIME_VOL_PERIOD)[-1]
   if regime_atr < regime_stddev :
       state.regime_filter = 1
   elif position is not None and position.unrealized_pnl < 0:
       state.regime_filter = -1
 
   plot_line("regime_filter", state.regime_filter, data.symbol)
 
   position = query_open_position_by_symbol(symbol=data.symbol, include_dust=False)
   plot_line("pos", 0.0 if position is None else float(position.position_value), symbol=data.symbol)
 
   kama_ma = data.kama(24)
   atr = data.atr(ATR_PERIODS)
  
   vol = max(0.25, atr[-1]/np.mean(atr.to_numpy()))
   plot_line("vol", vol, data.symbol)
 
   # sanity check
   if data is None or atr is None or kama_ma is None:
       return
  
   # plot our signals
   with PlotScope.root(data.symbol):
       plot_line("kama", kama_ma[-1])
       plot_line("kama_ubnd", kama_ma[-1]+ATR_MULTIPLIER * atr[-1])
       plot_line("kama_lbnd", kama_ma[-1]-ATR_MULTIPLIER * atr[-1])
 
   portfolio_value = float(query_portfolio_value())
  
   # compute the desired position based on the richness/cheapness
   scaled_signal = -(data.close[-1] - kama_ma[-1] ) / (atr[-1] * ATR_MULTIPLIER)
   projected_signal = (scaled_signal+1) /  2   # map from [-1,1] to [0, 1]
   signal = clamp(projected_signal, lo=0, hi=1)
  
   # compute target portfolio percent allocation based on signal
   long_target_alloc = (signal + SPREAD) * RISK_FACTOR / vol
   short_target_alloc = (signal - SPREAD) * RISK_FACTOR / vol
 
   long_target_alloc = clamp(long_target_alloc, 0, 1)
   short_target_alloc = clamp(short_target_alloc, 0, 1)
 
   if state.regime_filter != 1:
       long_target_alloc, short_target_alloc = 0.0, 0.0
 
   # calculate the trade sizes needed to achieve the desired portfolio position
   #risk_allocation = portfolio_value * RISK_FACTOR# / vol
   pos_value = 0.0 if position is None else float(position.position_value)
   buy_trade_size = max(0, (long_target_alloc * portfolio_value) - pos_value)
   sell_trade_size = min(0, (short_target_alloc * portfolio_value) - pos_value)
 
   # if the positions are too far from the desired position
   # then send orders to correct the difference
   if buy_trade_size > MIN_TRADE_SIZE:
       print(f"portfolio value {portfolio_value:.2f} buying  {buy_trade_size:+.2f} USDT" )
       adjust_position(symbol=data.symbol,weight=long_target_alloc)
   elif sell_trade_size < -MIN_TRADE_SIZE:
       print(f"portfolio value {portfolio_value:.2f} selling {sell_trade_size:+.2f} USDT" )
       adjust_position(symbol=data.symbol,weight=short_target_alloc)

Conclusion

There are many possible strategy components to choose from and even more ways to combine them. Knowing where to start is often a daunting process. By providing a concrete example upon which you can build for your own purposes, this article aims to reduce the complexity of the trading strategy development process.

The case study demonstrates how indicators, regime filters and position management can be composed into a mean reversion trading strategy. Depending on your needs and skillset, the information here can be used either as-is or various components can be selected and tweaked to improve existing strategies (or to create your own bespoke one).

As you become more comfortable, you can even begin to improve the case study’s code, an excellent “hands on” exercise of implementing various modifications and examining their effect(s) on the strategy’s performance.

Here are some suggested improvements that you might want to explore:

  • Using a different model for calculating mid price;

  • Alternative volatility indicators;

  • Parameter tuning to find the best Sharpe or ROMADD;

  • Use limit orders rather than market orders;

  • Alternative regime filters to avoid market crashes.

As you can see, one of the great things about trading is that it is impossible to run out of ideas to try. With the example, ideas, and suggestions for further improvement covered in this article, we’re confident that you have a wealth of material, insights, and tips to continue to explore designing and implementing your own strategy development process.