Academic Paper: "Long-Term Investing in Triple Leveraged Exchange Traded Funds", By Lewis Glenn

Table of Contents
Introduction #
In late 2025, I ran across a paper by Lewis Glenn entitled “Long-Term Investing in Triple Leveraged Exchange Traded Funds.” The paper explores the performance and risks associated with holding triple leveraged ETFs over extended periods, and proposes an investment strategy involving rebalancing two triple leveraged ETFs (abbreviated “TLETFs”) on a bimonthly schedule. From the abstract:
“The phrase long-term investing in triple leveraged ETFs is somewhat of an anathema for investment academicians. As a result of daily rebalancing and so-called beta slippage or “the constant leverage trap” it is highly likely over the long term to significantly deviate from the targeted leverage and, in so doing, generate wipeout losses. In fact, the data show that many 3X leveraged ETFs have performed poorly over the long term in the period of their existence. However, there are some significant exceptions. The 3X leveraged Nasdaq ETF, TQQQ, for example has delivered a total return exceeding 10,000% (CAGR of 54.4%) over the 10+ years of its existence, more than 13 times the total return of its underlying ETF (QQQ). Similar, but less impressive numbers have been achieved with triple leveraged ETFs that target the S&P 500 and the Dow Industrials.”
“These results, however, are accompanied by excessive volatility and very large maximum end-of-month (EOM) draw downs, in excess of 49% for TQQQ, and with maximum daily drawdown of almost 70%. Many retail investors would be unwilling to sustain such (paper) losses. Here we propose a strategy to mitigate these large draw downs while still retaining much of the upside performance of triple leveraging. We show that by simply establishing a portfolio consisting of an equal dollar amount of TQQQ and TMF (the triple leveraged ETF linked to the 20+ year treasury bond), and performing bimonthly rebalancing, a total return, after transaction costs, in excess of 5,800% (CAGR of 44.9%) would have been achieved over the 10+ years of TQQQ existence, with the maximum EOM draw down less than 25%.”
The paper was published (or dated, at least) November 7, 2020. In this piece, we will attempt to reproduce the results of the investment strategy presented in the paper section by section, and examine whether the impressive performance in sample has continued.
A note on the numbering of figures and tables: figures that reproduce a figure from the paper use the same number as the paper (for example, Figure 1), with a letter added when we split one of the paper’s figures into several plots (for example, Figures 9a, 9b, and 9c). Figure 10 from the paper is reproduced as a table. Figure 6 is omitted due to data limitations, and figure 11 (a pseudo-code diagram) is discussed but not reproduced. All figures and tables that do not appear in the paper are numbered with an “E” prefix (for example, Figure E1 and Table E1).
Python Imports #
# Standard Library
import os
import sys
import warnings
from pathlib import Path
# Data Handling
import pandas as pd
# Suppress warnings
warnings.filterwarnings("ignore")
# Add the source subdirectory to the system path to allow import config from settings.py
current_directory = Path(os.getcwd())
website_base_directory = current_directory.parent.parent.parent
src_directory = website_base_directory / "src"
sys.path.append(str(src_directory)) if str(src_directory) not in sys.path else None
# Import settings.py
from settings import config
# Add configured directories from config to path
SOURCE_DIR = config("SOURCE_DIR")
(
sys.path.append(str(Path(SOURCE_DIR)))
if str(Path(SOURCE_DIR)) not in sys.path
else None
)
# Add other configured directories
BASE_DIR = config("BASE_DIR")
CONTENT_DIR = config("CONTENT_DIR")
POSTS_DIR = config("POSTS_DIR")
PAGES_DIR = config("PAGES_DIR")
PUBLIC_DIR = config("PUBLIC_DIR")
SOURCE_DIR = config("SOURCE_DIR")
DATA_DIR = config("DATA_DIR")
DATA_MANUAL_DIR = config("DATA_MANUAL_DIR")
Python Functions #
Here are the functions needed for this project:
- load_data: Load data from a CSV, Excel, or Pickle file into a pandas DataFrame.
- pandas_set_decimal_places: Set the number of decimal places displayed for floating-point numbers in pandas.
- plot_time_series: Plot the timeseries data from a DataFrame for a specified date range and columns.
- run_regression: Run a linear regression using statsmodels OLS and return the results.
- summary_stats: Generate summary statistics for a series of returns.
- yf_pull_data: Download daily price data from Yahoo Finance and export it.
from load_data import load_data
from pandas_set_decimal_places import pandas_set_decimal_places
from plot_time_series import plot_time_series
from run_regression import run_regression
from summary_stats import summary_stats
from yf_pull_data import yf_pull_data
Data Overview #
For this exercise, we will use the following ETFs:
- TQQQ - ProShares UltraPro QQQ
- QQQ - Invesco QQQ Trust
- UPRO - ProShares UltraPro S&P 500
- SPY - SPDR S&P 500 ETF Trust
- UDOW - ProShares UltraPro Dow30
- DIA - SPDR Dow Jones Industrial Average ETF Trust
- TNA - Direxion Daily Small Cap Bull 3X Shares
- IWM - iShares Russell 2000 ETF
- TMF - Direxion Daily 20+ Year Treasury Bull 3X Shares
- TLT - iShares 20+ Year Treasury Bond ETF
- EDC - Direxion Daily MSCI Emerging Markets Bull 3X Shares
- EEM - iShares MSCI Emerging Markets ETF
- IEF - iShares 7-10 Year Treasury Bond ETF (used for the “black swan” filter in section 3.2)
- AGG - iShares Core U.S. Aggregate Bond ETF (used in extension 3)
We’ll use the adjusted close prices for each of these ETFs.
The paper also used the following funds:
- DZK - Direxion Daily MSCI Developed Markets Bull 3X Shares
- EFA - iShares MSCI EAFE ETF
But we will omit these due to DZK being delisted.
Acquire Data #
First, let’s get the data for these ETFs. If we already have the desired data, we can load it from a local pickle file. Otherwise, we can download it from Yahoo Finance using the yf_pull_data function.
pandas_set_decimal_places(2)
# Create a list of the ETF tickers
fund_list = [
"TQQQ",
"QQQ",
"UPRO",
"SPY",
"UDOW",
"DIA",
"TNA",
"IWM",
"TMF",
"TLT",
"EDC",
"EEM",
]
# Pull data for each ETF and cache it locally
for fund in fund_list:
yf_pull_data(
base_directory=DATA_DIR,
ticker=fund,
adjusted=False,
source="Yahoo_Finance",
asset_class="Exchange_Traded_Funds",
excel_export=True,
pickle_export=True,
output_confirmation=False,
)
Create Data Dictionary #
Then, create a dictionary to hold the data for each ETF.
# Create an empty dictionary to hold the data for each ETF
fund_data = {}
# Load the data for each ETF, rename the columns to include the ticker, store it in the fund_data dictionary
for fund in fund_list:
data = load_data(
base_directory=DATA_DIR,
ticker=fund,
source="Yahoo_Finance",
asset_class="Exchange_Traded_Funds",
timeframe="Daily",
file_format="pickle",
)
data = data.rename(
columns={
"Adj Close": f"{fund}_Adj_Close",
"Close": f"{fund}_Close",
"High": f"{fund}_High",
"Low": f"{fund}_Low",
"Open": f"{fund}_Open",
"Volume": f"{fund}_Volume",
}
)
fund_data[fund] = data
Section 2.0: Long-term Empirical behavior of Triple Leveraged Exchange Traded Funds #
Section 2.0 of the paper establishes some baseline definitions, specifically CAGR (referenced in the paper as cumulative annualized growth rate, but more commonly as compound annual growth rate), DDD (daily drawdown, daily and maximum), and MDD (monthly drawdown, monthly and maximum).
The following two helper functions will combine and normalize the fund prices, and calculate the summary statistics.
def combine_normalize(
fund_data: dict,
lev_etf: str,
etf: str,
start_date=None,
end_date=None,
) -> pd.DataFrame:
"""
Combine and normalize the data for a leveraged ETF and its corresponding non-leveraged ETF.
Parameters
----------
fund_data : dict
Dictionary containing DataFrames for each fund.
lev_etf : str
The ticker symbol for the leveraged ETF.
etf : str
The ticker symbol for the non-leveraged ETF.
start_date : optional,
Start date for the data. If None, uses the earliest available date (default is None).
end_date : optional
End date for the data. If None, uses the latest available date (default is None).
Returns
-------
pd.DataFrame
DataFrame with normalized values for both ETFs.
"""
# Combine into a single DataFrame
df = fund_data[lev_etf].merge(fund_data[etf], left_index=True, right_index=True)
# Normalize values to start at 1
df = df / df.iloc[0]
# Set start and end dates
if start_date is None:
start_date = df.index.min()
if end_date is None:
end_date = df.index.max()
df = df.loc[start_date:end_date]
return df
def calculate_returns_stats(
df: pd.DataFrame,
lev_etf: str,
etf: str,
max_per_ratio_date=None,
) -> pd.DataFrame:
"""
Calculate and display summary statistics for the returns of a leveraged ETF and its corresponding non-leveraged ETF.
Parameters
----------
df : pd.DataFrame
DataFrame containing the adjusted close prices of the ETFs.
lev_etf : str
Ticker symbol of the leveraged ETF.
etf : str
Ticker symbol of the non-leveraged ETF.
max_per_ratio_date : optional
Date of the maximum performance ratio. If provided, the CAGR is calculated through this date, while
all other statistics use the full date range. If None, the CAGR uses the full date range (default is None).
Returns
-------
pd.DataFrame
DataFrame containing the summary statistics for both ETFs, including the maximum monthly drawdown
calculated from end-of-month prices only.
"""
df[f"{lev_etf}_Return"] = df[f"{lev_etf}_Adj_Close"].pct_change()
df[f"{etf}_Return"] = df[f"{etf}_Adj_Close"].pct_change()
# Create empty DataFrame for summary statistics
sum_stats = pd.DataFrame()
df_lev_stats = summary_stats(
fund_list=[f"{lev_etf}"],
df=df[[f"{lev_etf}_Return"]].dropna(),
period="Daily",
use_calendar_days=False,
excel_export=False,
pickle_export=False,
output_confirmation=False,
)
sum_stats = pd.concat([sum_stats, df_lev_stats])
df_etf_stats = summary_stats(
fund_list=[f"{etf}"],
df=df[[f"{etf}_Return"]].dropna(),
period="Daily",
use_calendar_days=False,
excel_export=False,
pickle_export=False,
output_confirmation=False,
)
sum_stats = pd.concat([sum_stats, df_etf_stats])
# Replace the CAGR with the CAGR through the maximum performance ratio date, if provided
if max_per_ratio_date is not None:
for fund in [lev_etf, etf]:
df_cagr_stats = summary_stats(
fund_list=[f"{fund}"],
df=df.loc[:max_per_ratio_date, [f"{fund}_Return"]].dropna(),
period="Daily",
use_calendar_days=False,
excel_export=False,
pickle_export=False,
output_confirmation=False,
)
sum_stats.loc[f"{fund}_Return", "CAGR (Geometric)"] = df_cagr_stats.loc[
f"{fund}_Return", "CAGR (Geometric)"
]
# Record the end date used for the CAGR
sum_stats["CAGR End Date"] = (
max_per_ratio_date
if max_per_ratio_date is not None
else df.index.max().strftime("%Y-%m-%d")
)
# Calculate the maximum monthly drawdown using end-of-month prices only
for fund in [lev_etf, etf]:
month_end_prices = df[f"{fund}_Adj_Close"].resample("ME").last()
sum_stats.loc[f"{fund}_Return", "Max Monthly Drawdown"] = (
month_end_prices / month_end_prices.cummax() - 1
).min()
return sum_stats
Figure 1: Comparing the normalized value of TQQQ with its underlying index #
First, we will plot TQQQ and QQQ to match figure 1. Note that the data from the paper is normalized to begin at 1, so we will follow that approach as well.
lev_etf = "TQQQ"
etf = "QQQ"
end_date = "2020-10-04"
df = combine_normalize(
fund_data=fund_data,
lev_etf=lev_etf,
etf=etf,
start_date=None,
end_date=end_date,
)
print(f"Table E1: {lev_etf} & {etf} Normalized Data")
display(df)
Table E1: TQQQ & QQQ Normalized Data
| TQQQ_Adj_Close | TQQQ_Close | TQQQ_High | TQQQ_Low | TQQQ_Open | TQQQ_Volume | QQQ_Adj_Close | QQQ_Close | QQQ_High | QQQ_Low | QQQ_Open | QQQ_Volume | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Date | ||||||||||||
| 2010-02-11 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 |
| 2010-02-12 | 1.00 | 1.00 | 1.01 | 1.03 | 1.03 | 2.49 | 1.00 | 1.00 | 1.00 | 1.01 | 1.01 | 1.01 |
| 2010-02-16 | 1.04 | 1.04 | 1.04 | 1.08 | 1.09 | 2.78 | 1.01 | 1.01 | 1.01 | 1.03 | 1.02 | 0.90 |
| 2010-02-17 | 1.06 | 1.06 | 1.06 | 1.11 | 1.12 | 5.55 | 1.02 | 1.02 | 1.02 | 1.04 | 1.03 | 0.89 |
| 2010-02-18 | 1.08 | 1.08 | 1.08 | 1.12 | 1.13 | 11.24 | 1.03 | 1.03 | 1.03 | 1.04 | 1.04 | 0.71 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 2020-09-28 | 75.16 | 74.97 | 74.73 | 77.40 | 79.44 | 39.54 | 7.08 | 6.35 | 6.33 | 6.35 | 6.43 | 0.63 |
| 2020-09-29 | 74.37 | 74.19 | 75.21 | 78.61 | 79.56 | 32.09 | 7.04 | 6.32 | 6.34 | 6.44 | 6.43 | 0.29 |
| 2020-09-30 | 75.85 | 75.66 | 77.90 | 79.06 | 79.01 | 49.01 | 7.09 | 6.36 | 6.42 | 6.45 | 6.42 | 0.53 |
| 2020-10-01 | 79.40 | 79.20 | 79.37 | 82.46 | 83.91 | 40.32 | 7.20 | 6.46 | 6.46 | 6.54 | 6.55 | 0.54 |
| 2020-10-02 | 72.85 | 72.67 | 76.82 | 76.64 | 78.72 | 57.81 | 7.00 | 6.28 | 6.45 | 6.39 | 6.41 | 0.81 |
2680 rows × 12 columns
# Create one empty dataframe for all summary statistics
all_sum_stats = pd.DataFrame()
max_per_ratio_date = "2020-09-02"
sum_stats = calculate_returns_stats(
df=df,
lev_etf=lev_etf,
etf=etf,
max_per_ratio_date=max_per_ratio_date,
)
all_sum_stats = pd.concat([all_sum_stats, sum_stats])
print("Table E2: Leveraged and Non-Leveraged ETF Summary Statistics")
display(all_sum_stats)
Table E2: Leveraged and Non-Leveraged ETF Summary Statistics
| Number of Observations | Data Start Date | Data End Date | Annual Mean Return (Arithmetic) | Annualized Volatility | Annualized Sharpe Ratio | CAGR (Geometric) | Daily Max Return | Daily Max Return (Date) | Daily Min Return | Daily Min Return (Date) | Max Drawdown | Peak | Trough | Recovery Date | Calendar Days to Recovery | MAR Ratio | CAGR End Date | Max Monthly Drawdown | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| TQQQ_Return | 2679 | 2010-02-12 | 2020-10-02 | 0.57 | 0.58 | 0.99 | 0.55 | 0.27 | 2020-03-13 | -0.34 | 2020-03-16 | -0.70 | 2020-02-19 | 2020-03-20 | 2020-07-10 | 112 | 0.71 | 2020-09-02 | -0.49 |
| QQQ_Return | 2679 | 2010-02-12 | 2020-10-02 | 0.20 | 0.20 | 1.03 | 0.21 | 0.08 | 2020-03-13 | -0.12 | 2020-03-16 | -0.29 | 2020-02-19 | 2020-03-16 | 2020-06-03 | 79 | 0.70 | 2020-09-02 | -0.17 |
The paper lists a CAGR of 54.4% for TQQQ and 21.2% for QQQ. We find nearly the same values in our calculations, as can be seen above at 55% and 21%.
For the DDD, the paper finds a maximum DDD of 69.2%, and we find 70%. For the MDD, the paper finds a maximum MDD of 49.1%, and we find 49%.
Note that the paper calculates the CAGR and the drawdowns over different date ranges. The CAGR is calculated through the date of the maximum performance ratio, which is the normalized value of the leveraged ETF divided by the normalized value of its underlying ETF. For TQQQ and QQQ, the paper finds a maximum performance ratio of 13.1 (101.12/7.73) on 9/2/2020. The DDD and MDD, on the other hand, are calculated over the full period shown in the plots, which ends on 10/4/2020. We follow the same approach, calculating the CAGR through the maximum performance ratio date and all other statistics through 10/4/2020.
Any slight differences can likely be attributed to our use of 252 trading days per year, vs the paper’s use of 250 trading days per year.
plot_time_series(
df=df,
plot_start_date=None,
plot_end_date=None,
plot_columns=[f"{lev_etf}_Adj_Close", f"{etf}_Adj_Close"],
title=f"Figure 1: {lev_etf} & {etf} Adjusted Close Price (Normalized) as of {end_date}",
x_label="Date",
x_format="Year",
x_tick_spacing=1,
x_tick_start=None,
x_tick_rotation=0,
y_label="Price ($)",
y_format="Decimal",
y_format_decimal_places=0,
y_tick_spacing="Auto",
y_tick_rotation=0,
caption="Data Source: Yahoo Finance, Analysis by the Author",
)

Figure 2: Comparing the normalized value of UPRO with its underlying index #
Next, UPRO and SPY.
lev_etf = "UPRO"
etf = "SPY"
end_date = "2020-10-04"
df = combine_normalize(
fund_data=fund_data,
lev_etf=lev_etf,
etf=etf,
start_date=None,
end_date=end_date,
)
print(f"Table E3: {lev_etf} & {etf} Normalized Data")
display(df)
Table E3: UPRO & SPY Normalized Data
| UPRO_Adj_Close | UPRO_Close | UPRO_High | UPRO_Low | UPRO_Open | UPRO_Volume | SPY_Adj_Close | SPY_Close | SPY_High | SPY_Low | SPY_Open | SPY_Volume | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Date | ||||||||||||
| 2009-06-25 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 |
| 2009-06-26 | 0.99 | 0.99 | 1.00 | 1.05 | 1.06 | 5.08 | 1.00 | 1.00 | 1.00 | 1.02 | 1.02 | 0.60 |
| 2009-06-29 | 1.02 | 1.02 | 1.02 | 1.06 | 1.07 | 3.37 | 1.01 | 1.01 | 1.01 | 1.02 | 1.03 | 0.60 |
| 2009-06-30 | 1.00 | 1.00 | 1.03 | 1.04 | 1.10 | 6.65 | 1.00 | 1.00 | 1.01 | 1.02 | 1.03 | 0.82 |
| 2009-07-01 | 1.01 | 1.01 | 1.04 | 1.08 | 1.08 | 4.67 | 1.00 | 1.00 | 1.01 | 1.03 | 1.03 | 0.62 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 2020-09-28 | 23.67 | 23.06 | 23.10 | 24.24 | 24.46 | 3.34 | 4.54 | 3.63 | 3.63 | 3.71 | 3.72 | 0.23 |
| 2020-09-29 | 23.28 | 22.68 | 23.07 | 24.11 | 24.63 | 2.74 | 4.52 | 3.61 | 3.63 | 3.70 | 3.72 | 0.18 |
| 2020-09-30 | 23.80 | 23.19 | 23.79 | 24.39 | 24.44 | 5.30 | 4.55 | 3.64 | 3.67 | 3.72 | 3.71 | 0.37 |
| 2020-10-01 | 24.24 | 23.62 | 23.88 | 24.84 | 25.44 | 3.11 | 4.58 | 3.66 | 3.68 | 3.74 | 3.77 | 0.32 |
| 2020-10-02 | 23.57 | 22.97 | 23.30 | 23.98 | 24.10 | 4.32 | 4.54 | 3.63 | 3.66 | 3.70 | 3.70 | 0.32 |
2839 rows × 12 columns
max_per_ratio_date = "2020-02-19"
sum_stats = calculate_returns_stats(
df=df,
lev_etf=lev_etf,
etf=etf,
max_per_ratio_date=max_per_ratio_date,
)
all_sum_stats = pd.concat([all_sum_stats, sum_stats])
print("Table E4: Leveraged and Non-Leveraged ETF Summary Statistics")
display(all_sum_stats)
Table E4: Leveraged and Non-Leveraged ETF Summary Statistics
| Number of Observations | Data Start Date | Data End Date | Annual Mean Return (Arithmetic) | Annualized Volatility | Annualized Sharpe Ratio | CAGR (Geometric) | Daily Max Return | Daily Max Return (Date) | Daily Min Return | Daily Min Return (Date) | Max Drawdown | Peak | Trough | Recovery Date | Calendar Days to Recovery | MAR Ratio | CAGR End Date | Max Monthly Drawdown | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| TQQQ_Return | 2679 | 2010-02-12 | 2020-10-02 | 0.57 | 0.58 | 0.99 | 0.55 | 0.27 | 2020-03-13 | -0.34 | 2020-03-16 | -0.70 | 2020-02-19 | 2020-03-20 | 2020-07-10 | 112.00 | 0.71 | 2020-09-02 | -0.49 |
| QQQ_Return | 2679 | 2010-02-12 | 2020-10-02 | 0.20 | 0.20 | 1.03 | 0.21 | 0.08 | 2020-03-13 | -0.12 | 2020-03-16 | -0.29 | 2020-02-19 | 2020-03-16 | 2020-06-03 | 79.00 | 0.70 | 2020-09-02 | -0.17 |
| UPRO_Return | 2838 | 2009-06-26 | 2020-10-02 | 0.42 | 0.52 | 0.81 | 0.39 | 0.28 | 2020-03-24 | -0.35 | 2020-03-16 | -0.77 | 2020-02-19 | 2020-03-23 | NaT | NaN | 0.42 | 2020-02-19 | -0.60 |
| SPY_Return | 2838 | 2009-06-26 | 2020-10-02 | 0.15 | 0.17 | 0.87 | 0.15 | 0.09 | 2020-03-24 | -0.11 | 2020-03-16 | -0.34 | 2020-02-19 | 2020-03-23 | 2020-08-10 | 140.00 | 0.43 | 2020-02-19 | -0.19 |
For UPRO and SPY, the paper finds a maximum performance ratio of 7.5 (34.13/4.53) on 2/19/2020, and lists a CAGR through that date of 39.0% for UPRO and 15.1% for SPY. We find 39% for UPRO and 15% for SPY. For the DDD, the paper finds a maximum DDD of 78.8% for UPRO, and we find 77%, so very close there. The paper’s maximum MDD for UPRO is 60.4%, which matches our 60%.
plot_time_series(
df=df,
plot_start_date=None,
plot_end_date=None,
plot_columns=[f"{lev_etf}_Adj_Close", f"{etf}_Adj_Close"],
title=f"Figure 2: {lev_etf} & {etf} Adjusted Close Price (Normalized) as of {end_date}",
x_label="Date",
x_format="Year",
x_tick_spacing=1,
x_tick_start=None,
x_tick_rotation=0,
y_label="Price ($)",
y_format="Decimal",
y_format_decimal_places=0,
y_tick_spacing="Auto",
y_tick_rotation=0,
caption="Data Source: Yahoo Finance, Analysis by the Author",
)

Figure 3: Comparing the normalized value of UDOW with its underlying index #
Next, figure #3 plots UDOW and DIA.
lev_etf = "UDOW"
etf = "DIA"
end_date = "2020-10-04"
df = combine_normalize(
fund_data=fund_data,
lev_etf=lev_etf,
etf=etf,
start_date=None,
end_date=end_date,
)
print(f"Table E5: {lev_etf} & {etf} Normalized Data")
display(df)
Table E5: UDOW & DIA Normalized Data
| UDOW_Adj_Close | UDOW_Close | UDOW_High | UDOW_Low | UDOW_Open | UDOW_Volume | DIA_Adj_Close | DIA_Close | DIA_High | DIA_Low | DIA_Open | DIA_Volume | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Date | ||||||||||||
| 2010-02-11 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 |
| 2010-02-12 | 1.00 | 1.00 | 1.00 | 0.99 | 1.00 | 2.15 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.37 |
| 2010-02-16 | 1.06 | 1.06 | 1.06 | 1.04 | 1.05 | 1.56 | 1.01 | 1.01 | 1.01 | 1.02 | 1.02 | 0.90 |
| 2010-02-17 | 1.09 | 1.09 | 1.09 | 1.08 | 1.08 | 6.41 | 1.02 | 1.02 | 1.02 | 1.03 | 1.03 | 0.88 |
| 2010-02-18 | 1.11 | 1.11 | 1.12 | 1.09 | 1.09 | 5.00 | 1.03 | 1.03 | 1.03 | 1.03 | 1.03 | 0.87 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 2020-09-28 | 12.03 | 11.55 | 11.70 | 11.41 | 11.46 | 49.80 | 3.49 | 2.72 | 2.72 | 2.75 | 2.74 | 0.28 |
| 2020-09-29 | 11.82 | 11.35 | 11.55 | 11.22 | 11.51 | 69.70 | 3.47 | 2.70 | 2.71 | 2.74 | 2.74 | 0.20 |
| 2020-09-30 | 12.24 | 11.76 | 12.08 | 11.43 | 11.43 | 86.74 | 3.51 | 2.73 | 2.75 | 2.75 | 2.74 | 0.41 |
| 2020-10-01 | 12.32 | 11.83 | 12.11 | 11.63 | 11.98 | 69.12 | 3.52 | 2.74 | 2.76 | 2.77 | 2.78 | 0.30 |
| 2020-10-02 | 12.13 | 11.66 | 11.87 | 11.25 | 11.31 | 71.53 | 3.50 | 2.72 | 2.74 | 2.74 | 2.73 | 0.48 |
2680 rows × 12 columns
max_per_ratio_date = "2020-02-12"
sum_stats = calculate_returns_stats(
df=df,
lev_etf=lev_etf,
etf=etf,
max_per_ratio_date=max_per_ratio_date,
)
all_sum_stats = pd.concat([all_sum_stats, sum_stats])
print("Table E6: Leveraged and Non-Leveraged ETF Summary Statistics")
display(all_sum_stats)
Table E6: Leveraged and Non-Leveraged ETF Summary Statistics
| Number of Observations | Data Start Date | Data End Date | Annual Mean Return (Arithmetic) | Annualized Volatility | Annualized Sharpe Ratio | CAGR (Geometric) | Daily Max Return | Daily Max Return (Date) | Daily Min Return | Daily Min Return (Date) | Max Drawdown | Peak | Trough | Recovery Date | Calendar Days to Recovery | MAR Ratio | CAGR End Date | Max Monthly Drawdown | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| TQQQ_Return | 2679 | 2010-02-12 | 2020-10-02 | 0.57 | 0.58 | 0.99 | 0.55 | 0.27 | 2020-03-13 | -0.34 | 2020-03-16 | -0.70 | 2020-02-19 | 2020-03-20 | 2020-07-10 | 112.00 | 0.71 | 2020-09-02 | -0.49 |
| QQQ_Return | 2679 | 2010-02-12 | 2020-10-02 | 0.20 | 0.20 | 1.03 | 0.21 | 0.08 | 2020-03-13 | -0.12 | 2020-03-16 | -0.29 | 2020-02-19 | 2020-03-16 | 2020-06-03 | 79.00 | 0.70 | 2020-09-02 | -0.17 |
| UPRO_Return | 2838 | 2009-06-26 | 2020-10-02 | 0.42 | 0.52 | 0.81 | 0.39 | 0.28 | 2020-03-24 | -0.35 | 2020-03-16 | -0.77 | 2020-02-19 | 2020-03-23 | NaT | NaN | 0.42 | 2020-02-19 | -0.60 |
| SPY_Return | 2838 | 2009-06-26 | 2020-10-02 | 0.15 | 0.17 | 0.87 | 0.15 | 0.09 | 2020-03-24 | -0.11 | 2020-03-16 | -0.34 | 2020-02-19 | 2020-03-23 | 2020-08-10 | 140.00 | 0.43 | 2020-02-19 | -0.19 |
| UDOW_Return | 2679 | 2010-02-12 | 2020-10-02 | 0.37 | 0.51 | 0.72 | 0.36 | 0.33 | 2020-03-24 | -0.35 | 2020-03-16 | -0.80 | 2020-02-12 | 2020-03-23 | NaT | NaN | 0.33 | 2020-02-12 | -0.66 |
| DIA_Return | 2679 | 2010-02-12 | 2020-10-02 | 0.13 | 0.17 | 0.77 | 0.14 | 0.11 | 2020-03-24 | -0.13 | 2020-03-16 | -0.37 | 2020-02-12 | 2020-03-23 | NaT | NaN | 0.34 | 2020-02-12 | -0.23 |
For UDOW and DIA, the paper finds a maximum performance ratio of 5.9 (21.62/3.68) on 2/12/2020, and lists a CAGR through that date of 35.7% for UDOW and 13.8% for DIA. Our values are 36% and 14% respectively. The DDD values from the paper at 80.3% and 33.7% are reasonably close to ours at 80% and 37%. For the MDD, the paper lists 66.3% for UDOW, which matches our 66%, and 19.4% for DIA, compared with our 23%. Interestingly, 19.4% is exactly the maximum MDD we find for SPY, so this may be a transcription error in the paper.
plot_time_series(
df=df,
plot_start_date=None,
plot_end_date=None,
plot_columns=[f"{lev_etf}_Adj_Close", f"{etf}_Adj_Close"],
title=f"Figure 3: {lev_etf} & {etf} Adjusted Close Price (Normalized) as of {end_date}",
x_label="Date",
x_format="Year",
x_tick_spacing=1,
x_tick_start=None,
x_tick_rotation=0,
y_label="Price ($)",
y_format="Decimal",
y_format_decimal_places=0,
y_tick_spacing="Auto",
y_tick_rotation=0,
caption="Data Source: Yahoo Finance, Analysis by the Author",
)

Figure 4: Comparing the normalized value of TNA with its underlying index #
Figure #4 plots TNA and IWM.
lev_etf = "TNA"
etf = "IWM"
end_date = "2020-10-04"
df = combine_normalize(
fund_data=fund_data,
lev_etf=lev_etf,
etf=etf,
start_date=None,
end_date=end_date,
)
print(f"Table E7: {lev_etf} & {etf} Normalized Data")
display(df)
Table E7: TNA & IWM Normalized Data
| TNA_Adj_Close | TNA_Close | TNA_High | TNA_Low | TNA_Open | TNA_Volume | IWM_Adj_Close | IWM_Close | IWM_High | IWM_Low | IWM_Open | IWM_Volume | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Date | ||||||||||||
| 2008-11-19 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 |
| 2008-11-20 | 0.79 | 0.79 | 0.78 | 0.79 | 0.75 | 1.82 | 0.93 | 0.93 | 0.93 | 0.93 | 0.92 | 1.46 |
| 2008-11-21 | 0.91 | 0.91 | 0.70 | 0.71 | 0.71 | 1.74 | 0.98 | 0.98 | 0.90 | 0.90 | 0.88 | 1.67 |
| 2008-11-24 | 1.10 | 1.10 | 0.87 | 0.93 | 0.76 | 1.28 | 1.05 | 1.05 | 0.98 | 0.99 | 0.92 | 1.01 |
| 2008-11-25 | 1.16 | 1.16 | 0.89 | 1.05 | 0.90 | 1.04 | 1.07 | 1.07 | 0.98 | 1.03 | 0.99 | 0.83 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 2020-09-28 | 5.99 | 5.08 | 3.87 | 4.94 | 3.83 | 1.13 | 4.32 | 3.63 | 3.33 | 3.55 | 3.31 | 0.18 |
| 2020-09-29 | 5.90 | 5.00 | 3.86 | 4.91 | 3.94 | 1.34 | 4.30 | 3.61 | 3.33 | 3.59 | 3.35 | 0.19 |
| 2020-09-30 | 5.96 | 5.05 | 3.99 | 4.96 | 3.94 | 1.39 | 4.31 | 3.62 | 3.37 | 3.60 | 3.35 | 0.30 |
| 2020-10-01 | 6.23 | 5.28 | 4.01 | 5.06 | 4.01 | 1.15 | 4.38 | 3.68 | 3.37 | 3.62 | 3.37 | 0.27 |
| 2020-10-02 | 6.32 | 5.36 | 4.11 | 5.00 | 3.90 | 1.52 | 4.40 | 3.70 | 3.40 | 3.61 | 3.34 | 0.31 |
2988 rows × 12 columns
max_per_ratio_date = "2018-08-29"
sum_stats = calculate_returns_stats(
df=df,
lev_etf=lev_etf,
etf=etf,
max_per_ratio_date=max_per_ratio_date,
)
all_sum_stats = pd.concat([all_sum_stats, sum_stats])
print("Table E8: Leveraged and Non-Leveraged ETF Summary Statistics")
display(all_sum_stats)
Table E8: Leveraged and Non-Leveraged ETF Summary Statistics
| Number of Observations | Data Start Date | Data End Date | Annual Mean Return (Arithmetic) | Annualized Volatility | Annualized Sharpe Ratio | CAGR (Geometric) | Daily Max Return | Daily Max Return (Date) | Daily Min Return | Daily Min Return (Date) | Max Drawdown | Peak | Trough | Recovery Date | Calendar Days to Recovery | MAR Ratio | CAGR End Date | Max Monthly Drawdown | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| TQQQ_Return | 2679 | 2010-02-12 | 2020-10-02 | 0.57 | 0.58 | 0.99 | 0.55 | 0.27 | 2020-03-13 | -0.34 | 2020-03-16 | -0.70 | 2020-02-19 | 2020-03-20 | 2020-07-10 | 112.00 | 0.71 | 2020-09-02 | -0.49 |
| QQQ_Return | 2679 | 2010-02-12 | 2020-10-02 | 0.20 | 0.20 | 1.03 | 0.21 | 0.08 | 2020-03-13 | -0.12 | 2020-03-16 | -0.29 | 2020-02-19 | 2020-03-16 | 2020-06-03 | 79.00 | 0.70 | 2020-09-02 | -0.17 |
| UPRO_Return | 2838 | 2009-06-26 | 2020-10-02 | 0.42 | 0.52 | 0.81 | 0.39 | 0.28 | 2020-03-24 | -0.35 | 2020-03-16 | -0.77 | 2020-02-19 | 2020-03-23 | NaT | NaN | 0.42 | 2020-02-19 | -0.60 |
| SPY_Return | 2838 | 2009-06-26 | 2020-10-02 | 0.15 | 0.17 | 0.87 | 0.15 | 0.09 | 2020-03-24 | -0.11 | 2020-03-16 | -0.34 | 2020-02-19 | 2020-03-23 | 2020-08-10 | 140.00 | 0.43 | 2020-02-19 | -0.19 |
| UDOW_Return | 2679 | 2010-02-12 | 2020-10-02 | 0.37 | 0.51 | 0.72 | 0.36 | 0.33 | 2020-03-24 | -0.35 | 2020-03-16 | -0.80 | 2020-02-12 | 2020-03-23 | NaT | NaN | 0.33 | 2020-02-12 | -0.66 |
| DIA_Return | 2679 | 2010-02-12 | 2020-10-02 | 0.13 | 0.17 | 0.77 | 0.14 | 0.11 | 2020-03-24 | -0.13 | 2020-03-16 | -0.37 | 2020-02-12 | 2020-03-23 | NaT | NaN | 0.34 | 2020-02-12 | -0.23 |
| TNA_Return | 2987 | 2008-11-20 | 2020-10-02 | 0.42 | 0.72 | 0.58 | 0.35 | 0.27 | 2020-03-24 | -0.37 | 2020-03-16 | -0.88 | 2018-08-31 | 2020-03-23 | NaT | NaN | 0.19 | 2018-08-29 | -0.83 |
| IWM_Return | 2987 | 2008-11-20 | 2020-10-02 | 0.15 | 0.24 | 0.64 | 0.17 | 0.09 | 2020-03-24 | -0.13 | 2020-03-16 | -0.41 | 2018-08-31 | 2020-03-23 | NaT | NaN | 0.32 | 2018-08-29 | -0.32 |
For TNA and IWM, the paper finds a maximum performance ratio of 3.8 (18.48/4.81) on 8/29/2018, well before the end of the period, and lists a CAGR through that date of 34.5% for TNA and 17.3% for IWM. Our values are 35% and 17% respectively. The DDD values from the paper at 88.1% and 41.1% are very close to ours at 88% and 41%. For the MDD, the paper lists 68.0% for TNA and 32.3% for IWM. Our IWM value of 32% matches, but our TNA value is 83%, measured from the end-of-month peak in August 2018 to the end-of-month trough in March 2020. We were not able to reproduce the paper’s value for TNA.
plot_time_series(
df=df,
plot_start_date=None,
plot_end_date=None,
plot_columns=[f"{lev_etf}_Adj_Close", f"{etf}_Adj_Close"],
title=f"Figure 4: {lev_etf} & {etf} Adjusted Close Price (Normalized) as of {end_date}",
x_label="Date",
x_format="Year",
x_tick_spacing=1,
x_tick_start=None,
x_tick_rotation=0,
y_label="Price ($)",
y_format="Decimal",
y_format_decimal_places=0,
y_tick_spacing="Auto",
y_tick_rotation=0,
caption="Data Source: Yahoo Finance, Analysis by the Author",
)

An interesting observation from the paper, from page 6:
“Note that, in this case, the normalized value of TNA at the end of the period actually briefly diminishes below that of IWM as a result of the financial crisis triggered by the COVID-19 pandemic, another illustration of the extreme volatility of the TLETFs compared with their underlying.”
Figure 5: Comparing the normalized value of TMF with its underlying index #
Next, figure #5 plots TMF and TLT.
lev_etf = "TMF"
etf = "TLT"
end_date = "2020-10-04"
df = combine_normalize(
fund_data=fund_data,
lev_etf=lev_etf,
etf=etf,
start_date=None,
end_date=end_date,
)
print(f"Table E9: {lev_etf} & {etf} Normalized Data")
display(df)
Table E9: TMF & TLT Normalized Data
| TMF_Adj_Close | TMF_Close | TMF_High | TMF_Low | TMF_Open | TMF_Volume | TLT_Adj_Close | TLT_Close | TLT_High | TLT_Low | TLT_Open | TLT_Volume | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Date | ||||||||||||
| 2009-04-16 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 |
| 2009-04-17 | 0.96 | 0.96 | 0.97 | 0.95 | 0.98 | 2.64 | 0.99 | 0.99 | 0.99 | 0.99 | 1.00 | 2.45 |
| 2009-04-20 | 1.01 | 1.01 | 1.00 | 0.99 | 0.98 | 0.60 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.62 |
| 2009-04-21 | 0.98 | 0.98 | 1.03 | 0.99 | 1.03 | 1.37 | 0.99 | 0.99 | 1.01 | 0.99 | 1.01 | 0.86 |
| 2009-04-22 | 0.95 | 0.95 | 0.97 | 0.94 | 0.98 | 2.22 | 0.99 | 0.99 | 0.99 | 0.98 | 1.00 | 2.16 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 2020-09-28 | 3.51 | 2.82 | 2.78 | 2.82 | 2.82 | 9.83 | 2.22 | 1.60 | 1.59 | 1.60 | 1.60 | 4.23 |
| 2020-09-29 | 3.52 | 2.83 | 2.80 | 2.82 | 2.81 | 15.62 | 2.22 | 1.60 | 1.60 | 1.60 | 1.60 | 9.76 |
| 2020-09-30 | 3.43 | 2.75 | 2.74 | 2.72 | 2.77 | 22.09 | 2.20 | 1.59 | 1.59 | 1.58 | 1.59 | 12.29 |
| 2020-10-01 | 3.45 | 2.77 | 2.73 | 2.71 | 2.70 | 22.16 | 2.21 | 1.59 | 1.58 | 1.58 | 1.58 | 10.92 |
| 2020-10-02 | 3.41 | 2.73 | 2.73 | 2.71 | 2.76 | 18.32 | 2.20 | 1.58 | 1.58 | 1.58 | 1.59 | 8.36 |
2888 rows × 12 columns
max_per_ratio_date = "2020-03-09"
sum_stats = calculate_returns_stats(
df=df,
lev_etf=lev_etf,
etf=etf,
max_per_ratio_date=max_per_ratio_date,
)
all_sum_stats = pd.concat([all_sum_stats, sum_stats])
print("Table E10: Leveraged and Non-Leveraged ETF Summary Statistics")
display(all_sum_stats)
Table E10: Leveraged and Non-Leveraged ETF Summary Statistics
| Number of Observations | Data Start Date | Data End Date | Annual Mean Return (Arithmetic) | Annualized Volatility | Annualized Sharpe Ratio | CAGR (Geometric) | Daily Max Return | Daily Max Return (Date) | Daily Min Return | Daily Min Return (Date) | Max Drawdown | Peak | Trough | Recovery Date | Calendar Days to Recovery | MAR Ratio | CAGR End Date | Max Monthly Drawdown | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| TQQQ_Return | 2679 | 2010-02-12 | 2020-10-02 | 0.57 | 0.58 | 0.99 | 0.55 | 0.27 | 2020-03-13 | -0.34 | 2020-03-16 | -0.70 | 2020-02-19 | 2020-03-20 | 2020-07-10 | 112.00 | 0.71 | 2020-09-02 | -0.49 |
| QQQ_Return | 2679 | 2010-02-12 | 2020-10-02 | 0.20 | 0.20 | 1.03 | 0.21 | 0.08 | 2020-03-13 | -0.12 | 2020-03-16 | -0.29 | 2020-02-19 | 2020-03-16 | 2020-06-03 | 79.00 | 0.70 | 2020-09-02 | -0.17 |
| UPRO_Return | 2838 | 2009-06-26 | 2020-10-02 | 0.42 | 0.52 | 0.81 | 0.39 | 0.28 | 2020-03-24 | -0.35 | 2020-03-16 | -0.77 | 2020-02-19 | 2020-03-23 | NaT | NaN | 0.42 | 2020-02-19 | -0.60 |
| SPY_Return | 2838 | 2009-06-26 | 2020-10-02 | 0.15 | 0.17 | 0.87 | 0.15 | 0.09 | 2020-03-24 | -0.11 | 2020-03-16 | -0.34 | 2020-02-19 | 2020-03-23 | 2020-08-10 | 140.00 | 0.43 | 2020-02-19 | -0.19 |
| UDOW_Return | 2679 | 2010-02-12 | 2020-10-02 | 0.37 | 0.51 | 0.72 | 0.36 | 0.33 | 2020-03-24 | -0.35 | 2020-03-16 | -0.80 | 2020-02-12 | 2020-03-23 | NaT | NaN | 0.33 | 2020-02-12 | -0.66 |
| DIA_Return | 2679 | 2010-02-12 | 2020-10-02 | 0.13 | 0.17 | 0.77 | 0.14 | 0.11 | 2020-03-24 | -0.13 | 2020-03-16 | -0.37 | 2020-02-12 | 2020-03-23 | NaT | NaN | 0.34 | 2020-02-12 | -0.23 |
| TNA_Return | 2987 | 2008-11-20 | 2020-10-02 | 0.42 | 0.72 | 0.58 | 0.35 | 0.27 | 2020-03-24 | -0.37 | 2020-03-16 | -0.88 | 2018-08-31 | 2020-03-23 | NaT | NaN | 0.19 | 2018-08-29 | -0.83 |
| IWM_Return | 2987 | 2008-11-20 | 2020-10-02 | 0.15 | 0.24 | 0.64 | 0.17 | 0.09 | 2020-03-24 | -0.13 | 2020-03-16 | -0.41 | 2018-08-31 | 2020-03-23 | NaT | NaN | 0.32 | 2018-08-29 | -0.32 |
| TMF_Return | 2887 | 2009-04-17 | 2020-10-02 | 0.21 | 0.44 | 0.46 | 0.15 | 0.20 | 2020-03-20 | -0.18 | 2020-03-18 | -0.53 | 2012-07-25 | 2013-08-21 | 2015-01-05 | 502.00 | 0.21 | 2020-03-09 | -0.50 |
| TLT_Return | 2887 | 2009-04-17 | 2020-10-02 | 0.08 | 0.15 | 0.54 | 0.08 | 0.08 | 2020-03-20 | -0.07 | 2020-03-17 | -0.21 | 2012-07-25 | 2013-08-21 | 2014-12-10 | 476.00 | 0.34 | 2020-03-09 | -0.18 |
For TMF and TLT, the paper finds a maximum performance ratio of 1.9 (4.412/2.294) on 3/9/2020, and reports a CAGR through that date of 14.5% for TMF and 7.9% for TLT. Our values are 15% and 8% respectively. For the drawdowns, the paper reports 53.3% for TMF and 20.7% for TLT, whereas our values are 53% and 21%. For the MDD, the paper reports 49.7% for TMF and 18.2% for TLT, and we find 50% and 18%. So again, very close for both the CAGR and the drawdown metrics.
plot_time_series(
df=df,
plot_start_date=None,
plot_end_date=None,
plot_columns=[f"{lev_etf}_Adj_Close", f"{etf}_Adj_Close"],
title=f"Figure 5: {lev_etf} & {etf} Adjusted Close Price (Normalized) as of {end_date}",
x_label="Date",
x_format="Year",
x_tick_spacing=1,
x_tick_start=None,
x_tick_rotation=0,
y_label="Price ($)",
y_format="Decimal",
y_format_decimal_places=1,
y_tick_spacing="Auto",
y_tick_rotation=0,
caption="Data Source: Yahoo Finance, Analysis by the Author",
)

Figure 7: Comparing the normalized value of EDC with its underlying index #
As noted above, we will omit DZK and EFA, so we will jump to figure #7 which plots EDC and EEM.
lev_etf = "EDC"
etf = "EEM"
end_date = "2020-10-04"
df = combine_normalize(
fund_data=fund_data,
lev_etf=lev_etf,
etf=etf,
start_date=None,
end_date=end_date,
)
print(f"Table E11: {lev_etf} & {etf} Normalized Data")
display(df)
Table E11: EDC & EEM Normalized Data
| EDC_Adj_Close | EDC_Close | EDC_High | EDC_Low | EDC_Open | EDC_Volume | EEM_Adj_Close | EEM_Close | EEM_High | EEM_Low | EEM_Open | EEM_Volume | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Date | ||||||||||||
| 2008-12-30 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 |
| 2008-12-31 | 1.05 | 1.05 | 1.06 | 1.01 | 1.04 | 0.56 | 1.01 | 1.01 | 1.01 | 1.01 | 1.01 | 0.89 |
| 2009-01-02 | 1.21 | 1.21 | 1.22 | 1.13 | 1.13 | 0.76 | 1.06 | 1.06 | 1.07 | 1.04 | 1.04 | 1.39 |
| 2009-01-05 | 1.24 | 1.24 | 1.27 | 1.23 | 1.29 | 1.19 | 1.08 | 1.08 | 1.08 | 1.07 | 1.07 | 1.29 |
| 2009-01-06 | 1.34 | 1.34 | 1.36 | 1.35 | 1.37 | 2.62 | 1.10 | 1.10 | 1.10 | 1.11 | 1.11 | 1.36 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 2020-09-28 | 0.86 | 0.67 | 0.68 | 0.69 | 0.71 | 4.07 | 2.22 | 1.76 | 1.76 | 1.79 | 1.80 | 0.91 |
| 2020-09-29 | 0.86 | 0.67 | 0.67 | 0.70 | 0.70 | 2.88 | 2.22 | 1.76 | 1.76 | 1.79 | 1.79 | 0.51 |
| 2020-09-30 | 0.89 | 0.70 | 0.71 | 0.72 | 0.72 | 7.45 | 2.25 | 1.79 | 1.79 | 1.81 | 1.81 | 0.97 |
| 2020-10-01 | 0.92 | 0.72 | 0.72 | 0.74 | 0.75 | 6.49 | 2.28 | 1.81 | 1.80 | 1.83 | 1.83 | 1.03 |
| 2020-10-02 | 0.89 | 0.70 | 0.71 | 0.72 | 0.72 | 6.74 | 2.25 | 1.79 | 1.80 | 1.81 | 1.81 | 0.65 |
2961 rows × 12 columns
max_per_ratio_date = None
sum_stats = calculate_returns_stats(
df=df,
lev_etf=lev_etf,
etf=etf,
max_per_ratio_date=max_per_ratio_date,
)
all_sum_stats = pd.concat([all_sum_stats, sum_stats])
print("Table E12: Leveraged and Non-Leveraged ETF Summary Statistics")
display(all_sum_stats)
Table E12: Leveraged and Non-Leveraged ETF Summary Statistics
| Number of Observations | Data Start Date | Data End Date | Annual Mean Return (Arithmetic) | Annualized Volatility | Annualized Sharpe Ratio | CAGR (Geometric) | Daily Max Return | Daily Max Return (Date) | Daily Min Return | Daily Min Return (Date) | Max Drawdown | Peak | Trough | Recovery Date | Calendar Days to Recovery | MAR Ratio | CAGR End Date | Max Monthly Drawdown | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| TQQQ_Return | 2679 | 2010-02-12 | 2020-10-02 | 0.57 | 0.58 | 0.99 | 0.55 | 0.27 | 2020-03-13 | -0.34 | 2020-03-16 | -0.70 | 2020-02-19 | 2020-03-20 | 2020-07-10 | 112.00 | 0.71 | 2020-09-02 | -0.49 |
| QQQ_Return | 2679 | 2010-02-12 | 2020-10-02 | 0.20 | 0.20 | 1.03 | 0.21 | 0.08 | 2020-03-13 | -0.12 | 2020-03-16 | -0.29 | 2020-02-19 | 2020-03-16 | 2020-06-03 | 79.00 | 0.70 | 2020-09-02 | -0.17 |
| UPRO_Return | 2838 | 2009-06-26 | 2020-10-02 | 0.42 | 0.52 | 0.81 | 0.39 | 0.28 | 2020-03-24 | -0.35 | 2020-03-16 | -0.77 | 2020-02-19 | 2020-03-23 | NaT | NaN | 0.42 | 2020-02-19 | -0.60 |
| SPY_Return | 2838 | 2009-06-26 | 2020-10-02 | 0.15 | 0.17 | 0.87 | 0.15 | 0.09 | 2020-03-24 | -0.11 | 2020-03-16 | -0.34 | 2020-02-19 | 2020-03-23 | 2020-08-10 | 140.00 | 0.43 | 2020-02-19 | -0.19 |
| UDOW_Return | 2679 | 2010-02-12 | 2020-10-02 | 0.37 | 0.51 | 0.72 | 0.36 | 0.33 | 2020-03-24 | -0.35 | 2020-03-16 | -0.80 | 2020-02-12 | 2020-03-23 | NaT | NaN | 0.33 | 2020-02-12 | -0.66 |
| DIA_Return | 2679 | 2010-02-12 | 2020-10-02 | 0.13 | 0.17 | 0.77 | 0.14 | 0.11 | 2020-03-24 | -0.13 | 2020-03-16 | -0.37 | 2020-02-12 | 2020-03-23 | NaT | NaN | 0.34 | 2020-02-12 | -0.23 |
| TNA_Return | 2987 | 2008-11-20 | 2020-10-02 | 0.42 | 0.72 | 0.58 | 0.35 | 0.27 | 2020-03-24 | -0.37 | 2020-03-16 | -0.88 | 2018-08-31 | 2020-03-23 | NaT | NaN | 0.19 | 2018-08-29 | -0.83 |
| IWM_Return | 2987 | 2008-11-20 | 2020-10-02 | 0.15 | 0.24 | 0.64 | 0.17 | 0.09 | 2020-03-24 | -0.13 | 2020-03-16 | -0.41 | 2018-08-31 | 2020-03-23 | NaT | NaN | 0.32 | 2018-08-29 | -0.32 |
| TMF_Return | 2887 | 2009-04-17 | 2020-10-02 | 0.21 | 0.44 | 0.46 | 0.15 | 0.20 | 2020-03-20 | -0.18 | 2020-03-18 | -0.53 | 2012-07-25 | 2013-08-21 | 2015-01-05 | 502.00 | 0.21 | 2020-03-09 | -0.50 |
| TLT_Return | 2887 | 2009-04-17 | 2020-10-02 | 0.08 | 0.15 | 0.54 | 0.08 | 0.08 | 2020-03-20 | -0.07 | 2020-03-17 | -0.21 | 2012-07-25 | 2013-08-21 | 2014-12-10 | 476.00 | 0.34 | 2020-03-09 | -0.18 |
| EDC_Return | 2960 | 2008-12-31 | 2020-10-02 | 0.25 | 0.72 | 0.35 | -0.01 | 0.28 | 2009-03-23 | -0.35 | 2020-03-16 | -0.92 | 2011-04-08 | 2020-03-23 | NaT | NaN | -0.01 | 2020-10-02 | -0.90 |
| EEM_Return | 2960 | 2008-12-31 | 2020-10-02 | 0.10 | 0.25 | 0.40 | 0.07 | 0.09 | 2009-03-23 | -0.12 | 2020-03-16 | -0.38 | 2018-01-26 | 2020-03-23 | NaT | NaN | 0.19 | 2020-10-02 | -0.33 |
Finally, the paper does not provide a maximum performance ratio or any CAGR values for EDC or EEM, so we calculate the CAGR over the full period, and find -1% for EDC and 7% for EEM. For the drawdowns, we observed 92% for EDC and 38% for EEM, which is in line with the values of 92.3% and 38.2% that were provided in the paper. The MDD values from the paper of 89.6% and 32.7% also match ours at 90% and 33%.
plot_time_series(
df=df,
plot_start_date=None,
plot_end_date=None,
plot_columns=[f"{lev_etf}_Adj_Close", f"{etf}_Adj_Close"],
title=f"Figure 7: {lev_etf} & {etf} Adjusted Close Price (Normalized) as of {end_date}",
x_label="Date",
x_format="Year",
x_tick_spacing=1,
x_tick_start=None,
x_tick_rotation=0,
y_label="Price ($)",
y_format="Decimal",
y_format_decimal_places=1,
y_tick_spacing="Auto",
y_tick_rotation=0,
caption="Data Source: Yahoo Finance, Analysis by the Author",
)

Section 3.0: The Risk Mitigation Strategy #
In section 3.0, the paper talks about using a portfolio composed of a combination of 2 TLETFs, rebalanced periodically to maintain a specific asset allocation. The section starts with the following claim:
“As mentioned earlier, it is easy to show that QQQ, SPY, and DIA are generally negatively correlated with TLT and the same can be said about their corresponding TLETFs.”
Before we move on to the portfolio and strategy, let’s first look at the correlations between the above mentioned ETFs and the corresponding TLETFs.
corr_data = pd.DataFrame()
for fund in ["QQQ", "SPY", "DIA", "TLT"]:
df = fund_data[fund]
df[f"{fund}_Return"] = df[f"{fund}_Adj_Close"].pct_change()
corr_data = pd.concat([corr_data, df[[f"{fund}_Return"]]], axis=1)
corr_data = corr_data.dropna()
corr_data = corr_data[corr_data.index >= "2010-02-11"]
corr_data = corr_data[corr_data.index <= "2020-10-04"]
print("Table E13: QQQ, SPY, DIA, and TLT Daily Returns")
display(corr_data)
print("Table E14: Correlation Matrix of QQQ, SPY, DIA, and TLT Daily Returns")
display(corr_data.corr())
Table E13: QQQ, SPY, DIA, and TLT Daily Returns
| QQQ_Return | SPY_Return | DIA_Return | TLT_Return | |
|---|---|---|---|---|
| Date | ||||
| 2010-02-11 | 0.02 | 0.01 | 0.01 | -0.01 |
| 2010-02-12 | 0.00 | -0.00 | -0.00 | 0.00 |
| 2010-02-16 | 0.01 | 0.02 | 0.02 | 0.00 |
| 2010-02-17 | 0.01 | 0.00 | 0.01 | -0.01 |
| 2010-02-18 | 0.01 | 0.01 | 0.01 | -0.00 |
| ... | ... | ... | ... | ... |
| 2020-09-28 | 0.02 | 0.02 | 0.02 | -0.00 |
| 2020-09-29 | -0.00 | -0.01 | -0.01 | 0.00 |
| 2020-09-30 | 0.01 | 0.01 | 0.01 | -0.01 |
| 2020-10-01 | 0.02 | 0.01 | 0.00 | 0.00 |
| 2020-10-02 | -0.03 | -0.01 | -0.00 | -0.00 |
2680 rows × 4 columns
Table E14: Correlation Matrix of QQQ, SPY, DIA, and TLT Daily Returns
| QQQ_Return | SPY_Return | DIA_Return | TLT_Return | |
|---|---|---|---|---|
| QQQ_Return | 1.00 | 0.93 | 0.87 | -0.42 |
| SPY_Return | 0.93 | 1.00 | 0.97 | -0.48 |
| DIA_Return | 0.87 | 0.97 | 1.00 | -0.47 |
| TLT_Return | -0.42 | -0.48 | -0.47 | 1.00 |
The above table confirms the conjecture from the paper. The correlations amongst the equity and bond funds are in fact negative.
Next, we will confirm the correlations for the leveraged ETFs.
lev_corr_data = pd.DataFrame()
for fund in ["TQQQ", "UPRO", "UDOW", "TMF"]:
df = fund_data[fund]
df[f"{fund}_Return"] = df[f"{fund}_Adj_Close"].pct_change()
lev_corr_data = pd.concat([lev_corr_data, df[[f"{fund}_Return"]]], axis=1)
lev_corr_data = lev_corr_data.dropna()
lev_corr_data = lev_corr_data[lev_corr_data.index <= "2020-10-04"]
print("Table E15: TQQQ, UPRO, UDOW, and TMF Daily Returns")
display(lev_corr_data)
print("Table E16: Correlation Matrix of TQQQ, UPRO, UDOW, and TMF Daily Returns")
display(lev_corr_data.corr())
Table E15: TQQQ, UPRO, UDOW, and TMF Daily Returns
| TQQQ_Return | UPRO_Return | UDOW_Return | TMF_Return | |
|---|---|---|---|---|
| Date | ||||
| 2010-02-12 | 0.00 | -0.00 | -0.00 | 0.01 |
| 2010-02-16 | 0.04 | 0.05 | 0.07 | 0.01 |
| 2010-02-17 | 0.02 | 0.01 | 0.02 | -0.03 |
| 2010-02-18 | 0.02 | 0.02 | 0.03 | -0.02 |
| 2010-02-19 | 0.00 | 0.01 | 0.00 | 0.02 |
| ... | ... | ... | ... | ... |
| 2020-09-28 | 0.06 | 0.05 | 0.05 | -0.01 |
| 2020-09-29 | -0.01 | -0.02 | -0.02 | 0.00 |
| 2020-09-30 | 0.02 | 0.02 | 0.04 | -0.03 |
| 2020-10-01 | 0.05 | 0.02 | 0.01 | 0.01 |
| 2020-10-02 | -0.08 | -0.03 | -0.01 | -0.01 |
2679 rows × 4 columns
Table E16: Correlation Matrix of TQQQ, UPRO, UDOW, and TMF Daily Returns
| TQQQ_Return | UPRO_Return | UDOW_Return | TMF_Return | |
|---|---|---|---|---|
| TQQQ_Return | 1.00 | 0.93 | 0.87 | -0.41 |
| UPRO_Return | 0.93 | 1.00 | 0.97 | -0.48 |
| UDOW_Return | 0.87 | 0.97 | 1.00 | -0.46 |
| TMF_Return | -0.41 | -0.48 | -0.46 | 1.00 |
Here we have the same finding. The correlations between the leveraged equity ETFs and the leveraged bond ETF are also negative. They are also nearly identical to the correlations for the underlying non-leveraged ETFs over the same period. This is expected, since the daily return of a leveraged ETF is approximately 3 times the daily return of its underlying index, and scaling a return series does not change its correlation.
Next, we’ll look at the strategy. From the paper:
Strategies that exploit this feature have been shown to exceed the performance of both that of both TLT and the equity ETFs and with reduced risk6,7. Here we focus on the triple leveraged versions of QQQ (TQQQ) and TLT (TMF) and propose simply to purchase a portfolio consisting of equal dollar amounts of both TLETFs and attempt to maintain this percentage by rebalancing on the last day of the k th month, where k=1,2,3,…
We’ll use the following helper function to execute the strategy.
def rebalance_strategy(
fund_data: dict,
fund_1: str,
fund_2: str,
fund_1_weight: float = 0.5,
rebalance_months: int = 2,
transaction_fee: float = 0.0025,
start_date=None,
end_date=None,
) -> pd.DataFrame:
"""
Simulate a two-fund portfolio that is periodically rebalanced back to a target allocation.
Parameters
----------
fund_data : dict
Dictionary containing DataFrames for each fund.
fund_1 : str
The ticker symbol for the first fund.
fund_2 : str
The ticker symbol for the second fund.
fund_1_weight : float, optional
Target allocation to the first fund; the second fund receives the remainder (default is 0.5).
rebalance_months : int, optional
Rebalance on the last trading day of every k-th month, counting the start month as month 1 (default is 2).
transaction_fee : float, optional
Fee charged on the dollar amount of each buy or sell order at a rebalance (default is 0.0025).
start_date : optional
Start date for the data. If None, uses the earliest date both funds have data (default is None).
end_date : optional
End date for the data. If None, uses the latest available date (default is None).
Returns
-------
pd.DataFrame
DataFrame with the normalized fund prices, the value of each holding, the normalized
strategy value, the strategy daily return, and the rebalance flag and fee paid on each day.
"""
# Combine the adjusted close prices over the common date range
df = fund_data[fund_1][[f"{fund_1}_Adj_Close"]].merge(
fund_data[fund_2][[f"{fund_2}_Adj_Close"]], left_index=True, right_index=True
)
# Filter by date range
df = df.loc[start_date:end_date]
# Normalize prices to start at 1
df = df / df.iloc[0]
# Calculate the return series for each fund
df[f"{fund_1}_Return"] = df[f"{fund_1}_Adj_Close"].pct_change()
df[f"{fund_2}_Return"] = df[f"{fund_2}_Adj_Close"].pct_change()
# Calculate the drawdowns for each fund
df[f"{fund_1}_Drawdown"] = (
df[f"{fund_1}_Adj_Close"] / df[f"{fund_1}_Adj_Close"].cummax() - 1
)
df[f"{fund_2}_Drawdown"] = (
df[f"{fund_2}_Adj_Close"] / df[f"{fund_2}_Adj_Close"].cummax() - 1
)
df[f"{fund_1}_Drawdown_Daily_Max"] = df[f"{fund_1}_Drawdown"].cummin()
df[f"{fund_2}_Drawdown_Daily_Max"] = df[f"{fund_2}_Drawdown"].cummin()
# Flag the last trading day of each month, excluding the final row since that month may be incomplete
month = pd.Series(df.index.to_period("M"), index=df.index)
month_end = month != month.shift(-1)
month_end.iloc[-1] = False
# Rebalance on the last trading day of every k-th month
month_number = (
(df.index.year - df.index[0].year) * 12
+ (df.index.month - df.index[0].month)
+ 1
)
rebalance = month_end & (month_number % rebalance_months == 0)
fund_2_weight = 1 - fund_1_weight
# Initial purchase at the target allocation with a starting portfolio value of 1
fund_1_shares = fund_1_weight / df[f"{fund_1}_Adj_Close"].iloc[0]
fund_2_shares = fund_2_weight / df[f"{fund_2}_Adj_Close"].iloc[0]
fund_1_values, fund_2_values, fees = [], [], []
for date, row in df.iterrows():
fund_1_value = fund_1_shares * row[f"{fund_1}_Adj_Close"]
fund_2_value = fund_2_shares * row[f"{fund_2}_Adj_Close"]
fee = 0.0
if rebalance.loc[date]:
total_value = fund_1_value + fund_2_value
# Fee on the dollar amount traded in each fund to get back to the target allocation
fee = transaction_fee * (
abs(fund_1_weight * total_value - fund_1_value)
+ abs(fund_2_weight * total_value - fund_2_value)
)
total_value -= fee
fund_1_value = fund_1_weight * total_value
fund_2_value = fund_2_weight * total_value
fund_1_shares = fund_1_value / row[f"{fund_1}_Adj_Close"]
fund_2_shares = fund_2_value / row[f"{fund_2}_Adj_Close"]
fund_1_values.append(fund_1_value)
fund_2_values.append(fund_2_value)
fees.append(fee)
df[f"{fund_1}_Value"] = fund_1_values
df[f"{fund_2}_Value"] = fund_2_values
df["Strategy_Adj_Close"] = df[f"{fund_1}_Value"] + df[f"{fund_2}_Value"]
df["Strategy_Return"] = df["Strategy_Adj_Close"].pct_change()
# Calculate the drawdown for the strategy
df["Strategy_Drawdown"] = (
df["Strategy_Adj_Close"] / df["Strategy_Adj_Close"].cummax() - 1
)
df["Strategy_Drawdown_Daily_Max"] = df["Strategy_Drawdown"].cummin()
df["Rebalance"] = rebalance
df["Transaction_Fee"] = fees
return df
Now, we’ll execute the strategy for TQQQ and TMF, as the paper does with a start date of 2/11/2010 and an end date of 10/4/2020.
tqqq_tmf = rebalance_strategy(
fund_data=fund_data,
fund_1="TQQQ",
fund_2="TMF",
fund_1_weight=0.5,
rebalance_months=2,
transaction_fee=0.0025, # 0.25% transaction fee
start_date=None,
end_date="2020-10-04",
)
print("Table E17: TQQQ/TMF Rebalance Strategy Data")
display(tqqq_tmf)
Table E17: TQQQ/TMF Rebalance Strategy Data
| TQQQ_Adj_Close | TMF_Adj_Close | TQQQ_Return | TMF_Return | TQQQ_Drawdown | TMF_Drawdown | TQQQ_Drawdown_Daily_Max | TMF_Drawdown_Daily_Max | TQQQ_Value | TMF_Value | Strategy_Adj_Close | Strategy_Return | Strategy_Drawdown | Strategy_Drawdown_Daily_Max | Rebalance | Transaction_Fee | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Date | ||||||||||||||||
| 2010-02-11 | 1.00 | 1.00 | NaN | NaN | 0.00 | 0.00 | 0.00 | 0.00 | 0.50 | 0.50 | 1.00 | NaN | 0.00 | 0.00 | False | 0.00 |
| 2010-02-12 | 1.00 | 1.01 | 0.00 | 0.01 | 0.00 | 0.00 | 0.00 | 0.00 | 0.50 | 0.51 | 1.01 | 0.01 | 0.00 | 0.00 | False | 0.00 |
| 2010-02-16 | 1.04 | 1.02 | 0.04 | 0.01 | 0.00 | 0.00 | 0.00 | 0.00 | 0.52 | 0.51 | 1.03 | 0.02 | 0.00 | 0.00 | False | 0.00 |
| 2010-02-17 | 1.06 | 0.99 | 0.02 | -0.03 | 0.00 | -0.03 | 0.00 | -0.03 | 0.53 | 0.49 | 1.02 | -0.01 | -0.01 | -0.01 | False | 0.00 |
| 2010-02-18 | 1.08 | 0.97 | 0.02 | -0.02 | 0.00 | -0.05 | 0.00 | -0.05 | 0.54 | 0.49 | 1.03 | 0.00 | -0.01 | -0.01 | False | 0.00 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 2020-09-28 | 75.16 | 5.83 | 0.06 | -0.01 | -0.26 | -0.20 | -0.70 | -0.53 | 23.78 | 19.49 | 43.28 | 0.03 | -0.17 | -0.42 | False | 0.00 |
| 2020-09-29 | 74.37 | 5.85 | -0.01 | 0.00 | -0.26 | -0.20 | -0.70 | -0.53 | 23.53 | 19.56 | 43.09 | -0.00 | -0.17 | -0.42 | False | 0.00 |
| 2020-09-30 | 75.85 | 5.69 | 0.02 | -0.03 | -0.25 | -0.22 | -0.70 | -0.53 | 21.51 | 21.51 | 43.02 | -0.00 | -0.17 | -0.42 | True | 0.01 |
| 2020-10-01 | 79.40 | 5.73 | 0.05 | 0.01 | -0.21 | -0.22 | -0.70 | -0.53 | 22.52 | 21.63 | 44.15 | 0.03 | -0.15 | -0.42 | False | 0.00 |
| 2020-10-02 | 72.85 | 5.66 | -0.08 | -0.01 | -0.28 | -0.23 | -0.70 | -0.53 | 20.66 | 21.37 | 42.03 | -0.05 | -0.19 | -0.42 | False | 0.00 |
2680 rows × 16 columns
And then we can check the summary statistics to confirm that our implementation of the strategy matches the results reported in the paper.
# Create one empty dataframe for all summary statistics
all_strat_sum_stats = pd.DataFrame()
tqqq_tmf_sum_stats = summary_stats(
fund_list=["TQQQ", "TMF"],
df=tqqq_tmf[["Strategy_Return"]].dropna(),
period="Daily",
use_calendar_days=False,
excel_export=False,
pickle_export=False,
output_confirmation=False,
)
# Rename the index to indicate the strategy
tqqq_tmf_sum_stats.index = ["TQQQ/TMF Strategy"]
all_strat_sum_stats = pd.concat([all_strat_sum_stats, tqqq_tmf_sum_stats])
tqqq_sum_stats = summary_stats(
fund_list=["TQQQ", "TMF"],
df=tqqq_tmf[["TQQQ_Return"]].dropna(),
period="Daily",
use_calendar_days=False,
excel_export=False,
pickle_export=False,
output_confirmation=False,
)
# Rename the index to indicate the strategy
tqqq_sum_stats.index = ["TQQQ"]
all_strat_sum_stats = pd.concat([all_strat_sum_stats, tqqq_sum_stats])
tmf_sum_stats = summary_stats(
fund_list=["TQQQ", "TMF"],
df=tqqq_tmf[["TMF_Return"]].dropna(),
period="Daily",
use_calendar_days=False,
excel_export=False,
pickle_export=False,
output_confirmation=False,
)
# Rename the index to indicate the strategy
tmf_sum_stats.index = ["TMF"]
all_strat_sum_stats = pd.concat([all_strat_sum_stats, tmf_sum_stats])
print("Table E18: Strategy Summary Statistics")
display(all_strat_sum_stats)
Table E18: Strategy Summary Statistics
| Number of Observations | Data Start Date | Data End Date | Annual Mean Return (Arithmetic) | Annualized Volatility | Annualized Sharpe Ratio | CAGR (Geometric) | Daily Max Return | Daily Max Return (Date) | Daily Min Return | Daily Min Return (Date) | Max Drawdown | Peak | Trough | Recovery Date | Calendar Days to Recovery | MAR Ratio | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| TQQQ/TMF Strategy | 2679 | 2010-02-12 | 2020-10-02 | 0.39 | 0.28 | 1.40 | 0.42 | 0.11 | 2020-03-20 | -0.16 | 2020-03-18 | -0.42 | 2020-03-06 | 2020-03-18 | 2020-05-07 | 50 | 1.00 |
| TQQQ | 2679 | 2010-02-12 | 2020-10-02 | 0.57 | 0.58 | 0.99 | 0.50 | 0.27 | 2020-03-13 | -0.34 | 2020-03-16 | -0.70 | 2020-02-19 | 2020-03-20 | 2020-07-10 | 112 | 0.71 |
| TMF | 2679 | 2010-02-12 | 2020-10-02 | 0.26 | 0.44 | 0.59 | 0.18 | 0.20 | 2020-03-20 | -0.18 | 2020-03-18 | -0.53 | 2012-07-25 | 2013-08-21 | 2015-01-05 | 502 | 0.33 |
The paper reports a CAGR of 44.9%, and our implementation finds 42%. The discrepancy here is likely the end date. The paper references 10/4/2020, but it seems the data points it uses are actually from 9/2/2020.
Figure 8: Strategy Performance With Bimonthly Rebalancing #
We can reproduce figure 8, which shows the performance of TQQQ, TMF, and the strategy over time.
plot_time_series(
df=tqqq_tmf,
plot_start_date=None,
plot_end_date=None,
plot_columns=["TQQQ_Adj_Close", "TMF_Adj_Close", "Strategy_Adj_Close"],
title="Figure 8: Rebalance Strategy, TQQQ, and TMF Adjusted Close Price (Normalized) as of 2020-10-04",
x_label="Date",
x_format="Year",
x_tick_spacing=1,
x_tick_start=None,
x_tick_rotation=0,
y_label="Price ($)",
y_format="Decimal",
y_format_decimal_places=1,
y_tick_spacing="Auto",
y_tick_rotation=0,
caption="Data Source: Yahoo Finance, Analysis by the Author",
)

Figure 9: Maximum end-of-month draw downs corresponding to fig. 8 #
Next, we can recreate figure 9, with the maximum DDD and MDD. First, the full daily drawdown plot.
plot_time_series(
df=tqqq_tmf,
plot_start_date=None,
plot_end_date=None,
plot_columns=["TQQQ_Drawdown", "TMF_Drawdown", "Strategy_Drawdown"],
title="Figure 9a: Rebalance Strategy, TQQQ, and TMF Daily Drawdowns as of 2020-10-04",
x_label="Date",
x_format="Year",
x_tick_spacing=1,
x_tick_start=None,
x_tick_rotation=0,
y_label="Drawdown",
y_format="Percentage",
y_format_decimal_places=0,
y_tick_spacing="Auto",
y_tick_rotation=0,
caption="Data Source: Yahoo Finance, Analysis by the Author",
)

Next, the maximum DDD.
plot_time_series(
df=tqqq_tmf,
plot_start_date=None,
plot_end_date=None,
plot_columns=[
"TQQQ_Drawdown_Daily_Max",
"TMF_Drawdown_Daily_Max",
"Strategy_Drawdown_Daily_Max",
],
title="Figure 9b: Rebalance Strategy, TQQQ, and TMF Maximum Daily Drawdowns as of 2020-10-04",
x_label="Date",
x_format="Year",
x_tick_spacing=1,
x_tick_start=None,
x_tick_rotation=0,
y_label="Drawdown",
y_format="Percentage",
y_format_decimal_places=0,
y_tick_spacing="Auto",
y_tick_rotation=0,
caption="Data Source: Yahoo Finance, Analysis by the Author",
)

Finally, the maximum MDD.
tqqq_tmf_monthly = tqqq_tmf.resample("ME").last()
# Calculate the monthly drawdowns using end-of-month prices only, then the running maximum monthly drawdown
for fund in ["TQQQ", "TMF", "Strategy"]:
tqqq_tmf_monthly[f"{fund}_Drawdown_Monthly"] = (
tqqq_tmf_monthly[f"{fund}_Adj_Close"]
/ tqqq_tmf_monthly[f"{fund}_Adj_Close"].cummax()
- 1
)
tqqq_tmf_monthly[f"{fund}_Drawdown_Monthly_Max"] = tqqq_tmf_monthly[
f"{fund}_Drawdown_Monthly"
].cummin()
plot_time_series(
df=tqqq_tmf_monthly,
plot_start_date=None,
plot_end_date=None,
plot_columns=[
"TQQQ_Drawdown_Monthly_Max",
"TMF_Drawdown_Monthly_Max",
"Strategy_Drawdown_Monthly_Max",
],
title="Figure 9c: Rebalance Strategy, TQQQ, and TMF Maximum Monthly Drawdowns as of 2020-10-04",
x_label="Date",
x_format="Year",
x_tick_spacing=1,
x_tick_start=None,
x_tick_rotation=0,
y_label="Drawdown",
y_format="Percentage",
y_format_decimal_places=0,
y_tick_spacing="Auto",
y_tick_rotation=0,
caption="Data Source: Yahoo Finance, Analysis by the Author",
)

Our plot above matches figure 9 from the paper. We find a maximum MDD of 49% for TQQQ, 50% for TMF, and 24% for the strategy, compared with 49.1% for TQQQ and 49.7% for TMF from section 2.0 of the paper, and 24.4% for the strategy from section 3.2.
Note that the MDD uses end-of-month prices for both the drawdown and the prior peak, so a peak or trough within a month (such as TQQQ’s peak on 2/19/2020) is not captured. This is why the MDD values are lower than the DDD values shown in the previous plot.
Section 3.1: Optimum Balance Interval for Risk Mitigation Strategy #
Figure 10: Strategy performance on 9/2/20 as a function of rebalancing interval #
Section 3.1 of the paper examines how the performance of the strategy on 9/2/2020 (the date of the peak normalized value) changes as the rebalancing interval is varied from one to six months, both with and without the 0.25% transaction fee. We can reproduce this by running the strategy for each rebalancing interval.
# Calculate the normalized value of the strategy on 9/2/2020 for rebalancing intervals of 1 to 6 months, with and without the transaction fee
rebalance_interval_results = pd.DataFrame(
index=pd.Index(range(1, 7), name="Rebalance Interval (Months)")
)
for k in rebalance_interval_results.index:
for fee, col in [(0.0, "No Transaction Fee"), (0.0025, "Transaction Fee = 0.25%")]:
df_k = rebalance_strategy(
fund_data=fund_data,
fund_1="TQQQ",
fund_2="TMF",
fund_1_weight=0.5,
rebalance_months=k,
transaction_fee=fee,
start_date=None,
end_date="2020-09-02",
)
rebalance_interval_results.loc[k, col] = df_k["Strategy_Adj_Close"].iloc[-1]
print("Figure 10: Strategy performance on 9/2/20 as a function of rebalancing interval")
display(rebalance_interval_results)
Figure 10: Strategy performance on 9/2/20 as a function of rebalancing interval
| No Transaction Fee | Transaction Fee = 0.25% | |
|---|---|---|
| Rebalance Interval (Months) | ||
| 1 | 50.65 | 49.33 |
| 2 | 52.82 | 51.86 |
| 3 | 41.31 | 40.78 |
| 4 | 44.83 | 44.34 |
| 5 | 49.15 | 48.57 |
| 6 | 39.33 | 38.99 |
Section 3.2: Risk Mitigation Strategy Modified to Account for “Black Swan” Events #
In section 3.2, the paper proposes the idea of switching the allocation from the rebalance strategy to cash or a cash substitute, after a severe drop in the price of TQQQ. Specifically:
Such large one-day losses might cause even disciplined investors to panic sell. Here we propose a simple filter to overlay the risk mitigation strategy described above. The basic idea is to switch the simple strategy to cash, or a near-cash substitute (here we use the 7-10 year Treasury ETF, IEF) whenever an automated alert indicates that the daily return of TQQQ has dropped below a preset minimum. A return to the standard strategy is then only made when a second alert indicates that the daily adjusted close of TQQQ has returned to and exceeded the value it had when the switch was made.
This is an interesting idea, and we will implement it next, but first we need to pull data for IEF.
yf_pull_data(
base_directory=DATA_DIR,
ticker="IEF",
adjusted=False,
source="Yahoo_Finance",
asset_class="Exchange_Traded_Funds",
excel_export=True,
pickle_export=True,
output_confirmation=False,
)
| Adj Close | Close | High | Low | Open | Volume | |
|---|---|---|---|---|---|---|
| Date | ||||||
| 2002-07-30 | 40.00 | 81.77 | 82.12 | 81.70 | 81.94 | 41300 |
| 2002-07-31 | 40.37 | 82.52 | 82.58 | 82.05 | 82.05 | 32600 |
| 2002-08-01 | 40.53 | 82.86 | 82.90 | 82.52 | 82.54 | 71400 |
| 2002-08-02 | 40.85 | 83.50 | 83.70 | 82.90 | 83.02 | 120300 |
| 2002-08-05 | 41.05 | 83.92 | 83.92 | 83.53 | 83.68 | 159300 |
| ... | ... | ... | ... | ... | ... | ... |
| 2026-10-16 | 90.42 | 90.42 | 90.54 | 90.12 | 90.15 | 10831400 |
| 2026-10-19 | 90.08 | 90.08 | 90.38 | 90.02 | 90.32 | 10400200 |
| 2026-10-20 | 90.25 | 90.25 | 90.50 | 90.12 | 90.29 | 8956100 |
| 2026-10-21 | 90.23 | 90.23 | 90.47 | 90.00 | 90.39 | 8909200 |
| 2026-10-22 | 91.20 | 91.20 | 91.40 | 90.65 | 90.75 | 16518600 |
6098 rows × 6 columns
Then append it to the existing dictionary.
fund_list.append("IEF")
# Load the data for each ETF, rename the columns to include the ticker, store it in the fund_data dictionary
for fund in fund_list:
data = load_data(
base_directory=DATA_DIR,
ticker=fund,
source="Yahoo_Finance",
asset_class="Exchange_Traded_Funds",
timeframe="Daily",
file_format="pickle",
)
data = data.rename(
columns={
"Adj Close": f"{fund}_Adj_Close",
"Close": f"{fund}_Close",
"High": f"{fund}_High",
"Low": f"{fund}_Low",
"Open": f"{fund}_Open",
"Volume": f"{fund}_Volume",
}
)
fund_data[fund] = data
And we will use the following helper function. It implements the paper’s filter with a threshold of 20% for the one-day drop in TQQQ (the paper’s $\partial^{\ast}$) and IEF as the cash substitute. There are a few implementation details worth noting:
- The paper’s pseudo-code in figure 11 sets the switch-back price $v^{\ast}$ to the price of TQQQ on the day of the drop, $v(n)$. Implemented that way, TQQQ closes above $v^{\ast}$ the very next day (3/10/2020), and the filter switches in and out three times during March 2020. However, the paper states that the return to the standard strategy occurs on 5/18/2020, which is the first date that TQQQ closes above its price on the day before the drop, $v(n-1)$. This also matches the paper’s description of TQQQ needing to return to “the value it had when the switch was made”, so we use $v(n-1)$ for $v^{\ast}$.
- While the strategy is holding IEF, the scheduled bimonthly rebalances are skipped.
- Each switch into or out of IEF pays the 0.25% transaction fee on both the sell and the buy.
def rebalance_strategy_bs(
fund_data: dict,
fund_1: str,
fund_2: str,
cash_fund: str = "IEF",
drop_threshold: float = 0.20,
fund_1_weight: float = 0.5,
rebalance_months: int = 2,
transaction_fee: float = 0.0025,
start_date=None,
end_date=None,
) -> pd.DataFrame:
"""
Simulate a two-fund portfolio that is periodically rebalanced back to a target allocation, with a
"black swan" filter that switches the portfolio to a cash substitute after a large one-day drop in
the first fund.
When the daily return of the first fund falls below -drop_threshold, the entire portfolio is sold and
moved into the cash fund at that day's close, and the first fund's price from the prior day's close
(its value before the drop) is recorded. The portfolio stays in the cash fund, skipping any scheduled
rebalances, until the first fund's price closes above the recorded price, at which point the cash fund
is sold and the two-fund portfolio is bought back at the target allocation.
Parameters
----------
fund_data : dict
Dictionary containing DataFrames for each fund.
fund_1 : str
The ticker symbol for the first fund. Its daily return triggers the switch to the cash fund.
fund_2 : str
The ticker symbol for the second fund.
cash_fund : str, optional
The ticker symbol for the cash substitute held after the filter is triggered (default is "IEF").
drop_threshold : float, optional
One-day decline in the first fund that triggers the switch to the cash fund (default is 0.20).
fund_1_weight : float, optional
Target allocation to the first fund; the second fund receives the remainder (default is 0.5).
rebalance_months : int, optional
Rebalance on the last trading day of every k-th month, counting the start month as month 1 (default is 2).
transaction_fee : float, optional
Fee charged on the dollar amount of each buy or sell order at a rebalance or switch (default is 0.0025).
start_date : optional
Start date for the data. If None, uses the earliest date all three funds have data (default is None).
end_date : optional
End date for the data. If None, uses the latest available date (default is None).
Returns
-------
pd.DataFrame
DataFrame with the normalized fund prices, the value of each holding, the normalized
strategy value, the strategy daily return, the rebalance and switch flags, whether the
portfolio is held in the cash fund, and the fee paid on each day.
"""
# Combine the adjusted close prices over the common date range
df = (
fund_data[fund_1][[f"{fund_1}_Adj_Close"]]
.merge(
fund_data[fund_2][[f"{fund_2}_Adj_Close"]],
left_index=True,
right_index=True,
)
.merge(
fund_data[cash_fund][[f"{cash_fund}_Adj_Close"]],
left_index=True,
right_index=True,
)
)
# Filter by date range
df = df.loc[start_date:end_date]
# Normalize prices to start at 1
df = df / df.iloc[0]
# Calculate the return series for each fund
df[f"{fund_1}_Return"] = df[f"{fund_1}_Adj_Close"].pct_change()
df[f"{fund_2}_Return"] = df[f"{fund_2}_Adj_Close"].pct_change()
# Calculate the drawdowns for each fund
df[f"{fund_1}_Drawdown"] = (
df[f"{fund_1}_Adj_Close"] / df[f"{fund_1}_Adj_Close"].cummax() - 1
)
df[f"{fund_2}_Drawdown"] = (
df[f"{fund_2}_Adj_Close"] / df[f"{fund_2}_Adj_Close"].cummax() - 1
)
df[f"{fund_1}_Drawdown_Daily_Max"] = df[f"{fund_1}_Drawdown"].cummin()
df[f"{fund_2}_Drawdown_Daily_Max"] = df[f"{fund_2}_Drawdown"].cummin()
# Flag the last trading day of each month, excluding the final row since that month may be incomplete
month = pd.Series(df.index.to_period("M"), index=df.index)
month_end = month != month.shift(-1)
month_end.iloc[-1] = False
# Rebalance on the last trading day of every k-th month
month_number = (
(df.index.year - df.index[0].year) * 12
+ (df.index.month - df.index[0].month)
+ 1
)
rebalance = month_end & (month_number % rebalance_months == 0)
fund_2_weight = 1 - fund_1_weight
# Initial purchase at the target allocation with a starting portfolio value of 1
fund_1_shares = fund_1_weight / df[f"{fund_1}_Adj_Close"].iloc[0]
fund_2_shares = fund_2_weight / df[f"{fund_2}_Adj_Close"].iloc[0]
cash_fund_shares = 0.0
# Price of the first fund before the drop that triggered the switch to the cash fund (v* in the paper); 0 when
# not in the cash fund. The paper's flowchart sets v* = v(n), but its switch back on 5/18/20 matches v* = v(n-1)
switch_price = 0.0
prior_close = df[f"{fund_1}_Adj_Close"].shift(1)
fund_1_values, fund_2_values, cash_fund_values = [], [], []
rebalanced, switched, in_cash_fund, fees = [], [], [], []
for date, row in df.iterrows():
fund_1_value = fund_1_shares * row[f"{fund_1}_Adj_Close"]
fund_2_value = fund_2_shares * row[f"{fund_2}_Adj_Close"]
cash_fund_value = cash_fund_shares * row[f"{cash_fund}_Adj_Close"]
fee = 0.0
rebalance_today = False
switch_today = False
if switch_price > 0:
# Currently in the cash fund; switch back once the first fund closes above the switch price
if row[f"{fund_1}_Adj_Close"] > switch_price:
total_value = cash_fund_value
# Fee on selling the cash fund and buying the two funds
fee = transaction_fee * 2 * total_value
total_value -= fee
fund_1_value = fund_1_weight * total_value
fund_2_value = fund_2_weight * total_value
cash_fund_value = 0.0
fund_1_shares = fund_1_value / row[f"{fund_1}_Adj_Close"]
fund_2_shares = fund_2_value / row[f"{fund_2}_Adj_Close"]
cash_fund_shares = 0.0
switch_price = 0.0
switch_today = True
elif row[f"{fund_1}_Return"] < -drop_threshold:
# One-day drop in the first fund exceeds the threshold; sell both funds and buy the cash fund
total_value = fund_1_value + fund_2_value
# Fee on selling the two funds and buying the cash fund
fee = transaction_fee * 2 * total_value
total_value -= fee
cash_fund_value = total_value
fund_1_value = 0.0
fund_2_value = 0.0
cash_fund_shares = cash_fund_value / row[f"{cash_fund}_Adj_Close"]
fund_1_shares = 0.0
fund_2_shares = 0.0
switch_price = prior_close.loc[date]
switch_today = True
elif rebalance.loc[date]:
total_value = fund_1_value + fund_2_value
# Fee on the dollar amount traded in each fund to get back to the target allocation
fee = transaction_fee * (
abs(fund_1_weight * total_value - fund_1_value)
+ abs(fund_2_weight * total_value - fund_2_value)
)
total_value -= fee
fund_1_value = fund_1_weight * total_value
fund_2_value = fund_2_weight * total_value
fund_1_shares = fund_1_value / row[f"{fund_1}_Adj_Close"]
fund_2_shares = fund_2_value / row[f"{fund_2}_Adj_Close"]
rebalance_today = True
fund_1_values.append(fund_1_value)
fund_2_values.append(fund_2_value)
cash_fund_values.append(cash_fund_value)
rebalanced.append(rebalance_today)
switched.append(switch_today)
in_cash_fund.append(switch_price > 0)
fees.append(fee)
df[f"{fund_1}_Value"] = fund_1_values
df[f"{fund_2}_Value"] = fund_2_values
df[f"{cash_fund}_Value"] = cash_fund_values
df["Strategy_Adj_Close"] = (
df[f"{fund_1}_Value"] + df[f"{fund_2}_Value"] + df[f"{cash_fund}_Value"]
)
df["Strategy_Return"] = df["Strategy_Adj_Close"].pct_change()
# Calculate the drawdown for the strategy
df["Strategy_Drawdown"] = (
df["Strategy_Adj_Close"] / df["Strategy_Adj_Close"].cummax() - 1
)
df["Strategy_Drawdown_Daily_Max"] = df["Strategy_Drawdown"].cummin()
df["Rebalance"] = rebalanced
df["Switch"] = switched
df[f"In_{cash_fund}"] = in_cash_fund
df["Transaction_Fee"] = fees
return df
Now, we’ll execute the “black swan” strategy for TQQQ and TMF, as the paper does with a start date of 2/11/2010 and an end date of 10/4/2020.
tqqq_tmf_bs = rebalance_strategy_bs(
fund_data=fund_data,
fund_1="TQQQ",
fund_2="TMF",
cash_fund="IEF",
drop_threshold=0.2,
fund_1_weight=0.5,
rebalance_months=2,
transaction_fee=0.0025, # 0.25% transaction fee
start_date=None,
end_date="2020-10-04",
)
print("Table E19: TQQQ/TMF Black Swan Strategy Data")
display(tqqq_tmf_bs)
Table E19: TQQQ/TMF Black Swan Strategy Data
| TQQQ_Adj_Close | TMF_Adj_Close | IEF_Adj_Close | TQQQ_Return | TMF_Return | TQQQ_Drawdown | TMF_Drawdown | TQQQ_Drawdown_Daily_Max | TMF_Drawdown_Daily_Max | TQQQ_Value | TMF_Value | IEF_Value | Strategy_Adj_Close | Strategy_Return | Strategy_Drawdown | Strategy_Drawdown_Daily_Max | Rebalance | Switch | In_IEF | Transaction_Fee | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Date | ||||||||||||||||||||
| 2010-02-11 | 1.00 | 1.00 | 1.00 | NaN | NaN | 0.00 | 0.00 | 0.00 | 0.00 | 0.50 | 0.50 | 0.00 | 1.00 | NaN | 0.00 | 0.00 | False | False | False | 0.00 |
| 2010-02-12 | 1.00 | 1.01 | 1.00 | 0.00 | 0.01 | 0.00 | 0.00 | 0.00 | 0.00 | 0.50 | 0.51 | 0.00 | 1.01 | 0.01 | 0.00 | 0.00 | False | False | False | 0.00 |
| 2010-02-16 | 1.04 | 1.02 | 1.00 | 0.04 | 0.01 | 0.00 | 0.00 | 0.00 | 0.00 | 0.52 | 0.51 | 0.00 | 1.03 | 0.02 | 0.00 | 0.00 | False | False | False | 0.00 |
| 2010-02-17 | 1.06 | 0.99 | 1.00 | 0.02 | -0.03 | 0.00 | -0.03 | 0.00 | -0.03 | 0.53 | 0.49 | 0.00 | 1.02 | -0.01 | -0.01 | -0.01 | False | False | False | 0.00 |
| 2010-02-18 | 1.08 | 0.97 | 1.00 | 0.02 | -0.02 | 0.00 | -0.05 | 0.00 | -0.05 | 0.54 | 0.49 | 0.00 | 1.03 | 0.00 | -0.01 | -0.01 | False | False | False | 0.00 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 2020-09-28 | 75.16 | 5.83 | 1.69 | 0.06 | -0.01 | -0.26 | -0.20 | -0.70 | -0.53 | 22.53 | 18.47 | 0.00 | 40.99 | 0.03 | -0.17 | -0.30 | False | False | False | 0.00 |
| 2020-09-29 | 74.37 | 5.85 | 1.69 | -0.01 | 0.00 | -0.26 | -0.20 | -0.70 | -0.53 | 22.29 | 18.52 | 0.00 | 40.82 | -0.00 | -0.17 | -0.30 | False | False | False | 0.00 |
| 2020-09-30 | 75.85 | 5.69 | 1.69 | 0.02 | -0.03 | -0.25 | -0.22 | -0.70 | -0.53 | 20.37 | 20.37 | 0.00 | 40.75 | -0.00 | -0.17 | -0.30 | True | False | False | 0.01 |
| 2020-10-01 | 79.40 | 5.73 | 1.69 | 0.05 | 0.01 | -0.21 | -0.22 | -0.70 | -0.53 | 21.33 | 20.49 | 0.00 | 41.82 | 0.03 | -0.15 | -0.30 | False | False | False | 0.00 |
| 2020-10-02 | 72.85 | 5.66 | 1.69 | -0.08 | -0.01 | -0.28 | -0.23 | -0.70 | -0.53 | 19.57 | 20.25 | 0.00 | 39.81 | -0.05 | -0.19 | -0.30 | False | False | False | 0.00 |
2680 rows × 20 columns
And then we can check the summary statistics to confirm that our implementation of the strategy matches the results reported in the paper.
tqqq_tmf_bs_sum_stats = summary_stats(
fund_list=["TQQQ", "TMF"],
df=tqqq_tmf_bs[["Strategy_Return"]].dropna(),
period="Daily",
use_calendar_days=False,
excel_export=False,
pickle_export=False,
output_confirmation=False,
)
# Rename the index to indicate the strategy
tqqq_tmf_bs_sum_stats.index = ["TQQQ/TMF BS"]
all_strat_sum_stats = pd.concat([all_strat_sum_stats, tqqq_tmf_bs_sum_stats])
print("Table E20: Strategy Summary Statistics")
display(all_strat_sum_stats)
Table E20: Strategy Summary Statistics
| Number of Observations | Data Start Date | Data End Date | Annual Mean Return (Arithmetic) | Annualized Volatility | Annualized Sharpe Ratio | CAGR (Geometric) | Daily Max Return | Daily Max Return (Date) | Daily Min Return | Daily Min Return (Date) | Max Drawdown | Peak | Trough | Recovery Date | Calendar Days to Recovery | MAR Ratio | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| TQQQ/TMF Strategy | 2679 | 2010-02-12 | 2020-10-02 | 0.39 | 0.28 | 1.40 | 0.42 | 0.11 | 2020-03-20 | -0.16 | 2020-03-18 | -0.42 | 2020-03-06 | 2020-03-18 | 2020-05-07 | 50 | 1.00 |
| TQQQ | 2679 | 2010-02-12 | 2020-10-02 | 0.57 | 0.58 | 0.99 | 0.50 | 0.27 | 2020-03-13 | -0.34 | 2020-03-16 | -0.70 | 2020-02-19 | 2020-03-20 | 2020-07-10 | 112 | 0.71 |
| TMF | 2679 | 2010-02-12 | 2020-10-02 | 0.26 | 0.44 | 0.59 | 0.18 | 0.20 | 2020-03-20 | -0.18 | 2020-03-18 | -0.53 | 2012-07-25 | 2013-08-21 | 2015-01-05 | 502 | 0.33 |
| TQQQ/TMF BS | 2679 | 2010-02-12 | 2020-10-02 | 0.38 | 0.26 | 1.48 | 0.41 | 0.07 | 2020-03-06 | -0.09 | 2020-09-03 | -0.30 | 2018-08-29 | 2018-12-24 | 2019-04-23 | 120 | 1.38 |
Figure 12: Strategy performance with and without IEF filter #
We can then plot figure 12, which compares the performance of the strategy with and without the black swan filter.
tqqq_tmf_both = tqqq_tmf.merge(
tqqq_tmf_bs, on="Date", how="outer", suffixes=("", "_BS")
)
plot_time_series(
df=tqqq_tmf_both,
plot_start_date=None,
plot_end_date=None,
plot_columns=[
"TQQQ_Adj_Close",
"TMF_Adj_Close",
"Strategy_Adj_Close",
"Strategy_Adj_Close_BS",
],
title="Figure 12a: Rebalance Strategy, Black Swan Strategy, TQQQ, and TMF Adjusted Close Price (Normalized) as of 2020-10-04",
x_label="Date",
x_format="Year",
x_tick_spacing=1,
x_tick_start=None,
x_tick_rotation=0,
y_label="Price ($)",
y_format="Decimal",
y_format_decimal_places=1,
y_tick_spacing="Auto",
y_tick_rotation=0,
caption="Data Source: Yahoo Finance, Analysis by the Author",
)

Zooming in on the data for 2020:
plot_time_series(
df=tqqq_tmf_both[tqqq_tmf_both.index >= "2020-01-01"],
plot_start_date=None,
plot_end_date=None,
plot_columns=["Strategy_Adj_Close", "Strategy_Adj_Close_BS"],
title="Figure 12b: Rebalance Strategy and Black Swan Strategy Adjusted Close Price (Normalized), 2020-01-01 to 2020-10-04",
x_label="Date",
x_format="Month",
x_tick_spacing=1,
x_tick_start=None,
x_tick_rotation=0,
y_label="Price ($)",
y_format="Decimal",
y_format_decimal_places=1,
y_tick_spacing="Auto",
y_tick_rotation=0,
caption="Data Source: Yahoo Finance, Analysis by the Author",
)

Interestingly, the BS strategy misses much of the COVID crash in March of 2020, while retaining nearly all of the performance throughout the rest of the date range.
Figure 13: Maximum daily draw downs with IEF filter used for mitigation strategy #
Finally, figure 13 shows the maximum DDD with the BS filter applied.
plot_time_series(
df=tqqq_tmf_bs,
plot_start_date=None,
plot_end_date=None,
plot_columns=["TQQQ_Drawdown", "TMF_Drawdown", "Strategy_Drawdown"],
title="Figure 13a: Black Swan Strategy, TQQQ, and TMF Daily Drawdowns as of 2020-10-04",
x_label="Date",
x_format="Year",
x_tick_spacing=1,
x_tick_start=None,
x_tick_rotation=0,
y_label="Drawdown",
y_format="Percentage",
y_format_decimal_places=0,
y_tick_spacing="Auto",
y_tick_rotation=0,
caption="Data Source: Yahoo Finance, Analysis by the Author",
)

Next, the maximum DDD.
plot_time_series(
df=tqqq_tmf_bs,
plot_start_date=None,
plot_end_date=None,
plot_columns=[
"TQQQ_Drawdown_Daily_Max",
"TMF_Drawdown_Daily_Max",
"Strategy_Drawdown_Daily_Max",
],
title="Figure 13b: Black Swan Strategy, TQQQ, and TMF Maximum Daily Drawdowns as of 2020-10-04",
x_label="Date",
x_format="Year",
x_tick_spacing=1,
x_tick_start=None,
x_tick_rotation=0,
y_label="Drawdown",
y_format="Percentage",
y_format_decimal_places=0,
y_tick_spacing="Auto",
y_tick_rotation=0,
caption="Data Source: Yahoo Finance, Analysis by the Author",
)

The above plot is the true replication of figure 13, and it does in fact appear to match. We see a maximum DDD of 30% for the BS strategy, and this does appear to match what is in the paper.
Finally, the maximum MDD.
tqqq_tmf_bs_monthly = tqqq_tmf_bs.resample("ME").last()
# Calculate the monthly drawdowns using end-of-month prices only, then the running maximum monthly drawdown
for fund in ["TQQQ", "TMF", "Strategy"]:
tqqq_tmf_bs_monthly[f"{fund}_Drawdown_Monthly"] = (
tqqq_tmf_bs_monthly[f"{fund}_Adj_Close"]
/ tqqq_tmf_bs_monthly[f"{fund}_Adj_Close"].cummax()
- 1
)
tqqq_tmf_bs_monthly[f"{fund}_Drawdown_Monthly_Max"] = tqqq_tmf_bs_monthly[
f"{fund}_Drawdown_Monthly"
].cummin()
plot_time_series(
df=tqqq_tmf_bs_monthly,
plot_start_date=None,
plot_end_date=None,
plot_columns=[
"TQQQ_Drawdown_Monthly_Max",
"TMF_Drawdown_Monthly_Max",
"Strategy_Drawdown_Monthly_Max",
],
title="Figure 13c: Black Swan Strategy, TQQQ, and TMF Maximum Monthly Drawdowns as of 2020-10-04",
x_label="Date",
x_format="Year",
x_tick_spacing=1,
x_tick_start=None,
x_tick_rotation=0,
y_label="Drawdown",
y_format="Percentage",
y_format_decimal_places=0,
y_tick_spacing="Auto",
y_tick_rotation=0,
caption="Data Source: Yahoo Finance, Analysis by the Author",
)

Extension 1: Expanding the date range for analysis #
Above we saw that the rebalance strategy performed well during a time when the correlations between equity and bond funds were negative, which is the entirety of the range between 2010 and 2020. Now, let’s expand the date range to present, and see if the results of the rebalance strategy hold up.
tqqq_tmf_full = rebalance_strategy(
fund_data=fund_data,
fund_1="TQQQ",
fund_2="TMF",
fund_1_weight=0.5,
rebalance_months=2,
transaction_fee=0.0025, # 0.25% transaction fee
start_date=None,
end_date=None,
)
print("Table E21: TQQQ/TMF Rebalance Strategy Data (Full Period)")
display(tqqq_tmf_full)
Table E21: TQQQ/TMF Rebalance Strategy Data (Full Period)
| TQQQ_Adj_Close | TMF_Adj_Close | TQQQ_Return | TMF_Return | TQQQ_Drawdown | TMF_Drawdown | TQQQ_Drawdown_Daily_Max | TMF_Drawdown_Daily_Max | TQQQ_Value | TMF_Value | Strategy_Adj_Close | Strategy_Return | Strategy_Drawdown | Strategy_Drawdown_Daily_Max | Rebalance | Transaction_Fee | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Date | ||||||||||||||||
| 2010-02-11 | 1.00 | 1.00 | NaN | NaN | 0.00 | 0.00 | 0.00 | 0.00 | 0.50 | 0.50 | 1.00 | NaN | 0.00 | 0.00 | False | 0.00 |
| 2010-02-12 | 1.00 | 1.01 | 0.00 | 0.01 | 0.00 | 0.00 | 0.00 | 0.00 | 0.50 | 0.51 | 1.01 | 0.01 | 0.00 | 0.00 | False | 0.00 |
| 2010-02-16 | 1.04 | 1.02 | 0.04 | 0.01 | 0.00 | 0.00 | 0.00 | 0.00 | 0.52 | 0.51 | 1.03 | 0.02 | 0.00 | 0.00 | False | 0.00 |
| 2010-02-17 | 1.06 | 0.99 | 0.02 | -0.03 | 0.00 | -0.03 | 0.00 | -0.03 | 0.53 | 0.49 | 1.02 | -0.01 | -0.01 | -0.01 | False | 0.00 |
| 2010-02-18 | 1.08 | 0.97 | 0.02 | -0.02 | 0.00 | -0.05 | 0.00 | -0.05 | 0.54 | 0.49 | 1.03 | 0.00 | -0.01 | -0.01 | False | 0.00 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 2026-10-16 | 332.43 | 0.48 | -0.08 | 0.00 | -0.21 | -0.93 | -0.82 | -0.94 | 15.13 | 18.89 | 34.02 | -0.03 | -0.46 | -0.78 | False | 0.00 |
| 2026-10-19 | 344.27 | 0.47 | 0.04 | -0.02 | -0.19 | -0.94 | -0.82 | -0.94 | 15.67 | 18.48 | 34.15 | 0.00 | -0.46 | -0.78 | False | 0.00 |
| 2026-10-20 | 343.15 | 0.48 | -0.00 | 0.02 | -0.19 | -0.93 | -0.82 | -0.94 | 15.61 | 18.93 | 34.54 | 0.01 | -0.46 | -0.78 | False | 0.00 |
| 2026-10-21 | 353.88 | 0.48 | 0.03 | 0.00 | -0.16 | -0.93 | -0.82 | -0.94 | 16.10 | 19.02 | 35.12 | 0.02 | -0.45 | -0.78 | False | 0.00 |
| 2026-10-22 | 323.71 | 0.51 | -0.09 | 0.06 | -0.24 | -0.93 | -0.82 | -0.94 | 14.73 | 20.07 | 34.80 | -0.01 | -0.45 | -0.78 | False | 0.00 |
4200 rows × 16 columns
tqqq_tmf_full_sum_stats = summary_stats(
fund_list=["TQQQ", "TMF"],
df=tqqq_tmf_full[["Strategy_Return"]].dropna(),
period="Daily",
use_calendar_days=False,
excel_export=False,
pickle_export=False,
output_confirmation=False,
)
# Rename the index to indicate the strategy
tqqq_tmf_full_sum_stats.index = ["TQQQ/TMF Full Strategy"]
all_strat_sum_stats = pd.concat([all_strat_sum_stats, tqqq_tmf_full_sum_stats])
print("Table E22: Strategy Summary Statistics")
display(all_strat_sum_stats)
Table E22: Strategy Summary Statistics
| Number of Observations | Data Start Date | Data End Date | Annual Mean Return (Arithmetic) | Annualized Volatility | Annualized Sharpe Ratio | CAGR (Geometric) | Daily Max Return | Daily Max Return (Date) | Daily Min Return | Daily Min Return (Date) | Max Drawdown | Peak | Trough | Recovery Date | Calendar Days to Recovery | MAR Ratio | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| TQQQ/TMF Strategy | 2679 | 2010-02-12 | 2020-10-02 | 0.39 | 0.28 | 1.40 | 0.42 | 0.11 | 2020-03-20 | -0.16 | 2020-03-18 | -0.42 | 2020-03-06 | 2020-03-18 | 2020-05-07 | 50.00 | 1.00 |
| TQQQ | 2679 | 2010-02-12 | 2020-10-02 | 0.57 | 0.58 | 0.99 | 0.50 | 0.27 | 2020-03-13 | -0.34 | 2020-03-16 | -0.70 | 2020-02-19 | 2020-03-20 | 2020-07-10 | 112.00 | 0.71 |
| TMF | 2679 | 2010-02-12 | 2020-10-02 | 0.26 | 0.44 | 0.59 | 0.18 | 0.20 | 2020-03-20 | -0.18 | 2020-03-18 | -0.53 | 2012-07-25 | 2013-08-21 | 2015-01-05 | 502.00 | 0.33 |
| TQQQ/TMF BS | 2679 | 2010-02-12 | 2020-10-02 | 0.38 | 0.26 | 1.48 | 0.41 | 0.07 | 2020-03-06 | -0.09 | 2020-09-03 | -0.30 | 2018-08-29 | 2018-12-24 | 2019-04-23 | 120.00 | 1.38 |
| TQQQ/TMF Full Strategy | 4199 | 2010-02-12 | 2026-10-22 | 0.27 | 0.34 | 0.80 | 0.24 | 0.17 | 2022-11-10 | -0.16 | 2020-03-18 | -0.78 | 2021-11-19 | 2022-11-09 | NaT | NaN | 0.30 |
Here we can see that the performance has degraded over time, especially due to the significant drawdown in TMF. Next, we will look at the plots.
plot_time_series(
df=tqqq_tmf_full,
plot_start_date=None,
plot_end_date=None,
plot_columns=["TQQQ_Adj_Close", "TMF_Adj_Close", "Strategy_Adj_Close"],
title=f"Figure E1: Rebalance Strategy, TQQQ, and TMF Adjusted Close Price (Normalized) as of {tqqq_tmf_full.index[-1].date()}",
x_label="Date",
x_format="Year",
x_tick_spacing=1,
x_tick_start=None,
x_tick_rotation=0,
y_label="Price ($)",
y_format="Decimal",
y_format_decimal_places=1,
y_tick_spacing="Auto",
y_tick_rotation=0,
caption="Data Source: Yahoo Finance, Analysis by the Author",
)

We can see that the strategy peaked in late 2021, and appears to have struggled since then. This could be due to the TQQQ performance, but since 2023 TQQQ has shown significant growth. Therefore, we suspect that TMF is actually the cause of the strategy underperformance, and we should look at its performance closely.
lev_etf = "TMF"
etf = "TLT"
end_date = None
df = combine_normalize(
fund_data=fund_data,
lev_etf=lev_etf,
etf=etf,
start_date=None,
end_date=end_date,
)
print(f"Table E23: {lev_etf} & {etf} Normalized Data (Full Period)")
display(df)
Table E23: TMF & TLT Normalized Data (Full Period)
| TMF_Adj_Close | TMF_Close | TMF_High | TMF_Low | TMF_Open | TMF_Volume | TLT_Adj_Close | TLT_Close | TLT_High | TLT_Low | TLT_Open | TLT_Volume | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Date | ||||||||||||
| 2009-04-16 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 |
| 2009-04-17 | 0.96 | 0.96 | 0.97 | 0.95 | 0.98 | 2.64 | 0.99 | 0.99 | 0.99 | 0.99 | 1.00 | 2.45 |
| 2009-04-20 | 1.01 | 1.01 | 1.00 | 0.99 | 0.98 | 0.60 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.62 |
| 2009-04-21 | 0.98 | 0.98 | 1.03 | 0.99 | 1.03 | 1.37 | 0.99 | 0.99 | 1.01 | 0.99 | 1.01 | 0.86 |
| 2009-04-22 | 0.95 | 0.95 | 0.97 | 0.94 | 0.98 | 2.22 | 0.99 | 0.99 | 0.99 | 0.98 | 1.00 | 2.16 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 2026-10-16 | 0.29 | 0.19 | 0.19 | 0.19 | 0.19 | 1451.62 | 1.31 | 0.78 | 0.78 | 0.78 | 0.77 | 40.17 |
| 2026-10-19 | 0.28 | 0.19 | 0.19 | 0.19 | 0.19 | 1054.76 | 1.30 | 0.77 | 0.77 | 0.77 | 0.78 | 29.34 |
| 2026-10-20 | 0.29 | 0.19 | 0.19 | 0.19 | 0.19 | 1008.60 | 1.31 | 0.78 | 0.78 | 0.78 | 0.78 | 26.97 |
| 2026-10-21 | 0.29 | 0.19 | 0.19 | 0.19 | 0.19 | 910.70 | 1.32 | 0.78 | 0.78 | 0.78 | 0.78 | 34.37 |
| 2026-10-22 | 0.31 | 0.20 | 0.20 | 0.20 | 0.20 | 2202.68 | 1.34 | 0.79 | 0.79 | 0.79 | 0.79 | 62.18 |
4408 rows × 12 columns
plot_time_series(
df=df,
plot_start_date=None,
plot_end_date=None,
plot_columns=[f"{lev_etf}_Adj_Close", f"{etf}_Adj_Close"],
title=f"Figure E2: {lev_etf} & {etf} Adjusted Close Price (Normalized) as of {df.index[-1].date()}",
x_label="Date",
x_format="Year",
x_tick_spacing=1,
x_tick_start=None,
x_tick_rotation=0,
y_label="Price ($)",
y_format="Decimal",
y_format_decimal_places=1,
y_tick_spacing="Auto",
y_tick_rotation=0,
caption="Data Source: Yahoo Finance, Analysis by the Author",
)

Here we have the answer. TMF has essentially collapsed since mid-2020, which explains the underperformance of the strategy despite the strong performance of TQQQ in recent years. Every time the strategy has been rebalancing, it has been selling TQQQ to buy TMF at depressed prices, which has hindered overall performance.
Let’s look at the drawdown plots to see how the strategy has been affected by the TMF collapse.
plot_time_series(
df=tqqq_tmf_full,
plot_start_date=None,
plot_end_date=None,
plot_columns=["TQQQ_Drawdown", "TMF_Drawdown", "Strategy_Drawdown"],
title=f"Figure E3: Rebalance Strategy, TQQQ, and TMF Daily Drawdowns as of {tqqq_tmf_full.index[-1].date()}",
x_label="Date",
x_format="Year",
x_tick_spacing=1,
x_tick_start=None,
x_tick_rotation=0,
y_label="Drawdown",
y_format="Percentage",
y_format_decimal_places=0,
y_tick_spacing="Auto",
y_tick_rotation=0,
caption="Data Source: Yahoo Finance, Analysis by the Author",
)

Next, the maximum DDD.
plot_time_series(
df=tqqq_tmf_full,
plot_start_date=None,
plot_end_date=None,
plot_columns=[
"TQQQ_Drawdown_Daily_Max",
"TMF_Drawdown_Daily_Max",
"Strategy_Drawdown_Daily_Max",
],
title=f"Figure E4: Rebalance Strategy, TQQQ, and TMF Maximum Daily Drawdowns as of {tqqq_tmf_full.index[-1].date()}",
x_label="Date",
x_format="Year",
x_tick_spacing=1,
x_tick_start=None,
x_tick_rotation=0,
y_label="Drawdown",
y_format="Percentage",
y_format_decimal_places=0,
y_tick_spacing="Auto",
y_tick_rotation=0,
caption="Data Source: Yahoo Finance, Analysis by the Author",
)

To confirm the change in regime, we can look at the correlation between the daily returns of TQQQ and TMF over time, first by calendar year, and then on a rolling 252 trading day (roughly one year) basis.
# Calculate the correlation between TQQQ and TMF daily returns for each calendar year
annual_corr = (
tqqq_tmf_full.groupby(tqqq_tmf_full.index.year)
.apply(lambda x: x["TQQQ_Return"].corr(x["TMF_Return"]))
.to_frame(name="TQQQ_TMF_Correlation")
)
annual_corr.index.name = "Year"
print("Table E24: Annual Correlation of TQQQ and TMF Daily Returns")
display(annual_corr)
Table E24: Annual Correlation of TQQQ and TMF Daily Returns
| TQQQ_TMF_Correlation | |
|---|---|
| Year | |
| 2010 | -0.55 |
| 2011 | -0.62 |
| 2012 | -0.56 |
| 2013 | -0.20 |
| 2014 | -0.41 |
| 2015 | -0.33 |
| 2016 | -0.29 |
| 2017 | -0.16 |
| 2018 | -0.23 |
| 2019 | -0.41 |
| 2020 | -0.39 |
| 2021 | 0.07 |
| 2022 | 0.08 |
| 2023 | 0.14 |
| 2024 | 0.00 |
| 2025 | 0.04 |
| 2026 | 0.20 |
# Calculate the rolling 252 day correlation between TQQQ and TMF daily returns
tqqq_tmf_full["TQQQ_TMF_Rolling_Correlation"] = (
tqqq_tmf_full["TQQQ_Return"].rolling(252).corr(tqqq_tmf_full["TMF_Return"])
)
plot_time_series(
df=tqqq_tmf_full,
plot_start_date=None,
plot_end_date=None,
plot_columns=["TQQQ_TMF_Rolling_Correlation"],
title=f"Figure E5: TQQQ & TMF 252-Day Rolling Correlation of Daily Returns as of {tqqq_tmf_full.index[-1].date()}",
x_label="Date",
x_format="Year",
x_tick_spacing=1,
x_tick_start=None,
x_tick_rotation=0,
y_label="Correlation",
y_format="Decimal",
y_format_decimal_places=2,
y_tick_spacing="Auto",
y_tick_rotation=0,
caption="Data Source: Yahoo Finance, Analysis by the Author",
)

The correlation was negative in every year from 2010 through 2020, ranging from -0.16 to -0.62. Since 2021, it has been zero or positive in every year. The rolling correlation shows the same shift. Before 2021, it was positive for only a brief period in 2017, whereas since 2021 it has been positive for the vast majority of days.
What we find here is almost the worst case scenario for long-term investing in triple leveraged exchange traded funds. For years, we have a strategy that performs very well, with relatively low risk (Sharpe ratio of 1.4), but due to a regime change in markets, and a reversal of the historic equity/bond negative correlation, the strategy experiences significant drawdowns and underperformance, highlighting the challenges and risks associated with long-term investing in such leveraged instruments.
Extension 2: Expanding the date range for analysis (including “Black Swan”) #
We can also extend the date range for the BS strategy, to see if that impacted the overall performance at all.
tqqq_tmf_bs_full = rebalance_strategy_bs(
fund_data=fund_data,
fund_1="TQQQ",
fund_2="TMF",
cash_fund="IEF",
drop_threshold=0.2,
fund_1_weight=0.5,
rebalance_months=2,
transaction_fee=0.0025, # 0.25% transaction fee
start_date=None,
end_date=None,
)
print("Table E25: TQQQ/TMF Black Swan Strategy Data (Full Period)")
display(tqqq_tmf_bs_full)
Table E25: TQQQ/TMF Black Swan Strategy Data (Full Period)
| TQQQ_Adj_Close | TMF_Adj_Close | IEF_Adj_Close | TQQQ_Return | TMF_Return | TQQQ_Drawdown | TMF_Drawdown | TQQQ_Drawdown_Daily_Max | TMF_Drawdown_Daily_Max | TQQQ_Value | TMF_Value | IEF_Value | Strategy_Adj_Close | Strategy_Return | Strategy_Drawdown | Strategy_Drawdown_Daily_Max | Rebalance | Switch | In_IEF | Transaction_Fee | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Date | ||||||||||||||||||||
| 2010-02-11 | 1.00 | 1.00 | 1.00 | NaN | NaN | 0.00 | 0.00 | 0.00 | 0.00 | 0.50 | 0.50 | 0.00 | 1.00 | NaN | 0.00 | 0.00 | False | False | False | 0.00 |
| 2010-02-12 | 1.00 | 1.01 | 1.00 | 0.00 | 0.01 | 0.00 | 0.00 | 0.00 | 0.00 | 0.50 | 0.51 | 0.00 | 1.01 | 0.01 | 0.00 | 0.00 | False | False | False | 0.00 |
| 2010-02-16 | 1.04 | 1.02 | 1.00 | 0.04 | 0.01 | 0.00 | 0.00 | 0.00 | 0.00 | 0.52 | 0.51 | 0.00 | 1.03 | 0.02 | 0.00 | 0.00 | False | False | False | 0.00 |
| 2010-02-17 | 1.06 | 0.99 | 1.00 | 0.02 | -0.03 | 0.00 | -0.03 | 0.00 | -0.03 | 0.53 | 0.49 | 0.00 | 1.02 | -0.01 | -0.01 | -0.01 | False | False | False | 0.00 |
| 2010-02-18 | 1.08 | 0.97 | 1.00 | 0.02 | -0.02 | 0.00 | -0.05 | 0.00 | -0.05 | 0.54 | 0.49 | 0.00 | 1.03 | 0.00 | -0.01 | -0.01 | False | False | False | 0.00 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 2026-10-16 | 332.43 | 0.48 | 1.47 | -0.08 | 0.00 | -0.21 | -0.93 | -0.82 | -0.94 | 14.33 | 17.90 | 0.00 | 32.23 | -0.03 | -0.46 | -0.78 | False | False | False | 0.00 |
| 2026-10-19 | 344.27 | 0.47 | 1.47 | 0.04 | -0.02 | -0.19 | -0.94 | -0.82 | -0.94 | 14.84 | 17.51 | 0.00 | 32.35 | 0.00 | -0.46 | -0.78 | False | False | False | 0.00 |
| 2026-10-20 | 343.15 | 0.48 | 1.47 | -0.00 | 0.02 | -0.19 | -0.93 | -0.82 | -0.94 | 14.79 | 17.93 | 0.00 | 32.72 | 0.01 | -0.46 | -0.78 | False | False | False | 0.00 |
| 2026-10-21 | 353.88 | 0.48 | 1.47 | 0.03 | 0.00 | -0.16 | -0.93 | -0.82 | -0.94 | 15.25 | 18.01 | 0.00 | 33.26 | 0.02 | -0.45 | -0.78 | False | False | False | 0.00 |
| 2026-10-22 | 323.71 | 0.51 | 1.48 | -0.09 | 0.06 | -0.24 | -0.93 | -0.82 | -0.94 | 13.95 | 19.01 | 0.00 | 32.96 | -0.01 | -0.45 | -0.78 | False | False | False | 0.00 |
4200 rows × 20 columns
tqqq_tmf_bs_full_sum_stats = summary_stats(
fund_list=["TQQQ", "TMF"],
df=tqqq_tmf_bs_full[["Strategy_Return"]].dropna(),
period="Daily",
use_calendar_days=False,
excel_export=False,
pickle_export=False,
output_confirmation=False,
)
# Rename the index to indicate the strategy
tqqq_tmf_bs_full_sum_stats.index = ["TQQQ/TMF Full BS"]
all_strat_sum_stats = pd.concat([all_strat_sum_stats, tqqq_tmf_bs_full_sum_stats])
print("Table E26: Strategy Summary Statistics")
display(all_strat_sum_stats)
Table E26: Strategy Summary Statistics
| Number of Observations | Data Start Date | Data End Date | Annual Mean Return (Arithmetic) | Annualized Volatility | Annualized Sharpe Ratio | CAGR (Geometric) | Daily Max Return | Daily Max Return (Date) | Daily Min Return | Daily Min Return (Date) | Max Drawdown | Peak | Trough | Recovery Date | Calendar Days to Recovery | MAR Ratio | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| TQQQ/TMF Strategy | 2679 | 2010-02-12 | 2020-10-02 | 0.39 | 0.28 | 1.40 | 0.42 | 0.11 | 2020-03-20 | -0.16 | 2020-03-18 | -0.42 | 2020-03-06 | 2020-03-18 | 2020-05-07 | 50.00 | 1.00 |
| TQQQ | 2679 | 2010-02-12 | 2020-10-02 | 0.57 | 0.58 | 0.99 | 0.50 | 0.27 | 2020-03-13 | -0.34 | 2020-03-16 | -0.70 | 2020-02-19 | 2020-03-20 | 2020-07-10 | 112.00 | 0.71 |
| TMF | 2679 | 2010-02-12 | 2020-10-02 | 0.26 | 0.44 | 0.59 | 0.18 | 0.20 | 2020-03-20 | -0.18 | 2020-03-18 | -0.53 | 2012-07-25 | 2013-08-21 | 2015-01-05 | 502.00 | 0.33 |
| TQQQ/TMF BS | 2679 | 2010-02-12 | 2020-10-02 | 0.38 | 0.26 | 1.48 | 0.41 | 0.07 | 2020-03-06 | -0.09 | 2020-09-03 | -0.30 | 2018-08-29 | 2018-12-24 | 2019-04-23 | 120.00 | 1.38 |
| TQQQ/TMF Full Strategy | 4199 | 2010-02-12 | 2026-10-22 | 0.27 | 0.34 | 0.80 | 0.24 | 0.17 | 2022-11-10 | -0.16 | 2020-03-18 | -0.78 | 2021-11-19 | 2022-11-09 | NaT | NaN | 0.30 |
| TQQQ/TMF Full BS | 4199 | 2010-02-12 | 2026-10-22 | 0.26 | 0.32 | 0.81 | 0.23 | 0.17 | 2022-11-10 | -0.12 | 2022-05-05 | -0.78 | 2021-11-19 | 2022-11-09 | NaT | NaN | 0.30 |
tqqq_tmf_full_both = tqqq_tmf_full.merge(
tqqq_tmf_bs_full, on="Date", how="outer", suffixes=("", "_BS")
)
plot_time_series(
df=tqqq_tmf_full_both,
plot_start_date=None,
plot_end_date=None,
plot_columns=[
"TQQQ_Adj_Close",
"TMF_Adj_Close",
"Strategy_Adj_Close",
"Strategy_Adj_Close_BS",
],
title=f"Figure E6: Rebalance Strategy, Black Swan Strategy, TQQQ, and TMF Adjusted Close Price (Normalized) as of {tqqq_tmf_full_both.index[-1].date()}",
x_label="Date",
x_format="Year",
x_tick_spacing=1,
x_tick_start=None,
x_tick_rotation=0,
y_label="Price ($)",
y_format="Decimal",
y_format_decimal_places=1,
y_tick_spacing="Auto",
y_tick_rotation=0,
caption="Data Source: Yahoo Finance, Analysis by the Author",
)

From the summary stats and the plot, we can see that the BS strategy did not materially change the performance. It might have had a risk-mitigation effect during COVID, but ultimately is not designed to handle nor is effective during long prolonged drawdowns.
Extension 3: Using UPRO and AGG #
For our third extention, we’ll examine the performance of a portfolio consisting of UPRO and AGG, to see how it compares to the previous strategies.
One might say that UPRO is a more “well rounded” fund than TQQQ, in that it includes not only tech but other sectors of the market as well. This diversification could potentially reduce the overall volatility of the portfolio while still providing leveraged exposure to the broader market.
Similarly, AGG (the iShares Core U.S. Aggregate Bond ETF) provides exposure to the bond market, which tends to have a low correlation with equities. Using a non-leveraged fund will likely reduce the performance, but it might also help stabilize the volatility and provide a smoother overall return profile.
To compare with the TQQQ/TMF strategy over the same period, we’ll use the same start date of 2/11/2010, even though UPRO has data back to June 2009.
First, we need to pull data for AGG.
yf_pull_data(
base_directory=DATA_DIR,
ticker="AGG",
adjusted=False,
source="Yahoo_Finance",
asset_class="Exchange_Traded_Funds",
excel_export=True,
pickle_export=True,
output_confirmation=False,
)
| Adj Close | Close | High | Low | Open | Volume | |
|---|---|---|---|---|---|---|
| Date | ||||||
| 2003-09-29 | 48.74 | 102.17 | 102.30 | 102.10 | 102.29 | 13600 |
| 2003-09-30 | 49.00 | 102.70 | 102.74 | 102.29 | 102.30 | 62600 |
| 2003-10-01 | 48.97 | 102.65 | 102.75 | 102.60 | 102.64 | 66300 |
| 2003-10-02 | 48.90 | 102.49 | 102.65 | 102.01 | 102.20 | 68900 |
| 2003-10-03 | 48.54 | 101.75 | 102.05 | 101.70 | 102.05 | 64500 |
| ... | ... | ... | ... | ... | ... | ... |
| 2026-10-16 | 95.38 | 95.38 | 95.58 | 95.22 | 95.29 | 11389000 |
| 2026-10-19 | 95.11 | 95.11 | 95.39 | 95.04 | 95.34 | 10400500 |
| 2026-10-20 | 95.27 | 95.27 | 95.51 | 95.16 | 95.31 | 9952700 |
| 2026-10-21 | 95.30 | 95.30 | 95.50 | 95.11 | 95.40 | 9833700 |
| 2026-10-22 | 96.12 | 96.12 | 96.23 | 95.62 | 95.69 | 10825400 |
5804 rows × 6 columns
Then append it to the existing dictionary.
fund_list.append("AGG")
# Load the data for each ETF, rename the columns to include the ticker, store it in the fund_data dictionary
for fund in fund_list:
data = load_data(
base_directory=DATA_DIR,
ticker=fund,
source="Yahoo_Finance",
asset_class="Exchange_Traded_Funds",
timeframe="Daily",
file_format="pickle",
)
data = data.rename(
columns={
"Adj Close": f"{fund}_Adj_Close",
"Close": f"{fund}_Close",
"High": f"{fund}_High",
"Low": f"{fund}_Low",
"Open": f"{fund}_Open",
"Volume": f"{fund}_Volume",
}
)
fund_data[fund] = data
Now we can run the strategy.
upro_agg_full = rebalance_strategy(
fund_data=fund_data,
fund_1="UPRO",
fund_2="AGG",
fund_1_weight=0.5,
rebalance_months=2,
transaction_fee=0.0025, # 0.25% transaction fee
start_date="2010-02-11",
end_date=None,
)
print("Table E27: UPRO/AGG Rebalance Strategy Data")
display(upro_agg_full)
Table E27: UPRO/AGG Rebalance Strategy Data
| UPRO_Adj_Close | AGG_Adj_Close | UPRO_Return | AGG_Return | UPRO_Drawdown | AGG_Drawdown | UPRO_Drawdown_Daily_Max | AGG_Drawdown_Daily_Max | UPRO_Value | AGG_Value | Strategy_Adj_Close | Strategy_Return | Strategy_Drawdown | Strategy_Drawdown_Daily_Max | Rebalance | Transaction_Fee | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Date | ||||||||||||||||
| 2010-02-11 | 1.00 | 1.00 | NaN | NaN | 0.00 | 0.00 | 0.00 | 0.00 | 0.50 | 0.50 | 1.00 | NaN | 0.00 | 0.00 | False | 0.00 |
| 2010-02-12 | 1.00 | 1.00 | -0.00 | 0.00 | -0.00 | 0.00 | -0.00 | 0.00 | 0.50 | 0.50 | 1.00 | -0.00 | -0.00 | -0.00 | False | 0.00 |
| 2010-02-16 | 1.04 | 1.00 | 0.05 | 0.00 | 0.00 | 0.00 | -0.00 | 0.00 | 0.52 | 0.50 | 1.02 | 0.03 | 0.00 | -0.00 | False | 0.00 |
| 2010-02-17 | 1.06 | 1.00 | 0.01 | -0.00 | 0.00 | -0.00 | -0.00 | -0.00 | 0.53 | 0.50 | 1.03 | 0.01 | 0.00 | -0.00 | False | 0.00 |
| 2010-02-18 | 1.08 | 1.00 | 0.02 | -0.00 | 0.00 | -0.00 | -0.00 | -0.00 | 0.54 | 0.50 | 1.04 | 0.01 | 0.00 | -0.00 | False | 0.00 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 2026-10-16 | 82.92 | 1.47 | -0.03 | -0.00 | -0.07 | -0.04 | -0.77 | -0.18 | 8.44 | 8.58 | 17.03 | -0.02 | -0.04 | -0.43 | False | 0.00 |
| 2026-10-19 | 83.96 | 1.46 | 0.01 | -0.00 | -0.06 | -0.04 | -0.77 | -0.18 | 8.55 | 8.56 | 17.11 | 0.00 | -0.04 | -0.43 | False | 0.00 |
| 2026-10-20 | 84.70 | 1.47 | 0.01 | 0.00 | -0.05 | -0.04 | -0.77 | -0.18 | 8.63 | 8.57 | 17.20 | 0.01 | -0.03 | -0.43 | False | 0.00 |
| 2026-10-21 | 86.08 | 1.47 | 0.02 | 0.00 | -0.03 | -0.04 | -0.77 | -0.18 | 8.77 | 8.57 | 17.34 | 0.01 | -0.03 | -0.43 | False | 0.00 |
| 2026-10-22 | 82.98 | 1.48 | -0.04 | 0.01 | -0.07 | -0.03 | -0.77 | -0.18 | 8.45 | 8.65 | 17.10 | -0.01 | -0.04 | -0.43 | False | 0.00 |
4200 rows × 16 columns
upro_agg_full_sum_stats = summary_stats(
fund_list=["UPRO", "AGG"],
df=upro_agg_full[["Strategy_Return"]].dropna(),
period="Daily",
use_calendar_days=False,
excel_export=False,
pickle_export=False,
output_confirmation=False,
)
# Rename the index to indicate the strategy
upro_agg_full_sum_stats.index = ["UPRO/AGG Full Strategy"]
all_strat_sum_stats = pd.concat([all_strat_sum_stats, upro_agg_full_sum_stats])
print("Table E28: Strategy Summary Statistics")
display(all_strat_sum_stats)
Table E28: Strategy Summary Statistics
| Number of Observations | Data Start Date | Data End Date | Annual Mean Return (Arithmetic) | Annualized Volatility | Annualized Sharpe Ratio | CAGR (Geometric) | Daily Max Return | Daily Max Return (Date) | Daily Min Return | Daily Min Return (Date) | Max Drawdown | Peak | Trough | Recovery Date | Calendar Days to Recovery | MAR Ratio | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| TQQQ/TMF Strategy | 2679 | 2010-02-12 | 2020-10-02 | 0.39 | 0.28 | 1.40 | 0.42 | 0.11 | 2020-03-20 | -0.16 | 2020-03-18 | -0.42 | 2020-03-06 | 2020-03-18 | 2020-05-07 | 50.00 | 1.00 |
| TQQQ | 2679 | 2010-02-12 | 2020-10-02 | 0.57 | 0.58 | 0.99 | 0.50 | 0.27 | 2020-03-13 | -0.34 | 2020-03-16 | -0.70 | 2020-02-19 | 2020-03-20 | 2020-07-10 | 112.00 | 0.71 |
| TMF | 2679 | 2010-02-12 | 2020-10-02 | 0.26 | 0.44 | 0.59 | 0.18 | 0.20 | 2020-03-20 | -0.18 | 2020-03-18 | -0.53 | 2012-07-25 | 2013-08-21 | 2015-01-05 | 502.00 | 0.33 |
| TQQQ/TMF BS | 2679 | 2010-02-12 | 2020-10-02 | 0.38 | 0.26 | 1.48 | 0.41 | 0.07 | 2020-03-06 | -0.09 | 2020-09-03 | -0.30 | 2018-08-29 | 2018-12-24 | 2019-04-23 | 120.00 | 1.38 |
| TQQQ/TMF Full Strategy | 4199 | 2010-02-12 | 2026-10-22 | 0.27 | 0.34 | 0.80 | 0.24 | 0.17 | 2022-11-10 | -0.16 | 2020-03-18 | -0.78 | 2021-11-19 | 2022-11-09 | NaT | NaN | 0.30 |
| TQQQ/TMF Full BS | 4199 | 2010-02-12 | 2026-10-22 | 0.26 | 0.32 | 0.81 | 0.23 | 0.17 | 2022-11-10 | -0.12 | 2022-05-05 | -0.78 | 2021-11-19 | 2022-11-09 | NaT | NaN | 0.30 |
| UPRO/AGG Full Strategy | 4199 | 2010-02-12 | 2026-10-22 | 0.20 | 0.24 | 0.83 | 0.19 | 0.12 | 2025-04-09 | -0.13 | 2020-03-12 | -0.43 | 2021-12-27 | 2022-10-14 | 2024-06-12 | 607.00 | 0.43 |
Comparing the last 3 lines from above gives us an interesting result. Our CAGR for the UPRO/AGG strategy is lower than that of the TQQQ/TMF strategy, but the volatility and drawdowns are both significantly reduced, leading to a more stable overall performance, and an improved MAR ratio.
And now the plots for the UPRO/AGG strategy.
plot_time_series(
df=upro_agg_full,
plot_start_date=None,
plot_end_date=None,
plot_columns=["UPRO_Adj_Close", "AGG_Adj_Close", "Strategy_Adj_Close"],
title=f"Figure E7: Rebalance Strategy, UPRO, and AGG Adjusted Close Price (Normalized) as of {upro_agg_full.index[-1].date()}",
x_label="Date",
x_format="Year",
x_tick_spacing=1,
x_tick_start=None,
x_tick_rotation=0,
y_label="Price ($)",
y_format="Decimal",
y_format_decimal_places=1,
y_tick_spacing="Auto",
y_tick_rotation=0,
caption="Data Source: Yahoo Finance, Analysis by the Author",
)

plot_time_series(
df=upro_agg_full,
plot_start_date=None,
plot_end_date=None,
plot_columns=["UPRO_Drawdown", "AGG_Drawdown", "Strategy_Drawdown"],
title=f"Figure E8: Rebalance Strategy, UPRO, and AGG Daily Drawdowns as of {upro_agg_full.index[-1].date()}",
x_label="Date",
x_format="Year",
x_tick_spacing=1,
x_tick_start=None,
x_tick_rotation=0,
y_label="Drawdown",
y_format="Percentage",
y_format_decimal_places=0,
y_tick_spacing="Auto",
y_tick_rotation=0,
caption="Data Source: Yahoo Finance, Analysis by the Author",
)

plot_time_series(
df=upro_agg_full,
plot_start_date=None,
plot_end_date=None,
plot_columns=[
"UPRO_Drawdown_Daily_Max",
"AGG_Drawdown_Daily_Max",
"Strategy_Drawdown_Daily_Max",
],
title=f"Figure E9: Rebalance Strategy, UPRO, and AGG Maximum Daily Drawdowns as of {upro_agg_full.index[-1].date()}",
x_label="Date",
x_format="Year",
x_tick_spacing=1,
x_tick_start=None,
x_tick_rotation=0,
y_label="Drawdown",
y_format="Percentage",
y_format_decimal_places=0,
y_tick_spacing="Auto",
y_tick_rotation=0,
caption="Data Source: Yahoo Finance, Analysis by the Author",
)

Extension 4: Extrapolating UPRO Back To 1993 #
UPRO only has data back to its inception on 6/25/2009, which means that all of the results above come from a period that excludes both the dot-com crash and the 2008 financial crisis. To see how the strategy might have performed through those periods, we will extrapolate the returns for UPRO back to the start of SPY in January 1993, using a regression of the daily returns of UPRO on those of SPY over the period where both funds have data. Before the inception of UPRO we use the extrapolated data, and after its inception we use the actual data.
For the bond fund, we will use VBMFX (the Vanguard Total Bond Market Index Fund), which tracks the same index as AGG but has data back to 1986. We will then run the strategy on both UPRO/VBMFX and SPY/VBMFX from 1993 to present.
First, we need to pull data for VBMFX.
yf_pull_data(
base_directory=DATA_DIR,
ticker="VBMFX",
adjusted=False,
source="Yahoo_Finance",
asset_class="Mutual_Funds",
excel_export=True,
pickle_export=True,
output_confirmation=False,
)
| Adj Close | Close | High | Low | Open | Volume | |
|---|---|---|---|---|---|---|
| Date | ||||||
| 1986-12-11 | 1.38 | 10.00 | 10.00 | 10.00 | 10.00 | 0 |
| 1986-12-12 | 1.38 | 10.00 | 10.00 | 10.00 | 10.00 | 0 |
| 1986-12-15 | 1.37 | 9.97 | 9.97 | 9.97 | 9.97 | 0 |
| 1986-12-16 | 1.38 | 10.00 | 10.00 | 10.00 | 10.00 | 0 |
| 1986-12-17 | 1.38 | 10.00 | 10.00 | 10.00 | 10.00 | 0 |
| ... | ... | ... | ... | ... | ... | ... |
| 2026-10-16 | 9.32 | 9.32 | 9.32 | 9.32 | 9.32 | 0 |
| 2026-10-19 | 9.29 | 9.29 | 9.29 | 9.29 | 9.29 | 0 |
| 2026-10-20 | 9.30 | 9.30 | 9.30 | 9.30 | 9.30 | 0 |
| 2026-10-21 | 9.30 | 9.30 | 9.30 | 9.30 | 9.30 | 0 |
| 2026-10-22 | 9.38 | 9.38 | 9.38 | 9.38 | 9.38 | 0 |
10042 rows × 6 columns
Then add it to the existing dictionary. Since VBMFX is a mutual fund rather than an ETF, we load it separately.
data = load_data(
base_directory=DATA_DIR,
ticker="VBMFX",
source="Yahoo_Finance",
asset_class="Mutual_Funds",
timeframe="Daily",
file_format="pickle",
)
data = data.rename(
columns={
"Adj Close": "VBMFX_Adj_Close",
"Close": "VBMFX_Close",
"High": "VBMFX_High",
"Low": "VBMFX_Low",
"Open": "VBMFX_Open",
"Volume": "VBMFX_Volume",
}
)
fund_data["VBMFX"] = data
Next, we run a regression of the daily returns of UPRO on the daily returns of SPY, over the period where both funds have data. The slope (beta) is the leverage that UPRO actually delivers, and the constant (alpha) captures the daily costs of the fund, including financing and fees.
# Merge UPRO and SPY over the period where both funds have data
upro_spy = fund_data["UPRO"][["UPRO_Adj_Close"]].merge(
fund_data["SPY"][["SPY_Adj_Close"]], left_index=True, right_index=True
)
upro_spy["UPRO_Return"] = upro_spy["UPRO_Adj_Close"].pct_change()
upro_spy["SPY_Return"] = upro_spy["SPY_Adj_Close"].pct_change()
upro_spy = upro_spy.dropna()
upro_spy_model = run_regression(
df=upro_spy,
x_plot_column="SPY_Return",
y_plot_column="UPRO_Return",
regression_model="OLS-statsmodels",
regression_constant=True,
)
alpha = upro_spy_model.params.iloc[0]
beta = upro_spy_model.params.iloc[1]
print("Table E29: Regression of UPRO Daily Returns on SPY Daily Returns")
display(upro_spy_model.summary())
Table E29: Regression of UPRO Daily Returns on SPY Daily Returns
| Dep. Variable: | UPRO_Return | R-squared: | 0.997 |
|---|---|---|---|
| Model: | OLS | Adj. R-squared: | 0.997 |
| Method: | Least Squares | F-statistic: | 1.257e+06 |
| Date: | Sat, 24 Oct 2026 | Prob (F-statistic): | 0.00 |
| Time: | 18:28:23 | Log-Likelihood: | 21156. |
| No. Observations: | 4358 | AIC: | -4.231e+04 |
| Df Residuals: | 4356 | BIC: | -4.230e+04 |
| Df Model: | 1 | ||
| Covariance Type: | nonrobust |
| coef | std err | t | P>|t| | [0.025 | 0.975] | |
|---|---|---|---|---|---|---|
| const | -0.0002 | 2.86e-05 | -6.754 | 0.000 | -0.000 | -0.000 |
| SPY_Return | 2.9894 | 0.003 | 1121.375 | 0.000 | 2.984 | 2.995 |
| Omnibus: | 2146.529 | Durbin-Watson: | 2.969 |
|---|---|---|---|
| Prob(Omnibus): | 0.000 | Jarque-Bera (JB): | 3412005.980 |
| Skew: | -0.729 | Prob(JB): | 0.00 |
| Kurtosis: | 140.070 | Cond. No. | 93.3 |
Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.
Before extrapolating, we can check how well the regression fits the actual UPRO data. Over the period where both funds have data, we compare the CAGR of the actual UPRO returns with the CAGR of the returns constructed from SPY, using only the beta, and using both the alpha and the beta.
# Compare the actual UPRO returns with the returns constructed from SPY
fit_check = pd.DataFrame(
{
"Actual UPRO": upro_spy["UPRO_Return"],
"Beta x SPY": beta * upro_spy["SPY_Return"],
"Alpha + Beta x SPY": alpha + beta * upro_spy["SPY_Return"],
}
)
years = len(fit_check) / 252
fit_check_cagr = ((1 + fit_check).prod() ** (1 / years) - 1).to_frame(name="CAGR")
print("Table E30: CAGR of Actual UPRO vs Returns Constructed From SPY")
display(fit_check_cagr)
Table E30: CAGR of Actual UPRO vs Returns Constructed From SPY
| CAGR | |
|---|---|
| Actual UPRO | 0.33 |
| Beta x SPY | 0.39 |
| Alpha + Beta x SPY | 0.33 |
The table above tells us that we have an adequate fit for the regression.
Next, we use the alpha and beta to extrapolate the UPRO returns back to the start of SPY, and then walk the price backwards from the first actual price of UPRO. Here is the data around the inception of UPRO after the extrapolation:
# Merge SPY with UPRO over the full history of SPY
upro_extrap = fund_data["SPY"][["SPY_Adj_Close"]].merge(
fund_data["UPRO"][["UPRO_Adj_Close"]], left_index=True, right_index=True, how="left"
)
upro_extrap["SPY_Return"] = upro_extrap["SPY_Adj_Close"].pct_change()
upro_extrap["UPRO_Return"] = upro_extrap["UPRO_Adj_Close"].pct_change()
# Before the inception of UPRO, fill the returns using the regression coefficients
upro_extrap["UPRO_Return"] = upro_extrap["UPRO_Return"].fillna(
alpha + beta * upro_extrap["SPY_Return"]
)
# Walk backwards from the first actual price of UPRO: Price_{t} = Price_{t+1} / (1 + Return_{t+1})
first_valid_idx = upro_extrap["UPRO_Adj_Close"].first_valid_index()
splice_loc = upro_extrap.index.get_loc(first_valid_idx)
for i in range(splice_loc - 1, -1, -1):
current_return = upro_extrap.iloc[i + 1]["UPRO_Return"]
next_price = upro_extrap.iloc[i + 1]["UPRO_Adj_Close"]
upro_extrap.loc[upro_extrap.index[i], "UPRO_Adj_Close"] = next_price / (
1 + current_return
)
# Store as a separate fund so the actual UPRO data is unchanged
fund_data["UPRO_Extrap"] = upro_extrap[["UPRO_Adj_Close"]].rename(
columns={"UPRO_Adj_Close": "UPRO_Extrap_Adj_Close"}
)
print("Table E31: Extrapolated UPRO Data Around the Inception of UPRO")
display(upro_extrap.iloc[splice_loc - 3 : splice_loc + 3])
Table E31: Extrapolated UPRO Data Around the Inception of UPRO
| SPY_Adj_Close | UPRO_Adj_Close | SPY_Return | UPRO_Return | |
|---|---|---|---|---|
| Date | ||||
| 2009-06-22 | 65.80 | 1.03 | -0.03 | -0.09 |
| 2009-06-23 | 65.85 | 1.03 | 0.00 | 0.00 |
| 2009-06-24 | 66.41 | 1.06 | 0.01 | 0.03 |
| 2009-06-25 | 67.86 | 1.13 | 0.02 | 0.06 |
| 2009-06-26 | 67.68 | 1.12 | -0.00 | -0.01 |
| 2009-06-29 | 68.32 | 1.15 | 0.01 | 0.03 |
And now we can run the strategy for both UPRO/VBMFX and SPY/VBMFX, from 1993 to present.
upro_extrap_vbmfx_full = rebalance_strategy(
fund_data=fund_data,
fund_1="UPRO_Extrap",
fund_2="VBMFX",
fund_1_weight=0.5,
rebalance_months=2,
transaction_fee=0.0025, # 0.25% transaction fee
start_date=None,
end_date=None,
)
print("Table E32: UPRO (Extrap)/VBMFX Rebalance Strategy Data")
display(upro_extrap_vbmfx_full)
spy_vbmfx_full = rebalance_strategy(
fund_data=fund_data,
fund_1="SPY",
fund_2="VBMFX",
fund_1_weight=0.5,
rebalance_months=2,
transaction_fee=0.0025, # 0.25% transaction fee
start_date=None,
end_date=None,
)
print("Table E33: SPY/VBMFX Rebalance Strategy Data")
display(spy_vbmfx_full)
Table E32: UPRO (Extrap)/VBMFX Rebalance Strategy Data
| UPRO_Extrap_Adj_Close | VBMFX_Adj_Close | UPRO_Extrap_Return | VBMFX_Return | UPRO_Extrap_Drawdown | VBMFX_Drawdown | UPRO_Extrap_Drawdown_Daily_Max | VBMFX_Drawdown_Daily_Max | UPRO_Extrap_Value | VBMFX_Value | Strategy_Adj_Close | Strategy_Return | Strategy_Drawdown | Strategy_Drawdown_Daily_Max | Rebalance | Transaction_Fee | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Date | ||||||||||||||||
| 1993-01-29 | 1.00 | 1.00 | NaN | NaN | 0.00 | 0.00 | 0.00 | 0.00 | 0.50 | 0.50 | 1.00 | NaN | 0.00 | 0.00 | False | 0.00 |
| 1993-02-01 | 1.02 | 1.00 | 0.02 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.51 | 0.50 | 1.01 | 0.01 | 0.00 | 0.00 | False | 0.00 |
| 1993-02-02 | 1.03 | 1.00 | 0.01 | -0.00 | 0.00 | -0.00 | 0.00 | -0.00 | 0.51 | 0.50 | 1.01 | 0.00 | 0.00 | 0.00 | False | 0.00 |
| 1993-02-03 | 1.06 | 1.00 | 0.03 | 0.00 | 0.00 | -0.00 | 0.00 | -0.00 | 0.53 | 0.50 | 1.03 | 0.02 | 0.00 | 0.00 | False | 0.00 |
| 1993-02-04 | 1.07 | 1.00 | 0.01 | 0.00 | 0.00 | 0.00 | 0.00 | -0.00 | 0.54 | 0.50 | 1.04 | 0.01 | 0.00 | 0.00 | False | 0.00 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 2026-10-16 | 183.52 | 4.00 | -0.03 | 0.00 | -0.07 | -0.05 | -0.98 | -0.19 | 31.96 | 32.27 | 64.23 | -0.02 | -0.04 | -0.72 | False | 0.00 |
| 2026-10-19 | 185.82 | 3.99 | 0.01 | -0.00 | -0.06 | -0.05 | -0.98 | -0.19 | 32.37 | 32.17 | 64.53 | 0.00 | -0.04 | -0.72 | False | 0.00 |
| 2026-10-20 | 187.46 | 4.00 | 0.01 | 0.00 | -0.05 | -0.05 | -0.98 | -0.19 | 32.65 | 32.20 | 64.85 | 0.00 | -0.04 | -0.72 | False | 0.00 |
| 2026-10-21 | 190.51 | 4.00 | 0.02 | 0.00 | -0.03 | -0.05 | -0.98 | -0.19 | 33.18 | 32.20 | 65.38 | 0.01 | -0.03 | -0.72 | False | 0.00 |
| 2026-10-22 | 183.64 | 4.03 | -0.04 | 0.01 | -0.07 | -0.04 | -0.98 | -0.19 | 31.99 | 32.48 | 64.46 | -0.01 | -0.04 | -0.72 | False | 0.00 |
8491 rows × 16 columns
Table E33: SPY/VBMFX Rebalance Strategy Data
| SPY_Adj_Close | VBMFX_Adj_Close | SPY_Return | VBMFX_Return | SPY_Drawdown | VBMFX_Drawdown | SPY_Drawdown_Daily_Max | VBMFX_Drawdown_Daily_Max | SPY_Value | VBMFX_Value | Strategy_Adj_Close | Strategy_Return | Strategy_Drawdown | Strategy_Drawdown_Daily_Max | Rebalance | Transaction_Fee | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Date | ||||||||||||||||
| 1993-01-29 | 1.00 | 1.00 | NaN | NaN | 0.00 | 0.00 | 0.00 | 0.00 | 0.50 | 0.50 | 1.00 | NaN | 0.00 | 0.00 | False | 0.00 |
| 1993-02-01 | 1.01 | 1.00 | 0.01 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.50 | 0.50 | 1.00 | 0.00 | 0.00 | 0.00 | False | 0.00 |
| 1993-02-02 | 1.01 | 1.00 | 0.00 | -0.00 | 0.00 | -0.00 | 0.00 | -0.00 | 0.50 | 0.50 | 1.00 | 0.00 | 0.00 | 0.00 | False | 0.00 |
| 1993-02-03 | 1.02 | 1.00 | 0.01 | 0.00 | 0.00 | -0.00 | 0.00 | -0.00 | 0.51 | 0.50 | 1.01 | 0.01 | 0.00 | 0.00 | False | 0.00 |
| 1993-02-04 | 1.02 | 1.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | -0.00 | 0.51 | 0.50 | 1.01 | 0.00 | 0.00 | 0.00 | False | 0.00 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 2026-10-16 | 31.73 | 4.00 | -0.01 | 0.00 | -0.02 | -0.05 | -0.55 | -0.19 | 6.13 | 6.06 | 12.19 | -0.01 | -0.02 | -0.30 | False | 0.00 |
| 2026-10-19 | 31.87 | 3.99 | 0.00 | -0.00 | -0.02 | -0.05 | -0.55 | -0.19 | 6.16 | 6.04 | 12.19 | 0.00 | -0.02 | -0.30 | False | 0.00 |
| 2026-10-20 | 31.97 | 4.00 | 0.00 | 0.00 | -0.01 | -0.05 | -0.55 | -0.19 | 6.18 | 6.04 | 12.22 | 0.00 | -0.01 | -0.30 | False | 0.00 |
| 2026-10-21 | 32.14 | 4.00 | 0.01 | 0.00 | -0.01 | -0.05 | -0.55 | -0.19 | 6.21 | 6.04 | 12.25 | 0.00 | -0.01 | -0.30 | False | 0.00 |
| 2026-10-22 | 31.76 | 4.03 | -0.01 | 0.01 | -0.02 | -0.04 | -0.55 | -0.19 | 6.14 | 6.10 | 12.23 | -0.00 | -0.01 | -0.30 | False | 0.00 |
8491 rows × 16 columns
upro_extrap_vbmfx_full_sum_stats = summary_stats(
fund_list=["UPRO (Extrap)/VBMFX"],
df=upro_extrap_vbmfx_full[["Strategy_Return"]].dropna(),
period="Daily",
use_calendar_days=False,
excel_export=False,
pickle_export=False,
output_confirmation=False,
)
# Rename the index to indicate the strategy
upro_extrap_vbmfx_full_sum_stats.index = ["UPRO (Extrap)/VBMFX Full Strategy"]
all_strat_sum_stats = pd.concat([all_strat_sum_stats, upro_extrap_vbmfx_full_sum_stats])
upro_extrap_sum_stats = summary_stats(
fund_list=["UPRO (Extrap)/VBMFX"],
df=upro_extrap_vbmfx_full[["UPRO_Extrap_Return"]].dropna(),
period="Daily",
use_calendar_days=False,
excel_export=False,
pickle_export=False,
output_confirmation=False,
)
# Rename the index to indicate the strategy
upro_extrap_sum_stats.index = ["UPRO (Extrap)"]
all_strat_sum_stats = pd.concat([all_strat_sum_stats, upro_extrap_sum_stats])
spy_vbmfx_full_sum_stats = summary_stats(
fund_list=["SPY/VBMFX"],
df=spy_vbmfx_full[["Strategy_Return"]].dropna(),
period="Daily",
use_calendar_days=False,
excel_export=False,
pickle_export=False,
output_confirmation=False,
)
# Rename the index to indicate the strategy
spy_vbmfx_full_sum_stats.index = ["SPY/VBMFX Full Strategy"]
all_strat_sum_stats = pd.concat([all_strat_sum_stats, spy_vbmfx_full_sum_stats])
spy_sum_stats = summary_stats(
fund_list=["SPY"],
df=spy_vbmfx_full[["SPY_Return"]].dropna(),
period="Daily",
use_calendar_days=False,
excel_export=False,
pickle_export=False,
output_confirmation=False,
)
# Rename the index to indicate the strategy
spy_sum_stats.index = ["SPY"]
all_strat_sum_stats = pd.concat([all_strat_sum_stats, spy_sum_stats])
print("Table E34: Strategy Summary Statistics")
display(all_strat_sum_stats)
Table E34: Strategy Summary Statistics
| Number of Observations | Data Start Date | Data End Date | Annual Mean Return (Arithmetic) | Annualized Volatility | Annualized Sharpe Ratio | CAGR (Geometric) | Daily Max Return | Daily Max Return (Date) | Daily Min Return | Daily Min Return (Date) | Max Drawdown | Peak | Trough | Recovery Date | Calendar Days to Recovery | MAR Ratio | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| TQQQ/TMF Strategy | 2679 | 2010-02-12 | 2020-10-02 | 0.39 | 0.28 | 1.40 | 0.42 | 0.11 | 2020-03-20 | -0.16 | 2020-03-18 | -0.42 | 2020-03-06 | 2020-03-18 | 2020-05-07 | 50.00 | 1.00 |
| TQQQ | 2679 | 2010-02-12 | 2020-10-02 | 0.57 | 0.58 | 0.99 | 0.50 | 0.27 | 2020-03-13 | -0.34 | 2020-03-16 | -0.70 | 2020-02-19 | 2020-03-20 | 2020-07-10 | 112.00 | 0.71 |
| TMF | 2679 | 2010-02-12 | 2020-10-02 | 0.26 | 0.44 | 0.59 | 0.18 | 0.20 | 2020-03-20 | -0.18 | 2020-03-18 | -0.53 | 2012-07-25 | 2013-08-21 | 2015-01-05 | 502.00 | 0.33 |
| TQQQ/TMF BS | 2679 | 2010-02-12 | 2020-10-02 | 0.38 | 0.26 | 1.48 | 0.41 | 0.07 | 2020-03-06 | -0.09 | 2020-09-03 | -0.30 | 2018-08-29 | 2018-12-24 | 2019-04-23 | 120.00 | 1.38 |
| TQQQ/TMF Full Strategy | 4199 | 2010-02-12 | 2026-10-22 | 0.27 | 0.34 | 0.80 | 0.24 | 0.17 | 2022-11-10 | -0.16 | 2020-03-18 | -0.78 | 2021-11-19 | 2022-11-09 | NaT | NaN | 0.30 |
| TQQQ/TMF Full BS | 4199 | 2010-02-12 | 2026-10-22 | 0.26 | 0.32 | 0.81 | 0.23 | 0.17 | 2022-11-10 | -0.12 | 2022-05-05 | -0.78 | 2021-11-19 | 2022-11-09 | NaT | NaN | 0.30 |
| UPRO/AGG Full Strategy | 4199 | 2010-02-12 | 2026-10-22 | 0.20 | 0.24 | 0.83 | 0.19 | 0.12 | 2025-04-09 | -0.13 | 2020-03-12 | -0.43 | 2021-12-27 | 2022-10-14 | 2024-06-12 | 607.00 | 0.43 |
| UPRO (Extrap)/VBMFX Full Strategy | 8490 | 1993-02-01 | 2026-10-22 | 0.16 | 0.26 | 0.61 | 0.13 | 0.11 | 2009-03-23 | -0.14 | 2020-03-16 | -0.72 | 2007-10-09 | 2009-03-09 | 2013-01-18 | 1411.00 | 0.18 |
| UPRO (Extrap) | 8490 | 1993-02-01 | 2026-10-22 | 0.31 | 0.55 | 0.56 | 0.17 | 0.43 | 2008-10-13 | -0.35 | 2020-03-16 | -0.98 | 2000-03-24 | 2009-03-09 | 2017-11-28 | 3186.00 | 0.17 |
| SPY/VBMFX Full Strategy | 8490 | 1993-02-01 | 2026-10-22 | 0.08 | 0.09 | 0.86 | 0.08 | 0.06 | 2008-10-13 | -0.05 | 2020-03-12 | -0.30 | 2007-10-09 | 2009-03-09 | 2010-04-15 | 402.00 | 0.26 |
| SPY | 8490 | 1993-02-01 | 2026-10-22 | 0.12 | 0.19 | 0.65 | 0.11 | 0.15 | 2008-10-13 | -0.11 | 2020-03-16 | -0.55 | 2007-10-09 | 2009-03-09 | 2012-08-16 | 1256.00 | 0.20 |
To make the comparison as easy as possible, we will plot both the UPRO (Extrap)/VBMFX strategy and the actual SPY/VBMFX strategy together, along with each of the individual components.
plot_time_series(
df=upro_extrap_vbmfx_full.merge(
spy_vbmfx_full,
on="Date",
how="outer",
suffixes=("_UPRO_Extrap_VBMFX", "_SPY_VBMFX"),
).rename(columns={"VBMFX_Adj_Close_UPRO_Extrap_VBMFX": "VBMFX_Adj_Close"}),
plot_start_date=None,
plot_end_date=None,
plot_columns=[
"UPRO_Extrap_Adj_Close",
"SPY_Adj_Close",
"VBMFX_Adj_Close",
"Strategy_Adj_Close_UPRO_Extrap_VBMFX",
"Strategy_Adj_Close_SPY_VBMFX",
],
title=f"Figure E10: Rebalance Strategies, UPRO (Extrap), SPY, and VBMFX Adjusted Close Price (Normalized) as of {upro_extrap_vbmfx_full.index[-1].date()}",
x_label="Date",
x_format="Year",
x_tick_spacing=2,
x_tick_start=None,
x_tick_rotation=0,
y_label="Price ($)",
y_format="Decimal",
y_format_decimal_places=0,
y_tick_spacing="Auto",
y_tick_rotation=0,
caption="Data Source: Yahoo Finance, Analysis by the Author",
)

plot_time_series(
df=upro_extrap_vbmfx_full.merge(
spy_vbmfx_full,
on="Date",
how="outer",
suffixes=("_UPRO_Extrap_VBMFX", "_SPY_VBMFX"),
).rename(columns={"VBMFX_Drawdown_UPRO_Extrap_VBMFX": "VBMFX_Drawdown"}),
plot_start_date=None,
plot_end_date=None,
plot_columns=[
"UPRO_Extrap_Drawdown",
"SPY_Drawdown",
"VBMFX_Drawdown",
"Strategy_Drawdown_UPRO_Extrap_VBMFX",
"Strategy_Drawdown_SPY_VBMFX",
],
title=f"Figure E11: Rebalance Strategies, UPRO (Extrap), SPY, and VBMFX Daily Drawdowns as of {upro_extrap_vbmfx_full.index[-1].date()}",
x_label="Date",
x_format="Year",
x_tick_spacing=2,
x_tick_start=None,
x_tick_rotation=0,
y_label="Drawdown",
y_format="Percentage",
y_format_decimal_places=0,
y_tick_spacing="Auto",
y_tick_rotation=0,
caption="Data Source: Yahoo Finance, Analysis by the Author",
)

plot_time_series(
df=upro_extrap_vbmfx_full.merge(
spy_vbmfx_full,
on="Date",
how="outer",
suffixes=("_UPRO_Extrap_VBMFX", "_SPY_VBMFX"),
).rename(
columns={
"VBMFX_Drawdown_Daily_Max_UPRO_Extrap_VBMFX": "VBMFX_Drawdown_Daily_Max"
}
),
plot_start_date=None,
plot_end_date=None,
plot_columns=[
"UPRO_Extrap_Drawdown_Daily_Max",
"SPY_Drawdown_Daily_Max",
"VBMFX_Drawdown_Daily_Max",
"Strategy_Drawdown_Daily_Max_UPRO_Extrap_VBMFX",
"Strategy_Drawdown_Daily_Max_SPY_VBMFX",
],
title=f"Figure E12: Rebalance Strategies, UPRO (Extrap), SPY, and VBMFX Maximum Daily Drawdowns as of {upro_extrap_vbmfx_full.index[-1].date()}",
x_label="Date",
x_format="Year",
x_tick_spacing=2,
x_tick_start=None,
x_tick_rotation=0,
y_label="Drawdown",
y_format="Percentage",
y_format_decimal_places=0,
y_tick_spacing="Auto",
y_tick_rotation=0,
caption="Data Source: Yahoo Finance, Analysis by the Author",
)

Conclusion #
First, the way the paper handles the different dates for the CAGR, DDD, and MDD is quite unusual and requires careful consideration when attempting to reproduce the results accurately. Typically, one would expect a specific data range to be consistently applied across all metrics to ensure comparability and validity of the results. The unusual way that the paper handled the dates actually required quite a bit of extra effort to align the data correctly and update the summary stats tables with additional fields to accurately portray what was being calculated in the paper.
Second, the pseudo-code for the “black swan” filter in figure 11 does not match the results reported in the paper. The pseudo-code sets the switch-back price $v^{\ast}$ to the price of TQQQ on the day of the drop, $v(n)$, but implemented that way, the filter switches back to the standard strategy the very next day and whipsaws in and out of IEF three times during March 2020. Using the price of TQQQ on the day before the drop, $v(n-1)$, instead reproduces both the 5/18/2020 switch-back date and the 30% maximum DDD reported in the paper. When the pseudo-code and the reported results disagree, the results (and the paper’s written description of the method) are the better guide for a replication.
Future Investigation #
Nothing here yet.
References #
- Glenn, Lewis A., Long-Term Investing in Triple Leveraged Exchange Traded Funds (October 30, 2020). Available at SSRN: https://ssrn.com/abstract=3722318 or http://dx.doi.org/10.2139/ssrn.3722318
Code #
The Jupyter notebook with the functions and all other code is available here.The HTML export of the jupyter notebook is available here.
The PDF export of the jupyter notebook is available here.