Overall Statistics
Total Orders
302
Average Win
0.20%
Average Loss
-0.19%
Compounding Annual Return
-2.283%
Drawdown
9.500%
Expectancy
0.025
Start Equity
100000
End Equity
91182.58
Net Profit
-8.817%
Sharpe Ratio
-0.618
Sortino Ratio
-0.816
Probabilistic Sharpe Ratio
0.056%
Loss Rate
49%
Win Rate
51%
Profit-Loss Ratio
1.02
Alpha
-0.008
Beta
-0.232
Annual Standard Deviation
0.041
Annual Variance
0.002
Information Ratio
-0.789
Tracking Error
0.128
Treynor Ratio
0.109
Total Fees
$525.55
Estimated Strategy Capacity
$780000.00
Lowest Capacity Asset
BCH SB7MJZ5V64PX
Portfolio Turnover
2.28%
#region imports
from AlgorithmImports import *

from scipy import stats
from math import floor
from datetime import timedelta
from collections import deque
import itertools as it
from decimal import Decimal
#endregion
# https://quantpedia.com/Screener/Details/12


class PairsTradingAlgorithm(QCAlgorithm):
    
    def initialize(self):
        self.set_start_date(2014,1,1)
        self.set_end_date(2018,1,1)
        self.set_cash(100000)
       
        tickers = [ 'XLK', 'QQQ', 'BANC', 'BBVA', 'BBD', 'BCH', 'BLX', 'BSBR', 'BSAC', 'SAN',
                    'CIB', 'BXS', 'BAC', 'BOH', 'BMO', 'BK', 'BNS', 'BKU', 'BBT','NBHC', 'OFG',
                    'BFR', 'CM', 'COF', 'C', 'VLY', 'WFC', 'WAL', 'WBK','RBS', 'SHG', 'STT', 'STL', 'SCNB', 'SMFG', 'STI']
                    # 'DKT', 'DB', 'EVER', 'KB', 'KEY', , 'MTB', 'BMA', 'MFCB', 'MSL', 'MTU', 'MFG', 
                    # 'PVTD', 'PB', 'PFS', 'RF', 'RY', 'RBS', 'SHG', 'STT', 'STL', 'SCNB', 'SMFG', 'STI',
                    # 'SNV', 'TCB', 'TD', 'USB', 'UBS', 'VLY', 'WFC', 'WAL', 'WBK', 'WF', 'YDKN', 'ZBK']
        self._threshold = 2
        self._symbols = []
        for i in tickers:
            self._symbols.append(self.add_equity(i, Resolution.DAILY).symbol)
        
        formation_period = 252

        self._history_price = {}
        for symbol in self._symbols:
            hist = self.history([symbol], formation_period+1, Resolution.DAILY)
            if hist.empty: 
                self._symbols.remove(symbol)
            else:
                self._history_price[str(symbol)] = deque(maxlen=formation_period)
                for tuple in hist.loc[str(symbol)].itertuples():
                    self._history_price[str(symbol)].append(float(tuple.close))
                if len(self._history_price[str(symbol)]) < formation_period:
                    self._symbols.remove(symbol)
                    self._history_price.pop(str(symbol))

        self._symbol_pairs = list(it.combinations(self._symbols, 2))  
        # Add the benchmark
        self.add_equity("SPY", Resolution.DAILY) 
        self.schedule.on(self.date_rules.month_start("SPY"), self.time_rules.after_market_open("SPY"), self._rebalance)
        self._count = 0
        self._sorted_pairs = None
        
    def on_data(self, data):
        # Update the price series everyday
        for symbol in self._symbols:
            if data.bars.contains_key(symbol) and str(symbol) in self._history_price:
                self._history_price[str(symbol)].append(float(data[symbol].close)) 
        if self._sorted_pairs is None: 
            return
        
        for i in self._sorted_pairs:
            # calculate the spread of two price series
            spread = np.array(self._history_price[str(i[0])]) - np.array(self._history_price[str(i[1])])
            mean = np.mean(spread)
            std = np.std(spread)
            ratio = self.portfolio[i[0]].price / self.portfolio[i[1]].price
            # long-short position is opened when pair prices have diverged by two standard deviations
            if spread[-1] > mean + self._threshold * std:
                if not self.portfolio[i[0]].invested and not self.portfolio[i[1]].invested:
                    quantity = int(self.calculate_order_quantity(i[0], 0.1))
                    self.sell(i[0], quantity) 
                    self.buy(i[1], floor(ratio*quantity))                
            
            elif spread[-1] < mean - self._threshold * std: 
                quantity = int(self.calculate_order_quantity(i[0], 0.1))
                if not self.portfolio[i[0]].invested and not self.portfolio[i[1]].invested:
                    self.sell(i[1], quantity) 
                    self.buy(i[0], 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(i[0]) 
                    self.liquidate(i[1])                

    def _rebalance(self):
        # schedule the event to fire every half year to select pairs with the smallest historical distance
        if self._count % 6 == 0:
            distances = {}
            for i in self._symbol_pairs:
                distances[i] = Pair(i[0], i[1], self._history_price[str(i[0])], self._history_price[str(i[1])]).distance()
                self._sorted_pairs = sorted(distances, key = lambda x: distances[x])[:4]
        self._count += 1
            

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 = price_a
        self.price_b = price_b
    
    def distance(self):
        # calculate the sum of squared deviations between two normalized price series
        norm_a = np.array(self.price_a)/self.price_a[0]
        norm_b = np.array(self.price_b)/self.price_b[0]
        return sum((norm_a - norm_b)**2)