Overall Statistics |
Total Trades 509 Average Win 1.50% Average Loss -0.79% Compounding Annual Return 22.025% Drawdown 59.400% Expectancy 0.406 Net Profit 110.978% Sharpe Ratio 0.707 Probabilistic Sharpe Ratio 20.750% Loss Rate 52% Win Rate 48% Profit-Loss Ratio 1.90 Alpha 0.179 Beta 0.101 Annual Standard Deviation 0.268 Annual Variance 0.072 Information Ratio 0.276 Tracking Error 0.315 Treynor Ratio 1.883 Total Fees $991.49 Estimated Strategy Capacity $7100000.00 Lowest Capacity Asset AXNX WZ5BE168YI5H |
#region imports from AlgorithmImports import * #endregion """ SEL(stock selection part) Based on the 'Quality Companies in an Uptrand' strategy introduced by Chris Cain, 22 Nov 2019 adapted and recoded by Jonathon Tzu and Peter Guenther https://www.quantconnect.com/forum/discussion/9678/quality-companies-in-an-uptrend/p1 https://www.quantconnect.com/forum/discussion/9632/amazing-returns-superior-stock-selection-strategy-superior-in-amp-out-strategy/p2 I/O(in & out part) Based on the 'In & Out' strategy introduced by Peter Guenther, 4 Oct 2020 expanded/inspired by Tentor Testivis, Dan Whitnable (Quantopian), Vladimir, Thomas Chang, Mateusz Pulka, Derek Melchin (QuantConnect), Nathan Swenson, Goldie Yalamanchi, and Sudip Sil https://www.quantopian.com/posts/new-strategy-in-and-out https://www.quantconnect.com/forum/discussion/9597/the-in-amp-out-strategy-continued-from-quantopian/p1 code version: In_out_flex_v5_disambiguate_v3 """ from QuantConnect.Data.UniverseSelection import * import math import numpy as np import pandas as pd import scipy as sp class QualUp_InOut(QCAlgorithm): def Initialize(self): self.SetStartDate(2019, 1, 1) #Set Start Date #self.SetEndDate(2009, 12, 31) #Set End Date self.cap = 100000 self.SetCash(self.cap) res = Resolution.Hour # Holdings ### 'Out' holdings and weights self.BND1 = self.AddEquity('TLT', res).Symbol #TLT; TMF for 3xlev self.HLD_OUT = {self.BND1: 1} ### 'In' holdings and weights (static stock selection strategy) ##### These are determined flexibly via sorting on fundamentals ##### In & Out parameters ##### # Feed-in constants self.INI_WAIT_DAYS = 15 # out for 3 trading weeks # Market and list of signals based on ETFs self.MRKT = self.AddEquity('SPY', res).Symbol # market self.PRDC = self.AddEquity('XLI', res).Symbol # production (industrials) self.METL = self.AddEquity('DBB', res).Symbol # input prices (metals) self.NRES = self.AddEquity('IGE', res).Symbol # input prices (natural res) self.DEBT = self.AddEquity('SHY', res).Symbol # cost of debt (bond yield) self.USDX = self.AddEquity('UUP', res).Symbol # safe haven (USD) self.GOLD = self.AddEquity('GLD', res).Symbol # gold self.SLVA = self.AddEquity('SLV', res).Symbol # vs silver self.INFL = self.AddEquity('RINF', res).Symbol # disambiguate GPLD/SLVA pair via inflaction expectations self.UTIL = self.AddEquity('XLU', res).Symbol # utilities self.INDU = self.PRDC # vs industrials self.SHCU = self.AddEquity('FXF', res).Symbol # safe haven currency (CHF) self.RICU = self.AddEquity('FXA', res).Symbol # vs risk currency (AUD) self.FORPAIRS = [self.GOLD, self.SLVA, self.UTIL, self.SHCU, self.RICU, self.INFL] self.SIGNALS = [self.PRDC, self.METL, self.NRES, self.DEBT, self.USDX] self.pairlist = ['G_S', 'U_I', 'C_A'] # Initialize variables ## 'In'/'out' indicator self.be_in = 999 #initially, set to an arbitrary value different from 1 (in) and 0 (out) self.be_in_prior = 0 ## Day count variables self.dcount = 0 # count of total days since start self.outday = -self.INI_WAIT_DAYS+1 # dcount when self.be_in=0 ## Flexi wait days self.WDadjvar = self.INI_WAIT_DAYS self.adjwaitdays = self.INI_WAIT_DAYS # set a warm-up period to initialize the indicator self.SetWarmUp(timedelta(350)) ##### Qual-Up strategy parameters ##### self.UniverseSettings.Resolution = Resolution.Hour self.AddUniverse(self.UniverseCoarseFilter, self.UniverseFundamentalsFilter) self.num_screener = 250 self.num_stocks = 20 self.formation_days = 126 self.lowmom = False self.data = {} self.setrebalancefreq = 60 # X days, update universe and momentum calculation self.updatefinefilter = 0 self.symbols = None self.reb_count = 0 self.Schedule.On( self.DateRules.EveryDay(), self.TimeRules.AfterMarketOpen('SPY', 90), self.rebalance_when_out_of_the_market ) self.Schedule.On( self.DateRules.EveryDay(), self.TimeRules.BeforeMarketClose('SPY', 0), self.record_vars ) # Setup daily consolidation symbols = self.SIGNALS + [self.MRKT] + self.FORPAIRS for symbol in symbols: self.consolidator = TradeBarConsolidator(timedelta(days=1)) self.consolidator.DataConsolidated += self.consolidation_handler self.SubscriptionManager.AddConsolidator(symbol, self.consolidator) # Warm up history self.lookback = 252 self.history = self.History(symbols, self.lookback, Resolution.Daily) if self.history.empty or 'close' not in self.history.columns: return self.history = self.history['close'].unstack(level=0).dropna() self.update_history_shift() # Benchmark = record SPY self.spy = [] def UniverseCoarseFilter(self, coarse): # Update at the beginning (by setting self.OUTDAY = -self.INI_WAIT_DAYS), every X days (rebalance frequency), and one day before waitdays are up if not ((self.be_in and ((self.dcount-self.reb_count)==self.setrebalancefreq)) or (self.dcount==self.outday+self.adjwaitdays-1)): self.updatefinefilter = 0 return Universe.Unchanged self.updatefinefilter = 1 # drop stocks which have no fundamental data or have too low prices selected = [x for x in coarse if (x.HasFundamentalData) and (float(x.Price) > 5)] # rank the stocks by dollar volume filtered = sorted(selected, key=lambda x: x.DollarVolume, reverse=True) return [x.Symbol for x in filtered[:500]] def UniverseFundamentalsFilter(self, fundamental): if self.updatefinefilter == 0: return Universe.Unchanged rank_cash_return = sorted(fundamental, key=lambda x: x.ValuationRatios.CashReturn, reverse=True) rank_fcf_yield = sorted(fundamental, key=lambda x: x.ValuationRatios.FCFYield, reverse=True) rank_roic = sorted(fundamental, key=lambda x: x.OperationRatios.ROIC.Value, reverse=True) rank_ltd_to_eq = sorted(fundamental, key=lambda x: x.OperationRatios.LongTermDebtEquityRatio.Value, reverse=True) combo_rank = {} for i,ele in enumerate(rank_cash_return): rank1 = i rank2 = rank_fcf_yield.index(ele) score = sum([rank1*0.5,rank2*0.5]) combo_rank[ele] = score rank_value = dict(sorted(combo_rank.items(), key=lambda item:item[1], reverse=False)) stock_dict = {} # assign a score to each stock, you can also change the rule of scoring here. for i,ele in enumerate(rank_roic): rank1 = i rank2 = rank_ltd_to_eq.index(ele) rank3 = list(rank_value.keys()).index(ele) score = sum([rank1*0.33,rank2*0.33,rank3*0.33]) stock_dict[ele] = score # sort the stocks by their scores #self.sorted_stock = sorted(stock_dict.items(), key=lambda d:d[1],reverse=False) #sorted_symbol = [x[0] for x in self.sorted_stock] self.sorted_stock = sorted(stock_dict.items(), key=lambda d:d[1],reverse=True) self.sorted_symbol = [self.sorted_stock[i][0] for i in range(len(self.sorted_stock))] top= self.sorted_symbol[:self.num_screener] self.symbols = [x.Symbol for x in top] #self.Log("100 fine-filtered stocks\n" + str(sorted([str(i.Value) for i in self.symbols]))) self.updatefinefilter = 0 self.reb_count = self.dcount return self.symbols def OnSecuritiesChanged(self, changes): for security in changes.RemovedSecurities: symbol_data = self.data.pop(security.Symbol, None) if symbol_data: symbol_data.dispose() for security in changes.AddedSecurities: if security.Symbol not in self.data: self.data[security.Symbol] = SymbolData(security.Symbol, self.formation_days, self) def consolidation_handler(self, sender, consolidated): self.history.loc[consolidated.EndTime, consolidated.Symbol] = consolidated.Close self.history = self.history.iloc[-self.lookback:] self.update_history_shift() def update_history_shift(self): self.history_shift = self.history.rolling(11, center=True).mean().shift(60) def rebalance_when_out_of_the_market(self): if self.history.empty: return # Returns sample to detect extreme observations returns_sample = (self.history / self.history_shift - 1) # Reverse code USDX: sort largest changes to bottom returns_sample[self.USDX] = returns_sample[self.USDX] * (-1) # For pairs, take returns differential, reverse coded returns_sample['G_S'] = -(returns_sample[self.GOLD] - returns_sample[self.SLVA]) returns_sample['U_I'] = -(returns_sample[self.UTIL] - returns_sample[self.INDU]) returns_sample['C_A'] = -(returns_sample[self.SHCU] - returns_sample[self.RICU]) # Extreme observations; statist. significance = 1% pctl_b = np.nanpercentile(returns_sample, 1, axis=0) extreme_b = returns_sample.iloc[-1] < pctl_b # Re-assess/disambiguate double-edged signals median = np.nanmedian(returns_sample, axis=0) abovemedian = returns_sample.iloc[-1] > median ### Interest rate expectations (cost of debt) may increase because the economic outlook improves (showing in rising input prices) = actually not a negative signal extreme_b.loc[self.DEBT] = np.where((extreme_b.loc[self.DEBT].any()) & (abovemedian[[self.METL, self.NRES]].any()), False, extreme_b.loc[self.DEBT]) ### GOLD/SLVA differential may increase due to inflation expectations which actually suggest an economic improvement = actually not a negative signal try: extreme_b.loc['G_S'] = np.where((extreme_b.loc[['G_S']].any()) & (abovemedian.loc[[self.INFL]].any()), False, extreme_b.loc['G_S']) except: pass # Determine waitdays empirically via safe haven excess returns, 50% decay self.WDadjvar = int( max(0.50 * self.WDadjvar, self.INI_WAIT_DAYS * max(1, np.where((returns_sample[self.GOLD].iloc[-1]>0) & (returns_sample[self.SLVA].iloc[-1]<0) & (returns_sample[self.SLVA].iloc[-2]>0), self.INI_WAIT_DAYS, 1), np.where((returns_sample[self.UTIL].iloc[-1]>0) & (returns_sample[self.INDU].iloc[-1]<0) & (returns_sample[self.INDU].iloc[-2]>0), self.INI_WAIT_DAYS, 1), np.where((returns_sample[self.SHCU].iloc[-1]>0) & (returns_sample[self.RICU].iloc[-1]<0) & (returns_sample[self.RICU].iloc[-2]>0), self.INI_WAIT_DAYS, 1) )) ) self.adjwaitdays = min(60, self.WDadjvar) # Determine whether 'in' or 'out' of the market if (extreme_b[self.SIGNALS + self.pairlist]).any(): self.be_in = False self.outday = self.dcount self.trade({**dict.fromkeys(self.Portfolio.Keys, 0), **self.HLD_OUT}) if self.dcount >= self.outday + self.adjwaitdays: self.be_in = True # Update stock ranking/holdings, when swithing from 'out' to 'in' plus every X days when 'in' (set rebalance frequency) if (self.be_in and not self.be_in_prior) or (self.be_in and (self.dcount==self.reb_count)): self.rebalance() #self.Plot("In Out", "in_market", int(self.be_in)) #self.Plot("In Out", "num_out_signals", extreme_b[self.SIGNALS + self.pairlist].sum()) #self.Plot("Wait Days", "waitdays", self.adjwaitdays) self.be_in_prior = self.be_in self.dcount += 1 def rebalance(self): #self.Debug(str(self.Time) + "rebalance: be_in:" + str(self.be_in) + " flip_flag:" + str(self.flip_flag)) if self.symbols is None: return symbols = self.calc_return(self.symbols) #self.Log("The 10 selected stocks:\n" + str(sorted([str(i) for i in symbols])), end ="-") #self.Log("Sell the following current holdings:\n" + str(sorted([str(i) for i in list(dict.fromkeys(set([x.Symbol for x in self.Portfolio.Values if x.Invested]) - set(symbols)))])), end ="-") if len(symbols)==0: return weight = 0.99/len(symbols) self.trade({**dict.fromkeys(symbols, weight), **dict.fromkeys(list(dict.fromkeys(set([x.Symbol for x in self.Portfolio.Values if x.Invested]) - set(symbols))), 0), **dict.fromkeys(self.HLD_OUT, 0)}) def calc_return(self, stocks): ready = [self.data[symbol] for symbol in stocks if self.data[symbol].Roc.IsReady] sorted_by_roc = sorted(ready, key=lambda x: x.Roc.Current.Value, reverse = not self.lowmom) return [symbol_data.Symbol for symbol_data in sorted_by_roc[:self.num_stocks] ] def trade(self, weight_by_sec): buys = [] for sec, weight in weight_by_sec.items(): # Check that we have data in the algorithm to process a trade if not self.CurrentSlice.ContainsKey(sec) or self.CurrentSlice[sec] is None: continue cond1 = weight == 0 and self.Portfolio[sec].IsLong cond2 = weight > 0 and not self.Portfolio[sec].Invested if cond1 or cond2: quantity = self.CalculateOrderQuantity(sec, weight) if quantity > 0: buys.append((sec, quantity)) elif quantity < 0: self.Order(sec, quantity) for sec, quantity in buys: self.Order(sec, quantity) def record_vars(self): self.spy.append(self.history[self.MRKT].iloc[-1]) spy_perf = self.spy[-1] / self.spy[0] * self.cap self.Plot('Strategy Equity', 'SPY', spy_perf) account_leverage = self.Portfolio.TotalHoldingsValue / self.Portfolio.TotalPortfolioValue self.Plot('Holdings', 'leverage', round(account_leverage, 2)) class SymbolData(object): def __init__(self, symbol, roc_period, algorithm): self.Symbol = symbol self.Roc = RateOfChange(roc_period) self.algorithm = algorithm self.consolidator = algorithm.ResolveConsolidator(symbol, Resolution.Daily) algorithm.RegisterIndicator(symbol, self.Roc, self.consolidator) # Warm up ROC history = algorithm.History(symbol, roc_period, Resolution.Daily) if history.empty or 'close' not in history.columns: return for index, row in history.loc[symbol].iterrows(): self.Roc.Update(index, row['close']) def dispose(self): self.algorithm.SubscriptionManager.RemoveConsolidator(self.Symbol, self.consolidator)