Herron Topic 2 - Practice - Sec 02

FINA 6333 for Spring 2025

Author

Richard Herron

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import pandas_datareader as pdr
import seaborn as sns
import statsmodels.api as sm
import yfinance as yf
%precision 4
pd.options.display.float_format = '{:.4f}'.format
# %config InlineBackend.figure_format = 'retina'

Announcements

  1. I am still grading your projects; I hope to finish them by next Tuesday
  2. I posted 50 practice problems to prepare for the end-of-course Programming Assessment here: https://northeastern.instructure.com/courses/207607/discussion_topics/2727917
    1. I built 5 autograded notebooks to help you prepare for the assessment, and I will build more in the coming weeks
    2. To run these notebooks, install the otter-grader package: In the Anaconda command prompt (or Terminal on Mac) run conda activate fina6333 then conda install otter-grader
    3. See the video at the link above for details

Five-Minute Review

  1. Technical analysis (TA) is a method of evaluating trends in trading prices and volume (and open interest in the futures and options markets only)
  2. The three tenants of TA are:
    1. Markets discount everything
    2. Prices move in trends
    3. History repeats itself, so these trends are recurring
  3. Academics have not found much evidence that TA generate profits that exceed transaction costs, but we will spend a week on it for three reasons:
    1. You asked for it, and it will help build our data analytics skills
    2. It is a small part of the Chartered Financial Analyst (CFA) curriculum
    3. That TA still receives attention 60 years after the Efficient Markets Hypothesis (EMH) suggests that it has some value that academics have been unable to measure

Practice

Implement the SMA(20) strategy with BTC-USD from the lecture notebook

Try to create the btc_sma data frame from the btc data frame in one code cell with one assignment (i.e., one =).

btc = (
    yf.download(
        tickers='BTC-USD',
        auto_adjust=False,
        progress=False,
        multi_level_index=False
    )
    .iloc[:-1] # drop incomplete trading day
)

After class, I converted our code to a function. We might want to test different moving average parameters, and a function makes these tests easier.

The following calc_sma() function accepts:

  1. A data frame df of daily values from yfinance.download()
  2. An integer window that specifies the number of trading days in the SMA window

And returns the original data frame df plus the following columns:

  1. Return with daily returns
  2. SMA for the window-trading-day moving average
  3. Position for the weight on the security each day
  4. Strategy for the return on the strategy each day
def calc_sma(df, window=20):
    return (
        df
        .assign(
            Return=lambda x: x['Adj Close'].pct_change(),
            SMA=lambda x: x['Adj Close'].rolling(window=window).mean(),
            Position=lambda x: np.select(
                condlist=[
                    x['Adj Close'].shift(1) > x['SMA'].shift(1),
                    x['Adj Close'].shift(1) <= x['SMA'].shift(1)
                ],
                choicelist=[1, 0],
                default=np.nan
            ),
            Strategy=lambda x: x['Position'] * x['Return']
        )
    )
btc_sma = btc.pipe(calc_sma, window=20)

It can be helpful to visualize a few places where the price (here Adj Close) crosses the moving average (here SMA(20)). In class, we found that there are a few changes in position in the middle of October, 2014. The following code create two subplots in one figure, then puts one plot in each of the subplots.

For the first crossover on October 12, we buy at the close on October 12 and earn the security return on October 13. For the second crossover on October 23, we sell at the close on October 23 and earn zero return on October 24.

columns_sma20 = {
    'Adj Close': 'Price',
    'SMA': 'SMA(20)',
    'Return': 'Buy-And-Hold',
    'Strategy': 'SMA(20)'
}
fig, ax = plt.subplots(2, 1)
df = btc_sma.loc['2014-10-10':'2014-10-25']
df[['Adj Close', 'SMA']].rename(columns=columns_sma20).plot(ax=ax[0])
df[['Position']].plot(ax=ax[1])
plt.suptitle('SMA(20) Crossovers and Positions in Mid-October 2014')
plt.tight_layout()
plt.show()

We can compare the total returns on BTC-USD and our SMA(20) strategy! We .dropna() first because the SMA(20) strategy needs 20 days of data to make its first investing decision.

(
    btc_sma
    [['Return', 'Strategy']]
    .dropna()
    .rename(columns=columns_sma20)
    .add(1)
    .cumprod()
    .plot()
)
plt.semilogy()
plt.suptitle('Comparison of One-Dollar Investments in BTC-USD Strategies')
plt.ylabel('Value of One-Dollar Investments')
plt.show()

These total returns, at least today, are similar!

(
    btc_sma
    [['Return', 'Strategy']]
    .dropna()
    .rename(columns=columns_sma20)
    .add(1)
    .prod()
    .plot(kind='bar')
)
plt.suptitle('Comparison of One-Dollar Investments in BTC-USD Strategies')
plt.ylabel('Value of One-Dollar Investments')
plt.show()

However, the Sharpe ratio of the SMA(20) is higher, because time out of the market when Position=0 reduces risk. For simplicity, we will ignore the risk-free rate in the Sharpe ratio formula.

(
    btc_sma
    [['Return', 'Strategy']]
    .dropna()
    .rename(columns=columns_sma20)
    .apply(lambda x: np.sqrt(252) * x.mean() / x.std())
    .plot(kind='bar')
)
plt.suptitle('Comparison of Reward-to-Risk in BTC-USD Strategies')
plt.ylabel('Sharpe Ratio')
plt.show()

But how persistent is this outperformance in terms of reward-to-risk ratios? We can quickly modify the code above to calculate Sharpe ratios each year! We find that SMA(20) Sharpe ratio edge is not persistent.

df = (
    btc_sma
    [['Return', 'Strategy']]
    .dropna()
    .rename(columns=columns_sma20)
    .resample('YE')
    .apply(lambda x: np.sqrt(252) * x.mean() / x.std())
)
df.index = df.index.year
df.plot(kind='bar')
plt.suptitle('Comparison of Reward-to-Risk in BTC-USD Strategies over Time')
plt.ylabel('Sharpe Ratio')
plt.show()


We can use our list comprehension skills to easily try several window sizes!

def CAGR(x):
    T = x.count()
    return (x.add(1).prod() ** (252 / T)) - 1
def Sharpe(x, ann_fac=np.sqrt(252)):
    return ann_fac * x.mean() / x.std()
columns_sman = {
    'Adj Close': 'Price',
    'SMA': 'SMA(N)',
    'Return': 'Buy-And-Hold',
    'Strategy': 'SMA(N)'
}
windows = list(range(5, 55, 5))

btc_smas = (
    pd.concat(
        objs=[
            btc.pipe(calc_sma, window=w)[['Return', 'Strategy']].agg([CAGR, Sharpe])
            for w in windows
        ],
        keys=windows,
        names=['Window', 'Statistic']
    )
    .rename(columns=columns_sman)
)    
btc_smas
Buy-And-Hold SMA(N)
Window Statistic
5 CAGR 0.4090 0.5002
Sharpe 0.8877 1.2139
10 CAGR 0.4090 0.4558
Sharpe 0.8877 1.1490
15 CAGR 0.4090 0.4687
Sharpe 0.8877 1.1826
20 CAGR 0.4090 0.4454
Sharpe 0.8877 1.1327
25 CAGR 0.4090 0.4756
Sharpe 0.8877 1.1728
30 CAGR 0.4090 0.4656
Sharpe 0.8877 1.1521
35 CAGR 0.4090 0.5298
Sharpe 0.8877 1.2464
40 CAGR 0.4090 0.5818
Sharpe 0.8877 1.3250
45 CAGR 0.4090 0.5621
Sharpe 0.8877 1.2892
50 CAGR 0.4090 0.5258
Sharpe 0.8877 1.2250
(
    btc_smas
    .reset_index()
    .melt(
        id_vars=['Window', 'Statistic'],
        value_vars=['Buy-And-Hold', 'SMA(N)'],
        var_name='Strategy',
        value_name='Value'
    )
    .pipe(
        sns.catplot,
        x='Window',
        col='Statistic',
        hue='Strategy',
        y='Value',
        kind='bar',
        sharey=False
    )
)
plt.suptitle('Comparsion of BTC-USD Buy-And-Hold and SMA(N) for Various Ns', y=1.05)
plt.show()


Investigate how SMA(20) generates returns

Consider the following:

  1. Does SMA(20) avoid the worst performing days? How many of the worst 20 days does SMA(20) avoid? Try the .nlargest() method.
  2. Does SMA(20) preferentially avoid low-return days? Try to combine the .groupby() method and pd.qcut() function.
  3. Does SMA(20) preferentially avoid high-volatility days? Try to combine the .groupby() method and pd.qcut() function.

The SMA(20) does well here because it avoids 17 of the 20 worst days, without avoiding the best days.

btc_sma.loc[btc_sma['Return'].nsmallest(20).index, ['Position']].value_counts()
Position
0.0000      17
1.0000       3
Name: count, dtype: int64
btc_sma.loc[btc_sma['Return'].nlargest(20).index, ['Position']].value_counts()
Position
0.0000      10
1.0000      10
Name: count, dtype: int64

We can also look at the descriptive statistics by Postiion and strategy. Two observations:

  1. The min column shows that SMA(20) misses the bad days, like we see above
  2. The mean in Position=1 is about 5 times higher than in Position=0
(
    btc_sma
    [['Position', 'Return', 'Strategy']]
    .dropna()
    .groupby('Position')
    .describe()
    .rename(columns=columns_sma20)
    .rename_axis(columns=['Strategy', 'Statistic'])
    .stack('Strategy', future_stack=True)
)
Statistic count mean std min 25% 50% 75% max
Position Strategy
0.0000 Buy-And-Hold 1721.0000 0.0008 0.0390 -0.3717 -0.0138 0.0012 0.0162 0.2394
SMA(20) 1721.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000 0.0000
1.0000 Buy-And-Hold 2091.0000 0.0032 0.0333 -0.1409 -0.0114 0.0013 0.0173 0.2525
SMA(20) 2091.0000 0.0032 0.0333 -0.1409 -0.0114 0.0013 0.0173 0.2525

We can also use the seaborn package to visualize Position (i.e., the portfolio weight on Bitcoin) during periods of high and low Bitcoin returns and volatility. The SMA(20) strategy is long Bitcoin about 55% of the time, whether Bitcoin returns are high (bin 2) or low (bin 1).

(
    btc_sma
    [['Return', 'Position']]
    .dropna()
    .assign(Return_Bin=lambda x: pd.qcut(x['Return'], q=3))
    .pipe(
        sns.barplot,
        x='Return_Bin',
        y='Position'
    )
)

plt.title('Fraction of Time SMA(20) Strategy is Long by Return Bin')
plt.show()

However, the SMA(20) stateegy, for this security, sample, and window, spends less times in Bitcoind during volatilit times.

(
    btc_sma
    [['Return', 'Position']]
    .dropna()
    .assign(Volatility_Bin=lambda x: pd.qcut(x['Return'].rolling(20).std(), q=3))
    .pipe(
        sns.barplot,
        x='Volatility_Bin',
        y='Position'
    )
)

plt.title('Fraction of Time SMA(20) Strategy is Long by Volatility Bin')
plt.show()

Implement the SMA(20) strategy with IBM

How often does SMA(20) outperform buy-and-hold with 10-year rolling windows?

ibm = (
    yf.download(
        tickers='IBM',
        auto_adjust=False,
        progress=False,
        multi_level_index=False
    )
    .iloc[:-1] # drop incomplete trading day
)
ibm_sma = ibm.pipe(calc_sma, window=20)
(
    ibm_sma
    [['Return', 'Strategy']]
    .dropna()
    .rename(columns=columns_sma20)
    .add(1)
    .cumprod()
    .plot()
)
plt.semilogy()
plt.suptitle('Comparison of One-Dollar Investments in IBM Strategies')
plt.ylabel('Value of One-Dollar Investments')
plt.show()

Over the full, 60-year sample, SMA(20) underperforms buy-and-hold. What about on rolling ten-year windows? The results look balanced, and neither clearly outperforms.

(
    ibm_sma
    [['Return', 'Strategy']]
    .dropna()
    .rename(columns=columns_sma20)
    .pipe(np.log1p)
    .rolling(window=10*252)
    .sum()
    .pipe(np.exp)
    .plot()
)
plt.semilogy()
plt.suptitle('Comparison of One-Dollar Investments in IBM Strategies')
plt.ylabel('Value of One-Dollar Investments')
plt.show()

We can quantify how often SMA(20) outperform buy-and-hold. SMA(20) outperforms only 24% of the time!

(
    ibm_sma
    [['Return', 'Strategy']]
    .dropna()
    .pipe(np.log1p)
    .rolling(window=10*252)
    .sum()
    .pipe(np.exp)
    .pipe(lambda x: x['Strategy'] > x['Return'])
    .mean()
)
0.2382
windows = list(range(5, 55, 5))

ibm_smas = (
    pd.concat(
        objs=[
            ibm.pipe(calc_sma, window=w)[['Return', 'Strategy']].agg([CAGR, Sharpe])
            for w in windows
        ],
        keys=windows,
        names=['Window', 'Statistic']
    )
    .rename(columns=columns_sman)
)    
(
    ibm_smas
    .reset_index()
    .melt(
        id_vars=['Window', 'Statistic'],
        value_vars=['Buy-And-Hold', 'SMA(N)'],
        var_name='Strategy',
        value_name='Value'
    )
    .pipe(
        sns.catplot,
        x='Window',
        col='Statistic',
        hue='Strategy',
        y='Value',
        kind='bar',
        sharey=False
    )
)
plt.suptitle('Comparsion of IBM Buy-And-Hold and SMA(N) for Various Ns', y=1.05)
plt.show()

Implement a long-only BB(20, 2) strategy with Bitcoin

Bollinger Bands are bands around a trend, typically defined in terms of simple moving averages and volatilities. A long-only BB(20, 2) strategy has upper and lower bands at 2 standard deviations above and below the SMA(20). It invests as follows:

  1. Buy when the closing price crosses LB(20) from below
  2. Sell when the closing price crosses UB(20) from above
  3. No short-selling

The long-only BB(20, 2) is more difficult to implement than the long-only SMA(20) because we need to track buys and sells. For example, if the closing price is between LB(20) and BB(20), we need to know if our last trade was a buy or a sell. Further, if the closing price is below LB(20), we can still be long because we sell when the closing price crosses UB(20) from above.

More on Bollinger Bands here and here.

def calc_bb(df, m=20, n=2):
    return (
        df
        .assign(
            Return=lambda x: x['Adj Close'].pct_change(),
            SMA=lambda x: x['Adj Close'].rolling(window=m).mean(),
            SMV=lambda x: x['Adj Close'].rolling(window=m).std(),
            UB=lambda x: x['SMA'] + n*x['SMV'],
            LB=lambda x: x['SMA'] - n*x['SMV'],
            Position_w_nan=lambda x: np.select(
                condlist=[
                    (x['Adj Close'].shift(1) > x['LB'].shift(1)) & (x['Adj Close'].shift(2) <= x['LB'].shift(2)),
                    (x['Adj Close'].shift(1) < x['UB'].shift(1)) & (x['Adj Close'].shift(2) >= x['UB'].shift(2))
                ],
                choicelist=[1, 0],
                default=np.nan
            ),
            Position=lambda x: x['Position_w_nan'].ffill(),
            Strategy=lambda x: x['Position'] * x['Return']
        )
    )
btc_bb = btc.pipe(calc_bb)

The BB(20, 2) only spends 40% of its time long BTC!

btc_bb['Position'].mean()
0.4013

And BTC-USD performance is worse when it long that when its neutral!

  • Mean daily return is lower
  • Volatility of daily returns is higher
  • Every percentile of the distribution is worse
btc_bb.groupby('Position')['Return'].describe()
count mean std min 25% 50% 75% max
Position
0.0000 2268.0000 0.0027 0.0343 -0.1874 -0.0110 0.0019 0.0172 0.2525
1.0000 1520.0000 0.0013 0.0386 -0.3717 -0.0149 0.0007 0.0161 0.2394
columns_bb = {
    'Adj Close': 'Price',
    'Return': 'Buy-And-Hold',
    'Strategy': 'BB(20, 2)'
}
(
    btc_bb
    [['Return', 'Strategy']]
    .dropna()
    .rename(columns=columns_bb)
    .add(1)
    .cumprod()
    .plot()
)
plt.semilogy()
plt.suptitle('Comparison of One-Dollar Investments in BTC-USD Strategies')
plt.ylabel('Value of One-Dollar Investments')
plt.show()

Here are the final values of $1 investments, which are difficult to read on the log scale above.

(
    btc_bb
    [['Return', 'Strategy']]
    .rename(columns=columns_bb)
    .add(1)
    .prod()
    .rename_axis('Strategy')
    .to_frame('Value of One-Dollar Investment')
)
Value of One-Dollar Investment
Strategy
Buy-And-Hold 183.6056
BB(20, 2) 2.1877

We need a more complex plot to better understand what is going on!

import matplotlib.ticker as ticker
fig, ax = plt.subplots(nrows=2, ncols=1, sharex=True)
df = btc_bb.loc['2017-09':'2017-12']

ax[0].plot(df[['Adj Close']], label='Price')
ax[0].plot(df[['SMA']], label='SMA(20)')
ax[0].plot(df[['UB']], label='LB and UB', color='green', linestyle=':')
ax[0].plot(df[['LB']], color='green', linestyle=':')
ax[0].legend()
ax[0].set_ylabel('Price ($)')

ax[1].plot(df[['Position']], label='Signal')
ax[1].legend()
ax[1].set_ylabel('Position')

ax[0].yaxis.set_major_formatter(ticker.StrMethodFormatter('{x:,.0f}'))
fig.autofmt_xdate()

plt.suptitle('Key Variables in BTC-USD BB(20, 2) Strategy')
plt.show()

Implement a long-short RSI(14) strategy with Bitcoin

From Fidelity:

The Relative Strength Index (RSI), developed by J. Welles Wilder, is a momentum oscillator that measures the speed and change of price movements. The RSI oscillates between zero and 100. Traditionally the RSI is considered overbought when above 70 and oversold when below 30. Signals can be generated by looking for divergences and failure swings. RSI can also be used to identify the general trend.

The RSI formula: \(RSI(n) = 100 - \frac{100}{1 + RS(n)}\), where \(RS(n) = \frac{SMA(U, n)}{SMA(D, n)}\). For “up days”, \(U = \Delta \text{Adj\ Close}\) and \(D = 0\). For “down days”, \(U = 0\) and \(D = - \Delta \text{Adj\ Close}\).

We will implement a long-short RSI(14) as follows:

  1. Buy when the RSI crosses 30 from below, and sell when the RSI crosses 50 from below
  2. Short when the RSI crosses 70 from above, and cover when the RSI crosses 50 from above

More about RSI here.

def calc_rsi(df, window=14, lo=30, mid=50, hi=70):
    return (
        df
        .assign(
            Return=lambda x: x['Adj Close'].pct_change(),
            # This approach with .max() and .min() handles NA values better than the in-class solution
            Diff=lambda x: x['Adj Close'].diff(), 
            Zero=0,
            U=lambda x: x[['Diff', 'Zero']].max(axis=1, skipna=False),
            D=lambda x: -x[['Diff', 'Zero']].min(axis=1, skipna=False),
            SMAU=lambda x: x['U'].rolling(window=window).mean(),
            SMAD=lambda x: x['D'].rolling(window=window).mean(),
            RS=lambda x: x['SMAU'] / x['SMAD'],
            RSI=lambda x: 100 - 100 / (1 + x['RS']),
            Position_w_nan=lambda x: np.select(
                condlist=[
                    (x['RSI'].shift(1) > lo) & (x['RSI'].shift(2) <= lo),
                    (x['RSI'].shift(1) > mid) & (x['RSI'].shift(2) <= mid),
                    (x['RSI'].shift(1) < hi) & (x['RSI'].shift(2) >= hi),
                    (x['RSI'].shift(1) < mid) & (x['RSI'].shift(2) >= mid),
                ],
                choicelist=[1, 0, -1, 0],
                default=np.nan
            ),
            Position=lambda x: x['Position_w_nan'].ffill(),
            Strategy=lambda x: x['Position'] * x['Return']
        )
    )
btc_rsi = btc.pipe(calc_rsi)
columns_rsi = {
    'Adj Close': 'Price',
    'Return': 'Buy-And-Hold',
    'Strategy': 'RSI(14)'
}
(
    btc_rsi
    [['Return', 'Strategy']]
    .dropna()
    .rename(columns=columns_rsi)
    .add(1)
    .cumprod()
    .plot()
)
plt.semilogy()
plt.suptitle('Comparison of One-Dollar Investments in BTC-USD Strategies')
plt.ylabel('Value of One-Dollar Investments')
plt.show()

Implement a golden cross with Bitcoin

Someone in Section 04 mentioned two-moving average strategies, so I added this golden cross, where the 50-day SMA crosses the 200-day SMA, to every section.

From Grok:

In technical analysis, a golden cross is a bullish chart pattern that occurs when a short-term moving average (typically the 50-day moving average) crosses above a long-term moving average (typically the 200-day moving average). This crossover is considered a signal that a stock, index, or other asset may be entering a sustained upward trend, suggesting potential buying opportunities for traders and investors.

More here.

def calc_cross(df, long=200, short=50):
    return (
        df
        .assign(
            Return=lambda x: x['Adj Close'].pct_change(),
            SMAL=lambda x: x['Adj Close'].rolling(window=long).mean(),
            SMAS=lambda x: x['Adj Close'].rolling(window=short).mean(),
            Position=lambda x: np.select(
                condlist=[
                    x['SMAS'].shift(1) > x['SMAL'].shift(1),
                    x['SMAS'].shift(1) <= x['SMAL'].shift(1)
                ],
                choicelist=[1, 0],
                default=np.nan
            ),
            Strategy=lambda x: x['Position'] * x['Return']
        )
    )
btc_cross = btc.pipe(calc_cross, long=200, short=50)
columns_cross = {
    'Adj Close': 'Price',
    'SMAL': 'SMA(200)',
    'SMAS': 'SMA(50)',
    'Return': 'Buy-And-Hold',
    'Strategy': 'Golden Cross(200, 50)'
}
btc_cross.query('Position == 1')
Adj Close Close High Low Open Volume Return SMAL SMAS Position Strategy
Date
2015-07-15 285.8290 285.8290 293.2480 285.3670 288.0450 27486600 -0.0057 247.7823 249.0083 1.0000 -0.0057
2015-07-16 278.0890 278.0890 291.1830 275.2400 286.0420 49482600 -0.0271 247.5865 249.8244 1.0000 -0.0271
2015-07-17 279.4720 279.4720 280.2800 272.0430 278.0910 27591400 0.0050 247.4205 250.6657 1.0000 0.0050
2015-07-18 274.9010 274.9010 282.5270 274.0750 279.3310 25187100 -0.0164 247.2414 251.4218 1.0000 -0.0164
2015-07-19 273.6140 273.6140 275.6700 272.5130 274.7670 15332500 -0.0047 247.0085 252.2272 1.0000 -0.0047
... ... ... ... ... ... ... ... ... ... ... ...
2025-03-10 78532.0000 78532.0000 83955.9297 77420.5938 80597.1484 54061099422 -0.0257 83357.9603 95531.6020 1.0000 -0.0257
2025-03-11 82862.2109 82862.2109 83577.7578 76624.2500 78523.8750 54702837196 0.0551 83451.7996 95148.5130 1.0000 0.0551
2025-03-12 83722.3594 83722.3594 84358.5781 80635.2500 82857.3750 40353484454 0.0104 83549.5164 94700.0348 1.0000 0.0104
2025-03-13 81066.7031 81066.7031 84301.6953 79931.8516 83724.9219 31412940153 -0.0317 83633.1822 94248.3075 1.0000 -0.0317
2025-03-14 83969.1016 83969.1016 85263.2891 80797.5625 81066.9922 29588112414 0.0358 83738.6244 93848.4861 1.0000 0.0358

2320 rows × 11 columns

fig, ax = plt.subplots(2, 1)
df = btc_cross.loc['2015-07':'2015-12']
df[['Adj Close', 'SMAL', 'SMAS']].rename(columns=columns_cross).plot(ax=ax[0])
df[['Position']].plot(ax=ax[1])
plt.suptitle('Golden Cross(200, 50) Crossovers and Positions in 2015H2')
plt.tight_layout()
plt.show()

(
    btc_cross
    [['Return', 'Strategy']]
    .dropna()
    .rename(columns=columns_cross)
    .add(1)
    .cumprod()
    .plot()
)
plt.semilogy()
plt.suptitle('Comparison of One-Dollar Investments in BTC-USD Strategies')
plt.ylabel('Value of One-Dollar Investments')
plt.show()

Compare all strategies

I added this comparison of all strategies after class.

df = (
    btc_sma[['Return', 'Strategy']]
    .join(btc_bb[['Strategy']], rsuffix='_bb') 
    .join(btc_rsi[['Strategy']], rsuffix='_rsi') 
    .join(btc_cross[['Strategy']], rsuffix='_cross') 
    .dropna()
)


(
    df
    .add(1)
    .cumprod()
    .rename_axis(columns='Strategy')
    .rename(columns={
        'Return': 'Buy-And-Hold', 
        'Strategy': 'SMA(20)',
        'Strategy_bb': 'BB(20, 2)',
        'Strategy_rsi': 'RSI(14)',
        'Strategy_cross': 'Golden Cross(200, 50)',
    })
    .plot()
)

plt.semilogy()
plt.suptitle('Comparison of One-Dollar Investments in BTC-USD Strategies')
plt.ylabel('Value of One-Dollar Investments')
plt.show()