Overall Statistics
Total Trades
390
Average Win
0.60%
Average Loss
-0.63%
Compounding Annual Return
2.463%
Drawdown
8.300%
Expectancy
0.108
Net Profit
19.463%
Sharpe Ratio
0.428
Probabilistic Sharpe Ratio
3.418%
Loss Rate
43%
Win Rate
57%
Profit-Loss Ratio
0.94
Alpha
0.016
Beta
0.015
Annual Standard Deviation
0.042
Annual Variance
0.002
Information Ratio
-0.466
Tracking Error
0.152
Treynor Ratio
1.164
Total Fees
$439.66
Estimated Strategy Capacity
$1300000.00
Lowest Capacity Asset
PEP 2T
#region imports

from AlgorithmImports import *
from universes import *
import json
#endregion


"""
Simple Bollinger bands pair trading also
open long or short pair trade at a 2 std deviation, close at middle band cross in 2 steps:
  - intraday as soon as middle band crossed
  - end of day on re-cross in the "wrong way" to allow participation in the trend, we may  loose a little versus closing all @ middle band 
 but should have opportunities for large gain in longer trends

research.ipynb allows us to see and manipulate the saved states of the algo (in live mode only, nothing is saved during backtest)

We save the state for each trade as things could crash in the middle and we do not want to reconcile manually...

TODO implement update_adj_price before going live!
TODO ADD (lots) of logging before go live
TODO RECONCILIATION: make sure both legs of the pair trade actually execute to avoid being unhedged
TODO RECONCILIATION: fill self.pair_qty in 
TODO Add earnings check: do not enter 1 or 2 weeks (analysis to be completed) before earnings
TODO Add earnings check: exit 2 days before earnings to avoid randomness.
"""

class PairTrading(QCAlgorithm):

    def Initialize(self):

        ###############################################################
        ############## START ALGO CONFIG

        #relies on pairs list defined in universes.py 
        pairs_2021 = rw_2021
        # pairs_2021 = list(set((x,y) for x, y in rw_2021) | set((x,y) for x, y in rw_fab_pairs_2021))

        BACKTEST_START_YEAR = 2015

        # Notional size for each leg of the trade
        INITIAL_TRADE_SIZE = 15000

        # PARAM FOR MAX NUMBER OF CONCURRENT PAIR TRADE 
        # set to len(pairs_2021) to test the overall stability, taking the first n slots introduces a bit of randomness 
        # TODO is it worth ranking pairs "somehow" (selection score or recent performance...) before allocating new slots?
        self.MAX_POS = 2 #len(pairs_2021)

        # MAX PERCENTAGE OF PORTFOLIO IN SINGLE SECURITY, no more than 3 trades if small number of pairs else 5%, that seems reasonable
        self.MAX_CONCENTRATION = max(0.05, 3.0 / self.MAX_POS)

        # TODO MAX SECTOR/INDUSTRY CONCENTRATION
        
        year_pairs = {
            # 2010 : pairs_2021,
            # 2011 : pairs_2021,
            # 2012 : pairs_2021,
            # 2013 : pairs_2021,
            # 2014 : pairs_2021,
            2015 : pairs_2021,
            2016 : pairs_2021,
            2017 : pairs_2021,
            2018 : pairs_2021,
            2019 : pairs_2021,
            2020 : pairs_2021,
            2021 : pairs_2021,
            2022 : pairs_2021,
            }

        self.SetStartDate(BACKTEST_START_YEAR, 1, 1)  # Set Start Date
        self.SetCash(self.MAX_POS * INITIAL_TRADE_SIZE)  # Set Strategy Cash

        ###############################################################
       
        self.year_pair_name = {}
        #self.ids = {}
        
        self.ratios = {}
        self.bbs = {}

        added_sym = set()
        added_pairs = set()
        
        self.trade_on = {}
        self.pstate = {}
        self.pair_qty = {}
        
        # we will adjust the prices ourselves to allow the algo to deal with it without a restart once live
        self.price_adj_factor = {} 

        # we load state data for live before we initialise defaults
        self.load_live_state()

        for year, pairs in year_pairs.items():
            for p in pairs:
                s1 = p[0]
                s2 = p[1]
                
                
                self.add_symbol(s1, added_sym)
                self.add_symbol(s2, added_sym)
                pair_name = self.get_pair_name(s1, s2)
                if year not in self.year_pair_name:
                    self.year_pair_name[year] = set()
                self.year_pair_name[year].add(pair_name)

                if pair_name not in added_pairs:
                    self.ratios[pair_name] = None
                    self.bbs[pair_name] = BollingerBands(20, 2) # try sma instead of default ema
                    added_pairs.add(pair_name)
                    self.pstate[pair_name] = self.pstate.get(pair_name, 0)
                    self.pair_qty[pair_name] = self.pair_qty.get(pair_name, [0 , 0])
                    self.trade_on[pair_name] = self.trade_on.get(pair_name, 0)                
        
        self.pday = 0
        self.rebalance = False

        ref_sym = "SPY"#pairs_2021[0][0]# "LNG"
        self.AddEquity("SPY")
        self.Schedule.On(self.DateRules.EveryDay(), self.TimeRules.BeforeMarketClose(ref_sym, 15), self.rebalance_flag)

        self.SetWarmup(TimeSpan.FromDays(30))

    def add_symbol(self, symbol, added_sym):
        if symbol in added_sym:
            return
        security = self.AddEquity(symbol, Resolution.Minute)

        # we set leverage to 2.5 to allow for long short full account and margin portfolio 
        #security.MarginModel = PatternDayTradingMarginModel()
        security.SetLeverage(3)
        if self.LiveMode:
            security.SetDataNormalizationMode(DataNormalizationMode.Raw)
        else:
            security.SetDataNormalizationMode(DataNormalizationMode.TotalReturn)

        added_sym.add(symbol)
        self.price_adj_factor[symbol] = 1.0        
    
    def rebalance_flag(self):
        self.rebalance = True

    def OnData(self, data: Slice):
        # workaround Schedule.On not called before end of warmup
        if self.IsWarmingUp: 
            if self.Time.hour == 15 and self.Time.minute == 30:
                self.rebalance = True      

        #if self.pday != self.Time.day:
        if self.rebalance:
            # golong = set()
            # goshort = set()
            # udpate indicators first
            for pair in self.ratios.keys():
                stock1, stock2 = self.get_2symbols_from_pair_name(pair)            
                if self.Securities[stock2].Price == 0:
                    continue
                
                #first let's deal with dividends and split etc...
                # Slice: Splits Splits;
                # Slice: Dividends Dividends;
                # Slice: Delistings Delistings;
                self.update_adj_prices(data)

                self.ratios[pair] = self.get_ratio(stock1, stock2)
                self.bbs[pair].Update(IndicatorDataPoint(self.Time, self.ratios[pair]))
            
            if not self.IsWarmingUp:
                # First, close all possible positions to know how many knew we will be abe to open
                self.close_positions()

                # Open new positions
                # but first count open spread trade live so we don't open more than self.MAX_POS
                s2qty = self.open_positions()
                
                # TODO add a trending component when crossing middle band: close half then trail with running min of 1 std
                

            self.rebalance = False
            self.pday = self.Time.day
        elif not self.IsWarmingUp: #intraday take profit asap
            self.take_profit_intraday(data)
    
    def get_pair_name(self, s1, s2):
        return s1 + '#' + s2

    def get_2symbols_from_pair_name(self, pair_name):
        return pair_name.split('#')

    def get_state(self, ratio, pair):
        state = 0
        if ratio > self.bbs[pair].UpperBand.Current.Value:
            state = 2
        elif ratio > self.bbs[pair].MiddleBand.Current.Value:
            state = 1
        elif ratio < self.bbs[pair].LowerBand.Current.Value:
            state = -2
        elif ratio < self.bbs[pair].MiddleBand.Current.Value:
            state = -1
        return state

    def get_ratio(self, stock1, stock2):
        return self.Securities[stock1].Price * self.price_adj_factor[stock1] / (self.Securities[stock2].Price * self.price_adj_factor[stock1])

    def update_adj_prices(self, data: Slice):
        # TODO deal with adj prices for live trading
        if self.LiveMode:
            raise Exception("not implemented")        

    def close_positions(self):
        for pair in self.ratios.keys():
            stock1, stock2 = self.get_2symbols_from_pair_name(pair)
            if self.Securities[stock2].Price == 0:
                continue
            if self.bbs[pair].IsReady:
                state = self.get_state(self.ratios[pair], pair)
                if not self.IsWarmingUp and len(self.ratios) <= 10: # cannot plot more than 10 series
                    self.Plot('state', pair, state)
        
                tag = f"{pair} {state} {self.pstate[pair]}  {self.ratios[pair]}"
                if pair in self.year_pair_name.get(self.Time.year, []) and state == 2:
                    pass
                elif pair in self.year_pair_name.get(self.Time.year, []) and state == -2:
                    pass   
                elif self.pstate[pair] < 0 and state > 0 and self.trade_on[pair] > 0:
                    self.trade_on[pair] = 0
                    q1, q2 = self.pair_qty[pair]
        
                    # because we could be trading the stock on 2 sides resulting qty could be 0
                    if q1 != 0 : 
                        self.MarketOrder(stock1, -q1, tag=tag+",close")
                    if q2 != 0 : 
                        self.MarketOrder(stock2, -q2, tag=tag+",close")
                    self.pair_qty[pair] = [0, 0]
                    self.save_live_state()
                elif self.pstate[pair] > 0 and state < 0 and self.trade_on[pair] < 0:
                    self.trade_on[pair] = 0
                    q1, q2 = self.pair_qty[pair]
        
                    if q1 != 0 : 
                        self.MarketOrder(stock1, -q1, tag=tag+",close")
                    if q2 != 0 : 
                        self.MarketOrder(stock2, -q2, tag=tag+",close")
                    self.pair_qty[pair] = [0, 0]
                    self.save_live_state()
        

    def open_positions(self):
        current_pos_count = len([ v for v in self.trade_on.values() if v != 0])
        for pair in self.ratios.keys():
            stock1, stock2 = self.get_2symbols_from_pair_name(pair)
        
            if self.Securities[stock2].Price == 0:
                continue
            if self.bbs[pair].IsReady:
                state = self.get_state(self.ratios[pair], pair)
                if current_pos_count >= self.MAX_POS:
                    self.pstate[pair] = state
                    break
        
                tag = f"{pair} {state} {self.pstate[pair]} {self.ratios[pair]}"
                if pair in self.year_pair_name.get(self.Time.year, []) and state == 2:
                    #if self.Portfolio[stock1].Quantity >= 0:
                    if self.trade_on[pair] >= 0:
                        self.trade_on[pair] = -2
                        weight = 1.0 / self.MAX_POS # len(self.year_pair_name[self.Time.year])
                        price1 = self.Securities[stock1].Price
                        price2 = self.Securities[stock2].Price
                        pf_value = self.Portfolio.TotalPortfolioValue
        
                        if abs(-weight + self.Portfolio[stock1].Quantity * price1 / pf_value) > self.MAX_CONCENTRATION:
                            # would be above concentration dont trade
                            continue
                        if abs(weight + self.Portfolio[stock1].Quantity * price2 / pf_value) > self.MAX_CONCENTRATION:
                            # would be above concentration dont trade
                            continue
        
                        weightval = weight * pf_value
                        s1qty = round(weightval / price1 , 0)
                        s2qty = round(weightval / price2 , 0)
        
                        # !!! rounding could make it 0 for just 1 piece
                        # TODO check that the resulting ratio is still close to the initial one so that we have a change to converge profitably!
                        # TODO linked to the above => beware of adjusted prices in backtest which might make the ratio possible or impossible to trade in the past
                        if s1qty != 0 and s2qty != 0: 
                            self.MarketOrder(stock1, -s1qty, tag=tag)
                            self.MarketOrder(stock2, s2qty, tag=tag)
                            self.pair_qty[pair] = [-s1qty, s2qty]
                            self.save_live_state()
                            current_pos_count += 1
                #elif self.pstate == -2 and state > -2:
                elif pair in self.year_pair_name.get(self.Time.year, []) and state == -2:
                    #if self.Portfolio[stock1].Quantity <= 0:
                    if self.trade_on[pair] <= 0:
                        self.trade_on[pair] = 2
                        weight = 1.0 / self.MAX_POS # len(self.year_pair_name[self.Time.year])
                        price1 = self.Securities[stock1].Price
                        price2 = self.Securities[stock2].Price
                        pf_value = self.Portfolio.TotalPortfolioValue
        
                        if abs(weight + self.Portfolio[stock1].Quantity * price1 / pf_value) > self.MAX_CONCENTRATION:
                            # would be above concentration dont trade
                            continue
                        if abs(-weight + self.Portfolio[stock1].Quantity * price2 / pf_value) > self.MAX_CONCENTRATION:
                            # would be above concentration dont trade
                            continue
                
                        weightval = weight * pf_value
                        s1qty = round(weightval / price1 , 0)
                        s2qty = round(weightval / price2 , 0)
                        if s1qty != 0 and s2qty != 0:                             
                            self.MarketOrder(stock1, s1qty, tag=tag)
                            self.MarketOrder(stock2, -s2qty, tag=tag)
                            self.pair_qty[pair] = [s1qty, -s2qty]
                            self.save_live_state()
                            current_pos_count += 1
        
                self.pstate[pair] = state
        

    def take_profit_intraday(self, data):
        # TODO loop only on trade_on pairs => faster
        for pair in self.ratios.keys():
            if self.trade_on[pair] == 0:
                continue
            stock1, stock2 = self.get_2symbols_from_pair_name(pair)
            if self.ratios[pair] != None and data.Bars.ContainsKey(stock1) and data.Bars.ContainsKey(stock2):
                ratio = data.Bars[stock1].Close / data.Bars[stock2].Close
        
                if self.trade_on[pair] == 2 and ratio > self.bbs[pair].MiddleBand.Current.Value or \
                    self.trade_on[pair] == -2 and ratio < self.bbs[pair].MiddleBand.Current.Value:
                    self.trade_on[pair] = self.trade_on[pair] / 2 # ie -1 or 1                        
                    q1, q2 = self.pair_qty[pair]
                    rq1 = round(q1/2,0)
                    rq2 = round(q2/2,0)
                    if rq1 != 0 and rq2 != 0: # otherwise will get unbalanced
                        self.MarketOrder(stock1, -rq1, tag="half")
                        self.MarketOrder(stock2, -rq2, tag="half")
            
                        self.pair_qty[pair] = [q1-rq1, q2-rq2]  
                        self.save_live_state()

    def save_live_state(self):
       if self.LiveMode:
            self.ObjectStore.Save("pair_qty", json.dumps(self.pair_qty))
            self.ObjectStore.Save("trade_on", json.dumps(self.trade_on))
            self.ObjectStore.Save("pstate", json.dumps(self.pstate))

    def load_live_state(self):
       if self.LiveMode:
            if self.ObjectStore.ContainsKey("pair_qty"):
                self.pair_qty = json.loads(self.ObjectStore.Read("pair_qty"))
            if self.ObjectStore.ContainsKey("trade_on"):
                self.trade_on = json.loads(self.ObjectStore.Read("trade_on"))
            if self.ObjectStore.ContainsKey("pstate"):
                self.pstate = json.loads(self.ObjectStore.Read("pstate"))

#region imports
from AlgorithmImports import *
#endregion


# Your New Python File
rw_2021 = [['KO','PEP']#,['F','GM'],
]