title: “Bitcoin Quant Strategies - Momentum Trading”
date: 2019-07-20
tags: tech
mathjax: true
In this research I studied on the performance of simple and exponential moving average crossover strategies, with window sizes chosen by optimizing in-sample PNL, sharpe ratio and 30-day maximum drawdown. The calibrated strategy performs well, earning 500% cumulative return compared to baseline and a sharpe ratio of 1.30. The the 30-day maximum drawdown is similar to the baseline.
Strategy | P&L | Sharpe Ratio | Maximum Drawdown | |
---|---|---|---|---|
0 | Baseline | 0.28 | -1.48 | 0.35 |
1 | MA | 1.54 | 1.30 | 0.37 |
2 | EWMA | 1.45 | 1.10 | 0.38 |
It is no secret that price manipulations have always plagued the rising crypto-market. In this [paper], the auther studies large transactions behind the tether
coin, and showed more evidence supporting that each large move in the crypto-market usually only come from the act of only a few. In this type of regime, I argue that technical indicator may be a better bet to profit compared to any attempt to apply fundamental analysis, because an increase in price no longer comes from the increase in a crypto’s intrinsic value, but rather speculation and manipulation. In this exercise I will mainly focus on moving average crossover techniques and its optimization.
import itertools
from IPython.display import display, HTML, Image
import matplotlib.pyplot as plt
import matplotlib.ticker as ticker
import numpy as np
import pandas as pd
from pandas.plotting import register_matplotlib_converters
import warnings
register_matplotlib_converters()
warnings.filterwarnings("ignore")
plt.rcParams['font.family'] = "serif"
plt.rcParams['font.serif'] = "DejaVu Serif"
plt.rcParams['figure.figsize'] = (12, 6)
plt.rcParams['figure.dpi'] = 150
plt.rcParams['lines.linewidth'] = 0.75
pd.set_option('max_row', 10)
def disp(df):
return display(HTML(df.to_html(max_rows=10, header=True).replace('<table border="1" class="dataframe">','<table>')))
I got the preliminary bitcoin data from bitcoincharts. Data include price and volume information recorded by Bitstamp and split by seconds. This provide great granularity that can be grouped into any desirable levels later on.
data = pd.read_csv('bitstampUSD.csv', header=None, names=['time', 'price', 'volume'])
data['time'] = pd.to_datetime(data['time'], unit='s')
data.set_index('time', inplace=True)
Get 3-month treasury data.
url = 'https://fred.stlouisfed.org/graph/fredgraph.csv?id=DTB3'
tr = pd.read_csv(url, index_col=0, parse_dates=True)
Data are grouped in to daily, with average applied to price and sum applied to trade volume. The backtest period is selected to be from 2018 to 2019, where the market was in continuous downturn. This ensure that our strategy performs well in adverse scenarios.
df1 = data.loc['2018-01-01':'2019-01-01'].resample('1D').agg({'price': np.mean, 'volume': np.sum})
df2 = tr.loc['2018-01-01':'2019-01-01']
df = df1.join(df2).replace('.', np.NaN).fillna(method='ffill').fillna(method='bfill').rename({'DTB3': 'tr'}, axis=1)
df.tr = df.tr.astype(float)/100
disp(df)
price | volume | tr | |
---|---|---|---|
time | |||
2018-01-01 | 13386.429268 | 7688.030685 | 0.0142 |
2018-01-02 | 14042.643870 | 16299.669303 | 0.0142 |
2018-01-03 | 14947.898046 | 12275.001197 | 0.0139 |
2018-01-04 | 14802.363927 | 15004.018593 | 0.0139 |
2018-01-05 | 15967.972719 | 16248.914680 | 0.0137 |
... | ... | ... | ... |
2018-12-28 | 3752.739978 | 13055.718407 | 0.0235 |
2018-12-29 | 3862.153295 | 6901.382332 | 0.0235 |
2018-12-30 | 3783.210991 | 5736.453708 | 0.0235 |
2018-12-31 | 3745.258717 | 6667.163737 | 0.0240 |
2019-01-01 | 3709.889253 | 5149.606277 | 0.0240 |
plt.plot(df.price, c='tab:grey')
plt.ylabel('Bitcoin Price in USD')
plt.show()
A simple moving average strategy use the cross-over point of two moving averages as the trading signal. Here we use grid-search to find out the window size pair that optimizes our desired metrics, namely P&L, Sharpe ratio and 30-day maximum drawdown.
def moving_average(df0, ma1, ma2, transactionFee=0, runBaseline=False, returnStats=True, ewma=False):
df = df0.copy()
if ewma:
df['ma'+str(ma1)] = df.price.ewm(span=ma1).mean()
df['ma'+str(ma2)] = df.price.ewm(span=ma2).mean()
else:
df['ma'+str(ma1)] = df.price.rolling(ma1).mean()
df['ma'+str(ma2)] = df.price.rolling(ma2).mean()
df['ind'] = df['ma'+str(ma1)] > df['ma'+str(ma2)]
df.dropna(inplace=True)
df['buy'] = (df.ind != df.ind.shift(1)) & df.ind & (df.index != df.index[0])
df['sell'] = (df.ind != df.ind.shift(1)) & df.ind.shift(1) & (df.buy.cumsum() > 0)
if runBaseline:
df.ind = 1
df.buy = 1
df['pnl'] = df.ind * (df.buy.cumsum() > 0) * df.price.shift(-1) / df.price
df.pnl = df.pnl * np.where(df.ind != df.ind.shift(1), 1-transactionFee, 1)
df.dropna(inplace=True)
df.pnl.replace(0, 1, inplace=True)
if returnStats:
df['tr_daily'] = (1 + df.tr)**(1/365) - 1
pnl = round(df.pnl.cumprod()[-1], 2)
sharpe_ratio = round(np.mean(df.pnl-1-df.tr_daily) / np.std(df.pnl-1) * np.sqrt(365), 2)
mdd_dur = 30
max_draw_down = round(np.max(df.pnl.cumprod().rolling(mdd_dur).max() -
df.pnl.cumprod().shift(mdd_dur)), 2)
return pnl, sharpe_ratio, max_draw_down
else:
return df
First let’s compute the baseline results, from a simple buy and hold strategy.
pnl, spr, mdd = moving_average(df, 1, 1, runBaseline=True)
comp = pd.DataFrame({'Strategy': 'Baseline', 'P&L': pnl, 'Sharpe Ratio': spr, 'Maximum Drawdown': mdd}, index=[0])
disp(comp)
Strategy | P&L | Sharpe Ratio | Maximum Drawdown | |
---|---|---|---|---|
0 | Baseline | 0.28 | -1.48 | 0.35 |
Performing grid-search for the optimal window size pair. Note that 25bps of transaction fee is added, this is to reflect the typical fee charged by crypto exchanges. I used coinbase pro’s fee here as an example.
fee = 0.0025
test_range = np.arange(1, 61)
result_ma = pd.DataFrame(columns=['Strategy','MA1', 'MA2', 'P&L', 'Sharpe Ratio', 'Maximum Drawdown'])
# grid-search
for ma1 in test_range:
for ma2 in test_range:
if ma2 > ma1 + 3:
pnl, spr, mdd = moving_average(df, ma1, ma2, transactionFee=fee)
result_ma = result_ma.append({'Strategy': 'MA', 'MA1': ma1, 'MA2': ma2,
'P&L': pnl,
'Sharpe Ratio': spr,
'Maximum Drawdown': mdd}, ignore_index=True)
disp(result_ma.sort_values('P&L', ascending=False).head())
Strategy | MA1 | MA2 | P&L | Sharpe Ratio | Maximum Drawdown | |
---|---|---|---|---|---|---|
2 | MA | 1 | 7 | 1.54 | 1.30 | 0.37 |
3 | MA | 1 | 8 | 1.31 | 0.86 | 0.34 |
12 | MA | 1 | 17 | 1.26 | 0.81 | 0.33 |
11 | MA | 1 | 16 | 1.26 | 0.81 | 0.32 |
9 | MA | 1 | 14 | 1.25 | 0.78 | 0.32 |
disp(result_ma.sort_values('Sharpe Ratio', ascending=False).head())
Strategy | MA1 | MA2 | P&L | Sharpe Ratio | Maximum Drawdown | |
---|---|---|---|---|---|---|
2 | MA | 1 | 7 | 1.54 | 1.30 | 0.37 |
3 | MA | 1 | 8 | 1.31 | 0.86 | 0.34 |
11 | MA | 1 | 16 | 1.26 | 0.81 | 0.32 |
12 | MA | 1 | 17 | 1.26 | 0.81 | 0.33 |
9 | MA | 1 | 14 | 1.25 | 0.78 | 0.32 |
disp(result_ma.sort_values('Maximum Drawdown', ascending=True).head())
Strategy | MA1 | MA2 | P&L | Sharpe Ratio | Maximum Drawdown | |
---|---|---|---|---|---|---|
1129 | MA | 26 | 59 | 0.51 | -3.38 | 0.04 |
1099 | MA | 25 | 60 | 0.54 | -3.19 | 0.04 |
1130 | MA | 26 | 60 | 0.52 | -3.29 | 0.04 |
1160 | MA | 27 | 60 | 0.52 | -3.35 | 0.04 |
1128 | MA | 26 | 58 | 0.54 | -3.01 | 0.06 |
Choosing 1-7 as our selected window pair. Plotting the PNL over the 1-year backtest period.
bt = df.copy()
bt['Baseline: Buy and Hold'] = bt.price/bt.price[0]
bt['Strategy 1: MA 1-7'] = moving_average(df.copy(), 1, 7, returnStats=False).pnl.cumprod()
bt['Strategy 2: MA 1-7 with Fee'] = moving_average(df.copy(), 1, 7, transactionFee=fee, returnStats=False).pnl.cumprod()
plt.plot(bt.iloc[:, 3], c='tab:grey')
plt.plot(bt.iloc[:, 4], c='tab:red')
plt.plot(bt.iloc[:, 5], c='tab:red', alpha=0.5)
plt.legend(bt.columns[3:6], frameon=False)
plt.ylabel('Cumulative Asset Value Based on $1 Investment')
plt.show()
It seems that the trading fee does not have a material impact on the result. We plot the buy/sell signals as follow.
bt = df.copy()
ma = moving_average(bt, 1, 7, transactionFee=fee, returnStats=False)
plt.plot(bt.price, c='black', label='Bitcoin Price')
plt.plot(ma.price.loc[ma.buy], '^', markersize=3, color='g', label='Buy Signal')
plt.plot(ma.price.loc[ma.sell], 'v', markersize=3, color='r', label='Sell Signal')
plt.legend()
plt.show()
Perform the same grid-search optimization using EWMA (Exponentially Weighted Moving Averages).
test_range = np.arange(1, 61)
result_ewma = pd.DataFrame(columns=['Strategy','MA1', 'MA2', 'P&L', 'Sharpe Ratio', 'Maximum Drawdown'])
# grid-search
for ma1 in test_range:
for ma2 in test_range:
if ma2 > ma1 + 3:
pnl, spr, mdd = moving_average(df, ma1, ma2, transactionFee=fee, ewma=True)
result_ewma = result_ewma.append({'Strategy': 'EWMA', 'MA1': ma1, 'MA2': ma2,
'P&L': pnl,
'Sharpe Ratio': spr,
'Maximum Drawdown': mdd}, ignore_index=True)
disp(result_ewma.sort_values('P&L', ascending=False).head())
Strategy | MA1 | MA2 | P&L | Sharpe Ratio | Maximum Drawdown | |
---|---|---|---|---|---|---|
0 | EWMA | 1 | 5 | 1.45 | 1.10 | 0.38 |
1 | EWMA | 1 | 6 | 1.39 | 0.98 | 0.39 |
5 | EWMA | 1 | 10 | 1.38 | 1.01 | 0.32 |
10 | EWMA | 1 | 15 | 1.36 | 0.99 | 0.38 |
6 | EWMA | 1 | 11 | 1.31 | 0.87 | 0.31 |
disp(result_ewma.sort_values('Sharpe Ratio', ascending=False).head())
Strategy | MA1 | MA2 | P&L | Sharpe Ratio | Maximum Drawdown | |
---|---|---|---|---|---|---|
0 | EWMA | 1 | 5 | 1.45 | 1.10 | 0.38 |
5 | EWMA | 1 | 10 | 1.38 | 1.01 | 0.32 |
10 | EWMA | 1 | 15 | 1.36 | 0.99 | 0.38 |
1 | EWMA | 1 | 6 | 1.39 | 0.98 | 0.39 |
12 | EWMA | 1 | 17 | 1.31 | 0.89 | 0.38 |
disp(result_ewma.sort_values('Maximum Drawdown', ascending=True).head())
Strategy | MA1 | MA2 | P&L | Sharpe Ratio | Maximum Drawdown | |
---|---|---|---|---|---|---|
797 | EWMA | 17 | 42 | 0.51 | -2.34 | 0.18 |
1069 | EWMA | 25 | 30 | 0.53 | -2.22 | 0.18 |
1068 | EWMA | 25 | 29 | 0.51 | -2.29 | 0.18 |
1067 | EWMA | 24 | 60 | 0.67 | -1.92 | 0.18 |
1066 | EWMA | 24 | 59 | 0.67 | -1.92 | 0.18 |
Selecting 1-7 as our window pair. Plotting the cumulative strategy return and buy/sell signals.
bt = df.copy()
bt['Baseline: Buy and Hold'] = bt.price/bt.price[0]
bt['Strategy 1: EMWA 1-5 (Best PNL)'] = moving_average(df.copy(), 1, 5, returnStats=False, ewma=True).pnl.cumprod()
bt['Strategy 2: EMWA 1-5 (Best PNL) with Fee'] = moving_average(df.copy(), 1, 5, transactionFee=fee, returnStats=False, ewma=True).pnl.cumprod()
plt.plot(bt.iloc[:, 3], c='tab:grey')
plt.plot(bt.iloc[:, 4], c='tab:blue')
plt.plot(bt.iloc[:, 5], c='tab:blue', alpha=0.5)
plt.legend(bt.columns[3:6], frameon=False)
plt.ylabel('Cumulative Asset Value Based on $1 Investment')
plt.show()
bt = df.copy()
ma = moving_average(bt, 1, 5, transactionFee=fee, returnStats=False, ewma=True).copy()
plt.plot(bt.price, c='black', label='Bitcoin Price')
plt.plot(ma.price.loc[ma.buy], '^', markersize=3, color='g', label='Buy Signal')
plt.plot(ma.price.loc[ma.sell], 'v', markersize=3, color='r', label='Sell Signal')
plt.legend()
plt.show()
Comparing the MA and EWMA strategies.
bt = df.copy()
bt['Baseline: Buy and Hold'] = bt.price/bt.price[0]
bt['Strategy 1: Moving Average 1-7'] = moving_average(df.copy(), 1, 7, transactionFee=fee, returnStats=False).pnl.cumprod()
bt['Strategy 2: EWMA 1-5'] = moving_average(df.copy(), 1, 5, transactionFee=fee, returnStats=False, ewma=True).pnl.cumprod()
plt.plot(bt.iloc[:, 3], c='tab:grey')
plt.plot(bt.iloc[:, 4], c='tab:red')
plt.plot(bt.iloc[:, 5], c='tab:blue')
plt.legend(bt.columns[3:6], frameon=False)
plt.ylabel('Cumulative Asset Value Based on $1 Investment')
plt.show()
As we can see, the MA strategy slightly outperforms the EWMA strategy in all three metrics.
comp = comp.append(result_ma.iloc[2, [0, 3, 4, 5]], ignore_index=True)
comp = comp.append(result_ewma.iloc[0, [0, 3, 4, 5]], ignore_index=True)
disp(comp)
Strategy | P&L | Sharpe Ratio | Maximum Drawdown | |
---|---|---|---|---|
0 | Baseline | 0.28 | -1.48 | 0.35 |
1 | MA | 1.54 | 1.30 | 0.37 |
2 | EWMA | 1.45 | 1.10 | 0.38 |
Starting 08-01-2019, I have implemented the optimal MA strategy on a VPS (virtual private server), running 24/7 through the coinbase pro api. Will post update on this periodically.