Simple Moving Average#

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

Load Data#

Consider the Tesla stock of the latest 90 days.

stocks: pd.DataFrame = pd.read_csv("../../data/stocks.csv", index_col=0, parse_dates=True)
company = "TSLA"
stock = stocks.query(f"Company == '{company}'").drop(columns=["Company", "Sector"])
stock_price = stock[["Close"]]
stock_price
Close
Date
2017-11-02 19.950666
2017-11-03 20.406000
2017-11-06 20.185333
2017-11-07 20.403334
2017-11-08 20.292667
... ...
2022-10-26 224.639999
2022-10-27 225.089996
2022-10-28 228.520004
2022-10-31 227.539993
2022-11-01 227.820007

1258 rows × 1 columns

Long and Short-Term Simple Moving Averages#

For each day, we need to compute the average stock price within a period of time before that day. To do so, we need to slide a fixed length window (hence the name moving average) along the times series and compute the mean value of the prices falling into the window. Panda’s DataFrame (or Series) has a method rolling, which can achieve this easily.

In this notebook, we assume the periods of the long and short-term SMA are 20 and 5, respectively. We now compute the moving averages:

long_period = 20
short_period = 5

long = stock_price.rolling(long_period).mean()
short = stock_price.rolling(short_period).mean()

# plot stock price, long-term SMA and short-term SMA of the last 90 days
num_days = 90
plt.figure(figsize=(12, 4))
plt.plot(stock_price[-num_days:], label="Stock Price", color="k")
plt.plot(long[-num_days:], label="Long-Term SMA", color="r")
plt.plot(short[-num_days:], label="Short-Term SMA", color="b")
plt.legend()
plt.grid()
plt.show()
../_images/simple-moving-average_8_0.png

Golden Cross vs. Death Cross#

The golden cross

  • appears when the short-term MA crosses up through the long-term MA, and

  • it indicates a signal to buy.

The death cross

  • appears when the short-term MA crosses trends down and crosses the long-term MA, and

  • it indicates a signal to sell.

We can use bitwise operations to find golden and death crosses. By definition, we note that a golden cross occurs when

  • short-term MA > long-term MA on current day, and

  • short-term MA < long-term MA on the preceding day.

In code:

(short > long) & ((short < long).shift(1))

The death crosses can be found in the similar way.

golden = ((short > long) & ((short < long).shift(1))).query("Close == True").index
death = ((short < long) & ((short > long).shift(1))).query("Close == True").index

print(f"Golden cross: {golden}")
print(f"Death cross: {death}")
Golden cross: DatetimeIndex(['2017-12-11', '2018-01-11', '2018-02-22', '2018-04-11',
               '2018-05-02', '2018-06-04', '2018-08-03', '2018-09-21',
               '2018-10-25', '2018-11-30', '2019-01-10', '2019-02-11',
               '2019-02-14', '2019-04-01', '2019-06-11', '2019-09-05',
               '2019-10-01', '2019-10-11', '2019-12-12', '2020-04-07',
               '2020-08-14', '2020-09-17', '2020-09-30', '2020-11-09',
               '2020-11-18', '2021-02-05', '2021-03-17', '2021-04-06',
               '2021-05-28', '2021-06-10', '2021-07-30', '2021-08-26',
               '2021-11-23', '2021-12-28', '2022-03-18', '2022-06-02',
               '2022-06-27', '2022-07-07', '2022-09-13', '2022-10-31'],
              dtype='datetime64[ns]', name='Date', freq=None)
Death cross: DatetimeIndex(['2017-12-04', '2017-12-28', '2018-02-06', '2018-03-07',
               '2018-04-26', '2018-05-17', '2018-07-03', '2018-08-20',
               '2018-10-04', '2018-11-26', '2018-12-20', '2019-01-23',
               '2019-02-12', '2019-02-20', '2019-04-10', '2019-07-30',
               '2019-09-26', '2019-10-04', '2019-11-27', '2020-02-28',
               '2020-07-31', '2020-09-09', '2020-09-24', '2020-10-22',
               '2020-11-12', '2021-02-03', '2021-02-10', '2021-03-22',
               '2021-04-29', '2021-06-08', '2021-07-19', '2021-08-19',
               '2021-11-15', '2021-12-06', '2022-01-18', '2022-04-13',
               '2022-06-09', '2022-07-01', '2022-08-24', '2022-09-27'],
              dtype='datetime64[ns]', name='Date', freq=None)

Plot the golden and death crosses:

Tip

For empty values, we use np.nan instead of pd.NA so that they will be ignored when plotting, which is as desired. Otherwise, an error will occur.

# the data frame `buy_sell_df` is just for plotting
# we don't need it for making decisions
buy_sell_df = stock_price.copy()
buy_sell_df["Buy"] = np.nan # use `np.nan` instead of `pd.NA`
buy_sell_df["Sell"] = np.nan
buy_sell_df.loc[golden, "Buy"] = short.loc[golden, "Close"]
buy_sell_df.loc[death, "Sell"] = short.loc[death, "Close"]

# plot the last 90 days
plt.figure(figsize=(12, 4))
plt.plot(stock_price[-num_days:], label="Stock Price", color="k", alpha=0.5)
plt.plot(long[-num_days:], label="Long-Term SMA", color="r", alpha=0.5)
plt.plot(short[-num_days:], label="Short-Term SMA", color="b", alpha=0.5)
plt.scatter(
    buy_sell_df[-num_days:].index, buy_sell_df[-num_days:]["Buy"], 
    marker="^", color="r", zorder=2,
    label="Golden Cross"
)
plt.scatter(
    buy_sell_df[-num_days:].index, buy_sell_df[-num_days:]["Sell"], 
    marker="v", color="g", zorder=2,
    label="Death Cross"
)
plt.legend()
plt.grid()
plt.show()
../_images/simple-moving-average_15_0.png

Profit#

Find the buying and selling dates starting from a given date, say 2022-06-27:

start_date = "2022-06-27"
buy_dates = golden[golden >= start_date]
sell_dates = death[death >= start_date]

# if we need to sell first, then ignore the first selling date 
# since we don't have any shares 
if buy_dates[0] > sell_dates[0]:
    sell_dates = sell_dates[1:]

If you buy a certain amount of shares and then sell it later, then the percentage change is given by

\[ \text{Percentage} = \frac{\text{Selling Price} - \text{Buying Price}}{\text{Buying Price}} \]
buy = stock_price.loc[buy_dates, "Close"].to_numpy()
sell = stock_price.loc[sell_dates, "Close"].to_numpy()

# buying and selling dates should come in pairs 
# except that there may be one more buying date in the end
assert len(buy) == len(sell) or len(buy) == len(sell) + 1

# don't buy
if len(buy) == len(sell) + 1:
    buy = buy[:-1]
    
# an array of percentage profit
pct = (sell - buy) / buy

pct
array([-0.07209155,  0.21490402, -0.03145861])

In this case, the array of percentage changes only has one element since we have seen in the figure that we only need to buy and sell once.

Suppose that you investigate \(x\) dollars (capital principal), and you buy and sell \(n\) times. The percentage change of the \(i\)-th trade is denoted by \(p_i\). Then the final amount of money you will have is

\[ x (1 + p_1) (1 + p_2) \cdots (1 + p_n) \]

Hence, the profit is

\[ \text{Profit} = x (1 + p_1) (1 + p_2) \cdots (1 + p_n) - x \]

If you investigate 100 dollars, then you will gain:

# capital principal
capital = 100

# total amount
total = capital * np.prod(1 + pct)

# profit/gain
profit = total - capital

profit
9.185579844095642

Hence, the profit rate is:

print(f"Profit rate: {profit:.2f}%")
Profit rate: 9.19%