title: “Bitcoin Quant Strategies - Momentum Trading”
date: 2019-07-20
tags: tech
mathjax: true

home: 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

Motivation

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.

Packages

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)

Function

def disp(df):
    return display(HTML(df.to_html(max_rows=10, header=True).replace('<table border="1" class="dataframe">','<table>')))

Data Exploration

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

png

Simple Moving Average

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

png

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

png

EWMA

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

png

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

png

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

png

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

Implementation

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.