Introduction
Momentum trading is an interesting approach to short-term trading. It involves a “strategy to capitalize on the continuance of an existing market trend…(by)…going long stocks, futures, or market ETFs showing upward-trending prices and short the respective assets with downward-trending prices” (Investopedia). Two classic indicators of momentum are the Relative Strength Index (RSI) and the Stochastic Oscillator (STO). In this post, I evaluate the effectiveness of simple RSI and STO trading strategies.
Meet the Indicators
Relative Strength Index (RSI)
The RSI is a momentum oscillator that measures the speed and magnitude of price movements (StockCharts). A nice feature about the RSI is that it is bound between 0 and 100. This enables us to set fixed thresholds to indicate when to buy a stock (because it’s oversold) and when to sell a stock (because it’s overbought). The RSI has three parameters:
- Lookback Period: This the number of days in the past to calculate gains and losses. The traditional setting is 14 periods.
- Oversold Threshold: This indicates the level at which a stock is considered too low, possibly due to overreaction. In theory, oversold stocks are poised for an upward rebound. The traditional setting is 30.
- Overbought Threshold: This indicates the level at which a stock is considered excessively high. The traditional setting is 70.
Stochastic Oscillator (STO)
The STO is also a momentum oscillator, and it measures the relative position of the current closing price within the trading range of the past n days. For example, if the trading range in the past 14 days was $10 to $1000 and the current price is $700, then the STO would give a reading of about 70 (out of 100). The parameters are the same as the RSI, except that the traditional oversold threshold is 20 and the traditional overbought threshold is 80. Refer to StockCharts for a more detailed description of the STO.
Trading Simulation
Next, we run trading simulations to evaluate the effectiveness of the RSI and STO in delivering positive excess returns.
Evaluation Metrics
I use two metrics to evaluate the strategies:
- Annualised Returns: In my first post, I computed the overall returns from MACD-based trading strategies, which resulted in huge variance due to the different time periods used for the various stocks. Thus, in this post, I annualise returns from the RSI/STO trading strategies and the buy-and-hold benchmark.
- Percentage of Profitable Trades: This is simply the Precision metric from my first post, but in simpler terms. It measures the proportion of all trades executed by the RSI/STO strategies that were profitable.
Parameters
I tested the following parameters for both the RSI and STO:
- Lookback Period: 5, 10, 15, and 20
- Oversold Threshold: 10, 20, and 30
- Overbought Threshold: 70, 80, and 90
This amounted to 30 configurations per indicator (RSI/STO).
Data
We used 493 stocks from the S&P 500 to test the trading strategies. In total, approximately 35,500 simulations were run.
# Import required modules
import fix_yahoo_finance as yf
import matplotlib as mpl
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from pandas_datareader import data as pdr
from sklearn.linear_model import LinearRegression
import warnings
from yahoo_finance import Share
# Settings
warnings.filterwarnings('ignore')
# Override pdr
yf.pdr_override()
# Import stocklist
sp500 = pd.read_csv('sp500.csv')
Helper Functions
# Function for simple moving average
def sma(x, n = 200):
# Copy data
df = x.copy()
# Calculate rolling mean
temp_sma = pd.rolling_mean(df.Close, n)
# Output
return temp_sma
# Function for RSI
def rsi(x, n = 14):
# Copy data
df = x.copy()
# Calculate difference
df['delta'] = df.Close.diff()
# Calculate gains and losses
gains = df.delta.copy()
gains[gains < 0] = 0
losses = df.delta.copy()
losses[losses > 0] = 0
# Calculate rolling n-day average of gains and losses
avg_gains = pd.rolling_mean(gains, n)
avg_losses = pd.rolling_mean(losses, n).abs()
# Calculate relative strength
rs = avg_gains / avg_losses
# Calculate RSI
rsi = 100.0 - (100.0 / (1.0 + rs))
# Output
return(rsi)
# Function for Stochastic Oscillator
def stoch(x, n = 14):
# Copy data
df = x.copy()
# Calculate %K
stoch_k = ((df.Close - pd.rolling_min(df.Low, n)) / (pd.rolling_max(df.High, n) - pd.rolling_min(df.Low, n))) * 100
# Output
return stoch_k
# Function for trading simulations
def sim_trade(close, buy_signal, sell_signal, sma_filter, indicator, bah, verbose = False):
# Initialise variables
BUY_STATE = 0
BUY_PRICE = 0
PORTFOLIO_VALUE = 100
TRADES = 0
PROFITABLE_TRADES = 0
RETURNS = []
# Loop through
for i in np.arange(len(close)):
# Check if stock is trading above SMA-200
if sma_filter.iloc[i]:
# Check buy state
if BUY_STATE == 0:
# If buy signal triggered
if buy_signal.iloc[i] == 1:
# Update buy state
BUY_STATE = 1
# Save price
BUY_PRICE = close.iloc[i].copy()
else:
# If sell signal triggered
if sell_signal.iloc[i] == 1:
# Update buy state
BUY_STATE = 0
# Calculate returns
temp_returns = close.iloc[i].copy() / BUY_PRICE
# Update portfolio value
PORTFOLIO_VALUE = PORTFOLIO_VALUE * temp_returns
# Append returns
RETURNS.append(temp_returns - 1)
# Count trades
TRADES += 1
# Count profitable trades
if close.iloc[i].copy() / BUY_PRICE > 1:
PROFITABLE_TRADES += 1
# Compute days
DAYS = len(close)
# Compute annualised returns
ANN_BAH = (1 + bah / 100) ** (250 / DAYS) - 1
ANN_IND = (PORTFOLIO_VALUE / 100) ** (250 / DAYS) - 1
# Print
if verbose:
print(indicator + ' Returns (Total): ' + '{0:.2f}%'.format(PORTFOLIO_VALUE - 100))
print(indicator + ' Excess Returns (Total): ' + '{0:.2f}%'.format(PORTFOLIO_VALUE - 100 - bah))
print(indicator + ' Returns (Annualised): ' + '{0:.2f}%'.format(ANN_IND * 100))
print(indicator + ' Excess Returns (Annualised): ' + '{0:.2f}%'.format(ANN_IND * 100 - ANN_BAH * 100))
print(indicator + ' Mean Returns: ' + '{0:.2f}%'.format(np.mean(RETURNS) * 100))
print(indicator + ' SD Returns: ' + '{0:.2f}%'.format(np.std(RETURNS) * 100))
print(indicator + ' Trades: ' + str(TRADES))
print(indicator + ' % Profitable: ' + '{0:.2f}%'.format(PROFITABLE_TRADES / TRADES * 100))
print()
# Output
return [PORTFOLIO_VALUE - 100, PORTFOLIO_VALUE - 100 - bah, np.mean(RETURNS) * 100, np.std(RETURNS) * 100,
TRADES, PROFITABLE_TRADES, DAYS, ANN_IND * 100, ANN_BAH * 100, bah]
# Function to test configurations
def sim_config(stock, all_n = [5, 10, 15, 20], lower = [10, 20, 30], upper = [70, 80, 90]):
# Initialise list
output = []
# Fix start date and end date
start_date = '1979-01-01'
end_date = '2018-06-01'
# Pull data
orig_df = pdr.get_data_yahoo(stock, start_date, end_date, progress=False)
# Compute SMA-200
orig_df['sma200'] = sma(orig_df)
# FILTER 1: STOCK IS TRADING ABOVE SMA-200
orig_df['f1'] = orig_df.Close > orig_df.sma200
# ---- RSI SIMULATIONS ---- #
# print('Simulating RSI...')
for rn in all_n:
for rl in lower:
for ru in upper:
# Copy data
temp_df = orig_df.copy()
# Compute RSI
temp_df['rsi'] = rsi(temp_df, n = rn)
# Trading signals
temp_df['rsi_buy'] = ((temp_df.rsi.shift(1) < rl) & (temp_df.rsi > rl)).astype(int)
temp_df['rsi_sell'] = ((temp_df.rsi.shift(1) < ru) & (temp_df.rsi > ru)).astype(int)
# Drop missing values
temp_df.dropna(axis = 0, inplace = True)
# Compute buy and hold returns
bah_returns = (temp_df.Close.iloc[-1] / temp_df.Close.iloc[0] - 1) * 100
# Simulate trade
temp_results = sim_trade(temp_df.Close, temp_df.rsi_buy, temp_df.rsi_sell, temp_df.f1, 'RSI', bah_returns)
# Append stock, settings, and simulation results
output.append(
tuple(
[stock, 'rsi', rn, rl, ru] + temp_results
)
)
# ---- STO SIMULATIONS ---- #
# print('Simulating STO...')
for sn in all_n:
for sl in lower:
for su in upper:
# Copy data
temp_df = orig_df.copy()
# Compute RSI
temp_df['sto'] = stoch(temp_df, n = sn)
# Trading signals
temp_df['sto_buy'] = ((temp_df.sto.shift(1) < sl) & (temp_df.sto > sl)).astype(int)
temp_df['sto_sell'] = ((temp_df.sto.shift(1) < su) & (temp_df.sto > su)).astype(int)
# Drop missing values
temp_df.dropna(axis = 0, inplace = True)
# Compute buy and hold returns
bah_returns = (temp_df.Close.iloc[-1] / temp_df.Close.iloc[0] - 1) * 100
# Simulate trade
temp_results = sim_trade(temp_df.Close, temp_df.sto_buy, temp_df.sto_sell, temp_df.f1, 'STO', bah_returns)
# Append stock, settings, and simulation results
output.append(
tuple(
[stock, 'sto', sn, sl, su] + temp_results
)
)
# Output
# print('Done!')
return output
Run Trade Simulations
# Initialise data frame for storage
rsi_stoch_df = pd.DataFrame()
# rsi_stoch_df = pd.read_csv('rsi_stoch_results.csv')
# Collect data on all S&P 500 companies
for i in np.arange(495, len(sp500.Symbol)):
# Get symbol
stk = sp500.Symbol.iloc[i]
# Update
print('Processing [' + str(i) + '] ' + stk + '...', end = '', flush = True)
# Simulate trades and append results
temp_res = sim_config(stk)
# Convert to df
temp_res_df = pd.DataFrame(temp_res, columns = ['stock', 'indicator', 'n', 'lower', 'upper', 'returns', 'exc_returns',
'mean_returns', 'sd_returns', 'trades', 'prof_trades', 'days', 'ann_returns', 'ann_bah',
'bah'])
# Append to data frame
rsi_stoch_df = pd.concat([rsi_stoch_df, temp_res_df], axis = 0)
# Save data
rsi_stoch_df.to_csv('rsi_stoch_results.csv', index = False)
# Calculate excess returns
temp_print_df = temp_res_df[['stock', 'indicator', 'ann_returns', 'ann_bah']].copy()
temp_print_df['ann_exc'] = temp_print_df.ann_returns - temp_print_df.ann_bah
# Print
print('RSI | STO Excess Returns: ' + '{0:.2f}%'.format(temp_print_df.ann_exc[temp_print_df.indicator == 'rsi'].mean()) + \
' | ' + '{0:.2f}%'.format(temp_print_df.ann_exc[temp_print_df.indicator == 'sto'].mean()))
Analysis of Results
In this section, we review the excess returns and percentage of profitable trades from the simulations.
# Load data
rsi_stoch_df = pd.read_csv('rsi_stoch_results.csv')
# Compute annualised excess returns
rsi_stoch_df['ann_exc'] = rsi_stoch_df.ann_returns - rsi_stoch_df.ann_bah
# Compute profitable trades
rsi_stoch_df['profitable_pct'] = rsi_stoch_df.prof_trades / rsi_stoch_df.trades * 100
Which Performed Better: The RSI or the STO?
The STO exhibited slightly better performance than the RSI. Approximately 11.4% of all STO strategies delivered positive excess returns, compared to 8.6% for RSI strategies. However, both indicators failed to deliver positive excess returns on average. An interesting thing to note is that although the STO generated more buy signals, the proportion of trades that were actually profitable as predicted by the STO was comparable to that of the RSI.
# Summarise and rank by annualised excess returns
rsi_stoch_df[['indicator', 'ann_exc', 'days', 'trades', 'profitable_pct']].groupby(['indicator']).mean().sort_values(by = 'ann_exc', ascending = False)
ann_exc | days | trades | profitable_pct | |
---|---|---|---|---|
indicator | ||||
sto | -6.017121 | 6830.234280 | 136.760367 | 75.054938 |
rsi | -7.609239 | 6826.818458 | 47.954868 | 78.381752 |
Which Setting Performed Best?
Lookback Period (n)
In general, there were minor advantages from using a shorter lookback period. However, once again, all lookback period settings failed to generate positive excess returns on average.
# Summarise and rank by annualised excess returns
round(rsi_stoch_df[['n', 'indicator', 'ann_exc', 'days', 'trades', 'profitable_pct']].groupby(['n']).mean().sort_values(by = 'ann_exc', ascending = False), 2)
ann_exc | days | trades | profitable_pct | |
---|---|---|---|---|
n | ||||
5 | -5.31 | 6825.26 | 181.13 | 73.17 |
10 | -6.55 | 6829.04 | 88.43 | 76.15 |
15 | -7.29 | 6829.75 | 57.40 | 78.45 |
20 | -8.11 | 6830.05 | 42.47 | 79.26 |
Thresholds
Like the lookback periods and indicators, average excess returns were negative for all combinations of thresholds (for oversold or overbought signals).
# Create ID for thresholds
rsi_stoch_df['thresh'] = rsi_stoch_df.lower.astype(str) + '-' + rsi_stoch_df.upper.astype(str)
# Summarise and rank by annualised excess returns
round(rsi_stoch_df[['thresh', 'indicator', 'ann_exc', 'days', 'trades', 'profitable_pct']].groupby(['thresh', 'indicator']).mean().sort_values(by = 'ann_exc', ascending = False).head(10), 2)
ann_exc | days | trades | profitable_pct | ||
---|---|---|---|---|---|
thresh | indicator | ||||
30-90 | rsi | -3.00 | 6826.82 | 47.03 | 80.96 |
20-90 | sto | -4.96 | 6830.23 | 127.87 | 75.29 |
30-90 | sto | -5.01 | 6830.23 | 146.06 | 75.77 |
30-80 | rsi | -5.21 | 6826.82 | 73.17 | 77.64 |
10-90 | sto | -5.44 | 6830.23 | 99.93 | 75.63 |
20-90 | rsi | -5.89 | 6826.82 | 37.83 | 80.51 |
20-80 | sto | -5.99 | 6830.23 | 142.69 | 75.19 |
30-80 | sto | -6.14 | 6830.23 | 165.62 | 75.38 |
10-80 | sto | -6.31 | 6830.23 | 108.29 | 75.49 |
20-70 | sto | -6.67 | 6830.23 | 151.05 | 74.08 |
Combined Settings
When split by the combined settings (lookback period + oversold and overbought thresholds), there was no difference: no configuration could deliver positive excess returns on average. The increase in the percentage of profitable trades was inversely proportionate to the number of trades. This is what we expect: the fewer signals there are, the more spread out they are likely to be; and the longer you hold a stock, the higher the probability that that trade will be profitable.
# Create ID for combined setting
rsi_stoch_df['full_setting'] = rsi_stoch_df.n.astype(str) + '-' \
+ rsi_stoch_df.lower.astype(str) + '-' + rsi_stoch_df.upper.astype(str)
# Summarise and rank by annualised excess returns
round(rsi_stoch_df[['full_setting', 'indicator', 'ann_exc', 'days', 'trades', 'profitable_pct']].groupby(['full_setting', 'indicator']).mean().sort_values(by = 'ann_exc', ascending = False).head(10), 2)
ann_exc | days | trades | profitable_pct | ||
---|---|---|---|---|---|
full_setting | indicator | ||||
15-30-90 | rsi | -1.98 | 6829.06 | 9.70 | 83.13 |
10-30-90 | rsi | -2.76 | 6827.73 | 34.70 | 78.13 |
5-30-90 | sto | -3.15 | 6829.67 | 233.58 | 73.64 |
5-20-90 | sto | -3.23 | 6829.67 | 207.22 | 73.34 |
5-30-90 | rsi | -3.34 | 6820.85 | 140.84 | 73.55 |
20-30-90 | rsi | -3.92 | 6829.64 | 2.87 | 90.08 |
5-10-90 | sto | -3.97 | 6829.67 | 160.34 | 73.38 |
5-20-90 | rsi | -4.24 | 6820.85 | 118.02 | 73.93 |
5-20-80 | sto | -4.63 | 6829.67 | 237.98 | 73.20 |
5-30-80 | sto | -4.73 | 6829.67 | 272.34 | 72.92 |
Which Stocks Performed Best?
The stocks given in the table below were stocks on which the trading strategies beat the market. This does not mean that you can simply apply RSI and STO trading strategies to these stocks and expect a profit in the future, because (1) past relationships between the technical indicators and these stocks’ prices may not persist, and (2) there was something fundamentally wrong with all of these stocks.
First, note how all of these stocks had negative buy-and-hold returns. Second, note how there were so few trades across the simulation window for both the RSI and the STO. When we put these pieces of information together, we realise that, chances are, the RSI and STO trading strategies recommended buying these stocks when they were still climbing, selling these stocks at a profit, and making no further buy or sell recommendations. Consequently, these trading strategies achieved a small profit or loss that were larger (in absolute terms) than the buy-and-hold benchmark returns. That explains why the RSI and STO strategies looked good on these stocks.
Using this new information by considering only stocks that had positive buy-and-hold returns, we can revise our previous statistics on the percentage of RSI and STO trading strategies that delivered positive excess returns from 8.6% and 11.4% to 6.2% and 9.1% respectively.
# Summarise and rank by annualised excess returns
round(rsi_stoch_df[['stock', 'indicator', 'ann_exc', 'ann_returns', 'ann_bah', 'trades', 'days']].groupby(['stock', 'indicator']).mean().sort_values(by = 'ann_exc', ascending = False).head(10), 2)
ann_exc | ann_returns | ann_bah | trades | days | ||
---|---|---|---|---|---|---|
stock | indicator | |||||
UA | sto | 36.22 | 2.90 | -33.32 | 0.83 | 531.0 |
rsi | 34.28 | 0.96 | -33.32 | 0.31 | 531.0 | |
KHC | sto | 17.39 | 3.92 | -13.47 | 8.92 | 534.0 |
EVHC | rsi | 17.31 | -2.25 | -19.56 | 3.39 | 1009.0 |
sto | 15.99 | -3.57 | -19.56 | 10.36 | 1009.0 | |
KHC | rsi | 14.79 | 1.32 | -13.47 | 2.50 | 534.0 |
NAVI | sto | 11.11 | 0.59 | -10.51 | 10.83 | 839.0 |
rsi | 10.97 | 0.46 | -10.51 | 4.61 | 839.0 | |
COTY | rsi | 8.33 | 5.44 | -2.89 | 6.58 | 1052.0 |
JNPR | rsi | 8.18 | -0.31 | -8.49 | 24.00 | 4565.0 |
Conclusion [TLDR]
In conclusion, we found no evidence that the RSI and STO trading strategies could beat the buy-and-hold benchmark. The STO generated more buy/sell signals and performed slightly better than the RSI. However, both performed poorly on absolute terms: only 6.2% of RSI trading strategies and 9.1% of STO trading strategies for stocks with a positive buy-and-hold return generated positive excess returns. Traders will need to incorporate other technical indicators in their trading strategies to increase their chances of beating the buy-and-hold benchmark.
Click here for the full Jupyter notebook.
Credits for images: FinanceAndMarkets.com