Overall Statistics
Total Trades
162
Average Win
4.58%
Average Loss
-1.18%
Compounding Annual Return
22.462%
Drawdown
16.500%
Expectancy
2.720
Net Profit
1296.248%
Sharpe Ratio
1.408
Probabilistic Sharpe Ratio
87.659%
Loss Rate
24%
Win Rate
76%
Profit-Loss Ratio
3.88
Alpha
0.18
Beta
0.125
Annual Standard Deviation
0.137
Annual Variance
0.019
Information Ratio
0.447
Tracking Error
0.212
Treynor Ratio
1.54
Total Fees
$1421.64
'''
Looks at trailing return for various indicators to predict bear and bull market conditions.

See: https://www.quantconnect.com/forum/discussion/10246/intersection-of-roc-comparison-using-out-day-approach/p1
See: https://drive.google.com/file/d/1JE-2Ter1TWuQvZC12vC892c2wLPHAcUS/view
'''
import numpy as np

# Will invest in these tickers when bullish conditions are predicted.
BULLISH_TICKERS = ['SPY', 'QQQ']
# Will invest in these tickers when bearish conditions are predicted.
BEARISH_TICKERS = ['TLT','TLH']

# How many samples (1x sample per day) to record for volatility calculations.
HISTORICAL_SAMPLE_SIZE = 126

# Maximum amount of portfolio value (ratio) that will be invested at once.
MAXIMUM_PORTFOLIO_USAGE = 0.99

# Initial cash, USD.
STARTING_CASH = 100000


class BullishBearishInOut(QCAlgorithm):

    def Initialize(self):

        self.SetStartDate(2008, 1, 1)
        # self.SetEndDate(2021, 1, 1)
        self.SetCash(STARTING_CASH)
        
        self.bullish_symbols = [self.AddEquity(ticker, Resolution.Minute).Symbol for ticker in BULLISH_TICKERS]
        self.bearish_symbols = [self.AddEquity(ticker, Resolution.Minute).Symbol for ticker in BEARISH_TICKERS]
        
        # Default should be set to 85. A larger time constant means this algorithm will trade less frequenty.
        # Time constant is used to calucate two things:
        #    lookback_n_days: How many days to look back to calculate trailing returns for the indicators.
        #    settling_n_days: How many days to wait after last downturn prediction before investing in bullish tickers.
        #    
        # Assuming an annualized volatility of 0.02:
        # time_constant = 50 -> lookback_n_days: 40, settling_n_days: 10
        # time_constant = 85 -> lookback_n_days: 65, settling_n_days: 17
        # time_constat = 100 -> lookback_n_days: 80, settling_n_days: 20
        # time_constant = 200 -> lookback_n_days: 160, settling_n_days: 400
        self.time_constant = float(self.GetParameter("time_constant"))

        self.SLV = self.AddEquity('SLV', Resolution.Daily).Symbol  
        self.GLD = self.AddEquity('GLD', Resolution.Daily).Symbol  
        self.XLI = self.AddEquity('XLI', Resolution.Daily).Symbol 
        self.XLU = self.AddEquity('XLU', Resolution.Daily).Symbol
        self.DBB = self.AddEquity('DBB', Resolution.Daily).Symbol  
        self.UUP = self.AddEquity('UUP', Resolution.Daily).Symbol  
        self.MKT = self.AddEquity('SPY', Resolution.Daily).Symbol 

        indicators = [self.SLV, self.GLD, self.XLI, self.XLU, self.DBB, self.UUP]
        
        self.bull_market_suspected = True
        self.day_count = 0 
        self.last_downturn_indicator_count = 0        
        self.desired_weight = {}
        self.initial_market_price = None
        self.SetWarmUp(timedelta(350))

        self.Schedule.On(self.DateRules.EveryDay(), self.TimeRules.AfterMarketOpen('SPY', 60),
            self.daily_check)
        self.Schedule.On(self.DateRules.EveryDay(), self.TimeRules.AfterMarketOpen('SPY', 120),
            self.trade)    
            
        symbols = [self.MKT] + indicators
        for symbol in symbols:
            self.consolidator = TradeBarConsolidator(timedelta(days=1))
            self.consolidator.DataConsolidated += self.consolidation_handler
            self.SubscriptionManager.AddConsolidator(symbol, self.consolidator)
        
        self.history = self.History(symbols, HISTORICAL_SAMPLE_SIZE + 1, Resolution.Daily)
        if self.history.empty or 'close' not in self.history.columns:
            return
        self.history = self.history['close'].unstack(level=0).dropna()
        
        
    def consolidation_handler(self, sender, consolidated):
        """Adds close price of this symbol to history."""
        self.history.loc[consolidated.EndTime, consolidated.Symbol] = consolidated.Close
        

    def daily_check(self):
        """Looks to see if a market downturn is supected. If so, sets self.bull_market_suspected to false.
        
        Clears the indicator after a certain number of days have passed.
        """
        # Trim history so that it includes only the dates we need.
        # This protectes history size from growing with each passing day.
        self.history = self.history.iloc[-(HISTORICAL_SAMPLE_SIZE + 1):] 
        
        # Calculate the anualized volatility.
        # self.history[[self.MKT]]: The last HISTORICAL_SAMPLE_SIZE + 1 days of closing prices
        # .pct_change(): HISTORICAL_SAMPLE_SIZE daily price changes
        # .std(): standard deviation of price changes
        # * np.sqrt(252): Turn from daily to anual volatility.
        # Details: https://www.investopedia.com/ask/answers/021015/how-can-you-calculate-volatility-excel.asp
        annualized_volatility = self.history[[self.MKT]].pct_change().std() * np.sqrt(252)
        
        # Calculate setting and lookback days.
        # For details see parameter sensativity discussion: https://quantopian-archive.netlify.app/forum/threads/new-strategy-in-and-out.html
        settling_n_days = int(annualized_volatility * self.time_constant)
        lookback_n_days = int((1.0 - annualized_volatility) * self.time_constant)
        
        # Calculate the percentage change over the lookback days.
        percent_change_over_lookback = self.history.pct_change(lookback_n_days).iloc[-1]
        
        market_downturn_suspected = (
            # Gold (GLD) has increased more than silver (SLV)
            percent_change_over_lookback[self.SLV] < percent_change_over_lookback[self.GLD] and 
            # Utilities sector (XLU) has increased more than industrial sector (XLI)
            percent_change_over_lookback[self.XLI] < percent_change_over_lookback[self.XLU] and 
            # Dollar bullish (UUP) has increased more than base metals fund (DBB)
            percent_change_over_lookback[self.DBB] < percent_change_over_lookback[self.UUP])

        if market_downturn_suspected:
            self.bull_market_suspected = False
            self.last_downturn_indicator_count = self.day_count
        # Wait settling_period from the previous market_downturn_suspected signal before flagging a bull market.
        if self.day_count >= self.last_downturn_indicator_count + settling_n_days:
            self.bull_market_suspected = True
        self.day_count += 1
        
    def trade(self):
        """Buys stocks or bonds, as determined by bull indicator.
        
        If self.bull_market_suspected:
            Buys all bullish securities at an equal weight. Sells all bearish securities.
        If not self.bull_market_suspected:
            Buys all bearish securities at an equal weight. Sells all bullish securities.
            
        Does not trade unless self.bull_market_suspected changes.
        """
        if self.bull_market_suspected:
            bullish_security_weight = MAXIMUM_PORTFOLIO_USAGE / len(self.bullish_symbols)
            bearish_security_weight = 0
        else:
            bullish_security_weight = 0
            bearish_security_weight = MAXIMUM_PORTFOLIO_USAGE / len(self.bearish_symbols);

        for bullish_symbol in self.bullish_symbols:
            self.desired_weight[bullish_symbol] = bullish_security_weight
        for bearish_symbol in self.bearish_symbols:
            self.desired_weight[bearish_symbol] = bearish_security_weight

        for security, weight in self.desired_weight.items():
            if weight == 0 and self.Portfolio[security].IsLong:
                self.Liquidate(security)
                
            is_held_and_should_sell = weight == 0 and self.Portfolio[security].IsLong
            is_not_held_and_should_buy = weight != 0 and not self.Portfolio[security].Invested
            if is_held_and_should_sell or is_not_held_and_should_buy:
                self.SetHoldings(security, weight)

    def OnEndOfDay(self):
        """Plots handy information.
        
        Benchmark progress on main plot "Strategy Equity"
        Portfolio holdings on smaller plot "Holdings"
        
        Does not transact. Only affects plots.
        """
        
        market_price = self.Securities[self.MKT].Close
        if self.initial_market_price is None:
            self.initial_market_price = market_price
        self.Plot("Strategy Equity", "SPY", STARTING_CASH * market_price / self.initial_market_price)
        
        account_leverage = self.Portfolio.TotalHoldingsValue / self.Portfolio.TotalPortfolioValue
        self.Plot('Holdings', 'leverage', round(account_leverage, 1))
        actual_weight = {}
        for sec, weight in self.desired_weight.items(): 
            actual_weight[sec] = round(self.ActiveSecurities[sec].Holdings.Quantity * self.Securities[sec].Price / self.Portfolio.TotalPortfolioValue,4)
            self.Plot('Holdings', self.Securities[sec].Symbol, round(actual_weight[sec], 3))