Overall Statistics
Total Trades
1055
Average Win
1.16%
Average Loss
-0.31%
Compounding Annual Return
16.085%
Drawdown
32.100%
Expectancy
1.037
Net Profit
707.325%
Sharpe Ratio
0.593
Sortino Ratio
0.586
Probabilistic Sharpe Ratio
4.059%
Loss Rate
57%
Win Rate
43%
Profit-Loss Ratio
3.75
Alpha
0.043
Beta
0.843
Annual Standard Deviation
0.193
Annual Variance
0.037
Information Ratio
0.193
Tracking Error
0.152
Treynor Ratio
0.136
Total Fees
$3263.12
Estimated Strategy Capacity
$9500000.00
Lowest Capacity Asset
GRMN S0DPIYB0VD5X
Portfolio Turnover
2.02%
#region imports
from AlgorithmImports import *
#endregion


##########################
# Symbol Data Class 
##########################
class SymbolData():
    
    def __init__(self, algo, symbol, etfWeight, lookbackInDays, perfMetric):
        self.algo       = algo
        self.symbol     = symbol   
        self.etfWeight  = etfWeight
        self.perfMetric = perfMetric
        self.momp       = MomentumPercent(lookbackInDays)
        self.ker        = KaufmanEfficiencyRatio(lookbackInDays)
        self.atr        = AverageTrueRange(14)
        
        # Track Price 
        # -----------
        self.lastPrice          = 0
        self.entryPrice         = 0
        self.lastDailyBar       = None
        self.lastHourlyBar      = None
        
        # Messages
        # ---------
        self.ExitMessage        = ""
        self.EntryMessage       = ""

        # Stop Loss State
        # -------------------------------
        self.initialStopLoss    = 0
        self.trailStopATRCoef   = 3
        self.ResetStopLosses()

    def SeedHistory(self,history):
        for row in history.loc[self.symbol].itertuples():
            self.momp.Update(row.Index, row.close)
            self.ker.Update(row.Index, row.close)
            
            
    ## ====================================
    def OnSymbolHourlyData(self, tradeBar):
        if(tradeBar.Period == timedelta(seconds=3600)):
            if(self.lastHourlyBar != tradeBar):
                self.lastPrice      = tradeBar.Close
                self.lastHourlyBar   = tradeBar
                

    ## ====================================
    def OnSymbolDailyData(self, tradeBar):
        if(tradeBar.Period == timedelta(days=1)):
            if(self.lastDailyBar != tradeBar):
                self.atr.Update(tradeBar)
                self.lastPrice      = tradeBar.Close
                self.lastDailyBar   = tradeBar
                self.PlotCharts()

    @property 
    def PerfMetricValue(self):        
        if( (self.perfMetric == MetricsEnum.MOMENTUM_PCT) and self.momp.IsReady):
            if (self.momp.Current.Value > 0):
                return self.momp.Current.Value

        elif( (self.perfMetric == MetricsEnum.MOMP_KER) and self.ker.IsReady and self.momp.IsReady):
            if(self.momp.Current.Value > 0):
                # calculate a new composite metric that combines both
                return (self.ker.Current.Value * self.momp.Current.Value)
                
            
            # if( self.ker.Current.Value > 0.4 and self.momp.Current.Value > 0):
            #     return self.ker.Current.Value
        
        # elif( (self.perfMetric == MetricsEnum.KER) and self.ker.IsReady and self.momp.IsReady):
            return self.ker.Current.Value if self.momp.Current.Value > 0 else -abs(self.ker.Current.Value)
    
        
        return float('-inf')

    # Trailing Stop Exit    
    # =====================================
    def TrailingExitSignalFired(self):
        if( not self.atr.IsReady ):
            return False   

        # If trailing stop is NOT set, get last price, and set it
        # --------------------------------------------------------
        if( not self.trailStopActivated ):
            self.highestPrice       = self.lastPrice
            self.trailingStopLoss   = self.lastPrice - (self.atr.Current.Value * self.trailStopATRCoef)

            # Make sure we are never below 2% of entry price
            self.trailingStopLoss   = max (self.trailingStopLoss, self.lowestStopLoss)

            self.trailStopActivated = True
            
            # Recursively call this function to check for stops
            # again, now that the trailing stop has been activated
            # and the stop loss value has been updated.
            # --------------------------------------------------
            return self.TrailingExitSignalFired()            
            
        # If trailing stop loss is activated, check if price closed below it.
        # If it did, then exit. If not, update the trailing stop loss. 
        # -------------------------------------------------------------------
        else: 
            if self.PriceIsBelowTrailingStop():              
                return True

            else:
                # Udpate trailing stop loss if price has gone up
                # -----------------------------------------------
                if self.lastPrice > self.highestPrice:
                    self.highestPrice = self.lastPrice
                    newTrailingStopLoss = self.highestPrice -  (self.atr.Current.Value * self.trailStopATRCoef)
                    
                    # Make sure we are never below 2% of entry price
                    self.trailingStopLoss    = max (self.trailingStopLoss, newTrailingStopLoss, self.lowestStopLoss)
                    
                    if( self.symbol.Value == "BOOT"): #ADMA
                        self.algo.Log(f"[{self.symbol}][Trailing Stop Updated] ${self.trailingStopLoss:.2f} (Price: ${self.lastPrice:.2f})")
                    
                    # check again just in case price ends up below the new trailing stop level
                    if self.PriceIsBelowTrailingStop():
                        return True

        return False 

    # ------------------------------------------------
    def PriceIsBelowTrailingStop(self):
        if( self.lastPrice < self.trailingStopLoss ):
            # self.algo.LogUtil.Info(f"[{self.symbol}][Trailing Stop Triggered] : ${self.lastPrice:.2f}")
            self.ExitMessage = "Trailing Stop Triggered"
            # self.PlotCharts()
            return True

    ## Logic to run immediately after a new position is opened.
    ## ---------------------------------------------------------
    def OnPositionOpened(self):            
    
        # self.algo.LogUtil.Info(f"Bought {self.symbol.Value}] @ approx ${self.lastPrice:.2f}") 
        self.EntryMessage   = ""
        self.entryPrice = self.lastPrice
        self.algo.WarmUpIndicator(self.symbol, self.atr)
        self.algo.AddEquity(self.symbol.Value, Resolution.Daily)
        
        # For trailing stop
        # ------------------
        self.SetInitialStops()
        return
    
    
    
    ## Logic to run immediately after a position is closed
    ## ---------------------------------------------------------
    def OnPositionClosed(self):            
        self.PlotCharts()
        # self.algo.Log(f"Sold {self.symbol.Value}] @ approx ${self.lastPrice:.2f}")         
        self.ExitMessage  = "No Exit Message"
        self.ResetStopLosses()
        
    # ========================================================================
    def ResetStopLosses(self):
        self.trailStopActivated   = False
        self.initialStopLoss      = 0
        self.trailActivationPrice = 0
        self.trailingStopLoss     = 0
        
    # ========================================================================
    # Set initial stop and activation level. Called after new position opened.
    # ========================================================================
    def SetInitialStops(self):
        ## TODO: Use onOrderEvent to set this, because the actual price may be different
        self.entryPrice         = self.lastPrice
        self.initialStopLoss    = self.lastPrice - (self.atr.Current.Value * self.trailStopATRCoef)        

        # Make sure we are never below 2% of entry price
        self.initialStopLoss    = max(self.initialStopLoss, self.entryPrice * 0.98)
        self.lowestStopLoss     = self.initialStopLoss    


    # ====================
    def PlotCharts(self):    
    
        return 
        # if( self.symbol.Value != "SLAB"):
        #     return
        
        # Plot Price on a single chart for this symbol 
        # ------------------------------------------------
        self.algo.Plot(f"{self.symbol.Value}-charts", "Price", self.lastPrice)        
        
        # Stop losses & activation levels
        # ------------------------------------
        # self.algo.Plot(f"{self.symbol}-charts", "Initial Stop", self.initialStopLoss)            
        # self.algo.Plot(f"{self.symbol}-charts", "Acivation Pt", self.trailActivationPrice)            
        self.algo.Plot(f"{self.symbol}-charts", "TrailingStop", self.trailingStopLoss)            
                
        return 

###############################
# Perf Enum  
############################### 
class MetricsEnum(Enum):
    
    MOMENTUM_PCT  = "MOMENTUM PCT"
    MOMP_KER      = "MOMP_KER"
    SHARPE        = "SHARPE"
    DRAWDOWN      = "DRAWDOWN"
    RET_DD_RATIO  = "RET/DD"
    THARP_EXP     = "THARP EXP"


###############################
# Interval Enum  
############################### 
class IntervalEnum(Enum):
    
    MONTHLY  = "MONTHLY"
    WEEKLY   = "WEEKLY"
    DAILY    = "DAILY"
#region imports
from AlgorithmImports import *
#endregion
# https://www.quantconnect.com/forum/discussion/12347/creating-our-own-index-fund/p1
##########################################################################################
#
# QQQ Index Rebalancer
# ------------------------
#
# Inspired by:
# https://www.quantconnect.com/forum/discussion/12347/creating-our-own-index-fund/p1
#
# Values for External Params
# ---------------------------------------
# maxHoldings           = 5     # Number of positions to hold, max
# lookbackInDays        = 50    # look at performance over last 30 days    
# perfMetricIndex       = 0     # '0' corresponds to Momentum Pct         
# rebalancePeriodIndex  = 0     # '0' corresponds to Monthly rebalancing
# useETFWeights = 0     = 0     # Honor the ETF's weighting (even if a few holdings) 
# 
# Todo
# ----------------------------------------
# Add Hourly Stop Tracked ATR StopLoss
#
# Queue position 
#   - instead of trying to setHolding right away, queue till next data slice
#   - next data slice may be next day, so lets subecribe to minute data on the spot. 
#
# Plot ker distribution
#
##########################################################################################

# import matplotlib.pyplot as plt
from SymbolData import *
class ETFUniverse(QCAlgorithm):

    ## Main entry point for the algo     
    ## ==================================================================================
    def Initialize(self):
        self.InitAssets()
        self.InitExternalParams()
        self.InitBacktestParams()
        self.InitAlgoParams()
        self.ScheduleRoutines()

    ## Init assets: Symbol, broker model, universes, etc. Called from Initialize(). 
    ## ==================================================================================
    def InitAssets(self):
        self.ticker = "QQQ"
        self.etfSymbol = self.AddEquity(self.ticker, Resolution.Hour).Symbol
        self.AddUniverse(self.Universe.ETF(self.etfSymbol, self.UniverseSettings, self.ETFConstituentsFilter))
        self.UniverseSettings.Resolution = Resolution.Hour
        self.SetSecurityInitializer(lambda x: x.SetMarketPrice(self.GetLastKnownPrice(x)))
        
    ## Set backtest params: dates, cash, etc. Called from Initialize().
    ## ==================================================================
    def InitBacktestParams(self):
        self.SetStartDate(2010, 1, 1)   # Set Start Date
        # self.SetEndDate(2017, 1, 1)     # Set end Date

        self.SetCash(100000)             # Set Strategy Cash
        self.EnableAutomaticIndicatorWarmUp = True 
        self.SetWarmup(self.lookbackInDays+1, Resolution.Daily)
        self.SetBenchmark("SPY")

    # ====================================================================
    # Initialize external parameters. Called from Initialize(). 
    # ====================================================================
    def InitExternalParams(self):
        self.maxExposurePct         = float(self.GetParameter("maxExposurePct"))
        self.maxHoldings            = int(self.GetParameter("maxHoldings"))
        self.perfMetricIndex        = int(self.GetParameter("perfMetric"))
        self.rebalancePeriodIndex   = int(self.GetParameter("rebalancePeriodIndex"))
        self.lookbackInDays         = int(self.GetParameter("lookbackInDays"))
        self.useETFWeights          = bool(int(self.GetParameter("useETFWeights")) == 1)

    # ==================================================================================
    # Set algo params: Symbol, broker model, ticker, etc. Called from Initialize(). 
    # ==================================================================================
    def InitAlgoParams(self):

        # Flags to track and trigger rebalancing state
        self.timeToRebalance = True
        self.universeRepopulated = False
        
        # State vars
        self.weights            = {}
        self.symDataDict        = {}
        self.SelectedSymbols    = []
        self.SelectedSymbolData = []
        self.queuedPositions    = []

        # Values related to our alpha (rebalancing period and preferred perf metric 
        self.PerfMetrics      = [MetricsEnum.MOMENTUM_PCT, MetricsEnum.MOMP_KER ]
        self.rankPerfMetric   = self.PerfMetrics[self.perfMetricIndex]

        self.RebalancePeriods = [IntervalEnum.MONTHLY, IntervalEnum.WEEKLY, IntervalEnum.DAILY]
        self.rebalancePeriod  = self.RebalancePeriods[self.rebalancePeriodIndex]

    # ==================================
    def ScheduleRoutines(self):
        if( self.rebalancePeriod == IntervalEnum.MONTHLY ):
            self.Schedule.On( self.DateRules.MonthStart(self.etfSymbol),
                              self.TimeRules.AfterMarketOpen(self.etfSymbol, 31),
                              self.SetRebalanceFlag )

        elif( self.rebalancePeriod == IntervalEnum.WEEKLY ):
            self.Schedule.On( self.DateRules.WeekStart(self.etfSymbol),
                              self.TimeRules.AfterMarketOpen(self.etfSymbol, 31),
                              self.SetRebalanceFlag )

    
    # ====================================
    def ETFConstituentsFilter(self, constituents):
        if( self.timeToRebalance ):
            self.weights = {c.Symbol: c.Weight for c in constituents}
            
            ## set flags
            self.universeRepopulated = True
            self.timeToRebalance = False



            for symbol in self.weights:
                if symbol not in self.symDataDict:
                    
                    # symbol, algo, etfWeight, lookbackInDays, MetricsEnum.MOMENTUM_PCT):
                    self.symDataDict[symbol] = SymbolData(self, symbol, self.weights[symbol], \
                                                          self.lookbackInDays, self.rankPerfMetric)
                
                symbolData = self.symDataDict[symbol]
                
                history = self.History(symbol, 31, Resolution.Daily)
                symbolData.SeedHistory(history)    
            
            # Sort by efficiency ratio, take top 1/3rd 
            # ----------------------------------------------------
            # numHoldings = int(len(self.symDataDict)/3) 
            # sortedByEfficencyRatio = sorted(self.symDataDict.items(), key=lambda x: x[1].EfficiencyRatio, reverse=True)[:self.numHoldings]
            # Sort by perfMetric
            
            # debug
            perf_values = [data.PerfMetricValue if data.PerfMetricValue != float('-inf') else 0.0 for _, data in self.symDataDict.items()]

            self.StoreResearchData(perf_values)            
            filtered_data = [(symbol, data) for symbol, data in self.symDataDict.items() if data.PerfMetricValue != float('-inf')]
            sortedByPerfMetric = sorted(filtered_data, key=lambda x: x[1].PerfMetricValue, reverse=True)[:self.maxHoldings]
            
            # debug
            # chosenSymbolMetrics = [(x[0].Value,x[1].PerfMetricValue)  for x in sortedByPerfMetric]

            # Get Symbol object (the key of dict)
            self.SelectedSymbols    = [x[0] for x in sortedByPerfMetric]
            self.SelectedSymbolData = [x[1] for x in sortedByPerfMetric]
            
            
            #### TODO: 
            #### Remove Symboldata from self.symDataDict that didnt make the cut
            # self.CleanUpSymbolData(self.SelectedSymbols)
            
            # self.Plot("Symbols Found", 'Line', (len(self.SelectedSymbols)))	
            # self.Debug(f"!!!Symbols Found: {self.Time.strftime('%y-%m-%d')}{[x.Value for x in self.SelectedSymbols]}")
            

            return self.SelectedSymbols 

        else: 
            return []    
            
            
    # ====================================
    def SetRebalanceFlag(self):
        self.timeToRebalance = True

    # ====================================
    def LiquidatePositions(self):
        
        for symbol,symbolData in self.symDataDict.items():
            
            if(self.Securities.ContainsKey(symbol) and (symbol in self.Portfolio) and self.Portfolio[symbol].Invested):
                symbolData.OnPositionClosed()

            self.RemoveSecurity(symbol.Value) 
        
        # self.liquidationEvents = 0 if (not hasattr(self, "liquidationEvents")) else self.liquidationEvents +1
        # self.Plot("Liquidation Event", 'Line', self.liquidationEvents)	
        self.Liquidate()

        


    # ====================================
    def OnData(self,dataSlice):
        
        # Debug: Plot how many holdings we have
        # -------------------------------------
        # numHoldings = len([x.Key for x in self.Portfolio if x.Value.Invested])
        # self.Plot("Num of Positions", 'Line', numHoldings)	
        
        # Process queued positions from previous cycle
        # --------------------------------------------
        self.ProcessQueuedPositions()
        

        # Send new slice data to each symboldata object
        # ---------------------------------------------- 
        for symbol,symbolData in self.symDataDict.items():
            if(symbol in dataSlice) and (self.Portfolio[symbol].Invested):

                # If it's a daily bar....
                if(symbol in dataSlice and dataSlice[symbol] is not None and dataSlice[symbol].Period == timedelta(days=1)):                    
                    # Calll the daily data handler in symbolData
                    symbolData.OnSymbolDailyData(dataSlice[symbol])

                # else If it's an hourly bar....
                elif(symbol in dataSlice and dataSlice[symbol] is not None and dataSlice[symbol].Period == timedelta(seconds=3600)):
                    symbolData.OnSymbolHourlyData(dataSlice[symbol])

                    # Check trailing stop, queue for liqudation if exit signal fired
                    if symbolData.TrailingExitSignalFired():
                        self.QueuePosition(symbol, 0)
                
        
        # Handle Universe repopulation
        # ----------------------------
        if (self.universeRepopulated):
            # self.Liquidate()
            self.LiquidatePositions()
            self.weights = {}

            
            if(self.useETFWeights):
                # calculate sum of the etf weights for each selected symbol
                weightsSum = sum(self.symDataDict[symbol].etfWeight if self.symDataDict[symbol].etfWeight is not None else 0 for symbol in self.SelectedSymbols)
            
                ## Debug: Check how many of these have 0 etf weight
                # zero_count = sum(1 for symbol in self.SelectedSymbols if self.symDataDict[symbol].etfWeight is None)
                # total_count = len(self.SelectedSymbols)
                # percentage = (zero_count / total_count) * 100


            # self.Debug(f"!!!Symbols to trade: {self.Time.strftime('%y-%m-%d')}{[x.Value for x in self.SelectedSymbols]}")
            # # self.Debug(f"{[x.etfWeight for x in [symdata for sym,symdata in self.symDataDict]]}")
            # # self.Debug(f"{[x.etfWeight for sym, x in self.symDataDict.items()]}")
            # self.Debug(f"{[f'{sym.Value} {x.etfWeight}' for sym, x in self.symDataDict.items() if sym in self.SelectedSymbols]}")

            for symbol in self.SelectedSymbols:

                # Either use ETF Weights or equal weights
                if(self.useETFWeights and weightsSum > 0):
                    try:
                        symbolWeight = self.symDataDict[symbol].etfWeight / weightsSum # respect weighting
                    except:
                        symbolWeight = 1 / len(self.SelectedSymbols) # Equally weighted    
                else:
                    symbolWeight = 1 / len(self.SelectedSymbols) # Equally weighted


                if(symbol in self.CurrentSlice):
                    if True or self.Securities[symbol].IsTradable:
                        self.AddEquity(symbol.Value, Resolution.Hour)
                        self.QueuePosition(symbol,symbolWeight)



                    # self.SetHoldings(symbol, symbolWeight) 
                
    
            self.universeRepopulated = False    


            # for symbol, weight in self.weights.items():
            #     if symbol in self.ActiveSecurities:
            #         self.SetHoldings(symbol, weight)  # Market cap weighted
            #         # self.SetHoldings(symbol, 1 / len(self.weights))  # Equally weighted
    
    def QueuePosition(self, symbol, symbolWeight):
        # if we havent already queued this position with a weight, queue it. 
        ## we queue it instead of opening the position, because
        ## we dont yet have data for this symbol. We will get 
        ## data for it in the next call to OnData, where we do
        ## open the position
        
        # If we are atttempting to queue a symbol that already has a 
        # trade queued with weight 0 (meaning an exit before rebalancing)
        # then remove the previous entry
        self.queuedPositions = [(sym, weight) for sym, weight in self.queuedPositions if not (sym == symbol and symbolWeight == 0)]

        if symbol not in [x[0] for x in self.queuedPositions]:
            self.queuedPositions.append((symbol, symbolWeight))




    ## Loop through symboldata and remove what we dont need
    ## -------------------------------------------------
    def CleanUpSymbolData(self, selectedSymbols):
        # delete symbolData we dont need any more. 
        symbolsToRemove = [symbol for symbol in self.symDataDict 
                            if self.Securities.ContainsKey(symbol) 
                            and not self.Portfolio[symbol].Invested
                            and symbol not in selectedSymbols]

        for symbol in symbolsToRemove:
            del self.symDataDict[symbol]
            
    ## Loop through queued positions and open new trades  
    ## -------------------------------------------------
    def ProcessQueuedPositions(self):

        if len(self.queuedPositions) == 0:
            return

        tradeEvents = 0
        # Iterate through a copy of self.queuedPositions, 
        # since we modify self.queuedPositions in this loop.
        # ---------------------------------------------------
        for symbol,weight in [*self.queuedPositions]:

            if self.CurrentSlice.ContainsKey(symbol) and self.CurrentSlice[symbol] is not None:                    
                                
                openOrders = self.Transactions.GetOpenOrders()
                pendingPositionExists = any(order.Symbol == symbol for order in openOrders)

                if not pendingPositionExists:
                    orderNote = f"{weight:3.2f} of {symbol.Value}"
                    
                    if weight == 0:
                        orderNote = f"Possible Trailing Exit for {symbol.Value}" 

                    self.Log(f"{weight:3.2f} of {symbol.Value}")
                    adjustedWeight = weight * self.maxExposurePct
                    self.SetHoldings(symbol,weight, tag=f"{orderNote}")
                    tradeEvents = tradeEvents + 1

                    if weight > 0:
                        self.symDataDict[symbol].OnPositionOpened()

                # Remove occurences of symbol from queue
                self.queuedPositions = [t for t in self.queuedPositions if t[0] != symbol]

        
        self.Plot("Trade Dequeues", 'Line', tradeEvents)	


            
            # else:
            #     self.Log(f"[ERROR] Could not process {symbol} in queue: No Data in current slice")            
    
    # =================================================================
    # Periodically check if a security is no longer in the ETF
    # =================================================================
    # def RemoveDelistedSymbols(self, changes):
    #     for investedSymbol in [x.Key for x in self.Portfolio if x.Value.Invested]:
    #         if( investedSymbol not in self.weights.keys() ):
    #             self.Liquidate(symbol, 'No longer in universe')


    # This is used in the research notebook to plot values.
    def StoreResearchData(self, perf_values):

        # save the data to object store for late retrieval
        # Convert the dictionary to a JSON string
        # this is 
        json_data = json.dumps(perf_values)
        save_successful = self.ObjectStore.Save("my_key", json_data)