This Jupyter notebook is saved on GitHub: https://github.com/IanLKaplan/twelve_percent
The approach to investment return and risk varies with the investor and their stage in life. A prudent investor that does not need to access their investments for twenty years might be willing to accept higher risk in return for higher long term gains. A prudent investor who is near or at retirement age would have much less tolerance for risk.
The Holy Grail of investing would be market (e.g., S&P 500) returns or better with much less risk (like the Holy Grail, this is generally unattainable).
This Jupyter notebook investigates a stock market trading algorithm that is often referred to as ETF rotation. The ETF rotation algorithm has less risk and, in some cases, better returns than a "buy and hold" algorithm (see All Weather" Portfolios) For example, the ETF rotation algorithm described in this notebook had higher returns with less risk than the S&P 500 for a portfolio investment that started in 2008 to 2019. After 2019 the return of the ETF rotation portfolio was equal or less than the S&P 500.
I first learned about the ETF rotation algorithm in the (short) book The 12% Solution: Earn A 12% Average Annual Return On Your Money, Beating The S&P 500 by David Alan Carter. According to David Alan Carter, the outlined ETF rotation algorithm yields a 12% return, on average. The time period in the book ends in December 2017.
The analysis in The 12% Solution was done with on-line tools like those from ETFReplay.com and stockcharts.com. The promise of these on-line tools is that anyone can investigate investment strategies without having to develop custom software in Python or R. On-line tools are generally "black boxes" and the user must trust that the on-line tool is properly simulating the desired portfolio strategy.
Following the ETF rotation algorithm outlined in The 12% Solution, the reader trades one of four equity ETFs or a cash ETF and one of two bond ETFs. The The 12% Solution suggests that this analysis can be done using on-line tools.
The 12% Solution portfolio allocates 60% of the portfolio to equity (stock) ETFs and 40% in bonds. The 60% equity/40% bond investment portfolio allocation is often recommended for lower risk investment portfolios.
The ETF rotation algorithm outlined in The 12% solution is sometimes referred to as a momentum algorithm. For the equity portion or bond portion of the portfolio, an ETF with the highest previous three month return is selected from the equity or bond ETF sets. This ETF is held for one month before selecting another ETF (which may be the same as the current ETF). The portfolio is rebalanced every month, so 60% is always allocated to the equity ETF set (or SHY) and 40% is allocated to the bond set.
The momentum algorithm is based on the premise that the return of an ETF in the current month is predicted by the past three month return. As we shall see later, a quantitative analysis of this momentum algorithm is difficult because of the limited data set and the resultant error.
As we can see in the case of the 2008 financial crash, the ETF rotation algorithm will shift assets out of stocks into cash and bonds. Note that this does not work for rapid market crashes like the 2020 COVID-19 market crash.
The bond portion of the portfolio is allocated to either a US Treasury bond ETF or to a corporate high yield (sometimes called junk bonds) ETF, depending on the relative yield over the previous three months.
In this Jupyter notebook I have tried to reproduce the results described in The 12% solution. The results in this notebook do not match the results in The 12% solution. The results in The 12% Solution show higher return than those calculated in this notebook. I have tried to find the reasons for the difference in portfolio returns calculated here and in The 12% Solution, but I have not succeeded.
In The 12% Solution there are four ETFs that are used in rotation, two bond funds and one ETF (SHY) that is used as a proxy for "cash" (e.g., an asset at the risk free rate).
IWM: iShares Russell 2000 ETF
IWM tracks a market-cap-weighted index of US small-cap stocks. The index selects stocks ranked 1,001-3,000 by market cap.
Inception date: 5/22/2000
MDY: SPDR S&P Midcap 400 ETF Trust
MDY tracks a market-cap-weighted index of midcap US companies.
Inception date: 5/04/1995
QQQ: Invesco QQQ Trust
QQQ tracks a modified-market-cap-weighted index of 100 NASDAQ-listed stocks.
Inception date: 3/10/1999
SPY: SPDR S&P 500 ETF Trust
SPY tracks a market-cap-weighted index of US large- and midcap stocks selected by the S&P Committee (e.g., S&P 500).
Inception date: Jan 22, 1993
TLT: iShares 20+ Year Treasury Bond ETF
TLT tracks a market-weighted index of debt issued by the US Treasury with remaining maturities of 20 years or more.
Inception date: 7/22/2002
JNK: SPDR Bloomberg High Yield Bond ETF
JNK tracks a market-weighted index of highly liquid, high-yield, US dollar-denominated corporate bonds.
Inception date: 11/28/2007
SHY: iShares 10-20 Year Treasury Bond ETF
SHY tracks a market weighted index of debt issued by the US Treasury with 1-3 years remaining to maturity. Treasury STRIPS are excluded.
Inception date: 07/22/2002
In The 12% Solution the backtest time period starts January 2008. The inception period for the JNK corporate high yield bond fund is November 18, 2007. To simplify the software in this notebook, the start period is March 3, 2008 (providing a look-back period over December 2007, January 2008 and February 2008).
from datetime import datetime, timedelta
from numpy import sqrt
from tabulate import tabulate
from typing import List, Tuple
from pandas_datareader import data
import matplotlib.pyplot as plt
import pandas as pd
from pandas.core.indexes.datetimes import DatetimeIndex
from dateutil.relativedelta import relativedelta
import numpy as np
from pathlib import Path
import tempfile
from IPython.display import Image
plt.style.use('seaborn-whitegrid')
pd.options.mode.chained_assignment = 'raise'
def get_market_data(file_name: str,
data_col: str,
symbols: List,
data_source: str,
start_date: datetime,
end_date: datetime) -> pd.DataFrame:
"""
file_name: the file name in the temp directory that will be used to store the data
data_col: the type of data - 'Adj Close', 'Close', 'High', 'Low', 'Open', Volume'
symbols: a list of symbols to fetch data for
data_source: yahoo, etc...
start_date: the start date for the time series
end_date: the end data for the time series
Returns: a Pandas DataFrame containing the data.
If a file of market data does not already exist in the temporary directory, fetch it from the
data_source.
"""
temp_root: str = tempfile.gettempdir() + '/'
file_path: str = temp_root + file_name
temp_file_path = Path(file_path)
file_size = 0
if temp_file_path.exists():
file_size = temp_file_path.stat().st_size
if file_size > 0:
close_data = pd.read_csv(file_path, index_col='Date')
else:
panel_data: pd.DataFrame = data.DataReader(symbols, data_source, start_date, end_date)
close_data: pd.DataFrame = panel_data[data_col]
close_data.to_csv(file_path)
assert len(close_data) > 0, f'Error reading data for {symbols}'
return close_data
equity_etfs = ['IWM', 'MDY', 'QQQ', 'SPY']
bond_etfs = ['JNK', 'TLT']
cash_etf = 'SHY'
data_source = 'yahoo'
# The start date is the date used in the examples in The 12% Solution
# yyyy-mm-dd
start_date_str = '2008-03-03'
start_date: datetime = datetime.fromisoformat(start_date_str)
look_back_date_str = '2007-12-03'
look_back_date: datetime = datetime.fromisoformat(look_back_date_str)
end_date: datetime = datetime.today() - timedelta(days=1)
# get rid of any time component
end_date = datetime(end_date.year, end_date.month, end_date.day)
d2019_start = datetime.fromisoformat("2019-01-02")
d2019_end = datetime.fromisoformat("2019-12-31")
etf_adjclose_file = 'equity_etf_adjclose'
equity_adj_close = get_market_data(file_name=etf_adjclose_file,
data_col='Adj Close',
symbols=equity_etfs,
data_source=data_source,
start_date=look_back_date,
end_date=end_date)
shy_adjclose_file = 'shy_adjclose'
shy_adj_close = get_market_data(file_name=shy_adjclose_file,
data_col='Adj Close',
symbols=[cash_etf],
data_source=data_source,
start_date=look_back_date,
end_date=end_date)
fixed_income_adjclose_file = "fixed_income_adjclose"
fixed_income_adjclose = get_market_data(file_name=fixed_income_adjclose_file,
data_col='Adj Close',
symbols=bond_etfs,
data_source=data_source,
start_date=look_back_date,
end_date=end_date)
# 13-week yearly treasury bond quote
risk_free_asset = '^IRX'
rf_file_name = 'rf_adj_close'
# The bond return is reported as a yearly return percentage
rf_adj_close = get_market_data(file_name=rf_file_name,
data_col='Adj Close',
symbols=[risk_free_asset],
data_source=data_source,
start_date=start_date,
end_date=end_date)
# The ^IRX interest rate is reported as a yearly percentage rate.
# Convert this to a daily interest rate
rf_adj_rate_np: np.array = np.array( rf_adj_close.values ) / 100
rf_daily_np = ((1 + rf_adj_rate_np) ** (1/360)) - 1
rf_daily_df: pd.DataFrame = pd.DataFrame( rf_daily_np, index=rf_adj_close.index, columns=['^IRX'])
corr_mat = round(equity_adj_close.corr(), 3)
In The 12% Solution ETF rotation, the ETF with the highest return in the previous three months is selected, unless the return is less than the return of SHY, in which case SHY is selected.
An important point to note is that the equity ETFs in the The 12% Solution set are highly correlated. One ETF may do somewhat better than another, but they are all market ETFs and have similar performance. If the "market" is down then all of the ETFs will probably be down.
The correlation matrix below shows the correlation between the equity ETFs.
print(tabulate(corr_mat, headers=[*corr_mat.columns], tablefmt='fancy_grid'))
The high correlation between the ETFs suggests two areas that are worth investigating:
Would the financial performance be similar if only a single ETF were used. For example, SPY?
If a larger ETF universe is used, ETFs with lower correlation might be selected resulting in better performance.
The return calculations in this notebook use simple return. The simple return for a time period t is:
$\ R_t = \large \frac{R_t - R_{t-1}}{R_{t-1}} = \frac{R_t}{R_{t-1}} - 1$
The portfolio value calculated via continuously compounded returns is:
$\ portfolio\ value\ = V_t = V_{t-1} + V_{t-1} \times R_{t} $
where $\ V_{0} = initial\ investment $
def convert_date(some_date):
if type(some_date) == str:
some_date = datetime.fromisoformat(some_date)
elif type(some_date) == np.datetime64:
ts = (some_date - np.datetime64('1970-01-01T00:00')) / np.timedelta64(1, 's')
some_date = datetime.utcfromtimestamp(ts)
return some_date
def findDateIndex(date_index: DatetimeIndex, search_date: datetime) -> int:
'''
In a DatetimeIndex, find the index of the date that is nearest to search_date.
This date will either be equal to search_date or the next date that is less than
search_date
'''
index: int = -1
i = 0
search_date = convert_date(search_date)
date_t = datetime.today()
for i in range(0, len(date_index)):
date_t = convert_date(date_index[i])
if date_t >= search_date:
break
if date_t > search_date:
index = i - 1
else:
index = i
return index
# A unit test for findDateIndex, which is a function that is used throughout the code.
test_date_index = DatetimeIndex(['2007-12-03', '2007-12-04', '2007-12-05', '2007-12-06', '2007-12-07',
'2007-12-10', '2007-12-11', '2007-12-12', '2007-12-13', '2007-12-14',
'2007-12-15', '2007-12-18', '2007-12-19'])
assert findDateIndex(test_date_index, datetime.fromisoformat('2007-12-03')) == 0
assert findDateIndex(test_date_index, datetime.fromisoformat('2007-12-09')) == 4
assert findDateIndex(test_date_index, datetime.fromisoformat('2007-12-14')) == 9
assert findDateIndex(test_date_index, datetime.fromisoformat('2007-12-17')) == 10
assert findDateIndex(test_date_index, datetime.fromisoformat('2007-12-20')) == 12
asset_adj_close = equity_adj_close.copy()
asset_adj_close[shy_adj_close.columns[0]] = shy_adj_close
start_date_ix = findDateIndex(asset_adj_close.index, start_date)
assert start_date_ix >= 0
def chooseAssetName(start: int, end: int, asset_set: pd.DataFrame) -> str:
'''
Choose an ETF asset or cash for a particular range of close price values.
The ETF and cash time series should be contained in a single DataFrame
The function returns a DataFrame with the highest returning asset for the
period.
'''
asset_columns = asset_set.columns
asset_name = asset_columns[0]
if len(asset_columns) > 1:
ret_list = []
start_date = asset_set.index[start]
end_date = asset_set.index[end]
for asset in asset_set.columns:
ts = asset_set[asset][start:end+1]
start_val = ts[0]
end_val = ts[-1]
r = (end_val/start_val) - 1
ret_list.append(r)
ret_df = pd.DataFrame(ret_list).transpose()
ret_df.columns = asset_set.columns
ret_df = round(ret_df, 3)
column = ret_df.idxmax(axis=1)[0]
asset_name = column
return asset_name
The ETF rotation set for 60% of the portfolio is composed of the equity ETFs and SHY.
The 12% Solution ETF rebalancing algorithm picks the ETF with the highest three month return or SHY (the proxy for cash). The starting date is the first trading day in March 2008, March 3, 2008. The start of the look-back period is December 1, 2007.
The stock part of the portfolio (60%) is invested in the chosen asset for the next month and the portfolio is rebalanced again.The 40% of the portfolio that is held in bonds is invested in either TLT (the Treasury ETF) or JNK (the high yield corporate bond ETF).
To check that the code is picking the right asset, we can look at the plot showing the percentage return for each of the assets.
def percent_return_df(start_date: datetime, end_date: datetime, prices_df: pd.DataFrame) -> pd.DataFrame:
def percent_return(time_series: pd.Series) -> pd.Series:
return list(((time_series[i] / time_series[0]) - 1.0 for i in range(0, len(time_series))))
date_index = prices_df.index
start_ix = findDateIndex(date_index, start_date)
end_ix = findDateIndex(date_index, end_date)
period_df = prices_df[:][start_ix:end_ix+1]
period_return_df = pd.DataFrame()
for col in period_df.columns:
return_series = percent_return(period_df[col])
period_return_df[col] = return_series
period_return_df.index = period_df.index
return_percent_df = round(period_return_df * 100, 2)
return return_percent_df
quarter_return_df = percent_return_df(start_date=look_back_date, end_date=start_date, prices_df=asset_adj_close)
ax = quarter_return_df.plot(grid=True, title='December 2007 to March 2008 Returns', figsize=(10,6))
ax.set_ylabel('Return Percent')
start_date_ix = findDateIndex(asset_adj_close.index, start_date)
asset_name = chooseAssetName(0, start_date_ix, asset_adj_close)
print(f'The asset for the first three month period will be {asset_name}')
Choose a start date that has three trading months of past data.
From the start date to the current date, stepping by one trading month
The result will be the portfolio total as a DataFrame (with a date index).
trading_days = 252
trading_quarter = trading_days // 4
def simple_return(time_series: np.array, period: int = 1) -> List :
return list(((time_series[i]/time_series[i-period]) - 1.0 for i in range(period, len(time_series), period)))
def return_df(time_series_df: pd.DataFrame) -> pd.DataFrame:
r_df: pd.DataFrame = pd.DataFrame()
time_series_a: np.array = time_series_df.values
return_l = simple_return(time_series_a, 1)
r_df = pd.DataFrame(return_l)
date_index = time_series_df.index
r_df.index = date_index[1:len(date_index)]
r_df.columns = time_series_df.columns
return r_df
def apply_return(start_val: float, return_df: pd.DataFrame) -> np.array:
port_a: np.array = np.zeros( return_df.shape[0] + 1)
port_a[0] = start_val
return_a = return_df.values
for i in range(1, len(port_a)):
port_a[i] = port_a[i-1] + port_a[i-1] * return_a[i-1]
return port_a
def find_month_periods(start_date: datetime, end_date:datetime, data: pd.DataFrame) -> pd.DataFrame:
start_date = convert_date(start_date)
end_date = convert_date(end_date)
date_index = data.index
start_ix = findDateIndex(date_index, start_date)
end_ix = findDateIndex(date_index, end_date)
start_l = list()
end_l = list()
cur_month = start_date.month
start_l.append(start_ix)
i = 0
for i in range(start_ix, end_ix+1):
date_i = convert_date(date_index[i])
if date_i.month != cur_month:
end_l.append(i-1)
start_l.append(i)
cur_month = date_i.month
end_l.append(i)
# if there is note something like a full month period, remove the last period
if end_l[-1] - start_l[-1] < 18:
end_l.pop()
start_l.pop()
start_df = pd.DataFrame(start_l)
end_df = pd.DataFrame(end_l)
start_date_df = pd.DataFrame(date_index[start_l])
end_date_df = pd.DataFrame(date_index[end_l])
periods_df = pd.concat([start_df, start_date_df, end_df, end_date_df], axis=1)
periods_df.columns = ['start_ix', 'start_date', 'end_ix', 'end_date']
return periods_df
def portfolio_return(holdings: float,
asset_percent: float,
bond_percent: float,
asset_etfs: pd.DataFrame,
bond_etfs: pd.DataFrame,
start_date: datetime,
end_date: datetime) -> Tuple[pd.DataFrame, pd.DataFrame]:
"""
An implementation of the 12% Solution ETF rotation. From start_date to end_date
the code calculates the highest past three month return from the asset_etfs and bond_etfs. The
ETF with the highest return is selected for the next month.
Calculate the monthly period from start_date to end_date. This will provide the monthly periods
for the calculation.
The daily returns are calculated for the next month and applied to the current portfolio balance.
Then the loop steps forward by one month.
"""
assert asset_etfs.shape[0] == bond_etfs.shape[0]
periods_df = find_month_periods(start_date, end_date, asset_etfs)
date_index = asset_etfs.index
bond_asset_l = list()
equity_asset_l = list()
month_index_l = list()
portfolio_a = np.zeros(0)
for row in range(periods_df.shape[0]):
asset_holdings = holdings * asset_percent
bond_holdings = holdings * bond_percent
period_info = periods_df[:][row:row+1]
month_start_date = convert_date(period_info['start_date'].values[0])
month_start_ix = period_info['start_ix'].values[0]
month_end_ix = period_info['end_ix'].values[0]
back_start_ix = (month_start_ix - trading_quarter) if (month_start_ix - trading_quarter) >= 0 else 0
equity_asset = chooseAssetName(start=back_start_ix, end=month_start_ix, asset_set=asset_etfs)
bond_asset = chooseAssetName(start=back_start_ix, end=month_start_ix, asset_set=bond_etfs)
equity_asset_l.append(equity_asset)
bond_asset_l.append(bond_asset)
month_index_l.append(month_start_date)
asset_month_prices_df = pd.DataFrame(asset_etfs[equity_asset][month_start_ix:month_end_ix + 1])
bond_month_prices_df = pd.DataFrame(bond_etfs[bond_asset][month_start_ix:month_end_ix + 1])
asset_month_return_df = return_df(asset_month_prices_df)
bond_month_return_df = return_df(bond_month_prices_df)
asset_month_a = apply_return(asset_holdings, asset_month_return_df)
bond_month_a = apply_return(bond_holdings, bond_month_return_df)
portfolio_total_a = asset_month_a + bond_month_a
holdings = portfolio_total_a[-1]
portfolio_a = np.append(portfolio_a, portfolio_total_a)
portfolio_df = pd.DataFrame(portfolio_a)
portfolio_df.columns = ['portfolio']
num_rows = periods_df.shape[0]
first_row = periods_df[:][0:1]
last_row = periods_df[:][num_rows - 1:num_rows]
start_ix = first_row['start_ix'].values[0]
end_ix = last_row['end_ix'].values[0]
portfolio_index = date_index[start_ix:end_ix + 1]
portfolio_df.index = portfolio_index
choices_df = pd.DataFrame()
choices_df['Equity'] = pd.DataFrame(equity_asset_l)
choices_df['Bond'] = pd.DataFrame(bond_asset_l)
choices_df.index = month_index_l
return portfolio_df, choices_df
def portfolio_replay(holdings: float,
asset_percent: float,
bond_percent: float,
asset_etfs: pd.DataFrame,
bond_etfs: pd.DataFrame,
etf_selection: pd.DataFrame) -> pd.DataFrame:
"""
Calculate the portfolio time series from a pre-defined set of ETFs (e.g., equity and bond)
The etf_selection_df DataFrame has the following columns:
start_ix start_date end_ix end_date equity bond
"""
assert asset_etfs.shape[0] == bond_etfs.shape[0]
date_index = asset_etfs.index
portfolio_a = np.zeros(0)
for index, month_allocation in etf_selection.iterrows():
asset_holdings = holdings * asset_percent
bond_holdings = holdings * bond_percent
month_start_ix = month_allocation['start_ix']
month_end_ix = month_allocation['end_ix']
equity_asset = month_allocation['equity']
bond_asset = month_allocation['bond']
asset_month_prices_df = pd.DataFrame(asset_etfs[equity_asset][month_start_ix:month_end_ix + 1])
bond_month_prices_df = pd.DataFrame(bond_etfs[bond_asset][month_start_ix:month_end_ix + 1])
asset_month_return_df = return_df(asset_month_prices_df)
bond_month_return_df = return_df(bond_month_prices_df)
asset_month_a = apply_return(asset_holdings, asset_month_return_df)
bond_month_a = apply_return(bond_holdings, bond_month_return_df)
portfolio_total_a = asset_month_a + bond_month_a
holdings = portfolio_total_a[-1]
portfolio_a = np.append(portfolio_a, portfolio_total_a)
portfolio_df = pd.DataFrame(portfolio_a)
portfolio_df.columns = ['portfolio']
num_rows = etf_selection.shape[0]
first_row = etf_selection[:][0:1]
last_row = etf_selection[:][num_rows - 1:num_rows]
start_ix = first_row['start_ix'].values[0]
end_ix = last_row['end_ix'].values[0]
portfolio_index = date_index[start_ix:end_ix+1]
portfolio_df.index = portfolio_index
return portfolio_df
holdings = 100000
equity_percent = 0.6
bond_percent = 0.4
tlt = pd.DataFrame(fixed_income_adjclose['TLT'])
portfolio_df, assets_df = portfolio_return(holdings=holdings,
asset_percent=equity_percent,
bond_percent=bond_percent,
asset_etfs=asset_adj_close,
bond_etfs=tlt,
start_date=start_date,
end_date=end_date)
def build_plot_data(holdings: float, portfolio_df: pd.DataFrame, spy_df: pd.DataFrame) -> pd.DataFrame:
port_start_date = portfolio_df.index[0]
port_start_date = convert_date(port_start_date)
port_end_date = portfolio_df.index[-1]
port_end_date = convert_date(port_end_date)
spy_index = spy_df.index
spy_start_ix = findDateIndex(spy_index, port_start_date)
spy_end_ix = findDateIndex(spy_index, port_end_date)
spy_df = pd.DataFrame(spy_df[:][spy_start_ix:spy_end_ix+1])
spy_return = return_df(spy_df)
spy_return_a = apply_return(start_val=holdings, return_df=spy_return)
spy_port = pd.DataFrame(spy_return_a)
spy_port.columns = ['SPY']
spy_port.index = spy_df.index
plot_df = portfolio_df.copy()
plot_df['SPY'] = spy_port
return plot_df
def adjust_time_series(ts_one_df: pd.DataFrame, ts_two_df: pd.DataFrame) -> Tuple[pd.DataFrame, pd.DataFrame]:
"""
Adjust two DataFrame time series with overlapping date indices so that they
are the same length with the same date indices.
"""
ts_one_index = pd.to_datetime(ts_one_df.index)
ts_two_index = pd.to_datetime(ts_two_df.index)
# filter the close prices
matching_dates = ts_one_index.isin( ts_two_index )
ts_one_adj = ts_one_df[matching_dates]
# filter the rf_prices
ts_one_index = pd.to_datetime(ts_one_adj.index)
matching_dates = ts_two_index.isin(ts_one_index)
ts_two_adj = ts_two_df[matching_dates]
return ts_one_adj, ts_two_adj
#
# Test for build_plot_data to make sure that the SPY component is handled correctly.
#
spy_unadj = pd.DataFrame(asset_adj_close['SPY'])
start_ix = findDateIndex(spy_unadj.index, d2019_start)
end_ix = findDateIndex(spy_unadj.index, d2019_end)
start_val = spy_unadj[spy_unadj.columns[0]].values[start_ix]
spy_section = pd.DataFrame(spy_unadj[spy_unadj.columns[0]][start_ix:end_ix+1])
spy_section.columns = ['portfolio']
plot_df = build_plot_data(start_val, spy_section, spy_unadj)
portfolio_a = plot_df[plot_df.columns[0]].values
spy_a = plot_df[plot_df.columns[1]].values
assert all( np.round(portfolio_a, 2) == np.round(spy_a, 2) )
#
# End test
#
The results in this notebook do not match the results shown in the book The 12% Solution or on the author's website https://www.trendlineprofits.com I have tried to make sure that the differences are not a result of errors in this notebook's code.
The plot below was generated by the portfolio calculation code using an equity and bond set composed only of SPY for 2019. The portfolio result should closely match the result for SPY.
spyonly_df, t = portfolio_return(holdings=holdings,
asset_percent=equity_percent,
bond_percent=bond_percent,
asset_etfs=spy_unadj,
bond_etfs=spy_unadj,
start_date=d2019_start,
end_date=d2019_end)
plot_df = build_plot_data(holdings, spyonly_df, spy_unadj)
plot_df.plot(grid=True, title='SPY Only', figsize=(10,6))
spy_df = pd.DataFrame(equity_adj_close['SPY'])
spy_df, portfolio_df = adjust_time_series(spy_df, portfolio_df)
plot_df = build_plot_data(holdings, portfolio_df, spy_df)
In the book The 12% Solution the first example uses ETF rotation and a single bond ETF, TLT (no bond rotation).
The plot below shows the results of the ETF rotation portfolio composed of 60% equity ETFs (or SHY) and 40% TLT.
The author of The 12% Solution notes that the ETF rotation portfolio has better performance than the S&P 500 (e.g., the SPY ETF) for a ten year period from 2008 to 2017. This changes after 2019.
plot_df.plot(grid=True, title='Portfolio (TLT bond only) and SPY', figsize=(10,6))
spy_return = return_df(spy_df)
port_return = return_df(portfolio_df)
spy_volatility = round(spy_return.values.std() * sqrt(trading_days) * 100, 2)
port_volatility = round(port_return.values.std() * sqrt(trading_days) * 100, 2)
vol_df = pd.DataFrame([port_volatility, spy_volatility])
vol_df.columns = ['Yearly Standard Deviation (percent)']
vol_df.index = ['Portfolio', 'SPY']
The volatility (risk) of the ETF selection portfolio is significantly less than the SPY ETF which tracks the S&P 500.
print(tabulate(vol_df, headers=[*vol_df.columns], tablefmt='fancy_grid'))
def excess_return_series(asset_return: pd.Series, risk_free: pd.Series) -> pd.DataFrame:
excess_ret = asset_return.values.flatten() - risk_free.values.flatten()
excess_ret_df = pd.DataFrame(excess_ret, index=asset_return.index)
return excess_ret_df
def excess_return_df(asset_return: pd.DataFrame, risk_free: pd.Series) -> pd.DataFrame:
excess_df: pd.DataFrame = pd.DataFrame()
for col in asset_return.columns:
e_df = excess_return_series(asset_return[col], risk_free)
e_df.columns = [col]
excess_df[col] = e_df
return excess_df
def calc_sharpe_ratio(asset_return: pd.DataFrame, risk_free: pd.Series, period: int) -> pd.DataFrame:
excess_return = excess_return_df(asset_return, risk_free)
return_mean: List = []
return_stddev: List = []
for col in excess_return.columns:
mu = np.mean(excess_return[col])
std = np.std(excess_return[col])
return_mean.append(mu)
return_stddev.append(std)
# daily Sharpe ratio
# https://quant.stackexchange.com/questions/2260/how-to-annualize-sharpe-ratio
sharpe_ratio = (np.asarray(return_mean) / np.asarray(return_stddev)) * np.sqrt(period)
result_df: pd.DataFrame = pd.DataFrame(sharpe_ratio).transpose()
result_df.columns = asset_return.columns
ix = asset_return.index
dateformat = '%Y-%m-%d'
ix_start = ix[0]
ix_start = convert_date(ix_start)
ix_end = ix[len(ix)-1]
ix_end = convert_date(ix_end)
index_str = f'{ix_start} : {ix_end}'
result_df.index = [ index_str ]
return result_df
# Interest rates are quoted for the days when banks are open. The number of bank open days is less than
# the number of trading days. Adjust the portfolio_return series and the interest rate series so that they
# align.
rf_daily_adj, portfolio_return_adj = adjust_time_series(rf_daily_df, port_return)
spy_return_adj, t = adjust_time_series(spy_return, rf_daily_adj)
rf_daily_s = rf_daily_adj.squeeze()
portfolio_sharpe = calc_sharpe_ratio(portfolio_return_adj, rf_daily_s, trading_days)
spy_sharpe = calc_sharpe_ratio(spy_return_adj, rf_daily_s, trading_days)
sharpe_df = pd.concat([portfolio_sharpe, spy_sharpe], axis=1)
The yearly Sharpe ratio for the ETF rotation portfolio (TLT bond only) and SPY is shown below.
print(tabulate(sharpe_df, headers=[*sharpe_df.columns], tablefmt='fancy_grid'))
def period_return(portfolio_df: pd.DataFrame, period: int) -> pd.DataFrame:
date_index = portfolio_df.index
values_a = portfolio_df.values
date_list = list()
return_list = list()
for i in range(period, len(values_a), period):
r = (values_a[i]/values_a[i-period]) - 1
d = date_index[i]
return_list.append(r)
date_list.append(d)
return_df = pd.DataFrame(return_list)
return_df.index = date_list
return return_df
period_return_df = period_return(portfolio_df=portfolio_df, period=trading_days)
spy_period_return_df = period_return(portfolio_df=spy_df, period=trading_days)
portfolio_spy_return_df = pd.concat([period_return_df, spy_period_return_df], axis=1)
portfolio_spy_return_df.columns = ['ETF Rotation', 'SPY']
portfolio_spy_return_df = round(portfolio_spy_return_df * 100, 2)
print(tabulate(portfolio_spy_return_df, headers=[*portfolio_spy_return_df.columns], tablefmt='fancy_grid'))
average_return_df = pd.DataFrame(portfolio_spy_return_df.mean()).transpose()
print(tabulate(average_return_df, headers=[*average_return_df.columns], tablefmt='fancy_grid'))
This portfolio starts out with 60% of the portfolio in equity ETFs or the cash ETF and 40% in one or more bond ETFs. If the portfolio were not rebalanced, over time the portion of the portfolio allocated to equity ETFs (and cash) would drift upward, since equities usually have a higher return than bonds.
The portfolio is rebalanced monthly as each ETF is chosen. In the All Weather Portfolio rebalancing the portfolio every year results in better portfolio results compared to rebalancing monthly or quarterly.
In the case of the 12% Solution portfolio, rebalancing yearly only resulted in a small improvement in portfolio results.
portfolio_df, assets_df = portfolio_return(holdings=holdings,
asset_percent=equity_percent,
bond_percent=bond_percent,
asset_etfs=asset_adj_close,
bond_etfs=fixed_income_adjclose,
start_date=start_date,
end_date=end_date)
plot_df = build_plot_data(holdings, portfolio_df, spy_df)
The second example of ETF rotation in The 12% Solution adds bond rotation. The 40% of the portfolio allocated to bonds is rotated between TLT and JNK (a "high-yield" corporate bond ETF). As with the equity ETFs, the bond ETF with the highest return in the past three months is chosen for the current month.
In the case of the bond ETF rotation, the cash ETF SHY is not added to the bond set (an example with SHY added to the bond set is explored later in the notebook).
The plot below shows the performance of the ETF rotation portfolio, with bond rotation (TLT and JNK).
plot_df.plot(grid=True, title='Portfolio (TLT and JNK bond rotation) and SPY', figsize=(10,6))
The addition of bond ETF rotation slightly increased the standard deviation, which resulted in a slight decrease in the Sharpe ratio.
port_return = return_df(portfolio_df)
port_volatility = round(port_return.values.std() * sqrt(trading_days) * 100, 2)
vol_df = pd.DataFrame([port_volatility, spy_volatility])
vol_df.columns = ['Yearly Standard Deviation (percent)']
vol_df.index = ['Portfolio', 'SPY']
print(tabulate(vol_df, headers=[*vol_df.columns], tablefmt='fancy_grid'))
rf_daily_adj, portfolio_return_adj = adjust_time_series(rf_daily_df, port_return)
portfolio_sharpe = calc_sharpe_ratio(portfolio_return_adj, rf_daily_s, trading_days)
sharpe_df = pd.concat([portfolio_sharpe, spy_sharpe], axis=1)
print(tabulate(sharpe_df, headers=[*sharpe_df.columns], tablefmt='fancy_grid'))
Adding ETF rotation to the bond portion of the portfolio results in performance that is close to the S&P 500 with half the volatility (risk).
The table below shows the yearly percentage returns for the portfolio without bond ETF rotation, the portfolio with bond ETF rotation and SPY (S&P 500).
period_return_bond_df = period_return(portfolio_df=portfolio_df, period=trading_days)
portfolio_spy_return_df = pd.concat([period_return_df, period_return_bond_df, spy_period_return_df], axis=1)
portfolio_spy_return_df.columns = ['ETF Rotation','ETF w/bond rotation', 'SPY']
portfolio_spy_return_df = round(portfolio_spy_return_df * 100, 2)
print(tabulate(portfolio_spy_return_df, headers=[*portfolio_spy_return_df.columns], tablefmt='fancy_grid'))
average_return_df = pd.DataFrame(portfolio_spy_return_df.mean()).transpose()
The book The 12% Solution is named for the 12% average yearly return from the ETF rotation portfolio that includes bond rotation. The average return in the notebook is less than the average return reported in the book.
The time period in the book ends in 2017. The book did not include the market volatility from COVID and the Russian invasion of Ukraine. As we can see in the plots above, the ETF rotation algorithm out performs the S&P 500 until 2019, when the S&P 500 begins to have a higher return.
print(tabulate(average_return_df, headers=[*average_return_df.columns], tablefmt='fancy_grid'))
The author of The 12% Solution, David Alan Carter, points out that one of the best ways to make money on the stock market is to avoid losses. The ETF rotation algorithm beats the S&P 500 (SPY) from 2008 to 2019. This section looks at how much of this performance is due to the fact that the 2008 market crash was avoided.
d2010_start: datetime = datetime.fromisoformat('2010-01-04')
d2010_portfolio_df, assets_df = portfolio_return(holdings=holdings,
asset_percent=equity_percent,
bond_percent=bond_percent,
asset_etfs=asset_adj_close,
bond_etfs=fixed_income_adjclose,
start_date=d2010_start,
end_date=end_date)
plot_df = build_plot_data(holdings, d2010_portfolio_df, spy_df)
plot_df.plot(grid=True, title='Portfolio (TLT and JNK bond rotation) from 2010 and SPY', figsize=(10,6))
d2010_portfolio_df, d2010_spy_df = adjust_time_series(d2010_portfolio_df, spy_df)
period_return_df = period_return(portfolio_df=d2010_portfolio_df, period=trading_days)
spy_period_return_df = period_return(portfolio_df=d2010_spy_df, period=trading_days)
portfolio_spy_return_df = pd.concat([period_return_df, spy_period_return_df], axis=1)
portfolio_spy_return_df.columns = ['ETF Rotation', 'SPY']
portfolio_spy_return_df = round(portfolio_spy_return_df * 100, 2)
The yearly returns are shown below
print(tabulate(portfolio_spy_return_df, headers=[*portfolio_spy_return_df], tablefmt='fancy_grid'))
d2010_port_return = return_df(d2010_portfolio_df)
d2010_spy_return = return_df(d2010_spy_df)
spy_volatility = round(d2010_spy_return.values.std() * sqrt(trading_days) * 100, 2)
port_volatility = round(d2010_port_return.values.std() * sqrt(trading_days) * 100, 2)
vol_df = pd.DataFrame([port_volatility, spy_volatility])
vol_df.columns = ['Yearly Standard Deviation (percent)']
vol_df.index = ['Portfolio', 'SPY']
The volatility of the ETF rotation portfolio is around two thirds of the S&P 500 volatility.
print(tabulate(vol_df, headers=[*vol_df.columns], tablefmt='fancy_grid'))
average_return_df = pd.DataFrame(portfolio_spy_return_df.mean()).transpose()
print(tabulate(average_return_df, headers=[*average_return_df.columns], tablefmt='fancy_grid'))
Investment is always a balance between risk and reward. Investing in an ETF rotation portfolio in 2010 has about sixty percent of the risk of the S&P 500. The average return is also about sixty percent of the S&P 500.
The book The 12% Solution has an associated website www.trendlineprofits.com that is available free for people who purchased the book. This website includes performance information on The 12% Solution portfolio. These results in the book and on the website do not match the results calculated in this notebook.
In this section the plots calculated in this notebook and those from trendlineprofits.com are shown.
In all cases the portfolio return reported on trendlineprofits.com is higher than the portfolio returns calculated in this notebook.
five_year_start = datetime.fromisoformat("2017-01-03")
three_year_start = datetime.fromisoformat("2019-01-02")
one_year_start = datetime.fromisoformat("2021-01-04")
march_25_2022_end = datetime.fromisoformat("2022-03-25")
max_portfolio_df, assets_df = portfolio_return(holdings=holdings,
asset_percent=equity_percent,
bond_percent=bond_percent,
asset_etfs=asset_adj_close,
bond_etfs=fixed_income_adjclose,
start_date=start_date,
end_date=march_25_2022_end)
plot_max_df = build_plot_data(holdings, max_portfolio_df, spy_df)
five_year_portfolio_df, assets_df = portfolio_return(holdings=holdings,
asset_percent=equity_percent,
bond_percent=bond_percent,
asset_etfs=asset_adj_close,
bond_etfs=fixed_income_adjclose,
start_date=five_year_start,
end_date=march_25_2022_end)
plot_five_df = build_plot_data(holdings, five_year_portfolio_df, spy_df)
three_year_portfolio_df, assets_df = portfolio_return(holdings=holdings,
asset_percent=equity_percent,
bond_percent=bond_percent,
asset_etfs=asset_adj_close,
bond_etfs=fixed_income_adjclose,
start_date=three_year_start,
end_date=march_25_2022_end)
plot_three_df = build_plot_data(holdings, three_year_portfolio_df, spy_df)
one_year_portfolio_df, assets_df = portfolio_return(holdings=holdings,
asset_percent=equity_percent,
bond_percent=bond_percent,
asset_etfs=asset_adj_close,
bond_etfs=fixed_income_adjclose,
start_date=one_year_start,
end_date=march_25_2022_end)
plot_one_df = build_plot_data(holdings, one_year_portfolio_df, spy_df)
plot_max_df.plot(grid=True, title=f'ETF Rotation and SPY: {start_date.strftime("%m/%d/%Y")} - {march_25_2022_end.strftime("%m/%d/%Y")}', figsize=(10,6))
Image(filename='images/the-12-total-return-2008-to-ytd-03-25-2022_orig.jpg')
plot_five_df.plot(grid=True, title=f'5-years: {five_year_start.strftime("%m/%d/%Y")} - {march_25_2022_end.strftime("%m/%d/%Y")} and SPY', figsize=(10,6))
Image(filename='images/the-12-total-return-2017-to-ytd-03-25-2022_orig.jpg')