title: “Bitcoin Quant Strategies - Perpetual Swap Funding”
date: 2019-07-20
tags: tech
mathjax: true

home: true

Background

In this research, we dive into the bitcoin perpetual swap contract on the BitMEX exchange. Specifically, we are interested at the predicting power of its funding structure and the subsequent applications to algorithmic trading.

Traditionally, future provides additional liquidity and leverage to market participants. With USD, one may either buy Bitcoin, or Bitcoin futures which provide x more gain potentials. However, every future contract has an expiry date and can be traded at significantly spread. The first Bitcoin future in the U.S. was traded on Dec 10, 2017 on the Cboe Futures Exchange.

The Bitcoin perpetual swap contract, on the contrast, does not have an expiry date thus removing the need to rollover. It trades much closer to the underlying Bitcoin price via a funding mechanism. On BitMEX, the swap holders must exchange fundings every hours between the long and short counter-parties. This create price pressure for the swap price to converge to the actual Bitcoin price.

For example, if swap price Bitcoin price, then the funding would be positive and therefore the long positions will need to pay funding to its short counter-parties. This creates pressures for the swap price to decrease and move towards the Bitcoin price.

Strategy

The funding creates a great monetary incentive if you are holding the contract on the right side and we would like to see if we can capture the funding gain overtime with an algorithmic trading strategy. Since the funding is announced hours before the actual exchange happens, we have an hour window of entry after knowing that a profitable funding will occur. After we enter the contract and collect the funding, we then have another hour window for exiting (this assumes we only want to enter contract at any given time). We will try to look for optimal enter/exit time combinations and evaluate performances.

This is similar to the mean reversion strategy discussed by BitMEX’s founder Arthur Hayes in his blog[1][2]. We are carrying this strategy further, analyzing enter and exit options at more granular level and proposing a more optimal execution strategy.

Dependency

import pytz
import time
import datetime
import requests
import numpy as np
import pandas as pd
from random import random
import statsmodels.api as sm
import matplotlib.pyplot as plt
from IPython.display import display, HTML, Image
from sklearn.linear_model import LinearRegression
from pandas.plotting import register_matplotlib_converters
register_matplotlib_converters()
plt.rcParams['font.family'] = "serif"
plt.rcParams['font.serif'] = "DejaVu Serif"
plt.rcParams['figure.figsize'] = (12, 6)
plt.rcParams['figure.dpi'] = 400
plt.rcParams['lines.linewidth'] = 0.75
pd.set_option('max_row', 6)
def disp(df, max_rows=6):
    return display(HTML(df.to_html(max_rows=max_rows, header=True).replace('<table border="1" class="dataframe">','<table>')))

Data

We can retrieve historical funding rates and minutely swap price data from the BitMEX api.

def get_funding():
    start_date = datetime.datetime(2016, 5, 14, 0, 0, 0, 0, tzinfo=pytz.utc)
    end_date   = datetime.datetime.now(tz=pytz.utc) - datetime.timedelta(days=14)

    endpoint = 'https://www.bitmex.com/api/v1/funding'
    payload  = {'count':'500', 'reverse':'false', 'symbol':'XBTUSD', 'startTime': start_date}
    response = requests.get(endpoint, params=payload)

    funding_rates_df = pd.DataFrame(response.json())
    funding_rates_df['timestamp'] = pd.to_datetime(funding_rates_df['timestamp'], utc=True)
    funding_rates_df.set_index('timestamp', drop=True, inplace=True)
    start_date = funding_rates_df.index[-1] + datetime.timedelta(hours=1)

    while start_date < end_date:
        time.sleep(random()) # requesting too frequently will cause error
        endpoint = 'https://www.bitmex.com/api/v1/funding'
        payload  = {'count':'500', 'reverse':'false', 'symbol':'XBTUSD', 'startTime': start_date}
        response = requests.get(endpoint, params=payload)

        funding_rates_df_tmp = pd.DataFrame(response.json())
        funding_rates_df_tmp['timestamp'] = pd.to_datetime(funding_rates_df_tmp['timestamp'], utc=True)
        funding_rates_df_tmp.set_index('timestamp', drop=True, inplace=True)
        start_date = funding_rates_df_tmp.index[-1] + datetime.timedelta(hours=1)

        funding_rates_df = funding_rates_df.append([funding_rates_df_tmp])

    funding_rates_df.to_csv('funding_rates_df.csv')
    return funding_rates_df

def get_swap():
    # start_date = datetime.datetime(2016, 5, 14, 0, 0, 0, 0, tzinfo=pytz.utc)
    # end_date   = datetime.datetime.now(tz=pytz.utc)

    start_date = datetime.datetime(2019, 1, 1, 0, 0, 0, 0, tzinfo=pytz.utc)
    end_date   = datetime.datetime(2019, 11, 14, 23, 59, 59, 0, tzinfo=pytz.utc)

    endpoint = 'https://www.bitmex.com/api/v1/trade/bucketed'
    payload  = {'count':'1000', 'reverse':'false',
                'symbol':'XBTUSD', 'startTime': start_date, 'binSize': '1m'}
    response = requests.get(endpoint, params=payload)
    print(response)
    swap_df = pd.DataFrame(response.json())
    swap_df['timestamp'] = pd.to_datetime(swap_df['timestamp'], utc=True)
    swap_df.set_index('timestamp', drop=True, inplace=True)
    start_date = swap_df.index[-1] + datetime.timedelta(hours=1)

    while start_date < end_date:
        try:
            time.sleep(random())
            endpoint = 'https://www.bitmex.com/api/v1/trade/bucketed'
            payload  = {'count':'1000', 'reverse':'false',
                        'symbol':'XBTUSD', 'startTime': start_date, 'binSize': '1m'}
            response = requests.get(endpoint, params=payload)

            swap_df_tmp = pd.DataFrame(response.json())
            swap_df_tmp['timestamp'] = pd.to_datetime(swap_df_tmp['timestamp'], utc=True)
            swap_df_tmp.set_index('timestamp', drop=True, inplace=True)
            start_date = swap_df_tmp.index[-1] + datetime.timedelta(hours=1)

            swap_df = swap_df.append([swap_df_tmp])
            print(start_date)
        except Exception as e:
            print(e)
            continue

    swap_df.to_csv("swap_df_1m_2019.csv")
    return swap_df_tmp
swap = pd.read_csv("swap_df_1m.csv")
swap['timestamp'] = pd.to_datetime(swap['timestamp'], utc=True)
swap.set_index('timestamp', inplace=True)
swap.fillna(method='ffill', inplace=True)
swap.dropna(inplace=True)

funding = pd.read_csv("funding_rates_df.csv")
funding['timestamp'] = pd.to_datetime(funding['timestamp'], utc=True)
funding.set_index('timestamp', inplace=True)
funding = funding['fundingRate'].to_frame()
funding = funding.loc[funding.index >= '2016-06-05']

df = swap.join([funding])

# swap return by holding from funding time -30m to +1m
df['swapRet'] = df.swapPrice.shift(-300) / df.swapPrice.shift(30) - 1
df.dropna(inplace=True)
disp(df)
swapPrice fundingRate swapRet
timestamp
2016-06-05 04:00:00+00:00 585.6001 0.000242 -0.002756
2016-06-05 12:00:00+00:00 581.3784 0.000237 -0.004034
2016-06-05 20:00:00+00:00 580.9900 0.000234 -0.000978
... ... ... ...
2019-11-06 04:00:00+00:00 9314.4560 0.000100 0.013475
2019-11-06 12:00:00+00:00 9395.8470 0.000198 -0.008921
2019-11-06 20:00:00+00:00 9301.4603 0.000374 0.002900
colors = np.where(df.fundingRate >= 0, 'tab:green', 'tab:red')
fig, ax1 = plt.subplots()

ax1.plot(df.swapPrice, c='black', linewidth=0.3)
ax1.set_ylabel('swapPrice')
ax1.set_ylim(-22500, 22500)

ax2 = ax1.twinx()
ax2.scatter(df.index, df.fundingRate, c=colors, s=0.1)
ax2.set_ylabel('fundingRate')
ax2.set_ylim(-0.0075, 0.025)

plt.title("Swap Price vs Funding Rate")
plt.show()

output_11_0.png

Analysis

Regress the funding rates to the swap return at different enter/exit time. Here we are trying to look for statistically significant (ideally, negative) correlation between the two. Since a negative correlation would imply additional gain from price change on top of the funding profit. We only consider entering a contract if the funding is outside twice of its 60-day historical rolling standard deviations.

def filter_on_rolling_std(df, window, sigma_band, t1, t2, run_reg=False, show_summary=False, show_coef=True):
    df_sigma = df.copy()
    df_sigma['sigma'] = df_sigma.fundingRate.rolling(window).std()
    df_sigma = df_sigma.fillna(method = 'ffill').dropna()
    df_sigma = df_sigma.loc[(df_sigma.fundingRate > sigma_band * df_sigma.sigma) | (df_sigma.fundingRate < -sigma_band * df_sigma.sigma)]
    if run_reg:
        y = np.array(df_sigma['swapRet'])
        X = np.array(df_sigma[['fundingRate']])
        X = sm.add_constant(X)
        model = sm.OLS(y,X).fit()

        coef = model.params[1].round(4)
        pval = model.pvalues[0].round(4)
        if show_summary:
            print(model.summary())
        if show_coef:
            print('enter', t1, 'exit', t2, 'coef', coef, 'pval', pval)

    return df_sigma, coef, pval
comp_exit = pd.DataFrame(columns=['exit time', 'coef', 'pval'])
t1 = -100

for i in np.arange(0, 481, 10):
    t2=i

    df = swap.join([funding])
    df['swapRet'] = df.swapPrice.shift(-t2) / df.swapPrice.shift(-t1) - 1
    df.dropna(inplace=True)
    df_sigma, coef, pval = filter_on_rolling_std(df, 180, 2, t1, t2, True, show_coef=False)
    comp_exit = comp_exit.append({'exit time': t2, 'coef': coef, 'pval': pval}, ignore_index=True)


comp_exit = comp_exit.set_index('exit time')
plt.plot(comp_exit)
plt.axhline(y=0.05, color='grey', linestyle='dashed')
plt.legend(comp_exit.columns, frameon=False)
plt.xlabel('Exit Time')
plt.xticks(np.arange(0, 481, step=60))
plt.show()

png

Here we observe that as exit time becomes longer, the coefficient becomes more negative and p-value of the coefficient indicates higher significance. Thus we would want to hold the swap position more than 360 minutes/6 hours. Next we look at the impact from entry time.

comp_enter = pd.DataFrame(columns=['enter time', 'coef', 'pval'])
t1 = -60
t2 = 420

for i in np.arange(-120, 0, 1):
    t1=i

    df = swap.join([funding])
    df['swapRet'] = df.swapPrice.shift(-t2) / df.swapPrice.shift(-t1) - 1
    df.dropna(inplace=True)
    if t1 == -60:
        df_sigma, coef, pval = filter_on_rolling_std(df, 180, 2, t1, t2, True, show_summary=True, show_coef=False)
    else:
        df_sigma, coef, pval = filter_on_rolling_std(df, 180, 2, t1, t2, True, show_coef=False)
    comp_enter = comp_enter.append({'enter time': t1, 'coef': coef, 'pval': pval}, ignore_index=True)


comp_enter = comp_enter.set_index('enter time')
plt.plot(comp_enter)
plt.axhline(y=0.05, color='grey', linestyle='dashed')
plt.legend(comp_enter.columns, frameon=False)
plt.xlabel('Enter Time')
plt.xticks(np.arange(-120, 0, step=30))
plt.show()

png

Similar trends are observed in the entry times and that earlier the entry, the more profit it seems to imply from price changes. We show a summary of the regression at enter time minutes and exit time minutes. There is a moderate R-square of 0.039 and high significance in coefficient which suggest a mean reversion in price given that specific time window.

                                OLS Regression Results                            
    ==============================================================================
    Dep. Variable:                      y   R-squared:                       0.039
    Model:                            OLS   Adj. R-squared:                  0.036
    Method:                 Least Squares   F-statistic:                     10.46
    Date:                Thu, 05 Dec 2019   Prob (F-statistic):            0.00138
    Time:                        03:38:11   Log-Likelihood:                 552.01
    No. Observations:                 257   AIC:                            -1100.
    Df Residuals:                     255   BIC:                            -1093.
    Df Model:                           1                                         
    Covariance Type:            nonrobust                                         
    ==============================================================================
                     coef    std err          t      P>|t|      [0.025      0.975]
    ------------------------------------------------------------------------------
    const          0.0035      0.002      1.809      0.072      -0.000       0.007
    x1            -2.2357      0.691     -3.235      0.001      -3.597      -0.875
    ==============================================================================
    Omnibus:                       17.788   Durbin-Watson:                   2.247
    Prob(Omnibus):                  0.000   Jarque-Bera (JB):               54.546
    Skew:                           0.089   Prob(JB):                     1.43e-12
    Kurtosis:                       5.250   Cond. No.                         391.
    ==============================================================================

    Warnings:
    [1] Standard Errors assume that the covariance matrix of the errors is correctly specified.

Strategy

Based on the research above, this strategy will enter into a swap agreement to collect funding at and exit at . At , if the next funding does not fall outside of the 2-sigma band AND if we are in a position to collect the next funding, we will test two choices of

We will test the impact of a 10bps fee + slippage on each trade.

t1 = -60
t2 = 420
window = 180
sigma_band = 2
fee = 0.0010 # per two trades

df = swap.join([funding])
df['swapRet']   = df.swapPrice.shift(-t2) / df.swapPrice.shift(-t1) - 1
df['swapRet1H'] = df.swapPrice.shift(1) / df.swapPrice.shift(-t1) - 1
df.dropna(inplace=True)
df['sigma'] = df.fundingRate.rolling(window).std()
df.dropna(inplace=True)
df['pnl'] = np.where((df.fundingRate > sigma_band*df.sigma) \
                    | (df.fundingRate < -sigma_band*df.sigma), \
                    1 + np.abs(df.fundingRate) + \
                    df.swapRet * -np.sign(df.fundingRate), 1)

df['pnlOptimized'] = np.where((df.pnl != 1) & (df.pnl.shift(-1) == 1) \
                              & (np.sign(df.fundingRate) == np.sign(df.fundingRate.shift(-1))),
                              df.pnl + np.abs(df.fundingRate.shift(-1)) + \
                              df.swapRet1H.shift(-1) * -np.sign(df.fundingRate.shift(-1)), df.pnl)

df['pnlFee'] = np.where((df.pnl != 1) & (df.pnl.shift(1) == 1), df.pnl - fee, df.pnl) # enter fee
df['pnlFee'] = np.where((df.pnl != 1) & (df.pnl.shift(-1) == 1), df.pnlFee - fee, df.pnlFee) # exit fee
df['pnlFee'] = np.where((df.pnl != 1) & (df.pnl.shift(1) != 1) \
                        & (np.sign(df.fundingRate) != np.sign(df.fundingRate.shift(1))),
                        df.pnlFee - fee, df.pnlFee) # change position enter fee
df['pnlFee'] = np.where((df.pnl != 1) & (df.pnl.shift(-1) != 1) \
                        & (np.sign(df.fundingRate) != np.sign(df.fundingRate.shift(-1))),
                        df.pnlFee - fee, df.pnlFee) # change position exit fee
disp(df.iloc[289:293])
swapPrice fundingRate swapRet swapRet1H sigma pnl pnlOptimized pnlFee
timestamp
2016-11-16 20:00:00+00:00 748.1800 0.000264 0.008467 0.014128 0.001042 1.000000 1.000000 1.000000
2016-11-17 04:00:00+00:00 744.8734 0.003750 0.008865 0.001431 0.001066 0.994885 0.994885 0.993885
2016-11-17 20:00:00+00:00 742.2400 0.003140 -0.007095 -0.001269 0.001081 1.010235 1.008677 1.009235
2016-11-18 04:00:00+00:00 740.0665 0.001242 0.012663 0.002800 0.001080 1.000000 1.000000 1.000000
plt.plot(df.pnl.cumprod(), c='black')
plt.plot(df.pnlOptimized.cumprod(), c='tab:blue')
plt.plot(df.pnlFee.cumprod(), c='grey')
plt.legend(['PNL', 'PNL optimized', 'PNL with fee (not optimized)'])
plt.show()

png

def backtest_metric(df, pnl_column, mdd_interval=180):
    pnl = round(df[pnl_column].cumprod()[-1], 4)
    spr = round(np.mean(df[pnl_column]-1) / np.std(df[pnl_column]-1) * np.sqrt(365 * 3), 4)
    mdd = round(np.min((df[pnl_column].cumprod().rolling(mdd_interval).min() \
                        - df[pnl_column].cumprod().shift(mdd_interval)) \
                        / df[pnl_column].cumprod().shift(mdd_interval)), 4)
    return pnl, spr, mdd
result = pd.DataFrame(columns=['Strategy', 'P&L', 'Sharpe Ratio', 'Maximum Drawdown'])

pnl, spr, mdd = backtest_metric(df, 'pnl')
result = result.append({'Strategy': 'Baseline', 'P&L': pnl,
                        'Sharpe Ratio': spr, 'Maximum Drawdown': mdd}, ignore_index=True)

pnl, spr, mdd = backtest_metric(df, 'pnlOptimized')
result = result.append({'Strategy': 'Optimized', 'P&L': pnl,
                        'Sharpe Ratio': spr, 'Maximum Drawdown': mdd}, ignore_index=True)
disp(result)
Strategy P&L Sharpe Ratio Maximum Drawdown
0 Baseline 5.5625 2.2086 -0.1438
1 Optimized 6.7478 2.3101 -0.1221



We can see that this strategy does provide substantial P&L from the historical periods tested with relatively limited capital exposure. Fees would impact the gains slightly, and using an optimize approach would further improve the performance.




Reference:

[1]: XBTUSD Funding Mean Reversion Strategy, https://blog.bitmex.com/xbtusd-funding-mean-reversion-strategy/
[2]: Funding Mean Reversions 2018, https://blog.bitmex.com/funding-mean-reversions-2018/