Overall Statistics
Total Trades
147
Average Win
6.55%
Average Loss
-3.13%
Compounding Annual Return
88.165%
Drawdown
22.100%
Expectancy
0.990
Net Profit
766.786%
Sharpe Ratio
2.454
Probabilistic Sharpe Ratio
98.779%
Loss Rate
36%
Win Rate
64%
Profit-Loss Ratio
2.09
Alpha
0.599
Beta
-0.07
Annual Standard Deviation
0.241
Annual Variance
0.058
Information Ratio
1.567
Tracking Error
0.322
Treynor Ratio
-8.433
Total Fees
$2917.57
Estimated Strategy Capacity
$9300000.00
Lowest Capacity Asset
WYIG W3SX2VYT9W85
Portfolio Turnover
1.29%
# https://www.quantconnect.com/forum/discussion/10630/shorting-bubbles-at-the-top/p1
# detects bubbles and shorts

from AlgorithmImports import *

class Roboto(QCAlgorithm):
    # bubble signal
    FAST         =  7    # for EMA, lower values >> higher risk, higher returns
    SLOW         =  30   # for EMA
    MAGNITUDE    =  2.00 # magnitude of the bubble

    # position configuration for opening in CheckForEntries
    FREE_CASH         =  0.03 # adjust based on risk tolerance for FreePortfolioValuePercentage in Initialize
    DYN_POSITION_SIZE = -0.50 # variable affecting dynamic position sizing for next short positions
    MAX_POSITION_SIZE = -0.15 # maximum individual position size. has a big effect on total returns (more negative values >> larger returns)
    MIN_BP            =  0.08 # liquidate most profitable position if buying power (= MarginRemaining / PortfolioValue) is too low
    OF_TOTAL_DV       =  0.02 # max share of daily dollar volume for order size
    MAX_POS           =  9    # max number of open positions
    USE_BULL          =  False
        
    # position configuration for liquidation in CheckForExits
    CUT_LOSS         = -0.10 # -10% = -0.10 !!!
    TCL_GET_EVEN     =  0.00 #  how fast is TCL trailing until break even (0.0 for none) !!!
    TCL_TRAIL        =  0.00 #  how fast is TCL trailing after break even (0.0 for none, if larger than TCL_GET_EVEN, overrides it) !!!

    TAKE_PROFIT      =  0.55 #  55% =  0.55 !!!
    MAX_POSITION_AGE =  45   #  45 days optimal
    TP_TRAIL         =  0.5  #  decreases TP with age up to 0.55 * (1 - 0.5) at MAX_POSITION_AGE (0.0 for none) !!!
    TP_KICK_IN       =  0.7  #  decreases TP with age kicking in at 80% of MAX_POSITION_AGE (never 1.0)
        
    # liquidity configuration
    MIN_Price            = 5.   # min price !!!
    MAX_Price            = 50.  # max price !!!
    MIN_VOLUME           = 1e6  # min volume !!!
    MIN_DOLLAR_VOLUME    = 1e5  # min dollar volume
    #MIN_TIME_OF_HISTORY = 0    # only include if there is a min of x days of history data (currently unused)
    MIN_TIME_IN_UNIVERSE = SLOW # min amount of time a security must remain in the universe before being removed (drives the speed of the backtest)
        
    # funnel
    N_COARSE             = MAX_POS # max number of coarse securities

    # portfolio configuration
    STARTING_CASH        = 100000 # for backtest in Initialize
        
    # debugging level
    #MSGS     = ['main', 'filter', 'logic', 'order', 'debug', 'error']
    MSGS     = ['logic', 'order', 'error']
        
    class SecurityData:
        # access yesterday's close via self.universe[Symbol].close
        def __init__(self, symbol, history):
            self.symbol   = symbol
            self.close    = 0
            self.ratio    = 0
            self.isBubble = False
            
            self.fast = ExponentialMovingAverage(Roboto.FAST)
            self.slow = ExponentialMovingAverage(Roboto.SLOW)
            self.vol  = ExponentialMovingAverage(Roboto.SLOW)
            
            # update all but the last day, as this will be updated after adding a new obj
            for bar in history[:history.size-1].itertuples():
                self.fast.Update(bar.Index[1], bar.close)
                self.slow.Update(bar.Index[1], bar.close)
                self.vol.Update(bar.Index[1], ((bar.open + bar.close)/2.0) * bar.volume) # we need to init with DollarVolume
            
        def update(self, time, price, volume, magnitude):
            self.close    = price
            self.ratio    = 0
            self.isBubble = False
            
            if self.fast.Update(time, price) and self.slow.Update(time, price) and self.vol.Update(time, volume):
                self.ratio = self.fast.Current.Value / self.slow.Current.Value
                self.isBubble = (self.ratio > magnitude) and (price / self.slow.Current.Value > magnitude)
        
    def Initialize(self):
        self.Debug("*** Roboto is initializing ***")
        self.SetTimeZone("America/New_York")
        
        # backtest
        self.SetBrokerageModel(BrokerageName.AlphaStreams)
        self.SetStartDate(2020, 1, 1)  
        #self.SetEndDate(2017, 1, 1)
        
        # live settings
        if self.LiveMode:
            self.minutes = 15
            res = Resolution.Minute
        else:
            self.minutes = 60
            res = Resolution.Hour
        
        # portfolio 
        self.SetCash(Roboto.STARTING_CASH)
        self.Settings.FreePortfolioValuePercentage = Roboto.FREE_CASH
        self.min_dollar_vol = Roboto.MIN_DOLLAR_VOLUME
        
        # universe selection
        self.UniverseSettings.Resolution = res
        self.UniverseSettings.MinimumTimeInUniverse = Roboto.MIN_TIME_IN_UNIVERSE # min amount of time a security must remain in the universe before being removed
        self.AddUniverse(self.CoarseFilter)
        
        self.universe = {} # contains all tracked securities in the universe
        self.open = [] # positions to open based on signal
        
        # further vars
        self.expiry         = {} # contains age of position
        self.trail_cut_loss = {} # contains trailing max of unrealized profit pct for cut loss
        self.bp             = 1.0 # buying power
        
        # set security symbols
        self.market         = self.AddEquity("SPY", res).Symbol
        self.bull           = self.AddEquity("QQQ", res).Symbol
        self.excl_smbls     = [self.market, self.bull]
        self.magnitude      = Roboto.MAGNITUDE
        
        # schedule our CheckForExits check for liquidation of positions using range(start, stop, step), NYSE 9:30 .. 16:00
        for i in range(0, 389, self.minutes):
            self.Schedule.On(self.DateRules.EveryDay(), self.TimeRules.AfterMarketOpen(self.bull, i),
                self.CheckForExits)
            
        # schedule our CheckForEntries check for shorting and entering bull security
        for i in range(60, 389, 60):
            self.Schedule.On(self.DateRules.EveryDay(), self.TimeRules.AfterMarketOpen(self.bull, i),
                self.CheckForEntries)
        
    def CoarseFilter(self, coarse):
        # for CoarseFundamental see https://www.quantconnect.com/docs/algorithm-reference/universes#
        if ('main' in Roboto.MSGS): self.Debug("{} CoarseFilter".format(self.Time))
        
        # ensure a minimum dollar volume corresponding to orders of 2 x our maximum fill rate
        self.min_dollar_vol = max(Roboto.MIN_DOLLAR_VOLUME,
            (self.Portfolio.TotalPortfolioValue * (1-Roboto.FREE_CASH) * abs(Roboto.MAX_POSITION_SIZE)) / (2. * Roboto.OF_TOTAL_DV))
            
        # 1st filter for hard and soft criteria
        cf_selected = [x for x in coarse
                            if  x.Market == "usa"
                            and x.HasFundamentalData
                            and x.Volume > Roboto.MIN_VOLUME
                            and x.DollarVolume > self.min_dollar_vol
                            and float(x.Price) >= Roboto.MIN_Price
                            and float(x.Price) <= Roboto.MAX_Price
                            ]
        if 'filter' in Roboto.MSGS: self.Debug("{} CoarseFilter-1 len:{}".format(self.Time, len(cf_selected))) # approx 500 securities
        
        # collect symbols which are new to our universe
        new_universe = {}
        
        for cf in cf_selected:
            
            # for every new symbol, create an entry in our universe with a SecurityData object (including initial population of our indicators with daily history)
            if (cf.Symbol not in self.universe):
                history = self.History(cf.Symbol, Roboto.SLOW, Resolution.Daily)
                self.universe[cf.Symbol] = Roboto.SecurityData(cf.Symbol, history)
                
            # for our complete universe, update our indicators with the data of the last trading day
            self.universe[cf.Symbol].update(cf.EndTime, cf.AdjustedPrice, cf.DollarVolume, self.magnitude)
                
            # for all cf_selected securities (and not the dropped ones), based on our newly created or updated universe entries, 
            new_universe[cf.Symbol] = self.universe[cf.Symbol]
        
        self.universe = new_universe
        
        # 2nd filter the values of our SecurityData dict to those who are over their bubble
        values = [x for x in self.universe.values() if x.isBubble]
        if 'filter' in Roboto.MSGS: self.Debug("{} CoarseFilter-2 len:{}".format(self.Time, len(values)))
        
        # 3rd filter for n_coarse sorted by the highest ratio
        values.sort(key = lambda x: x.ratio, reverse = True) # highest ratios first
        
        # we need to return only our array of Symbol objects
        symbols = [x.symbol for x in values[:Roboto.N_COARSE]]
        if 'filter' in Roboto.MSGS: self.Debug("{} CoarseFilter-3 len:{}".format(self.Time, len(symbols)))
        return symbols
        
    def OnSecuritiesChanged(self, changes):
        # is called whenever the universe changes
        if 'main' in Roboto.MSGS: self.Debug("{} Securities changed".format(self.Time))
        
        # remember all changed securities so they can be opened, and cancel all their orders
        self.open = []
        for security in changes.AddedSecurities:
            self.CancelAllOrders(security.Symbol)
            if not security.Invested and (security.Symbol not in self.excl_smbls):
                if 'logic' in Roboto.MSGS: self.Debug("{} Identified bubble for security {}".format(self.Time, security.Symbol))
                self.open.append(security.Symbol)
        
        
    def CheckForEntries(self):
        # once per day, check for entering new short positions for added securities from UniverseSelection

        if 'main' in Roboto.MSGS: self.Debug("{} CheckForEntries".format(self.Time))
        
        num_pos = len([f.Key for f in self.ActiveSecurities if f.Value.Invested]) # positions incl. bullish stock
        
        # open new positions based on self.open which is populated in OnSecuritiesChanged
        new_pos=0
        for symb in self.open:
            if (num_pos+new_pos) < Roboto.MAX_POS:
                if symb in self.universe:
                    new_pos += 1
                    dynamic = Roboto.DYN_POSITION_SIZE/(num_pos + new_pos) # negtive
                    target = max(Roboto.MAX_POSITION_SIZE, dynamic) # max of negative = min of positive
                    tag = "New pos. target allocation {}".format(round(target, 4))
                    self.Short(symb, target, tag)
                    self.open.remove(symb)
        
        # set some portion of portfolio to hold bullish index
        if Roboto.USE_BULL:
            remaining_allocation = max(1.0 - self.MIN_BP - (num_pos * (-1 * Roboto.MAX_POSITION_SIZE)), Roboto.MAX_POSITION_SIZE)
            if 'order' in Roboto.MSGS: self.Debug("{} *** Entering: bull security with {}".format(self.Time, remaining_allocation))
            self.SetHoldings([PortfolioTarget(self.bull, remaining_allocation)])
            self.Plot("Buying Power", "Bull", remaining_allocation)
            
        self.bp = self.Portfolio.MarginRemaining/self.Portfolio.TotalPortfolioValue
        self.Plot("Buying Power", "BP", self.bp)
        self.Plot("# Positions", "pos", num_pos)
        
    def Short(self, symbol, target, tag = "No Tag Provided"):
        # handle entry position sizing, target = negative
        
        # get close of yesterday, mean close of last 30 minutes, and price of last minute
        close_yesterday = self.universe[symbol].close
        price           = float(self.Securities[symbol].Close) # price of last bar according to res
        if 'logic' in Roboto.MSGS: self.Debug("{} Short check {} @ yest:{}, price:{}".format(self.Time, symbol, close_yesterday, price))
        
        # enter short if price is decreasing
        if price > 0:
            
            # calc target order quantity from target percent (quantity is calculated based on current price and is adjusted for the fee model attached to that security)
            q_target = self.CalculateOrderQuantity(symbol, target)
            
            # calc maximum order quantity based on max allowed securities dollar volume, must be negative for shorting
            q_max = - float(Roboto.OF_TOTAL_DV * self.universe[symbol].vol.Current.Value) / price
            
            # enter short with allowed quantity
            q = int(max(q_target, q_max)) # max of negative = min of positive
            if q < 0:
                if 'order' in Roboto.MSGS: self.Debug("{} *** Entering: short for {} @ {}".format(self.Time, q, symbol, price))
                self.EmitInsights(Insight.Price(symbol, timedelta(days = Roboto.MAX_POSITION_AGE), InsightDirection.Down, None, None, None, target))
                self.LimitOrder(symbol, q, price, tag)
            else:
                if q != 0:
                    if 'error' in Roboto.MSGS: self.Error("{} Received positive quantity for short order: {} {} @ {} (Target: {})".format(self.Time, q, symbol, price, target))
        else:
            if 'logic' in Roboto.MSGS: self.Debug("{} Shorting skipped for {} @ {}".format(self.Time, symbol, price))
        
    def CheckForExits(self):
        # every OnDate event, check for liquidation of portfolio positions based on loss, profit, age, and buying power
        if 'main' in Roboto.MSGS: self.Debug("{} CheckForExits".format(self.Time))
        
        closing = set()
        invested = [f.Key for f in self.ActiveSecurities if (f.Value.Invested and (f.Value.Symbol not in self.excl_smbls))]
        
        # liquidate loss positions or old positions or take profit
        for symb in invested:
            
            holding = self.Portfolio[symb]
            
            # update cut loss, limited to UnrealizedProfitPercent = 0
            self.trail_cut_loss[symb] = min(-Roboto.CUT_LOSS, max(self.trail_cut_loss[symb], holding.UnrealizedProfitPercent * Roboto.TCL_GET_EVEN))
            # update cut loss, not limited
            self.trail_cut_loss[symb] = max(self.trail_cut_loss[symb], holding.UnrealizedProfitPercent * Roboto.TCL_TRAIL)
            
            take_profit = Roboto.TAKE_PROFIT
            
            # update trailing profit decrease, kicking in at 70% of the days, decreasing up to Roboto.TP_TRAIL
            tp_decrease = 1 - max(0, ( (1+(self.Time - self.expiry[symb]).days) - Roboto.MAX_POSITION_AGE*Roboto.TP_KICK_IN) / (Roboto.MAX_POSITION_AGE - Roboto.MAX_POSITION_AGE*Roboto.TP_KICK_IN)) * Roboto.TP_TRAIL
            
            # exit positions with a large loss quickly with a market order
            if (holding.UnrealizedProfitPercent < (self.trail_cut_loss[symb] + Roboto.CUT_LOSS)) and ((self.Time - self.expiry[symb]).days > 1):
                if 'order' in Roboto.MSGS: self.Debug("{} *** Liquidating: Market Order for Cutting Losses on {} at {} days, {}%".format(self.Time, holding.Symbol, (self.Time - self.expiry[holding.Symbol]).days, round(holding.UnrealizedProfitPercent, 4) * 100))
                self.CancelAllOrders(holding.Symbol)
                tag = "Cutting loss, age {} days, result {}%".format((self.Time - self.expiry[holding.Symbol]).days, round(holding.UnrealizedProfitPercent, 4) * 100)
                self.RapidExit(holding.Symbol, tag)
                closing.add(holding.Symbol)
                
            # exit positions that have a large profit with a limit order
            elif (holding.UnrealizedProfitPercent > take_profit * tp_decrease):
                if 'order' in Roboto.MSGS: self.Debug("{} *** Liquidating: Limit Order for Taking Profit on {} at {} days, {}%".format(self.Time, holding.Symbol, (self.Time - self.expiry[holding.Symbol]).days, round(holding.UnrealizedProfitPercent, 4) * 100))
                self.CancelAllOrders(holding.Symbol)
                tag = "Taking profit, age {} days, result {}%".format((self.Time - self.expiry[holding.Symbol]).days, round(holding.UnrealizedProfitPercent, 4) * 100)
                self.Cover(holding.Symbol, tag)
                closing.add(holding.Symbol)
            
            # exit old positions with a limit order
            elif (self.Time - self.expiry[holding.Symbol]).days > Roboto.MAX_POSITION_AGE:
                if 'order' in Roboto.MSGS: self.Debug("{} *** Liquidating: Limit Order for Expired {} at {} days, {}%".format(self.Time, holding.Symbol, (self.Time - self.expiry[holding.Symbol]).days, round(holding.UnrealizedProfitPercent, 4) * 100))
                self.CancelAllOrders(holding.Symbol)
                tag = "Expired, age {} days, result {}%".format((self.Time - self.expiry[holding.Symbol]).days, round(holding.UnrealizedProfitPercent, 4) * 100)
                #self.RapidExit(holding.Symbol, tag)
                self.Cover(holding.Symbol, tag)
                closing.add(holding.Symbol)
                
        # liquidate most profitable position if buying power is too low
        self.bp = self.Portfolio.MarginRemaining / self.Portfolio.TotalPortfolioValue
        if self.bp < Roboto.MIN_BP:
            if 'logic' in Roboto.MSGS: self.Debug("{} Buying Power too low: {}".format(self.Time, self.bp))
         
            class Factor:
                def __init__(self, holding):
                    self.holding = holding
                    self.unrealized = self.holding.UnrealizedProfitPercent
            
            track = {}
            for symb in invested:
                holding = self.Portfolio[symb]
                track[holding.Symbol] = Factor(holding)
            
            values = list(set(track.values()) - set(closing)) # remove any symbols already beeing closed above (loss positions or old positions or take profit)
            if len(values) > 0:
                values.sort(key=lambda f: f.unrealized, reverse=True)
                if 'order' in Roboto.MSGS: self.Debug("{} *** Liquidating: Limit Order for Buying Power {} @ {}".format(self.Time, values[0].holding.Symbol, values[0].unrealized))
                self.CancelAllOrders(values[0].holding.Symbol)
                tag = "Liquidating, age {} days, result {}%".format((self.Time - self.expiry[holding.Symbol]).days, round(holding.UnrealizedProfitPercent, 4) * 100)
                self.Cover(values[0].holding.Symbol, tag)
            else:
                if 'error' in Roboto.MSGS: self.Error("{} Unable to liquidate: {} {}".format(self.Time, len(values), len(closing)))
        
    def RapidExit(self, symbol, tag = "No Tag Provided"):
        # performs a market order for a quick exit
        
        q = -1 * int(self.Portfolio[symbol].Quantity)
        if q > 0:
            if 'debug' in Roboto.MSGS: self.Debug("{} Rapid Exit {} {}".format(self.Time, q, symbol))
            self.EmitInsights(Insight.Price(symbol, timedelta(days = Roboto.MAX_POSITION_AGE), InsightDirection.Up, None, None, None, 0.00))
            self.MarketOrder(symbol, q, False, tag)
        else:
            if q != 0:
                if 'error' in Roboto.MSGS: self.Error("{} Received negative quantity for rapid exit order: {} {}".format(self.Time, q, symbol))
        
    def Cover(self, symbol, tag = "No Tag Provided"):
        # performs a limit order at previous close price for an exit
        
        q = -1 * int(self.Portfolio[symbol].Quantity)
        price = self.Securities[symbol].Close
        if q > 0:
            if 'debug' in Roboto.MSGS: self.Debug("{} Cover {} {} @ {}".format(self.Time, q, symbol, price))
            self.EmitInsights(Insight.Price(symbol, timedelta(days = Roboto.MAX_POSITION_AGE), InsightDirection.Flat, None, None, None, 0.00))
            self.LimitOrder(symbol, q, price, tag)
        else:
            if q != 0:
                if 'error' in Roboto.MSGS: self.Error("{} Received negative quantity for cover order: {} {} @ {}".format(self.Time, q, symbol, price))
        
    def CancelAllOrders(self, symbol):
        if 'debug' in Roboto.MSGS: self.Debug("{} Cancelling all orders for {}".format(self.Time, symbol))
        openOrders = self.Transactions.CancelOpenOrders(symbol)
        for oo in openOrders:
            if not (oo.Status == OrderStatus.CancelPending):
                r = oo.Cancel()
                if not r.IsSuccess:
                    if 'error' in Roboto.MSGS: self.Error("{} Failed to cancel open order {} of {} for reason: {}, {}".format(self.Time, oo.Quantity, oo.Symbol, r.ErrorMessage, r.ErrorCode))
        
    def OnOrderEvent(self, orderEvent):
        if orderEvent.Status == OrderStatus.Filled:
            
            order = self.Transactions.GetOrderById(orderEvent.OrderId)
            if 'debug' in Roboto.MSGS: self.Debug("{} Filled {} of {} at {}".format(self.Time, order.Quantity, order.Symbol, order.Price))
            
            # if completely liquidating position, stop tracking position age
            if not self.Portfolio[order.Symbol].Invested:
                try:
                    del self.expiry[order.Symbol]
                    del self.trail_cut_loss[order.Symbol]
                    if 'debug' in Roboto.MSGS: self.Debug("{} No longer tracking {}".format(self.Time, order.Symbol))
                except Error:
                    if 'error' in Roboto.MSGS: self.Error("{} Key deletion failed for {}".format(self.Time, order.Symbol))
            
            # if position is completely new, start tracking position age
            else:
                if (order.Symbol not in self.expiry):
                    self.expiry[order.Symbol] = self.Time
                else:
                    if 'error' in Roboto.MSGS: self.Error("{} Key already existed for {}".format(self.Time, order.Symbol))
                
                if (order.Symbol not in self.trail_cut_loss):
                    self.trail_cut_loss[order.Symbol] = 0
                else:
                    if 'error' in Roboto.MSGS: self.Error("{} Key already existed for {}".format(self.Time, order.Symbol))