Overall Statistics
Total Trades
575
Average Win
1.61%
Average Loss
-1.77%
Compounding Annual Return
31.576%
Drawdown
24.600%
Expectancy
0.498
Net Profit
1395.550%
Sharpe Ratio
1.026
Probabilistic Sharpe Ratio
47.826%
Loss Rate
22%
Win Rate
78%
Profit-Loss Ratio
0.91
Alpha
0.181
Beta
0.565
Annual Standard Deviation
0.216
Annual Variance
0.047
Information Ratio
0.716
Tracking Error
0.209
Treynor Ratio
0.391
Total Fees
$4854.09
Estimated Strategy Capacity
$110000000.00
Lowest Capacity Asset
LLY R735QTJ8XC9X
Portfolio Turnover
6.07%
from AlgorithmImports import *


class BBandsAlgorithm(QCAlgorithm):

    def Initialize(self) -> None:
        # backtest settings
        self.SetCash(100000)
        self.SetStartDate(2014, 1, 1)
        self.SetBrokerageModel(BrokerageName.InteractiveBrokersBrokerage, AccountType.Cash)
        self.lastSellTime = datetime.min

        self.BBstd = float(self.GetParameter("stdBB")) # 4
        self.BBvalue = float(self.GetParameter("BB")) # 15
        self.ROCvalue = float(self.GetParameter("ROC")) # 79

        # Initialize an empty dictionary to store last trade times (new addition)
        self.lastTradeTimes = {}

        # settings
        self.EnableAutomaticIndicatorWarmUp = True
        self.Settings.FreePortfolioValuePercentage = 0.05
        self.UniverseSettings.Resolution = Resolution.Daily

        # ETF universe
        self.etf = self.AddEquity("SPY", self.UniverseSettings.Resolution).Symbol
        self.AddUniverseSelection(
            ETFConstituentsUniverseSelectionModel(self.etf, self.UniverseSettings, self.ETFConstituentsFilter))

        # Alternative investments
        self.alternatives = {
            'UUP': self.AddEquity('UUP', self.UniverseSettings.Resolution).Symbol,
            'TLT': self.AddEquity('TLT', self.UniverseSettings.Resolution).Symbol,
            'GLD': self.AddEquity('GLD', self.UniverseSettings.Resolution).Symbol
        }

        self.SetBenchmark(self.etf)

        self.symbolData = {}
        self.universe = []
        self.buy_prices = {}

        # initialize flag for stop loss triggered
        self.stop_loss_triggered = False
    
    def ETFConstituentsFilter(self, constituents: List[ETFConstituentData]) -> List[Symbol]:
        # validate in-data
        if constituents is None:
            return Universe.Unchanged

        selected = [c for c in constituents if c.Weight]
        sorted_selected = sorted([c for c in selected], key=lambda c: c.Weight, reverse=True)[:(int(self.GetParameter("selected")))] #20

        self.universe = [c.Symbol for c in sorted_selected]
        return self.universe


    def OnOrderEvent(self, orderEvent):
        if orderEvent.Status == OrderStatus.Filled:
            order = self.Transactions.GetOrderById(orderEvent.OrderId)
            symbol = order.Symbol  # Extract the symbol from the order
            fillPrice = orderEvent.FillPrice  # Extract the fill price of the order

            # Check if it's a sell order and tag accordingly
            if order.Direction == OrderDirection.Sell:
                # Tagging for stop loss
                if fillPrice <= 0.95 * self.buy_prices.get(symbol, float('inf')):
                    order.Tag = "STOP LOSS"
                # Tagging for profit sell
                elif fillPrice >= 1.05 * self.buy_prices.get(symbol, 0):
                    order.Tag = "5%+ PROFIT"
                # Tagging if held for more than 10 days
                elif (self.Time - self.lastTradeTimes.get(symbol, datetime.min)).days >= 10:
                    order.Tag = "HELD FOR 10 DAYS"

            # Check if it's a buy order and tag accordingly
            if order.Direction == OrderDirection.Buy:
                order.Tag = "BUY CONDITIONS MET"

            # After tagging, here you may want to update your records such as `lastTradeTimes` or `buy_prices`
            if order.Direction == OrderDirection.Buy:
                self.buy_prices[symbol] = fillPrice
                self.lastTradeTimes[symbol] = self.Time  # Record the time of the purchase
            elif order.Direction == OrderDirection.Sell and symbol in self.lastTradeTimes:
                del self.lastTradeTimes[symbol] 

    def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
        # validate in-data
        if changes is None:
            return

        for security in changes.AddedSecurities:
            self.symbolData[security.Symbol] = SymbolData(self, security.Symbol)

        for security in changes.RemovedSecurities:
            self.Liquidate(security.Symbol)
            symbolData = self.symbolData.pop(security.Symbol, None)
            if symbolData:
                symbolData.dispose()

    def OnData(self, data: Slice) -> None:
        # Check if we are still warming up
        if self.IsWarmingUp:
            return

        if self.Time - self.lastSellTime < timedelta(days=1):
            return

        # liquidate assets that we should not trade
        for symbol in [x.Key for x in self.Portfolio if x.Value.Invested]:
            if symbol not in self.universe or not self.Securities[symbol].IsTradable:
                self.Liquidate(symbol)

            # Time-based Exit
            if symbol in self.lastTradeTimes:
                holding_period = (self.Time - self.lastTradeTimes[symbol]).days
                if holding_period >= 10:
                    self.Liquidate(symbol, "HELD FOR 10 DAYS")
                    # After liquidation, it's a good practice to remove the symbol from lastTradeTimes
                    del self.lastTradeTimes[symbol]
                    continue

        # Calculate the ROC for all symbols and select the max ROC symbol involving only positive ROC values
        roc_values = {symbol: self.symbolData[symbol].roc.Current.Value for symbol in self.universe if
                    symbol in self.symbolData and self.symbolData[symbol].roc.Current.Value > 0}
        if roc_values:
            max_roc_symbol = max(roc_values, key=roc_values.get)
            max_roc = roc_values[max_roc_symbol]
        else:
            max_roc_symbol = None
            max_roc = None

        # Check if all ROCs are negative and liquidate/reallocate if necessary
        if all(value < 0 for value in roc_values.values()):
            self.Liquidate(tag = "Liquidate for New Max ROC")
            self.Reallocate()
            return

        # Implement the Buy Conditions
        if max_roc_symbol and self.CanInvest() and max_roc > 0:
            price = self.Securities[max_roc_symbol].Price
            symbolData = self.symbolData[max_roc_symbol]
            if symbolData.bb.MiddleBand.Current.Value < price < symbolData.bb.UpperBand.Current.Value:
                quantity = self.CalculateOrderQuantity(max_roc_symbol, 1)
                orderTicket = self.MarketOrder(max_roc_symbol, quantity)
                orderTicket.UpdateTag(f"BUY CONDITIONS MET")
                self.buy_prices[max_roc_symbol] = price
                self.lastTradeTimes[max_roc_symbol] = self.Time  # Update the last trade time when a new holding is set (new addition)

        # Implement the Sell Conditions
        for symbol in self.Portfolio.Keys:
            price = self.Securities[symbol].Price
            if self.Portfolio[symbol].Invested:
                # Profit taking
                if price >= 1.05 * self.buy_prices.get(symbol, 0):
                    self.Liquidate(symbol,"5%+ PROFIT")
                    # It's a good practice to remove the symbol from lastTradeTimes after liquidation
                    if symbol in self.lastTradeTimes:
                        del self.lastTradeTimes[symbol]
                    continue

                # Stop Loss
                if price <= 0.95 * self.buy_prices.get(symbol, self.Portfolio[symbol].AveragePrice):
                    self.Liquidate(symbol, "STOP LOSS")
                    # After liquidation, it's a good practice to remove the symbol from lastTradeTimes
                    if symbol in self.lastTradeTimes:
                        del self.lastTradeTimes[symbol]
                    continue

    def CanInvest(self) -> bool:
        return  sum(1 for x in self.Portfolio if x.Value.Invested) < 10

    def Reallocate(self) -> None:
        # Calculate ROC for UUP and TLT
        roc_uup = self.symbolData[self.alternatives['UUP']].roc.Current.Value
        roc_tlt = self.symbolData[self.alternatives['TLT']].roc.Current.Value
        
        # Decide which alternative to buy based on ROC
        if roc_uup > 0 or roc_tlt > 0:
            if roc_uup > roc_tlt:
                quantity = self.CalculateOrderQuantity(self.alternatives['UUP'], 1)
                orderTicket = self.MarketOrder(self.alternatives['UUP'], quantity)
                orderTicket.UpdateTag(f"UUP ROC > 0")

            else:
                quantity = self.CalculateOrderQuantity(self.alternatives['TLT'], 1)
                orderTicket = self.MarketOrder(self.alternatives['TLT'], quantity)
                orderTicket.UpdateTag(f"TLT ROC > 0")

        else:
            # If both UUP and TLT have negative ROC, invest in GLD
                quantity = self.CalculateOrderQuantity(self.alternatives['GLD'], 1)
                orderTicket = self.MarketOrder(self.alternatives['GLD'], quantity)
                orderTicket.UpdateTag(f"GLD ROC > 0")

        # If any equities meet buy conditions, liquidate alternatives
        for symbol in self.universe:
            if self.Securities[symbol].Invested:
                symbol_data = self.symbolData[symbol]
                price = self.Securities[symbol].Price
                if symbol_data.roc.Current.Value > 0 and symbol_data.bb.MiddleBand.Current.Value < price < symbol_data.bb.UpperBand.Current.Value:
                    # Liquidate UUP, TLT, and GLD
                    self.Liquidate(self.alternatives['UUP'], "Liquidating for New Max ROC")
                    self.Liquidate(self.alternatives['TLT'], "Liquidating for New Max ROC")
                    self.Liquidate(self.alternatives['GLD'], "Liquidating for New Max ROC")
                    break  # Exit the loop as we only need to liquidate once

        
class SymbolData(object):
    def __init__(self, algorithm, symbol):
        self.algorithm = algorithm
        self.symbol = symbol

        self.stdBB = float(algorithm.BBstd)
        self.BBi = int(algorithm.BBvalue)
        self.ROCi = int(algorithm.ROCvalue)

        # Assuming 'BB' and 'ROC' are methods provided by QuantConnect's QCAlgorithm class
        self.bb = algorithm.BB(symbol, self.BBi, self.stdBB, MovingAverageType.Simple, Resolution.Daily)
        self.roc = algorithm.ROC(symbol, self.ROCi, Resolution.Daily)

    def dispose(self):
        # deregister indicators and remove consolidator
        pass