Overall Statistics
Total Orders
59
Average Win
0.02%
Average Loss
-0.02%
Compounding Annual Return
1.017%
Drawdown
0.100%
Expectancy
0.309
Start Equity
1000000
End Equity
1001295.4
Net Profit
0.130%
Sharpe Ratio
1.876
Sortino Ratio
2.041
Probabilistic Sharpe Ratio
80.962%
Loss Rate
32%
Win Rate
68%
Profit-Loss Ratio
0.91
Alpha
0
Beta
0
Annual Standard Deviation
0.002
Annual Variance
0
Information Ratio
2.891
Tracking Error
0.002
Treynor Ratio
0
Total Fees
$54.60
Estimated Strategy Capacity
$70000.00
Lowest Capacity Asset
SPXW XWIOBRFBVCEM|SPX 31
Portfolio Turnover
0.02%
#region imports
from AlgorithmImports import *
#endregion

from Underlying import Underlying
import operator

class DataHandler:
    # The supported cash indices by QC https://www.quantconnect.com/docs/v2/writing-algorithms/datasets/tickdata/us-cash-indices#05-Supported-Indices
    # These need to be added using AddIndex instead of AddEquity
    CashIndices = ['VIX','SPX','NDX']

    def __init__(self, context, ticker, strategy):
        self.ticker = ticker
        self.context = context
        self.strategy = strategy

    # Method to add the ticker[String] data to the context.
    # @param resolution [Resolution]
    # @return [Symbol]
    def AddUnderlying(self, resolution=Resolution.Minute):
        if self.__CashTicker():
            return self.context.AddIndex(self.ticker, resolution=resolution)
        else:
            return self.context.AddEquity(self.ticker, resolution=resolution)

    # SECTION BELOW IS FOR ADDING THE OPTION CHAIN
    # Method to add the option chain data to the context.
    # @param resolution [Resolution]
    def AddOptionsChain(self, underlying, resolution=Resolution.Minute):
        if self.ticker == "SPX":
            # Underlying is SPX. We'll load and use SPXW and ignore SPX options (these close in the AM)
            return self.context.AddIndexOption(underlying.Symbol, "SPXW", resolution)
        elif self.__CashTicker():
            # Underlying is an index
            return self.context.AddIndexOption(underlying.Symbol, resolution)
        else:
            # Underlying is an equity
            return self.context.AddOption(underlying.Symbol, resolution)

    # Should be called on an option object like this: option.SetFilter(self.OptionFilter)
    # !This method is called every minute if the algorithm resolution is set to minute
    def SetOptionFilter(self, universe):
        # Start the timer
        # self.context.executionTimer.start()
        # Include Weekly contracts
        # nStrikes contracts to each side of the ATM
        # Contracts expiring in the range (DTE-5, DTE)
        filteredUniverse = universe.Strikes(-self.strategy.nStrikesLeft, self.strategy.nStrikesRight)\
                                   .Expiration(max(0, self.strategy.dte - self.strategy.dteWindow), max(0, self.strategy.dte))\
                                   .IncludeWeeklys()

        # Stop the timer
        # self.context.executionTimer.stop()

        return filteredUniverse

    # SECTION BELOW HANDLES OPTION CHAIN PROVIDER METHODS
    def optionChainProviderFilter(self, symbols, min_strike_rank, max_strike_rank, minDte, maxDte):
        # Check if we got any symbols to process
        if len(symbols) == 0:
            return None

        # Filter the symbols based on the expiry range
        filteredSymbols = [symbol for symbol in symbols
                            if minDte <= (symbol.ID.Date.date() - self.context.Time.date()).days <= maxDte
                          ]

        # Exit if there are no symbols for the selected expiry range
        if not filteredSymbols:
            return None

        # If this is not a cash ticker, filter out any non-tradable symbols.
        # to escape the error `Backtest Handled Error: The security with symbol 'SPY 220216P00425000' is marked as non-tradable.`
        if not self.__CashTicker():
            filteredSymbols = [x for x in filteredSymbols if self.context.Securities[x.ID.Symbol].IsTradable]

        underlying = Underlying(self.context, self.strategy.underlyingSymbol)
        # Get the latest price of the underlying
        underlyingLastPrice = underlying.Price()

        # Find the ATM strike
        atm_strike = sorted(filteredSymbols
                            ,key = lambda x: abs(x.ID.StrikePrice - underlying.Price())
                            )[0].ID.StrikePrice

        # Get the list of available strikes
        strike_list = sorted(set([i.ID.StrikePrice for i in filteredSymbols]))

        # Find the index of ATM strike in the sorted strike list
        atm_strike_rank = strike_list.index(atm_strike)
        # Get the Min and Max strike price based on the specified number of strikes
        min_strike = strike_list[max(0, atm_strike_rank + min_strike_rank + 1)]
        max_strike = strike_list[min(atm_strike_rank + max_strike_rank - 1, len(strike_list)-1)]

        # Get the list of symbols within the selected strike range
        selectedSymbols = [symbol for symbol in filteredSymbols
                                if min_strike <= symbol.ID.StrikePrice <= max_strike
                          ]

        # Loop through all Symbols and create a list of OptionContract objects
        contracts = []
        for symbol in selectedSymbols:
            # Create the OptionContract
            contract = OptionContract(symbol, symbol.Underlying)
            self.AddOptionContracts([contract], resolution = self.context.timeResolution)

            # Set the BidPrice
            contract.BidPrice = self.Securities[contract.Symbol].BidPrice
            # Set the AskPrice
            contract.AskPrice = self.Securities[contract.Symbol].AskPrice
            # Set the UnderlyingLastPrice
            contract.UnderlyingLastPrice = underlyingLastPrice
            # Add this contract to the output list
            contracts.append(contract)

        # Return the list of contracts
        return contracts

    def getOptionContracts(self, slice):
        # Start the timer
        # self.context.executionTimer.start('Tools.DataHandler -> getOptionContracts')

        contracts = None
        # Set the DTE range (make sure values are not negative)
        minDte = max(0, self.strategy.dte - self.strategy.dteWindow)
        maxDte = max(0, self.strategy.dte)

        # Loop through all chains
        for chain in slice.OptionChains:
            # Look for the specified optionSymbol
            if chain.Key != self.strategy.optionSymbol:
                continue
            # Make sure there are any contracts in this chain
            if chain.Value.Contracts.Count != 0:
                # Put the contracts into a list so we can cache the Greeks across multiple strategies
                contracts = [
                    contract for contract in chain.Value if minDte <= (contract.Expiry.date() - self.context.Time.date()).days <= maxDte
                ]

        # If no chains were found, use OptionChainProvider to see if we can find any contracts
        # Only do this for short term expiration contracts (DTE < 3) where slice.OptionChains usually fails to retrieve any chains
        # We don't want to do this all the times for performance reasons
        if contracts == None and self.strategy.dte < 3:
            # Get the list of available option Symbols
            symbols = self.context.OptionChainProvider.GetOptionContractList(self.ticker, self.context.Time)
            # Get the contracts
            contracts = self.optionChainProviderFilter(symbols, -self.strategy.nStrikesLeft, self.strategy.nStrikesRight, minDte, maxDte)

        # Stop the timer
        # self.context.executionTimer.stop('Tools.DataHandler -> getOptionContracts')

        return contracts

    # Method to add option contracts data to the context.
    # @param contracts [Array]
    # @param resolution [Resolution]
    # @return [Symbol]
    def AddOptionContracts(self, contracts, resolution = Resolution.Minute):
        # Add this contract to the data subscription so we can retrieve the Bid/Ask price
        if self.__CashTicker():
            for contract in contracts:
                if contract.Symbol not in self.context.optionContractsSubscriptions:
                    self.context.AddIndexOptionContract(contract, resolution)
        else:
            for contract in contracts:
                if contract.Symbol not in self.context.optionContractsSubscriptions:
                    self.context.AddOptionContract(contract, resolution)

    # PRIVATE METHODS

    # Internal method to determine if we are using a cashticker to add the data.
    # @returns [Boolean]
    def __CashTicker(self):
        return self.ticker in self.CashIndices
#region imports
from AlgorithmImports import *
#endregion

"""
    Underlying class for the Options Strategy Framework.
    This class is used to get the underlying price.

    Example:
        self.underlying = Underlying(self, self.underlyingSymbol)
        self.underlyingPrice = self.underlying.Price()
"""


class Underlying:
    def __init__(self, context, underlyingSymbol):
        self.context = context
        self.underlyingSymbol = underlyingSymbol

    def Security(self):
        return self.context.Securities[self.underlyingSymbol]

    # Returns the underlying symbol current price.
    def Price(self):
        return self.Security().Price

    def Close(self):
        return self.Security().Close
from AlgorithmImports import *
from DataHandler import DataHandler


class LiveSPXTest(QCAlgorithm):
    def initialize(self) -> None:
        self.set_start_date(2022, 1, 20)
        self.set_end_date(2022, 3, 7)
        self.set_cash(1000000)

        self.SetBrokerageModel(BrokerageName.InteractiveBrokersBrokerage, AccountType.Margin)

        self.strategy = Strategy()

        self.dataHandler = DataHandler(self, "SPX", self.strategy)
        self.underlying = self.dataHandler.AddUnderlying(Resolution.MINUTE)
        self.option = self.dataHandler.AddOptionsChain(self.underlying, Resolution.MINUTE)

        self.underlying.SetDataNormalizationMode(DataNormalizationMode.Raw)

        self.option.SetFilter(self.dataHandler.SetOptionFilter)

        self.spx = self.underlying.Symbol
        self.strategy.underlyingSymbol = self.spx
        self.spx_option = self.option.Symbol
        self.strategy.optionSymbol = self.spx_option

        self.ema_slow = self.ema(self.spx, 80)
        self.ema_fast = self.ema(self.spx, 200)

        self.openOrders = {}
        self.stopOrders = {}
        self.openPositions = {}  # Dictionary to track open positions

    def OnData(self, slice: Slice) -> None:
        if self.Time.hour >= 14:
            self.CancelOpenOrders()
            return
        
        if self.checkOpenOrders():
            return
        
        self.checkExpiredPositions()

        # Check if there are any open SPX positions
        if self.openPositions:
            # self.Log(f"Already have open SPX positions: {self.openPositions}")
            return  # Exit if there are open positions

        chain = self.dataHandler.getOptionContracts(slice)
        self.placeCallOrder(chain)
        # self.placePutOrder(chain)

    def OnOrderEvent(self, orderEvent: OrderEvent) -> None:
        if orderEvent.Status == OrderStatus.Filled:
            if orderEvent.OrderId in self.openPositions:
                del self.openPositions[orderEvent.OrderId]
                self.Log(f"Order closed: {orderEvent.OrderId}")
                # Remove stoped orders as the position has been closed
                for order_id in list(self.stopOrders.keys()):
                    self.Transactions.CancelOrder(order_id)
                    del self.stopOrders[order_id]

            if orderEvent.OrderId in self.openOrders:
                del self.openOrders[orderEvent.OrderId]
                self.Log(f"Order filled: {orderEvent.OrderId}")
                self.openPositions[orderEvent.OrderId] = self.Time  # Track the open position
                # Add stop limit order at 2-3weeks worth of premium
                symbol = orderEvent.Symbol
                quantity = 3  # Example quantity, adjust as needed
                stop_price = 0.15 * 12.5  # Example stop price, adjust as needed
                limit_price = 0.15 * 13  # Example limit price, adjust as needed
                tag = f"Stop Limit Order for {orderEvent.OrderId}"
                # Place the stop limit order
                ticket = self.StopLimitOrder(symbol, quantity, stop_price, limit_price, tag)
                self.stopOrders[ticket.OrderId] = self.Time
                self.Log(f"Stop Limit Order placed: {ticket.OrderId}")

        elif orderEvent.Status == OrderStatus.Submitted:
            # if orderEvent.OrderId not in self.stopOrders:
            #     self.openOrders[orderEvent.OrderId] = self.Time
            self.Log(f"Limit Order submitted: {orderEvent.OrderId}")
        elif orderEvent.Status == OrderStatus.Canceled:
            if orderEvent.OrderId in self.openOrders:
                del self.openOrders[orderEvent.OrderId]
                self.Log(f"Order canceled: {orderEvent.OrderId}")
        else:
            if orderEvent.OrderId in self.openPositions:
                self.Log(f"Order closed via exercise?!: {orderEvent.OrderId}")
                del self.openPositions[orderEvent.OrderId]
    
    def CancelOpenOrders(self):
        for order_id in list(self.openOrders.keys()):
            self.Log(f"Cancelling order: {order_id}")
            self.Transactions.CancelOrder(order_id)
            # No need to delete from here as we delete in OnOrderEvent
            # del self.openOrders[order_id]


    def checkOpenOrders(self):
        if self.openOrders:
            for order_id in list(self.openOrders.keys()):  # Use list to create a copy of keys
                order_time = self.openOrders[order_id]
                # Check if any order has been open for less than 15 minutes
                if (self.Time - order_time).total_seconds() / 60 < 15:
                    return True # Exit if any order is still within the 15-minute window
                
                # Check if the order has been open for more than 15 minutes
                if (self.Time - order_time).total_seconds() / 60 > 15:
                    self.Log(f"Order has been open for more than 15 minutes: {order_id}")
                    self.Transactions.CancelOrder(order_id)
        return False

    def placeCallOrder(self, chain):
        if chain is None:
            # self.Log(" -> No chains inside currentSlice!")
            return

        chain = self.filterByExpiry(chain, self.Time.date())
        
        underlying_price = self.Securities[self.spx].Price
        target_strike = underlying_price * 1.01
        # strikes_rights_and_bids = [(contract.Strike, contract.Right, contract.BidPrice) for contract in chain]
        # self.Log(f"Contract Strikes, Rights, and Bid Prices: {strikes_rights_and_bids}")
        # self.Log(f"Target strike: {target_strike}")
        # self.Log(f"Underlying price: {underlying_price}")

        otm_calls = [x for x in chain if x.Right == OptionRight.Call and x.Strike >= target_strike]
        if not otm_calls:
            self.Log("No OTM calls found")
            return

        # Filter for the call option with the premium closest to 0.10
        otm_call = min(otm_calls, key=lambda x: abs(x.BidPrice - 0.15))
        # self.Log(f"OTM call: {otm_call.Symbol}, Bid Price: {otm_call.BidPrice}")

        if otm_call.BidPrice > 0.10:
            # Sell the selected call option with a limit order
            ticket = self.LimitOrder(otm_call.Symbol, -3, otm_call.BidPrice)
            self.openOrders[ticket.OrderId] = self.Time
            self.Log(f"Limit Order placed: {otm_call.Symbol} at {otm_call.BidPrice}")
        # else:
            # self.Log("Bid price is below the minimum threshold of 0.10")

    def checkExpiredPositions(self):
        expired_positions = [order_id for order_id, position_time in self.openPositions.items() if position_time.date() < self.Time.date()]
        for order_id in expired_positions:
            del self.openPositions[order_id]
            self.Log(f"Expired position removed: {order_id}")
            
        # Remove stoped orders as the position has been closed
        expired_stop_orders = [order_id for order_id, position_time in self.stopOrders.items() if position_time.date() < self.Time.date()]
        for order_id in expired_stop_orders:
            self.Transactions.CancelOrder(order_id)
            del self.stopOrders[order_id]
            self.Log(f"Expired stop order removed: {order_id}")

    def filterByExpiry(self, chain, expiry=None):
        if expiry is not None:
            filteredChain = [
                contract for contract in chain if contract.Expiry.date() == expiry
            ]
        else:
            filteredChain = chain

        return filteredChain

    def on_end_of_algorithm(self) -> None:
        if self.portfolio[self.spx].total_sale_volume > 0:
            raise Exception("Index is not tradable.")

class Strategy:
    def __init__(self):
        self.nStrikesLeft = 20
        self.nStrikesRight = 20
        self.dte = 0
        self.underlyingSymbol = None
        self.optionSymbol = None
        self.dteWindow = 0