Backtesting the Most Underrated SMA Trading Strategy which Beats the Market

And it’s definitely not the SMA 50 & 200 crossover

Nikhil Adithyan
Level Up Coding

--

Photo by Cedrik Wesche on Unsplash

In the world of algo trading, Simple Moving Average (SMA) is a widely used term and a lot of traders use this indicator to build different kinds of trading strategies. People love this indicator for its simplistic nature and its capability to show the trend/direction of the price movement.

But many don't know how to use this indicator to its full potential. The problem is, that a lot of traders are restricting themselves to a limited number of SMA strategies like the golden crossover or the SMA 50 & 200 crossover. These strategies are not bad but too generic and basic, and at most times, find themselves lost amidst the overly complicated system of the stock market.

So today, we’ll uncover one such truly great SMA trading strategy that tries to push the limits of this indicator and more importantly, to beat the market. Also, this strategy does not fall under the categories of “widely used” and “too basic”.

In this article, we’ll first discuss our trading strategy and its mechanics. After that, we’ll move to the coding part where we will first extract both historical and technical indicators data using FMP’s historical and technical data endpoints respectively, then, we’ll backtest and evaluate the trading strategy and compare the strategy returns to that of the buy/hold strategy.

With that being said, let’s dive into the article!

The Trading Strategy

Our trading strategy is going to be a crossover strategy but unlike the ones which are for long-term purposes, ours is specifically designed for intraday trading. Since we’ll be dealing with data with short timeframes, the lookback periods of our SMAs will also have smaller lengths. For our trading strategy, we’re going to use three SMAs with lookback periods of 5, 8, and 13. The following are the entry and exit rules of our trading strategy:

We enter the market if: SMA 8 crosses above SMA 13 and SMA 5 is greater than SMA 8.

We exit the market if: SMA 8 crosses below SMA 13 and SMA 5 is lesser than SMA 8.

This is a simple yet effective crossover strategy that helps in capturing the direction of the price movements and provides trading signals accordingly. Now that we have a good background of the trading strategy that we’re going to implement, let’s move on to the coding part to transform this idea into an actual useful strategy.

Importing Packages

The first and foremost step is to import all the required packages into our Python environment. In this article, we’ll be using four packages which are:

  • Pandas — for data formatting, clearing, manipulating, and other related purposes
  • Matplotlib — for creating charts and different kinds of visualizations
  • Requests — for making API calls in order to extract data
  • Termcolor — to customize the standard output shown in Jupyter notebook
  • Math — for various mathematical functions and operations

The following code imports all the above-mentioned packages into our Python environment:

# IMPORTING PACKAGES

import pandas as pd
import requests
import math
from termcolor import colored as cl
import matplotlib.pyplot as plt

plt.rcParams['figure.figsize'] = (20,10)
plt.style.use('fivethirtyeight')

If you haven’t installed any of the imported packages, make sure to do so using the pip command in your terminal.

Extracting Historical Data

Obtaining the historical data of the stock is very vital for the backtesting process. For data accuracy and reliability, we will use FinancialModelingPrep’s (FMP) historical data endpoint which allows the extraction of end-of-day/intraday data for any specific stock. We are going to backtest our crossover strategy on Meta’s stock and the following code extracts the 5-min intraday data of the same:

# EXTRACTING HISTORICAL DATA

api_key = 'YOUR API KEY'
hist_json = requests.get(f'https://financialmodelingprep.com/api/v3/historical-chart/5min/META?apikey={api_key}').json()
meta_df = pd.DataFrame(hist_json).iloc[::-1].reset_index().drop('index', axis = 1)

meta_df

The code is very simple. We are first storing the API key in the api_key variable. Make sure to replace YOUR API KEY with your secret API key which you can obtain once you create an FMP developer account.

Then, using the get function provided by the Requests package, we are making an API call to get the historical data of META. Finally, we are converting the extracted JSON response into a workable Pandas dataframe along with some data manipulation and this is the output:

META intraday data (Image by Author)

As you can see, the time interval between each data point is five minutes since this is a 5-min intraday data. The output is very clean and simple without any fancy additional data which is good in a lot of cases. The data starts from February 12 till February 23 which is the maximum timeframe for which the 5-min intraday can be extracted. Now let’s proceed to the next step of extracting the technical indicator which is SMA.

Extracting SMA values

This process is going to be very similar to that of the previous one except for one change. Instead of using the historical data endpoint, we’ll be using FMP’s technical indicators endpoint in order to extract Meta’s SMA with three different lookback periods. The following code seamlessly extracts the SMA values using the FMP’s endpoint:

# EXTRACTING SMA VALUES

for period in [5,8,13]:
meta_df[f'sma{period}'] = pd.DataFrame(requests.get(f'https://financialmodelingprep.com/api/v3/technical_indicator/5min/META?type=sma&period={period}&from=2024-02-02&apikey={api_key}').json()).iloc[::-1].reset_index().drop('index', axis = 1)['sma']

meta_df['date'] = pd.to_datetime(meta_df.date)
meta_df.tail()

Instead of pulling the SMA values with different lookback periods separately, we can make use of the for-loop to save time and unnecessary lines of code. Using the for-loop, we are extracting the SMA values with the previously discussed lookback periods and storing them in their respective columns in the historical dataframe.

We are also matching the starting date of the SMA values to that of the intraday dataframe to merge the closing prices with the SMA values. After extracting the technical indicator data, we converted the data type of the “date” column from object to datetime using the to_datetime function provided by Pandas. This is the final dataframe:

META SMA values dataframe (Image by Author)

Visualizing the Data

This is one of the most overlooked steps in the backtesting process. Creating charts and visualizations out of the extracted data helps in easily drawing insights which can be really useful for tweaking or making changes to the strategy.

The following code uses Matplotlib to create a simple chart out of our intraday closing price and SMA data:

# PLOTTING SMA VALUES

plt.plot(meta_df['close'][:100], label = 'META', linewidth = 6)
plt.plot(meta_df['sma5'][:100], label = 'SMA 5', linewidth = 3, linestyle = '--')
plt.plot(meta_df['sma8'][:100], label = 'SMA 8', linewidth = 4, alpha = 0.7)
plt.plot(meta_df['sma13'][:100], label = 'SMA 13', linewidth = 4, alpha = 0.7)
plt.title('META SMA 5,8,13')
plt.legend()

There is nothing fancy with the code. We are using the basic functions provided by Matplotlib to create a multiple-line chart. We are making some style changes to the SMA lines to differentiate its values from the closing price of META. This is the resulting chart of the above code:

META SMA Chart (Image by Author)

The chart might look pretty clumsy given there are four lines each representing different values. But let’s break it down to understand our crossover trading strategy even better.

The thick blue line in the chart is obviously, the closing price of META and we’re not going to focus on this data. Rather, we are going to shift our focus entirely on the other three lines which are the SMA lines with different lookback periods.

But before going any further, let’s analyze the “5–8–13” sequence. We could’ve gone with any other numerical sequence but we specifically chose this because 5,8, and 13 are Fibonacci numbers. Many traders use Fibonacci in their trading system as it’s believed to capture potential support and resistance levels.

Now, let’s focus on the SMA lines. The SMA 5 is the shortest-term SMA and is used for tracking short-term price movements. Following that, there are SMA 8 and SMA 13 which are considered as medium-term and long-term SMAs respectively.

The trading logic behind this sequence is that, when the SMA 5 is greater than SMA 8 and SMA 13, it indicates that the market is in an uptrend. And when the opposite occurs, the market is going towards a downtrend. Basically, the SMA 8 and 13 are the signaling factors of a potential downtrend/uptrend and the SMA 5 is the confirmation factor that validates the trend.

Many people might prefer a dual combination like SMA 5 & 8 or SMA 5 & 13 but we are going with the “5–18–13” because it helps in reducing the market noise or alerting traders with false signals which can be very lethal at times. So we are going with this triple combination to add more layers and complexity to our trading strategy.

Backtesting the Crossover Trading Strategy

We have arrived at one of the most important and interesting steps in this article. Now that we know the ins and outs of our trading strategy let’s build it and backtest it in Python. We are going to follow a very basic and straightforward system of backtesting for the sake of simplicity. The following code backtests the crossover strategy and reveals its results:

# BACKTESTING THE STRATEGY

def implement_strategy(meta, investment):

in_position = False
equity = investment

for i in range(1, len(meta)):
if meta['sma8'][i-1] < meta['sma13'][i-1] and meta['sma8'][i] > meta['sma13'][i] and meta['sma5'][i] > meta['sma8'][i] and meta['close'][i] > meta['sma5'][i] and in_position == False:
no_of_shares = math.floor(equity/meta.close[i])
equity -= (no_of_shares * meta.close[i])
in_position = True
print(cl('BUY: ', color = 'green', attrs = ['bold']), f'{no_of_shares} Shares are bought at ${meta.close[i]} on {str(meta["date"][i])[:10]}')
elif meta['sma8'][i-1] > meta['sma13'][i-1] and meta['sma8'][i] < meta['sma13'][i] and meta['sma5'][i] < meta['sma8'][i] and meta['close'][i] < meta['sma5'][i] and in_position == True:
equity += (no_of_shares * meta.close[i])
in_position = False
print(cl('SELL: ', color = 'red', attrs = ['bold']), f'{no_of_shares} Shares are bought at ${meta.close[i]} on {str(meta["date"][i])[:10]}')
if in_position == True:
equity += (no_of_shares * meta.close[i])
print(cl(f'\nClosing position at {meta.close[i]} on {str(meta["date"][i])[:10]}', attrs = ['bold']))
in_position = False

earning = round(equity - investment, 2)
roi = round(earning / investment * 100, 2)
print(cl(f'EARNING: ${earning} ; ROI: {roi}%', attrs = ['bold']))

implement_strategy(meta_df, 100000)

I’m not going to dive deep into the dynamics of this code as it will take some time to explain it. Basically, the program executes the trades based on the conditions that are satisfied. It enters the market when our entry condition is satisfied and exits when the exit condition is satisfied. These are trades executed by our program followed by the backtesting results:

SMA crossover strategy results (Image by Author)

Our strategy has executed a lot of trades within a time span of ten days and it has made a profit of around six grand with an ROI of 6.13% which I think is wonderful. But we can confidently say that our strategy is successful only when we compare and exceed the returns of the buy/hold strategy.

Buy/Hold Returns Comparison

This is a pivotal point in this article as we’ll get to know whether we’re actually able to come up with a good strategy that beats the market. And the only way to know this is by comparing the results of our crossover strategy with that of the buy/hold strategy. To those who don’t know what the buy/hold strategy is, it’s a strategy where the trader buys and holds the stock no matter what the circumstance is for a longer period.

The following code implements the buy/hold strategy and calculates the returns:

# BUY/HOLD STRATEGY RETURNS 

bh_roi = round(list(meta_df['close'].pct_change().cumsum())[-1],4)*100
print(cl(f'BUY/HOLD STRATEGY ROI: {bh_roi}%', attrs = ['bold']))

The code follows the simple mathematics behind calculating the stock returns for the given data and this is the output:

Buy/Hold Strategy returns (Image by Author)

Another simple way of determining the buy/hold strategy returns is through Google Finance:

Image by Author

Though there is a slight margin of error between the two results, we can say that the buy/hold strategy ROI is around 3.20%. This means that we have beat the market by 2% and that is insane!

SMA 50 & 200 Strategy Returns Comparison

Yes, we did beat the buy/hold strategy returns which itself makes our strategy of high potential. But we are going to perform this another validation step to see if our crossover strategy beats the classic SMA 50 & 200 crossover strategy.

For those who don’t the what the SMA 50 & 200 crossover strategy is, it’s basically one of the most basic and widely used SMA trading strategies. According to the strategy, we enter the market if SMA 50 crosses above SMA 200 and we exit the market if SMA 50 crosses below SMA 200.

The following code backtests this strategy and shows the results:

# SMA 50 & 200 CROSSOVER RETURNS

for period in [50,200]:
meta_df[f'sma{period}'] = pd.DataFrame(requests.get(f'https://financialmodelingprep.com/api/v3/technical_indicator/5min/META?type=sma&period={period}&from=2024-01-01&apikey={api_key}').json()).iloc[::-1].reset_index().drop('index', axis = 1)['sma']

def implement_50_200_strategy(meta, investment):

in_position = False
equity = investment

for i in range(1, len(meta)):
if meta['sma50'][i-1] < meta['sma200'][i-1] and meta['sma50'][i] > meta['sma200'][i] and in_position == False:
no_of_shares = math.floor(equity/meta.close[i])
equity -= (no_of_shares * meta.close[i])
in_position = True
print(cl('BUY: ', color = 'green', attrs = ['bold']), f'{no_of_shares} Shares are bought at ${meta.close[i]} on {str(meta.index[i])[:10]}')
elif meta['sma50'][i-1] > meta['sma200'][i-1] and meta['sma50'][i] < meta['sma200'][i] and in_position == True:
equity += (no_of_shares * meta.close[i])
in_position = False
print(cl('SELL: ', color = 'red', attrs = ['bold']), f'{no_of_shares} Shares are bought at ${meta.close[i]} on {str(meta.index[i])[:10]}')
if in_position == True:
equity += (no_of_shares * meta.close[i])
print(cl(f'\nClosing position at {meta.close[i]} on {str(meta.index[i])[:10]}', attrs = ['bold']))
in_position = False

earning = round(equity - investment, 2)
roi = round(earning / investment * 100, 2)
print(cl(f'EARNING: ${earning} ; ROI: {roi}%', attrs = ['bold']))

implement_50_200_strategy(meta_df, 100000)

In the first two lines, we are using the for-loop we created earlier to extract SMA values to get SMA 50 and SMA 200 data. The rest of the code is very similar to that of the previous one where we backtested our 5–8–13 crossover trading strategy except we are making some changes to the entry and exit conditions according to the SMA 50 & 200 crossover strategy. This is the backtesting result:

SMA 50 & 200 strategy results (Image by Author)

The SMA 50 & 200 crossover strategy not only failed to beat our 5–8–13 crossover strategy but even failed to surpass the buy/hold strategy returns. With this, we can indeed say that we have built a very effective trading strategy that exceeds the returns of one of the most widely used SMA strategies. That’s great!

Conclusion

What an overwhelming ride it was! Here’s an overall outline of this article:

  • We started off with getting some background about our 5–8–13 SMA crossover trading strategy to get info about its mechanics.
  • Then we moved to the coding part where we first extracted the intraday and SMA data using FMP’s historical and technical indicator API endpoints respectively.
  • Following that, we created a chart to visualize the SMA data in order to dive deep into our crossover trading strategy and draw more insights.
  • Finally, we stepped onto the backtesting process which can be broken into three parts: the backtesting of our crossover strategy, buy/hold strategy returns comparison, and SMA 50 & 200 crossover strategy returns comparison.

This 5–8–13 crossover strategy is one of many underrated SMA trading strategies. There is still a lot to be explored. You can try this strategy with different types of moving averages like EMA, TEMA, and so on. You can also try tweaking and playing around with the strategy parameters to make the best out of it.

With that being said, you’ve reached the end of the article. Hope you learned something new and useful today. If you guys have ideas about improving the strategy, feel free to post them in the comments. Thank you for your time.

--

--