Overall Statistics
Total Orders
437
Average Win
7.04%
Average Loss
-2.15%
Compounding Annual Return
514.314%
Drawdown
28.100%
Expectancy
1.563
Start Equity
8000
End Equity
843674.25
Net Profit
10445.928%
Sharpe Ratio
5.784
Sortino Ratio
8.135
Probabilistic Sharpe Ratio
99.999%
Loss Rate
40%
Win Rate
60%
Profit-Loss Ratio
3.27
Alpha
0
Beta
0
Annual Standard Deviation
0.428
Annual Variance
0.184
Information Ratio
5.91
Tracking Error
0.428
Treynor Ratio
0
Total Fees
$5261.78
Estimated Strategy Capacity
$2000000.00
Lowest Capacity Asset
PSQ TJNNZWL5I4IT
Portfolio Turnover
32.92%
Drawdown Recovery
69
# region imports
from AlgorithmImports import *
import smtplib
from email.mime.text import MIMEText
# endregion

# Your New Python File
def send_email(subject, body, recipients, sender="sixseven.tralalero@gmail.com"):
    if isinstance(recipients, str):
        recipients_list = [recipients]
    else:
        recipients_list = list(recipients)
    msg = MIMEText(body, "plain", "utf-8")
    msg["Subject"] = subject
    msg["From"] = sender
    msg["To"] = ", ".join(recipients_list)
    try:
        with smtplib.SMTP("smtp.gmail.com", 587) as smtp_server:
            smtp_server.starttls()
            smtp_server.login(
                sender, "cewn vuqx yhdt cspj"
            )  # Use an app password if 2FA is enabled
            smtp_server.sendmail(sender, recipients, msg.as_string())
    except Exception as e:
        logger.error(
            f"Failed to send email: {e}, subject: {subject}, recipients: {recipients}"
        )
from datetime import timedelta, datetime
import math
from AlgorithmImports import *
import numpy as np
from settings import settings
from email_util import send_email

class RSIRebalanceStrategy(QCAlgorithm):

    def Initialize(self):
        self.set_start_date(2023, 1, 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 = ["QQQ", "QLD", "TQQQ", "SPY", "QID", "UPW", "VIXY", "LABU", 
            "QQQE", "VTV", "VOOG", "VOOV", "XLP", "XLY", "GLD", "KMLM", "XLU", "PSQ"]

        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 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

        if self.live_mode: 
            self.indicator_data = {
                "rsi": {},
                "sma": {}
            }

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

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

        elif rsi("TQQQ", 10) < 31:
            set_holdings("TQQQ", 1)
        elif rsi("LABU", 10) < 25:
            set_holdings("LABU", 1)

        elif price("TQQQ") > sma("TQQQ", 200):
            if rsi("TQQQ", 10) > rsi("KMLM", 10):
                set_holdings("TQQQ", 1)
            else:
                set_holdings("PSQ", 1)
        else:
            if price("TQQQ") > sma("TQQQ", 20):
                set_holdings("TQQQ", 1)
            elif price("KMLM") > sma("KMLM", 20):
                set_holdings("QID", 1)
            else:
                set_holdings("XLU", 1)

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

    def price(self, symbol: str):
        return self.securities[symbol].price

    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: str, period):
        if self.indicator_data["rsi"].get(symbol) is None:
            self.indicator_data["rsi"] = {symbol: {}}
        
        if self.indicator_data["rsi"][symbol].get(period) is None or self.indicator_data["rsi"][symbol].get(period) is 0: 
            self.indicator_data["rsi"][symbol] = {period: 0}
        else: 
            return self.indicator_data["rsi"][symbol][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, 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].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][period] = rsi

                return rsi
        else:
            return None

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

        r_w = RollingWindow[float](period)
        history = self.history(symbol, period - 1, Resolution.DAILY)
        total = sum(bar.close for bar in history) + self.securities[symbol].price

        sma = round(total / period, 3)
        self.indicator_data["sma"][symbol] = {period: sma}

        return sma

    def set_holdings_2(self, symbol: str, 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 on_end_of_algorithm(self) -> None:
        self.debug(f"Cash invested: {self.cashInvested}")
        time_elapsed =  datetime.now() - self.start_time
        self.debug(f"Algo took: {time_elapsed.total_seconds()} seconds")

    # def on_order_event(self, order_event: OrderEvent) -> None:
    #     if not self.live_mode:
    #         return
        
    #     order = None
    #     try:
    #         order = self.transactions.get_order_by_id(order_event.order_id)
    #     except Exception:
    #         order = None

    #     symbol = None
    #     if order is not None and getattr(order, "Symbol", None) is not None:
    #         symbol = order.symbol.value
    #     else:
    #         symbol = getattr(order_event, "Symbol", "UNKNOWN")

    #     qty = getattr(order, "Quantity", getattr(order_event, "Quantity", 0))
    #     order_type = getattr(order, "Type", getattr(order_event, "Type", ""))
    #     fill_qty = getattr(order_event, "FillQuantity", 0)
    #     fill_price = getattr(order_event, "FillPrice", 0)
    #     message = getattr(order_event, "Message", "")

    #     status = order_event.status

    #     if (
    #         status == OrderStatus.SUBMITTED
    #         or status == OrderStatus.PARTIALLY_FILLED
    #         or status == OrderStatus.FILLED
    #         or status == OrderStatus.CANCELED
    #         or status == OrderStatus.INVALID
    #     ):
    #         order_message = (
    #             f"Order {str(status)}\n"
    #             f"  Id: {order_event.order_id}\n"
    #             f"  Symbol: {symbol}\n"
    #             f"  Qty: {qty}\n"
    #             f"  Type: {order_type}\n"
    #             f"  FillQty: {fill_qty}\n"
    #             f"  FillPrice: {fill_price}"
    #             f"  Message: {message}"
    #         ) 
            
    #         self.log(
    #             order_message
    #         )

    #         send_email("Smart QQQ", order_message, "tuenguyen12329@gmail.com")



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()