Overall Statistics
Total Trades
3263
Average Win
0.51%
Average Loss
-0.46%
Compounding Annual Return
21.339%
Drawdown
22.300%
Expectancy
0.311
Net Profit
1128.286%
Sharpe Ratio
1.017
Probabilistic Sharpe Ratio
39.144%
Loss Rate
38%
Win Rate
62%
Profit-Loss Ratio
1.12
Alpha
0.172
Beta
0.224
Annual Standard Deviation
0.191
Annual Variance
0.036
Information Ratio
0.41
Tracking Error
0.236
Treynor Ratio
0.864
Total Fees
$8560.10
"""
DUAL MOMENTUM-IN OUT v2 by Vladimir
https://www.quantconnect.com/forum/discussion/9597/the-in-amp-out-strategy-continued-from-quantopian/p3/comment-28146

inspired by Peter Guenther, Tentor Testivis, Dan Whitnable, Thomas Chang and T Smith.

"""
import numpy as np
import pandas as pd

class DualMomentumInOut(QCAlgorithm):

    def Initialize(self):

        self.SetBrokerageModel(BrokerageName.InteractiveBrokersBrokerage, AccountType.Margin)
        self.SetStartDate(2008, 1, 1)
        self.cap = self.Portfolio.Cash
        self.SetCash(self.cap)

        self.MKT = self.AddEquity('SPY', Resolution.Hour).Symbol  
        self.XLI = self.AddEquity('XLI', Resolution.Hour).Symbol 
        self.XLU = self.AddEquity('XLU', Resolution.Hour).Symbol 
        self.SLV = self.AddEquity('SLV', Resolution.Hour).Symbol 
        self.GLD = self.AddEquity('GLD', Resolution.Hour).Symbol 
        self.FXA = self.AddEquity('FXA', Resolution.Hour).Symbol
        self.FXF = self.AddEquity('FXF', Resolution.Hour).Symbol
        self.DBB = self.AddEquity('DBB', Resolution.Hour).Symbol
        self.UUP = self.AddEquity('UUP', Resolution.Hour).Symbol          
        self.IGE = self.AddEquity('IGE', Resolution.Hour).Symbol
        self.SHY = self.AddEquity('SHY', Resolution.Hour).Symbol
        self.AddEquity("TLT", Resolution.Minute)

        self.FORPAIRS = [self.XLI, self.XLU, self.SLV, self.GLD, self.FXA, self.FXF]
        self.SIGNALS  = [self.XLI, self.DBB, self.IGE, self.SHY, self.UUP]
        self.PAIR_LIST = ['S_G', 'I_U', 'A_F']
        
        self.no_signals = 0
        self.INI_WAIT_DAYS = 15
        self.SHIFT = 55
        self.MEAN = 11
        self.init = 0
        
        self.bull = 1 
        self.count = 0 
        self.outday = 0
        self.in_stock = 0
        self.spy = []
        self.wait_days = self.INI_WAIT_DAYS
        self.wt = {}
        self.real_wt = {}
        
        self.UniverseSettings.Resolution = Resolution.Minute
        self.AddUniverse(self.MomentumSelectionFunction, self.FundamentalSelectionFunction)
        self.num_screener = 100
        self.num_stocks = 15
        self.formation_days = 70
        self.lowmom = False
        
        # rebalance the universe selection once a month
        self.rebalence_flag = 0
        # make sure to run the universe selection at the start of the algorithm even it's not the manth start
        self.flip_flag = 0
        self.first_month_trade_flag = 1
        self.trade_flag = 0 
        self.symbols = None
        
        self.SetWarmUp(timedelta(126))
        

        self.Schedule.On(self.DateRules.EveryDay(), self.TimeRules.AfterMarketOpen('SPY', 120),
            self.calculate_signal)

        #self.Schedule.On(
        #    self.DateRules.EveryDay(),
        #    self.TimeRules.AfterMarketOpen('SPY', 120),
        #    self.rebalance_when_out_of_the_market
        #)

        self.Schedule.On(
            self.DateRules.MonthStart("SPY"), 
            self.TimeRules.AfterMarketOpen('SPY', 150),
            Action(self.monthly_rebalance))
            
        symbols = self.SIGNALS + [self.MKT] + self.FORPAIRS
        for symbol in symbols:
            self.consolidator = TradeBarConsolidator(timedelta(days = 1))
            self.consolidator.DataConsolidated += self.consolidation_handler
            self.SubscriptionManager.AddConsolidator(symbol, self.consolidator)
            
        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() 

    def monthly_rebalance(self):
        if self.bull:
            self.Log("REBALANCE: Monthly trigger")
            self.rebalance()
        
    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_mean = self.history.shift(self.SHIFT).rolling(self.MEAN).mean()    
            
   
    def returns(self, symbol, period, excl):
        prices = self.History(symbol, TimeSpan.FromDays(period + excl), Resolution.Daily).close
        return prices[-excl] / prices[0]
        
        
    def calculate_signal(self):
        mom = (self.history / self.history_shift_mean - 1)

        mom[self.UUP] = mom[self.UUP] * (-1)
        mom['S_G'] = mom[self.SLV] - mom[self.GLD]
        mom['I_U'] = mom[self.XLI] - mom[self.XLU]
        mom['A_F'] = mom[self.FXA] - mom[self.FXF]   

        pctl = np.nanpercentile(mom, 5, axis=0)
        extreme = mom.iloc[-1] < pctl

        self.wait_days = int(
            max(0.50 * self.wait_days,
                self.INI_WAIT_DAYS * max(1,
                     np.where((mom[self.GLD].iloc[-1]>0) & (mom[self.SLV].iloc[-1]<0) & (mom[self.SLV].iloc[-2]>0), self.INI_WAIT_DAYS, 1),
                     np.where((mom[self.XLU].iloc[-1]>0) & (mom[self.XLI].iloc[-1]<0) & (mom[self.XLI].iloc[-2]>0), self.INI_WAIT_DAYS, 1),
                     np.where((mom[self.FXF].iloc[-1]>0) & (mom[self.FXA].iloc[-1]<0) & (mom[self.FXA].iloc[-2]>0), self.INI_WAIT_DAYS, 1)
                     )))
                     
        adjwaitdays = min(60, self.wait_days)

        # self.Debug('{}'.format(self.wait_days))

        for signal in self.SIGNALS:
            if extreme[self.SIGNALS].any():
                self.no_signals += 1
        for fx in self.PAIR_LIST:
            if extreme[self.PAIR_LIST].any():
                self.no_signals += 1
                
        if self.no_signals > 5:
            self.bull = False
            self.SetHoldings("TLT", 1, True)
            self.outday = self.count
            self.no_signals = 0
        
        else:
            self.no_signals = 0
            
        if self.count >= self.outday + adjwaitdays:
            if not self.bull:
                self.flip_flag = 1
                self.Log("REBALANCE: IN trigger")
                self.rebalance()
                self.flip_flag = 0
            self.bull = True
            
        self.count += 1
        
        self.Log(f"TotalPortfolioValue: {self.Portfolio.TotalPortfolioValue}, TotalMarginUsed: {self.Portfolio.TotalMarginUsed}, MarginRemaining: {self.Portfolio.MarginRemaining}, Cash:  {self.Portfolio.Cash}")
        #self.Log("TotalHoldingsValue: " + str(self.Portfolio.TotalHoldingsValue))
    
        for key in sorted(self.Portfolio.keys()):
            if self.Portfolio[key].Quantity > 0.0:
                self.Log(f"Symbol/Qty: {key} / {self.Portfolio[key].Quantity}, Avg: {self.Portfolio[key].AveragePrice}, Curr: { self.Portfolio[key].Price}, Profit($): {self.Portfolio[key].UnrealizedProfit}");


    def MomentumSelectionFunction(self, momentum):
        if (self.rebalence_flag or self.first_month_trade_flag) and (self.bull or self.flip_flag):
            # drop stocks which have no fundamental data or have too low prices
            selected = [x for x in momentum 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[:200]]
        else:
            return self.symbols


    def FundamentalSelectionFunction(self, fundamental):
        if (self.rebalence_flag or self.first_month_trade_flag) and (self.bull or self.flip_flag):
            hist = self.History([i.Symbol for i in fundamental], 1, Resolution.Daily)
            try:
                filtered_fundamental = [x for x in fundamental if (x.ValuationRatios.EVToEBITDA > 0) 
                                                    and (x.EarningReports.BasicAverageShares.ThreeMonths > 0) 
                                                    and float(x.EarningReports.BasicAverageShares.ThreeMonths) * hist.loc[str(x.Symbol)]['close'][0] > 2e9]
            except:
                filtered_fundamental = [x for x in fundamental if (x.ValuationRatios.EVToEBITDA > 0) 
                                                and (x.EarningReports.BasicAverageShares.ThreeMonths > 0)] 

            top = sorted(filtered_fundamental, key = lambda x: x.ValuationRatios.EVToEBITDA, reverse=True)[:self.num_screener]
            self.symbols = [x.Symbol for x in top]
            self.rebalence_flag = 0
            self.first_month_trade_flag = 0
            self.trade_flag = 1
            return self.symbols
        else:
            return self.symbols


    def rebalance(self):
        self.rebalence_flag = 1
        if self.symbols is None: return
        chosen_df = self.calc_return(self.symbols)
        chosen_df = chosen_df.iloc[:self.num_stocks]
        
        self.existing_pos = 0
        add_symbols = []
        for symbol in self.Portfolio.Keys:
            if symbol.Value == 'SPY': continue
            if (symbol.Value not in chosen_df.index):
                self.SetHoldings(symbol, 0)
            elif (symbol.Value in chosen_df.index): 
                self.existing_pos += 1
        
        weight = 0.99/len(chosen_df)
        for symbol in chosen_df.index:
            self.SetHoldings(Symbol.Create(symbol, SecurityType.Equity, Market.USA), weight)    
    
                
    def calc_return(self, stocks):
        hist = self.History(stocks, self.formation_days, Resolution.Daily)
        current = self.History(stocks, 1, Resolution.Minute)
        
        self.price = {}
        ret = {}
     
        for symbol in stocks:
            if str(symbol) in hist.index.levels[0] and str(symbol) in current.index.levels[0]:
                self.price[symbol.Value] = list(hist.loc[str(symbol)]['close'])
                self.price[symbol.Value].append(current.loc[str(symbol)]['close'][0])
        for symbol in self.price.keys():
            ret[symbol] = (self.price[symbol][-1] - self.price[symbol][0]) / self.price[symbol][0]
        df_ret = pd.DataFrame.from_dict(ret, orient='index')
        df_ret.columns = ['return']
        sort_return = df_ret.sort_values(by = ['return'], ascending = self.lowmom)
        
        return sort_return