Quant BuffetRelax, Not Over Thinking

Pairs Trading in Cryptocurrencies

Log in to collect

Academic paper

Cryptocurrency Pair Trading

AuthorsChiara Lesa; Ronald Hochreiter

Institute
  • ATVienna University of Economics and Business
  • ?WU Vienna University of Economics and Business
  • ATWebster Vienna Private University
  • ?Webster University - Webster Vienna Private University

Strategy in a nutshell

Pairs trading on top 20 cryptocurrencies by market cap. Select coin pairs with minimal price-distance residuals, track z-score of price distance, and trade when threshold (e.g., 2σ) is exceeded. Positions closed when distance normalizes. Equal-weighted, rebalanced daily.

Economic rationale

Strategy exploits mean-reversion in coin price pairs. Deviations from historical price distances are expected to revert, allowing statistical arbitrage. Empirical tests confirm mean-reversion in crypto markets, supporting profitability.

Backtest performance

Annualised return70.4%
Volatility39.76%
Beta-0.002
Sharpe ratio1.77
Sortino ratio-0.475
Maximum drawdown-21%
Win rate58%

Full Python code

from AlgorithmImports import *
from numpy import floor
from datetime import datetime
from itertools import combinations
# endregion

class PairsTradinginCryptocurrencies(QCAlgorithm):

def Initialize(self):
self.SetStartDate(2018, 1, 1)
self.SetCash(100000)

# formation-trading ratio of 2:1
self.formation_period:int = int((datetime.now() - self.Time).days / 3 * 2)
self.z_score_period:int = 6 * 21

self.market:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol

self.crypto_tickers:List[str] = [
    'LINKUSD', 'QTUMUSD', 'NEOUSD', 'OMGUSD', 'BATUSD', 'ETHUSD', 'BNTUSD', 'XLMUSD', 'ADAUSD', 'XRPUSD',
    'EOSUSD', 'ETCUSD', 'ZECUSD', 'DASHUSD', 'XMRUSD', 'TRXUSD', 'ENJUSD', 'MANAUSD' # 'BNBUSD'
]

self.possible_pairs = set(combinations(self.crypto_tickers, 2))

self.pair_count:int = 6
self.leverage:int = 2
self.price:Dict[str, RollingWindow] = {}
self.percentage_traded:float = .2

self.threshold:float = 2.
self.pair_z_score:Dict[TickerPair, RollingWindow] = {}

self.SetWarmup(self.formation_period, Resolution.Daily)
self.traded_pairs:List[TickerPair] = list()

# data subscription
for ticker in self.crypto_tickers:
    data = self.AddCrypto(ticker, Resolution.Daily, Market.Bitfinex)
    data.SetLeverage(self.leverage)

    self.price[ticker] = RollingWindow[float](self.formation_period)

self.recent_month:int = -1

def OnData(self, data: Slice) -> None:
# store daily prices
for ticker in self.crypto_tickers:
    if ticker in data and data[ticker]:
        self.price[ticker].Add(data[ticker].Value)

if self.IsWarmingUp: return

for pair in self.traded_pairs:
    price_i:np.ndarray = np.array(list(self.price[pair._ticker_i])[::-1])
    price_j:np.ndarray = np.array(list(self.price[pair._ticker_j])[::-1])

    St:np.ndarray = price_i - price_j
    
    # price residuals, rolling mean and std
    St_rolling:np.ndarray = [St[i - self.z_score_period : i] for i in range(self.z_score_period, self.formation_period)]
    Dt_rolling:np.ndarray = [np.mean(st) for st in St_rolling]
    STDt_rolling:np.ndarray = [np.std(st) for st in St_rolling]

    # z-score
    Zt:np.ndarray = [(st[-1] - Dt_rolling[i]) / STDt_rolling[i] for i, st in enumerate(St_rolling)]

    Zt_mean:float = np.mean(Zt)
    Zt_std:float = np.std(Zt)
    
    quantity_i:float = floor((self.Portfolio.TotalPortfolioValue * self.percentage_traded) / self.pair_count / price_i[-1])
    quantity_j:float = floor((self.Portfolio.TotalPortfolioValue * self.percentage_traded) / self.pair_count / price_j[-1])

    # if the z-score touches the threshold from bellow, the spread is overpriced and shorted by selling coin i and buying coin j
    upper_threshold:float = Zt_mean + (self.threshold * Zt_std)
    lower_threshold:float = Zt_mean - (self.threshold * Zt_std)

    if Zt[-1] > upper_threshold:
        if pair.spread_is_bought():
            self.liquidate_spread(pair)
        else:
            if not pair.spread_is_sold():
                self.trade_spread(pair, -quantity_i, quantity_j)
        
    # when threshold is hit from above, then the portfolio value is below its long-run value so that the spread is bought, which means buying coin i and selling coin j
    elif Zt[-1] < lower_threshold:
        if pair.spread_is_sold():
            self.liquidate_spread(pair)
        else:
            if not pair.spread_is_bought():
                self.trade_spread(pair, quantity_i, -quantity_j)
    
    elif Zt[-1] < Zt_mean and Zt[-1] > lower_threshold:
        if pair.spread_is_sold():
            self.liquidate_spread(pair)

    elif Zt[-1] > Zt_mean and Zt[-1] < upper_threshold:
        if pair.spread_is_bought():
            self.liquidate_spread(pair)

# find new pairs to trade
if len([x for x in self.crypto_tickers if self.price[x].IsReady]) < self.pair_count:
    return
else:
    if len(self.traded_pairs) != 0:
        return

ssd_by_pair:Dict[TickerPair, float] = {}

for ticker_i, ticker_j in self.possible_pairs:
    if not self.price[ticker_i].IsReady or not self.price[ticker_j].IsReady: continue

    price_i:np.ndarray = np.array(list(self.price[ticker_i])[::-1])
    price_j:np.ndarray = np.array(list(self.price[ticker_j])[::-1])
    St:np.ndarray = price_i - price_j
    ssd_by_pair[TickerPair(ticker_i, ticker_j)] = sum(St ** 2)

if len(ssd_by_pair) != 0:
    sorted_by_ssd:List[TickerPair] = sorted(ssd_by_pair, key=ssd_by_pair.get, reverse=True)
    self.traded_pairs = sorted_by_ssd[-self.pair_count:]

def trade_spread(self, pair, quantity_i:float, quantity_j:float) -> None:
self.MarketOrder(pair._ticker_i, quantity_i)
self.MarketOrder(pair._ticker_j, quantity_j)
pair.set_quantity(quantity_i, quantity_j)

def liquidate_spread(self, pair) -> None:
self.MarketOrder(pair._ticker_i, -pair._quantity_i)
self.MarketOrder(pair._ticker_j, -pair._quantity_j)
pair.set_quantity(0., 0.)

class TickerPair():
def __init__(self, ticker_i:str, ticker_j:str) -> None:
self._ticker_i:str = ticker_i
self._ticker_j:str = ticker_j

self._quantity_i:float = 0.
self._quantity_j:float = 0.

def set_quantity(self, quantity_i:float, quantity_j:float) -> None:
self._quantity_i = quantity_i
self._quantity_j = quantity_j

def spread_is_bought(self) -> bool:
return self._quantity_i > 0. and self._quantity_j < 0.

def spread_is_sold(self) -> bool:
return self._quantity_i < 0. and self._quantity_j > 0.