1. Import libraries and set plotting style¶
This section is for loading necessary packages such pandas, numpy, matplotlib, and setting a consistent plotting style.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# We loaded necessary packages.
# we use seaborn style for default plotting settings
plt.style.use('seaborn-v0_8')
pd.set_option('display.max_columns', 20)
pd.set_option('display.width', 120)
# the following is for making plots a little larger and consistent.
plt.rcParams['figure.figsize'] = (10, 6)
plt.rcParams['axes.titlesize'] = 13
plt.rcParams['axes.labelsize'] = 11
plt.rcParams['legend.fontsize'] = 10
2. Load stk1999.pkl and inspect the data¶
This section is for checking the data file, making sure that MSFT and SPY are there. Also confirming the data format so we know that what we are delaing with.
# Loading the file to a pandas DataFrame named raw. the second line shows just the first 5 rows so we can check the structure.
raw = pd.read_pickle('stk1999.pkl')
raw.head(10)
| Symbol | Open | High | Low | Close | Volume | |
|---|---|---|---|---|---|---|
| Date | ||||||
| 1999-01-01 | ACU | 2.2500 | 2.2500 | 2.2500 | 2.2500 | 0 |
| 1999-01-01 | ACY | 8.4400 | 8.4400 | 8.4400 | 8.4400 | 0 |
| 1999-01-01 | AE | 5.7500 | 5.7500 | 5.7500 | 5.7500 | 0 |
| 1999-01-01 | AMS | 1.1900 | 1.1900 | 1.1900 | 1.1900 | 0 |
| 1999-01-01 | APT | 0.5000 | 0.5000 | 0.5000 | 0.5000 | 0 |
| 1999-01-01 | AWX | 7.0600 | 7.0600 | 7.0600 | 7.0600 | 0 |
| 1999-01-01 | BCV | 22.7500 | 22.7500 | 22.7500 | 22.7500 | 0 |
| 1999-01-01 | BDL | 5.0600 | 5.0600 | 5.0600 | 5.0600 | 0 |
| 1999-01-01 | BDR | 6.6200 | 6.6200 | 6.6200 | 6.6200 | 0 |
| 1999-01-01 | BHB | 15.8333 | 15.8333 | 15.8333 | 15.8333 | 0 |
#check the type again for raw to make surte it is loaded as a DataFrame.
print(type(raw))
#How many columns and rows are there in the data?
print(raw.shape)
print(raw.columns)
#a detailed summary to see each columns information and types.
raw.info()
<class 'pandas.core.frame.DataFrame'> (526609, 6) Index(['Symbol', 'Open', 'High', 'Low', 'Close', 'Volume'], dtype='object') <class 'pandas.core.frame.DataFrame'> DatetimeIndex: 526609 entries, 1999-01-01 to 1999-12-31 Data columns (total 6 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 Symbol 526609 non-null object 1 Open 526609 non-null float64 2 High 526609 non-null float64 3 Low 526609 non-null float64 4 Close 526609 non-null float64 5 Volume 526609 non-null int64 dtypes: float64(4), int64(1), object(1) memory usage: 28.1+ MB
#this is just to see the date format
print(raw.index[:5])
print(raw.index[-5:])
#What are the unique symbols in the data?
print(raw['Symbol'].unique())
DatetimeIndex(['1999-01-01', '1999-01-01', '1999-01-01', '1999-01-01', '1999-01-01'], dtype='datetime64[ns]', name='Date', freq=None) DatetimeIndex(['1999-12-31', '1999-12-31', '1999-12-31', '1999-12-31', '1999-12-31'], dtype='datetime64[ns]', name='Date', freq=None) ['ACU' 'ACY' 'AE' ... 'A' 'TDY' 'CCZ']
# Since we will be working with MSFT and SPY. I just wanted to confirm how many times they occur in the data.
print((raw['Symbol'] == 'MSFT').sum())
print((raw['Symbol'] == 'SPY').sum())
261 261
3. Create a clean 1999 closing price table for MSFT and SPY¶
Just to keep everything clean and being able to visualize the process I will create some tables for MSFT AND SPY before performing further calculations. This will be the base of core calucations later on.
#extract our target tickers and keep only the columns that we need which is the closing price column
data_1999 = raw[raw['Symbol'].isin(['MSFT', 'SPY'])].copy()
data_1999 = data_1999[['Symbol', 'Close']]
data_1999.head()
#right now we only have MSFT and SPY after these lines
| Symbol | Close | |
|---|---|---|
| Date | ||
| 1999-01-01 | SPY | 123.31 |
| 1999-01-04 | SPY | 123.03 |
| 1999-01-05 | SPY | 124.44 |
| 1999-01-06 | SPY | 127.44 |
| 1999-01-07 | SPY | 126.81 |
#we will reshape the data so that we have one column for MSFT and one column for SPY.
#I will then sort the data by date just to be sure that everything is ordrered correctly.
prices = data_1999.pivot_table(index=data_1999.index, columns='Symbol', values='Close')
prices = prices.sort_index()
prices.head()
| Symbol | MSFT | SPY |
|---|---|---|
| Date | ||
| 1999-01-01 | 34.673 | 123.31 |
| 1999-01-04 | 35.250 | 123.03 |
| 1999-01-05 | 36.625 | 124.44 |
| 1999-01-06 | 37.813 | 127.44 |
| 1999-01-07 | 37.625 | 126.81 |
#removing any missing rows so that both tickers will line up on the same trading day
prices = prices.dropna().copy()
print(prices.shape)
prices.head()
(261, 2)
| Symbol | MSFT | SPY |
|---|---|---|
| Date | ||
| 1999-01-01 | 34.673 | 123.31 |
| 1999-01-04 | 35.250 | 123.03 |
| 1999-01-05 | 36.625 | 124.44 |
| 1999-01-06 | 37.813 | 127.44 |
| 1999-01-07 | 37.625 | 126.81 |
#This is to show you the final DataFrame that we will perform our calcualtions on. index start and end dates are shown and the table end is printed again for your reference.
print(prices.index.min())
print(prices.index.max())
print(prices.columns)
prices.tail()
1999-01-01 00:00:00 1999-12-31 00:00:00 Index(['MSFT', 'SPY'], dtype='object', name='Symbol')
| Symbol | MSFT | SPY |
|---|---|---|
| Date | ||
| 1999-12-27 | 59.560 | 146.28 |
| 1999-12-28 | 58.750 | 146.19 |
| 1999-12-29 | 58.970 | 146.81 |
| 1999-12-30 | 58.810 | 146.63 |
| 1999-12-31 | 58.375 | 146.88 |
4. Build the MACD(5,10) indicator for MSFT¶
Here we will create the signal for both strategies after this block of code our strategy will depend on the sign of MACD indicator
#create a new copy of prices as macd data.
macd_data = prices.copy()
# this is the core of this strategy. My assumption is that MACD(5,10) = EMA(5) - EMA(10)
# the positions are +1 if MACD is higher than zero adn -1 otherwise
#5 and 10 day EMAs are calcualted here on Microsoft. I used pandas ewm() object which calculates EMA. span =5 for 5 days, 10 for 10 days.
macd_data['EMA_5'] = macd_data['MSFT'].ewm(span=5, adjust=False).mean()
macd_data['EMA_10'] = macd_data['MSFT'].ewm(span=10, adjust=False).mean()
#MACD(5,10) is the difference between the two EMAs
macd_data['MACD'] = macd_data['EMA_5'] - macd_data['EMA_10']
macd_data.head(10)
| Symbol | MSFT | SPY | EMA_5 | EMA_10 | MACD |
|---|---|---|---|---|---|
| Date | |||||
| 1999-01-01 | 34.673 | 123.31 | 34.673000 | 34.673000 | 0.000000 |
| 1999-01-04 | 35.250 | 123.03 | 34.865333 | 34.777909 | 0.087424 |
| 1999-01-05 | 36.625 | 124.44 | 35.451889 | 35.113744 | 0.338145 |
| 1999-01-06 | 37.813 | 127.44 | 36.238926 | 35.604518 | 0.634408 |
| 1999-01-07 | 37.625 | 126.81 | 36.700951 | 35.971878 | 0.729073 |
| 1999-01-08 | 37.470 | 127.75 | 36.957300 | 36.244264 | 0.713037 |
| 1999-01-11 | 36.875 | 126.53 | 36.929867 | 36.358943 | 0.570924 |
| 1999-01-12 | 35.548 | 124.25 | 36.469245 | 36.211499 | 0.257746 |
| 1999-01-13 | 35.953 | 123.38 | 36.297163 | 36.164499 | 0.132664 |
| 1999-01-14 | 35.438 | 121.22 | 36.010775 | 36.032408 | -0.021633 |
# Let's see if these values make sense. 5 day EMA should be more responsive and MACD should be the difference.
macd_data[['MSFT', 'EMA_5', 'EMA_10', 'MACD']].head(15)
#Everyhting looks good for now.
| Symbol | MSFT | EMA_5 | EMA_10 | MACD |
|---|---|---|---|---|
| Date | ||||
| 1999-01-01 | 34.673 | 34.673000 | 34.673000 | 0.000000 |
| 1999-01-04 | 35.250 | 34.865333 | 34.777909 | 0.087424 |
| 1999-01-05 | 36.625 | 35.451889 | 35.113744 | 0.338145 |
| 1999-01-06 | 37.813 | 36.238926 | 35.604518 | 0.634408 |
| 1999-01-07 | 37.625 | 36.700951 | 35.971878 | 0.729073 |
| 1999-01-08 | 37.470 | 36.957300 | 36.244264 | 0.713037 |
| 1999-01-11 | 36.875 | 36.929867 | 36.358943 | 0.570924 |
| 1999-01-12 | 35.548 | 36.469245 | 36.211499 | 0.257746 |
| 1999-01-13 | 35.953 | 36.297163 | 36.164499 | 0.132664 |
| 1999-01-14 | 35.438 | 36.010775 | 36.032408 | -0.021633 |
| 1999-01-15 | 37.438 | 36.486517 | 36.287971 | 0.198546 |
| 1999-01-18 | 37.438 | 36.803678 | 36.497067 | 0.306611 |
| 1999-01-19 | 38.908 | 37.505119 | 36.935418 | 0.569700 |
| 1999-01-20 | 40.658 | 38.556079 | 37.612251 | 0.943828 |
| 1999-01-21 | 39.578 | 38.896719 | 37.969660 | 0.927059 |
5. Strategy 1¶
# For strategy one, we will go long (+1) when MACD > 0, short (-1) otherwise
#this creates the +1 and -1 positions in a new column.
macd_data['pos_s1'] = np.where(macd_data['MACD'] > 0, 1, -1)
# Close all the positions at the end of 1999
macd_data.iloc[-1, macd_data.columns.get_loc('pos_s1')] = 0
macd_data[['MSFT', 'EMA_5', 'EMA_10', 'MACD', 'pos_s1']].head(15)
| Symbol | MSFT | EMA_5 | EMA_10 | MACD | pos_s1 |
|---|---|---|---|---|---|
| Date | |||||
| 1999-01-01 | 34.673 | 34.673000 | 34.673000 | 0.000000 | -1 |
| 1999-01-04 | 35.250 | 34.865333 | 34.777909 | 0.087424 | 1 |
| 1999-01-05 | 36.625 | 35.451889 | 35.113744 | 0.338145 | 1 |
| 1999-01-06 | 37.813 | 36.238926 | 35.604518 | 0.634408 | 1 |
| 1999-01-07 | 37.625 | 36.700951 | 35.971878 | 0.729073 | 1 |
| 1999-01-08 | 37.470 | 36.957300 | 36.244264 | 0.713037 | 1 |
| 1999-01-11 | 36.875 | 36.929867 | 36.358943 | 0.570924 | 1 |
| 1999-01-12 | 35.548 | 36.469245 | 36.211499 | 0.257746 | 1 |
| 1999-01-13 | 35.953 | 36.297163 | 36.164499 | 0.132664 | 1 |
| 1999-01-14 | 35.438 | 36.010775 | 36.032408 | -0.021633 | -1 |
| 1999-01-15 | 37.438 | 36.486517 | 36.287971 | 0.198546 | 1 |
| 1999-01-18 | 37.438 | 36.803678 | 36.497067 | 0.306611 | 1 |
| 1999-01-19 | 38.908 | 37.505119 | 36.935418 | 0.569700 | 1 |
| 1999-01-20 | 40.658 | 38.556079 | 37.612251 | 0.943828 | 1 |
| 1999-01-21 | 39.578 | 38.896719 | 37.969660 | 0.927059 | 1 |
# check the tail and make sure that the positions are indeed closed at the end.
macd_data[['MSFT', 'MACD', 'pos_s1']].tail(10)
| Symbol | MSFT | MACD | pos_s1 |
|---|---|---|---|
| Date | |||
| 1999-12-20 | 56.375 | 2.526925 | 1 |
| 1999-12-21 | 57.935 | 2.536527 | 1 |
| 1999-12-22 | 58.780 | 2.516065 | 1 |
| 1999-12-23 | 58.720 | 2.343325 | 1 |
| 1999-12-24 | 58.720 | 2.107083 | 1 |
| 1999-12-27 | 59.560 | 1.977794 | 1 |
| 1999-12-28 | 58.750 | 1.664680 | 1 |
| 1999-12-29 | 58.970 | 1.426334 | 1 |
| 1999-12-30 | 58.810 | 1.185640 | 1 |
| 1999-12-31 | 58.375 | 0.916586 | 0 |
# this counts how many times the position changes from long to short and vice versa.
# this will tell us how active our strategy is
macd_data['trade_s1'] = macd_data['pos_s1'].diff().fillna(0)
num_changes_s1 = (macd_data['trade_s1'] != 0).sum()
print("Number of position changes in Strategy 1:", num_changes_s1)
Number of position changes in Strategy 1: 22
It is important to note that the 22 here means 22 position changes, not necessarily 22 separate orders. Since the strategy flips between +1 and -1, one change can represent a reversal.
6. Backtesting Strategy 1¶
This block will allow us to see the performance of the strategy. How it compares to buy and hold and how much would have earned each day.
#compute daily MSFT returns
macd_data['ret_msft'] = macd_data['MSFT'].pct_change()
# if yesterday’s position was 1, strategy return will be MSFT return today
#if yesterdays position was -1, strategy return will be the negative of MSFT return today becasue we are using yesterday's signal to trade.
macd_data['strategy1_ret'] = macd_data['pos_s1'].shift(1) * macd_data['ret_msft']
#this replaces missing first row return with 0.
macd_data['strategy1_ret'] = macd_data['strategy1_ret'].fillna(0)
#this creates the buy and hold return which we can compare later to our return.
macd_data['buyhold_ret'] = macd_data['ret_msft'].fillna(0)
macd_data[['MSFT', 'pos_s1', 'ret_msft', 'strategy1_ret', 'buyhold_ret']].head(10)
# so in the final table, ret_msft is the daily percentage return of MSFT
# strategy1_ret is our strategy’s daily return
# buyhold_ret is the benchmark return on MSFT
| Symbol | MSFT | pos_s1 | ret_msft | strategy1_ret | buyhold_ret |
|---|---|---|---|---|---|
| Date | |||||
| 1999-01-01 | 34.673 | -1 | NaN | 0.000000 | 0.000000 |
| 1999-01-04 | 35.250 | 1 | 0.016641 | -0.016641 | 0.016641 |
| 1999-01-05 | 36.625 | 1 | 0.039007 | 0.039007 | 0.039007 |
| 1999-01-06 | 37.813 | 1 | 0.032437 | 0.032437 | 0.032437 |
| 1999-01-07 | 37.625 | 1 | -0.004972 | -0.004972 | -0.004972 |
| 1999-01-08 | 37.470 | 1 | -0.004120 | -0.004120 | -0.004120 |
| 1999-01-11 | 36.875 | 1 | -0.015879 | -0.015879 | -0.015879 |
| 1999-01-12 | 35.548 | 1 | -0.035986 | -0.035986 | -0.035986 |
| 1999-01-13 | 35.953 | 1 | 0.011393 | 0.011393 | 0.011393 |
| 1999-01-14 | 35.438 | -1 | -0.014324 | -0.014324 | -0.014324 |
# this section will turn daily returns to cumultive returns.
#so if it starts at 1 and ends at 1.20, it means that $1 grows to $1.20 which represents 20% total return.
#we will create cumulative performaces of both to compare easily.
macd_data['cum_buyhold'] = (1 + macd_data['buyhold_ret']).cumprod()
macd_data['cum_strategy1'] = (1 + macd_data['strategy1_ret']).cumprod()
macd_data[['cum_buyhold', 'cum_strategy1']].head(10)
| Symbol | cum_buyhold | cum_strategy1 |
|---|---|---|
| Date | ||
| 1999-01-01 | 1.000000 | 1.000000 |
| 1999-01-04 | 1.016641 | 0.983359 |
| 1999-01-05 | 1.056297 | 1.021717 |
| 1999-01-06 | 1.090560 | 1.054858 |
| 1999-01-07 | 1.085138 | 1.049613 |
| 1999-01-08 | 1.080668 | 1.045289 |
| 1999-01-11 | 1.063508 | 1.028691 |
| 1999-01-12 | 1.025236 | 0.991672 |
| 1999-01-13 | 1.036916 | 1.002970 |
| 1999-01-14 | 1.022063 | 0.988603 |
# Here are the ending values for both strategies.
print("Final buy-and-hold value:", macd_data['cum_buyhold'].iloc[-1])
print("Final Strategy 1 value:", macd_data['cum_strategy1'].iloc[-1])
Final buy-and-hold value: 1.6835866524384984 Final Strategy 1 value: 1.333540699321843
7. Plots - Strategy 1¶
We will make 3 charts for Strategy 1:
MSFT price with EMA(5) and EMA(10)
MSFT price + EMAs + position on the other axis
Buy and holf vs Strategy 1 cumulative performance
7.1 MSFT with EMA(5) and EMA(10)¶
#MSFT price with EMA(5) and EMA(10), raw inputs for the strategy
plt.figure(figsize=(10, 6))
plt.plot(macd_data.index, macd_data['MSFT'], label='MSFT')
plt.plot(macd_data.index, macd_data['EMA_5'], label='EMA_5')
plt.plot(macd_data.index, macd_data['EMA_10'], label='EMA_10')
plt.title('MSFT Price with EMA(5) and EMA(10) in 1999')
plt.xlabel('Date')
plt.ylabel('Price')
plt.legend()
plt.show()
7.2 MSFT + EMAs + Strategy 1 position¶
#this shows how the strategy flips over the position over time. We can easily see when we are long and short and for how long.
fig, ax1 = plt.subplots(figsize=(10, 6))
# Left axis shows the price and EMAs
ax1.plot(macd_data.index, macd_data['MSFT'], label='MSFT')
ax1.plot(macd_data.index, macd_data['EMA_5'], label='EMA_5')
ax1.plot(macd_data.index, macd_data['EMA_10'], label='EMA_10')
ax1.set_xlabel('Date')
ax1.set_ylabel('Price')
# Right axis shows the postition, which oscillates between -1 and 1 as seen below.
ax2 = ax1.twinx()
ax2.plot(macd_data.index, macd_data['pos_s1'], label='Position', linestyle='--')
ax2.set_ylabel('Position')
ax2.set_ylim(-1.1, 1.1)
#combining the legends
lines1, labels1 = ax1.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels()
ax1.legend(lines1 + lines2, labels1 + labels2, loc='upper left')
plt.title('MSFT, EMA(5), EMA(10), and Strategy 1 Position')
plt.show()
7.3 Cumulative performance of Strategy 1 vs buy and hold¶
# we just translate what we have found in cumulative performance part into a graph.
plt.figure(figsize=(10, 6))
plt.plot(macd_data.index, macd_data['cum_buyhold'], label='Buy-and-Hold MSFT')
plt.plot(macd_data.index, macd_data['cum_strategy1'], label='Strategy 1: MACD(5,10)')
plt.title('Performance of MSFT and Strategy 1 Over Time')
plt.xlabel('Date')
plt.ylabel('Cumulative Value')
plt.legend()
plt.show()
7.4 Shaded long/short regions to performance chart¶
# This highlights the short periods, which may help visually explain where the strategy differs from buy and hold.
plt.figure(figsize=(10, 6))
plt.plot(macd_data.index, macd_data['cum_buyhold'], label='Buy-and-Hold MSFT')
plt.plot(macd_data.index, macd_data['cum_strategy1'], label='Strategy 1: MACD(5,10)')
# Shade periods where the strategy is short
short_mask = macd_data['pos_s1'] == -1
plt.fill_between(
macd_data.index,
macd_data['cum_buyhold'].min(),
macd_data[['cum_buyhold', 'cum_strategy1']].max().max(),
where=short_mask,
alpha=0.15
)
plt.title('Performance of MSFT and Strategy 1 Over Time')
plt.xlabel('Date')
plt.ylabel('Cumulative Value')
plt.legend()
plt.show()
7.5 Strategy 1 equity and cumulative maximum¶
# Computing Strategy 1 running maximum in this line
macd_data['cummax_strategy1'] = macd_data['cum_strategy1'].cummax()
# drawdown for Strategy 1
macd_data['drawdown_strategy1'] = (
macd_data['cum_strategy1'] / macd_data['cummax_strategy1'] - 1
)
# maximum drawdown date for Strategy 1
max_dd_date_s1 = macd_data['drawdown_strategy1'].idxmin()
max_dd_value_s1 = macd_data['drawdown_strategy1'].min()
# this will give us the graph that is similar to 16-7 in the textbook.
# equity is the cumualtive value of strategy 1
# cummax is the running maximum of that equity curve
#we will pinpoint the max drawdown date with a vertical line
plt.figure(figsize=(10, 6))
plt.plot(macd_data.index, macd_data['cum_strategy1'], label='equity')
plt.plot(macd_data.index, macd_data['cummax_strategy1'], label='cummax')
plt.axvline(max_dd_date_s1, linestyle='--', label='max drawdown date')
plt.title('Strategy 1 Equity Curve and Running Maximum')
plt.xlabel('Date')
plt.ylabel('Cumulative Value')
plt.legend()
plt.show()
print("Strategy 1 max drawdown date:", max_dd_date_s1)
print("Strategy 1 max drawdown:", max_dd_value_s1)
Strategy 1 max drawdown date: 1999-06-16 00:00:00 Strategy 1 max drawdown: -0.21656153771907927
8. Strategy 2¶
Now we build the paired trading position.
When Strategy 1 says long MSFT, Strategy 2 long MSFT and short SPY with the same dollar amount.
On the last day, both positions should be 0.
# MSFT will be taking the same signal as Strategy 1
# SPY takes the opposite signal
macd_data['pos_s2_msft'] = macd_data['pos_s1']
macd_data['pos_s2_spy'] = -macd_data['pos_s1']
macd_data[['MSFT', 'SPY', 'MACD', 'pos_s1', 'pos_s2_msft', 'pos_s2_spy']].head(15)
| Symbol | MSFT | SPY | MACD | pos_s1 | pos_s2_msft | pos_s2_spy |
|---|---|---|---|---|---|---|
| Date | ||||||
| 1999-01-01 | 34.673 | 123.31 | 0.000000 | -1 | -1 | 1 |
| 1999-01-04 | 35.250 | 123.03 | 0.087424 | 1 | 1 | -1 |
| 1999-01-05 | 36.625 | 124.44 | 0.338145 | 1 | 1 | -1 |
| 1999-01-06 | 37.813 | 127.44 | 0.634408 | 1 | 1 | -1 |
| 1999-01-07 | 37.625 | 126.81 | 0.729073 | 1 | 1 | -1 |
| 1999-01-08 | 37.470 | 127.75 | 0.713037 | 1 | 1 | -1 |
| 1999-01-11 | 36.875 | 126.53 | 0.570924 | 1 | 1 | -1 |
| 1999-01-12 | 35.548 | 124.25 | 0.257746 | 1 | 1 | -1 |
| 1999-01-13 | 35.953 | 123.38 | 0.132664 | 1 | 1 | -1 |
| 1999-01-14 | 35.438 | 121.22 | -0.021633 | -1 | -1 | 1 |
| 1999-01-15 | 37.438 | 124.38 | 0.198546 | 1 | 1 | -1 |
| 1999-01-18 | 37.438 | 124.38 | 0.306611 | 1 | 1 | -1 |
| 1999-01-19 | 38.908 | 125.19 | 0.569700 | 1 | 1 | -1 |
| 1999-01-20 | 40.658 | 126.19 | 0.943828 | 1 | 1 | -1 |
| 1999-01-21 | 39.578 | 122.84 | 0.927059 | 1 | 1 | -1 |
#check tail to see if the positions are closed at the year end.
macd_data[['MACD', 'pos_s2_msft', 'pos_s2_spy']].tail(10)
| Symbol | MACD | pos_s2_msft | pos_s2_spy |
|---|---|---|---|
| Date | |||
| 1999-12-20 | 2.526925 | 1 | -1 |
| 1999-12-21 | 2.536527 | 1 | -1 |
| 1999-12-22 | 2.516065 | 1 | -1 |
| 1999-12-23 | 2.343325 | 1 | -1 |
| 1999-12-24 | 2.107083 | 1 | -1 |
| 1999-12-27 | 1.977794 | 1 | -1 |
| 1999-12-28 | 1.664680 | 1 | -1 |
| 1999-12-29 | 1.426334 | 1 | -1 |
| 1999-12-30 | 1.185640 | 1 | -1 |
| 1999-12-31 | 0.916586 | 0 | 0 |
# This counts signal changes for the paired strategy as we did in strategy 1.
# Since Strategy 2 is driven by the same MACD signal as Strategy 1, this number should match Strategy 1.
macd_data['trade_s2'] = macd_data['pos_s2_msft'].diff().fillna(0)
num_changes_s2 = (macd_data['trade_s2'] != 0).sum()
print("Number of position changes in Strategy 2:", num_changes_s2)
Number of position changes in Strategy 2: 22
9. Backtesting Strategy 2¶
Now we calculate the return of the paired strategy.
Since we are investing the same dollar amount, I am treating as if we are investing +50% in one leg and investing −50% in the other leg.
# daily SPY returns
macd_data['ret_spy'] = macd_data['SPY'].pct_change()
# Strategy 2 daily return
# we are using yesterday's paired positions same as strategy 1.
# we are assigning +0.5 weight on one asset, -0.5 weight on the other asset and same dollar size on both legs
# So the whole strategy is scaled to a net starting capital of 1.0.
macd_data['strategy2_ret'] = (
0.5 * macd_data['pos_s2_msft'].shift(1) * macd_data['ret_msft']
+ 0.5 * macd_data['pos_s2_spy'].shift(1) * macd_data['ret_spy']
)
# this replaces missing first row return with 0
macd_data['strategy2_ret'] = macd_data['strategy2_ret'].fillna(0)
macd_data[['ret_msft', 'ret_spy', 'pos_s2_msft', 'pos_s2_spy', 'strategy2_ret']].head(10)
| Symbol | ret_msft | ret_spy | pos_s2_msft | pos_s2_spy | strategy2_ret |
|---|---|---|---|---|---|
| Date | |||||
| 1999-01-01 | NaN | NaN | -1 | 1 | 0.000000 |
| 1999-01-04 | 0.016641 | -0.002271 | 1 | -1 | -0.009456 |
| 1999-01-05 | 0.039007 | 0.011461 | 1 | -1 | 0.013773 |
| 1999-01-06 | 0.032437 | 0.024108 | 1 | -1 | 0.004164 |
| 1999-01-07 | -0.004972 | -0.004944 | 1 | -1 | -0.000014 |
| 1999-01-08 | -0.004120 | 0.007413 | 1 | -1 | -0.005766 |
| 1999-01-11 | -0.015879 | -0.009550 | 1 | -1 | -0.003165 |
| 1999-01-12 | -0.035986 | -0.018019 | 1 | -1 | -0.008983 |
| 1999-01-13 | 0.011393 | -0.007002 | 1 | -1 | 0.009198 |
| 1999-01-14 | -0.014324 | -0.017507 | -1 | 1 | 0.001591 |
#This turns Strategy 2 daily returns into a cumulative equity curve, same as we did for Strategy 1.
macd_data['cum_strategy2'] = (1 + macd_data['strategy2_ret']).cumprod()
macd_data[['cum_strategy2']].head(10)
| Symbol | cum_strategy2 |
|---|---|
| Date | |
| 1999-01-01 | 1.000000 |
| 1999-01-04 | 0.990544 |
| 1999-01-05 | 1.004187 |
| 1999-01-06 | 1.008369 |
| 1999-01-07 | 1.008355 |
| 1999-01-08 | 1.002540 |
| 1999-01-11 | 0.999368 |
| 1999-01-12 | 0.990390 |
| 1999-01-13 | 0.999499 |
| 1999-01-14 | 1.001089 |
# Final cumulative value of Strategy 2
print("Final Strategy 2 value:", macd_data['cum_strategy2'].iloc[-1])
Final Strategy 2 value: 1.286125708646444
#ending values for :
#buy and hold MSFT
# MACD on MSFT alone
# paired MACD strategy with SPY
print("Final buy and hold value:", macd_data['cum_buyhold'].iloc[-1])
print("Final Strategy 1 value:", macd_data['cum_strategy1'].iloc[-1])
print("Final Strategy 2 value:", macd_data['cum_strategy2'].iloc[-1])
Final buy and hold value: 1.6835866524384984 Final Strategy 1 value: 1.333540699321843 Final Strategy 2 value: 1.286125708646444
10. Plots - Strategy 2¶
#diagrams similar to, 15-3, 16-7, 16-5
# So in this block we will makem thses charts:
#Performance comparison chart
#Strategy 2 equity curve with cumulative maximum
#Drawdown chart
10.1 Performance comparison chart for Strategy 2¶
plt.figure(figsize=(10, 6))
plt.plot(macd_data.index, macd_data['cum_buyhold'], label='Buy-and-Hold MSFT')
plt.plot(macd_data.index, macd_data['cum_strategy2'], label='Strategy 2: MSFT-SPY Pair')
plt.title('Performance of MSFT and Strategy 2 Over Time')
plt.xlabel('Date')
plt.ylabel('Cumulative Value')
plt.legend()
plt.show()
10.2 Equity curve and cumulative maximum¶
# cumulative strategy2 is the actual equity curve, while the cummax strategy2 is the running peak so far.
#When the equity curve falls below the cumulative max, the strategy is in drawdown.
macd_data['cummax_strategy2'] = macd_data['cum_strategy2'].cummax()
plt.figure(figsize=(10, 6))
plt.plot(macd_data.index, macd_data['cum_strategy2'], label='equity')
plt.plot(macd_data.index, macd_data['cummax_strategy2'], label='cummax')
plt.title('Strategy 2 Equity Curve and Running Maximum')
plt.xlabel('Date')
plt.ylabel('Cumulative Value')
plt.legend()
plt.show()
10.3 Drawdown for Strategy 2¶
# we will compute drawdown as a percentage below the running maximum
# Here 0 means the strategy is at a new peak.Negative values mean the strategy is below its previous peak and the most negative point is the maximum drawdown.
macd_data['drawdown_strategy2'] = (
macd_data['cum_strategy2'] / macd_data['cummax_strategy2'] - 1
)
plt.figure(figsize=(10, 6))
plt.plot(macd_data.index, macd_data['drawdown_strategy2'], label='Drawdown')
plt.title('Strategy 2 Drawdown Over Time')
plt.xlabel('Date')
plt.ylabel('Drawdown')
plt.legend()
plt.show()
10.4 Vertical line at the max drawdown date¶
# here we find the date of maximum drawdown
max_dd_date = macd_data['drawdown_strategy2'].idxmin()
max_dd_value = macd_data['drawdown_strategy2'].min()
plt.figure(figsize=(10, 6))
plt.plot(macd_data.index, macd_data['cum_strategy2'], label='equity')
plt.plot(macd_data.index, macd_data['cummax_strategy2'], label='cummax')
plt.axvline(max_dd_date, linestyle='--', label='max drawdown date')
plt.title('Strategy 2 Equity Curve with Maximum Drawdown Date')
plt.xlabel('Date')
plt.ylabel('Cumulative Value')
plt.legend()
plt.show()
print("Max drawdown date:", max_dd_date)
print("Max drawdown:", max_dd_value)
Max drawdown date: 1999-06-16 00:00:00 Max drawdown: -0.08934745065196747
11. Drawdown statistics¶
Now I would like to show the maximum drawdown, and the date it occurs. the start of that drawdown period and the recovery date if it recovers within 1999
11.1 Maximum drawdown value and date¶
# with min(), we find the most negative drawdown, idxmin() finds the date where that worst drawdown happens
#So overall this gives us the deepest loss from a previous peak.
max_drawdown = macd_data['drawdown_strategy2'].min()
max_drawdown_date = macd_data['drawdown_strategy2'].idxmin()
print("Maximum drawdown:", max_drawdown)
print("Maximum drawdown date:", max_drawdown_date)
Maximum drawdown: -0.08934745065196747 Maximum drawdown date: 1999-06-16 00:00:00
11.2 Start date of the max drawdown period¶
# wjat is the peak level just before the max drawdown?
peak_value_before_dd = macd_data.loc[:max_drawdown_date, 'cummax_strategy2'].iloc[-1]
# Find the first date where the strategy reached that peak\
# That date is the start of the max drawdown period
drawdown_start_date = macd_data.loc[:max_drawdown_date, 'cum_strategy2'][
macd_data.loc[:max_drawdown_date, 'cum_strategy2'] == peak_value_before_dd
].index[0]
print("Drawdown start date:", drawdown_start_date)
Drawdown start date: 1999-05-14 00:00:00
11.3 Find the recovery date, if it exists¶
# This is where we check whether the equity curve ever climbs back to the old peak after the worst drawdown if yes, we record the first recovery date, if not, recovery is None
recovery_candidates = macd_data.loc[max_drawdown_date:, 'cum_strategy2']
recovery_candidates = recovery_candidates[recovery_candidates >= peak_value_before_dd]
if len(recovery_candidates) > 0:
recovery_date = recovery_candidates.index[0]
else:
recovery_date = None
print("Recovery date:", recovery_date)
Recovery date: 1999-08-24 00:00:00
11.4 Drawdown duration in trading days¶
# Drawdown duration in trading days.
# we wwant to see how many days the drawdown lasted, from the peak date to the recovery date.
if recovery_date is not None:
drawdown_duration = macd_data.loc[drawdown_start_date:recovery_date].shape[0] - 1
else:
drawdown_duration = macd_data.loc[drawdown_start_date:].shape[0] - 1
print("Drawdown duration (trading days):", drawdown_duration)
Drawdown duration (trading days): 72
11.5 Put drawdown statistics into a small table¶
# we put everything that we calculated in a table.
drawdown_summary = pd.DataFrame({
'Metric': [
'Maximum Drawdown',
'Max Drawdown Date',
'Drawdown Start Date',
'Recovery Date',
'Drawdown Duration (days)'
],
'Value': [
max_drawdown,
max_drawdown_date,
drawdown_start_date,
recovery_date,
drawdown_duration
]
})
drawdown_summary
| Metric | Value | |
|---|---|---|
| 0 | Maximum Drawdown | -0.089347 |
| 1 | Max Drawdown Date | 1999-06-16 00:00:00 |
| 2 | Drawdown Start Date | 1999-05-14 00:00:00 |
| 3 | Recovery Date | 1999-08-24 00:00:00 |
| 4 | Drawdown Duration (days) | 72 |
12. Summary table of results¶
Now we will create one final table comparing the three approaches:
Buy and Hold MSFT
Strategy 1 - MACD on MSFT
Strategy 2 - MACD on MSFT + opposite SPY trade
We will include: the final value, total return, annualized return, annualized volatility, maximum drawdown
12.1 Calculate the performance statistics¶
#How many trading days do we have in this sample?
n_days = len(macd_data)
# Annualization factor for daily data
annual_factor = 252
# Maximum drawdown for buy and hold and Strategy 1, we have already computed it for Strategy 2 so we don;t need to do it again.
macd_data['cummax_buyhold'] = macd_data['cum_buyhold'].cummax()
macd_data['drawdown_buyhold'] = macd_data['cum_buyhold'] / macd_data['cummax_buyhold'] - 1
macd_data['cummax_strategy1'] = macd_data['cum_strategy1'].cummax()
macd_data['drawdown_strategy1'] = macd_data['cum_strategy1'] / macd_data['cummax_strategy1'] - 1
12.2 Summary table¶
summary_table = pd.DataFrame({
'Final Value': [
macd_data['cum_buyhold'].iloc[-1],
macd_data['cum_strategy1'].iloc[-1],
macd_data['cum_strategy2'].iloc[-1]
],
'Total Return': [
macd_data['cum_buyhold'].iloc[-1] - 1,
macd_data['cum_strategy1'].iloc[-1] - 1,
macd_data['cum_strategy2'].iloc[-1] - 1
],
'Annualized Return': [
macd_data['cum_buyhold'].iloc[-1] ** (annual_factor / n_days) - 1,
macd_data['cum_strategy1'].iloc[-1] ** (annual_factor / n_days) - 1,
macd_data['cum_strategy2'].iloc[-1] ** (annual_factor / n_days) - 1
],
'Annualized Volatility': [
macd_data['buyhold_ret'].std() * np.sqrt(annual_factor),
macd_data['strategy1_ret'].std() * np.sqrt(annual_factor),
macd_data['strategy2_ret'].std() * np.sqrt(annual_factor)
],
'Sharpe-like Ratio': [
macd_data['buyhold_ret'].mean() / macd_data['buyhold_ret'].std() * np.sqrt(annual_factor),
macd_data['strategy1_ret'].mean() / macd_data['strategy1_ret'].std() * np.sqrt(annual_factor),
macd_data['strategy2_ret'].mean() / macd_data['strategy2_ret'].std() * np.sqrt(annual_factor)
],
'Maximum Drawdown': [
macd_data['drawdown_buyhold'].min(),
macd_data['drawdown_strategy1'].min(),
macd_data['drawdown_strategy2'].min()
]
}, index=[
'Buy-and-Hold MSFT',
'Strategy 1',
'Strategy 2'
])
summary_table
summary_table_rounded = summary_table.copy()
summary_table_rounded = summary_table_rounded.round(4)
summary_table_rounded
| Final Value | Total Return | Annualized Return | Annualized Volatility | Sharpe-like Ratio | Maximum Drawdown | |
|---|---|---|---|---|---|---|
| Buy-and-Hold MSFT | 1.6836 | 0.6836 | 0.6536 | 0.3741 | 1.5308 | -0.1969 |
| Strategy 1 | 1.3335 | 0.3335 | 0.3204 | 0.3752 | 0.9272 | -0.2166 |
| Strategy 2 | 1.2861 | 0.2861 | 0.2750 | 0.1522 | 1.6730 | -0.0893 |