The stochastic RSI (StochRSI) is regularly used to spot areas where a security’s price has extended too far one way or the other. This makes it ripe for use in a mean-reversion strategy where you buy low and sell high or short it if it get’s too high with the hope the price drops.

Like the RSI — which it is derived from — it can also be used to spot momentum trends. The indicator is an oscillator which moves from 0–100 with 50 being the centerline. Following this interpretation, we can use moves above 50 to spot upward momentum and moves below 50 to trade the short side.

We’ll walk through both of these approaches with backtests in Python for you to replicate on your own. If you’re not familiar with the StochRSI, you can find all the details on calculating the indicator here with clear explanations, the math, and code examples.

Testing a Mean-Reverting Stochastic RSI Strategy

The most common StochRSI strategy is based on mean-reversion. Like the RSI, the StochRSI often uses 80 to indicate overbought levels to short, and 20 to indicate oversold levels to buy. Additionally, a 14 day lookback and smoothing period is common. For our purposes here, we’ll stick with these standard values.

Now, getting to the code, let’s import a few standard packages in Python.

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import yfinance as yf

Next, we’re going to build a function to calculate our indicator (we’re going to skip the discussion, you can find all the details here). We’ll call it calcStochRSI() and it will rely on a few functions to calculate the RSI and the stochastic oscillator to get our indicator of choice.

def calcRSI(data, P=14):
# Calculate gains and losses
data['diff_close'] = data['Close'] - data['Close'].shift(1)
data['gain'] = np.where(data['diff_close']>0,
data['diff_close'], 0)
data['loss'] = np.where(data['diff_close']<0,
np.abs(data['diff_close']), 0)

# Get initial values
data[['init_avg_gain', 'init_avg_loss']] = data[
['gain', 'loss']].rolling(P).mean()
# Calculate smoothed avg gains and losses for all t > P
avg_gain = np.zeros(len(data))
avg_loss = np.zeros(len(data))

for i, _row in enumerate(data.iterrows()):
row = _row[1]
if i < P - 1:
last_row = row.copy()
continue
elif i == P-1:
avg_gain[i] += row['init_avg_gain']
avg_loss[i] += row['init_avg_loss']
else:
avg_gain[i] += ((P - 1) * avg_gain[i-1] + row['gain']) / P
avg_loss[i] += ((P - 1) * avg_loss[i-1] + row['loss']) / P

last_row = row.copy()

data['avg_gain'] = avg_gain
data['avg_loss'] = avg_loss
# Calculate RS and RSI
data['RS'] = data['avg_gain'] / data['avg_loss']
data['RSI'] = 100 - 100 / (1 + data['RS'])
return data

def calcStochOscillator(data, N=14):
data['low_N'] = data['RSI'].rolling(N).min()
data['high_N'] = data['RSI'].rolling(N).max()
data['StochRSI'] = 100 * (data['RSI'] - data['low_N']) /
(data['high_N'] - data['low_N'])
return data

def calcStochRSI(data, P=14, N=14):
data = calcRSI(data, P)
data = calcStochOscillator(data, N)
return data

def calcReturns(df):
# Helper function to avoid repeating too much code
df['returns'] = df['Close'] / df['Close'].shift(1)
df['log_returns'] = np.log(df['returns'])
df['strat_returns'] = df['position'].shift(1) * df['returns']
df['strat_log_returns'] = df['position'].shift(1) *
df['log_returns']
df['cum_returns'] = np.exp(df['log_returns'].cumsum()) - 1
df['strat_cum_returns'] = np.exp(df['strat_log_returns'].cumsum())
/ - 1
df['peak'] = df['cum_returns'].cummax()
df['strat_peak'] = df['strat_cum_returns'].cummax()
return df

With these functions we just need to build the logic for our strategies and we’re set. Notice too that we have a helper function called calcReturns which we can quickly apply to the result of our backtest to get all of our return values from.

This mean reversion model is going to short or sell (depending on your preference) when the StochRSI moves above 80, and buy when it’s below 20.

def StochRSIReversionStrategy(data, P=14, N=14, short_level=80,
'''
Buys when the StochRSI is oversold and sells when it's
overbought
'''
df = calcStochRSI(data, P, N)
df['position'] = np.nan
df['position'])
if shorts:
df['position'] = np.where(df['StochRSI']>short_level, -1,
df['position'])
else:
df['position'] = np.where(df['StochRSI']>short_level, 0,
df['position'])

df['position'] = df['position'].ffill().fillna(0)
return calcReturns(df)

We need some data to run our model. We’ll go to the list of companies in the S&P 500, randomly sample one of them, get our data using the yfinance library, then run it through our model.

table = pd.read_html(
'https://en.wikipedia.org/wiki/List_of_S%26P_500_companies')
df = table[0]
syms = df['Symbol']

# Sample symbols
ticker = np.random.choice(syms.values)
print(f"Ticker Symbol: {ticker}")
start = '2000-01-01'
end = '2020-12-31'

# Get Data
yfObj = yf.Ticker(ticker)
data = yfObj.history(start=start, end=end)
data.drop(['Open', 'High', 'Low', 'Volume', 'Dividends',
'Stock Splits'], inplace=True, axis=1)

# Run test
df_rev = StochRSIReversionStrategy(data.copy())

# Plot results
colors = plt.rcParams['axes.prop_cycle'].by_key()['color']
fig, ax = plt.subplots(2, figsize=(12, 8))

ax[0].plot(df_rev['strat_cum_returns']*100, label='Mean Reversion')
ax[0].set_ylabel('Returns (%)')
ax[0].set_title('Cumulative Returns for Mean Reversion and' +
f' Buy and Hold Strategies for {ticker}')
ax[0].legend(bbox_to_anchor=[1, 0.6])

ax[1].plot(df_rev['StochRSI'], label='StochRSI', linewidth=0.5)
ax[1].plot(df_rev['RSI'], label='RSI', linewidth=1)
ax[1].axhline(80, label='Over Bought', color=colors[1], linestyle=':')
ax[1].axhline(20, label='Over Sold', color=colors[2], linestyle=':')
ax[1].axhline(50, label='Centerline', color='k', linestyle=':')
ax[1].set_ylabel('Stochastic RSI')
ax[1].set_xlabel('Date')
ax[1].set_title(f'Stochastic RSI for {ticker}')
ax[1].legend(bbox_to_anchor=[1, 0.75])

plt.tight_layout()
plt.show()

The mean reversion strategy crushes the buy and hold strategy for Boston Scientific outperforming it with a 28X return versus 2X over the 21-year period we examined.

In the second plot, we show the StochRSI and our key levels. I also added the RSI for a comparison to the StochRSI which is much more volatile. This leads to frequent trading, which could severly impact your actual returns if you have a small account with relatively high transaction costs. We’re just running this on a single instrument, so we wind up with 443 trades, or trading every 12 days, which doesn’t seem like that much. However, if we were to manage a proper portfolio of instruments with this indicator and trade this frequently, we could be moving in and out of multiple trades per day and transaction costs become significant.

Of course, this all dependent on your actual system and broker, so it may not be a major concern in your specific case.

# Get trades
diff = df_rev['position'].diff().dropna()

fig, ax = plt.subplots(1, figsize=(12, 8))
ax.plot(df_rev['Close'], linewidth=1, label=f'{ticker}')
ax.set_ylabel('Price')
ax.set_title(f'{ticker} Price Chart and Trades for' +
'StochRSI Mean Reversion Strategy')
ax.legend()
plt.show()

To look at some of the key metrics for the overall strategy, let’s look use the following getStratStats function.

def getStratStats(log_returns: pd.Series,
risk_free_rate: float = 0.02):
stats = {}

# Total Returns
stats['tot_returns'] = np.exp(log_returns.sum()) - 1

# Mean Annual Returns
stats['annual_returns'] = np.exp(log_returns.mean() * 252) - 1

# Annual Volatility
stats['annual_volatility'] = log_returns.std() * np.sqrt(252)

# Sortino Ratio
annualized_downside = log_returns.loc[log_returns<0].std() *
np.sqrt(252)
stats['sortino_ratio'] = (stats['annual_returns'] -
risk_free_rate) / annualized_downside

# Sharpe Ratio
stats['sharpe_ratio'] = (stats['annual_returns'] -
risk_free_rate) / stats['annual_volatility']

# Max Drawdown
cum_returns = log_returns.cumsum() - 1
peak = cum_returns.cummax()
drawdown = peak - cum_returns
stats['max_drawdown'] = drawdown.max()

# Max Drawdown Duration
strat_dd = drawdown[drawdown==0]
strat_dd_diff = strat_dd.index[1:] - strat_dd.index[:-1]
strat_dd_days = strat_dd_diff.map(lambda x: x.days).values
strat_dd_days = np.hstack([strat_dd_days,
(drawdown.index[-1] - strat_dd.index[-1]).days])
stats['max_drawdown_duration'] = strat_dd_days.max()

return stats

Applying it to our strategy and the buy-and-hold baseline:

rev_stats = getStratStats(df_rev['strat_log_returns'])
bh_stats = getStratStats(df_rev['log_returns'])

pd.concat([
pd.DataFrame(rev_stats, index=['Mean Reversion']),
pd.DataFrame(bh_stats, index=['Buy and Hold'])])

Here we see that 28X return on this strategy with roughly the same annual volatility of the underlying. Additionally, we have much, much better risk adjusted returns as measured by the Sortino and Sharpe Ratios.

We do see one of the potential issues of mean reversion strategies in the COVID crash of 2020. The total returns of the strategy were dramatically cut because the strategy was positioned for a reversion upwards, but the market continued to tank and the model just held on. It recovered some of this, but never approached its pre-COVID highs in this test. Proper use of stops can help limit these large losses and potentially increase overall returns.

## Stochastic RSI and Momentum

The other base strategy we had mentioned before is using StochRSI as a momentum indicator. When the indicator crosses the centerline (StochRSI=50) we either buy or short the stock based on its direction.

def StochRSIMomentumStrategy(data, P=14, N=14,
centerline=50, shorts=True):
'''
Buys when the StochRSI moves above the centerline,
sells when it moves below
'''
df = calcStochRSI(data, P, N)
df['position'] = np.nan
df['position'] = np.where(df['StochRSI']>50, 1, df['position'])
if shorts:
df['position'] = np.where(df['StochRSI']<50, -1, df['position'])
else:
df['position'] = np.where(df['StochRSI']<50, 0, df['position'])
df['position'] = df['position'].ffill().fillna(0)

return calcReturns(df)

Running our backtest:

# Run test
df_mom = StochRSIMomentumStrategy(data.copy())

# Plot results
colors = plt.rcParams['axes.prop_cycle'].by_key()['color']
fig, ax = plt.subplots(2, figsize=(12, 8))

ax[0].plot(df_mom['strat_cum_returns']*100, label='Momentum')
ax[0].set_ylabel('Returns (%)')
ax[0].set_title('Cumulative Returns for Momentum and' +
f' Buy and Hold Strategies for {ticker}')
ax[0].legend(bbox_to_anchor=[1, 0.6])

ax[1].plot(df_mom['StochRSI'], label='StochRSI', linewidth=0.5)
ax[1].plot(df_mom['RSI'], label='RSI', linewidth=1)
ax[1].axhline(50, label='Centerline', color='k', linestyle=':')
ax[1].set_ylabel('Stochastic RSI')
ax[1].set_xlabel('Date')
ax[1].set_title(f'Stochastic RSI for {ticker}')
ax[1].legend(bbox_to_anchor=[1, 0.75])
plt.tight_layout()
plt.show()

In this case, our momentum strategy performed quite poorly, losing almost all of our initial investment over our hypothetical time period.

Looking at our strategy’s stats, this model isn’t offering anything beneficial.

mom_stats = getStratStats(df_mom['strat_log_returns'])

pd.concat([
pd.DataFrame(mom_stats, index=['Momentum']),
pd.DataFrame(rev_stats, index=['Mean Reversion']),
pd.DataFrame(bh_stats, index=['Buy and Hold'])])

This doesn’t mean that the Stochastic RSI is not suited for this type of application. It might work better with additional indicators or filters that could combine to improve our results. Or, perhaps tuning the parameters a bit will give you some better results.