ETF Rotation
by
Ian Kaplan
April 2022

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.

The ETF Rotation Set

Equity ETFs

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

Bond ETFs

  • 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

Cash Proxy

  • 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).

In [111]:
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)

ETF Correlation

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.

In [112]:
print(tabulate(corr_mat, headers=[*corr_mat.columns], tablefmt='fancy_grid'))
╒═════╤═══════╤═══════╤═══════╤═══════╕
│     │   IWM │   MDY │   QQQ │   SPY │
╞═════╪═══════╪═══════╪═══════╪═══════╡
│ IWM │ 1     │ 0.997 │ 0.953 │ 0.981 │
├─────┼───────┼───────┼───────┼───────┤
│ MDY │ 0.997 │ 1     │ 0.955 │ 0.986 │
├─────┼───────┼───────┼───────┼───────┤
│ QQQ │ 0.953 │ 0.955 │ 1     │ 0.988 │
├─────┼───────┼───────┼───────┼───────┤
│ SPY │ 0.981 │ 0.986 │ 0.988 │ 1     │
╘═════╧═══════╧═══════╧═══════╧═══════╛

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.

Asset return over time

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 $

In [113]:
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

Start Period

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.

In [114]:
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}')
The asset for the first three month period will be SHY

ETF Rotation Algorithm

  1. Create a DataFrame that constains adjusted close price for the ETF universe and the cash ETF (SHY). This will be referred to as the asset DataFrame
  2. Create a DataFrame that contains the adjusted close price for the bond ETF universe. This will either be TLT or TLT and JNK.
  3. 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

    1. Choose the asset with the highest return over the past three trading months from the asset DataFrame (e.g., an equity or the cash ETF).
    2. Choose the bond asset with the highest return over the past three trading months from the bond DataFrame.
    3. Calculate the daily return for the selected asset for one month forward from the start date.
    4. Calculate the daily return for one month forward from the start date for the bond asset.
    5. Apply the asset return to 60% of the current portfolio forward for one trading month
    6. Apply the bond return to 40% of the current portfolio forward for one trading month
    7. Add the two portfolio components to produce the total portfolio value for the month
    8. Concatenate the current portfolio total to the portfolio total series that will be used to plot the portfolio performance.
  4. Move the start date forward by one month of trading days

The result will be the portfolio total as a DataFrame (with a date index).

In [115]:
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
#

Checking the Code for Portfolio Calculation

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.

In [116]:
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)

Portfolio Results (without bond rotation)

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.

In [117]:
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.

In [118]:
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)
╒═══════════╤═══════════════════════════════════════╕
│           │   Yearly Standard Deviation (percent) │
╞═══════════╪═══════════════════════════════════════╡
│ Portfolio │                                  9.96 │
├───────────┼───────────────────────────────────────┤
│ SPY       │                                 20.49 │
╘═══════════╧═══════════════════════════════════════╛

Sharpe Ratio

The yearly Sharpe ratio for the ETF rotation portfolio (TLT bond only) and SPY is shown below.

In [119]:
print(tabulate(sharpe_df, headers=[*sharpe_df.columns], tablefmt='fancy_grid'))
╒═══════════════════════════════════════════╤═════════════╤══════════╕
│                                           │   portfolio │      SPY │
╞═══════════════════════════════════════════╪═════════════╪══════════╡
│ 2008-03-04 00:00:00 : 2022-03-31 00:00:00 │    0.843345 │ 0.604977 │
╘═══════════════════════════════════════════╧═════════════╧══════════╛

Portfolio Return

In [120]:
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)

Percentage return for the ETF Rotation Portfolio and SPY

In [121]:
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()
╒════════════╤════════════════╤════════╕
│            │   ETF Rotation │    SPY │
╞════════════╪════════════════╪════════╡
│ 2009-03-03 │           0.45 │ -46.25 │
├────────────┼────────────────┼────────┤
│ 2010-03-03 │          20.35 │  63.98 │
├────────────┼────────────────┼────────┤
│ 2011-03-02 │          12.67 │  19.13 │
├────────────┼────────────────┼────────┤
│ 2012-03-01 │          13.72 │   7.17 │
├────────────┼────────────────┼────────┤
│ 2013-03-05 │           0.93 │  14.51 │
├────────────┼────────────────┼────────┤
│ 2014-03-05 │          16.52 │  24.14 │
├────────────┼────────────────┼────────┤
│ 2015-03-05 │          16.16 │  14.29 │
├────────────┼────────────────┼────────┤
│ 2016-03-04 │          -5.25 │  -2.8  │
├────────────┼────────────────┼────────┤
│ 2017-03-06 │           7.25 │  21.15 │
├────────────┼────────────────┼────────┤
│ 2018-03-06 │          16.27 │  17.03 │
├────────────┼────────────────┼────────┤
│ 2019-03-07 │           1.72 │   2.69 │
├────────────┼────────────────┼────────┤
│ 2020-03-06 │          15.24 │  10.21 │
├────────────┼────────────────┼────────┤
│ 2021-03-08 │          11.97 │  30.72 │
├────────────┼────────────────┼────────┤
│ 2022-03-07 │           0.05 │  11.34 │
╘════════════╧════════════════╧════════╛

Average Return (percent)

In [122]:
print(tabulate(average_return_df, headers=[*average_return_df.columns], tablefmt='fancy_grid'))
╒════╤════════════════╤═════════╕
│    │   ETF Rotation │     SPY │
╞════╪════════════════╪═════════╡
│  0 │        9.14643 │ 13.3793 │
╘════╧════════════════╧═════════╛

Rebalancing

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.

In [123]:
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)

Adding in Bond Rotation

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).

In [124]:
plot_df.plot(grid=True, title='Portfolio (TLT and JNK bond rotation) and SPY', figsize=(10,6))
Out[124]:
<AxesSubplot:title={'center':'Portfolio (TLT and JNK bond rotation) and SPY'}, xlabel='Date'>

Standard Deviation and Sharpe Ratio

The addition of bond ETF rotation slightly increased the standard deviation, which resulted in a slight decrease in the Sharpe ratio.

In [125]:
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']

Yearly Standard Deviation (with bond rotation)

In [126]:
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)
╒═══════════╤═══════════════════════════════════════╕
│           │   Yearly Standard Deviation (percent) │
╞═══════════╪═══════════════════════════════════════╡
│ Portfolio │                                 11.15 │
├───────────┼───────────────────────────────────────┤
│ SPY       │                                 20.49 │
╘═══════════╧═══════════════════════════════════════╛

Sharpe Ratio (with bond rotation)

In [127]:
print(tabulate(sharpe_df, headers=[*sharpe_df.columns], tablefmt='fancy_grid'))
╒═══════════════════════════════════════════╤═════════════╤══════════╕
│                                           │   portfolio │      SPY │
╞═══════════════════════════════════════════╪═════════════╪══════════╡
│ 2008-03-04 00:00:00 : 2022-03-31 00:00:00 │    0.854414 │ 0.604977 │
╘═══════════════════════════════════════════╧═════════════╧══════════╛

Portfolio Return with Bond Rotation

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).

In [128]:
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()
╒════════════╤════════════════╤═══════════════════════╤════════╕
│            │   ETF Rotation │   ETF w/bond rotation │    SPY │
╞════════════╪════════════════╪═══════════════════════╪════════╡
│ 2009-03-03 │           0.45 │                 -1.57 │ -46.25 │
├────────────┼────────────────┼───────────────────────┼────────┤
│ 2010-03-03 │          20.35 │                 42.69 │  63.98 │
├────────────┼────────────────┼───────────────────────┼────────┤
│ 2011-03-02 │          12.67 │                 14.93 │  19.13 │
├────────────┼────────────────┼───────────────────────┼────────┤
│ 2012-03-01 │          13.72 │                 15.03 │   7.17 │
├────────────┼────────────────┼───────────────────────┼────────┤
│ 2013-03-05 │           0.93 │                 -1.97 │  14.51 │
├────────────┼────────────────┼───────────────────────┼────────┤
│ 2014-03-05 │          16.52 │                 17.68 │  24.14 │
├────────────┼────────────────┼───────────────────────┼────────┤
│ 2015-03-05 │          16.16 │                 16.16 │  14.29 │
├────────────┼────────────────┼───────────────────────┼────────┤
│ 2016-03-04 │          -5.25 │                 -6.88 │  -2.8  │
├────────────┼────────────────┼───────────────────────┼────────┤
│ 2017-03-06 │           7.25 │                 10.4  │  21.15 │
├────────────┼────────────────┼───────────────────────┼────────┤
│ 2018-03-06 │          16.27 │                 16.36 │  17.03 │
├────────────┼────────────────┼───────────────────────┼────────┤
│ 2019-03-07 │           1.72 │                 -4.25 │   2.69 │
├────────────┼────────────────┼───────────────────────┼────────┤
│ 2020-03-06 │          15.24 │                  9.55 │  10.21 │
├────────────┼────────────────┼───────────────────────┼────────┤
│ 2021-03-08 │          11.97 │                 21.7  │  30.72 │
├────────────┼────────────────┼───────────────────────┼────────┤
│ 2022-03-07 │           0.05 │                 -2.69 │  11.34 │
╘════════════╧════════════════╧═══════════════════════╧════════╛

Average Return (percent)

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.

In [129]:
print(tabulate(average_return_df, headers=[*average_return_df.columns], tablefmt='fancy_grid'))
╒════╤════════════════╤═══════════════════════╤═════════╕
│    │   ETF Rotation │   ETF w/bond rotation │     SPY │
╞════╪════════════════╪═══════════════════════╪═════════╡
│  0 │        9.14643 │                 10.51 │ 13.3793 │
╘════╧════════════════╧═══════════════════════╧═════════╛

ETF Rotation from 2010 to present

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.

In [130]:
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)

Yearly Return

The yearly returns are shown below

In [131]:
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']
╒════════════╤════════════════╤═══════╕
│            │   ETF Rotation │   SPY │
╞════════════╪════════════════╪═══════╡
│ 2011-01-03 │          10.38 │ 14.31 │
├────────────┼────────────────┼───────┤
│ 2012-01-03 │          11.78 │  2.46 │
├────────────┼────────────────┼───────┤
│ 2013-01-04 │          -0.05 │ 17.35 │
├────────────┼────────────────┼───────┤
│ 2014-01-06 │          15.65 │ 27.1  │
├────────────┼────────────────┼───────┤
│ 2015-01-06 │          19.57 │ 11.72 │
├────────────┼────────────────┼───────┤
│ 2016-01-06 │          -5.86 │  1.55 │
├────────────┼────────────────┼───────┤
│ 2017-01-05 │          10.36 │ 16.32 │
├────────────┼────────────────┼───────┤
│ 2018-01-05 │          13.9  │ 23.12 │
├────────────┼────────────────┼───────┤
│ 2019-01-08 │          -2.67 │ -4.31 │
├────────────┼────────────────┼───────┤
│ 2020-01-08 │          11.61 │ 28.75 │
├────────────┼────────────────┼───────┤
│ 2021-01-07 │          22.68 │ 19.03 │
├────────────┼────────────────┼───────┤
│ 2022-01-06 │           1.61 │ 25.08 │
╘════════════╧════════════════╧═══════╛

Volatility

The volatility of the ETF rotation portfolio is around two thirds of the S&P 500 volatility.

In [132]:
print(tabulate(vol_df, headers=[*vol_df.columns], tablefmt='fancy_grid'))
╒═══════════╤═══════════════════════════════════════╕
│           │   Yearly Standard Deviation (percent) │
╞═══════════╪═══════════════════════════════════════╡
│ Portfolio │                                 10.76 │
├───────────┼───────────────────────────────────────┤
│ SPY       │                                 17.04 │
╘═══════════╧═══════════════════════════════════════╛

Average Portfolio Return

In [133]:
average_return_df = pd.DataFrame(portfolio_spy_return_df.mean()).transpose()
print(tabulate(average_return_df, headers=[*average_return_df.columns], tablefmt='fancy_grid'))
╒════╤════════════════╤═════════╕
│    │   ETF Rotation │     SPY │
╞════╪════════════════╪═════════╡
│  0 │           9.08 │ 15.2067 │
╘════╧════════════════╧═════════╛

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.

Notebook results and 12% Solution Results

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.

In [134]:
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)

2008 to March 25, 2022

In [135]:
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')
Out[135]:

5 Years: 2017 to March 25, 2022

In [136]:
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')
Out[136]: