Overall Statistics
Total Orders
1508
Average Win
5.71%
Average Loss
-3.75%
Compounding Annual Return
157.509%
Drawdown
42.200%
Expectancy
0.621
Start Equity
8000
End Equity
119102633.09
Net Profit
1488682.914%
Sharpe Ratio
2.663
Sortino Ratio
3.386
Probabilistic Sharpe Ratio
99.989%
Loss Rate
36%
Win Rate
64%
Profit-Loss Ratio
1.52
Alpha
0
Beta
0
Annual Standard Deviation
0.373
Annual Variance
0.139
Information Ratio
2.728
Tracking Error
0.373
Treynor Ratio
0
Total Fees
$980792.14
Estimated Strategy Capacity
$790000.00
Lowest Capacity Asset
QLD TJNNZWL5I4IT
Portfolio Turnover
25.16%
Drawdown Recovery
177
from datetime import timedelta, datetime
import math
from AlgorithmImports import *
import numpy as np
from settings import settings

class RSIRebalanceStrategy(QCAlgorithm):

    def Initialize(self):
        self.set_start_date(2015, 6, 1)
        # self.set_end_date(2022, 1, 1)

        # https://www.interactivebrokers.ca/en/accounts/tradingPermissions.php?ib_entity=ca
        self.ibkr_market_order_buffer = 0.02
        self.ibkr_fee_buffer = 25

        self.set_brokerage_model(
            BrokerageName.INTERACTIVE_BROKERS_BROKERAGE, AccountType.CASH
        )

        self.symbols = [
            "QLD",
            "TQQQ",
            "SPY",
            "PSQ",
            "UPW",
            "VIXY",
            "IBB",
            "QQQE",
            "VTV",
            "VOOG",
            "VOOV",
            "XLP",
            "XLY",
            "GLD",
            "IBB",
            "TLT",
            "SQQQ"
        ]

        self.equities = {}

        for name in self.symbols:
            self.equities[name] = self.add_equity_symbol(name)

        if not self.live_mode:
            self.set_warmup(timedelta(days=250))
            self.set_cash(settings.start_cash)

            self.schedule.on(
                self.date_rules.month_start(1),
                self.time_rules.after_market_close(self.equities["SPY"], 0),
                self.add_cash,
            )
            self.schedule.on(
                self.date_rules.month_start(15),
                self.time_rules.after_market_close(self.equities["SPY"], 0),
                self.add_cash,
            )

        self.schedule.on(
            self.date_rules.every_day(),
            self.time_rules.before_market_close(self.equities["SPY"], 6),
            self.reset_liquidated,
        )

        step = -0.25 if self.live_mode else -1
        stop = 0 if self.live_mode else 2
        for offset in np.arange(5, stop, step):
            self.schedule.on(
                self.date_rules.every_day(),
                self.time_rules.before_market_close(self.equities["SPY"], offset),
                self.rebalance,
            )

        self.cashInvested = self.portfolio.cash_book["USD"].amount
        self.liquidated = False
        self.additional_cash = 0
        self.indicator_data = {
            "rsi": {},
            "sma": {}
        }
        self.start_time = datetime.now()

    def reset_liquidated(self):
        self.liquidated = False
        self.indicator_data = {
            "rsi": {},
            "sma": {}
        }

    def add_equity_symbol(self, symbol: str) -> Symbol:
        s = self.add_equity(
            symbol, Resolution.MINUTE, data_normalization_mode=DataNormalizationMode.ADJUSTED
        )
        s.set_settlement_model(ImmediateSettlementModel())
        s.set_fill_model(ImmediateFillModel())
        return s.Symbol

    def add_cash(self):
        dcaCash = settings.dca_cash
        self.portfolio.cash_book["USD"].add_amount(dcaCash)
        self.cashInvested += dcaCash

    def rsi_2(self, symbol, period):
        symbol_key = symbol.value
        if self.indicator_data["rsi"].get(symbol_key) is None:
            self.indicator_data["rsi"] = {symbol_key: {}}
        
        if self.indicator_data["rsi"][symbol_key].get(period) is None or self.indicator_data["rsi"][symbol_key].get(period) is 0: 
            self.indicator_data["rsi"][symbol_key] = {period: 0}
        else: 
            return self.indicator_data["rsi"][symbol_key][period]

        warmup = int(round_up(11 * math.sqrt(period) + 5.5 * period, 0))
        extension = min(warmup, 250)
        r_w = RollingWindow[float](extension)
        history = self.history(symbol.value, extension - 1, Resolution.DAILY)
        for historical_bar in history:
            r_w.add(historical_bar.close)
        while r_w.count < extension:
            current_price = self.securities[symbol.value].price
            r_w.add(current_price)
        if r_w.is_ready:
            average_gain = 0
            average_loss = 0
            gain = 0
            loss = 0
            for i in range(extension - 1, extension - period - 1, -1):
                gain += max(r_w[i - 1] - r_w[i], 0)
                loss += abs(min(r_w[i - 1] - r_w[i], 0))
            average_gain = gain / period
            average_loss = loss / period
            for i in range(extension - period - 1, 0, -1):
                average_gain = (
                    average_gain * (period - 1) + max(r_w[i - 1] - r_w[i], 0)
                ) / period
                average_loss = (
                    average_loss * (period - 1) + abs(min(r_w[i - 1] - r_w[i], 0))
                ) / period
            if average_loss == 0:
                return 100
            else:
                rsi = 100 - (100 / (1 + average_gain / average_loss))

                self.indicator_data["rsi"][symbol_key][period] = rsi

                return rsi
        else:
            return None

    def sma_2(self, symbol: Symbol, period):
        symbol_key = symbol.value
        if self.indicator_data["sma"].get(symbol_key) is None:
            self.indicator_data["sma"] = {symbol_key: {}}
        
        if self.indicator_data["sma"][symbol_key].get(period) is None or self.indicator_data["sma"][symbol_key].get(period) is 0: 
            self.indicator_data["sma"][symbol_key] = {period: 0}
        else: 
            return self.indicator_data["sma"][symbol_key][period]

        r_w = RollingWindow[float](period)
        history = self.history(symbol.value, period - 1, Resolution.DAILY)
        for historical_bar in history:
            r_w.add(historical_bar.close)
        while r_w.count < period:
            current_price = self.securities[symbol.value].price
            r_w.add(current_price)
        if r_w.is_ready:
            sma = sum(r_w) / period

            self.indicator_data["sma"][symbol_key] = {period: sma}

            return sma
        else:
            return 0

    def set_holdings_2(self, symbol: Symbol, portion=1):
        """IBKR Brokage Model somehow doesn't wait till liquidation finishes in set_holdings(symbol, 1, True)
        So we liquidate explicitly first and set_holdings after
        """
        # liquidate any other symbols when switching
        if (
            not self.liquidated
            and not self.portfolio[symbol].invested
            and self.portfolio.invested
        ):
            # for curr_pos in current_positions:
            #     self.liquidate(curr_pos, tag="Liquidated")
            self.liquidate()
            self.liquidated = True
            return

        if (
            len(self.transactions.get_open_orders()) > 0
            or self.portfolio.unsettled_cash > 0
            or (
                self.liquidated
                and self.portfolio.total_portfolio_value != self.portfolio.cash
            )
            or not self.securities[symbol].has_data
        ):
            return

        # Usually portion < 1 is for risky asset like VIXY, no need to keep it's portion
        # as we don't hold it for long
        if portion < 1:
            if self.liquidated and not self.portfolio.invested:
                self.set_holdings(symbol, portion)
                return
            elif self.portfolio[symbol].invested:
                self.set_holdings(symbol, portion)
                return

        # Calculate for limit order
        # using 99% of buying power to avoid "Insufficient buying power to complete orders" error
        # no idea why QC's engine has weird initial margin stuff
        # TFSA doesn't have any initial margin requirements, so we can use 100% - 10 dollars
        buying_power = (
            self.portfolio.margin_remaining - 10
            if self.live_mode
            else self.portfolio.margin_remaining * 0.988
        )
        symbol_price = self.securities[symbol].ask_price

        # 5 cents buffer for limit order, IBKR will find lowest ask
        limit_price = round_up(symbol_price + 0.03)

        shares_num: int = math.floor(buying_power / limit_price)

        security = self.securities[symbol]
        initial_margin_params = InitialMarginParameters(security, shares_num - 1)
        initial_margin_required = (
            security.buying_power_model.get_initial_margin_requirement(
                initial_margin_params
            )
        )
        # Extract the numeric value from the InitialMargin object
        required_margin_value = initial_margin_required.value

        while (
            shares_num >= 2
            and required_margin_value > buying_power - self.ibkr_fee_buffer
        ):
            shares_num = shares_num - 1
            initial_margin_params = InitialMarginParameters(security, shares_num)
            initial_margin_required = (
                security.buying_power_model.get_initial_margin_requirement(
                    initial_margin_params
                )
            )
            required_margin_value = initial_margin_required.value

        # order_amount = shares_num * limit_price
        # satisfy_ibkr_market_order = (1 - (order_amount / cash)) > self.ibkr_market_order_buffer

        if (
            shares_num >= 2
            and required_margin_value < buying_power - self.ibkr_fee_buffer
        ):
            self.limit_order(symbol, shares_num, limit_price)
            return

    def rebalance(self):
        if self.time < self.start_date:
            return

        if not self.securities["SPY"].has_data:
            return

        if not self.portfolio["VIXY"].invested and self.time.minute <= 25:
            return

        rsi = self.rsi_2
        sma = self.sma_2
        set_holdings = self.set_holdings_2
        equities = self.equities

        if (
            rsi(equities["QQQE"], 10) > 79
            or rsi(equities["VTV"], 10) > 79
            or rsi(equities["VOOG"], 10) > 79
            or rsi(equities["VOOV"], 10) > 79
            or rsi(equities["XLP"], 10) > 75
            or rsi(equities["TQQQ"], 10) > 79
            or rsi(equities["XLY"], 10) > 80
            or rsi(equities["SPY"], 10) > 80
        ):
            set_holdings(equities["VIXY"], 1)

        elif rsi(equities["TQQQ"], 10) < 31:
            set_holdings(equities["TQQQ"], 1)
        elif rsi(equities["IBB"], 10) < 24:
            set_holdings(equities["IBB"], 1)

        elif self.securities["TQQQ"].price > sma(equities["TQQQ"], 200):
            set_holdings(equities["QLD"], 1)
        else:
            if self.securities["TQQQ"].price > sma(equities["TQQQ"], 20):
                if rsi(equities["SQQQ"], 10) < 31: 
                    set_holdings(equities["PSQ"], 1)
                else:
                    set_holdings(equities["QLD"], 1)
            else:
                if rsi(equities["PSQ"], 10) > rsi(equities["TLT"], 10):
                    set_holdings(equities["PSQ"], 1)
                else: 
                    set_holdings(equities["TLT"], 1)

    def on_end_of_algorithm(self) -> None:
        self.debug(f"Cash invested: {self.cashInvested}")
        time_elasped = datetime.now() - self.start_time
        self.debug(f"Algo took: {time_elasped.total_seconds()} seconds")

def round_up(n, decimals=2):
    multiplier = 10**decimals
    return math.ceil(n * multiplier) / multiplier
# region imports
from AlgorithmImports import *
# endregion

class Settings():
    def __init__(self):
        self.start_cash = 8000
        self.dca_cash = 230

settings = Settings()