This Python Jupyter notebook explores investment portfolios that have decent return (approximately 8 percent on average over the last ten years) and lower risk than the overall stock market.
If you are fortunate to have some cash available for investment you have to decide how to invest this cash (not making an explicit investment decision is a decision).
The US stock market, over the long term, has yielded good returns. Unfortunately it is an inescapable fact that risk and return are related. The returns provided by the stock market come with the risk of losses in your investment portfolio, at least in the short to medium term. At the time this notebook was written (November 2021) the stock market had dramatic returns after a COVID-19 inspired market crash. In such a time it is important to remember that there have been many periods where "the market" has had substantial downturns and low returns.
As any good financial adviser will tell you, your investment strategy should depend on your stage in life. Is retirement decades away, close or are you retired?
If your retirement is decades away, one of the simplest ways to achieve good investment returns is "dollar cost averaging" where you invest a certain amount every month in one or more low fee market index funds or ETFs.
When there is a market downturn your share purchase cost will be lower resulting in gains when the market recovers. Many people take advantage of dollar cost averaging by allocating a fraction of their salary for investment in their employer's 401K retirement plan.
If you are at or near retirement age then the inevitable market downturns are much less acceptable since you will not be able to take advantage of market cycles that could last for years.
This notebook explores conservative investment portfolios that have lower risk and lower correlation with the stock market (a.k.a., lower market beta). These are portfolios that may be appropriate for people who are retired or near retirement.
The portfolio that originally inspired this notebook is based on the Bridgewater Associates "All Weather" portfolio proposed by Bridgewater founder Ray Dalio. This portfolio is discussed in a Bridgewater promotional white paper The All Weather Story From the white paper:
What the average person needs is a good, reliable asset allocation they can hold for the long-run. Bridgewater’s answer is All Weather, the result of three decades of learning how to invest in the face of uncertainty.
The results in this notebook show that, in the last ten years (from 2021) the performance a portfolio that is similar to the Bridgewater "all weather" portfolio is no better than a simple 40 percent stock, 60 percent bond portfolio that is often recommended for those at or near retirement. This notebook shows a portfolio consisting of just two ETFs VTI (40%) and SCHP (60%) has less volatility than the "all weather" portfolio with returns that are only slightly lower.
The 40% market/60% bond mix is difficult to beat when it comes to risk and return. A number of dividend assets are examined in this notebook in an attempt to find a portfolio with higher return at a similar risk level. This search was not successful.
Most of the ETFs that were looked at in this notebook have only been in existence for about ten years. The past portfolio performance generally starts in January 2011 (or in some cases 2010).
The last decade has been a time of generally exceptional market returns. This has been driven at least in part by very low interest rates (sometimes rates close to zero). With fewer options for asset returns, money has flooded into the stock market, resulting in high market returns.
The results in this notebook suggest that 40% of the assets in "the market" and 60% in bonds yield the decent returns with lower risk. The market asset examined in this notebook is VTI, which is an ETF that mirrors the S&P 500.
I looked at a variety of other market ETFs (small cap, mid-cap, etc...) None of these had better return in the last ten years than VTI. One of the certainties in life is change and the market will not stay the same. A more diversified "market" asset, consisting of ETFs that hold value stocks for small and medium capitalization companies, along with an ETF holding foreign stocks would provide a way to deliver higher diversification for "the market" asset. I may look at the performance of a portfolio with a more diversified "market asset" in a future notebook.
An analysis and practical examples of the All Weather Portfolio is published on the Optimized Portfolio web site The portfolio proposed on the Optimized Portfolio website (and used in this notebook) replaces Dalio's commodity assets with utilities.
Ray Dalio's All Weather Portfolio consists of the following assets:
The Optimized Portfolio article on Dalio's All Weather portfolio lists a set of ETFs (Exchange Traded Funds) that approximate Dalio's portfolio recommendation, replacing commodities with utility assets.
The ETFs that make up the Optimized Portfolio version of the all weather portfolio are outlined below.
This ETF version of the All Weather portfolio is compared with a portfolio that only consists of the market (VTI) and bond assets (VGLT and VGIT). This make up of this 40% market/60% bond portfolio is listed below.
The ETFs in the portfolios are briefly described in the table below:
This ETF offers broad exposure to the U.S. equity market, investing in thousands of different securities across all sectors.
Expense ratio: 0.03%
Inception date: May 24, 2001
MSCI rating: BBB
This ETF offers exposure to long term government bonds, focusing on Treasuries that mature in ten years or more.
Expense ratio: 0.05%
Inception date: Nov 19, 2009
MSCI rating: A
This ETF offers exposure to intermediate term government bonds, focusing on Treasuries that mature in three to ten years.
Expense ratio: 0.05%
Inception date: Nov 19, 2009
MSCI rating: AThis Vanguard ETF offers exposure to the domestic utilities sector, a corner of the U.S. market that has historically exhibited low volatility and often features an attractive distribution yield.
Expense ratio: 0.10%
Inception date: Jan 26, 2004
MSCI rating: AA
This fund offers exposure to one of the world’s most famous metals, gold. IAU is designed to track the spot price of gold bullion by holding gold bars in a secure vault, allowing investors to free themselves from finding a place to store the metal.
Expense ratio: 0.25%
Inception date: Jan 21, 2005
MSCI rating: none provided, but rated B on etf.com
For this set of ETFs, earliest common start date is November 19, 2009
Leverage refers to using borrowed money to purchase an asset. Leverage increases both the potential profit if the asset increases in value and loss if the asset declines in value.
There are a number of ETFs that provide leverage for their asset class. The ETF selection here is from Optimized Portfolio. Due to the cost of leverage (e.g., borrowed money) the expense ratios for these ETFs is higher (expense ratios of 0.91 to 0.95 percent).
At the end of 2021 the stock market appears over priced and it seems inevitable that interest rates will rise. This suggests possible declines in both market funds and bond funds. In such an environment, leveraged ETFs may not be the best choice. A 40/60 stock/bond fund is included here to explore the impact of leverage.
This ETF offers 2x daily long leverage to the S&P 500 Index, making it a powerful tool for investors with a bullish short-term outlook for large cap equities.
Expense ratio: 0.91%
Inception date: Jun 19, 2006
MSCI rating: A
This ETF offers 2x long leveraged exposure to the broad-based Barclays Capital U.S. 20+ Year Treasury Index, making it a powerful tool for investors with a bullish short-term outlook for U.S. long-term treasuries.
Expense ratio: 0.95%
Inception date: Jan 19, 2010
MSCI rating: A
This ETF offers 2x long leveraged exposure to the broad-based Barclays Capital U.S. 7-10 Year Treasury Index, making it a powerful tool for investors with a bullish short-term outlook for U.S. long-term treasuries.
Expense ratio: 0.95%
Inception date: Jan 19, 2010
MSCI rating: A
Earliest common date for the leveraged ETFs: January 19, 2010
Two of the assets, VGLT and VGIT, for the unleveraged portfolio and UBT and UST for the leveraged portfolio are treasury bond ETFs. This gives the All Weather portfolios a weighting of 55% in treasury bonds and 30% stocks, with 15% in other assets (utilities and gold).
The All Weather portfolio allocation is close to the retirement portfolio allocation recommend by Charles Schwab (see Structuring Your Retirement Portfolio) for ages 60 to 69 (35% stocks, 60% bonds and 5% money market). A discussion of broad portfolio allocation strategies is published on the Optimized Portfolio site (see Portfolio Asset Allocation by Age)
This notebook shows that the contribution of the Gold ETFs (IAU) and the utility ETFs (VPU) are not significant compared to a 40/60 stock/bond portfolio, in terms of volatility and return. The "secret sause" of the All Weather portfolio doesn't deliver much, if anything in terms of return and lowered risk.
The AOM ETF is described as a conservative mix of assets. This notebook briefly looks at whether the AOM ETF could replace the 40/60 stock/bond portfolio investigated here.
AOM is one of four iShares Core target-risk ETFs. The fund is a fund-of-funds that invests exclusively in a global portfolio of iShares ETFs with 40% allocation to equity and 60% allocation to fixed income.
from datetime import datetime, timedelta
from tabulate import tabulate
from typing import List, Tuple
import pypfopt as pyopt
from pypfopt import expected_returns
from pypfopt import risk_models
from pandas_datareader import data
import matplotlib.pyplot as plt
import scipy.stats as stats
import pandas as pd
import numpy as np
from pathlib import Path
import tempfile
# QuantStrats was written by Ran Aroussi. See https://aroussi.com/
# https://pypi.org/project/QuantStats/
# https://github.com/ranaroussi/quantstats
# qs.drawdown()
import quantstats as qs
# See also https://seekingalpha.com/instablog/42079636-kayode-omotosho/5377452-computing-maximum-drawdown-of-stocks-in-python
plt.style.use('seaborn-whitegrid')
"""
Ideally this function would go in a local package. However, I want this Jupyter notebook
to display on github and I don't know of a way to get the local package import install
and import to work.
"""
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)
return close_data
Many of the assets in the portfolios investigated in this notebook pay a dividend. Factoring in this dividend is critical to arriving at a correct portfolio evaluation. This requires properly applying the asset close price and adjusted close price.
The close price for a stock is the market price of the stock at the end of a trading day. This is the (approximate) price that the asset can be purchased for in the market.
The adjusted close price is the end of market stock price adjusted for a variety of "corporate actions" which include dividend payments and stock splits. The return on the asset adjusted close price is used to calcuate portfolio value.
Dividend multipliers are calculated based on dividend as a percentage of the price, primarily to avoid negative historical pricing.
For example (from yahoo.com):
data_source = 'yahoo'
# yyyy-mm-dd
start_date_str = '2010-01-01'
start_date: datetime = datetime.fromisoformat(start_date_str)
end_date: datetime = datetime.today() - timedelta(days=1)
yyy_start_date = datetime.fromisoformat('2013-07-01')
yyy_close = get_market_data(file_name='yyy_close', data_col='Close', symbols=['yyy'],
data_source=data_source, start_date=yyy_start_date, end_date=end_date)
yyy_adjClose = get_market_data(file_name='yyy_adjClose', data_col='Adj Close', symbols=['yyy'],
data_source=data_source, start_date=yyy_start_date, end_date=end_date)
yyy_time_series = yyy_close
yyy_time_series.columns = ['Close']
yyy_time_series['Adj Close'] = yyy_adjClose
An example of the ETF YYY showing both the close price and the adjusted close price.
# Plot the close price and the adjusted close price
yyy_time_series.plot(grid=True, title='ETF YYY', figsize=(10,6))
<AxesSubplot:title={'center':'ETF YYY'}, xlabel='Date'>
As the table above show, an ETF that is similar to YYY is PCEF. Unlike YYY, PCEF has not lost as much share face value, although the yield is less (adjusted for costs, PCEF's yearly return is 4.34% vs. 6.65 for YYY).
The close price and adjusted close price for PCEF are plotted below. For comparison purposes the time period is the same as the previous plot.
pcef_close = get_market_data(file_name='pcef_close', data_col='Close', symbols=['pcef'],
data_source=data_source, start_date=yyy_start_date, end_date=end_date)
pcef_adjClose = get_market_data(file_name='pcef_adjClose', data_col='Adj Close', symbols=['pcef'],
data_source=data_source, start_date=yyy_start_date, end_date=end_date)
pcef_time_series = pcef_close
pcef_time_series.columns = ['Close']
pcef_time_series['Adj Close'] = pcef_adjClose
pcef_time_series.plot(grid=True, title='ETF PCEF', figsize=(10,6))
etf_symbols = ["VTI", "VGLT", "VGIT", "VPU", "IAU"]
# Fetch the close prices for the entire time period
#
etf_close_file = 'etf_close'
# Fetch all of the close prices: this is faster than fetching only the dates needed.
etf_close: pd.DataFrame = get_market_data(file_name=etf_close_file,
data_col="Close",
symbols=etf_symbols,
data_source=data_source,
start_date=start_date,
end_date=end_date)
etf_weights = {"VTI": 0.30, "VGLT": 0.40, "VGIT": 0.15, "VPU": 0.08, "IAU": 0.07}
prices = etf_close[0:1]
def calc_portfolio_holdings(initial_investment: int, weights: pd.DataFrame, prices: pd.DataFrame) -> Tuple[pd.DataFrame, pd.DataFrame]:
"""
Calculate the initial portfolio holdings given am amount of cash to invest.
:param initial_investment: The initial investment used to purchase the portfolio (no partial shares)
:param weights: a data frame containing the the weights for each asset as symbol: fraction
:param prices: the share prices
:return: the dollar value of the share holdings and the number of shares
"""
weights_np: np.array = np.zeros(weights.shape[1])
prices_np: np.array = np.zeros(weights.shape[1])
for ix, col in enumerate(weights.columns):
weights_np[ix] = weights[col]
prices_np[ix] = prices[col]
budget_np = weights_np * float(initial_investment)
shares = budget_np // prices_np
holdings = shares * prices_np
holdings_df: pd.DataFrame = pd.DataFrame(holdings).transpose()
holdings_df.columns = weights.columns
shares_df: pd.DataFrame = pd.DataFrame(shares).transpose()
shares_df.columns = weights.columns
return holdings_df, shares_df
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 simple_return(time_series: List, period: int) -> 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:
"""
Given a data frame consisting of price time series, return a data frame
that consists of the simple returns for the time series. The returned data
frame will have the same columns, but the time index will be one time period
less.
"""
r_df: pd.DataFrame = pd.DataFrame()
col_names = time_series_df.columns
for col in col_names:
col_vals = time_series_df[col]
col_ret = simple_return(col_vals, 1)
r_df[col] = col_ret
index = time_series_df.index
return r_df.set_index(index[1:len(index)])
The portflio return is calculated by iteratively applying the asset return from the initial portfolio value.
For a stock with no dividends and no splits the asset value calculated via continuously compounded returns should be the same as the close price. An example of a stock without dividends is Google (Alphabet), symbol GOOGL.
google_close_file = "google_close"
google_start_date_str = '2019-01-01'
google_end_date_str = '2019-12-31'
google_start_date: datetime = datetime.fromisoformat(google_start_date_str)
google_end_date: datetime = datetime.fromisoformat(google_end_date_str)
google_sym = 'GOOGL'
google_close: pd.DataFrame = get_market_data(file_name=google_close_file,
data_col='Close',
symbols=[google_sym],
data_source=data_source,
start_date=google_start_date,
end_date=google_end_date)
google_close = round(google_close, 2)
google_returns = return_df(google_close)
google_returns = google_returns[google_sym]
google_returns.plot(title=f'Google Returns {google_start_date.strftime("%Y-%m-%d")} to {google_end_date.strftime("%Y-%m-%d")}')
<AxesSubplot:title={'center':'Google Returns 2019-01-01 to 2019-12-31'}, xlabel='Date'>
The Google daily returns are plotted above.
google_val_np = np.zeros(google_close.shape, dtype=np.float64)
google_val_np[0] = google_close[0:1]
for t in range(1, google_close.shape[0]):
google_val_np[t] = google_val_np[t-1] + (google_val_np[t-1] * google_returns[t-1])
google_val_np = google_val_np.round(2)
google_val_df: pd.DataFrame = pd.DataFrame(google_val_np, index=google_close.index, columns=google_close.columns)
compare_rslt = 'the same' if (int(google_val_df.eq(google_close).sum()) == google_close.shape[0]) else 'different'
print(f'The close price and the value computed by continuously compounded returns are {compare_rslt}')
The close price and the value computed by continuously compounded returns are the same
A portfolio allocates a percentage of the investment cash to each asset categry which is used buy shares in each of the assets. There are no fractional shares, so the amount invested may be less than the total amount of cash.
# initial investment, in dollars
initial_investment = 10000
weights_df: pd.DataFrame = pd.DataFrame(etf_weights.values()).transpose()
weights_df.columns = etf_weights.keys()
holdings, shares = calc_portfolio_holdings(initial_investment=initial_investment,
weights=weights_df,
prices=prices)
print("Portfolio weights:")
print(tabulate(weights_df, headers=['', *weights_df.columns], tablefmt='fancy_grid'))
print("Number of Shares:")
print(tabulate(shares, headers=['As of Date', *shares.columns], tablefmt='fancy_grid'))
print(f'Total invested from {initial_investment} is {int(holdings.sum(axis=1))}')
print("Value of share holdings:")
print(tabulate(holdings, headers=['As of Date', *holdings.columns], tablefmt='fancy_grid'))
Portfolio weights: ╒════╤═══════╤════════╤════════╤═══════╤═══════╕ │ │ VTI │ VGLT │ VGIT │ VPU │ IAU │ ╞════╪═══════╪════════╪════════╪═══════╪═══════╡ │ 0 │ 0.3 │ 0.4 │ 0.15 │ 0.08 │ 0.07 │ ╘════╧═══════╧════════╧════════╧═══════╧═══════╛ Number of Shares: ╒══════════════╤═══════╤════════╤════════╤═══════╤═══════╕ │ As of Date │ VTI │ VGLT │ VGIT │ VPU │ IAU │ ╞══════════════╪═══════╪════════╪════════╪═══════╪═══════╡ │ 0 │ 52 │ 69 │ 25 │ 12 │ 31 │ ╘══════════════╧═══════╧════════╧════════╧═══════╧═══════╛ Total invested from 10000 is 9877 Value of share holdings: ╒══════════════╤═════════╤═════════╤════════╤════════╤═════════╕ │ As of Date │ VTI │ VGLT │ VGIT │ VPU │ IAU │ ╞══════════════╪═════════╪═════════╪════════╪════════╪═════════╡ │ 0 │ 2980.12 │ 3957.84 │ 1472.5 │ 785.64 │ 681.318 │ ╘══════════════╧═════════╧═════════╧════════╧════════╧═════════╛
The All Weather portfolio has 30% of it's assets in a stock market ETF (VTI: Vanguard Total Stock Market for the unleveraged portfolio and SSO: ProShares Ultra S&P 500 for the leveraged portfolio. If the overall stock market is changing in value, the value of these ETF shares will increase or decrease and the percentage of the portfolio held in "the market" will increase or decrease as well. To maintain a 30% share in the stock market asset the portfolio may need to be rebalanced.
Portfolio rebalancing is usually done either quarterly, every six months or yearly:
There are potential short term capital gains taxes from rebalancing a portfolio in a period under a year and a day, but they should not be significant.
trading_days = 253
days_in_quarter = trading_days // 4
half_year = trading_days // 2
def do_rebalance(cash_holdings: pd.DataFrame, weights_df: pd.DataFrame, portfolio_range: float) -> bool:
"""
cash_holdings: a data frame with the amount of cash for each holding.
weights: a data frame containing the portfolio target weights
range: the portfolio should be rebalanced if an asset is not +/- percent of the target weight
"""
total_cash: int = int(cash_holdings.sum(axis=1))
current_percent = round(cash_holdings.div(total_cash), 3)
# Calculate the allowed weight range for the portfolio
weight_range = weights_df.apply(lambda x: (x - (x * portfolio_range), x * (1 + portfolio_range)), axis=0)
# are any of the current portfolio weights outside of the allowed portfolio range
rebalance = list(float(current_percent[col]) < float(weight_range[col][0]) or float(current_percent[col]) > float(weight_range[col][1]) for col in current_percent.columns)
return any(rebalance)
def portfolio_rebalance(cash_holdings: pd.DataFrame,
weights: pd.DataFrame,
portfolio_range: float,
prices: pd.DataFrame) -> pd.DataFrame:
"""
cash_holdings: a data frame containing the current portfolio cash holdings by stock
weights: a data frame containing the portfolio weights
range: the +/- percentage range for rebalancing (e.g., +/- 0.05)
prices: the market prices on the day the portfolio will be rebalanced.
Return: rebalanced cash holdings
"""
new_holdings = cash_holdings
if do_rebalance(cash_holdings=cash_holdings, weights_df=weights, portfolio_range=portfolio_range):
total_cash = cash_holdings.sum(axis=1)
new_holdings, shares = calc_portfolio_holdings(initial_investment=total_cash,
weights=weights,
prices=prices)
return new_holdings
etf_adj_close_file = 'etf_adj_close'
# Fetch the adjusted close price for the unleveraged "all weather" set of ETFs'
# VTI, VGLT, VGIT, VPU and IAU
etf_adj_close: pd.DataFrame = get_market_data(file_name=etf_adj_close_file,
data_col="Adj Close",
symbols=etf_symbols,
data_source=data_source,
start_date=start_date,
end_date=end_date)
# +/- for each asset for portfolio rebalancing
portfolio_range = 0.05
returns = return_df(etf_adj_close)
portfolio_np = np.zeros(etf_close.shape, dtype=np.float64)
portfolio_total_np = np.zeros(etf_close.shape[0])
portfolio_total_np[0] = holdings.sum(axis=1)
# initialize the first row with the dollar value of the portfolio holdings.
portfolio_np[0,] = holdings
for t in range(1, portfolio_np.shape[0]):
for col, stock in enumerate(holdings.columns):
portfolio_np[t, col] = portfolio_np[t-1, col] + (portfolio_np[t-1, col] * returns[stock][t-1])
portfolio_total_np[t] = portfolio_total_np[t] + portfolio_np[t,col]
The plot below shows the portfolio value without rebalancing. In this case the initial portfolio is purchased using the "all weather" percentages and the portfolio is allowed to grow without rebalancing.
portfolio_total_df: pd.DataFrame = pd.DataFrame(portfolio_total_np, index=etf_close.index, columns=['Portfolio'])
portfolio_total_df.plot(title="Portfolio Value (without rebalancing)", grid=True, figsize=(10,8))
<AxesSubplot:title={'center':'Portfolio Value (without rebalancing)'}, xlabel='Date'>
Over time the fraction of the portfolio invested in the market index fund is likely to move outside of the 30% allocated to the market index if the portfolio is not rebalanced.
The plot below shows the percentage of the portfolio invested in the market, without portfolio rebalancing.
portfolio_total_np = portfolio_np.sum(axis=1)
portfolio_percent_np = portfolio_np / portfolio_total_np[:,None]
market_percent = portfolio_percent_np[:,0]
market_percent_df: pd.DataFrame = pd.DataFrame(market_percent, index=etf_close.index, columns=['Market'])
market_percent_df.plot(title="Percentage of Portfolio invested in the Market", grid=True, figsize=(10,8))
def calc_rebalanced_portfolio(holdings: pd.DataFrame,
etf_close: pd.DataFrame,
returns: pd.DataFrame,
weights: pd.DataFrame,
rebalance_days: int) -> Tuple[pd.DataFrame, pd.DataFrame]:
portfolio_np = np.zeros(etf_close.shape, dtype=np.float64)
portfolio_total_np = np.zeros(etf_close.shape[0])
portfolio_total_np[0] = holdings.sum(axis=1)
portfolio_np[0,] = holdings
for t in range(1, portfolio_np.shape[0]):
for col, stock in enumerate(holdings.columns):
portfolio_np[t, col] = portfolio_np[t-1, col] + (portfolio_np[t-1, col] * returns[stock][t-1])
portfolio_total_np[t] = portfolio_total_np[t] + portfolio_np[t,col]
if (t % rebalance_days) == 0:
current_holdings: pd.DataFrame = pd.DataFrame( portfolio_np[t,]).transpose()
current_holdings.columns = holdings.columns
date = etf_close.index[t]
current_holdings.index = [date]
close_prices_t = etf_close[t:t+1]
portfolio_np[t, ] = portfolio_rebalance(cash_holdings=current_holdings,
weights=weights,
portfolio_range=portfolio_range,
prices=close_prices_t)
portfolio_df: pd.DataFrame = pd.DataFrame(portfolio_np, index=etf_close.index, columns=etf_close.columns)
portfolio_total_df: pd.DataFrame = pd.DataFrame(portfolio_total_np, index=etf_close.index)
portfolio_total_df.columns = ['Portfolio Value']
return portfolio_df, portfolio_total_df
def plot_portfolio_weights(asset_values_df: pd.DataFrame, portfolio_total_df: pd.DataFrame) -> None:
portfolio_total_np = np.array(portfolio_total_df)
asset_values_np = np.array(asset_values_df)
portfolio_percent_np = asset_values_np / portfolio_total_np
col_names = asset_values_df.columns
fig, ax = plt.subplots(asset_values_np.shape[1], figsize=(10,8))
for col in range(0, asset_values_np.shape[1]):
asset_percent = portfolio_percent_np[:,col]
label = f'Portfolio Percent for {col_names[col]}'
ax[col].set_xlabel(label)
ax[col].grid(True)
ax[col].plot(asset_percent)
fig.tight_layout()
plt.show()
portfolio_quarterly_df, portfolio_total_quarterly_df = calc_rebalanced_portfolio(holdings=holdings,
etf_close=etf_close,
returns=returns,
weights=weights_df,
rebalance_days=days_in_quarter)
The "all weather" portfolio is designed to limit exposure to market downturns. The plot above shows that the market exposure grows as the percentage of the market asset increases.
The plot below shows the portfolio rebalanced every quarter.
portfolio_total_quarterly_df.plot(title="Portfolio Value (quarterly rebalanced)", grid=True, figsize=(10,8))
<AxesSubplot:title={'center':'Portfolio Value (quarterly rebalanced)'}, xlabel='Date'>
The portfolio is very volatile. The asset percentages in the all weather portfolio are:
print(tabulate(weights_df, headers=['', *weights_df.columns], tablefmt='fancy_grid'))
╒════╤═══════╤════════╤════════╤═══════╤═══════╕ │ │ VTI │ VGLT │ VGIT │ VPU │ IAU │ ╞════╪═══════╪════════╪════════╪═══════╪═══════╡ │ 0 │ 0.3 │ 0.4 │ 0.15 │ 0.08 │ 0.07 │ ╘════╧═══════╧════════╧════════╧═══════╧═══════╛
To verify that the portfolio rebalancing is producing the right asset percentages, the asset percentages are plotted below.
plot_portfolio_weights(portfolio_quarterly_df, portfolio_total_quarterly_df)
portfolio_biannual_df, portfolio_total_biannual_df = calc_rebalanced_portfolio(holdings=holdings,
etf_close=etf_close,
returns=returns,
weights=weights_df,
rebalance_days=half_year)
portfolio_total_biannual_df.plot(title="Portfolio Value (rebalanced twice a year)", grid=True, figsize=(10,8))
plot_portfolio_weights(portfolio_biannual_df, portfolio_total_biannual_df)
The plot below shows the "all weather" portfolio, rebalanced every year (253 trading days).
portfolio_yearly_df, portfolio_total_yearly_df = calc_rebalanced_portfolio(holdings=holdings,
etf_close=etf_close,
returns=returns,
weights=weights_df,
rebalance_days=trading_days)
portfolio_total_yearly_df.plot(title="Portfolio Value (rebalanced once a year)", grid=True, figsize=(10,8))
<AxesSubplot:title={'center':'Portfolio Value (rebalanced once a year)'}, xlabel='Date'>
The asset percentages for the yearly rebalanced portfolio are plotted below. The market percentage is relatively stable. With the higher portfolio yield and reasonable market percentages, yearly rebalancing is a better choice than quarterly rebalancing.
plot_portfolio_weights(portfolio_yearly_df, portfolio_total_yearly_df)
portfolio_quarterly_return = return_df(portfolio_total_quarterly_df)
portfolio_biannual_return = return_df(portfolio_total_biannual_df)
portfolio_yearly_return = return_df(portfolio_total_yearly_df)
portfolio_quarterly_sd = np.std( portfolio_quarterly_return ) * np.sqrt(portfolio_quarterly_return.shape[0])
portfolio_biannual_sd = np.std( portfolio_biannual_return ) * np.sqrt(portfolio_biannual_return.shape[0])
portfolio_yearly_sd = np.std( portfolio_yearly_return ) * np.sqrt(portfolio_yearly_return.shape[0])
sd_df = pd.DataFrame([portfolio_quarterly_sd, portfolio_biannual_sd, portfolio_yearly_sd]).transpose()
The portfolio that is rebalanced yearly is less volatile (see the standard deviations of daily return below) and has a higher yield at the end. This suggests that yearly rebalancing is a better choice the quarterly or bi-annual rebalancing. Rebalancing every year (and one day) also has tax advantages in the United States (e.g., long term capital gains vs. short term capital gains taxes).
print(tabulate(sd_df, headers=['stddev', 'quarterly', 'bi-annual', 'yearly'], tablefmt='fancy_grid'))
╒═════════════════╤═════════════╤═════════════╤══════════╕ │ stddev │ quarterly │ bi-annual │ yearly │ ╞═════════════════╪═════════════╪═════════════╪══════════╡ │ Portfolio Value │ 0.265376 │ 0.249892 │ 0.247419 │ ╘═════════════════╧═════════════╧═════════════╧══════════╛
The plot below shows the performance of the "all weather" portfolio and the SPY ETF which attempts to replicate the S&P 500.
market = 'SPY'
spy_close_file = 'spy_adj_close'
spy_adj_close = get_market_data(file_name=spy_close_file,
data_col='Adj Close',
symbols=[market],
data_source=data_source,
start_date=start_date,
end_date=end_date)
spy_close_start_file = 'spy_close'
spy_close = get_market_data(file_name=spy_close_start_file,
data_col='Close',
symbols=[market],
data_source=data_source,
start_date=start_date,
end_date=end_date)
spy_initial_price = spy_close[market][0]
market_return = return_df(spy_adj_close)
def calc_market_portfolio(market_return_df: pd.DataFrame,
date_index: pd.Index,
initial_investment: int,
initial_market_price: float ) -> pd.DataFrame:
market_portfolio_np: np.array = np.zeros(len(date_index))
market_portfolio_np[0] = (initial_investment // initial_market_price) * initial_market_price
for i in range(1, market_portfolio_np.shape[0]):
market_portfolio_np[i] = market_portfolio_np[i-1] + (market_portfolio_np[i-1] * market_return_df[market][i-1])
market_portfolio_df: pd.DataFrame = pd.DataFrame(market_portfolio_np, index=date_index, columns=[market])
return market_portfolio_df
market_portfolio_df = calc_market_portfolio(market_return_df=market_return,
date_index=spy_adj_close.index,
initial_investment=initial_investment,
initial_market_price=spy_initial_price)
portfolios_df: pd.DataFrame = pd.concat([portfolio_total_yearly_df, market_portfolio_df], axis=1)
portfolios_df.plot(title="Portfolio Value (rebalanced once a year) + SPY", grid=True, figsize=(10,8))
forty_sixty_weights_df = pd.DataFrame([0.40, 0.44, 0.16]).transpose()
forty_sixty_weights_df.columns = ['VTI', 'VGLT', 'VGIT']
forty_sixty_holdings, forty_sixty_shares = calc_portfolio_holdings(initial_investment=initial_investment,
weights=forty_sixty_weights_df,
prices=prices)
portfolio_forty_sixty_df, portfolio_total_forty_sixty_df = calc_rebalanced_portfolio(holdings=forty_sixty_holdings,
etf_close=etf_close[forty_sixty_weights_df.columns],
returns=returns[forty_sixty_weights_df.columns],
weights=forty_sixty_weights_df,
rebalance_days=trading_days)
portfolio_total_forty_sixty_df.columns = ['40/60 Portfolio']
portfolio_total_yearly_df.columns = ['All Weather']
portfolios_df: pd.DataFrame = pd.concat([portfolio_total_forty_sixty_df, portfolio_total_yearly_df], axis=1)
This section compares a 40 percent stock and 60 percent bond portfolio to the all weather portfolio.
As the plot below shows, there is very little difference between the "all weather" portfolio and a 40/60 stock/bond portfolio.
The claim of the Bridgewater white paper that discusses the "All Weather" portfolio seems to be that the investment experience of Bridgewater delivers a superior portfolio in the all weather portfolio. Most of this portfolio is a market asset (VTI) and bonds (VGLT and VGIT). The "secret sause" for this portfolio are commodities and gold assets in the Bridgewater portfolio and the utilities and gold assets in portfolio suggested by the Optimized Portfolio web site. The plot below suggests that this "secret sause" doesn't deliver an advantage above a simple 40/60 stock/bond portfolio. The 40/60 portfolio has a higher terminal value and lower volatility.
print("40/60 Portfolio weights:")
print(tabulate(forty_sixty_weights_df, headers=['', *forty_sixty_weights_df.columns], tablefmt='fancy_grid'))
portfolios_df.plot(title="40/60 Portfolio + All Weather Portfolio", grid=True, figsize=(10,8))
portfolio_sixty_forty_return = return_df(portfolio_total_forty_sixty_df)
portfolio_sixty_forty_sd = np.std( portfolio_sixty_forty_return ) * np.sqrt(portfolio_sixty_forty_return.shape[0])
sd_df = pd.DataFrame([np.array(portfolio_sixty_forty_sd), np.array(portfolio_yearly_sd)]).transpose()
print("Daily Return Portfolio volatility")
print(tabulate(sd_df, headers=['stddev', '40/60', 'All Weather'], tablefmt='fancy_grid'))
40/60 Portfolio weights: ╒════╤═══════╤════════╤════════╕ │ │ VTI │ VGLT │ VGIT │ ╞════╪═══════╪════════╪════════╡ │ 0 │ 0.4 │ 0.44 │ 0.16 │ ╘════╧═══════╧════════╧════════╛ Daily Return Portfolio volatility ╒══════════╤══════════╤═══════════════╕ │ stddev │ 40/60 │ All Weather │ ╞══════════╪══════════╪═══════════════╡ │ 0 │ 0.232995 │ 0.247419 │ ╘══════════╧══════════╧═══════════════╛
aom_adj_close_file = 'aom_adj_close'
aom_adj_close: pd.DataFrame = get_market_data(file_name=aom_adj_close_file,
data_col="Adj Close",
symbols=['AOM'],
data_source=data_source,
start_date=start_date,
end_date=end_date)
aom_returns = np.array(return_df(aom_adj_close))
aom_total_np = np.zeros(aom_adj_close.shape[0], dtype=np.float64)
aom_total_np[0] = initial_investment
for t in range(1, aom_total_np.shape[0]):
aom_total_np[t] = aom_total_np[t-1] + (aom_total_np[t-1] * aom_returns[t-1])
aom_total_df: pd.DataFrame = pd.DataFrame( aom_total_np )
aom_total_df.index = aom_adj_close.index
aom_total_df.columns = ['AOM']
An ETF that replicted a 40/60 stock/bond allocation would allow the holder to avoid yearly rebalancing. The AOM ETF is listed as a conservative ETF. Unfortunately, as the plot below shows, the return and the risk (volatility) of the AOM ETF is not as good as the 40/60 portfolio.
aom_sd = np.std( aom_returns ) * np.sqrt(aom_returns.shape[0])
sd_df = pd.DataFrame([aom_sd, portfolio_sixty_forty_sd]).transpose()
print("Daily Return Portfolio volatility")
print(tabulate(sd_df, headers=['stddev', 'AOM', '40/60'], tablefmt='fancy_grid'))
portfolios_df: pd.DataFrame = pd.concat([aom_total_df, portfolio_total_forty_sixty_df], axis=1)
portfolios_df.plot(title="AOM + 40/60 Portfolio", grid=True, figsize=(10,8))
Daily Return Portfolio volatility ╒══════════╤══════════╤══════════╕ │ stddev │ AOM │ 40/60 │ ╞══════════╪══════════╪══════════╡ │ 0 │ 0.246955 │ 0.232995 │ ╘══════════╧══════════╧══════════╛
<AxesSubplot:title={'center':'AOM + 40/60 Portfolio'}, xlabel='Date'>
def period_return(portfolio_total_df: pd.DataFrame, period: int) -> pd.DataFrame:
period_range: list = list(t for t in range(portfolio_total_df.shape[0]-1, -1, -period))
period_range.reverse()
portfolio_total_np: np.array = np.array(portfolio_total_df)
period_ret_l: list = []
period_dates_l: list = []
for ix in range(1, len(period_range)):
ret = (portfolio_total_np[ period_range[ix] ] / portfolio_total_np[ period_range[ix-1] ]) - 1
ret = float((ret * 100).round(2))
period_ret_l.append(ret)
period_dates_l.append(portfolio_total_df.index[period_range[ix]])
period_ret_df: pd.DataFrame = pd.DataFrame(period_ret_l, index=period_dates_l)
period_ret_df.columns = ['Return']
return period_ret_df
def plot_return(ret_df: pd.DataFrame, title:str) -> None:
# Point and line plot of return values
fig, ax = plt.subplots()
fig.set_size_inches(10, 8)
fig.suptitle(title)
fig.autofmt_xdate()
plt.ylabel('Percent')
ax.plot(ret_df, '-bo')
plt.show()
# Plot a table return values
fig, ax = plt.subplots()
fig.patch.set_visible(False)
ax.axis('off')
ax.axis('tight')
the_table = plt.table(cellText=ret_df.values, rowLabels=ret_df.index, loc='center')
the_table.auto_set_font_size(False)
the_table.set_fontsize(14)
plt.show()
annual_ret_df = period_return(portfolio_total_forty_sixty_df, trading_days)
plot_return(annual_ret_df, '40/60 Portfolio Annual Return')
mean_return_df = pd.DataFrame(annual_ret_df.mean(axis=0))
print(tabulate(mean_return_df, headers=['', 'Mean Annual Return'], tablefmt='fancy_grid'))
╒════════╤══════════════════════╕ │ │ Mean Annual Return │ ╞════════╪══════════════════════╡ │ Return │ 8.73182 │ ╘════════╧══════════════════════╛
Beta is a measure of the volatility of an asset or portfolio in relation to the overall market. The overall market has a beta of 1.0. An asset that has a beta that is close to 1.0 has volatility that is close to that of the market. An asset with a beta of 0.5 has a lower volatility correlation with the market. An asset that has a negative beta with the market will tend to move in the opposite direction compared to the market.
In practical terms an asset with a lower beta will be less affected by market movement (e.g., crashes or booms). An asset with a relatively low beta is a more conservative investment relative to the overall stock market since it will not be affected by market moves.
$ \large \beta = cor(R_{a}, R_{b}) \times \frac{\sigma_{ra}}{\sigma_{rb}} $
$ R_{a} = return\ series\ for\ asset $ $ R_{b} = return\ series\ for\ benchmark $ $ \sigma_{ra} = standard\ deviation\ of\ the\ asset\ returns $ $ \sigma_{b} = standard\ deviation\ of\ the\ benchmark $
def calc_asset_beta(asset_df: pd.DataFrame, market_df: pd.DataFrame) -> float:
asset_np = np.array(asset_df).flatten()
market_np = np.array(market_df).flatten()
sd_asset = np.std(asset_np)
sd_market = np.std(market_np)
# cor_tuple: correlation and p-value
cor_tuple = stats.pearsonr(asset_np, market_np)
cor = cor_tuple[0]
beta = cor * (sd_asset/sd_market)
return beta
portfolio_beta = calc_asset_beta(portfolio_sixty_forty_return, market_return)
vti_beta = calc_asset_beta(returns['VTI'], market_return)
vglt_beta = calc_asset_beta(returns['VGLT'], market_return)
beta_np = np.array([vti_beta, vglt_beta, portfolio_beta])
beta_df: pd.DataFrame = pd.DataFrame(beta_np).transpose()
The table below shows the beta values for VTI (the Vanguard market ETF), VGLT (the Vanguard long term bond fund) and the 40/60 portfolio. The beta is measured over the entire multi-year sample period.
print(tabulate(beta_df, headers=['', 'VTI Beta', 'VGLT Beta', '40/60 Portfolio'], tablefmt='fancy_grid'))
╒════╤════════════╤═════════════╤═══════════════════╕ │ │ VTI Beta │ VGLT Beta │ 40/60 Portfolio │ ╞════╪════════════╪═════════════╪═══════════════════╡ │ 0 │ 1.02009 │ -0.340059 │ 0.207991 │ ╘════╧════════════╧═════════════╧═══════════════════╛
$ Sharpe = \large \frac{E[R_a - R_{rf}]}{ \sqrt{var[R_a - R_{rf}]}} = \frac{E[R_a - R_{rf}]}{ \sigma_{ra}} = \frac{\mu_{ra}}{\sigma_{ra}} $
$ R_a$ is the asset return $ R_{rf}$ is the risk-free return (e.g., in this case the 13-week US Treasury bill). $ R_a - R_{rf}$ is the excess return over the risk-free rate.
The return values used here are daily return values. The Sharpe ratio is usually expressed as a yearly value. There are 253 trading days in a year. To convert the daily Sharpe ratio to a yearly Sharpe ratio (where T is the time period over which the Shapre ratio is calculated):
$ Sharpe_{year} = \large \frac{\mu_{ra} \times T}{ \sigma_{ra} \times \sqrt{T}} = \frac{\mu_{ra} \times T}{ \sigma_{ra} \times \sqrt{T}} \times \frac{\sqrt{T}}{\sqrt{T}} = \frac{\mu_{ra}}{\sigma_{ra}} \times \sqrt{T} $
For daily returns and a yearly Sharpe Ratio T will be the number of trading days in a year (253). If monthly returns were used, then T would be 12.
The risk free rate used in this notebook to calculate the Sharpe Ratio is based on the 13-week Treasury bill. The symbol used to fetch this rate is ^IRX. finance.yahoo.com reports the 13-week treasury bill rate as a yearly percentage rate. This is shown in the plot below:
# 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)
rf_adj_close.plot(title="Yield on the 13-week T-bill", grid=True, figsize=(10,8), ylabel='yearly percent')
<AxesSubplot:title={'center':'Yield on the 13-week T-bill'}, xlabel='Date', ylabel='yearly percent'>
The values on this plot are surprising. The yearly interest rate range is dramatic (from close to zero to 2.5 percent). At one point the interest rate was actually negative. The values returned by finance.yahoo.com can be verified on the St Louis Federal Reserve site (https://fred.stlouisfed.org/series/DTB3).
A Stack Exchange discussion provides a clear explaination for how to calculate the daily interest rate from the yearly rate. Again, note that the reported interest rate is a percentage value, so we first have to divide by 100 to return the fractional yearly rate rt. There are 360 bank interest days in a year.
$ \large r_{f,t}^{daily} = (1 + r_t)^{\frac{1}{360}} - 1$
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'])
rf_daily_df.plot(title="Daily yield on the 13-week T-bill", grid=True, figsize=(10,8), ylabel='daily yield x $10^{-5}$')
<AxesSubplot:title={'center':'Daily yield on the 13-week T-bill'}, xlabel='Date', ylabel='daily yield x $10^{-5}$'>
The risk free return may be underestimated. In practive an investor seeking an asset with close to risk free return might choose something like the Schwab SCHP ETF which pays a higher rate of return than ^IRX.
There are trading days when interest rates are not reported. As a result, there are fewer days in the interest rate series than there are in the return series generated from market adjusted close prices. The market return time series and the interest rate time series must be adjusted so that the dates line up.
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
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 = datetime.strptime(ix[0], dateformat).date()
ix_end = datetime.strptime(ix[len(ix)-1], dateformat).date()
index_str = f'{ix_start} : {ix_end}'
result_df.index = [ index_str ]
return result_df
ret_adj_df, rf_daily_adj = adjust_time_series(portfolio_sixty_forty_return, rf_daily_df)
sharpe_ratio_sixty_forty = calc_sharpe_ratio(ret_adj_df, rf_daily_adj, trading_days)
ret_adj_df, rf_daily_adj = adjust_time_series(market_return, rf_daily_df)
sharpe_ratio_market = calc_sharpe_ratio(ret_adj_df, rf_daily_adj, trading_days)
sharpe_df = pd.concat([sharpe_ratio_sixty_forty, sharpe_ratio_market], axis=1)
print("Sharpe Ratios")
print(tabulate(sharpe_df, headers=['', '40/60 portfolio', 'SPY'], tablefmt='fancy_grid'))
Sharpe Ratios ╒═════════════════════════╤═══════════════════╤══════════╕ │ │ 40/60 portfolio │ SPY │ ╞═════════════════════════╪═══════════════════╪══════════╡ │ 2010-01-05 : 2021-11-23 │ 1.25676 │ 0.895822 │ ╘═════════════════════════╧═══════════════════╧══════════╛
There are a large (and perhaps growing) number of ETF assets that have a large variety of characteristics. These include ETFs that use leverage (borrowed money) to purchase portfolio assets. These ETFs amplify asset risk and return. The amplification of risk and return can be seen in the 2X leveraged 40/60 bond portfolio that is discussed in this section. Both the profit and the loss are almost exactly multipled by a factor of two.
leveraged_etf_symbols = [ 'SSO', 'UBT', 'UST']
leveraged_etf_weights = {"SSO": 0.40, "UBT": 0.44, "UST": 0.16 }
leveraged_etf_weights_df: pd.DataFrame = pd.DataFrame( leveraged_etf_weights.values()).transpose()
leveraged_etf_weights_df.columns = leveraged_etf_weights.keys()
# Start dates on finance.yahoo.com are completely available on this date
leveraged_etf_start_date_str = '2011-01-01'
leveraged_start_date: datetime = datetime.fromisoformat(leveraged_etf_start_date_str)
leveraged_etf_close_file = 'leveraged_etf_close'
# Fetch the adjusted close price for the unleveraged "all weather" set of ETFs'
# VTI, VGLT, VGIT, VPU and IAU
leveraged_etf_close: pd.DataFrame = get_market_data(file_name=leveraged_etf_close_file,
data_col="Close",
symbols=leveraged_etf_symbols,
data_source=data_source,
start_date=leveraged_start_date,
end_date=end_date)
leveraged_etf_adj_close_file = 'leveraged_etf_adj_close'
# Fetch the adjusted close price for the unleveraged "all weather" set of ETFs'
# VTI, VGLT, VGIT, VPU and IAU
leveraged_etf_adj_close: pd.DataFrame = get_market_data(file_name=leveraged_etf_adj_close_file,
data_col="Adj Close",
symbols=leveraged_etf_symbols,
data_source=data_source,
start_date=leveraged_start_date,
end_date=end_date)
leveraged_prices = leveraged_etf_close[0:1]
leveraged_returns = return_df(leveraged_etf_adj_close)
leveraged_holdings, shares = calc_portfolio_holdings(initial_investment=initial_investment,
weights=leveraged_etf_weights_df,
prices=leveraged_prices)
leveraged_portfolio_df, leveraged_portfolio_total_df = calc_rebalanced_portfolio(holdings=leveraged_holdings,
etf_close=leveraged_etf_close,
returns=leveraged_returns,
weights=leveraged_etf_weights_df,
rebalance_days=trading_days)
market_return_index = market_return.index
leveraged_return_index = leveraged_returns.index
market_filter = market_return_index.isin(leveraged_return_index)
trunc_market_return = market_return[market_filter]
initial_start_date = leveraged_return_index[0]
spy_close_index = spy_close.index.isin([initial_start_date])
leveraged_spy_price = float(spy_close[spy_close_index].values[0])
market_portfolio_df = calc_market_portfolio(market_return_df=trunc_market_return,
date_index=leveraged_etf_close.index,
initial_investment=initial_investment,
initial_market_price=leveraged_spy_price)
leveraged_portfolio_plus_spy = pd.concat([leveraged_portfolio_total_df, market_portfolio_df], axis=1)
leveraged_portfolio_plus_spy.plot(title="40/60 stock/bond, 2X leverage + SPY", grid=True, figsize=(10,8))
leveraged_portfolio_total_return = return_df(leveraged_portfolio_total_df)
stat_values = [calc_asset_beta(leveraged_portfolio_total_return, trunc_market_return),
np.std(leveraged_portfolio_total_return),
np.std(trunc_market_return)]
stats_df = pd.DataFrame(stat_values).transpose()
stats_df.columns = ['2X Beta', '2X StdDev', 'Market StdDev']
print("2X portfolio beta and standard deviation of the daily return")
print(tabulate(stats_df, headers=['', *stats_df.columns], tablefmt='fancy_grid'))
2X portfolio beta and standard deviation of the daily return ╒════╤═══════════╤═════════════╤═════════════════╕ │ │ 2X Beta │ 2X StdDev │ Market StdDev │ ╞════╪═══════════╪═════════════╪═════════════════╡ │ 0 │ 0.256613 │ 0.00893903 │ 0.0106057 │ ╘════╧═══════════╧═════════════╧═════════════════╛
Although the standard deviation of the daily return for the leveraged portfolio is less than the market daily standard deviation, the drawdowns that can be seen in the plot are much higher and more frequent than the market. Although the asset return of the leveraged portfolio is higher than the market, this is not a portfolio for the feight of heart. This can be seen in the almost 5% portfolio loss in the plot below.
annual_ret_df = period_return(leveraged_portfolio_total_df, trading_days)
plot_return(annual_ret_df, '2X Portfolio Annual Return')
mean_return_df = pd.DataFrame(annual_ret_df.mean(axis=0))
print(tabulate(mean_return_df, headers=['', 'Mean Annual Return'], tablefmt='fancy_grid'))
╒════════╤══════════════════════╕ │ │ Mean Annual Return │ ╞════════╪══════════════════════╡ │ Return │ 16.476 │ ╘════════╧══════════════════════╛
The 40/60 stock/bond portfolio has an average yearly return of about 8%. But this return is volatile (in 2018 the portfolio barely made money and in 2020 the portfolio returned over 14%).
This section looks at dividend producing assets. The objective in building a portfolio of dividend assets is lower income volatility. For example, SCHP ETF returns dividends generated by a portfolio of US inflation adjusted treasury bills. These returns should have low volatility and low market correlation (Beta).
In choosing dividend producing assets the total return must be looked at. The total return consists of the market performance plus the dividend performance. As the adjusted close price plots above show, the YYY ETF market price has declined where the PCEF market prices has stayed relatively flat. This suggests that PCEF may be a better investment.
ETF Symbol | Distribution Yield | Monthly distribution per share | Expense Ratio | Net Distrition | Inception date |
---|---|---|---|---|---|
PCEF | 6.68% | 0.13 - 0.14 | 2.34% | 4.34% | Feb 19, 2010 |
YYY | 9.0% | 0.13 | 2.45% | 6.65% | June 21, 2013 |
SCHP | 3.54% | 0.19 | 0.05% | 3.49% | Aug 5, 2010 |
The YYY and PCEF ETFs have substantial expense ratios due to their investment strategy. The net distribution column is the distribution minus the expense ratio.
PCEF looks like an interesting investment until you look at the Sharpe ratio (0.62 to 0.72) and market Beta (1.21 to 1.41) (see https://finance.yahoo.com/quote/PCEF/risk/)
In contrast the SCHP ETF is Schwab's ETF that invests in TIPS (U.S. Treasury Inflation Protected Securities)
Symbol | ETF Name | Dividend Yield |
---|---|---|
VIG | Vanguard Dividend Appreciation | 1.55% |
VYM | Vanguard High Dividend Yield ETF | 2.69% |
The Vanguard Divident Appreciation has a decent Sharpe ratio (0.94 to 1.12) and market Beta (0.85 to 0.87) (see https://finance.yahoo.com/quote/VIG/risk?p=VIG) The performance characteristics are similar to a market ETF like VTI.
The Vanguard High Dividend Yield ETF has a relatively low Sharpe ration is a market Beta of close to 1 (see https://finance.yahoo.com/quote/VYM/risk?p=VYM) This means that the overall return has the same risk as the market.
PIMCO has a set of close end funds with relatively long history with relatively high dividend yields (as noted below, dividend yield is not the same as overall return).
The PIMCO funds are actively managed funds that have relatively high expense ratios compared to index fund ETFs. Some of the PIMCO funds, their returns and expense ratios are listed below. The PMF municipal bond fund is included because it has relatively low market correlation and volatility, although the total return is less than the other PIMCO funds.
Symbol | Description | Distribution Yield | Expense Ratio | Net Distrition | Inception date |
---|---|---|---|---|---|
PHK | High Income Fund | 9.11% | 1.15 | 7.96 | 04/30/2003 |
RCS | Strategic Income Fund, Inc. | 8.20 | 1.36 | 6.84 | 02/24/1994 |
PTY | Corporate & Income Opportunity Fund | 7.86% | 1.09% | 6.77 | 12/27/2002 |
PMF | Municipal Income Fund | 4.43 | 1.59 | 2.84 | 06/29/2001 |
dividend_symbols = ['PHK', 'RCS', 'PTY', 'PMF', 'SCHP']
# the Schwab TIPS fund was started in August 2010
div_start_date_str = start_date_str = '2011-01-01'
div_start_date: datetime = datetime.fromisoformat(div_start_date_str)
# end date is the previously defined end_date
dividend_adj_close_file = "dividend_adj_close"
dividend_adj_close = get_market_data(file_name=dividend_adj_close_file,
symbols=dividend_symbols,
data_col='Adj Close',
data_source=data_source,
start_date=div_start_date,
end_date=end_date)
dividend_returns = return_df(dividend_adj_close)
def calc_asset_value(initial_value: int, returns: pd.DataFrame) -> pd.DataFrame:
length = returns.shape[0] + 1
portfolio_value_np: np.array = np.zeros(length)
portfolio_value_np[0] = initial_value
for t in range(1, length):
portfolio_value_np[t] = portfolio_value_np[t-1] + (portfolio_value_np[t-1] * returns[t-1])
return pd.DataFrame(portfolio_value_np)
def calc_basket_value(initial_value: int, date_index: pd.Index, asset_returns_df: pd.DataFrame) -> pd.DataFrame:
"""
Calculate the asset value for a basket of stocks.
:param initial_value:
:param date_index:
:param asset_returns_df:
:return:
"""
portfolio_df: pd.DataFrame = pd.DataFrame()
index = [start_date, asset_returns_df.index]
for col in asset_returns_df.columns:
returns = asset_returns_df[col]
asset_value = calc_asset_value(initial_value, returns)
asset_value.columns = [col]
asset_value.index = date_index
portfolio_df[col] = asset_value
return portfolio_df
asset_returns_df = calc_basket_value(initial_value=initial_investment, date_index=dividend_adj_close.index, asset_returns_df=dividend_returns)
asset_returns_df.plot(title="PIMCO CEFs + SCHP", grid=True, figsize=(10,8))
def calc_basket_beta(asset_returns_df: pd.DataFrame, market_return_df: pd.DataFrame) -> pd.DataFrame:
asset_beta_l: list = []
for col in asset_returns_df.columns:
asset_beta = calc_asset_beta(asset_returns_df[col], market_return_df)
asset_beta_l.append(asset_beta)
basket_beta_df: pd.DataFrame = pd.DataFrame(asset_beta_l).transpose()
basket_beta_df.columns = asset_returns_df.columns
return basket_beta_df
def calc_basket_std(asset_returns_df: pd.DataFrame) -> pd.DataFrame:
asset_std: list = []
for col in asset_returns_df.columns:
asset_std.append( np.std(asset_returns_df[col]) )
basket_std: pd.DataFrame = pd.DataFrame(asset_std).transpose()
basket_std.columns = asset_returns_df.columns
return basket_std
market_return_index = market_return.index
dividend_return_index = dividend_returns.index
market_filter = market_return_index.isin(dividend_return_index)
trunc_market_return = market_return[market_filter]
asset_beta_df = calc_basket_beta(dividend_returns, trunc_market_return)
asset_beta_df.index = ['Beta']
print(tabulate(asset_beta_df, headers=['', *asset_beta_df.columns], tablefmt='fancy_grid'))
asset_std_df = calc_basket_std(dividend_returns)
asset_std_df['Market'] = pd.DataFrame([ np.std(trunc_market_return)])
asset_std_df.index = ['StdDev']
print("Daily Return Standard Deviation")
print(tabulate(asset_std_df, headers=['', *asset_std_df.columns], tablefmt='fancy_grid'))
dividend_returns_adj, rf_adj = adjust_time_series(dividend_returns, rf_daily_df)
pimco_asset_sharpe_ratio = calc_sharpe_ratio(dividend_returns_adj, pd.Series(rf_adj.values.flatten()), trading_days)
print("Pimco fund Sharpe Ratios")
print(tabulate(pimco_asset_sharpe_ratio, headers=['', *pimco_asset_sharpe_ratio.columns], tablefmt='fancy_grid'))
╒══════╤══════════╤══════════╤══════════╤══════════╤════════════╕ │ │ PHK │ RCS │ PTY │ PMF │ SCHP │ ╞══════╪══════════╪══════════╪══════════╪══════════╪════════════╡ │ Beta │ 0.656877 │ 0.581192 │ 0.612949 │ 0.216868 │ -0.0539157 │ ╘══════╧══════════╧══════════╧══════════╧══════════╧════════════╛ Daily Return Standard Deviation ╒════════╤═══════════╤═══════════╤═══════════╤════════════╤════════════╤═══════════╕ │ │ PHK │ RCS │ PTY │ PMF │ SCHP │ Market │ ╞════════╪═══════════╪═══════════╪═══════════╪════════════╪════════════╪═══════════╡ │ StdDev │ 0.0145043 │ 0.0138357 │ 0.0136675 │ 0.00897905 │ 0.00327286 │ 0.0106057 │ ╘════════╧═══════════╧═══════════╧═══════════╧════════════╧════════════╧═══════════╛ Pimco fund Sharpe Ratios ╒═════════════════════════╤══════════╤══════════╤══════════╤══════════╤══════════╕ │ │ PHK │ RCS │ PTY │ PMF │ SCHP │ ╞═════════════════════════╪══════════╪══════════╪══════════╪══════════╪══════════╡ │ 2011-01-04 : 2021-11-23 │ 0.297758 │ 0.400005 │ 0.600167 │ 0.532914 │ 0.702644 │ ╘═════════════════════════╧══════════╧══════════╧══════════╧══════════╧══════════╛
With the exception of the PIMCO municipal bond fund (PMF) all of the PIMCO funds are more volatile than the "market" in the same period.
The PIMCO income funds have beta values of under 0.7, which may make them attractive portfolio components. Of the PIMCO funds, the PTY (total income) seems the most attractive because of its dividend yield.
In their book The Incredible Shrinking Alpha, Larry E Swedroe and Andrew L Berkin point out that asset return is the same, whether it is paid out as a dividend or as asset appreciation. In analyzing return in this notebook the adjusted close price is used. The adjusted close price reflects dividend payments and market appreciation.
The market price for the PIMCO funds are highly volatile, as the plot above shows. The volatility is also reflected in their relatively low Sharpe ratios. The total return volatility of the PIMCO funds suggests that they may not be a good investment.
The VTI ETF is a good "market" ETF comparable to the S&P 500 SPY ETF. SCHP is Schwab's inflation protected treasury bond ETF. VTI is a proxy for the large cap stock market, while SCHP could be used as a proxy for the bond market. The simplicity of a VTI, SCHP portfolio, is the attraction. This section investigates the performance of a 40% VTI, 44% VGLT, 16% VGIT (the previous 40/60 portfolio) and a the portfolio with 40% VTI and 60% SCHP.
simple_port_symbols = [ 'VTI', 'SCHP']
simple_port_weights = {"VTI": 0.40, "SCHP": 0.60 }
simple_port_weights_df: pd.DataFrame = pd.DataFrame( simple_port_weights.values()).transpose()
simple_port_weights_df.columns = simple_port_weights.keys()
# The SCHP adjusted close time series has been previously fetched. See dividend_adj_close.
# The close prices has not been previously fetched.
schp_close_file = 'schp_close'
schp_close = get_market_data(file_name=schp_close_file,
data_col='Close',
symbols=['SCHP'],
data_source=data_source,
start_date=div_start_date,
end_date=end_date)
etf_close_forty_sixty = etf_close[forty_sixty_weights_df.columns]
# Truncate the etf_close time series so that they match the schp_close time series
# This is a close price not an adjusted close price time series.
etf_close_trunc: pd.DataFrame = pd.DataFrame()
for col in etf_close_forty_sixty.columns:
ts_close_trunc, temp = adjust_time_series(etf_close_forty_sixty[col], schp_close)
etf_close_trunc[col] = ts_close_trunc
# The dividend returns DataFrame is calculated from adjusted close prices
schp_returns = dividend_returns['SCHP']
etf_returns_trunc: pd.DataFrame = pd.DataFrame()
for col in forty_sixty_weights_df.columns:
ret_trunc, temp = adjust_time_series(returns[col], schp_returns)
etf_returns_trunc[col] = ret_trunc
sixty_forty_trunc_close = etf_close_trunc[0:1]
sixty_forty_trunc_holdings, shares = calc_portfolio_holdings(initial_investment=initial_investment,
weights=forty_sixty_weights_df,
prices=sixty_forty_trunc_close)
sixty_forty_port_trunc_df, sixty_forty_port_total_df = calc_rebalanced_portfolio(holdings=sixty_forty_trunc_holdings,
etf_close=etf_close_trunc,
returns=etf_returns_trunc,
weights=forty_sixty_weights_df,
rebalance_days=trading_days)
simple_port_returns = pd.concat([etf_returns_trunc['VTI'], schp_returns], axis=1)
simple_port_close = pd.concat([etf_close_trunc['VTI'], schp_close], axis=1)
simple_port_prices = simple_port_close[0:1]
simple_port_holdings, shares = calc_portfolio_holdings(initial_investment=initial_investment,
weights=simple_port_weights_df,
prices=simple_port_prices)
simple_port_df, simple_port_total_df = calc_rebalanced_portfolio(holdings=simple_port_holdings,
etf_close=simple_port_close,
returns=simple_port_returns,
weights=simple_port_weights_df,
rebalance_days=trading_days)
portfolios_df: pd.DataFrame = pd.concat([simple_port_total_df, sixty_forty_port_total_df], axis=1)
portfolios_df.columns = ['VTI/SCHP', 'VTI,(VGLT, VGIT)']
portfolios_df.plot(title="40/60 VTI/SCHP, 40/60 VTI,(VGLT, VGIT)", grid=True, figsize=(10,8))
def portfolio_adj_timeseries(portfolio_df: pd.DataFrame, time_series_df: pd.DataFrame) -> Tuple[pd.DataFrame, pd.DataFrame]:
portfolio_adj: pd.DataFrame = pd.DataFrame()
ts_adj: pd.DataFrame = pd.DataFrame()
for col in portfolio_df.columns:
port_adj, ts_adj = adjust_time_series(portfolio_df[col], time_series_df)
portfolio_adj[col] = port_adj
return portfolio_adj, ts_adj
portfolio_ret = return_df(portfolios_df)
portfolio_ret_adj, rf_adj = portfolio_adj_timeseries(portfolio_ret, rf_daily_df)
port_sharpe_ratio = calc_sharpe_ratio(portfolio_ret_adj, pd.Series(rf_adj.values.flatten()), trading_days)
port_vol = pd.DataFrame(list( portfolio_ret_adj.std())).transpose()
port_vol.columns = portfolio_ret_adj.columns
The return at the end of the time period for the VTI,(VGLT,VGIT) portfolio is slightly higher than the VTI/SCHP portfolio. The slightly lower return of the VTI/SCHP portfolio is balanced by the lower volatility and the higher Sharpe ration shown in the table below.
print('Portfolio daily return standard deviation')
print(tabulate(port_vol, headers=['StdDev', *port_vol.columns], tablefmt='fancy_grid'))
print('Sharpe Ratio')
print(tabulate(port_sharpe_ratio, headers=['', *port_sharpe_ratio.columns], tablefmt='fancy_grid'))
Portfolio daily return standard deviation ╒══════════╤════════════╤════════════════════╕ │ StdDev │ VTI/SCHP │ VTI,(VGLT, VGIT) │ ╞══════════╪════════════╪════════════════════╡ │ 0 │ 0.00431139 │ 0.00424946 │ ╘══════════╧════════════╧════════════════════╛ Sharpe Ratio ╒═════════════════════════╤════════════╤════════════════════╕ │ │ VTI/SCHP │ VTI,(VGLT, VGIT) │ ╞═════════════════════════╪════════════╪════════════════════╡ │ 2011-01-04 : 2021-11-23 │ 1.13983 │ 1.25064 │ ╘═════════════════════════╧════════════╧════════════════════╛
Drawdown plots show the cumulative drawdowns. These drawdowns are not necessarily sequential and may be interspersed with profit regions that are not shown in the plot.
# The drawdown package can only plot a single time series so select the VTI/SCHP series
sixty_forty_port_ret = portfolio_ret[portfolio_ret.columns[0]]
# The QuantStats package requires a datetime index. The existing index is strings.
sixty_forty_port_ret.index = pd.to_datetime(portfolio_ret.index)
qs.plots.drawdown( sixty_forty_port_ret, figsize=(10,8) )
Mean variance investment portfolio optimization was pioneered by Harry Markowitz, who won the Nobel prize in economics for this work. Mean variance optimization is often classified under the term "Modern Portfolio Theory". Mean variance optimization provides a more emperical approach to investment portfolio diversification, but this approach is not without it's problems.
As the name suggests, mean variance optimization relies on a calculated mean return. The theoretical work on mean variance optimization assumes that the mean is stable and the returns are normally distributed. These assumptions are manifestly untrue for almost any stock. The mean is not stable and the returns have "fat tails".
PyPortfolioOpt is a library that implements portfolio optimization methods, including classical mean-variance optimization techniques and Black-Litterman allocation, as well as more recent developments in the field like shrinkage and Hierarchical Risk Parity, along with some novel experimental features like exponentially-weighted covariance matrices.
The 40% market (VTI) and 60% bond (SCHP) portfolio is based on a rule of thumb. If mean variance portfolio optimization is used for these two assets, what would the resulting weights be?
In calculating the mean and the variance, the adjusted close price is used since this properly reflects the contribution of dividends in the returns.
There is a vast amount of academic literature on mean variance optimization and modifications that take into account "tail risk". I have seen less information about how to apply mean variance optimization.
# dividend_adj_close['SCHP'] contains the adjusted close prices for SCHP from it's inception.
# etf_adj_close['VTI'] contains the VTI adjusted close prices. This is a longer period that dividend_adj_close so the
# period must be adjusted so that it is the same.
vti_adj_close = etf_adj_close['VTI']
schp_adj_close = dividend_adj_close['SCHP']
vti_adj_close, schp_adj_close = adjust_time_series(vti_adj_close, schp_adj_close)
sixty_forty_adj_close = pd.concat([vti_adj_close, schp_adj_close], axis=1)
def calc_mv_opt_weights(adj_close: pd.DataFrame, default_df:pd.DataFrame) -> pd.DataFrame:
mu = expected_returns.capm_return(adj_close)
if all(mu > 0):
S = risk_models.CovarianceShrinkage(adj_close).ledoit_wolf()
ef = pyopt.EfficientFrontier(mu, S)
ef.max_sharpe()
opt_weights = ef.clean_weights()
opt_weights_df: pd.DataFrame = pd.DataFrame( opt_weights.values()).transpose()
opt_weights_df.columns = opt_weights.keys()
else:
opt_weights_df = default_df
return opt_weights_df
ix_l = list(ix for ix in range((sixty_forty_adj_close.shape[0]-1), -1, -trading_days))
ix_l.reverse()
if ix_l[0] > 0:
ix_l[0] = 0
vti_schp_weights = pd.DataFrame()
for i in range(1, len(ix_l)):
start_ix = ix_l[i-1]
end_ix = ix_l[i]
sec = sixty_forty_adj_close[start_ix:end_ix]
opt_weights_df = round(calc_mv_opt_weights(sec, simple_port_weights_df), 2)
opt_weights_df.index = [sixty_forty_adj_close.index[end_ix]]
vti_schp_weights = vti_schp_weights.append(opt_weights_df)
The table below shows the mean/variance weights for VTI/SCHP, optimized for the maximum Sharpe ratio. In calculating these weights, a previous year (253 trading days) adjusted close prices are used to calculate each set of weights.
The weights for 2015 are default weights since the mean return that year was negative and mean/variance optimizaton fails for a negative value.
print("Portfolio weights calculated via mean/variance optimization for maximum sharpe ratio")
print(tabulate(vti_schp_weights, headers=['', *vti_schp_weights.columns], tablefmt='fancy_grid'))
Portfolio weights calculated via mean/variance optimization for maximum sharpe ratio ╒════════════╤═══════╤════════╕ │ │ VTI │ SCHP │ ╞════════════╪═══════╪════════╡ │ 2012-11-07 │ 0.55 │ 0.45 │ ├────────────┼───────┼────────┤ │ 2013-11-08 │ 0.53 │ 0.47 │ ├────────────┼───────┼────────┤ │ 2014-11-11 │ 0.53 │ 0.47 │ ├────────────┼───────┼────────┤ │ 2015-11-12 │ 0.4 │ 0.6 │ ├────────────┼───────┼────────┤ │ 2016-11-14 │ 0.54 │ 0.46 │ ├────────────┼───────┼────────┤ │ 2017-11-15 │ 0.53 │ 0.47 │ ├────────────┼───────┼────────┤ │ 2018-11-16 │ 0.62 │ 0.38 │ ├────────────┼───────┼────────┤ │ 2019-11-20 │ 0.59 │ 0.41 │ ├────────────┼───────┼────────┤ │ 2020-11-20 │ 0.64 │ 0.36 │ ├────────────┼───────┼────────┤ │ 2021-11-23 │ 0.53 │ 0.47 │ ╘════════════╧═══════╧════════╛
The more adjusted close prices that are used to calculate the mean and variance, the more accurate these values will be (although there are ways fat tails values, which are sometimes referred to as "Black Swans"). To take advantage of the accuracy provided by more adjusted close price values, the weights would not be recalculated every year but calculated once for the current year going forward using the entire historical data set. While this approach the one that would be followed, it creates problems for backtesting with historical data.
Recognizing that the ultimate sin in backtesting is to allow future knowledge to be used on historical data, this section looks at the historical performance of portfolio weights that were arrived at using adjusted close price data from the current time backward. The portfolio is rebalanced every year using these weights.
opt_port_weights_df = round(calc_mv_opt_weights(sixty_forty_adj_close, simple_port_weights_df), 2)
print("Optimized VTI/SCHP portfolio weights")
print(tabulate(opt_port_weights_df, headers=['', *opt_port_weights_df.columns], tablefmt='fancy_grid'))
opt_port_holdings, shares = calc_portfolio_holdings(initial_investment=initial_investment,
weights=opt_port_weights_df,
prices=simple_port_prices)
opt_port_df, opt_port_total_df = calc_rebalanced_portfolio(holdings=opt_port_holdings,
etf_close=simple_port_close,
returns=simple_port_returns,
weights=opt_port_weights_df,
rebalance_days=trading_days)
portfolios_df: pd.DataFrame = pd.concat([simple_port_total_df, opt_port_total_df], axis=1)
portfolios_df.columns = ['40/60 VTI/SCHP', 'Optimized VTI/SCHP']
portfolios_df.plot(title="40/60 VTI/SCHP, Optimized VTI/SCHP", grid=True, figsize=(10,8))
portfolios_ret = return_df(portfolios_df)
portfolios_ret_adj, rf_adj = portfolio_adj_timeseries(portfolios_ret, rf_daily_df)
portfolios_sharpe_ratio = calc_sharpe_ratio(portfolios_ret_adj, pd.Series(rf_adj.values.flatten()), trading_days)
Optimized VTI/SCHP portfolio weights ╒════╤═══════╤════════╕ │ │ VTI │ SCHP │ ╞════╪═══════╪════════╡ │ 0 │ 0.52 │ 0.48 │ ╘════╧═══════╧════════╛
The optimized portfolio weights result is a portfolio with a Sharpe ratio that is lower than the 40/60 portfolio.
print('Sharpe Ratio')
print(tabulate(portfolios_sharpe_ratio, headers=['', *portfolios_sharpe_ratio.columns], tablefmt='fancy_grid'))
Sharpe Ratio ╒═════════════════════════╤══════════════════╤══════════════════════╕ │ │ 40/60 VTI/SCHP │ Optimized VTI/SCHP │ ╞═════════════════════════╪══════════════════╪══════════════════════╡ │ 2011-01-04 : 2021-11-23 │ 1.13983 │ 1.05537 │ ╘═════════════════════════╧══════════════════╧══════════════════════╛
The optimized portfolio is more volatile and has higher drawdowns in cases.
# The drawdown package can only plot a single time series so select the optimized portfolio
# series
opt_port_ret = portfolios_ret[portfolios_ret.columns[1]]
# The QuantStats package requires a datetime index. The existing index is strings.
opt_port_ret.index = pd.to_datetime(opt_port_ret.index)
qs.plots.drawdown( opt_port_ret, figsize=(10,8) )
Stocks with good dividend yields are a possible alternative to dividend producing ETFs. Portfolio construction and maintanence for a set of individual stocks is more complicated than building a portfolio from a small set of ETFs and closed end funds. These stocks are listed as a comparision to the ETF and closed end funds.
Most stocks with good dividend yield are natural resource and chemical stocks. The natural resource and chemical sectors tend to be correlated, so these stocks would not be a diversified portfolio. But they could be combined with other assets to for an alternative to the all weather portfolio.
Dividend Stocks | |
---|---|
Symbol | Dividend yield (May 2021) |
OXLC (CEF) | 10.29% |
EVV (CEF) | 9.13 |
BHK (CEF) | 5.49 |
RIO | 5.0% |
BHP | 3.95% |
SWM | 3.91 |
LYB | 3.82 |
SCCO | 3.56 |
NEM | 3.24 |
OXLC (Oxford Lane Capital Corp) has a fairly eye poping dividend rate. This may be a result of the risk of the fund's underlying assets:
Oxford Lane Capital Corp. is a close ended fund launched and managed by Oxford Lane Management LLC. It invests in fixed income securities. The fund primarily invests in securitization vehicles which in turn invest in senior secured loans made to companies whose debt is rated below investment grade or is unrated. Oxford Lane Capital Corp was formed on June 9, 2010 and is domiciled in the United States.
Looking at the total return (asset price appreciation and dividend) the stock looks less attractive historically as the stock is currently (Nov 2021) selling for half of its initial issue price.
Eaton Vance Limited Duration Income Fund is a closed-ended fixed income mutual fund launched and managed by Eaton Vance Management. The fund invests in the fixed income markets of the United States. It primarily invests in senior, secured floating-rate loans, government agency mortgage-backed securities, and corporate bonds that are rated below investment grade. The fund seeks to maintain an average duration of three and a half years and average quality BBB/BBB- in its investments. It benchmarks the performance of its portfolio against the S&P/LSTA Leveraged Loan Index, the Merrill Lynch U.S. High Yield Index, and the Barclays Capital U.S. Intermediate Government Bond Index. Eaton Vance Limited Duration Income Fund was formed on May 30, 2003 and is domiciled in the United States.
The market beta of EVV is 0.44
The market price for EVV declined from 15.95 in Jan of 2011 to its current price of 13.20 (Nov 19, 2021).
The Sharpe ratio is ranges between 0.4 and 0.7
BlackRock Core Bond Trust is a closed-ended fixed income mutual fund launched by BlackRock, Inc. The fund is managed by BlackRock Advisors, LLC. It invests in the fixed income markets of the United States. The fund primarily invests in investment grade quality bonds, including corporate bonds, government and agency securities, and mortgage-related securities. BlackRock Core Bond Trust was formed on November 30, 2001 and is domiciled in the United States.
The market beta for BHK is 0.35
The market price for BHK on Jan 1, 2011 was 12.27. The current price (Nov 19, 2021) is 16.26.
The Sharpe ratio is close to 1.0
The BHK expense ratio is 0.92% The fund uses some amount of leverage which acconts for the relatively high expense for a bond fund.
Inception date: Nov 30, 2001
The total return of the BHK close end mutual fund appears to be the most attractive of the funds listed above.
bhk_close_file = 'bhk_close'
bhk_close = get_market_data(file_name=bhk_close_file,
data_col='Close',
symbols=['BHK'],
data_source=data_source,
start_date=div_start_date,
end_date=end_date)
bhk_adj_close_file = 'bhk_adj_close'
bhk_adj_close = get_market_data(file_name=bhk_adj_close_file,
data_col='Adj Close',
symbols=['BHK'],
data_source=data_source,
start_date=div_start_date,
end_date=end_date)
bhk_adj_close.plot(title="BHK Ajusted Close Price", grid=True, figsize=(10,8))
<AxesSubplot:title={'center':'BHK Ajusted Close Price'}, xlabel='Date'>
This section examines the BHK close end mutual fund. This fund has higher dividend yield than SCHP and relatively low market beta. This suggests that this fund might be used to boost the portfolio yield without adding significant risk.
bhk_port_adj_close = pd.concat([sixty_forty_adj_close, bhk_adj_close], axis=1)
bhk_port_close = pd.concat([simple_port_close, bhk_close], axis=1)
bhk_port_prices = bhk_port_close[0:1]
bhk_port_weights_df = round(calc_mv_opt_weights(bhk_port_adj_close, simple_port_weights_df), 2)
print(tabulate(bhk_port_weights_df, headers=['', *bhk_port_weights_df.columns], tablefmt='fancy_grid'))
bhk_port_holdings, shares = calc_portfolio_holdings(initial_investment=initial_investment,
weights=bhk_port_weights_df,
prices=bhk_port_prices)
bhk_port_returns = return_df(bhk_port_adj_close)
bhk_port_df, bhk_port_total_df = calc_rebalanced_portfolio(holdings=bhk_port_holdings,
etf_close=bhk_port_close,
returns=bhk_port_returns,
weights=bhk_port_weights_df,
rebalance_days=trading_days)
portfolios_df: pd.DataFrame = pd.concat([simple_port_total_df, bhk_port_total_df], axis=1)
portfolios_df.columns = ['40/60 VTI/SCHP', 'Optimized VTI/SCHP/BHK']
╒════╤═══════╤════════╤═══════╕ │ │ VTI │ SCHP │ BHK │ ╞════╪═══════╪════════╪═══════╡ │ 0 │ 0.34 │ 0.31 │ 0.35 │ ╘════╧═══════╧════════╧═══════╛
portfolios_df.plot(title="40/60 VTI/SCHP, Optimized VTI/SCHP/BHK", grid=True, figsize=(10,8))
portfolios_ret = return_df(portfolios_df)
portfolios_ret_adj, rf_adj = portfolio_adj_timeseries(portfolios_ret, rf_daily_df)
portfolios_sharpe_ratio = calc_sharpe_ratio(portfolios_ret_adj, pd.Series(rf_adj.values.flatten()), trading_days)
print('Sharpe Ratio')
print(tabulate(portfolios_sharpe_ratio, headers=['', *portfolios_sharpe_ratio.columns], tablefmt='fancy_grid'))
Sharpe Ratio ╒═════════════════════════╤══════════════════╤══════════════════════════╕ │ │ 40/60 VTI/SCHP │ Optimized VTI/SCHP/BHK │ ╞═════════════════════════╪══════════════════╪══════════════════════════╡ │ 2011-01-04 : 2021-11-23 │ 1.13983 │ 1.10581 │ ╘═════════════════════════╧══════════════════╧══════════════════════════╛
The Sharpe ratio of the VTI/SCHP/BHK is not was good as the 40/60 VTI/SCHP portfolio. Nor is it as good as the VTI,(VGLT, VGIT) examined above. The VTI,(VGLT, VGIT) portfolio has the advantage of low fees, in addition to it's higher Sharpe ratio and similar returns to the BHK portfollo.
From Wikipedia:
Quantopian aimed to create a crowd-sourced hedge fund by letting freelance quantitative analysts develop, test, and use trading algorithms to buy and sell securities.
In February 2020, Quantopian announced it would return investors' money due to the underperformance of its investment strategies. In November 2020, Quantopian announced it will shut down after 9 years.
Quantopian published a large library of open source (Apache 2 license) Python code that can be used for quantitative/computational finance. It is not clear to me who, if anyone, maintains and extends the Quantopian libraries. I have not seen any contributions to the repositories since 2020. The repository owners are all listed as "members" of Quantopian Inc.
This notebook is not financial advice, investing advice, or tax advice. The information in this notebook is for informational and recreational purposes only. Investment products discussed (ETFs, mutual funds, etc.) are for illustrative purposes only. This is not a recommendation to buy, sell, or otherwise transact in any of the products mentioned. Do your own due diligence. Past performance does not guarantee future returns.