| 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'], ]