# Bitcoin Trading Strategy (2): Chasing Momentum

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

1 | import itertools |

# Function

1 | def disp(df): |

# 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.

1 | data = pd.read_csv('bitstampUSD.csv', header=None, names=['time', 'price', 'volume']) |

Get 3-month treasury data.

1 | url = 'https://fred.stlouisfed.org/graph/fredgraph.csv?id=DTB3' |

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.

1 | df1 = data.loc['2018-01-01':'2019-01-01'].resample('1D').agg({'price': np.mean, 'volume': np.sum}) |

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 |

1 | plt.plot(df.price, c='tab:grey') |

# 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.

1 | def moving_average(df0, ma1, ma2, transactionFee=0, runBaseline=False, returnStats=True, ewma=False): |

First let’s compute the baseline results, from a simple buy and hold strategy.

1 | pnl, spr, mdd = moving_average(df, 1, 1, runBaseline=True) |

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.

1 | fee = 0.0025 |

1 | 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 |

1 | 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 |

1 | 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.

1 | bt = df.copy() |

It seems that the trading fee does not have a material impact on the result. We plot the buy/sell signals as follow.

1 | bt = df.copy() |

# EWMA

Perform the same grid-search optimization using EWMA (Exponentially Weighted Moving Averages).

1 | test_range = np.arange(1, 61) |

1 | 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 |

1 | 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 |

1 | 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.

1 | bt = df.copy() |

1 | bt = df.copy() |

Comparing the MA and EWMA strategies.

1 | bt = df.copy() |

As we can see, the MA strategy slightly outperforms the EWMA strategy in all three metrics.

1 | comp = comp.append(result_ma.iloc[2, [0, 3, 4, 5]], ignore_index=True) |

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.