Overall Statistics
Total Trades
1296
Average Win
0.41%
Average Loss
-0.44%
Compounding Annual Return
0.769%
Drawdown
35.000%
Expectancy
0.013
Net Profit
5.989%
Sharpe Ratio
0.115
Probabilistic Sharpe Ratio
0.357%
Loss Rate
48%
Win Rate
52%
Profit-Loss Ratio
0.94
Alpha
0.068
Beta
-0.448
Annual Standard Deviation
0.132
Annual Variance
0.017
Information Ratio
-0.467
Tracking Error
0.222
Treynor Ratio
-0.034
Total Fees
$5240.47
Estimated Strategy Capacity
$0.00054
Lowest Capacity Asset
ADRU SJNVOV70T9B9
# https://quantpedia.com/Screener/Details/55
import numpy as np
import pandas as pd
from scipy import stats
from math import floor
from datetime import timedelta
from collections import deque
import itertools as it
from decimal import Decimal

class PairsTradingwithCountryETFsAlgorithm(QCAlgorithm):

    def Initialize(self):

        self.SetStartDate(2011, 1, 1)  
        self.SetEndDate(2018, 8, 1)  
        self.SetCash(100000) 
        # choose ten sector ETFs
        tickers =  ["GAF",  # SPDR S&P Emerging Middle East & Africa ETF 2007.4
                    "ENZL", # iShares MSCI New Zealand Investable Market Index Fund 2010.9
                    "NORW",  # Global X FTSE Norway 30 ETF 2011
                    "EWY",  # iShares MSCI South Korea Index ETF 2000.6
                    "EWP",  # iShares MSCI Spain Index ETF 1996
                    "EWD",  # iShares MSCI Sweden Index ETF 1996
                    "EWL",  # iShares MSCI Switzerland Index ETF 1996
                    "GXC",  # SPDR S&P China ETF 2007.4
                    "EWC",  # iShares MSCI Canada Index ETF 1996
                    "EWZ",  # iShares MSCI Brazil Index ETF 2000.8
                    # "AND",  # Global X FTSE Andean 40 ETF 2011.3
                    "AIA",  # iShares S&P Asia 50 Index ETF 1996
                    "EWO",  # iShares MSCI Austria Investable Mkt Index ETF 1996
                    "EWK",  # iShares MSCI Belgium Investable Market Index ETF 1996
                    "ECH",  # iShares MSCI Chile Investable Market Index ETF 2018  2008
                    # "EGPT", # Market Vectors Egypt Index ETF 2011
                    "EWJ",  # iShares MSCI Japan Index ETF 1999
                    "EZU",  # iShares MSCI Eurozone ETF 2000
                    "EWW",  # iShares MSCI Mexico Inv. Mt. Idx 2000
                    # "ERUS", # iShares MSCI Russia ETF 2011
                    "IVV",  # iShares S&P 500 Index 2001
                    "AAXJ", # iShares MSCI All Country Asia ex Japan Index ETF 2008.8
                    "EWQ",  # iShares MSCI France Index ETF 2000
                    "EWH",  # iShares MSCI Hong Kong Index ETF 1999
                    # "EPI",  # WisdomTree India Earnings ETF 2008.3
                    "EIDO",  # iShares MSCI Indonesia Investable Market Index ETF 2008.3
                    "EWI",  # iShares MSCI Italy Index ETF 1996
                    "ADRU"] # BLDRS Europe 100 ADR Index ETF 2003

        self.threshold = 0.5
        self.symbols = []
        for i in tickers:
            self.symbols.append(self.AddEquity(i, Resolution.Hour).Symbol)
        
        self.pairs = {}
        self.formation_period = 121

        self.history_price = {}
        for symbol in self.symbols:
            hist = self.History([symbol.Value], self.formation_period+1, Resolution.Hour)
            if hist.empty: 
                self.symbols.remove(symbol)
            else:
                self.history_price[symbol.Value] = deque(maxlen=self.formation_period)
                for tuple in hist.loc[str(symbol)].itertuples():
                    self.history_price[symbol.Value].append(float(tuple.close))
                if len(self.history_price[symbol.Value]) < self.formation_period:
                    self.symbols.remove(symbol)
                    self.history_price.pop(symbol.Value)

        self.symbol_pairs = list(it.combinations(self.symbols, 2))  
        # Add the benchmark
        self.AddEquity("SPY", Resolution.Daily) 
        self.Schedule.On(self.DateRules.MonthStart("SPY"), self.TimeRules.AfterMarketOpen("SPY"), self.Rebalance)
        self.sorted_pairs = None


    def OnData(self, data):
        # Update the price series everyday
        for symbol in self.symbols:
            if data.Bars.ContainsKey(symbol) and symbol.Value in self.history_price:
                self.history_price[symbol.Value].append(float(data[symbol].Close)) 
        if self.sorted_pairs is None: return
        
        for i in self.sorted_pairs:
            pair = Pair(i[0], i[1], self.history_price[i[0].Value],  self.history_price[i[1].Value])
            index_a = pair.index_a[-1]
            index_b = pair.index_b[-1]
            delta = pair.distance()
            if index_a - index_b > self.threshold*delta:
                if not self.Portfolio[pair.symbol_a].Invested and not self.Portfolio[pair.symbol_b].Invested:
                    ratio = self.Portfolio[pair.symbol_a].Price / self.Portfolio[pair.symbol_b].Price
                    quantity = int(self.CalculateOrderQuantity(pair.symbol_a, 0.2))
                    self.Sell(pair.symbol_a, quantity) 
                    self.Buy(pair.symbol_b,  floor(ratio*quantity))                  
            
            elif index_a - index_b < -self.threshold*delta:
                if not self.Portfolio[pair.symbol_a].Invested and not self.Portfolio[pair.symbol_b].Invested:
                    ratio = self.Portfolio[pair.symbol_b].Price / self.Portfolio[pair.symbol_a].Price
                    quantity = int(self.CalculateOrderQuantity(pair.symbol_b, 0.2))
                    self.Sell(pair.symbol_b, quantity) 
                    self.Buy(pair.symbol_a, floor(ratio*quantity))              
            
            # the position is closed when prices revert back
            elif self.Portfolio[i[0]].Invested and self.Portfolio[i[1]].Invested:
                    self.Liquidate(pair.symbol_a) 
                    self.Liquidate(pair.symbol_b)  
      
    
    def Rebalance(self):
        # schedule the event to fire every half year to select pairs with the smallest historical distance
        distances = {}
        for i in self.symbol_pairs:
            if i[0].Value in self.history_price and i[1].Value in self.history_price:
                distances[i] = Pair(i[0], i[1], self.history_price[i[0].Value],  self.history_price[i[1].Value]).distance()
        self.sorted_pairs = sorted(distances, key = lambda x: distances[x])[:5]

class Pair:
    def __init__(self, symbol_a, symbol_b, price_a, price_b):
        self.symbol_a = symbol_a
        self.symbol_b = symbol_b
        self.price_a = np.array(price_a)
        self.price_b = np.array(price_b)
        # compute normalized cumulative price indices 
        self.index_a = np.cumprod(self.price_a[1:]/self.price_a[:-1])
        self.index_b = np.cumprod(self.price_b[1:]/self.price_b[:-1])
        
    
    def distance(self):
        return 1/120*sum(abs(self.index_a -self.index_b))